bmad-method-quarkus 1.0.4 → 1.0.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,23 +1,29 @@
1
1
  ---
2
2
  name: quarkus-observability-otel
3
- description: Observability standard for Quarkus native services using OpenTelemetry — distributed tracing with W3C Trace Context (traceparent), trace_id/span_id in every log line (JSON logs + MDC), OTLP export, metrics with Micrometer, custom spans, and trace propagation across REST, gRPC, and Kafka. Use this skill whenever the user mentions OpenTelemetry, OTel, tracing, trace_id, span_id, traceparent, distributed tracing, logging standard, log correlation, metrics, Grafana/Tempo/Jaeger/Prometheus, or debugging requests across services. Includes span, metric, logger and audit-bean naming conventions for the vertical-slice layout (one span per Handler.process()).
3
+ description: Observability standard for Quarkus native services using OpenTelemetry — distributed tracing with W3C Trace Context (traceparent), trace_id/span_id in every log line (JSON logs + MDC), OTLP export, MANDATORY Micrometer metrics (`quarkus-micrometer-registry-prometheus` in every service), custom spans, and trace propagation across REST, gRPC, and Kafka. Also covers build identity in telemetry (service.version/service.instance.id, build_info gauge), the export pipeline (batch span processor, graceful-shutdown flush budget, span attribute/event/link limits) and propagation safety (propagators, baggage, untrusted inbound traceparent). Use this skill whenever the user mentions OpenTelemetry, OTel, tracing, trace_id, span_id, traceparent, distributed tracing, logging standard, log correlation, metrics, Micrometer, quarkus-micrometer, MeterRegistry, counter/timer/gauge, `@Timed`/`@Counted`, /q/metrics, scrape, dashboards or alerts, Grafana/Tempo/Jaeger/Prometheus, baggage, propagators, sampling, service.version or build metadata, telemetry lost on shutdown/SIGTERM/terminationGracePeriodSeconds, span limits or telemetry volume/cost, or debugging requests across services. Includes span, metric, logger and audit-bean naming conventions for the vertical-slice layout (one span per Handler.process()).
4
4
  ---
5
5
 
6
6
  # OpenTelemetry Observability Standard (Quarkus)
7
7
 
8
8
  Every request must be traceable end-to-end: REST → Handler → JDBC → outbox → Kafka → consumer → gRPC. `trace_id`/`span_id` appear in every log line and propagate across every transport via W3C Trace Context. Applies to any Quarkus backend project; project directives (CLAUDE.md, ADRs, explicit instructions) override these defaults.
9
9
 
10
- Extensions: `quarkus-opentelemetry` (+ `quarkus-micrometer-registry-prometheus` or Micrometer→OTLP for metrics, `quarkus-logging-json` for structured logs). All native-compatible.
10
+ Extensions — **all three are mandatory in every service**, all native-compatible: `quarkus-opentelemetry` (traces), `quarkus-micrometer-registry-prometheus` (metrics see "Metrics"), `quarkus-logging-json` (structured logs). Traces, metrics and logs are one contract; a service that ships two of the three is not observable.
11
11
 
12
12
  ## Baseline configuration
13
13
 
14
14
  ```properties
15
15
  quarkus.application.name=iam-users # becomes service.name
16
16
  quarkus.otel.exporter.otlp.endpoint=http://otel-collector:4317
17
- quarkus.otel.resource.attributes=deployment.environment=${ENV:dev},service.namespace=alva
18
17
 
19
- # W3C Trace Context + Baggage are the DEFAULT propagatorsdo not override with B3 unless mandated
20
- # quarkus.otel.propagators=tracecontext,baggage
18
+ # Identity of the running artifactsee "Build identity in every signal"
19
+ quarkus.otel.resource.attributes=service.version=${quarkus.application.version},\
20
+ service.instance.id=${HOSTNAME:unknown},\
21
+ deployment.environment=${ENV:dev},\
22
+ service.namespace=alva
23
+
24
+ # Propagation: W3C Trace Context only. Add `baggage` ONLY for a documented
25
+ # cross-service need — see "Propagation and baggage". Never B3 unless mandated.
26
+ quarkus.otel.propagators=tracecontext
21
27
 
22
28
  # Sampling: head-based ratio in prod, always_on elsewhere
23
29
  %prod.quarkus.otel.traces.sampler=parentbased_traceidratio
@@ -29,6 +35,82 @@ quarkus.otel.traces.suppress-non-application-uris=true
29
35
 
30
36
  Export to an **OTel Collector** (agent/sidecar/gateway), never directly to the vendor backend — keeps services vendor-neutral.
31
37
 
38
+ ### Build identity in every signal
39
+
40
+ "Which build produced this?" is the first question of every incident, and it must be answerable from a trace, a log line and a dashboard *without* cross-referencing a deploy log. `service.version` is an OTel semantic-convention resource attribute; `quarkus.application.version` resolves it from the Maven project version at build time, so it is correct by construction and never hand-maintained. `service.instance.id` from `${HOSTNAME}` is the pod name under Kubernetes — it is what separates "the whole service is broken" from "one replica is broken".
41
+
42
+ Each signal carries it differently, and the difference matters:
43
+
44
+ | Signal | How the version travels | Cost |
45
+ |---|---|---|
46
+ | Traces | `service.version` resource attribute (above) | none — resource attributes are sent once per batch, not per span |
47
+ | Logs | an additional JSON field (see "Logs" below) | none |
48
+ | Metrics | a dedicated **info gauge**, never a common tag | one extra series per version |
49
+
50
+ **Do not add `version` as a Micrometer common tag.** It looks tidy and it is the standard trap: every metric in the service gets a new time series on every deploy, so cardinality grows with release frequency forever and any dashboard grouped without an explicit `sum by (…)` splits in two during a rolling deploy. The Prometheus idiom is a separate info metric pinned to 1, joined at query time (`… * on(instance) group_left(version) iam_build_info`):
51
+
52
+ ```java
53
+ // common/observability
54
+ @ApplicationScoped
55
+ public class BuildInfoMetric {
56
+
57
+ @Inject MeterRegistry registry;
58
+
59
+ @ConfigProperty(name = "quarkus.application.version")
60
+ String version;
61
+
62
+ void onStart(@Observes StartupEvent event) {
63
+ Gauge.builder("iam_build_info", () -> 1)
64
+ .description("Always 1; carries the running build's identity as tags")
65
+ .tag("version", version)
66
+ .register(registry);
67
+ }
68
+ }
69
+ ```
70
+
71
+ `<module>_build_info` is a **sanctioned exception** to the `<module>_<entity>_<action>_total|_seconds` naming rule below — `_info` is the established Prometheus convention for this pattern and dashboards expect it. If CI injects a commit SHA (as `BUILD_COMMIT`, per the quarkus-security-standards skill's `${VAR}` rule), add it as a second tag here and as a `service.build_id` resource attribute; it is bounded by the same one-series-per-deploy budget.
72
+
73
+ ## Export pipeline: batching, shutdown and hard limits
74
+
75
+ Spans are **buffered in memory** by the batch span processor and exported asynchronously. Two failure modes follow from that, and neither announces itself: a pod that dies before the buffer flushes loses the telemetry of the requests that mattered most, and an unbounded attribute turns one bad `setAttribute` call into an export-size incident.
76
+
77
+ ```properties
78
+ # Batch span processor — the in-memory buffer
79
+ quarkus.otel.bsp.schedule.delay=2S # default 5S; smaller = less at risk on a hard kill
80
+ quarkus.otel.bsp.max.queue.size=2048 # spans buffered; overflow is DROPPED, silently
81
+ quarkus.otel.bsp.max.export.batch.size=512
82
+ quarkus.otel.bsp.export.timeout=10S # must fit the shutdown budget below
83
+
84
+ quarkus.otel.exporter.otlp.timeout=5S
85
+
86
+ # Hard caps — the backstop for a bad attribute (mirror the OTEL_* spec env vars)
87
+ quarkus.otel.span.attribute.value.length.limit=4096
88
+ quarkus.otel.span.attribute.count.limit=128
89
+ quarkus.otel.span.event.count.limit=128
90
+ quarkus.otel.span.link.count.limit=128
91
+
92
+ # Graceful shutdown — drain in-flight requests before the SDK flushes
93
+ quarkus.shutdown.timeout=15S
94
+ ```
95
+
96
+ ### The shutdown budget (this is arithmetic, not a preference)
97
+
98
+ On `SIGTERM` Quarkus stops accepting requests, waits up to `quarkus.shutdown.timeout` for in-flight ones, shuts down beans, and only then does the OTel SDK flush its buffer — up to `bsp.export.timeout`. If the orchestrator's grace period expires first, the pod is `SIGKILL`ed mid-flush and the buffered spans die with it. So:
99
+
100
+ ```
101
+ terminationGracePeriodSeconds > quarkus.shutdown.timeout + (flush worst case) + margin
102
+ ```
103
+
104
+ **`bsp.export.timeout` is per batch export, not per flush** — a full queue drains in `max.queue.size / max.export.batch.size` sequential batches, so with the values above the worst case is `2048/512 = 4` exports, i.e. up to `4 × 10S`, not `10S`. Budget `15S + 40S + margin` → **`terminationGracePeriodSeconds: 75`** in the deployment manifest, or shrink the queue if you would rather cap the tail. Sizing the grace period off a single export timeout is the common mistake here. Set both sides deliberately; the Kubernetes default of 30s silently truncates this budget, and the loss is invisible — you get a trace that simply ends, which reads like a hung request rather than a dropped export. This is sharper for native binaries, which start and stop fast enough that pods churn often (scale-downs, rollouts, spot reclaims), so shutdown is a routine path, not an edge case.
105
+
106
+ Metrics behave differently and need no budget: Prometheus **pulls**, so the last scrape interval before termination is simply never collected. Counters are cumulative and aggregation tolerates the gap — just never build an alert on the final seconds of a terminating pod's counter.
107
+
108
+ ### On the limits
109
+
110
+ - Queue overflow and attribute truncation are both **silent**. The only signal is the BSP's own dropped-span counter — alert on it, or discover the gap mid-incident when the trace you need is the one that was dropped.
111
+ - **A limit truncates, it does not sanitize.** A 4096-character cap on an attribute value does nothing about privacy: a truncated email address is still an email address. Limits are a cost and blast-radius control; the no-PII rule below is the privacy control, and neither substitutes for the other.
112
+ - Sampling (`%prod` ratio above) is the primary volume lever; these caps are the per-span backstop for when a code change, not traffic, is what blew up the volume.
113
+
32
114
  ## Logs: trace_id and span_id in every line
33
115
 
34
116
  Quarkus OTel injects `traceId`, `spanId`, `sampled` into the MDC automatically.
@@ -42,10 +124,14 @@ Console (dev, human-readable):
42
124
  Prod: JSON logs (parseable, ship to the log pipeline):
43
125
 
44
126
  ```properties
