eks-gitops-demo ← PlatformCode architecture →DecidedApp domainGraphQLIdentityObservabilityGuardrailsPhased plan

App, identity and observability

Companion to the platform page and the code architecture page: what the app does, who can log in to what, which GraphQL features it shows, how it is observed with OpenTelemetry, and the build order. Prepared 2026-10-16. Phase 1 is built (the API, service kit, playground, CI and bootstrap infrastructure); later phases are design.

Decisions recorded 2026-10-16

1. App domain: a support-funding service

A participant receives a plan with a budget per support category. The participant (or a nominee acting for them) raises a support request: category, description, estimated amount and optionally a preferred provider. Agency staff approve or reject it with a reason; approval reserves part of the budget as a service booking for a provider. The provider delivers the service and claims payment against the booking. The plan's management type decides who may claim. A claim is a payment request against a budget, not an insurance claim.

Entities: a participant has plans and raises support requests against a plan; a nominee acts for a participant; a plan has budgets per category and claims; agency staff decide support requests and approve claims; a provider holds service bookings that reserve budget and submits claims. Participantscheme number · DOB masked Nomineeacts for a participant Planstatus · dates · management SupportRequestcategory · estimate · status SupportBudgetper category: allocated, spent Claimamount · status · retry-safe ServiceBookingreserves part of a budget Agency staffdecides requests, claims Providerdelivers, then claims has 1..n acts for raises against per category claims reserves holds submits approves decides (never their own)

Grey = people and organisations (also the callers); blue = records. Approving a support request creates the service booking. Every read and write passes the relationship checks in the use cases: nominee delegation, provider booking, plan management type, and separation of duties.

ConceptNameNote
Participant numberschemeNumberLater deprecated in favour of memberNumber to show schema evolution
Staff roleAGENCY_STAFF in the GraphQL enum; agency_staff as the realm role in tokens
Support categoriesDAILY_LIVING / THERAPY / EQUIPMENT
Realm, moduleRealm demo, module eks-gitops-demoIssuer https://auth.frankbluemind.com/realms/demo
Support requestSupportRequest with SUBMITTED / APPROVED / REJECTEDThe request → decision → booking → claim workflow
Plan managementA management type on each planA real authorization rule: who may submit claims
Service bookingServiceBooking as a first-class typeCreated when a support request is approved

2. GraphQL showcase

