De återstående dokumenten — VISION, MVP, DEMO, DRIFT och MASTER-PROMPT — får engelska versioner, och de svenska blir översättningar med samma notis som systembeskrivningen redan hade. DRIFT.md heter nu OPERATIONS.md; ett svenskt filnamn på ett engelskt dokument hade varit inkonsekvent. Dokumentationen är därmed 33 filer: sexton dokument med engelska som källa, sexton svenska syskon, och systembeskrivningen dessutom på tyska, danska och norska. Två fel hittades under översättningen, båda rättade i bägge språken: DRIFT.md pekade ut .github/workflows/ci.yml som Guidad Felsöknings CI och sa tre rader längre ned att samma fil tillhör Semantika och inte rörs. Det stämde innan CI flyttades till egen Gitea; rätt fil är .gitea/workflows/felsokning.yml. MVP.md kallade styrdokumentet Master Prompt v1.0 men länkade till v2.0. Att översätta ett dokument är den grundligaste läsning det får. Båda felen hade överlevt flera genomgångar av samma text på svenska. Kodidentifierare, miljövariabler, sökvägar och UI-etiketter står oöversatta i de engelska versionerna. Att skriva "Create demo case" i demomanuset hade gjort manuset obrukbart — knappen heter "Skapa demoärende". Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012EQg3rJsrQ1ZNTvkzmQAtt
15 KiB
Guidad Felsökning — operations on Kubernetes (fully self-hosted)
Canonical version. Swedish: OPERATIONS.sv.md. Code identifiers, environment variables and file paths are Swedish and appear verbatim.
The target architecture from the Master Prompt as code: the whole stack runnable in our own cluster — web, orchestrator, platform backend (auth + event API + Live Share) and Postgres. No external service dependencies beyond the Anthropic API for the model calls.
Architecture
flowchart LR
T[Technician] -->|HTTPS| I[Ingress + TLS]
T2[Customer via share link] -->|HTTPS| I
I -->|/| W[web\n2–10 pods, HPA]
I -->|/api/ai| A[ai-orkester\n2–10 pods, HPA]
I -->|/api, /halsa| P[plattform\n2–10 pods, HPA]
A -->|Claude API| C[(Anthropic)]
P --> DB[(Postgres\nStatefulSet + PVC)]
K[Secret: felsokning-hemligheter] --> A & P & DB
| Component | What | Where |
|---|---|---|
web |
The SPA behind unprivileged nginx (Dockerfile, docker/nginx.conf) |
Deployment + Service + HPA + PDB |
plattform |
Self-hosted backend (services/plattform): multi-tenant — registration creates an organisation plus a system administrator, admins manage users (technician/supervisor/admin), all case data organisation-isolated. Login (bcrypt via pgcrypto, HS256 JWT with role and org in the claims), append-only event API, public share endpoint |
Deployment + Service + HPA + PDB |
ai-orkester |
The orchestrator (services/ai-orkester): tasks routed to Sonnet 5 / Opus 5 / Haiku 4.5 — verifies the platform's JWT (shared secret) |
Deployment + Service + HPA + PDB |
postgres |
Event log and users; append-only guaranteed by database triggers — history cannot be changed or deleted regardless of role | Aurora PostgreSQL Serverless v2 outside the cluster, in a subnet layer with no route out. Automatic backup with PITR to the second |
| Secrets | anthropic-api-key, jwt-secret (shared by platform and orchestrator), postgres-losenord, integration-nyckel (encrypts the customers' brand-specific credentials) |
|
| Environment flags | TILLATNA_URSPRUNG (CORS list; omitted = *), TILLAT_INTERNA_UPPSLAG (true allows vendor lookups against private networks), REGISTRERING_OPPEN, ECM_REGLER_FIL, INTEGRATIONER_FIL |
Secret felsokning-hemligheter — never in images or manifests |
The client has two operating modes, chosen at build time: with
VITE_PLATTFORM_URL, login, sync, Live Share and the model calls go to the
cluster (fully self-hosted); without it the Supabase mode is used (edge function
plus managed Postgres/Auth) as before. Same event model, same orchestrator —
locked by parity tests.
Infrastructure as code
Two layers, in order:
| Layer | Where | What |
|---|---|---|
| 1 | felsokning/infra/aws |
VPC, EKS, Aurora, S3, ECR, Secrets Manager, Route 53/ACM, CloudWatch |
| 2 | felsokning/infra/terraform |
The workload in the cluster — reads layer 1's outputs |
The split is not a matter of taste. A single apply that both creates an EKS cluster and schedules into it is a known trap: the Kubernetes provider has to be configured with details that do not exist until the cluster does.
Layer 2 decides almost nothing on its own — 05-aws.tf reads the base's
outputs, so domain, registry, roles, certificate, bucket and secret names are
stated in exactly one place.
terraform output karta in either layer prints the whole truth in plain text,
straight from the definition.
Network boundaries
The Terraform path closes the namespace and opens only the actual flows:
| From | To | Why |
|---|---|---|
| the ingress controller | web, plattform, ai-orkester :8080 | the only way in |
| plattform | postgres :5432 | the event log |
| plattform | internet :443 except private networks | the customers' vendors |
| ai-orkester | internet :443 except private networks | Claude |
| web, postgres | — | call nothing |
The exceptions for private networks (10/8, 172.16/12, 192.168/16, 169.254/16,
127/8, 100.64/10) are the same boundary the code itself enforces in pekarInat
— two independent barriers against a customer-configured lookup being used to
reach the cluster's interior or the cloud metadata service. Requires a CNI that
enforces NetworkPolicy; otherwise the rules are documentation, not protection.
Deploying
# Layer 1 — the AWS base
cd felsokning/infra/aws
cp terraform.tfvars.exempel terraform.tfvars # domain, region, alarm address
terraform init && terraform apply
terraform output karta
# Layer 2 — the workload
cd ../terraform
cp terraform.tfvars.exempel terraform.tfvars # state bucket and image tag
terraform init && terraform apply -var bildtagg=<commit-sha>
After the first apply, three things remain, listed by terraform output karta
under kvar_att_gora (still to do): fill in the Claude key in Secrets Manager,
run felsokning/infra/postgres-init.sql against Aurora, and narrow
tillatna_api_cidr from 0.0.0.0/0.
Creating new organisations is closed by default (registrering_oppen = false);
users within an organisation are always created by its system administrator.
The database
Aurora PostgreSQL Serverless v2, outside the cluster, in a subnet layer with no route out at all — the database's inability to reach the internet therefore does not depend on a security group being configured correctly.
Automatic backup with PITR to the second within the retention window. If the event log is lost, what disappears is not "data" but every case's probative value: what was checked, by whom, when, with what evidence. That cannot be recreated afterwards.
The schema with the append-only triggers is
felsokning/infra/postgres-init.sql — the same file the integration test runs,
so they cannot drift apart.
Secrets
AWS Secrets Manager is the source of truth. External Secrets mirrors them into the cluster every hour, and the pods read them as ordinary environment variables.
Terraform never sees the values, and that is the whole point: a secret that passes through Terraform ends up in the state file. When a secret is rotated, the cluster follows on its own within the hour.
Access goes via IRSA: the platform's service account has a role bound to exactly that account in that namespace. The neighbouring pod on the same node gets nothing for free, and IMDSv2 with hop limit 1 stops a pod from borrowing the node's role via the metadata service. The same role signs against S3 — no keys exist to leak.
Attachments
Photos, video clips and instrument images used to sit as data URLs inside the events. That affected everything that reads the log: sync dragged the entire image payload along every fifteen seconds, the customer view likewise, and a backup of the log was in practice a copy of every photo.
Now the content lives outside the event and the log carries a reference with the content's SHA-256. That strengthens the probative value rather than weakening it: the hash sits in the append-only-protected log, so an image that has been swapped can be detected — previously the image sat in the log and simply had to be taken on trust. The content is checked against the hash every time it is served; if it does not match, the service answers 409 instead of showing the image.
Content-addressed, so the same photo documented twice is stored once.
bilage_lage |
Where the content lives | Use when |
|---|---|---|
databas (default) |
bilage_innehall (bytea) |
Works everywhere with no configuration; the images travel with the database backups |
s3 |
S3-compatible object storage (AWS, MinIO, Ceph) | The log and the images should grow independently of each other |
The signing against object storage is our own (SigV4 for PUT and GET) rather than the cloud provider's SDK — two operations do not justify tens of megabytes of dependencies. It is cross-verified against botocore in the tests, bit for bit.
The sharing boundary applies to attachments too. An attachment can be fetched through a share link only if the event it belongs to is visible at that level; the scanned work order is therefore never reachable through the customer link.
Older events with an embedded data URL keep working and always will — the log is append-only. Local mode, without login, also embeds: there is no server to upload to, and the documentation must not be lost because the network is down.
Access: blocking and revocation
A valid JWT signature is not enough. Every authenticated call looks up the account and checks two further things: that it is still active, and that the token version matches. It costs one primary-key lookup per call and in return gives immediate revocation, instead of a suspension taking effect only when the token expires up to twelve hours later.
| Situation | Route | Effect |
|---|---|---|
| Someone leaves | POST /api/anvandare/{id}/avaktivera (admin) |
Login is closed and ongoing sessions end immediately |
| The account should come back | POST /api/anvandare/{id}/aktivera (admin) |
Can log in again; previously revoked tokens stay dead |
| Phone lost | POST /api/auth/logga-ut-alla (oneself) |
All devices are logged out |
An administrator cannot disable themselves, and the boundary between organisations holds — org B cannot touch org A's users. The event log is never touched: the history is still tied to the person who did the work.
Login rate limiting lives in the database, not in memory, so the block holds behind several replicas: 10 failed attempts per account and 30 per source address within 15 minutes give a 429. The block applies to the account even on a correct password — otherwise it could be bypassed by whoever eventually guesses right. Other accounts are unaffected. No password is stored, only that an attempt happened and whether it succeeded; rows older than a day are cleaned up on the write path.
Observability
The services deliberately have almost no dependencies. Pulling in an OpenTelemetry SDK with thirty packages to measure four things would be the wrong trade, so observability rests on two standards that are both just text on stdout:
W3C Trace Context. The client starts the trace and traceparent travels
through the platform to the orchestrator. A technician's action can therefore be
followed all the way to the model's answer, instead of becoming two unrelated
traces.
CloudWatch EMF. Structured JSON from which CloudWatch itself extracts metrics — no agent, no SDK, nothing that can silently stop working.
Every call produces a log line with a breakdown of the time:
{"nivå":"info","meddelande":"plattform","spårId":"fd5dec…","väg":"/api/arenden/:id/handelser",
"status":200,"ms":842.1,"delar":{"databas":{"antal":3,"ms":31.2},"bilaga_skriv":{"antal":1,"ms":780.4}}}
That answers the question you actually have at three in the morning: where did the time go. Here in object storage, not in the database.
The route is always normalised
The route is normalised (/api/arenden/:id/handelser) before it becomes a
dimension. Organisation, case id and trace id never become dimensions —
every unique combination is its own time series that costs money. They sit as
ordinary fields, searchable in Logs Insights. A test locks this.
Queries that tend to be needed
# Where did the time go on a slow call?
fields tid, väg, ms, delar.databas.ms, delar.modell_handledning.ms, spårId
| filter ms > 1000 | sort ms desc | limit 20
# The whole chain for one trace — platform and orchestrator in the same view
fields tid, meddelande, väg, ms, status | filter spårId = "fd5dec…" | sort tid
# Which model calls cost the most?
stats sum(ut) as ut_tokens, avg(ms) as snitt by Uppgift, Modell
Alarms
Beyond the infrastructure alarms there are three on the application's own metrics: response time p95 above three seconds (the mean hides that every twentieth technician waits unreasonably long), server errors, and the model declining — the last of which indicates that the input contains something unexpected, not an operational fault.
Multi-tenancy and roles
Per the Master Prompt: each customer is its own tenant, no data is mixed between customers.
- Registration creates the organisation and makes the user its system administrator.
- Admins create users (technician/supervisor/admin) in their organisation —
through the UI or
POST /api/anvandare. - All case data is organisation-scoped: cases are created in the user's organisation and the event API verifies organisation membership on every call — another organisation's cases give a 404.
- The role lives in the JWT and is verified on the server; the client only adapts the UI.
The integration test (services/plattform/integrationstest.sh, also run in CI
against real Postgres) verifies the whole chain: registration, sync, idempotency,
the append-only trigger, organisation isolation, share filtering and role
enforcement.
Security and robustness
- Append-only in three layers: the client only appends, the API exposes no update or delete, and database triggers reject changes even for a misconfigured role.
- The JWT flow is verified across the services: the platform signs, the orchestrator verifies the same secret; a wrong secret and expired tokens are rejected (tested).
- All containers run non-root without capabilities; the backend services with a read-only root filesystem. Both fail closed without their secrets.
- HPA 2–10 pods per service at 70 % CPU; PDB keeps at least one pod up
during node drain; readiness and liveness probes everywhere (
pg_isreadyfor Postgres).
CI/CD with GitOps
CI (.gitea/workflows/felsokning.yml): tests, production build, integration
test against real Postgres and verifying container builds on every push and PR.
CD — all our own, no GitHub. Source code, build, registry and operations live in our AWS environment.
flowchart LR
D[Developer] --> G[Gitea
on our own EKS]
G --> R[Actions runner
same cluster]
R --> T[tests
typecheck
integration test]
T --> E[(ECR
immutable tags)]
E -.->|manual step| P[terraform apply]
P --> K[EKS]
- Gitea runs in the cluster with its own Actions runners. The workflow
syntax is the same as GitHub Actions, so
.gitea/workflows/felsokning.ymlis the same file that used to sit under.github— just moved. - The build publishes to ECR with immutable tags: a tag that has pointed at one build cannot point at another, so "which code is running in production" has an unambiguous answer.
- Deployment is a separate, manual step with an image tag. An image in the registry is not the same thing as an image that is running. Rollback = run again with the previous tag.
- Split permissions: the build role may publish to ECR but not touch the cluster; the operations role the other way round. A compromised build cannot deploy.
.github/workflows/ci.yml belongs to Semantika and is not touched.