How the three services are assembled¶
Each service is wired a different way, and the difference is deliberate. wolfram builds its whole object graph in
about a dozen lines of WolframApp.start. cobalt builds a larger one in CobaltApp.start and expresses teardown as
CoordinatedShutdown phases rather than a close() sequence. ferrite hands the job to Guice: FerriteModule lists
the seven bindings constructor injection cannot make on its own, and everything else is an @Inject constructor.
This page is about what holds what. Endpoint semantics, wire formats and failure behaviour are on the per-service pages (wolfram, cobalt, ferrite); what those pages cannot show is the shape of the graph, which is what decides where a test can cut it.
Every edge below is a constructor parameter, a factory argument, or a call site named in the source. Where the graph is awkward it is drawn awkward — an omitted edge in a class diagram is the kind of lie that costs somebody an afternoon.
wolfram¶
classDiagram
direction LR
class WolframApp {
-Vertx vertx
-HttpServer server
-Telemetry telemetry
-EventPublisher publisher
+close() Unit
}
class IngestApi {
+routes List~ServerEndpoint~
}
class IngestionService {
+ingest(headers, body) Future~Either~
+ingestBatch(body) Future~Either~
+validate(headers, body) Either
}
class EventPublisher {
<<trait>>
+publish(Envelope) Future~Either~
+brokerReachable() Boolean
}
class KafkaEventPublisher {
-Executor sender
+probe() Unit
}
class BrokerHealth {
+observe(reachable, at, cause) Unit
+reachable() Boolean
}
class JwtVerifier {
+verify(token, requiredScope) Either
}
class TimeClamp {
+check(envelope, now) Either
}
class AdminRoutes {
+mount(Router) Unit
}
WolframApp *-- EventPublisher
IngestApi --> IngestionService
IngestApi --> JwtVerifier
IngestionService --> EventPublisher
IngestionService --> TimeClamp
KafkaEventPublisher ..|> EventPublisher
KafkaEventPublisher --> BrokerHealth
AdminRoutes --> EventPublisher : readiness only
What can a wolfram test replace, and where does the broker enter the graph? One place: EventPublisher. It is the
service's only trait with a production implementation, and the reason it exists is not mockability in principle — it
is that the 503 paths (broker refused, queue full) are unreachable from a test otherwise.
Two things the diagram makes visible. AdminRoutes reads readiness through the same
interface, so a suite that stubs the publisher also drives /health/ready without a broker. And WolframApp retains
exactly four references — the four close() walks, in that order; IngestApi and IngestionService are reachable
only from the Vert.x router, because nothing needs to reach them again once the routes are mounted.
JwtVerifier is built through JwtVerifier.from, which returns Either, and the composition root throws on Left.
An unknown algorithm, an HMAC algorithm with no secret or an unparseable public key is therefore a boot failure naming
the field, not a 500 on the first authenticated request.
Not drawn: IngestMetrics, AuthMetrics and HttpMetrics. All three take Telemetry.registry and nothing else, all
three are held by the objects above, and drawing three boxes whose only edge is to the registry would have added a
third of the diagram for none of its information.
cobalt — the consume path¶
classDiagram
direction LR
class ConsumerSupervisor {
+start() Future~LifecycleResult~
+pause() Future~LifecycleResult~
+restart(target, explicit) Future~Either~
+status Future~ConsumerStatus~
}
class ConsumerFactory {
<<trait>>
+start() ConsumerHandle
}
class ConsumerHandle {
<<trait>>
+drain() Future~Done~
+completion Future~Done~
}
class EventConsumer
class ConsumerStream {
<<object>>
+processing(decode, processor, batchSize, committer) Flow
+restarting(settings, control) Source
}
class RecordDecoder {
+decode(message) DecodedRecord
}
class BatchProcessor {
+process(batch) Future~Vector~
}
class EventRepository {
<<trait>>
+insertAll(events) Future~Long~
}
class DeadLetterPublisher {
<<trait>>
+publish(DeadLetter) Future~Unit~
}
class CheckpointStore {
<<trait>>
+load(groupId) Future
+clear(groupId) Future~Int~
}
class AdminOffsets {
+committed(groupId) Map
+alterOffsets(groupId, seek) Unit
}
ConsumerSupervisor --> ConsumerFactory : one call per start or resume
ConsumerSupervisor o-- ConsumerHandle : the current one, or none
ConsumerSupervisor --> AdminOffsets
ConsumerSupervisor --> CheckpointStore
ConsumerFactory ..> EventConsumer : creates
ConsumerFactory ..> RecordDecoder : captured once
ConsumerFactory ..> BatchProcessor : captured once
EventConsumer ..|> ConsumerHandle
EventConsumer ..> ConsumerStream : assembles the graph
BatchProcessor --> EventRepository
BatchProcessor --> DeadLetterPublisher
What is rebuilt when a paused consumer resumes, and what survives? The stream is rebuilt — a drained stream is not
restartable, so ConsumerFactory.start() materialises a new EventConsumer and a new Consumer.Control every time.
Everything below the factory survives: RecordDecoder, BatchProcessor, the repository, the DLQ producer and the
connection pools are constructed once in CobaltApp.start and captured by the factory closure, so they outlive every
generation of the stream. That is why a resume costs a rebalance and nothing else.
ConsumerFactory and ConsumerHandle are two one-method traits over one implementation, and they exist for one
reason: the supervisor's transitions — pause, resume, a stream that dies, a drain that times out — are only testable
against a handle that needs no broker.
The stage order inside ConsumerStream.processing, and why Committer.flow must sit downstream of the write, is in
the stream. It is not redrawn here: the ASCII pipeline on that page already says
it in one line, and a second rendering is a second thing to keep in step with the code.
cobalt — the admin path¶
classDiagram
direction LR
class AdminServer {
+start() Unit
+stop() Unit
+boundPort() Int
}
class CobaltRoutes {
-guard(request, scope) Response
}
class AdminAuth {
+authorise(headers, required) Either
}
class JwtVerifier {
+verify(token, required) Either
}
class AdminHandlers
class SupervisorAdmin
class DeadLetterAdmin
class DeadLetterStore {
<<trait>>
+depth() DlqDepth
+recent(limit) Vector~DlqRecord~
+publish(record) Unit
}
class HealthChecks
class Probes {
+probe() Unit
}
class ConsumerSupervisor
AdminServer --> CobaltRoutes
CobaltRoutes --> AdminAuth : every /admin route
CobaltRoutes --> AdminHandlers
AdminAuth --> JwtVerifier
AdminHandlers --> SupervisorAdmin
AdminHandlers --> DeadLetterAdmin
AdminHandlers --> HealthChecks : reads
DeadLetterAdmin --> DeadLetterStore
SupervisorAdmin --> ConsumerSupervisor
Probes --> HealthChecks : writes
Probes --> SupervisorProbe : on the same tick
SupervisorProbe --> ConsumerSupervisor : status()
SupervisorProbe --> DeadLetterStore : depth()
Which objects does a request under /admin actually touch, and what does the health probe read on its tick besides
lag?
guard is the only door: it takes the required AdminScope, calls AdminAuth, and evaluates the handler by name, so
an unauthenticated request never reaches a Kafka admin client, a database or the DLQ consumer. AdminRoutes.Access
declares which door each path uses; AdminAccessSuite compares that map against Cask's own dispatch table in both
directions and AdminAuthIT drives every entry over a real socket — so a route added without an entry fails a unit
test rather than answering 200 to anybody.
SupervisorProbe is the edge worth looking at. The supervisor's gauges ride the lag tick rather than owning a second
timer against the same broker, and they come from two different systems — ConsumerSupervisor.status for
consume.running and consume.checkpoint.divergence, DeadLetterStore.depth for dlq.depth. Both readings live
behind one tick() with one caller, because they used not to: dlq.depth was registered by SupervisorMetrics and
written by nothing at all, and /metrics reported a flat zero while the DLQ filled. SupervisorMetricsSuite now
asserts that one tick leaves no registered gauge unread.
Which is also why Probes is constructed last in CobaltApp.start, after the supervisor and the DLQ store. An
earlier revision built it first and dropped the supervisor into an AtomicReference afterwards; nothing about that
deferral was necessary — the Admin client both are built around is created above them and shared — and it made a
whole reading easy to forget.
Two more asymmetries the graph hides unless you look for them:
- The DLQ has two owners with two Kafka clients.
KafkaDeadLetterPublisherwrites dead letters from the batch path;KafkaDeadLetterStorereads and republishes them from the admin path, behind one lock, with its own consumer (assign, neversubscribe) and its own producer. Sharing one producer would tie two lifetimes together and the failure — a replay against a producer the shutdown sequence already closed — is silent. ProbesandConsumerSupervisoreach hold their ownAdminOffsets, over the sameAdminclient. Two thin wrappers, one connection, one owner that closes it.
ConsumerSupervisor is the object both cobalt diagrams hold, which is the whole point of it: one lifecycle, reachable
from the stream that owns it and from the HTTP surface that commands it. HealthChecks is the other piece of shared
state — Probes writes it on a timer, AdminHandlers.ready reads it — so nothing probes a dependency on the request
thread, which is what stops a slow broker from making readiness time out rather than answer.
The consumer's state machine¶
stateDiagram-v2
direction LR
[*] --> stopped : Snapshot.initial
stopped --> starting : start / resume / restart
paused --> starting : start / resume / restart
failed --> starting : start / resume / restart
starting --> running : stream materialised
running --> stopping : stop / pause / restart
stopping --> stopped : stop, restart
stopping --> paused : pause
stopped --> paused : pause, no drain
failed --> paused : pause, no drain
running --> failed : stream died, restart policy exhausted
starting --> failed : the factory could not materialise a stream
stopped --> failed : the offset seek in a restart threw
Which command moves the consumer where, and which two of the six states can a command never observe? starting and
stopping. Both are transient and both are passed through while the transition lock is held, so the from a command
reports is never either of them — they exist only to be observed by GET /admin/consumer, which never takes the
lock. Seeing one in a status response means another thread is mid-transition, not that the consumer is wedged.
Three properties worth reading off it:
pauseandstopdo the same thing to the stream and differ only in the label. Both drain, commit, and leave the group. The distinction is intent, and it is worth a state because an alert on "not consuming" should fire forstoppedand stay quiet forpaused.resumeandstartare the same transition. Both are "materialise if not already consuming", including fromfailed. Two names for one operator intent, not two behaviours.stoppedandfailedare the split that matters. Both mean not consuming; only one is somebody's fault, and a supervisor that reported a dead stream asstoppedwould look exactly like one an operator had paused on purpose while lag grew.
failed is reachable asynchronously from any state, and the diagram draws only the representative edge from
running: the handle's completion callback fires when ConsumerStream.restarting has exhausted its backoff budget,
on a thread that holds no lock and is not synchronised with the command that started the stream. restart is the one
compound transition — drain to stopped, alter the group's offsets, then
materialise — because Kafka refuses alterConsumerGroupOffsets while the group has live members.
ferrite¶
classDiagram
direction TB
class WebRouter {
+routes Routes
}
class EventsController {
+list Action
+detail(eventUid) Action
}
class OverviewController {
+index Action
}
class TailController {
+stream Action
}
class SearchService {
+search(query) Future~Either~
+page(query) Future~Either~
+detail(ref) Future~Option~
}
class OverviewService {
+load(range) Future~Either~
}
class TailService {
+head(filter) Future~TailCursor~
+poll(filter, cursor) Future~TailBatch~
+hasCapacity Boolean
}
class Presenter {
<<object>>
+row(summary, now) EventRow
+results(outcome, query, now) Results
}
class OverviewPresenter {
<<object>>
+page(overview, now) OverviewPage
}
class EventRepository {
<<trait>>
}
class OverviewRepository {
<<trait>>
}
class SearchExecutionContext {
<<dispatcher>>
+fixedPoolSize 8
}
WebRouter --> EventsController
WebRouter --> OverviewController
WebRouter --> TailController
EventsController --> SearchService
EventsController --> Presenter
OverviewController --> OverviewService
OverviewController --> OverviewPresenter
OverviewController ..> Presenter : failure page only
TailController --> TailService
TailController ..> Presenter : renders each SSE row
SearchService --> EventRepository
OverviewService --> OverviewRepository
OverviewService --> EventRepository : the alert feed only
TailService --> EventRepository
SearchService --> SearchExecutionContext
OverviewService --> SearchExecutionContext
TailService --> SearchExecutionContext
Where does a value stop being a domain value and become something a template may see, and which layer talks to the
database? The presenters, and the services, respectively. Controllers parse and choose a representation; services
issue queries and never format; presenters are objects — pure, total, no Future, now passed in — and they are
the only place a repository row and a view model are allowed to meet. Nothing below the controllers knows what htmx
is, and no template ever sees a Filter, an Envelope or a repository row.
Two edges here are the interesting ones. OverviewService reaches both repositories: every query it issues but
one reads events.event_rollup_hourly, and the alert feed is the exception that touches the fact table, through the
partial index on severity_rank >= 50. And TailController renders through the same Presenter.row and the same
Twirl fragment the search page uses, which is why a row that arrives over SSE is the same markup as one that arrives
from a search — no second renderer, and no client-side templating of device-supplied strings.
SearchExecutionContext is the bound the whole read side is sized against — see
the bounded search dispatcher. The two repository providers
hand it to the repositories as well; that pair of edges is left off above to keep the tiers readable.
Not shown, because they sit beside the routing rather than in it:
AppRoutercomposesWebRouter orElse AssetsRouter orElse OpsRouter;AssetsRouteris separate only because constructingcontrollers.Assetsdrags in anHttpErrorHandler, anAssetsMetadataand aFileMimeTypes.MetricsFilteris anEssentialFilteradded byplay.filters.enabled, so it wraps every request without any controller depending on it. It excludes/metricsand/healthby prefix list (Meters.UninstrumentedPaths), where wolfram gets the same exclusion structurally, by mounting those routes outside the Tapir interpreter.FerriteModulebinds only what constructor injection cannot reach:Clock,Databases(eagerly, so an unreachable database fails the boot rather than the first request), the two repository providers,Readiness,TelemetryandAllowedHostsConfig. Everything else on this diagram is a JIT binding off an@Injectconstructor — includingSearchMetrics, which Guice reaches through a secondary@Injectconstructor takingTelemetry, so that no binding for Micrometer'sMeterRegistryhas to exist.
The three operational surfaces, side by side¶
A table rather than a diagram: the question this answers — "which path, on which service, needs what" — is a lookup keyed by path, and a boxes-and-arrows rendering of twenty-odd routes is a worse table with the same content.
| Service | Path | Method | Needs | Notes |
|---|---|---|---|---|
| wolfram | /v1/events |
POST | Bearer JWT with events:write |
Applied once on the shared base endpoint, so no route exists that could omit it |
| wolfram | /v1/events:batchCreate |
POST | Bearer JWT with events:write |
|
| wolfram | /v1/events:validate |
POST | Bearer JWT with events:write |
No side effect, still authenticated |
| wolfram | /metrics, /health/live, /health/ready |
GET | nothing | Plain Vert.x routes outside the Tapir interpreter, so they are structurally absent from http.server.requests |
| wolfram | /openapi.json, /docs |
GET | nothing | /docs goes through the interpreter, so it is metered; /openapi.json does not |
| cobalt | /admin/dlq, /admin/dlq/records, /admin/consumer |
GET | Bearer JWT, read scope | Disclosure: payloads and group offsets |
| cobalt | /admin/dlq:replay |
POST | Bearer JWT, write scope | dryRun=true by default |
| cobalt | /admin/consumer:pause :resume :stop :start |
POST | Bearer JWT, write scope | |
| cobalt | /admin/consumer:restart, :clearCheckpoints |
POST | Bearer JWT, write scope | :restart moves committed offsets; dryRun=true by default |
| cobalt | /metrics, /health/live, /health/ready |
GET | nothing | Prometheus and the orchestrator cannot hold a token |
| cobalt | /openapi.json, /openapi.yaml, /docs, /docs/assets |
GET | nothing | Closing them would blank the Swagger page, which fetches the document with no Authorization header |
| ferrite | /, /events, /events/{uid}, /live, /assets/* |
GET | nothing | The whole event corpus, unauthenticated — operations.md §8, item 1 |
| ferrite | /metrics, /health/live, /health/ready |
GET | nothing | Excluded from http.server.requests by MetricsFilter |
Three things this table is for.
A token that verifies is not a token that is allowed. wolfram and cobalt both split 401 from 403 and both refuse to boot with a verifier they cannot construct, but they scope differently: wolfram has one scope for one operation class, cobalt has two, and a cobalt token holding the write scope also satisfies read — because every mutating route already returns what the read routes return, so refusing the GET would be a 403 with no security content.
The two verifiers are separate implementations of one contract, on purpose and temporarily. wolfram's is built on
jwt-scala; cobalt's is built on the JDK's Mac and Signature, because jwt-scala is not on cobalt's classpath and
the shared module it belongs in does not exist yet. cobalt's is stricter in two places — exp is mandatory, and a
crit header is refused — because the operations behind that door are irreversible.
ferrite is the asymmetry. It requires no credential for anything, while the same events sit behind a scoped JWT on the other two services. CSRF, allowed-hosts and CSP are all enabled on it, and none of the three is authentication. It is recorded as a known limitation, not as a decision.
What is deliberately not drawn¶
- The ingest and consume pipelines as sequences.
docs/event-model.mdalready has an end-to-end sequence diagram from device to database, anddocs/services/cobalt.mdhas the stream's stage order as one line of ASCII. Redrawing either would create a second copy to keep in step with the code. - Configuration classes.
WolframConfig,CobaltConfigand their nested cases are a tree offinal case classes with one edge each; the reference that matters is the environment-variable table inoperations.md§3. - The meter façades.
IngestMetrics,ConsumerMetrics,SupervisorMetrics,ReplayMetrics,MaintenanceMetrics,SearchMetrics,HttpMetrics, and the sharedAuthMetrics. Each takes the registry and nothing else, so each would be one box with one edge. The vocabulary they implement isoperations.md§5, which is the question anybody actually has about them. - The Twirl view model. Twenty-odd flat
final case classes ofStrings incom.worxbend.ferrite.web.view. A class diagram of them would be a strictly worse rendering ofModels.scala, which already reads as a list and carries the reasoning for each field. MaintenanceJobs. It is anobjectwith a scheduler, two jobs and no collaborators worth an arrow — the interesting content is why the first partition pass is synchronous and why it lives in cobalt rather than ferrite, and both are sentences, not shapes.