FeatureSchema elementStatusWhy it matters
Custom scalarsDate, MoneyalreadyValidation at the edge; no float money
Directive-based auth@auth(requires: [...]) on mutationsalreadyCoarse role gate declared in the contract
Nested resolvers + field maskingdateOfBirth null for providers; only booked budgetsalreadyAuthorization on every path, not just the entry query
Relay cursor paginationClaimConnection, PageInfoalreadyStable paging for portal and mobile lists
Union payloads (errors as data)SubmitClaimPayload = Claim | ValidationError | BudgetExceededErroralreadyClients branch on typed outcomes, not error strings
Workflow with a domain-rule errordecideSupportRequest → SupportRequest | SelfApprovalNotAllowedError | ValidationErrorbuiltA business rule (separation of duties) reaches the client as a typed outcome
Interfaces + global lookupinterface Node, node(id:), nodes(ids:) (at most 50)built (Phase 5)Client caches (Relay, Apollo) refetch any object by id. node reads through the same use cases as the typed queries: an id the caller may not read is null, exactly like participant(id:), and masked fields stay masked
Shared interface across typesinterface Party { id displayName } (Participant, Provider)built (Phase 5)Polymorphic queries with fragments
Union searchsearch(term, first) → [Participant | Provider | Claim]built (Phase 5)One endpoint, many result types, still authorized per item: each type is searched inside the caller's scope. The term (2–100 characters) is a bind parameter and %/_ match literally; at most 50 results
Filter and sort inputsclaims(filter: ClaimFilter, orderBy: ClaimOrder), also on Plan.claimsbuilt (Phase 5)Typed query options instead of ad-hoc arguments. Keyset pagination in every order (submitted time or amount, ascending or descending, id as tie-break); the cursor records its order, and a cursor from another order is BAD_USER_INPUT
Error interfaceinterface UserError { message path } on ValidationError, BudgetExceededError, SelfApprovalNotAllowedError, IdempotencyKeyReusedErrorbuilt (Phase 5)Generic error rendering in clients; the payload unions keep their members
Idempotent mutationsubmitClaim(input: { idempotencyKey })built (Phase 5)A client retry can never pay twice: the same key and input return the first claim; the same key with a different input is IdempotencyKeyReusedError. Keys are per user (primary key (subject, key), stored in the claim's transaction) and kept; concurrent retries store one claim
DataLoader batchingclaims → provider, plan → budgetsbuiltRemoves the N+1 query problem; visible in traces
SubscriptionssupportRequestStatusChanged(planId), claimStatusChanged(planId) over WebSocket (graphql-transport-ws), fanned out with Postgres LISTEN/NOTIFYbuilt (Phase 6)The requester sees the decision live, whichever pod handled it; the ALB carries WebSockets. The bearer token goes in connection_init (invalid: close 4401; client not allowed: 4403); subscribing needs read access to the plan, and every update is re-read with the subscriber's access. At most 5 subscriptions per connection, a ping every 25 s (the ALB drops connections idle for 60 s), and the connection ends when its access token expires. Only subscriptions go over the socket; queries and mutations stay on POST, where the rate and time limits apply.
Schema evolutionParticipant.schemeNumber @deprecated(reason: "Use memberNumber; removal after 2026-12-31")built (Phase 5)Versionless API lifecycle: add, deprecate, measure use (graphql_deprecated_field_usage_total{field}), remove. A CI check flags a pull request that breaks the schema unless it is labelled breaking-change-approved; advisory, since the free plan cannot require checks on a private repository (docs/schema-changelog.md)
HardeningDepth and complexity limits; persisted queries: APQ (extensions.persistedQuery.sha256Hash) in the demo, a trusted-documents allowlist (PERSISTED_QUERIES=allowlist, persisted/queries.json) for first-party clients; introspection off outside dev; a strict Content-Security-Policy on every pagebuilt (Phase 7)Protects a public GraphQL endpoint from expensive or arbitrary queries; mobile clients send small hashes; pages run no inline code. Checked in Chrome: PKCE login and a GraphiQL query with 0 CSP violations
Integration adapterapproveClaim pays the claim through payments-svc with demo-api's own client-credentials token (demo-api-svc), the claim id as idempotency key; Claim.payment { status ref } is PENDING, PAID or FAILEDbuilt (Phase 6)The API as a consistent integration layer over back-end systems. The approval commits first; a failed payment leaves it standing (FAILED, audited as payment_failed). Two 2 s attempts with capped backoff (honouring Retry-After), one token refresh on a 401, and a circuit breaker that only unavailability can open. A replayed approval is refused before any payment, and concurrent approvals pay once (a conditional status update decides the winner).
Try the showcase (in the playground)
# any object by id, through a shared interface
{ node(id: "P1") { __typename id ... on Party { displayName } ... on Participant { memberNumber } } }
{ nodes(ids: ["PR1", "PL1", "C1"]) { __typename id } }

# one search, three result types, each inside what the caller may read
{ search(term: "sun") { __typename ... on Party { id displayName } ... on Claim { id status amount } } }

# filtered and ordered keyset pagination: pass pageInfo.endCursor as after:
{ claims(first: 2, filter: { status: SUBMITTED }, orderBy: { field: AMOUNT, direction: DESC }) {
    totalCount edges { node { id amount submittedAt } } pageInfo { hasNextPage endCursor } } }

# an idempotent retry (as riley.provider): run it twice, get the same claim id
mutation { submitClaim(input: { planId: "PL1", category: DAILY_LIVING, amount: "12.00",
    serviceDate: "2026-04-20", idempotencyKey: "try-1" }) {
  __typename ... on Claim { id } ... on UserError { message path } } }
SDL sketch of the new types (the design; the built schema is internal/adapter/graphql/schema/schema.graphqls)
interface Node { id: ID! }
interface Party { id: ID!  displayName: String! }
interface UserError { message: String!  path: [String!] }

enum PlanManagement { AGENCY PLAN_MANAGED SELF }
enum SupportCategory { DAILY_LIVING THERAPY EQUIPMENT }
enum SupportRequestStatus { SUBMITTED APPROVED REJECTED }
enum OrderDirection { ASC DESC }
enum ClaimOrderField { SUBMITTED_AT AMOUNT }

type Participant implements Node & Party {
  id: ID!
  displayName: String!
  memberNumber: String!
  schemeNumber: String! @deprecated(reason: "Use memberNumber; removal after 2026-12-31")
  dateOfBirth: Date            # null unless the caller may see it
  plans: [Plan!]!
}
type Provider implements Node & Party { id: ID!  displayName: String!  bookings: [ServiceBooking!]! }
type ServiceBooking implements Node {
  id: ID!  plan: Plan!  provider: Provider!  category: SupportCategory!  reserved: Money!  remaining: Money!
}
type SupportRequest implements Node {
  id: ID!  plan: Plan!  category: SupportCategory!  description: String!
  estimatedAmount: Money!  preferredProvider: Provider
  status: SupportRequestStatus!  decisionReason: String
  booking: ServiceBooking      # set when approved
}

union SearchResult = Participant | Provider | Claim

input ClaimFilter {           # submittedAfter is inclusive, submittedBefore exclusive (RFC 3339)
  status: ClaimStatus  category: SupportCategory  providerId: ID  submittedAfter: String  submittedBefore: String
}
input ClaimOrder { field: ClaimOrderField! = SUBMITTED_AT  direction: OrderDirection! = ASC }
input SubmitSupportRequestInput {
  planId: ID!  category: SupportCategory!  description: String!  estimatedAmount: Money!  preferredProviderId: ID
}
input DecideSupportRequestInput { id: ID!  approve: Boolean!  reason: String!  providerId: ID }
input SubmitClaimInput {
  bookingId: ID!  amount: Money!  serviceDate: Date!
  idempotencyKey: String       # optional; same key + same input → the same Claim, never a second payment
}

type ValidationError implements UserError { field: String!  message: String!  path: [String!] }
type BudgetExceededError implements UserError { message: String!  path: [String!]  remaining: Money!  requested: Money! }
type SelfApprovalNotAllowedError implements UserError { message: String!  path: [String!] }
type IdempotencyKeyReusedError implements UserError { message: String!  path: [String!] }

union SubmitSupportRequestPayload = SupportRequest | ValidationError | BudgetExceededError
union DecideSupportRequestPayload = SupportRequest | SelfApprovalNotAllowedError | ValidationError
union SubmitClaimPayload = Claim | ValidationError | BudgetExceededError | IdempotencyKeyReusedError

type Query {
  node(id: ID!): Node
  nodes(ids: [ID!]!): [Node]!
  search(term: String!, first: Int = 10): [SearchResult!]!
  claims(filter: ClaimFilter, orderBy: ClaimOrder, first: Int = 20, after: String): ClaimConnection!
}
type Mutation {
  submitSupportRequest(input: SubmitSupportRequestInput!): SubmitSupportRequestPayload! @auth(requires: [PARTICIPANT, NOMINEE])
  decideSupportRequest(input: DecideSupportRequestInput!): DecideSupportRequestPayload! @auth(requires: [AGENCY_STAFF])
  submitClaim(input: SubmitClaimInput!): SubmitClaimPayload! @auth(requires: [PROVIDER])
}
type Subscription {
  supportRequestStatusChanged(planId: ID!): SupportRequest!
  claimStatusChanged(planId: ID!): Claim!
}

Plan, Claim and the connection types also implement Node; existing types are unchanged apart from the renames. The self-approval check is a domain rule (see the code page); the @auth directive only gates the role.

3. Identity and access

Public host (one ALB, *.eksgitops.click cert)ServesLogin
app.frankbluemind.comSPA + /graphql (demo-api)Keycloak, Authorization Code + PKCE
argocd.frankbluemind.comArgo CD UIKeycloak SSO (OIDC client argocd); local admin disabled
grafana.frankbluemind.comGrafana dashboardsKeycloak SSO (generic OAuth); optional public dashboard link
auth.frankbluemind.comKeycloak realm demo: only /realms/* and /resources/*The admin console is not routed publicly
UserAppArgo CDGrafanaPurpose
zhaolin.sunrealm role participant, linked to participant P1argocd-admins → role:admingrafana-admins → AdminOwner; raises support requests
case.officeragency_staffno accessno accessApproves or rejects requests and claims
demo.guestparticipant, linked to demo participant P3argocd-readonly → role:readonlygrafana-viewers → ViewerShared reviewer login: raise requests, run GraphQL queries, look but not touch
sam.nominee / riley.providernominee (acts for P1) / provider (PR1, booked on P1)no accessno accessShow field masking per role
Keycloak clientTypeUsed byNotes
spaPublic, Authorization Code + PKCEThe SPA in the browserRedirect URI on app.frankbluemind.com; an audience mapper adds demo-api
demo-apiAudience only (no login)demo-api's token checkAccess tokens must carry aud: demo-api
demo-api-svcConfidential, client credentialsdemo-api → payments-svcAudience payments-svc; secret generated in Secrets Manager, delivered by ESO
argocdConfidential OIDCArgo CDGroups claim; secret generated → ESO → argocd-secret
grafanaConfidential OIDCGrafanaGroups claim → role_attribute_path; secret generated → ESO

Passwords. The five demo users get random passwords that stay memorable (word-word-number-symbol, from a CSPRNG). make secrets-seed writes them to Secrets Manager (demo/keycloak/users); an ExternalSecret hands them to the realm Job as environment variables and keycloak-config-cli substitutes them at import time, re-setting them on every import. Git and the realm ConfigMap hold only $(env:PW_<USERNAME>) placeholders, so Argo CD's read-only role, which can read ConfigMaps, learns nothing. The generated client secrets above take the same path and are never human-typed.

Accepted risk: a public site with shared demo logins. Cheap mitigations: Keycloak brute-force detection; guests are read-only in Argo CD and Grafana; only zhaolin.sun is an admin; the Keycloak admin console is not routed; optionally an ALB IP allowlist on argocd.frankbluemind.com; rotate the passwords after use (ROTATE_DEMO_PASSWORDS=1 make secrets-seed, then sync the realm).

Production path: no shared logins, and TOTP MFA required for admin groups.

config/keycloak/realm.yaml (keycloak-config-cli, excerpt)
realm: demo
enabled: true
bruteForceProtected: true
failureFactor: 10
groups:
  - name: argocd-admins
  - name: argocd-readonly
  - name: grafana-admins
  - name: grafana-viewers
roles:
  realm:
    - name: participant
    - name: nominee
    - name: provider
    - name: agency_staff
users:
  - username: zhaolin.sun
    enabled: true
    realmRoles: [participant]
    groups: [/argocd-admins, /grafana-admins]
    attributes: { participant_id: [P1] }
    credentials:
      - { type: password, temporary: false, value: "$(env:PW_ZHAOLIN_SUN)" } # Secrets Manager demo/keycloak/users
  - username: case.officer
    enabled: true
    realmRoles: [agency_staff]
  - username: demo.guest
    enabled: true
    realmRoles: [participant]
    groups: [/argocd-readonly, /grafana-viewers]
    attributes: { participant_id: [P3] }
clients:
  - clientId: argocd
    publicClient: false
    redirectUris: ["https://argocd.frankbluemind.com/auth/callback"]
    defaultClientScopes: [profile, email, groups]
Argo CD and Grafana SSO settings (excerpt)
# argocd-cm
url: https://argocd.frankbluemind.com
admin.enabled: "false"                          # after SSO is verified
oidc.config: |
  name: Keycloak
  issuer: https://auth.frankbluemind.com/realms/demo
  clientID: argocd
  clientSecret: $oidc.keycloak.clientSecret     # key in argocd-secret, written by ESO
  requestedScopes: ["openid", "profile", "email", "groups"]
# argocd-rbac-cm
policy.default: ""                              # no access unless a group grants it
policy.csv: |
  g, argocd-admins, role:admin
  g, argocd-readonly, role:readonly

# grafana.ini (Helm values)
[server]
root_url = https://grafana.frankbluemind.com
[auth]
disable_login_form = true
[auth.generic_oauth]
enabled = true
name = Keycloak
client_id = grafana
client_secret = $__file{/etc/secrets/grafana-oidc/client_secret}
scopes = openid profile email groups
auth_url = https://auth.frankbluemind.com/realms/demo/protocol/openid-connect/auth
token_url = https://auth.frankbluemind.com/realms/demo/protocol/openid-connect/token
api_url = https://auth.frankbluemind.com/realms/demo/protocol/openid-connect/userinfo
role_attribute_path = contains(groups[*], 'grafana-admins') && 'Admin' || contains(groups[*], 'grafana-viewers') && 'Viewer'
role_attribute_strict = true                    # no matching group → no access

4. Observability: OpenTelemetry metrics and traces

Decision: the app speaks only OpenTelemetry (OTLP). An in-cluster ADOT collector (AWS Distro for OpenTelemetry) forwards traces to AWS X-Ray and metrics to Amazon Managed Service for Prometheus; logs go to CloudWatch Logs through Fluent Bit. Self-hosted Grafana (one small pod, installed by Argo) is the UI over all three, with Keycloak SSO. The collector is the single seam for data, Grafana the single seam for viewing: changing a backend is config, never app code.

App pods and Keycloak send OTLP to the ADOT collector, which exports traces to X-Ray and metrics to Managed Prometheus. Fluent Bit ships logs to CloudWatch Logs. Self-hosted Grafana in the same cluster reads all three and is reached at grafana.frankbluemind.com through Keycloak SSO. EKS clustercollector, Fluent Bit, Grafana installed by Argo CD · IAM via Pod Identity demo-api podsOTel Go SDK · http, gqlgen, pgx Keycloaktracing → OTLP (planned) stdout JSON logsslog with trace_id, span_id ADOT collectorotlp → batch → exportersDeployment, 1 replicaPod Identity: X-Ray, AMP Fluent BitDaemonSet · tails node logs AWS-managed backends · Terraform AWS X-Raytraces · CloudWatch trace map Managed Prometheusmetrics · remote write CloudWatch Logs7-day retention Browsergrafana.frankbluemind.com Grafana (self-hosted)same cluster · 1 podKeycloak SSO · guest = Viewerdatasources: SigV4, Pod Identityalerts → SNS email OTLP gRPC traces metrics · SigV4 logs reads

Grafana runs in the same cluster; it is drawn on the right because it only reads from the backends.

LayerChoiceNotes
App instrumentation (built)OpenTelemetry Go SDK in the kit (internal/platform/otel), OTLP gRPC to the collector
  • HTTP (otel.HTTPMiddleware, otelhttp): a server span POST /graphql and http.server.request.duration labelled with the chi route template (a 404 keeps an empty route) and status code; server.address is the service name, never the Host header; /healthz, /readyz, /metrics are not recorded. 401 and 403 responses count in auth.failures.
  • GraphQL (otelgql): a span per operation named by its root fields (query.participant, mutation.submitClaim, query.me+plan; more than two become query.multiple; a document that does not parse or validate is other), a child span per root resolver, and graphql.operations / graphql.operation.duration. The client's operationName never becomes a label.
  • SQL (otelpgx): a client span per statement, named after the sqlc query (GetPlan), with no SQL text or arguments; only inside a traced request, so readiness pings never start traces. Pool gauges db.pool.*.
  • Outbound: otel.Transport on the Keycloak discovery/JWKS client and on the payments client (its token request too), so one trace runs from demo-api's approveClaim into payments-svc.
  • Propagation: W3C trace context, baggage and the X-Ray header; trace ids come from the X-Ray id generator, so X-Ray accepts them. demo-api is a public endpoint: each request starts a new trace and a caller's trace context is kept only as a link, so nobody outside can join a trace or force sampling; payments-svc (internal) continues demo-api's trace. slog lines carry trace_id, span_id and xray_trace_id (1-xxxxxxxx-…, searchable in X-Ray as-is).
  • Go runtime metrics; every seconds histogram uses the buckets 5 ms … 10 s.
  • Keycloak's built-in tracing: planned, not enabled.
CollectorADOT collector (Helm chart, installed by Argo)A Kubernetes object, so Argo owns it (the IaC rule). Pod Identity grants X-Ray write and AMP remote write. Memory limiter + batch processors.
TracesAWS X-RayViewed in Grafana (X-Ray data-source plugin) and in the CloudWatch trace map. Free tier covers demo volume.
MetricsAmazon Managed Service for Prometheus (AMP)PromQL, remote write with SigV4. Small cost at demo volume.
LogsFluent Bit → CloudWatch Logs7-day retention keeps the per-GB cost small. Grafana's CloudWatch data source queries them.
Dashboards + alertsGrafana, self-hosted (Helm chart via Argo)
  • UI only: data stays in the AWS backends; dashboards and data sources are provisioned from Git.
  • Data sources: AMP (Prometheus with SigV4), X-Ray plugin, CloudWatch; IAM through a Pod Identity role.
  • Keycloak SSO: zhaolin.sun is Admin, demo.guest is Viewer; no local login form.
  • Optional externally shared (public) link for the RED/scaling dashboard: read-only, fixed time range, no login.
  • Alerting → SNS email contact point. Roughly zero cost: one small pod.
AWS resourcesTerraform infra/eks/observability.tfAMP workspace, CloudWatch log groups, Pod Identity roles for the collector and for Grafana, SNS topic for alerts.

Alternatives

OptionGoodWhy not chosen
Amazon Managed Grafana as the UIZero-ops; best when the organisation already uses IAM Identity CenterEvery viewer must sign in through IAM Identity Center or SAML, and it is billed per active user. That doesn't fit a public demo with shared reviewer logins.
CloudWatch Observability EKS add-on only (Container Insights + Application Signals)Fewest pieces, CloudWatch-native, one add-onLess of a Prometheus/Grafana story. KISS fallback if time runs short.
Self-hosted kube-prometheus-stack + Tempo/LokiNo licence cost, fully portableHeavy for 2 nodes; you operate, upgrade and store the data
Third-party SaaS (Datadog, Grafana Cloud, Honeycomb)Rich UX, no backend to runExtra vendor and cost; easy later, because the same collector can export there

What we measure and alert on

Go: wiring the kit (built)
// cmd/api/main.go: config comes from OTEL_EXPORTER_OTLP_ENDPOINT, OTEL_SERVICE_NAME,
// OTEL_TRACES_SAMPLER_ARG; an empty endpoint keeps every provider a no-op.
shutdown, err := kitotel.Setup(context.WithoutCancel(ctx), cfg.Telemetry)
if err != nil {
	return fmt.Errorf("telemetry: %w", err)
}
defer kitotel.Flush(log, shutdown, 5*time.Second) // after the drain, inside the grace period

verifier := oidc.New(authn.Config{ /* … */ Transport: kitotel.Transport(nil)})
gql := graphql.NewHandler(svc, graphql.Options{ /* … */
	Telemetry: otelgql.New(otelgql.Options{ClientErrorCodes: []string{graphql.CodeDepthLimit}}),
})

