bmad-method-quarkus 1.0.5 → 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.
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "$schema": "https://json.schemastore.org/package.json",
3
3
  "name": "bmad-method-quarkus",
4
- "version": "1.0.5",
4
+ "version": "1.0.6",
5
5
  "description": "BMAD Method with Quarkus support",
6
6
  "keywords": [
7
7
  "agile",
@@ -44,7 +44,7 @@ Ultra-succinct, citable, and role-aware. You speak in file paths, AC IDs, and cl
44
44
 
45
45
  ## Domain Standards
46
46
 
47
- These 7 standards are installed at `.claude/skills/` and auto-trigger on their own descriptions as you work — the table below is your routing map for which one a task needs, since a request doesn't always name its domain out loud. `quarkus-hexagonal-core` defines the vocabulary the other six use, so it is the one you apply first:
47
+ These 9 standards are installed at `.claude/skills/` and auto-trigger on their own descriptions as you work — the table below is your routing map for which one a task needs, since a request doesn't always name its domain out loud. `quarkus-hexagonal-core` defines the vocabulary the other eight use, so it is the one you apply first. Eight are universal; `quarkus-temporal-workflows` applies only to projects that actually use Temporal:
48
48
 
49
49
  | Skill | Consult when the task touches |
50
50
  | --- | --- |
@@ -55,6 +55,8 @@ These 7 standards are installed at `.claude/skills/` and auto-trigger on their o
55
55
  | `quarkus-grpc-services` | `.proto` files, a `GrpcService` adapter, or an outbound call wrapped in `common/client` |
56
56
  | `quarkus-kafka-messaging` | Domain events — the outbox row, the relay mode, a `Consumer`, DLQ, idempotency |
57
57
  | `quarkus-observability-otel` | `@WithSpan` on `process()`, `trace_id`/`span_id` in logs, the `audit_event` context, metrics |
58
+ | `quarkus-security-standards` | Any credential/URL/API key, `.env`/`.env.example`, `application.properties` secrets, OIDC/JWT auth, CORS/TLS, or a security review |
59
+ | `quarkus-temporal-workflows` | **Temporal projects only** — a saga/compensation, a `@WorkflowInterface`/`@ActivityInterface`, the worker bootstrap, a multi-slice process one transaction can't make atomic, or scaffolding a **worker-only deployable** (no REST, no slices — it overrides the slice layout) |
58
60
 
59
61
  ## On Activation
60
62
 
@@ -86,15 +88,17 @@ Treat every entry in `{agent.persistent_facts}` as foundational context you carr
86
88
 
87
89
  ### Step 5: Load the House Standards
88
90
 
89
- Read all seven Quarkus standards **in full** before doing any work. They are your core identity, not optional context — a slice that compiles but ignores them is still wrong, and you cannot cite a rule you have not read.
91
+ Read the eight universal Quarkus standards **in full** before doing any work. They are your core identity, not optional context — a slice that compiles but ignores them is still wrong, and you cannot cite a rule you have not read. The ninth is conditional: read it only when the project uses Temporal.
90
92
 
91
- 1. `{project-root}/.claude/skills/quarkus-hexagonal-core/SKILL.md` — **read this one first**; the other six use the vocabulary it defines
93
+ 1. `{project-root}/.claude/skills/quarkus-hexagonal-core/SKILL.md` — **read this one first**; the other eight use the vocabulary it defines
92
94
  2. `{project-root}/.claude/skills/quarkus-sql-jdbc-agroal/SKILL.md`
93
95
  3. `{project-root}/.claude/skills/quarkus-error-handling-i18n/SKILL.md`
94
96
  4. `{project-root}/.claude/skills/quarkus-openapi-tmforum/SKILL.md`
95
97
  5. `{project-root}/.claude/skills/quarkus-grpc-services/SKILL.md`
96
98
  6. `{project-root}/.claude/skills/quarkus-kafka-messaging/SKILL.md`
97
99
  7. `{project-root}/.claude/skills/quarkus-observability-otel/SKILL.md`
100
+ 8. `{project-root}/.claude/skills/quarkus-security-standards/SKILL.md`
101
+ 9. `{project-root}/.claude/skills/quarkus-temporal-workflows/SKILL.md` — **only if the project uses Temporal** (`io.temporal:temporal-sdk` in a `pom.xml`, or an `orchestration/` package). Skip it otherwise; it governs nothing in a project without workflows.
98
102
 
99
103
  These are **sibling** skills installed flat at `.claude/skills/`, not nested under this one — a bare `skills/<name>/SKILL.md` path resolves from `{skill-root}`, finds nothing, and fails silently. If a file is missing at that path, try `{project-root}/src/bmm-skills/agents/bmad-quarkus-build/skills/<name>/SKILL.md` (pre-install staging layout) and use whichever resolves. If neither resolves, **say so before writing any code** rather than working from memory.
100
104
 
@@ -75,7 +75,7 @@ Canonical rules in the quarkus-hexagonal-core skill; the error/i18n specifics:
75
75
  | gRPC counterpart | `GrpcExceptionInterceptor`, in `common/error` | |
76
76
  | i18n components | `MessageResolver`, `ErrorCatalog` | |
77
77
  | Error DTO | `ErrorDto` (TMF630 Error shape) | |
78
- | Bundle files | `messages/errors[_<lang>].properties` | `errors_en.properties` |
78
+ | Bundle files | `messages/errors[_<lang>].properties` — the default (en) bundle is `errors.properties`, with no suffix | `errors.properties`, `errors_es.properties` |
79
79
  | Bundle key | the error code itself, never a prose key | `USR-404-001` |
80
80
 
81
81
  `GlobalExceptionHandler` is no longer "the only sanctioned `*Handler`" — under the slice standard `*Handler` is the business-logic class of every slice (`CreatePartyIndividualHandler`). What stays true is that there is exactly **one** global exception handler per transport, both in `common/error`, and that no `Resource`, `GrpcService` or `Consumer` builds an error response itself.
@@ -189,7 +189,11 @@ public class MessageResolver {
189
189
  var locale = headers.getAcceptableLanguages().stream()
190
190
  .findFirst().filter(l -> !"*".equals(l.getLanguage()))
191
191
  .orElse(Locale.ENGLISH); // default locale = en
192
- var bundle = ResourceBundle.getBundle("messages.errors", locale);
192
+ // getNoFallbackControl is REQUIRED: the default Control falls back to the JVM's
193
+ // default locale before the base bundle, so `Accept-Language: fr` on a pod with
194
+ // LANG=es returns Spanish instead of the English default.
195
+ var bundle = ResourceBundle.getBundle("messages.errors", locale,
196
+ ResourceBundle.Control.getNoFallbackControl(ResourceBundle.Control.FORMAT_PROPERTIES));
193
197
  var pattern = bundle.containsKey(code) ? bundle.getString(code)
194
198
  : bundle.getString("GEN-500-001");
195
199
  return MessageFormat.format(pattern, args);
@@ -5,10 +5,12 @@ description: Foundation for building Java + Quarkus backend services compiled to
5
5
 
6
6
  # Quarkus Vertical Slice + Hexagonal Core (Native-First)
7
7
 
8
- Foundation skill for all backend services. The other skills in this set (sql-jdbc-agroal, error-handling-i18n, openapi-tmforum, grpc-services, kafka-messaging, observability-otel) build on the structure defined here and use the vocabulary defined here — apply this one first when scaffolding.
8
+ Foundation skill for all backend services. The other eight skills in this set (`quarkus-sql-jdbc-agroal`, `quarkus-error-handling-i18n`, `quarkus-openapi-tmforum`, `quarkus-grpc-services`, `quarkus-kafka-messaging`, `quarkus-observability-otel`, `quarkus-security-standards`, and `quarkus-temporal-workflows` on Temporal projects) build on the structure defined here and use the vocabulary defined here — apply this one first when scaffolding.
9
9
 
10
10
  **Scope & precedence:** these standards apply to any Java/Quarkus backend project, whether or not it is organized as DDD bounded contexts. **Module naming:** every module/bounded context is identified in code by a short, lowercase, semantic name (`iam`, `tenant`, `wallet`, `customer`) — never by an inventory/governance code like `bc01`/`bc02`. If the organization keeps a numbered BC inventory, the `bcNN` ↔ name mapping lives in governance docs (domain README identity table, service catalog); packages, topics, config keys, URLs, metrics and proto packages only ever carry the semantic name. They are the default, not law: a project directive that says otherwise (CLAUDE.md, an ADR, an explicit user instruction) wins over this skill. When you deviate because of such a directive, follow the directive and mention which rule was overridden.
11
11
 
12
+ **One structural exception:** a **Temporal worker-only deployable** (`{module}-{capability}-worker`) — an app that hosts workflows and activities and exposes no API — has no vertical slices, no `Resource`, no `<Slice>Handler`/`<Slice>Sql` pair and usually no datasource. The naming rules, monorepo layout, native build, Dockerfile and the `service.yaml` + README standard below still apply to it in full; the slice layout, the REST adapter rules and the slice-oriented ArchUnit rules do not. See the quarkus-temporal-workflows skill §3 before scaffolding one — do not give it slices because this skill's checklist says so.
13
+
12
14
  ## The central idea: one feature = one folder
13
15
 
14
16
  Classic layered hexagonal architecture spreads a single feature across eight or more files (inbound port, use case, command, result, outbound port, JDBC adapter, DTO, mapper) in four packages. This standard keeps the **hexagon's dependency rule** but collapses it into a **vertical slice**: everything one business capability needs lives in one folder, and the hexagon is expressed by *class role*, not by package depth.
@@ -48,6 +50,7 @@ One monorepo per domain (bounded context in DDD projects) holds **every deployab
48
50
  │ │ ├── service.yaml # structured metadata — JSON-Schema validated in CI
49
51
  │ │ └── README.md # per-app operational doc (see "Per-app documentation")
50
52
  │ ├── iam-identity-ms/ # a second backend in the same domain, same internal structure
53
+ │ ├── iam-onboarding-worker/ # Temporal worker — no API, no slices (see temporal skill)
51
54
  │ ├── iam-admin-mf/ # microfrontend remote — own build, same service.yaml + README rule
52
55
  │ └── iam-shell-module/ # host/shell that composes the remotes
53
56
  ├── libs/ # code shared *within* this domain — never across domains
@@ -61,7 +64,7 @@ One monorepo per domain (bounded context in DDD projects) holds **every deployab
61
64
  └── README.md # domain onboarding doc (see "Domain README")
62
65
  ```
63
66
 
64
- **Scope of this skill inside the monorepo:** everything from "Slice layout" onward governs `-ms` apps only. `-mf` and `-module` apps are first-class residents of the same repo — they share `contracts/`, `libs/web/`, `deploy/` and `docs/`, and they carry the same mandatory `service.yaml` + `README.md` — but their internal structure is not this standard's business. A frontend never reaches a backend except through a published contract in `contracts/`; there is no shared runtime code between an `-ms` and an `-mf`.
67
+ **Scope of this skill inside the monorepo:** everything from "Slice layout" onward governs `-ms` apps only. A `-worker` app is a backend and follows this skill's naming, native build and documentation rules, but it has no slices — its internal structure belongs to the quarkus-temporal-workflows skill. `-mf` and `-module` apps are first-class residents of the same repo — they share `contracts/`, `libs/web/`, `deploy/` and `docs/`, and they carry the same mandatory `service.yaml` + `README.md` — but their internal structure is not this standard's business. A frontend never reaches a backend except through a published contract in `contracts/`; there is no shared runtime code between an `-ms` and an `-mf`.
65
68
 
66
69
  ### Deployable app naming (tenant-agnostic)
67
70
 
@@ -69,7 +72,9 @@ One monorepo per domain (bounded context in DDD projects) holds **every deployab
69
72
  {module}-{service or submodule}-{type-suffix}
70
73
  ```
71
74
 
72
- Suffixes: `-ms` (backend microservice — every Quarkus service here), `-mf` (microfrontend remote), `-module` (host/shell). Example: `iam-management-ms`. **Client and tenant never appear in app or code names** — they are runtime configuration (namespaces, labels, Helm values) applied at deploy time; the code is identical for every client and tenant.
75
+ Suffixes: `-ms` (backend microservice serving an API), `-worker` (backend deployable that serves **no** API currently Temporal workflow workers; see the quarkus-temporal-workflows skill), `-mf` (microfrontend remote), `-module` (host/shell). Examples: `iam-management-ms`, `customer-onboarding-worker`.
76
+
77
+ `-ms` vs `-worker` is a deploy-time contract, not a label: an `-ms` gets an ingress, HTTP probes and RPS-based scaling; a `-worker` gets none of those and scales on queue backlog. An app that serves an API *and* hosts a worker is an `-ms`. **Client and tenant never appear in app or code names** — they are runtime configuration (namespaces, labels, Helm values) applied at deploy time; the code is identical for every client and tenant.
73
78
 
74
79
  ## Slice layout (the canonical structure)
75
80
 
@@ -179,7 +184,7 @@ Several slices may expose operations under the same TMF base path (`create_party
179
184
 
180
185
  | Kind | Convention | Example |
181
186
  |---|---|---|
182
- | Deployable app | `{module}-{service or submodule}-{type}` kebab-case, tenant-agnostic (`-ms` backend) | `iam-management-ms` |
187
+ | Deployable app | `{module}-{service or submodule}-{type}` kebab-case, tenant-agnostic (`-ms` backend with an API, `-worker` backend without one) | `iam-management-ms`, `customer-onboarding-worker` |
183
188
  | Slice package | lowercase `snake_case`, verb-first | `com.alva.customer.create_party_individual` |
184
189
  | SQL constant | `UPPER_SNAKE_CASE` matching the method | `INSERT_PARTY`, `SELECT_PARTY_BY_ID` |
185
190
  | SQL table / column | `snake_case`, singular table, schema-qualified | `customer.party`, `created_at` |
@@ -228,7 +233,7 @@ private void validate(CreatePartyIndividualRequestDto request) {
228
233
 
229
234
  - Opens **one** `Connection` from the injected `DataSource` in try-with-resources and passes it to every `Sql` call in the operation. This is the performance contract: one connection per business operation, whether or not a transaction is active. Under JTA, Agroal would return the same enlisted connection anyway; on a read path with no `@Transactional`, per-method `getConnection()` would acquire *N* separate pool leases for *N* queries — passing `conn` makes both paths cost exactly one.
230
235
  - Calls `${SERVICE_CLASS_PREFIX}Sql` methods in business order (insert `party` first to get `partyId`, reuse it for dependent inserts). Before writing a call, re-read the real `Sql` method signature and pass exactly those parameters, in that order and type.
231
- - Catches every checked `SQLException` and rethrows it as `BusinessException` via `SqlStateTranslator` an unchecked exception is what triggers the container rollback, and a propagated checked exception would not.
236
+ - Catches every checked `SQLException` and rethrows it **unchecked** via `SqlStateTranslator` — as a `BusinessException` for the states that mean a business conflict (`23505`, `23503`) and as the `PersistenceException` family for everything else (see the mapping table in quarkus-sql-jdbc-agroal skill §9). An unchecked exception is what triggers the container rollback; a propagated checked one would not.
232
237
  - Date fields arrive from the DTO as `String`. To use them as `LocalDate`/`ZonedDateTime`, declare a **new local variable** and convert explicitly (`LocalDate.parse(...)`) — never reassign the DTO field.
233
238
  - Domain events are published by inserting an outbox row through `OutboxEventRecorder` from `common/messaging`, on this method's `Connection` and therefore in this same transaction — never by emitting to Kafka directly (see quarkus-kafka-messaging skill). Who relays that row (Debezium, `OutboxRelayJob`, or a post-commit emit) is a runtime property, `app.messaging.outbox.relay-mode`, and changes nothing here.
234
239
  - **The outbox is cross-cutting, not slice-local: never declare `insertOutboxEvent` on a `<Slice>Sql`.** It is the same table, the same columns and the same envelope in every slice, so the "duplicate twice, extract on the third" rule below does not apply — there is nothing slice-specific to discover. The shared `Connection` is what keeps the write transactional; the class's location is irrelevant to that guarantee. Same for `AuditRecorder` and any other table `common/` owns.
@@ -321,7 +326,7 @@ Full standard (pool config, batches, jsonb, pagination, keyset paging, SQLState
321
326
  - Constants are text blocks or comma-first concatenations, schema-qualified, with positional `?` placeholders only. **Check the real schema name before writing a query** — if the schema or table is a reserved word (`order`, `user`, `group`), quote it: `"order".party`.
322
327
  - If the PK has a database default (`id uuid DEFAULT customer.uuidv7() NOT NULL`), the `id` column is excluded from the INSERT column list and never passed as a parameter. Prefer `INSERT ... RETURNING id` over `getGeneratedKeys()`.
323
328
  - **Every method's first parameter is `Connection conn`** — supplied by the Handler, never obtained inside the method. That signature is the contract the Handler must honour exactly.
324
- - Methods `throws SQLException` — they never swallow it and never translate it. Translation to `BusinessException` happens in the Handler via `SqlStateTranslator`.
329
+ - Methods `throws SQLException` — they never swallow it and never translate it. Translation happens in the Handler via `SqlStateTranslator`, into `BusinessException` or the `PersistenceException` family depending on the SQLState.
325
330
  - **No transaction control here.** No `commit`, no `rollback`, no `setAutoCommit`.
326
331
  - Row mapping is hand-written into DTOs (`PartyDto.builder()...`) — no reflection-based mappers, which also keeps native images small.
327
332
 
@@ -344,7 +349,7 @@ All three are thin: extract metadata, invoke `handler.process(...)`, format the
344
349
  - `<Resource>Resource` class with `@Path` + `@Tag`, injecting the slice `Handler`(s).
345
350
  - Returns `RestResponse<T>` synchronously. Never `Uni`/`Multi`.
346
351
  - `@Blocking` on JDBC-backed methods. With a plain (non-reactive) return type Quarkus REST already dispatches to a worker thread, so the annotation is redundant *today* — keep it as an explicit threading contract that survives a later signature change. `@RunOnVirtualThread` is the alternative for high-concurrency blocking endpoints (see sql skill §8).
347
- - Extracts `tenantId`, `partyId`, `partyRolList`, `language` from headers when the contract requires them, and sets them on the request DTO before calling `process()`.
352
+ - Reads `tenantId` — and any other value that drives authorization, such as `partyId` or `partyRolList` — from the **validated token** (`@Inject JsonWebToken jwt` → `jwt.getClaim(...)`, or `@Claim` — not `SecurityIdentity`, which carries roles and mechanism attributes rather than claims), never from a request header the caller controls (see the Tenancy model rule under "Per-app documentation" and quarkus-security-standards skill §4). `language` and other non-security metadata may come from headers. It sets them on the request DTO before calling `process()`.
348
353
  - Full OpenAPI annotations per method: `@Operation` with `operationId`, one `@APIResponse` per relevant status, `@Parameter` for headers and path/query params, `Location` header on create.
349
354
 
350
355
  **gRPC** — full standard in **quarkus-grpc-services**:
@@ -394,10 +399,10 @@ Code shared across **apps** in the domain goes to `libs/` and uses constructor i
394
399
 
395
400
  ## Scaffolding checklist
396
401
 
397
- When creating a new service:
402
+ When creating a new service. A **Temporal worker-only deployable** follows the same steps with a reduced scope (quarkus-temporal-workflows skill §3): in step 2 drop `quarkus-rest*`, `quarkus-smallrye-openapi`, and — unless it owns a table of its own — `quarkus-agroal`, `quarkus-jdbc-*` and `quarkus-flyway`; in step 3 keep only the error/exception classes it actually throws; skip step 5's slice-oriented rules and ship the ArchUnit subset that skill lists. It has no slices and no `Resource`.
398
403
 
399
- 1. App folder `apps/<module>-<service-or-submodule>-ms/` per the monorepo layout above.
400
- 2. `pom.xml` with BOM `io.quarkus.platform:quarkus-bom` (latest 3.x LTS), extensions: `quarkus-rest`, `quarkus-rest-jackson`, `quarkus-agroal`, `quarkus-jdbc-postgresql`, `quarkus-arc`, `quarkus-narayana-jta`, `quarkus-hibernate-validator`, `quarkus-smallrye-health`, `quarkus-smallrye-openapi`, `quarkus-opentelemetry`, `quarkus-flyway` (kept even where migrations are disabled — see sql skill §10), plus Lombok and skill-specific extensions as needed.
404
+ 1. App folder `apps/<module>-<service-or-submodule>-ms/` per the monorepo layout above — `-worker` instead of `-ms` for a Temporal worker deployable.
405
+ 2. `pom.xml` with BOM `io.quarkus.platform:quarkus-bom` (latest 3.x LTS), extensions: `quarkus-rest`, `quarkus-rest-jackson`, `quarkus-agroal`, `quarkus-jdbc-postgresql`, `quarkus-arc`, `quarkus-narayana-jta`, `quarkus-hibernate-validator`, `quarkus-smallrye-health`, `quarkus-smallrye-openapi`, `quarkus-opentelemetry`, `quarkus-micrometer-registry-prometheus` (mandatory metrics — brings `quarkus-micrometer` transitively) and `quarkus-logging-json` (mandatory structured logs) — the three observability extensions are all required, see that skill — `quarkus-oidc` wherever the app exposes an authenticated API (security skill §4), `quarkus-flyway` (kept even where migrations are disabled — see sql skill §10), plus Lombok and skill-specific extensions as needed.
401
406
  3. `common/` package: `BusinessException`, `ErrorCatalog`, `ErrorDto`, `GlobalExceptionHandler`, `MessageResolver`, `SqlStateTranslator`, `StringUtils`.
402
407
  4. `application.properties` baseline (below).
403
408
  5. ArchUnit test (below) — in the same commit as the first slice, not later.
@@ -434,8 +439,12 @@ quarkus.flyway.migrate-at-start=false
434
439
  app.messaging.outbox.relay-mode=debezium
435
440
  app.messaging.outbox.poller.every=off
436
441
 
442
+ # Metrics — Micrometer is mandatory in every service (see quarkus-observability-otel skill)
443
+ quarkus.micrometer.export.prometheus.enabled=true
444
+ quarkus.datasource.metrics.enabled=true
445
+
437
446
  # i18n (see error-handling skill) — locales must be declared for native
438
- quarkus.locales=en,es
447
+ quarkus.locales=en,es,pt
439
448
  quarkus.default-locale=en
440
449
  quarkus.native.resources.includes=messages/*.properties
441
450
 
@@ -484,13 +493,13 @@ Rules:
484
493
 
485
494
  Every deployable under `apps/<app>/` carries two files, versioned with the code and updated in the **same PR** that changes the service's behavior:
486
495
 
487
- - **`service.yaml`** — structured metadata validated against a JSON Schema in CI: identity (domain, system, component type, lifecycle), ownership (team, SRE, support group), criticality, tenancy (model, data isolation), runtime (type, version, ports), provided APIs with their contract files in `contracts/`, dependencies with `hard`/`soft` requirement, and the observability contract (log format, levels, required fields, data-protection rules). It feeds the service catalog and the dependency map.
496
+ - **`service.yaml`** — structured metadata validated against a JSON Schema in CI: identity (domain, system, component type, lifecycle), **the API gateway base path(s) under `metadata.gateway`** (see "Gateway routing" below), ownership (team, SRE, support group), criticality, tenancy (model, data isolation), runtime (type, version, ports), provided APIs with their contract files in `contracts/`, dependencies with `hard`/`soft` requirement, and the observability contract (log format, levels, required fields, data-protection rules). It feeds the service catalog, the dependency map and the gateway route configuration.
488
497
  - **`README.md`** (per app) — operational doc written so a system administrator who has never seen the code can configure, deploy and recover the service. No `TBD` / "see code" (a justified `N/A` is allowed). No manual changelog section — Git keeps the history.
489
498
 
490
499
  Required `README.md` sections:
491
500
 
492
501
  1. **Identity** — app name, module (`part-of`), type (`ms`), owning team, repository, criticality, part of core yes/no
493
- 2. **Purpose & responsibility** — description + out of scope
502
+ 2. **Purpose, responsibility & gateway routes** — description + out of scope, **plus the API gateway base path(s) this service is published under** (see "Gateway routing" below). A `-worker` app declares `N/A — publishes no API`.
494
503
  3. **Slice inventory** — table: slice folder, capability, exposed channels (REST/gRPC/Kafka), link to the slice README
495
504
  4. **Tenancy model** — `shared` | `per-tenant` | `pool`; tenant identification (validated JWT claim, never a free header); data isolation; per-tenant parameters
496
505
  5. **APIs exposed** — table: api/version, protocol, route/topic, contract file, auth, visibility
@@ -509,6 +518,39 @@ Required `README.md` sections:
509
518
 
510
519
  Rule of completeness: if a variable, secret or dependency is not documented here, it does not exist — CI validates the `service.yaml` schema and the mandatory `README.md` sections as a blocking gate.
511
520
 
521
+ ### Gateway routing (README §2 + `service.yaml` `metadata.gateway`)
522
+
523
+ Infra configures the API gateway from these two files, so the route a service will be published under is declared **before** it is deployed, in the same PR as the endpoint.
524
+
525
+ **The base path is the module's semantic name**, the same name packages, topics, config keys and metrics already use: `/iam`, `/customer`, `/wallet` — **never the inventory code** (`/bc01` is wrong; the `bcNN` ↔ name mapping lives in governance docs, not in a URL). Every north-bound REST API of every `-ms` in the module therefore hangs off one shared prefix, and the API's own path continues from there per the quarkus-openapi-tmforum skill: `/{module}/{context}/{apiName}/v{major}`, e.g. `/iam/tmf-api/digitalIdentityManagement/v4`.
526
+
527
+ README §2 carries the human-readable table:
528
+
529
+ | Base path | Protocol | Visibility | Purpose |
530
+ |---|---|---|---|
531
+ | `/iam` | REST | public | north-bound TMF identity APIs |
532
+
533
+ `service.yaml` carries the same facts as structured metadata:
534
+
535
+ ```yaml
536
+ metadata:
537
+ name: iam-management-ms
538
+ module: iam
539
+ gateway:
540
+ base-paths:
541
+ - path: /iam
542
+ protocol: REST
543
+ visibility: public # public | partner | internal
544
+ purpose: north-bound TMF identity APIs
545
+ ```
546
+
547
+ Rules:
548
+ - **`service.yaml` is authoritative; the README renders the same facts for humans.** They must agree — two copies of one fact drift, and here the drift is a misrouted service. CI cross-checks them.
549
+ - **Only north-bound REST appears here.** Internal gRPC never crosses the gateway (internal calls are gRPC, external is REST — see "Baseline constraints"), so a `*GrpcService` contributes no gateway route.
550
+ - **A `-worker` app declares none** — `metadata.gateway.base-paths: []` and `N/A` in the README. It publishes no API by definition (see the quarkus-temporal-workflows skill).
551
+ - List **every** prefix when a service is published under more than one — a legacy alias kept during a migration, or a second public API — each with its own purpose, so nobody has to guess which is being retired.
552
+ - The declared path must match the ingress/gateway configuration in the GitOps repo. A README that disagrees with what is deployed is a defect, not a stale doc: it is the input infra works from.
553
+
512
554
  ### Slice README (inside the slice folder)
513
555
 
514
556
  Every slice carries its own `README.md`, generated from the real code — no invented routes, fields or tables. Sections:
@@ -547,7 +589,10 @@ class ArchitectureTest {
547
589
  .namingSlices("slice $1")
548
590
  .should().notDependOnEachOther()
549
591
  .ignoreDependency(resideInAPackage("..common.."), alwaysTrue())
550
- .ignoreDependency(alwaysTrue(), resideInAPackage("..common.."));
592
+ .ignoreDependency(alwaysTrue(), resideInAPackage("..common.."))
593
+ // Temporal only: orchestration/ may reach into slices, never the reverse (deliberately
594
+ // asymmetric — see quarkus-temporal-workflows skill §3). Omit on non-Temporal projects.
595
+ .ignoreDependency(resideInAPackage("..orchestration.."), alwaysTrue());
551
596
 
552
597
  // --- Hexagon dependency rule inside the slice ------------------------------
553
598
  @ArchTest
@@ -646,7 +691,9 @@ static final ArchRule businessExceptionIsTheOnlyOne = classes()
646
691
  .should().resideInAPackage("..common.exception..");
647
692
 
648
693
  @ArchTest
649
- static final ArchRule bannedSuffixes = noClasses().should().haveSimpleNameEndingWith("Impl")
694
+ static final ArchRule bannedSuffixes = noClasses()
695
+ .that().resideOutsideOfPackage("..orchestration..") // Temporal workflow/activity Impl pair — see below
696
+ .should().haveSimpleNameEndingWith("Impl")
650
697
  .orShould().haveSimpleNameEndingWith("Util") // note: "Utils" is deliberately absent — common/util/StringUtils
651
698
  .orShould().haveSimpleNameEndingWith("Helper")
652
699
  .orShould().haveSimpleNameEndingWith("Manager")
@@ -657,7 +704,7 @@ static final ArchRule bannedSuffixes = noClasses().should().haveSimpleNameEnding
657
704
  .orShould().haveSimpleNameEndingWith("DTO");
658
705
  ```
659
706
 
660
- `common/util/StringUtils` and the `Jdbc` namespace helper (sql skill §7) are the sanctioned exceptions to `bannedSuffixes` exclude them by fully-qualified name rather than weakening the rule.
707
+ `common/util/StringUtils`, the `Jdbc` namespace helper (sql skill §7) and, on Temporal projects, the `*WorkflowImpl`/`*ActivitiesImpl` pair inside `..orchestration..` (whose interface+implementation split the SDK requires — see quarkus-temporal-workflows skill §3) are the sanctioned exceptions to `bannedSuffixes`. Exclude them by package or fully-qualified name, as above, rather than weakening the rule.
661
708
 
662
709
  Rules the compiler cannot express (enforce in code review and in the generator checklists):
663
710
 
@@ -686,7 +733,7 @@ Rules the compiler cannot express (enforce in code review and in the generator c
686
733
 
687
734
  `<Slice>HandlerTest` is mandatory and mirrors the slice. Rules:
688
735
 
689
- - Mockito only — `@Mock <Slice>Sql sql;`, `@Mock DataSource dataSource;`, `@Mock Connection connection;`, `@InjectMocks <Slice>Handler handler;`, with `when(dataSource.getConnection()).thenReturn(connection)` in `@BeforeEach`. Never a real database, never `@QuarkusTest` for pure business rules, never both `@InjectMocks` (Mockito) and `@InjectMock` (Quarkus) in one file.
736
+ - Mockito only — `@Mock <Slice>Sql sql;`, `@Mock DataSource dataSource;`, `@Mock Connection connection;`, `@InjectMocks <Slice>Handler handler;`. Stub `when(dataSource.getConnection()).thenReturn(connection)` **inside the tests that reach `execution()`**, not in `@BeforeEach`: the mandatory validation-failure test (scenario 2 below) never opens a connection, so a shared stub fails it with Mockito's `UnnecessaryStubbingException` under the default strict stubs. Use `lenient()` only if you genuinely prefer the shared setup. Never a real database, never `@QuarkusTest` for pure business rules, never both `@InjectMocks` (Mockito) and `@InjectMock` (Quarkus) in one file.
690
737
  - Test names: `should<Result>When<Condition>` — `shouldReturnPartyIdWhenRequestIsValid`, `shouldThrowBusinessExceptionWhenGivenNameIsBlank`.
691
738
  - Minimum scenarios per public business path:
692
739
 
@@ -130,7 +130,9 @@ That is the whole dual-write guarantee — one connection, one transaction, no p
130
130
  Fire-and-forget technical messages (metrics, notifications with no consistency requirement) MAY use a direct `Emitter` — but it lives in a **`common/` capability bean**, never in a slice. `@Channel`/`Emitter` are `org.eclipse.microprofile.reactive.messaging` types, and the `handlerKnowsNoTransport` ArchUnit rule forbids them in a `Handler`:
131
131
 
132
132
  ```java
133
- // common/audit/AuditRecorder.java — the Handler injects AuditRecorder, not the Emitter
133
+ // common/audit/AuditEventPublisher.java — the Handler injects this bean, not the Emitter.
134
+ // Distinct from AuditRecorder (common/audit), which writes the local audit_event table
135
+ // on the Handler's Connection — see quarkus-observability-otel skill.
134
136
  @Inject @Channel("audit-out") Emitter<AuditEventPayload> audit;
135
137
  ```
136
138
 
@@ -314,12 +316,12 @@ mp.messaging.incoming.user-registered-in.connector=smallrye-kafka
314
316
  mp.messaging.incoming.user-registered-in.topic=alva.iam.user.registered.v1
315
317
  mp.messaging.incoming.user-registered-in.group.id=${quarkus.application.name}
316
318
  mp.messaging.incoming.user-registered-in.auto.offset.reset=earliest
317
- mp.messaging.incoming.user-registered-in.failure-strategy=delayed-retry-then-dead-letter-queue
319
+ mp.messaging.incoming.user-registered-in.failure-strategy=dead-letter-queue
318
320
  mp.messaging.incoming.user-registered-in.dead-letter-queue.topic=alva.iam.user.registered.v1.dlq
319
321
  ```
320
322
 
321
323
  Rules:
322
- - **Idempotency is mandatory**: `processed_event(consumer_group, event_id)` checked/inserted through the slice's `Sql` on the same `Connection`, in the same transaction as the side effects. Kafka is at-least-once; duplicates WILL happen.
324
+ - **Idempotency is mandatory**: `processed_event(consumer_group, event_id)` checked/inserted through the cross-cutting `ProcessedEventSql` in `common/messaging` — never a per-slice copy, it is the same table for every slice (see quarkus-sql-jdbc-agroal skill §6) — on the `Connection` the `Handler` already owns, in the same transaction as the side effects. Kafka is at-least-once; duplicates WILL happen.
323
325
  - Consumer group = service name; stable across deployments.
324
326
  - Failure strategy: bounded retries with delay, then DLQ topic `<topic>.dlq`. DLQ messages keep original headers plus `dead-letter-reason`. A DLQ must have an owner and an alert — never a silent graveyard.
325
327
  - Poison-pill safety: deserialization failures also route to DLQ (`...deserialization-failure-handler` or failure strategy), never block the partition.
@@ -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.