Architecture Decision Records¶
Only decisions that materially constrain the architecture belong here. Domain rules and terminology live directly in Domain.
Each record states the decision, the reason, and what it costs. The living detail of how a decision is implemented belongs to the architecture pages, not here.
Dates identify when a decision was accepted. An ADR defines the intended constraint; it is not evidence that the implementation is complete.
Index¶
| ADR | Title | Status |
|---|---|---|
| ADR-001 | Brand-as-tenant multi-tenancy | accepted |
| ADR-002 | Modular monolith first | superseded by ADR-007 |
| ADR-003 | Resolve from a local catalog, assemble progressively | accepted |
| ADR-004 | Server-owned consumer state, pseudonymous visitors | accepted |
| ADR-005 | Aggregate before the privacy wall | accepted |
| ADR-006 | Versioned integration-event contract | accepted |
| ADR-007 | Microservices from the start | accepted |
| ADR-008 | Polyglot service stacks by fit | accepted |
| ADR-009 | Single Postgres, schema-per-service roles | accepted |
| ADR-010 | Kafka for asynchronous facts, JSON Schema contracts | accepted |
| ADR-011 | Delegate authentication to Keycloak | accepted |
| ADR-012 | Services own and apply their migrations | accepted |
| ADR-013 | GS1 anchoring, incremental resolver conformance, strict GTIN, own SDK | accepted |
| ADR-014 | AWS deployment: single EC2 box, compose, Terraform | accepted |
| ADR-015 | Community provenance for external products | accepted |
| ADR-016 | Shopping List as its own context and service | accepted |
| ADR-017 | Major version marks a project phase | accepted |
| ADR-018 | Refined hexagonal layer split | superseded by ADR-022 (TS) and ADR-024 (Go); edge-service exception remains |
| ADR-019 | RuleSet loaded through a port, default-safe | accepted |
| ADR-020 | Personalization owns the selectable vocabulary | accepted |
| ADR-021 | Observability package; Prometheus + Grafana baseline | accepted |
| ADR-022 | TS services: domain, application and adapter layers | accepted, supersedes ADR-018 (TS) |
| ADR-023 | Producer-owned catalogs stay external | accepted |
| ADR-024 | Go services: one core package with direction-grouped adapters | accepted, supersedes ADR-018 (Go) |
| ADR-025 | Measurement pipeline aggregation | accepted |
| ADR-026 | Brand identity via Keycloak claim; separate SPA | accepted |
| ADR-027 | Local Kubernetes deployment (kind, Kustomize) | accepted |
| ADR-028 | One architectural style, two idiomatic layouts | accepted, clarifies ADR-008/022/024 |
ADR-001: Brand-as-tenant multi-tenancy¶
Status: accepted · Date: 2026-06-11
Decision: Use one shared, multi-tenant product deployment rather than a deployment per Brand. The database topology is the shared Postgres instance with isolated service schemas defined by ADR-009. Every brand-owned record carries brand_id; repository/query APIs enforce tenant scope. GTIN ownership is an explicit producer-catalog mapping under ADR-023. A Brand User belongs to exactly one Brand.
Why: It provides testable Brand isolation and low-overhead onboarding without multiplying infrastructure.
Trade-off: A tenant-scoping defect can leak data, so isolation requires central tests.
ADR-002: Modular monolith first¶
Status: superseded by ADR-007 · Date: 2026-06-11
Decision: Deploy the MVP as a modular monolith. Product Passport, Personalization & Verdict, Fridge, Identity & Consent, and Brand Analytics are code and ownership boundaries, not independently deployed services.
Why: The budget, team size and iteration speed do not justify microservices.
Trade-off: The roadmap may evolve modules into services later; boundaries must remain explicit enough to permit that extraction.
ADR-003: Resolve from a local catalog, assemble progressively¶
Status: accepted · Date: 2026-06-11
Decision: PackyTrace implements the GS1 Digital Link Resolver. A scan resolves a per-GTIN ProductCatalogEntry, while lot, serial and expiry remain per-item ScannedItem data. ResolvedPassport progressively combines the catalog entry with independent external sections, each behind its own ACL adapter and a section-local timeout that fails that one tab; the Open Food Facts source adds a circuit breaker. A catalog miss falls back to Open Food Facts, cached with a TTL and marked community provenance (ADR-015).
Why: Local catalog data keeps identity and Verdict computation reliable while external failures degrade individual sections instead of the whole scan.
Here, “progressively” describes independently modeled section outcomes. Section resolution completes within the request; this decision does not introduce client polling.
Trade-off: Catalog data must be cached and kept fresh.
ADR-004: Server-owned consumer state with pseudonymous visitors¶
Status: accepted · Date: 2026-06-11
Decision: Create a stable pseudonymous Visitor ID at first scan and link it when an Account is created. Health Profiles, consent records and Fridges are server-owned. Consent revocation, profile deletion and account deletion remain distinct operations; account deletion nullifies Account references on retained anonymous ScanRecords.
Why: This supports measurement, re-screening, expiry alerts and verifiable erasure without exposing health data in scan requests.
Trade-off: Visitor IDs undercount across cleared storage, devices and shared devices.
ADR-005: Aggregate before the privacy wall¶
Status: accepted · Date: 2026-06-11
Decision: Raw scan, Verdict, Fridge-save and account-link facts remain consumer-side. Only minimum-group-size BrandMetricBatchPublished aggregates cross into Brand Analytics.
Why: Brands need engagement metrics, but no per-scan or per-visitor trail may cross the privacy wall.
Trade-off: Small groups and real-time individual events cannot appear in dashboards.
ADR-006: Versioned integration-event contract¶
Status: accepted · Date: 2026-06-11
Decision: Integration events published between services use a common envelope with event ID, type, occurrence time, schema version, correlation ID and causation ID. Payloads contain domain reason codes, not localized presentation messages.
Why: Versioning and tracing are required for reliable asynchronous workflows.
Trade-off: Producers and consumers must maintain schema compatibility.
ADR-007: Microservices from the start¶
Status: accepted · supersedes ADR-002 · Date: 2026-06-11
Decision: Deploy the services independently from day one: the bounded-context services plus api-gateway and measurement-pipeline. Build the target topology directly, with no intermediate monolith phase. The fleet started at seven services and reached eight with ADR-016.
Why: The target topology requires independent deployment, Kafka integration and explicit service ownership. Building that topology directly exercises those boundaries from the first commit.
Trade-off: More operational surface for a solo developer, and slower MVP iteration than inside a single deployable.
ADR-008: Polyglot service stacks by fit¶
Status: accepted · Date: 2026-06-11
Decision: Each service uses the language that fits its job. Go (chi, pgx + sqlc, franz-go) takes proxying, external-source resilience, event-sourcing folds and Kafka throughput: api-gateway, passport-service, fridge-service, shopping-list-service and measurement-pipeline. TypeScript (Fastify, Kysely, Confluent Kafka client) takes the auth ecosystem, fast-iterating rule policies and dashboard-shaped queries: identity-service, personalization-service and brand-analytics-service. Every service uses the same ports-and-adapters layout, in the per-language form of ADR-022 and ADR-024.
Why: The mapping follows the repository's existing libraries, runtime profiles and team experience: Go is used for the concurrent proxy, source-adapter and stream-processing paths; TypeScript is used for the Keycloak integration, policy model and dashboard query service.
Trade-off: Two toolchains to maintain, and cross-cutting plumbing (config, logging, metrics) is implemented twice.
ADR-009: Single Postgres, schema-per-service with per-service roles¶
Status: accepted · Date: 2026-06-11
Decision: One Postgres instance with a schema and a GRANT-restricted role per data-owning service, currently seven: passport, personalization, fridge, shopping_list, identity, measurement and brand_analytics. No cross-schema access.
Why: A single infrastructure piece keeps local and deployed environments simple, while per-service roles make data ownership enforced by the database rather than by discipline.
Trade-off: A shared instance couples availability: if Postgres is down, every stateful service is down.
ADR-010: Kafka for asynchronous facts, JSON Schema contracts¶
Status: accepted · Date: 2026-06-11
Decision: Run Apache Kafka (KRaft, single node) carrying asynchronous integration facts
only. Immediate request/response interactions stay on synchronous internal REST, declared
per service in 'services/
Why: Independently deployed services need a broker, and JSON Schema gives contract governance across two languages without a schema registry or entity coupling.
Trade-off: Contract discipline lives in CI checks rather than a registry.
ADR-011: Delegate authentication to Keycloak¶
Status: accepted · Date: 2026-06-11
Decision: Authentication is delegated to a self-hosted Keycloak (OIDC). The api-gateway validates Keycloak-issued tokens; identity-service keeps only the domain parts: Visitor identities, visitor-to-account linking, the consent ledger, and Brand/BrandUser. No service stores passwords, issues tokens or handles sessions.
Why: Identity is a generic subdomain, and health-adjacent data must not ride on hand-rolled password auth. Password reset, refresh rotation, token revocation and session handling come free from a hardened provider.
Trade-off: A heavyweight JVM container joins the fleet and its configuration must be versioned alongside the code.
ADR-012: Services own and apply their migrations at startup¶
Status: accepted · Date: 2026-06-13
Decision: Each service carries its schema migrations in its own directory and applies them at startup, connecting as its own database role. Go services embed plain SQL and run goose as a library; TypeScript services use Kysely's Migrator. Generated data access (sqlc) reads the same SQL files as its schema source.
Why: No extra containers or cross-language tooling, and a service and its schema deploy as one unit, preserving exclusive data ownership.
Trade-off: Concurrent replicas of one data-bearing service can race on startup migration. Before such a service is allowed to run more than one replica, deployment must serialize migration with a dedicated job or database lock and record the chosen mechanism. There is no central migration audit across services.
ADR-013: GS1 anchoring, incremental resolver conformance, strict GTIN, own SDK¶
Status: accepted · Date: 2026-06-13
Decision: Refine ADR-003 with an incremental resolver scope. The accepted first increment parses the Digital Link and 302-redirects to the product page carrying the scanned qualifiers; it must not be described as a GS1-Conformant Resolver. A later increment adds linkType negotiation, the /.well-known/gs1resolver description and linkset responses is defined by the GS1-Conformant Resolver Standard and is an additive step rather than a rewrite: the SDK already names the default gs1:pip link type, and the resolver serves that one target. GTIN handling is strict: the canonical key is a check-digit-validated, zero-padded GTIN-14, and an invalid check digit is rejected rather than silently mis-resolved. Canonical AIs are 01, 10, 21 and 17. All parsing and validation lives in a standalone GS1 Digital Link SDK with parity Go and TypeScript implementations over one golden vector corpus, and zero PackyTrace domain concepts.
Why: Correct URI parsing and a credible path to interoperability are required even though full resolver conformance is outside the initial scope. A generic SDK keeps the standard logic in one tested place across both languages and prevents parser drift. Being generic GS1 logic rather than PackyTrace code, it is an ordinary dependency under ADR-010 and not cross-service sharing, a boundary that holds only while it stays free of domain concepts.
Trade-off: Go and TypeScript parity rests on the shared corpus rather than one binary, and full resolver conformance plus the open-source extraction are deferred.
ADR-014: AWS deployment, single EC2 box, compose, Terraform¶
Status: accepted · Date: 2026-06-16
Decision: Deploy the container stack onto one EC2 instance running Docker Compose, provisioned with Terraform under deployment/aws. Postgres, the broker and Keycloak run as containers on the box rather than RDS and MSK, while preserving ADR-009 role isolation with passwords from SSM. Redpanda replaces Apache Kafka in the deployed stack only, so the broker fits the box's RAM, with no contract change. Caddy is the only public surface: it terminates TLS, serves the SPA and proxies the API, while every service and datastore stays internal. Images are built in CI and pushed to GHCR; shell access is SSM Session Manager, and data persists on a separate EBS volume so the instance is replaceable.
Why: A single box was selected for the constrained demonstration budget while preserving the service, schema-isolation and privacy boundaries. HTTPS is required for browser camera access. Terraform makes the footprint reproducible and the instance replaceable.
Trade-off: There is no high availability: the box is a single point of failure and replacement costs a cold start plus certificate re-issue. This is a demonstration topology, not a production-readiness claim. It relaxes no service boundary or privacy rule; tested backup and restore are prerequisites for production use.
ADR-015: Community provenance for externally-sourced products¶
Status: accepted · Date: 2026-06-18
Decision: A ProductCatalogEntry carries a provenance of brand (authoritative) or open_food_facts (community, unverified). The read-through cache writes only community rows and never overwrites a brand-owned GTIN, so an onboarded Brand always wins. Community entries are attributed to a reserved CommunityBrandID sentinel that is not a tenant Brand, so no onboarded Brand can query their scans. The Measurement Pipeline must reject that sentinel before producing BrandMetricBatchPublished; a community scan must not become a Brand metric. Community data still feeds the Verdict, with LabelVerified = false disclosed to the client and unmappable allergen tags dropped rather than guessed.
Why: It lets physical barcodes resolve community-sourced products without diluting brand-owned trust, keeping ADR-001 isolation and the ADR-005 privacy wall intact and the Verdict clear about provenance.
Trade-off: Community data quality varies. The sentinel filter is a privacy-boundary rule, so acceptance requires a negative test proving that no published batch or retained Brand counter uses CommunityBrandID.
ADR-016: Shopping List as its own context and service¶
Status: accepted · Date: 2026-06-19
Decision: Shopping List becomes its own bounded context and an eighth independently deployed service, in Go like fridge-service because the job is the same shape, owning a new shopping_list schema and role. It is event-sourced: an append-only stream is the source of truth with a projection maintained in the same transaction. Items are product-linked with a quantity, referencing a validated GTIN plus a trusted product snapshot from passport-service, never free text from the client. The fridge-to-list loop is client-orchestrated through the gateway, so neither service writes the other's schema or calls it directly. A list is not health data, so ConsentRevoked leaves it untouched and only AccountDeleted erases it, an erasure that waits on the account-deletion flow defined by ADR-004.
Why: A standalone context keeps "wanted" and "owned" cleanly separated and independently deployable, and the client-orchestrated loop keeps the ADR-009 and ADR-010 boundaries intact instead of inventing cross-service writes.
Trade-off: An eighth service and a seventh schema to operate, and each loop crossing costs a client round-trip rather than an in-process call.
ADR-017: Major version marks a project phase, not an API contract break¶
Status: accepted · Date: 2026-06-25
Decision: The major version may mark an approved project-phase boundary even when no consumer contract changed, a sanctioned departure from strict SemVer justified because the fleet ships as one deployed unit with no external API consumers. An incompatible contract change still gets its own ADR with a migration path; a phase bump does not substitute for that record. The bump is produced the normal way, as a feat!: commit whose BREAKING CHANGE: footer names the phase boundary rather than a contract.
Why: It keeps the release pipeline mechanical while recording once that a major bump may mean "new phase", so future readers do not hunt for a migration that does not exist.
Trade-off: The major digit no longer carries strict SemVer meaning, so anyone who later exposes a public API from this repository must re-establish a stricter policy then.
ADR-018: Refine the hexagonal layer split: driven vs driving adapters, ports in their own packages¶
Status: superseded by ADR-022 for the TypeScript services and ADR-024 for the Go services; the edge-service exception still applies · refines ADR-008 · Date: 2026-06-26
Decision: Split each service at the top into business_logic/ and infrastructure/, group adapters by call direction (inbound_adapters for the driving side, outbound_adapters for the driven side), move the port interfaces into their own inbound_ports and outbound_ports packages, and extract tests into a sibling tests/ tree. passport-service was the pilot.
The following edge-service exception survives: api-gateway is exempt because it is the public edge, not a bounded context. It holds no domain and no use cases, so it stays a single internal/gateway package with one file per upstream, plus internal/config, and carries no depguard rule set. Cross-service imports, the only boundary that matters for an edge, are already covered by make check-boundaries.
Why: Naming the driven and driving split makes call direction explicit at the folder level, and own-package ports put each contract beside the entities it speaks in.
Trade-off: The interface-only packages and the two-segment path prefix made services harder to navigate, and the tests/ tree fights Go's colocated _test.go idiom. Both service fleets replaced this layout within days: TypeScript in ADR-022, Go in ADR-024. The two SvelteKit clients still use it, since neither successor ADR covers frontend code.
ADR-019: Personalization RuleSet is loaded through a port, config-overridable, default-safe¶
Status: accepted · refines ADR-008 · Date: 2026-06-26
Decision: Make the RuleSet first-class data rather than module constants, and source it through a driven port. The domain holds the typed LabelCheck, GoalBand and RuleSet shapes plus a canonical DEFAULT_RULE_SET, and the rules module becomes a pure interpreter of a passed-in set that stamps its version as the verdict's ruleVersion. A RuleSetProvider port supplies the active set, and its config adapter resolves it once at startup: with no configured path it serves the default, and with one it reads, parses and validates the JSON, falling back to the default on any problem. A rejected override emits a warning that names the configured path; a valid override logs its active RuleSet version.
Why: The typed shapes keep the "typed codes and constants, never free text" invariant structurally, since an override cannot express a rule the model cannot evaluate. Loading behind a port keeps the domain free of filesystem and JSON concerns. The built-in RuleSet is a versioned, tested policy baseline, so rejecting an invalid override and using that baseline is safer than serving no verdict. The warning prevents the fallback from being silent.
Trade-off: The RuleSet reloads only at boot, so tuning is a restart, and the check vocabulary is intentionally narrow: a policy needing data the platform does not ingest still needs a code change.
ADR-020: Personalization owns the selectable vocabulary and serves it with localized labels¶
Status: accepted · refines ADR-019 · Date: 2026-06-26
Decision: personalization-service owns the selectable goal, condition, allergen and diet vocabulary as a domain catalog with per-locale text, versioned alongside the RuleSet and unit-tested to cover its keys. It serves them from a public GET /api/v1/personalization/vocabulary endpoint that negotiates locale from Accept-Language and returns localized labels. The client fetches and caches the result behind a driven port, and its hardcoded copy is deleted.
Why: The lock-in was a copy of policy data, not a localization concern, so the cure is single ownership at the service that already owns the RuleSet. Serving localized labels keeps every client a thin renderer, so adding a client never means re-translating the vocabulary. This is a scoped exception to ADR-006: events and verdict reasons still travel as codes, and only this synchronous catalog serves text.
Trade-off: If the vocabulary request fails, the profile pickers have no selectable options; a client-side policy copy would create two owners. The client must therefore show the unavailable state and offer a retry.
ADR-021: Observability is a shared infrastructure package; Prometheus + Grafana baseline; readiness split from liveness¶
Status: accepted · Date: 2026-06-27
Decision: Prometheus is the metrics baseline, with Grafana for the SLO and QAS dashboards. Services expose GET /metrics with fleet-wide request counters, durations and in-flight gauges, plus scan, external-source, circuit-breaker, verdict and erasure metrics where they apply. GET /ready answers "should this instance receive traffic now" with Postgres and Kafka checks, separate from the lightweight GET /health liveness probe, and excludes external food APIs so a degraded scan cannot drain a service. The Go instrumentation is a shared workspace package consumed like the GS1 SDK; the TypeScript counterpart is a small per-service copy, because those images are self-contained and ADR-010 keeps services from sharing non-contract code at runtime. Labels are bounded to service, method, route template, status_class, outcome, source and section, never an identifier, GTIN, token or health-profile field.
Why: Adding metrics once unblocks the Application Metrics pattern, the QAS evidence and the SLIs behind the SLOs together. Instrumenting at the adapter and middleware seam preserves the layering the codebase already enforces, and bounded labels keep per-scan and per-visitor facts out of telemetry (ADR-005).
Trade-off: The TypeScript instrumentation is duplicated rather than shared, accepted to avoid build surgery and an ADR-010 exception. In production Grafana and Prometheus are exposed at the edge on their own subdomains behind authentication, so the original "internal only" stance holds for the local stack but not for the deployed one. The living reference is the Observability page.
ADR-022: TypeScript services use domain, application and adapter layers with an explicit composition root¶
Status: accepted · supersedes ADR-018 for the TypeScript services · Date: 2026-06-28
Decision: All three TypeScript services use three layer roots directly under src/, plus config/: domain/ depends on nothing, application/ holds the use cases and the ports they speak through, and adapters/ holds everything touching the outside world, split into inbound/ and outbound/. Driven ports sit one per file in application/ports/; driving ports are co-located with the use case that owns them, so a use-case file exports both its request type and the function that runs it. composition-root.ts is the only module that names concrete adapters, and server.ts is a thin entrypoint. Dependencies point inward, enforced by per-path ESLint no-restricted-imports blocks. Tests mirror the layers in a sibling tests/ tree, black-box through each module's public API.
Why: The folder names state each layer's responsibility, so the tree shows the dependency direction without a legend. One file per port makes each driven contract a named module rather than a barrel entry, and a named composition root makes adapter selection explicit in one place adapters are chosen. Scoping this to TypeScript lets the Go fleet use its own idiom.
Trade-off: The repository is bilingual about layout, so eslint.config.js and .golangci.yml describe two shapes. Letting the inbound adapter import application is looser than ADR-018's rule; the composition-root pattern, not the linter, keeps the router from reaching past the ports.
ADR-023: Producer-owned catalogs stay external; source-specific caches preserve privacy and resilience¶
Status: accepted · refines ADR-001, ADR-003, and ADR-015 · Date: 2026-06-28
Decision: Brand-owned product data is externally authoritative and never persisted in the PackyTrace database. Producers expose a versioned catalog snapshot that passport-service validates and atomically swaps into an in-memory registry; lookups read producer memory first, then the community cache. Synchronization is independent of consumer scans, fetched at startup and periodically with revalidation, so a consumer request never triggers a producer call and the producer cannot reconstruct scan timing or visitor IPs. A failed refresh keeps the last good snapshot. Supply-chain traces are lot-scoped on {gtin, lot}. Open Food Facts keeps its persistent database cache with a configurable TTL and circuit breaker, where a 404 is a healthy miss while timeouts, 5xx and malformed responses count toward opening. Demo producers are external simulators, not PackyTrace bounded contexts, and never receive consumer identity.
Why: Producer ownership becomes visible at the deployment and data boundaries without giving producers a side channel into raw engagement. Two different caches make two different resilience stories concrete: producer data disappears on a cold start, community data shows stale serving and a configured circuit breaker.
Trade-off: Availability right after a cold start depends on at least one successful producer sync, search is limited to loaded snapshots plus cached community rows, and the composite repository adds more wiring than one SQL repository.
ADR-024: Go services use one core package with direction-grouped adapters¶
Status: accepted · supersedes ADR-018 for the Go services · Date: 2026-06-29
Decision: Every Go bounded-context service is a cmd/server composition root plus one
internal/ tree. A single core package 'internal/
Why: One core package keeps each service cohesive and follows Go's convention that an interface belongs to its consumer. The inbound and outbound asymmetry is intentional: an outbound port is a contract the core declares but does not implement, so an interface is needed to invert the dependency, whereas an inbound contract is one the core already implements, so wrapping it inverts nothing and lets nothing new plug in.
Trade-off: A large core package is less granular than separate domain and application packages, and the direction folders add a level to every adapter path. The port asymmetry has to be understood before the layout reads as intentional.
ADR-025: Measurement pipeline aggregation: windowed Postgres counters, eventId dedupe, minimum group size at the wall¶
Status: accepted · realizes ADR-005 · Date: 2026-06-30
Decision: measurement-pipeline runs a consume, aggregate, gate, produce loop. It consumes the fact topics that have a projector through a consumer group, decoding the envelope into a technology-neutral Fact that a projector registry maps to metric increments. A new count metric needs only a registry entry when an existing fact already carries all required dimensions; a new fact or dimension still requires a versioned contract change. It aggregates into durable windowed Postgres counters keyed by period, brand, metric and dimensions, in tumbling windows derived from occurredAt. A processed_event ledger makes ingest idempotent in one transaction, so redelivery never double-counts. The flush loop publishes only closed-window groups at or above the minimum group size, enforced in the query and re-checked in the domain, and marks a group published only after broker ack.
Why: Postgres counters make aggregation restart-safe and inspectable without a stream-processing runtime, the dedupe ledger makes correctness independent of delivery guarantees, and enforcing the threshold twice makes the privacy wall a property of the data path rather than a convention.
Trade-off: Tumbling windows are coarser than calendar weeks, and the counter and ledger tables grow until a retention job prunes them.
ADR-026: Brand identity via a Keycloak brand_id token claim; the dashboard is a separate SPA at brands.$DOMAIN¶
Status: accepted · refines ADR-001, ADR-011 · Date: 2026-06-30
Decision: Brand identity is a brand_id claim on the Keycloak token. Brand users carry a brand_user realm role and a brand_id attribute that a protocol mapper copies into the access token. The gateway validates the token, extracts the claim into X-Brand-Id after stripping any client-supplied value, and guards '/api/v1/brand/'. brand-analytics-service* scopes every query by that header, so the tenant scope is never derived from request data. Brand login reuses the existing session flow. The dashboard is a separate static SvelteKit app served at its own subdomain rather than a route group in the consumer SPA, because that app serves one fallback identically on every host.
Why: A token claim is the rule-compliant way to carry tenant scope, since Keycloak owns identity and the gateway already had the extraction seam. A separate host and build isolates the brand desktop UI from the mobile consumer app.
Trade-off: A second frontend image, another Caddy block and a manual DNS record. The demonstration session endpoint may use the Resource Owner Password Credentials flow and browser token storage only in the demonstration environment. Before production, Brand login must move to Authorization Code with PKCE (or a backend-for-frontend session), and seeded Brand users must be removed.
ADR-027: Local Kubernetes deployment: kind, Kustomize, single-replica in-cluster infrastructure¶
Status: accepted · complements ADR-014 · Date: 2026-07-02
Decision: A third deployment track under deployment/k8s, alongside Compose for development and the EC2 demonstration deployment, replacing neither. kind is the target cluster, reproducible on any Docker host and able to load local images without a registry. Manifests are Kustomize, plain YAML in base/ with a thin kind overlay, generating ConfigMaps from the compose deployment's own files so there is one source of truth. Postgres, Kafka and Keycloak run in-cluster as single-replica workloads with PVCs rather than operators, and the compose one-shot containers become Jobs. Every pod carries liveness and readiness probes, resource requests, non-root security context and per-service database secrets, with a CPU-based autoscaler on the scan path and two gateway replicas.
Why: kind plus plain manifests keeps the deliverable reproducible and readable, single-replica in-cluster infrastructure keeps parity with compose without operator machinery, and the autoscaler on the scan path ties scaling to the SLO work.
Trade-off: This local kind topology is not highly available: it has one broker, one Postgres and committed development-only credentials that must never be reused for production. The passport autoscaler scales on CPU rather than the scan-latency SLI, and scaling beyond one replica is unsafe until startup migrations are serialized as required by ADR-012. Network policies are omitted because kind's default CNI does not enforce them.
ADR-028: One architectural style, two idiomatic layouts¶
Status: accepted · clarifies ADR-008, ADR-022 and ADR-024 · Date: 2026-07-31
Decision: The whole fleet uses ports and adapters (hexagonal), in both languages. ADR-022 and ADR-024 are two idiomatic packagings of that one style, not two styles: in either language, dependencies point inward and the core imports no adapter or infrastructure, the core declares the driven ports it does not implement, adapters are grouped by call direction and the two directions never import each other, one composition root names the concrete adapters, and the boundaries are linted rather than trusted. Two differences are intentional. Go collapses entities and use cases into one core package while TypeScript keeps domain/ and application/ apart, which is the separation Clean Architecture draws on top of ports and adapters. And Go reifies no driving port while TypeScript co-locates one with each use case. Documentation describes the fleet as ports and adapters and never calls one language hexagonal and the other Clean; Clean's vocabulary may be used as a lens on either.
Why: One named style makes the dependency rule reviewable across both languages. Recording the two intended layout differences prevents incidental differences from being mistaken for architecture policy.
Trade-off: The two trees still do not look alike and two lint configs still describe two shapes, so the shared name does not remove the navigation cost, and only the linters keep the layouts from drifting.