// cmd/api/store.go: the adapter only sees pgx's QueryTracer interface.
pool, err := postgres.Open(ctx, cfg.DatabaseURL, postgres.PoolOptions{ /* … */ Tracer: otelpgx.NewTracer()})
err = otelpgx.RecordPoolStats(pool)

// cmd/api/router.go: the chain's tracing slot.
r.Use(httpmw.Recover(log), httpmw.RequestID, kitotel.HTTPMiddleware("demo-api"), httpmw.AccessLog(log, clientIP) /* … */)
ADOT collector config (minimal)
extensions:
  sigv4auth: { region: ap-southeast-2, service: aps }
receivers:
  otlp:
    protocols:
      grpc: { endpoint: 0.0.0.0:4317 }
processors:
  memory_limiter: { check_interval: 1s, limit_percentage: 75, spike_limit_percentage: 20 }
  batch: {}
exporters:
  awsxray: { region: ap-southeast-2 }
  prometheusremotewrite:
    endpoint: https://aps-workspaces.ap-southeast-2.amazonaws.com/workspaces/<workspace-id>/api/v1/remote_write
    auth: { authenticator: sigv4auth }
    resource_to_telemetry_conversion: { enabled: true }
service:
  extensions: [sigv4auth]
  pipelines:
    traces:  { receivers: [otlp], processors: [memory_limiter, batch], exporters: [awsxray] }
    metrics: { receivers: [otlp], processors: [memory_limiter, batch], exporters: [prometheusremotewrite] }

