Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 19 additions & 17 deletions architecture/adr/018-wire-serialization-event-conventions.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,41 +2,43 @@

**Status:** Accepted
**Date:** June 2026
**Docs:** ADR-003 (Four Lifecycle States), ADR-009 (API Gateway & Control Plane); UDLM `registry/naming-conventions.md` §4; CNCF CloudEvents
**Tracking:** companion to the UDLM data-model casing decision (naming-conventions §4)
**Docs:** ADR-003 (Four Lifecycle States), ADR-009 (API Gateway & Control Plane); UDLM `registry/naming-conventions.md` §4; AEP (`aep.dev`); CNCF CloudEvents
**Tracking:** companion to the UDLM data-model casing decision (naming-conventions §4). Supersedes the camelCase first draft of this ADR.

## Context

UDLM defines the **data model** and fixes its on-the-wire casing — **`camelCase` keys** (UDLM `naming-conventions.md` §4): UDLM is an API- and event-bus-driven model consumed by code, not hand-edited config. DCM is the **runtime** that serializes and transports that model — over REST/gRPC at the API gateway (ADR-009) and over the event bus — between services written in **Go and Python**. This ADR covers the DCM-side conventions so the camelCase contract flows end to end without per-hop key-translation layers. (The casing of the data model itself is UDLM's call; this ADR is how the runtime honors it.)
UDLM defines the **data model** and fixes its on-the-wire casing — **`snake_case` keys** (UDLM `naming-conventions.md` §4). DCM is the **runtime** that serializes and transports that model — over REST/gRPC at the API gateway (ADR-009) and over the event bus — between services written in **Go and Python**. This ADR covers the DCM-side conventions so the snake_case contract flows end to end without per-hop key-translation layers. (The casing of the data model itself is UDLM's call; this ADR is how the runtime honors it.)

**Why snake_case (the reversal):** UDLM is a **canonical data model consumed natively** — the model *is* the wire form, so there is no separate "API casing" to translate to. DCM's API is the consumer with a hard external constraint: it conforms to **AEP** (`aep.dev`, the dcm-project engineering team's adopted API-design standard, enforced by `aep-dev/aep-openapi-linter`), whose prescribed fields are snake_case (`page_size`, `*_time`). Native-universal consumption **+** AEP-bound API jointly force one casing — snake_case. The first draft of this ADR chose camelCase on research that had not accounted for AEP; this revision reverses it. (Empirically: the AEP linter reports zero casing findings against the existing snake_case OpenAPI specs.)

## Decision

1. **Wire payloads are `camelCase`** — request/response bodies and event payloads match the UDLM data model. No snake_case↔camelCase translation layer between the API, the event bus, and services.
1. **Wire payloads are `snake_case`** — request/response bodies and event payloads match the UDLM data model and AEP. No snake_case↔camelCase translation layer between the API, the event bus, and services.

2. **Go services:** PascalCase exported struct fields with explicit tags — `json:"camelCase" yaml:"camelCase"`. Go keeps its idiom; the wire stays camelCase.
2. **Go services:** PascalCase exported struct fields with explicit tags — `json:"snake_case" yaml:"snake_case"`. Go keeps its idiom; the wire stays snake_case.
```go
type ResourceDefinition struct {
ResourceID string `json:"resourceId" yaml:"resourceId"`
MemoryLimit string `json:"memoryLimit" yaml:"memoryLimit"`
ResourceID string `json:"resource_id" yaml:"resource_id"`
MemoryLimit string `json:"memory_limit" yaml:"memory_limit"`
}
```

3. **Python services:** native `snake_case` attributes via **Pydantic** with `alias_generator=to_camel` + `populate_by_name=True`; serialize `by_alias=True`. Python keeps PEP 8; the wire stays camelCase.
3. **Python services:** native `snake_case` attributes via **Pydantic** — attribute names map **directly** to wire keys, so no alias generator is needed (the camelCase draft required `alias_generator=to_camel`; that is now removed). Python keeps PEP 8 *and* the wire matches it.

4. **Events use the CloudEvents envelope** (CNCF). Event **payload** property keys are `camelCase`. Event **type identifiers / topics** are lowercase **dot notation** — `resource.discovered`, `entity.realized`, `resource.definition.created` — so brokers (Kafka/NATS/etc.) can **wildcard-route** (`resource.definition.*`). Topics are never camelCased.
4. **Events use the CloudEvents envelope** (CNCF). Event **payload** property keys are `snake_case`. Event **type identifiers / topics** are lowercase **dot notation** — `resource.discovered`, `entity.realized`, `resource.definition.created` — so brokers (Kafka/NATS/etc.) can **wildcard-route** (`resource.definition.*`). Topics are an event-naming concern, orthogonal to payload casing. (CloudEvents *context attributes* are themselves flatcase per the CloudEvents spec — neither snake nor camel — and are unaffected.)

5. **No ad-hoc translation.** Frontends, third-party webhooks, and microservices consume the same camelCase shape; serialization config is centralized (Go tags / Pydantic aliasing), not reinvented per service.
5. **No ad-hoc translation.** Frontends, third-party webhooks, and microservices consume the same snake_case shape; serialization config is centralized (Go tags), not reinvented per service. The one place casing changes is an **export adapter** at a foreign-domain boundary (e.g. projecting a resource into a Kubernetes CRD, which is camelCase by convention) — never inside the DCM/UDLM/DAV core.

## Options considered

- **snake_case on the wire** — rejected: forces manual `json:"snake_case"` tags on every Go field anyway, and adds friction for any web/UI/webhook consumer.
- **PascalCase keys** — rejected: legacy CloudFormation idiom only; unnatural for the Go/Python/JS consumers here.
- **camelCase topics** — rejected for event *names*: breaks broker wildcard routing; dot-notation is the broker-friendly idiom.
- **camelCase on the wire** — rejected: conflicts with AEP (the adopted, lint-enforced API standard) and with native-universal UDLM consumption; would re-introduce the translation layer native consumption exists to remove. (This was the first draft; reversed.)
- **PascalCase keys** — rejected: legacy CloudFormation idiom only.
- **camelCase topics** — moot: event names use dot-notation regardless of payload casing, for broker wildcard routing.

## Consequences

- Frontends and third-party webhook consumers ingest payloads directly — no key-translation layer.
- Fewer custom serialization configs across microservices; one convention from API request → event bus → service.
- API specs are AEP-conformant on casing; the `aep-openapi-linter` casing checks pass.
- Python services need **no** Pydantic alias generator — attribute = wire key. Go adds snake_case struct tags (mechanical, centralized).
- Frontends and third-party webhook consumers ingest payloads directly — no key-translation layer; one convention from API request → event bus → service.
- Events are CloudEvents-compliant; dot-notation topics enable wildcard subscriptions.
- Go and Python each keep their native idioms; the boundary mapping is mechanical (struct tags / Pydantic).
- This ADR is the DCM realization of UDLM `naming-conventions.md` §4 — the data-model casing is owned by UDLM; the transport/serialization conventions are owned here.
- This ADR is the DCM realization of UDLM `naming-conventions.md` §4 — the data-model casing is owned by UDLM; the transport/serialization conventions are owned here. Both are now snake_case, so the contract is identity end to end.
2 changes: 1 addition & 1 deletion architecture/adr/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,4 +23,4 @@ Short, reviewable summaries of the major architectural decisions in DCM. Each AD
| [015](015-minimal-infrastructure.md) | Minimal Infrastructure | PostgreSQL is the only required dependency; everything else is optional |
| [016](016-application-definition-language.md) | Application Definition Language | **OPEN** — How should consumers define multi-resource applications? Options under evaluation |
| [017](017-brownfield-greening-discovered-ingestion.md) | Brownfield Greening / Discovered Ingestion | Bring existing resources into the four states — two discovery avenues (provider / 3rd-party), Discovered store holds unclaimed, reverse placement → claim → Realized, optional Intent backport; correlation IDs dedup the same resource across sources |
| [018](018-wire-serialization-event-conventions.md) | Wire Serialization & Event Conventions | camelCase payloads end-to-end (Go json tags / Python Pydantic to_camel); CloudEvents envelope; event topics use lowercase dot-notation for broker wildcard routing — the runtime side of UDLM's data-model casing |
| [018](018-wire-serialization-event-conventions.md) | Wire Serialization & Event Conventions | snake_case payloads end-to-end (AEP-conformant; Go json tags, Python Pydantic native — no alias generator); CloudEvents envelope; event topics use lowercase dot-notation for broker wildcard routing — the runtime side of UDLM's data-model casing |