45
- %prod.quarkus.log.console.json=true
127
+ %prod.quarkus.log.console.json.enabled=true
46
128
  %prod.quarkus.log.console.json.additional-field."service.name".value=${quarkus.application.name}
129
+ %prod.quarkus.log.console.json.additional-field."service.version".value=${quarkus.application.version}
130
+ %prod.quarkus.log.console.json.additional-field."service.instance.id".value=${HOSTNAME:unknown}
47
131
  ```
48
132
 
133
+ `service.version` and `service.instance.id` repeat the resource attributes from the baseline config on purpose: a log line has no OTel resource, so without these fields "which build logged this?" is only answerable by joining through the trace — and unsampled or pre-trace log lines cannot be joined at all.
134
+
49
135
  `quarkus-logging-json` includes MDC (traceId/spanId) as fields — logs↔traces correlation in Grafana/Tempo/Loki works with zero code. Rule: NEVER log a business operation without going through JBoss Logging/`Log` — `System.out` breaks correlation.
50
136
 
51
137
  Also return the trace id to API callers for support tickets: a tiny response filter adds header `X-Trace-Id: ${Span.current().getSpanContext().getTraceId()}` on every response (especially errors — pairs with the unified exception handler).
@@ -56,7 +142,7 @@ With the extension present, these produce/propagate spans with no code:
56
142
  - HTTP server (Quarkus REST) and REST Client
57
143
  - gRPC server & client (metadata `traceparent`)
58
144
  - Kafka via SmallRye Reactive Messaging (header `traceparent`)
59
- - Agroal/JDBC: add `quarkus-opentelemetry-jdbc` wrapper (`quarkus.datasource.jdbc.telemetry=true`) for SQL spans
145
+ - Agroal/JDBC: set `quarkus.datasource.jdbc.telemetry=true` for SQL spans — that property is the whole mechanism, on `quarkus-opentelemetry`; there is no separate JDBC extension to add
60
146
 
61
147
  ## Mandatory: trace context extraction via the OTel SDK only
62
148
 
@@ -69,6 +155,23 @@ String spanId = Span.current().getSpanContext().getSpanId();
69
155
 
70
156
  If `Span.current()` is invalid (no active trace — e.g., a batch job without instrumentation), fix the instrumentation of the entry point; do not fabricate ids.
71
157
 
158
+ ## Propagation and baggage: what crosses the service boundary
159
+
160
+ Trace context headers are **data that leaves the trust boundary in both directions**, and OTel baggage is the part teams underestimate: it is a set of key-value pairs that the instrumented clients attach to *every* outbound call — including calls to third-party APIs. Anything placed in baggage is, in practice, published to every vendor the service talks to.
161
+
162
+ **Default: `quarkus.otel.propagators=tracecontext`** (as in the baseline config). Baggage is off unless the platform has a documented cross-service need for it, recorded in the app's `service.yaml`. Narrowing the propagator list is free and removes the whole class of problem; do not enable baggage "in case someone needs it later."
163
+
164
+ When baggage *is* enabled, these are hard rules:
165
+
166
+ - **Never PII, credentials, tokens, secrets, or free text.** Same prohibition as span attributes (below) and log lines, with a wider blast radius because baggage crosses into systems you do not operate. Pairs with the quarkus-security-standards skill §7.
167
+ - **Baggage is caller-controlled input — never an authorization input.** The inbound `baggage` header is unauthenticated and unvalidated; any caller can set any value. In particular, **`tenantId` never comes from baggage**: it comes from a validated JWT claim, per the tenancy rule in the quarkus-hexagonal-core skill and §4 of the security skill. Reading a tenant from baggage is the same defect as reading it from a free header, wearing a telemetry costume.
168
+ - Bounded, low-cardinality enumerations only, with short keys — baggage rides in an HTTP header on every hop and is a per-request bandwidth cost.
169
+ - Do not auto-promote baggage entries into span attributes. That launders caller-controlled values into your telemetry store, where the cardinality and PII rules below assume the values are yours.
170
+
171
+ ### Inbound trace context at the public edge
172
+
173
+ North-bound REST is reachable by callers you do not control, and `traceparent` is as forgeable as any other header — an untrusted client can pin a trace id, join itself to an unrelated trace, or force sampling decisions. Internal gRPC calls come from inside the mesh and are trusted; the public REST ingress is not. Where the platform does not already strip client trace headers at the gateway, treat an inbound `traceparent` on a public endpoint as untrusted: start a new root span and **link** to the incoming context rather than continuing it. Trust the header on internal transports, never at the public boundary.
174
+
72
175
  ## Audit standard: `context` jsonb column in `audit_event`
73
176
 
74
177
  Every service with a local `audit_event` table persists the active trace in a `context` column of type `jsonb`:
@@ -176,11 +279,80 @@ Canonical class-naming rules live in the quarkus-hexagonal-core skill; these are
176
279
 
177
280
  Span, metric and log-field names are a contract with the dashboards — renaming one silently breaks alerts. Treat a rename like an API change.
178
281
 
179
- ## Metrics (Micrometer)
282
+ ## Metrics (Micrometer — mandatory extension)
283
+
284
+ **`quarkus-micrometer` ships in every service. It is not opt-in and does not need a per-service justification** — a service with no metrics is invisible to the platform's dashboards, alerts and capacity planning, and support cannot triage it. Declare the registry, which brings the core extension transitively:
285
+
286
+ ```xml
287
+ <dependency>
288
+ <groupId>io.quarkus</groupId>
289
+ <artifactId>quarkus-micrometer-registry-prometheus</artifactId>
290
+ </dependency>
291
+ ```
292
+
293
+ Declare the **registry only** — `quarkus-micrometer` arrives as its transitive dependency, and listing both invites a version skew on a Quarkus upgrade. Prometheus is the platform default registry; `quarkus-micrometer-registry-otlp` (metrics through the same collector as traces) is the sanctioned alternative where the platform standardizes on OTLP, and swapping registries is an ADR-level decision, not a per-service preference. Never two registries in one service.
294
+
295
+ ```properties
296
+ quarkus.micrometer.enabled=true
297
+ quarkus.micrometer.export.prometheus.enabled=true
298
+ quarkus.micrometer.export.prometheus.path=/q/metrics
299
+
300
+ # Binders: the free instrumentation. Turn them ON, do not reimplement them.
301
+ quarkus.micrometer.binder.jvm=true
302
+ quarkus.micrometer.binder.system=true
303
+ quarkus.micrometer.binder.http-server.enabled=true
304
+ quarkus.micrometer.binder.http-client.enabled=true
305
+ quarkus.micrometer.binder.grpc-server.enabled=true # where the app exposes gRPC
306
+ quarkus.micrometer.binder.kafka.enabled=true # where the app produces/consumes
307
+
308
+ # Pool metrics come from the datasource, not from a binder (see quarkus-sql-jdbc-agroal skill)
309
+ quarkus.datasource.metrics.enabled=true
310
+ ```
311
+
312
+ ### What you get for free — and must not rewrite by hand
313
+
314
+ | Source | Sample metrics | Enabled by |
315
+ |---|---|---|
316
+ | HTTP server | `http_server_requests_seconds` (count/sum/max, tagged `method`, `uri`, `status`, `outcome`) | `binder.http-server` |
317
+ | JVM / runtime | heap, GC, thread and class-loader gauges | `binder.jvm` |
318
+ | Agroal pool | active/available/awaiting connections, acquisition time | `quarkus.datasource.metrics.enabled` |
319
+ | Kafka clients | producer/consumer lag, throughput, error rates | `binder.kafka` |
320
+ | gRPC server | per-method call count and latency | `binder.grpc-server` |
321
+
322
+ A hand-written counter that duplicates one of these rows is a defect — it costs cardinality and drifts from the binder's semantics. Write a custom metric only for something the binders cannot know: a **business** outcome.
323
+
324
+ **Native caveat:** under GraalVM the JVM binder reports only what the substrate VM exposes (GC and heap are present; some HotSpot-specific gauges are not). Do not build an alert on a JVM gauge without confirming it appears in `/q/metrics` of the **native** binary — the JVM-mode dev run is not evidence.
325
+
326
+ ### Custom business metrics
327
+
328
+ Recorded in the `Handler`, the same boundary that owns the span and the transaction — never in `Sql`, never in `Resource`:
329
+
330
+ ```java
331
+ @ApplicationScoped
332
+ public class RegisterUserHandler {
333
+
334
+ @Inject MeterRegistry registry;
335
+
336
+ @Transactional
337
+ @WithSpan("usecase.registerUser")
338
+ public RegisterUserResponseDto process(RegisterUserRequestDto request) {
339
+ validate(request);
340
+ UUID userId = execution(request);
341
+ registry.counter("iam_user_registrations_total", "channel", request.getChannel()).increment();
342
+ return getResult(userId);
343
+ }
344
+ }
345
+ ```
346
+
347
+ - **Naming is the contract** (see the naming table above): `<module>_<entity>_<action>_total` for counters, `_seconds` for timers, `snake_case`, module prefix from the semantic module name — never `bcNN`.
348
+ - **Tags are bounded enumerations only.** `result=success|failure`, `channel=web|app|ivr`, `party_type=Individual|Organization`. **Never** `tenantId`, `userId`, `email`, `partyId`, `trace_id`, a raw URL, or an error *message* — each distinct value is a new time series, and an unbounded tag takes down the scrape target before anyone notices. The error **code** (`PTY-400-001`) is bounded and therefore a legal tag; the rendered message is not.
349
+ - No PII in a metric name or tag — same rule as span attributes, same reason.
350
+ - `@Timed` / `@Counted` on `process()` are acceptable for plain latency/invocation counts (CDI interceptors are build-time in Quarkus, so they are native-safe). Use the explicit `MeterRegistry` call whenever the tag depends on the outcome — an annotation cannot tag `result=failure` from a thrown `BusinessException`.
351
+ - Renaming a metric or a tag **breaks dashboards and alerts silently**. Treat it exactly like an API change: coordinate with whoever owns the dashboard, or add the new name alongside the old one for one release.
352
+
353
+ ### Exposure
180
354
 
181
- - Rely on built-in HTTP/JVM-substrate/Kafka metrics first.
182
- - Custom business metrics: counters/timers via `MeterRegistry`, names `iam_user_registrations_total` style, low-cardinality tags only.
183
- - Expose `/q/metrics` for Prometheus scrape or bridge Micrometer→OTLP if the platform standardizes on the collector for metrics too.
355
+ `/q/metrics` is an internal endpoint: scraped by Prometheus inside the cluster, **never routed through the public ingress** (see quarkus-security-standards skill §7 — it must also never echo a config value or connection string). It is excluded from tracing by `suppress-non-application-uris=true` above. The scrape path and the service's custom metrics belong in the app's `service.yaml` observability contract and its `README.md` (see quarkus-hexagonal-core skill).
184
356
 
185
357
  ## Health
186
358
 
@@ -188,9 +360,15 @@ Span, metric and log-field names are a contract with the dashboards — renaming
188
360
 
189
361
  ## Checklist per service
190
362
 
191
- 1. `quarkus-opentelemetry` + JSON logging configured as above; OTLP → collector.
363
+ 1. `quarkus-opentelemetry` + `quarkus-micrometer-registry-prometheus` + JSON logging configured as above; OTLP → collector.
192
364
  2. Log format includes traceId/spanId (both profiles); `X-Trace-Id` response filter registered.
193
365
  3. JDBC telemetry enabled; `audit_event.context` jsonb populated with `trace_id`/`span_id` (+ index) on the Handler's own `Connection`; outbox rows carry `traceparent` (header) AND `traceContext` in payload; the relay forwards the header.
194
366
  3b. No manual trace-string construction anywhere — grep-check in review; only `Span.current()` via the OTel SDK.
195
367
  4. Every slice `Handler.process()` annotated `@WithSpan("usecase.<sliceCamelCase>")`; attributes reviewed for PII/cardinality.
196
- 5. Verify end-to-end in dev: one request produces a single trace spanning REST DB Kafka consumer (Dev UI or Jaeger/Tempo).
368
+ 5. Micrometer binders enabled (jvm, system, http-server/client, plus grpc/kafka where used) and `quarkus.datasource.metrics.enabled=true`; every custom metric follows the naming table, carries only bounded tags, and duplicates no binder metric.
369
+ 6. `/q/metrics` returns data from the **native** binary and is reachable only from inside the cluster.
370
+ 7. `service.version` + `service.instance.id` present in resource attributes AND in the JSON log fields; `<module>_build_info` gauge registered — and `version` is **not** a common tag on other metrics.
371
+ 8. Shutdown budget satisfied: `terminationGracePeriodSeconds > quarkus.shutdown.timeout + quarkus.otel.bsp.export.timeout`, set in the deployment manifest, not left at the 30s default by accident.
372
+ 9. `quarkus.otel.propagators` explicitly set (`tracecontext` unless baggage is documented in `service.yaml`); no PII, token or `tenantId` in baggage; public-edge `traceparent` treated as untrusted.
373
+ 10. Span attribute/event/link limits configured; the BSP dropped-span counter is alerted on.
374
+ 11. Verify end-to-end in dev: one request produces a single trace spanning REST → DB → Kafka → consumer (Dev UI or Jaeger/Tempo), and increments the metrics that back its dashboard.
@@ -31,7 +31,7 @@ The raw OpenAPI document at `/q/openapi` may stay enabled in prod behind the gat
31
31
 
32
32
  ### URLs & versioning
33
33
  - Plural kebab-free camelCase resource names as in TMF specs: `/user`, `/partyAccount` — follow the TMF spec exactly when implementing one; plural nouns for custom resources.
34
- - Base path starts with the module's semantic name (`iam`, `tenant` — see quarkus-hexagonal-core skill, never a `bcNN` code): `/{module}/{context}/{apiName}/v{major}` e.g. `/iam/tmf-api/digitalIdentityManagement/v4`. Major version in path only.
34
+ - Base path starts with the module's semantic name (`iam`, `tenant` — see quarkus-hexagonal-core skill, never a `bcNN` code): `/{module}/{context}/{apiName}/v{major}` e.g. `/iam/tmf-api/digitalIdentityManagement/v4`. Major version in path only. That leading `/{module}` segment is also the app's **API gateway base path**, and it is declared in the same PR in README §2 and `service.yaml` under `metadata.gateway` — infra configures the route from there (see quarkus-hexagonal-core skill, "Gateway routing").
35
35
 
36
36
  ### Standard operations
37
37
  | Operation | Verb | Response |
@@ -89,6 +89,7 @@ public class DigitalIdentityResource {
89
89
 
90
90
  @Inject CreateDigitalIdentityHandler createHandler; // the slice's business logic
91
91
  @Inject FindDigitalIdentityHandler findHandler;
92
+ @Inject JsonWebToken jwt; // tenant/roles come from the validated token
92
93
 
93
94
  @GET
94
95
  @Blocking
@@ -96,12 +97,11 @@ public class DigitalIdentityResource {
96
97
  @APIResponse(responseCode = "200", content = @Content(schema = @Schema(implementation = DigitalIdentityDto[].class)))
97
98
  @APIResponse(responseCode = "400", ref = "#/components/responses/BadRequest")
98
99
  public RestResponse<List<DigitalIdentityDto>> list(
99
- @HeaderParam("tenantId") String tenantId,
100
100
  @QueryParam("fields") String fields,
101
101
  @QueryParam("offset") @DefaultValue("0") int offset,
102
102
  @QueryParam("limit") @DefaultValue("20") int limit) {
103
103
  var query = FilterDigitalIdentityDto.builder()
104
- .tenantId(tenantId).fields(fields).offset(offset).limit(limit).build();
104
+ .tenantId(jwt.getClaim("tenant_id")).fields(fields).offset(offset).limit(limit).build();
105
105
  return RestResponse.ok(findHandler.process(query).getItems());
106
106
  }
107
107
 
@@ -109,10 +109,9 @@ public class DigitalIdentityResource {
109
109
  @Blocking
110
110
  @Operation(operationId = "createDigitalIdentity")
111
111
  @APIResponse(responseCode = "201", headers = @Header(name = "Location"))
112
- public RestResponse<DigitalIdentityDto> create(@HeaderParam("tenantId") String tenantId,
113
- @Valid DigitalIdentityCreateDto dto,
112
+ public RestResponse<DigitalIdentityDto> create(@Valid DigitalIdentityCreateDto dto,
114
113
  @Context UriInfo uri) {
115
- dto.setTenantId(tenantId);
114
+ dto.setTenantId(jwt.getClaim("tenant_id")); // validated JWT claim, never a header
116
115
  var created = createHandler.process(dto); // BusinessException propagates to the global handler
117
116
  return RestResponse.ResponseBuilder
118
117
  .created(uri.getAbsolutePathBuilder().path(created.getId()).build())
@@ -123,8 +122,8 @@ public class DigitalIdentityResource {
123
122
 
124
123
  Rules:
125
124
  - Return `RestResponse<T>` synchronously — typed, so the generated schema is right. Never `Uni`/`Multi`: the `Handler` is blocking JDBC.
126
- - `@Blocking` (`io.smallrye.common.annotation.Blocking`) on JDBC-backed methods. With a plain return type Quarkus REST already dispatches to a worker thread, so it is redundant today — keep it as an explicit threading contract that survives a later signature change. `@RunOnVirtualThread` is the high-concurrency alternative (see sql skill §7).
127
- - Extract `tenantId`, `partyId`, `partyRolList`, `language` from headers where the contract requires them and set them on the request DTO before calling `process()`.
125
+ - `@Blocking` (`io.smallrye.common.annotation.Blocking`) on JDBC-backed methods. With a plain return type Quarkus REST already dispatches to a worker thread, so it is redundant today — keep it as an explicit threading contract that survives a later signature change. `@RunOnVirtualThread` is the high-concurrency alternative (see sql skill §8).
126
+ - Take `tenantId` — and anything else that drives authorization, such as `partyId` or `partyRolList` — from the validated token (`@Inject JsonWebToken jwt` → `jwt.getClaim(...)`, or `@Claim`; `SecurityIdentity` carries roles and mechanism attributes, **not** claims), **never** from a `@HeaderParam`: a header the caller sets is not authentication (see quarkus-security-standards skill §4 and the tenancy rule in quarkus-hexagonal-core). `language` and other non-security metadata may come from headers. Set them on the request DTO before calling `process()`.
128
127
  - No `try/catch` around `process()`. Errors travel as `BusinessException` to `GlobalExceptionHandler`, which resolves the localized TMF Error body (see quarkus-error-handling-i18n skill).
129
128
  - `operationId` on every operation (client generation depends on it); match TMF naming (`listX`, `retrieveX`, `createX`, `patchX`, `deleteX`).
130
129
  - Reusable components: define common responses (400/401/404/409/500 with TMF Error schema) once via an `@OpenAPIDefinition`/filter class, `ref` them everywhere.
@@ -0,0 +1,132 @@
1
+ ---
2
+ name: quarkus-security-standards
3
+ description: Security baseline for Quarkus backend services — credential and environment-variable handling via `.env` (never committed) plus a committed `.env.example` template, secrets management, authentication/authorization (OIDC/JWT), transport security, input/output hardening, dependency and container hardening, and secret-safe logging. Use this skill whenever the user creates or reviews `application.properties`/`.env` files, injects a credential/URL/API key/token, adds `quarkus-oidc`/`quarkus-smallrye-jwt`, configures CORS/TLS/headers, writes a `Dockerfile`, adds a dependency, or asks about security review, secret leakage, `.gitignore`, or OWASP. Pairs with quarkus-hexagonal-core (project layout), quarkus-sql-jdbc-agroal (injection-safe SQL), and quarkus-observability-otel (PII-safe logging).
4
+ ---
5
+
6
+ # Security Standard (Quarkus)
7
+
8
+ Applies to any Quarkus backend project; project directives (CLAUDE.md, ADRs, explicit instructions) override these defaults where they conflict.
9
+
10
+ ## 1. Environment variables: `.env` for local secrets, never committed
11
+
12
+ **No credential, URL, API key, token, or other environment-specific secret is ever hardcoded in source or committed to the repository.** Locally, they live in a `.env` file at the project root; in every other environment they come from the platform's secret store (see §2). This is the first rule to apply on any new service, before the first `application.properties` line is written.
13
+
14
+ Rules, in order:
15
+
16
+ 1. **Create `.env`** at the project root (or per-app root in a monorepo, `apps/<app>/.env`) holding every local credential, connection URL, host, port, and API key the service needs to run in `%dev`/`%test`. Real values only, never sample/placeholder values here.
17
+ 2. **`.env` is never versioned.** Add it to `.gitignore` **immediately**, in the same commit that creates it — never after the fact:
18
+
19
+ ```gitignore
20
+ # Local environment secrets — never commit
21
+ .env
22
+ .env.*
23
+ !.env.example
24
+ ```
25
+
26
+ If `.env` was ever committed by mistake, it must be purged from history (`git filter-repo` or equivalent) and every credential it contained rotated — removing the file from the tip of the branch is not sufficient.
27
+ 3. **Create `.env.example`** alongside it, committed to the repo, listing every variable `.env` must define — **keys only, placeholder or empty values, never real secrets**:
28
+
29
+ ```dotenv
30
+ # .env.example — copy to .env and fill in real values. Never commit .env.
31
+ DB_HOST=localhost
32
+ DB_PORT=5432
33
+ DB_NAME=customer
34
+ DB_USER=
35
+ DB_PASSWORD=
36
+ KAFKA_BOOTSTRAP_SERVERS=localhost:9092
37
+ OIDC_AUTH_SERVER_URL=
38
+ OIDC_CLIENT_ID=
39
+ OIDC_CLIENT_SECRET=
40
+ EXTERNAL_API_BASE_URL=
41
+ EXTERNAL_API_KEY=
42
+ ```
43
+
44
+ `.env.example` is the authoritative, reviewable list of what the service needs — update it in the same PR that introduces a new variable. A variable missing from `.env.example` does not exist as far as onboarding and CI are concerned.
45
+ 4. **Reference the variables from `application.properties` via Quarkus property expressions**, never by reading `.env` manually in code:
46
+
47
+ ```properties
48
+ quarkus.datasource.username=${DB_USER}
49
+ quarkus.datasource.password=${DB_PASSWORD}
50
+ quarkus.datasource.jdbc.url=jdbc:postgresql://${DB_HOST}:${DB_PORT}/${DB_NAME}
51
+ quarkus.oidc.auth-server-url=${OIDC_AUTH_SERVER_URL}
52
+ quarkus.oidc.credentials.secret=${OIDC_CLIENT_SECRET}
53
+ ```
54
+
55
+ Quarkus resolves `${VAR}` from process environment variables at runtime (uppercase-with-underscores env var names map automatically — no extra wiring). Quarkus/SmallRye Config reads a `.env` file in the working directory natively in dev and test — no extension is needed, and `quarkus-dotenv` should not be added. Nothing in `main` code parses `.env` directly.
56
+ 5. **Never print, log, or return an environment variable's value.** A `Handler`/`Resource` that echoes a config value back for "debugging" is a leak waiting to happen — see §7.
57
+
58
+ This applies to **every** environment-specific value, not just passwords: hostnames, ports, bucket names, tenant-specific URLs, and feature-flag endpoints belong in `.env`/the platform secret store too — hardcoding a "just a hostname" value is how an internal URL ends up in a public repo.
59
+
60
+ ## 2. Beyond local dev: secrets in real environments
61
+
62
+ `.env` is a **local-development convenience only** — it is never the secret source in a deployed environment.
63
+
64
+ - Deployed environments (dev cluster, staging, prod) inject secrets as environment variables or mounted files from a vault (HashiCorp Vault, cloud KMS/Secrets Manager, Kubernetes `Secret` backed by an external secret operator) — never from a `.env` file baked into an image.
65
+ - `application.properties` stays identical across environments: `${VAR}` placeholders only. What changes per environment is *where the platform gets the value from*, never the property file.
66
+ - Kubernetes: mount secrets as env vars via `secretKeyRef`, never as plain `ConfigMap` values, and never bake a secret into the container image at build time.
67
+ - The per-app `README.md` **Secrets** section (see quarkus-hexagonal-core skill, §"Per-app documentation") documents logical name, purpose, vault source, owner, and rotation period for every secret — **never the value itself**.
68
+ - Rotate credentials on a schedule and immediately on suspected exposure (accidental commit, departing team member with access, log leak).
69
+
70
+ ## 3. No secrets in code, config, or version control
71
+
72
+ - No API key, password, connection string, private key, or token as a string literal anywhere in `src/`, `pom.xml`, test fixtures, or committed `application.properties`. Grep for this in review: `grep -rniE "(password|secret|api[_-]?key|token)[[:space:]]*=[[:space:]]*['\"][^$]" src/` (a `${VAR}` right-hand side is fine; a literal value is not).
73
+ - Test fixtures use `%test` profile values pointing at Dev Services (ephemeral Testcontainers credentials, not real ones) — never a copy of a real credential "just for tests."
74
+ - `application.properties` committed to the repo may reference `${VAR}` freely; it never assigns a literal secret value, including in a profile-specific override (`%prod.quarkus.datasource.password=...` is banned exactly like the unscoped form).
75
+ - Private keys/certificates (`.pem`, `.jks`, `.p12`) are never committed. Reference their filesystem path (mounted from a secret) via `${VAR}`, same as any other credential.
76
+
77
+ ## 4. Authentication and authorization
78
+
79
+ - Inbound REST/gRPC authentication is `quarkus-oidc` (OIDC/JWT) against the org's identity provider — no home-grown token schemes, no hardcoded API keys as the sole auth mechanism for a production endpoint.
80
+ - `tenantId` is read from a **validated JWT claim**, never a free-form request header (see quarkus-hexagonal-core skill's tenancy rule) — a header the caller can set to any value is not authentication.
81
+ - Authorize at the `Resource`/`GrpcService` boundary with `@RolesAllowed`/`SecurityIdentity` checks before invoking `Handler.process()`; the `Handler` trusts the DTO it receives came from an already-authorized caller and does not re-implement authorization logic.
82
+ - Service-to-service calls (internal gRPC) use mTLS or a service-account token from the platform's identity system, injected via `${VAR}` per §1 — never a shared static secret checked into `common/client`.
83
+ - `quarkus.oidc.credentials.secret` and any client secret follow §1/§3 exactly: `.env` locally, vault in deployed environments, `${VAR}` in properties.
84
+
85
+ ## 5. Transport and network security
86
+
87
+ - TLS terminates at the ingress/service mesh in most deployments; where the service terminates TLS itself, `quarkus.http.ssl.certificate.*` paths point at mounted secret files via `${VAR}`, never inline certs/keys.
88
+ - HTTP is never the accepted transport for a real environment. **Where TLS terminates at the ingress (the usual case) leave `quarkus.http.insecure-requests` alone** — the pod has no certificate, so forcing `redirect`/`disabled` there breaks its own probes and the mesh's plain-HTTP hop to it. Set it only on a service that terminates TLS itself; otherwise enforce HTTPS at the ingress, where the certificate lives.
89
+ - CORS is explicit and scoped, never `*` in a deployed environment:
90
+
91
+ ```properties
92
+ quarkus.http.cors.enabled=true
93
+ quarkus.http.cors.origins=${ALLOWED_ORIGINS:http://localhost:3000}
94
+ quarkus.http.cors.methods=GET,POST,PUT,PATCH,DELETE
95
+ ```
96
+
97
+ - Internal service calls are gRPC, external/north-bound is REST (see quarkus-hexagonal-core skill) — this also means the internal surface is not exposed to the public network by default; ingress rules should reflect that boundary.
98
+
99
+ ## 6. Input handling and injection
100
+
101
+ - SQL: `PreparedStatement` placeholders only, never string-concatenated user input — full standard in quarkus-sql-jdbc-agroal skill (§2 there). This skill's `.env` rule and that skill's placeholder rule are the two halves of "never trust a string that reaches persistence."
102
+ - Bean Validation (`@NotBlank`, `@Size`, `@Pattern`) on every DTO field at the transport boundary; `Handler.validate()` covers business-rule validation (see quarkus-hexagonal-core skill) — reject malformed input before it reaches `execution()`.
103
+ - Deserialize only into typed DTOs (`@RegisterForReflection` where native requires it) — never accept and reflect back arbitrary JSON structures (mass-assignment risk).
104
+ - Any value echoed into a response, log line, or downstream call that originated from user input is treated as untrusted: encode/escape for its destination context (JSON, log line, SQL, shell) rather than assuming it is already safe.
105
+ - Never build a shell command, file path, or SQL fragment by concatenating request data. If a slice genuinely needs to invoke an external process, pass arguments as an array (no shell interpretation), never a single interpolated string.
106
+
107
+ ## 7. Secret-safe logging and error responses
108
+
109
+ - **Never log a credential, token, full connection string, or environment-variable value.** Log the variable's *name*, never its value, when debugging config issues.
110
+ - Pairs with quarkus-observability-otel skill: spans, attributes, and audit `context` are business-relevant and low-cardinality — the same rule bans PII, and it bans secrets identically. If a field would be a problem in a trace, it is a problem in a log line too.
111
+ - `GlobalExceptionHandler`/`GrpcExceptionInterceptor` (see quarkus-error-handling-i18n skill) return the catalog message only — never a stack trace, an internal exception message, or a raw `SQLException` string to the client. Full detail goes to the log (still secret-scrubbed), the client gets `<MOD>-<HTTP>-<seq>` + localized text + `X-Trace-Id`.
112
+ - Actuator/health/metrics endpoints (`/q/health`, `/q/metrics`) never surface configuration values, environment variables, or datasource URLs — verify no custom health check echoes a connection string in its response.
113
+
114
+ ## 8. Dependencies and container hardening
115
+
116
+ - Run dependency vulnerability scanning (OWASP Dependency-Check, `mvn org.owasp:dependency-check-maven:check`, or the platform's equivalent) in CI; a new dependency with a known critical CVE and no fix available needs an explicit, documented exception, not a silent merge.
117
+ - Pin dependency versions through the Quarkus BOM; avoid version ranges that can silently pull in an unreviewed transitive update.
118
+ - The native-image Dockerfile (see quarkus-hexagonal-core skill) already enforces the two container-hardening rules that matter most: a minimal base image with **no JDK/JRE layer** in the final image, and a **non-root `USER`**. Never widen either for convenience.
119
+ - No secret is ever baked into a Docker image layer (`ENV MY_SECRET=...` in a `Dockerfile`, or a `COPY .env`) — secrets are injected at container start by the orchestrator, per §2.
120
+
121
+ ## Checklist for a new service or slice
122
+
123
+ 1. `.env` created with real local values; `.gitignore` entry added in the **same commit**.
124
+ 2. `.env.example` committed with every variable name `.env` defines, placeholder/empty values only, kept current with every new variable.
125
+ 3. `application.properties` references every credential/URL via `${VAR}` — zero literal secrets, in any profile.
126
+ 4. `grep -rniE "(password|secret|api[_-]?key|token)[[:space:]]*=[[:space:]]*['\"][^$]" src/` returns nothing.
127
+ 5. Deployed environments source the same variables from the platform vault/secret store, documented (never valued) in the app's `README.md` Secrets section.
128
+ 6. Inbound auth is `quarkus-oidc`/JWT; `tenantId` comes from a validated claim, not a free header.
129
+ 7. TLS enforced outside `%dev`; CORS origins explicit, never `*` in a deployed environment.
130
+ 8. No log line, error response, health/metrics endpoint, or trace attribute ever carries a secret, token, or full connection string.
131
+ 9. Native Dockerfile: non-root user, no JDK/JRE layer, no secret baked into a layer.
132
+ 10. Dependency scan clean or exceptions documented.
@@ -9,7 +9,7 @@ Applies to any Quarkus backend project; project directives (CLAUDE.md, ADRs, exp
9
9
 
10
10
  Persistence is explicit SQL through the Agroal pool. No Panache, no Hibernate, no reflection-based row mappers. Data access lives in the slice's `<Slice>Sql` class — the driven adapter of the vertical slice (see quarkus-hexagonal-core skill). There are no `*Repository` ports and no `Jdbc*` adapters: one `Sql` class per slice, called only by that slice's `Handler`.
11
11
 
12
- One clarification to keep teams from chasing ghosts: **Agroal is a JDBC (blocking) pool** — there is no "reactive Agroal". The reactive path in Quarkus is the Vert.x SQL client (`quarkus-reactive-pg-client`) with its own pool. Default choice here is **Agroal + JDBC** (simpler, dominant skill base, works perfectly in native); reactive client only for measured hot paths with extreme concurrency (see §7).
12
+ One clarification to keep teams from chasing ghosts: **Agroal is a JDBC (blocking) pool** — there is no "reactive Agroal". The reactive path in Quarkus is the Vert.x SQL client (`quarkus-reactive-pg-client`) with its own pool. Default choice here is **Agroal + JDBC** (simpler, dominant skill base, works perfectly in native); reactive client only for measured hot paths with extreme concurrency (see §8).
13
13
 
14
14
  ## 1. Datasource configuration (Agroal)
15
15
 
@@ -78,7 +78,7 @@ Absolute rules:
78
78
  - SQL as `private static final String` text blocks (comma-first concatenation is equally acceptable), schema-qualified. Never concatenate user input — `PreparedStatement` placeholders ALWAYS (SQL injection + plan cache).
79
79
  - Quote reserved-word schemas and tables: `"order".party`. Verify the real schema name before writing the query.
80
80
  - Dynamic WHERE clauses: build from a whitelist of column/operator constants, values still as placeholders.
81
- - Methods declare `throws SQLException` and never catch it — the `Handler` translates it (§8). No `commit`, `rollback` or `setAutoCommit` here, ever.
81
+ - Methods declare `throws SQLException` and never catch it — the `Handler` translates it (§9). No `commit`, `rollback` or `setAutoCommit` here, ever.
82
82
  - One private `mapRow(ResultSet) -> Dto` per `Sql` class. Rows map to the slice's own DTOs; there is no separate domain entity and no `*RowMapper` class. No reflection mappers: they break native and hide cost.
83
83
 
84
84
  Naming (canonical rules in the quarkus-hexagonal-core skill):
@@ -90,7 +90,7 @@ Naming (canonical rules in the quarkus-hexagonal-core skill):
90
90
  | SQL constant | `UPPER_SNAKE_CASE` matching the method, `private static final String` | `SELECT_PARTY_BY_ID`, `INSERT_PARTY` |
91
91
  | Table / column | `snake_case`, singular table name, schema-qualified | `customer.party`, `created_at` |
92
92
  | Row target | the slice's `*Dto` | `PartyDto` |
93
- | Technical exception | `PersistenceException` + specific subtypes (§8) | `TransientPersistenceException` |
93
+ | Technical exception | `PersistenceException` + specific subtypes (§9) | `TransientPersistenceException` |
94
94
 
95
95
  Never name this class `*Repository`, `*Dao`, `*Manager` or `*Service` — those suffixes are banned by the core skill's ArchUnit rules. An `Sql` class that grows an `if` encoding a business rule has swallowed logic that belongs in the `Handler`.
96
96
 
@@ -116,7 +116,7 @@ public class CreatePartyIndividualHandler {
116
116
  UUID partyId = sql.insertParty(conn, request.getTenantId(), "Individual",
117
117
  "Active", request.getCreatedBy());
118
118
  sql.insertIndividual(conn, partyId, request.getGivenName(), request.getFamilyName());
119
- sql.insertOutboxEvent(conn, partyId, "party", EVENT_TYPE, TOPIC, payloadJson(partyId, request), traceparent());
119
+ outbox.record(conn, "party", partyId.toString(), EVENT_TYPE, TOPIC, eventMap(partyId, request));
120
120
  return partyId;
121
121
  } catch (SQLException e) {
122
122
  throw SqlStateTranslator.translate("PTY-500-001", e); // unchecked -> container rolls back
@@ -129,7 +129,7 @@ public class CreatePartyIndividualHandler {
129
129
 
130
130
  - `@Transactional` goes on `process()` only — never on a private method (CDI interceptors don't fire on self-invocation, so the transaction would silently never open), never on the `Sql` class.
131
131
  - **Read-only slices skip `@Transactional` entirely.** A single `SELECT`, or several reads that tolerate a non-repeatable view, are cheaper without a JTA transaction — still one `Connection`, opened and closed in `execution()`. Add the annotation the moment there is a write, or when several reads must see one snapshot.
132
- - Runtime exceptions roll back by default; `BusinessException` extends `RuntimeException`, so throwing it rolls back — correct by construction. A checked `SQLException` would **not** roll back, which is why §8 translation is mandatory.
132
+ - Runtime exceptions roll back by default; `BusinessException` extends `RuntimeException`, so throwing it rolls back — correct by construction. A checked `SQLException` would **not** roll back, which is why §9 translation is mandatory.
133
133
  - Programmatic control when annotations don't fit (loops with per-item commit, batch jobs) — from a `common/` bean or a `*Job`, never from inside a slice `Handler`:
134
134
 
135
135
  ```java
@@ -166,7 +166,7 @@ public UUID insertParty(Connection conn, String tenantId, String partyType,
166
166
  - **Generated ids**: when the PK has a database default (`id uuid DEFAULT customer.uuidv7() NOT NULL`), exclude `id` from the column list and never bind it. Read it back with `RETURNING id` + `executeQuery()` — preferred over `getGeneratedKeys()`.
167
167
  - Check `executeUpdate()` counts — 0 rows on an expected UPDATE is a bug or a concurrency signal, not a success. The `Handler` decides what that means; `Sql` just returns the count.
168
168
  - Optimistic locking: `version` column, `UPDATE ... WHERE id = ? AND version = ?`; 0 rows → the `Handler` throws `StaleVersionException` (code-carrying, in `common/exception`) with the slice's conflict code → 409 via its dedicated mapper (see quarkus-error-handling-i18n skill). It is not SQLState-derived — no `SQLException` occurs, so `SqlStateTranslator` never sees it; the `Handler` checks the update count. Do NOT name it `ConcurrentModificationException` — it shadows `java.util.ConcurrentModificationException` and an accidental import turns the 409 mapping into a 500.
169
- - Upserts: `ON CONFLICT ... DO UPDATE` explicitly; never SELECT-then-INSERT races. A unique-violation (`23505`) surfacing as a `SQLException` is translated by the `Handler` (§8), not swallowed here.
169
+ - Upserts: `ON CONFLICT ... DO UPDATE` explicitly; never SELECT-then-INSERT races. A unique-violation (`23505`) surfacing as a `SQLException` is translated by the `Handler` (§9), not swallowed here.
170
170
 
171
171
  ## 5. Batches, pagination, jsonb
172
172
 
@@ -205,9 +205,22 @@ ps.setObject(5, pgo);
205
205
 
206
206
  Read side: `rs.getString("context")` then parse. Index jsonb lookups you actually query (`(context->>'trace_id')`).
207
207
 
208
- ## 6. A tiny helper is allowed; a framework is not
208
+ ## 6. Cross-cutting tables get a cross-cutting `Sql`
209
209
 
210
- To kill boilerplate, ONE small internal helper class (~50 lines) per service or shared lib is the sanctioned maximum:
210
+ One `Sql` class per slice is the rule for the slice's **own** tables. A table that every slice writes the same way — `outbox_event`, `audit_event`, `processed_event` — gets **one** `Sql` class in `common/`, and a `Connection`-first capability bean in front of it:
211
+
212
+ | Table | Owner in `common/` | Called by |
213
+ |---|---|---|
214
+ | outbox | `OutboxSql` + `OutboxEventRecorder` (`common/messaging`) | slice `Handler`s, `OutboxDispatcher`, `OutboxRelayJob` |
215
+ | audit_event | `AuditRecorder` (`common/audit`) | slice `Handler`s |
216
+
217
+ The transactional guarantee is unaffected: these methods take the `Connection` the `Handler` already opened in `execution()`, so the row commits with the state change (§3). **Never copy `insertOutboxEvent` (or any other cross-cutting insert) onto a `<Slice>Sql`.** The columns are fixed by the migration and the envelope is fixed by the messaging standard, so every copy is identical by construction — and identical-by-construction code that is nonetheless written N times drifts: one slice grows a null-guard for a `NOT NULL` column, another falls behind a column addition, and nothing fails until the relay cannot route a row. The `duplicate twice, extract on the third` rule (hexagonal-core skill) is about code that might *turn out* to differ per slice; this cannot.
218
+
219
+ This does not license a `common/` grab-bag: it applies to tables `common/` genuinely owns, not to a query two slices happen to share today.
220
+
221
+ ## 7. A tiny helper is allowed; a framework is not
222
+
223
+ To kill boilerplate, ONE small internal helper class (~50 lines) per service or shared lib is the sanctioned maximum. (The cross-cutting owners of §6 are not helpers and do not count against this budget — they own a table, they do not generalize SQL.)
211
224
 
212
225
  ```java
213
226
  public final class Jdbc { // common/util — the one sanctioned exception to the banned-suffix rule
@@ -225,7 +238,7 @@ Note the signatures take `Connection`, not `DataSource` — the helper must not
225
238
  - The class is named `Jdbc` — a namespace, not `JdbcUtils`/`JdbcHelper`. The banned-suffix ArchUnit rule (see quarkus-hexagonal-core skill) exists precisely to stop this class from becoming a junk drawer.
226
239
  - jOOQ (code-gen, type-safe SQL) MAY be evaluated as an alternative via formal ADR; MyBatis/Hibernate remain excluded.
227
240
 
228
- ## 7. Blocking model: worker threads or virtual threads (and when reactive)
241
+ ## 8. Blocking model: worker threads or virtual threads (and when reactive)
229
242
 
230
243
  JDBC blocks. Never run it on the event loop:
231
244
 
@@ -234,7 +247,7 @@ JDBC blocks. Never run it on the event loop:
234
247
  - **Virtual threads**: `@RunOnVirtualThread` on JDBC-heavy endpoints is the modern default for high-concurrency blocking work (Java 25 baseline; synchronized-block pinning is fixed since JDK 24) — cheap threads, same simple code. Caveat: keep pool `max-size` as the real ceiling; virtual threads make it easy to pile up on `acquisition-timeout`.
235
248
  - **Reactive SQL client** (`quarkus-reactive-pg-client`, Vert.x pool — NOT Agroal): only for measured hot paths (extreme fan-in, streaming thousands of rows). It's a different programming model and a second pool to size; adopting it in a service requires an ADR. Do not mix both models in the same repository class.
236
249
 
237
- ## 8. SQLException translation
250
+ ## 9. SQLException translation
238
251
 
239
252
  `Sql` methods propagate `SQLException`; the `Handler` catches it once, in `execution()`, and translates it through `SqlStateTranslator` (`common/error`) so nothing checked ever escapes `process()` — a checked exception would not trigger the container rollback:
240
253
 
@@ -256,7 +269,7 @@ Every resulting code is registered in `ErrorCatalog` and present in all locale b
256
269
 
257
270
  Set `statement_timeout` (session or per-datasource via `quarkus.datasource.jdbc.additional-jdbc-properties.options=-c statement_timeout=5000`) so runaway queries fail fast instead of holding pool connections.
258
271
 
259
- ## 9. Schema migrations (Flyway — opt-in, disabled by default)
272
+ ## 10. Schema migrations (Flyway — opt-in, disabled by default)
260
273
 
261
274
  The schema is versioned in git as SQL under `db/` in every environment. **Who applies it is a flag**, because a service is rarely allowed to alter its own schema in production — that is a DBA or pipeline responsibility, and a native binary starting up in a replica set is the worst possible place to run DDL.
262
275
 
@@ -271,6 +284,9 @@ quarkus.flyway.migrate-at-start=false
271
284
  %test.quarkus.flyway.enabled=true
272
285
  %test.quarkus.flyway.migrate-at-start=true
273
286
 
287
+ # Classpath location by default. The monorepo keeps migrations in db/ at the REPO root, which is
288
+ # not on the classpath — either copy them into src/main/resources/db/migration at build time, or
289
+ # point Flyway at the filesystem: quarkus.flyway.locations=filesystem:../../db/migration
274
290
  quarkus.flyway.locations=db/migration
275
291
  quarkus.flyway.baseline-on-migrate=true # adopting an existing database
276
292
  ```
@@ -289,11 +305,11 @@ Rules:
289
305
  - The extension stays in `pom.xml` even when disabled — one binary for every environment, and a runtime flag can't resurrect a dependency that isn't there. Drop `quarkus-flyway` entirely only if no environment migrates at boot; then `db/` is applied exclusively by the pipeline.
290
306
  - Cross-cutting tables (`outbox_event`, `processed_event`, `audit_event` — see kafka/observability skills) are ordinary migrations in the same `db/migration` folder, not runtime-created.
291
307
 
292
- ## 10. Testing
308
+ ## 11. Testing
293
309
 
294
310
  - `<Slice>Sql` tests: `@QuarkusTest` + Dev Services (Testcontainers Postgres starts automatically — no config). Real SQL against real Postgres; never H2 (dialect lies). Obtain a `Connection` in the test and pass it in, exactly as the `Handler` does.
295
311
  - `<Slice>Handler` tests are pure Mockito with a mocked `Sql` — no database (see quarkus-hexagonal-core skill).
296
- - Flyway migrations run at test start — `%test` is one of the two profiles where the flag is on (`quarkus.flyway.enabled=true` + `migrate-at-start=true`, see §9), so tests validate DDL and queries together against the same SQL production will receive.
312
+ - Flyway migrations run at test start — `%test` is one of the two profiles where the flag is on (`quarkus.flyway.enabled=true` + `migrate-at-start=true`, see §10), so tests validate DDL and queries together against the same SQL production will receive.
297
313
  - Native verification: `@QuarkusIntegrationTest` re-runs the same tests against the binary.
298
314
 
299
315
  ## Checklist for a new `Sql` method
@@ -306,3 +322,4 @@ Rules:
306
322
  5. `executeUpdate()` count returned or checked; `SQLException` propagated for the `Handler` to translate, never swallowed.
307
323
  6. Generated ids via `RETURNING`; jsonb via `PGobject`; batch + chunking for bulk; keyset pagination if deep.
308
324
  7. Rows mapped by hand into the slice's `*Dto`; Dev Services test written.
325
+ 8. The table belongs to this slice. If it is cross-cutting (`outbox_event`, `audit_event`, `processed_event`), the method belongs in `common/` (§6) — not here.