Cost at demo volume: Grafana is one small pod; Managed Prometheus and X-Ray cost cents (X-Ray free tier); CloudWatch Logs per GB ingested, kept small by 7-day retention.

5. Public-demo guardrails

6. Phased plan, app and infrastructure together

Each phase ends with its pass/fail checks run against the live system; the next phase starts only when every box is ticked.

#BuildPass/fail checks
1Repo eks-gitops-demo: the app on clean architecture and the platform kit; CI → ECR
  • verify.sh passes against the app
  • Coverage on domain/ + app/ is ≥ 80%
  • Merge to main puts a signed image and chart in ECR
2EKS + Argo CD + RDS + Keycloak (production mode on RDS, realm as code with zhaolin.sun, case.officer, demo.guest; Argo SSO) + ALB/TLS for app., argocd., auth.
  • PKCE login works at https://app.frankbluemind.com
  • zhaolin.sun logs in to Argo CD through Keycloak and has admin
  • demo.guest sees the Argo CD apps read-only and cannot sync
  • Keycloak users survive a Keycloak pod restart (RDS persistence)
  • The Keycloak admin console is not reachable from the internet
  • make eks-down leaves nothing billable in the cluster layer
3Postgres store (same RDS, own database and user) + DataLoaders + the support-request flow + plan management and service bookings; migration Job, HPA
  • zhaolin.sun raises a support request, case.officer approves it and a booking is reserved
  • zhaolin.sun cannot decide their own request (SelfApprovalNotAllowedError)
  • Load test scales 2 → 6 pods and back; every pod returns the same data
  • No N+1 queries in the logs
4OpenTelemetry: SDK + ADOT collector + X-Ray + Managed Prometheus + CloudWatch Logs (Fluent Bit) + self-hosted Grafana at grafana.frankbluemind.com
  • One GraphQL request shows a single trace spanning ALB → API → resolvers → Postgres in X-Ray
  • The RED dashboard shows the scaling load test
  • demo.guest opens Grafana through Keycloak SSO as Viewer and sees the RED dashboard
  • The public dashboard link opens without login
  • An alert fires on injected errors
5Schema showcase (interfaces/node, search union, filter/sort, UserError, idempotency, @deprecated) + secrets (SOPS → Secrets Manager → ESO → Reloader)
  • A retried submitClaim creates exactly one claim
  • A SOPS edit reaches the pod with no manual step
6payments-svc on the same kit + subscriptions (LISTEN/NOTIFY) + integration resilience + PR security scans
  • A subscriber on pod A sees a decision made on pod B
  • A payment call shows one trace across demo-api and payments-svc
  • The breaker opens when payments-svc is down
  • Only demo-api can reach payments-svc (NetworkPolicy)
  • Scans gate PRs
7Hardening (limits, persisted queries) + IaC pipeline; rotate demo passwords after use
  • A too-deep query is rejected
  • A request flood gets 429s
  • In allowlist mode an ad-hoc query is refused and a trusted hash runs (built)
  • Chrome loads both pages and logs in with no CSP violation (built: make browser-check)
  • A Terraform PR shows the plan; merge applies it (built: tf-plan.yaml / tf-apply.yaml)
  • A merged sops edit lands in Secrets Manager with no manual step (built: secrets-sync.yaml)
← Platform architectureCode architecture →