agentleFS
Sign inSign up

tessary / backend

tessaryai/tessary/backend/AGENTS.md

Imperative rules for any agent touching backend/. The backend is Spring Boot 4.0.x on Java 25 (JVM + Loom virtual threads), Maven multi-module: The reactor is eleven modules; dependencies flow downward only and Maven enforces it, so a module can only import what sits below it. Full rationale in devdocs/modules.md. - shared/, contract/ — open foundation: ai.tessary.open.{errors,jobqueue,media,obs} and the checked-in canonical OpenAPI spec. - test-support/ — TestPostgres + its context initializer. Test scope in app; holds only fixtures that depend…

AGENTS.md17 starsChanged 21 days ago
# Backend agent guide

Imperative rules for any agent touching `backend/`. The backend is Spring Boot
4.0.x on Java 25 (JVM + Loom virtual threads), Maven **multi-module**:

The reactor is eleven modules; dependencies flow downward only and Maven enforces it, so a module
can only import what sits below it. Full rationale in [`devdocs/modules.md`](../devdocs/modules.md).

- `shared/`, `contract/` — open foundation: `ai.tessary.open.{errors,jobqueue,media,obs}` and
  the checked-in canonical OpenAPI spec.
- `test-support/` — `TestPostgres` + its context initializer. Test scope in `app`; holds only
  fixtures that depend on nothing in the platform, plus `OpenApiCanonicalizer`, the one canonical form both
  OpenAPI spec guards pin through.
- `core/` — `web`, `model`, `apidoc`, `ops`, `crypto`, `db`, `config`, `analytics`, `llmspi`, plus the
  Liquibase changelog and the schema-column generator that reads it.
- `tenancy/` — `tenant`, `auth`, `featureflags`, `version`. Declares bcrypt. The `featureflags` seam
  holds NO defaults — the open adapter is `org_feature_flag` rows and the capability default lives in
  `product/plan/CapabilityService`.
- `substrate/` — `storage`, `ingest`, `redaction`, `retention`, `pricing`, `sources`, `traces`,
  `git`, `usage`, `vitals`.
- `product/` — `pipeline`, `gate`, `plan`.
- `llm-runtime/` — `llm`, `sandbox` (agent-span telemetry only). The ONLY module that
  declares a provider SDK.
- `analysis/` — `classifier`, `rca`, `cases`, `alert`, `onboarding`, `prompt`.
- There is no `evaluation/`. It held judge, run, compile, synth, regrade, experiment, review and
  annotation, and all of them were deleted; the reactor is eleven modules.
- `surfaces/` — `query`, `search`, `mcp`, `ci`, `metering`, `billing`, `telemetry`.
- `app/` — `TessaryApplication`, `application.yaml`, and every `@SpringBootTest` integration test
  (they need the `@SpringBootConfiguration` only this module has).

The app is organized **package-by-feature** (vertical slices), not
package-by-layer: every package owns its controller → service → repository →
DTOs, or is cross-cutting infra (`config`, `db`, `crypto`, `web`), or the pure
`model/` record graph. [`devdocs/reference/architecture.md`](../devdocs/reference/architecture.md)
is the authoritative convention + full per-package inventory — read it before
adding a package or a controller. Config-key map:
[`devdocs/reference/config-keys.md`](../devdocs/reference/config-keys.md).

**Find the rule:** [Java](#java-rules) · [Errors](#error-handling) · [LLM](#llm-integration) · [Multimodal](#multimodal-traces-images) · [Logging](#logging--observability-conventions) · [Pipeline](#pipeline--curation-db-backed-project-scoped) · [Auth](#auth--tenancy) · [Static analysis](#static-analysis) · [Testing](#testing) · [Recipes](#recipes) · [Commit](#before-you-commit)

## Java rules

- **NEVER use raw `Object` / `var` at API boundaries.** Strong types on records, controllers, services. The only place `Object` is acceptable is the `applies_to` field on `ImplicitInvariant` (polymorphic by design: string or list).
- **Never throw raw `RuntimeException`** from services or controllers. Wrap with `TessaryException(ErrorCode, args)` so the wire response stays consistent.
- **Snake_case on the wire, camelCase in Java.** `@JsonProperty("snake_case")` on every record field that doesn't already match Jackson defaults. This is YAML-format compat — the `.tessary/` bundle and `curation.yaml` are the binding artifacts.
- **JVM runtime + Project Loom (no native image).** Reflection and dynamic class loading just work — Jackson-bound records need no registration. Bias I/O-bound fan-out toward virtual threads, but keep a concurrency bound (a `SimpleAsyncTaskExecutor` concurrency limit or a `Semaphore`) on anything that hits a rate-limited upstream (Bedrock/Anthropic/GitHub) or the bounded JDBC pool — see `config/AsyncConfig`. Use only stable Loom APIs (`Executors.newVirtualThreadPerTaskExecutor()` / `SimpleAsyncTaskExecutor.setVirtualThreads(true)`); no preview `StructuredTaskScope`, no `--enable-preview`.
- **All config via `@ConfigurationProperties`** (env-var prefix `TESSARY_*` through relaxed binding). No hardcoded paths; no `@Value` outside narrow per-bean cases. The documented exceptions and the full prefix → class map live in [`devdocs/reference/config-keys.md`](../devdocs/reference/config-keys.md).

## Error handling

- **Every error path returns the `ApiResponse` envelope.** Never return `ResponseEntity.notFound()` / `.badRequest()` / bare HTTP statuses from a controller. Throw `TessaryException(ErrorCode, args)` and let `web/GlobalExceptionHandler` build the response.
- **Hierarchical error codes**: per-domain enum implementing `ErrorCode`. Wire form is `<DOMAIN>.<NAME>` — the domain is auto-derived from the enum's declaring class (`PipelineError` → `PIPELINE`). The system lives in the **shared module**: `backend/shared/src/main/java/ai/tessary/open/errors/` (`ErrorCode`, `TessaryException`, `ErrorCatalog`, all per-domain enums).
- **Adding a new error domain**: create the enum in `open/errors/` (each constant: `(HttpStatus, "message template with %s")`), register it in `ErrorCatalog.REGISTERED`. Compiler enforces within-domain uniqueness; `ErrorCatalog`'s `@PostConstruct` enforces cross-domain uniqueness at boot.
- **`@Valid @RequestBody`** at the controller boundary only. Services receive validated records and assume invariants — drop manual null/blank checks once a field has Jakarta constraints. The handler turns `MethodArgumentNotValidException` into `COMMON.VALIDATION_FAILED` with `details: { field: message }`.

## LLM integration

- **The backend makes no in-process chat call.** A model is reached two ways: a model id handed to the coding agent in an E2B sandbox (`AGENT_VM` lanes, resolved by `llm/ProjectModelSettings#resolveAgenticModel` and credentialed by `llm/AgenticCredentialResolver`), or a typed question to a hosted decision model (`llm/decisions/`). Both run on the org's own provider credential (`llm/ProviderCredential*` — one key per provider, configured in Settings → Providers).
- **A detector that spends the org's own provider key seeds disabled and pauses visibly.** `defaultEnabled = false` in its catalog module, a `ClassifierPause` surfaced through the row's `readiness`, and `ProviderCredentialListener` to lift it; `classifier/frustration/` is the model. The conditions such a detector must meet are in `devdocs/reference/principles.md`.
- **Model selection lives in the DB, not Spring profiles.** Pickable (platform, model) pairs are curated in `llm/ModelCatalog` + `llm/PlatformCatalog`. A lane runs the project's explicit choice, else the first provider in `llm/LanePriority` the org holds a credential for, else fails closed with `MISSING_CREDENTIALS`. Every provider needs an org key. Adding a platform = a `PlatformCatalog` descriptor + a `ModelCatalog` entry + a provider mode in the sandbox launcher.
- **Bedrock spans TWO endpoints, and a model's own descriptor says which.** `llm/BedrockModelProfile` lists the Bedrock models the platform offers; its `endpoint` field — not a vendor name — decides which provider a lane resolves to, because `bedrock-runtime` (Converse, cross-region inference profiles, explicit cache points) and `bedrock-mantle` (OpenAI Responses, bare model ids, implicit caching) differ in host, wire, SigV4 service name, IAM action and region. Adding a platform model is now one entry there and nothing else — **there is exactly one price source in the codebase and it is the `price_book` / `model_price` tables**. Ingested spans price from it on arrival and never again; LLM calls made on the org's key (`llm_call`: sandbox runs and decision calls) price from the same book through `pricing/ModelResolver` + `pricing/PlatformCallPricer` and stamps `llm_call.price_book_version`. A rate is changed by a reviewed diff to `pricing/litellm-model-prices.json`, never by a table in Java. **That file's path and shape are a public contract**: tessary-home fetches it by raw GitHub URL and serves it to running instances, so never move, rename or reformat it (gate: `scripts/check-price-book-contract.sh`, whose header says how to move it safely) — if you find yourself typing a dollar figure into a `.java` file, that is the bug. There is no hand-maintained correction file any more (`manual-overrides.json` was retired); a model LiteLLM prices under a different spelling than a producer reports it (mantle's `bedrock_mantle/` route is the precedent) needs a producer-side fix, not a pricing-layer override.
- **Decision-model calls in `llm/decisions/` are hosted-classifier calls, not chat completions**: `JevDecisionClient` wraps each in a GenAI OTel span, and they price through `PlatformCallPricer`.
- **System prompts live in Java text blocks** near their caller. Move to resources only if prompt iteration gets painful.

## Multimodal traces (images, documents)

Traces carry media through the whole pipeline (ingest → store → display → export). v1 ingests and renders **text, images, and PDF documents**; other modalities (audio, video) are deferred behind an explicit-failure seam. Full contract: [`devdocs/reference/media-contract.md`](../devdocs/reference/media-contract.md); schema detail: [`devdocs/reference/trace-schema.md`](../devdocs/reference/trace-schema.md).

- **There is no model-facing media boundary left.** Grading was removed, and with it the judge's request build — the one place a `ContentBlock` was mapped into an LLM message, and the one place an unsupported block type was rejected (`JUDGE.UNSUPPORTED_CONTENT_TYPE` → 422) rather than silently flattened. `llm/ContentBlocks`, which did that mapping, had no caller afterwards and is deleted. What survives is the ingest-and-read half: a new modality arrives by adding a routed case in `ContentExtractor`, never an unrouted `ContentBlock` constant. `ContentBlock.isMedia()` is the single source of truth for "is this a media block" (`isImage()`/`isDocument()` narrow to a specific modality — see their own doc for why they stay separate predicates).
- **Producers emit media inline** (`data:` URI, raw base64 content block, or an `https://` URL) in `RawEntry`. Large payloads externalize through the `MediaStore` SPI (`shared` `open/media/`, default `storage/PostgresMediaStore`) via `ingest/MediaExternalizer`, which also runs `open/media/PdfTextExtractor` once per document at ingest so the persisted `document_ref` node carries pre-extracted text.
- **Media is never fetched server-side**: an `https://` media URL is stored and displayed, never downloaded. `ingest/UrlGuard.requirePublicHttp` (SSRF guard: rejects RFC1918, link-local, IMDS) now guards the outbound calls that remain, alert channels (`ChannelHttp`, which also reads responses through the `ingest/BoundedBody` byte cap), GitHub and provider base URLs. `spring.servlet.multipart.max-file-size` is 25 MB (== `max-request-size`) so a JSONL row carrying a base64 image/document clears Tomcat before the parser runs.
- **Export fidelity (`ingest/export/TraceSpanMapper`)**: real bytes are inlined as a base64 `data:` URI in the existing plain-string content field for both images and documents where recoverable (inline base64 needs no store; a `_ref` block rehydrates via `MediaStore`), falling back to a labeled placeholder (`[image: <url>]` / `[document omitted: <mediaType>]`) + a `has_media:true` flag when they cannot be — lossless-or-labeled, never a silent collapse.

## Logging + observability conventions

SLF4J + Logback; the `production` profile ships JSON to Grafana Alloy → Loki. The obs infra (`LogContext`, `Markers`, `MdcTaskDecorator`, `OpsOrWarnLogFilter`) lives in the shared module at `open/obs/`.

- **Manual SLF4J loggers, no Lombok.** `private static final Logger log = LoggerFactory.getLogger(X.class);` — one declaration style everywhere.
- **`StructuredLog`, with a human message AND structured fields.** Both, always — they serve different readers and repeating a value in each is deliberate, not duplication to remove.
  - `.message(...)` is a **sentence about what happened**, in the words someone tailing a terminal wants: `"swept 120 observations for groundedness in 41ms, 3 fired"`. Not an identifier. The dotted event name travels as an `event` field, where it still groups and filters.
  - `.field(k, v)` carries the same facts structured, so Loki can filter and graph them. Stable, terse keys (`findingId`, `caseId`, `durationMs`) consistent with the MDC keys.
  - Emit them as **key-value pairs, never interpolated into the message**. Both egress paths already understand them (`LogstashEncoder` renders JSON fields; the OTel appender has `captureKeyValuePairAttributes`), and Loki's native OTLP ingestion turns every attribute into structured metadata with no allowlist. A `key={}` baked into the message string looks structured and is not: it cannot be filtered or graphed, only regexed.
- **Log OUTCOMES and COST, not intent.** A line that says work *started* earns its place only at DEBUG. On 2026-07-31 `signal.sweep.start` + `signal.sweep.empty` were **77% of all production log volume** and said nothing an operator could act on. The completion line — what was scanned, what fired, how long it took — carries every fact the start line did, plus the answer.
- **Anything whose cost scales with size must log SIZE and DURATION.** Row counts hide byte volume: the ingest path looked trivial by span count while carrying megabytes of inline base64 per entry, and the redaction package logged *nothing at all* — so a CPU saturation that pinned both vCPUs took a profiler to locate rather than a grep. `durationMs` and `bytes` as numeric fields (unquoted, so they are graphable, not merely greppable).
- **Every new feature ships with enough logging to debug it at every step, in the same PR.** For each stage of a new flow ask: if this stalls, errors, or silently does nothing in production, what line tells me — and does it say *which* step, *how much* work, and *how long*? If the answer is "attach a profiler" or "read the code", the logging is not done. Absence of logs is the failure mode that costs the most: nothing alerts, nothing appears wrong, and the first symptom is an outage.
- **Level semantics:** ERROR — a job/request failed in a way that needs a human; always pass the throwable as the last arg. WARN — a recoverable degradation we tolerated (one unit failing is WARN; the whole job failing is ERROR). INFO — lifecycle milestones and per-unit outcomes safe at volume (bounded cardinality). DEBUG — developer detail, off in prod; when unsure between INFO and DEBUG, pick DEBUG. TRACE — effectively unused.
- **No credentials, tokens, or PII at any level.** Log a fingerprint/prefix or a count, not the value (see `SecretBox`'s key-fingerprint pattern).
- **WARN+ egress to Loki is a data-egress surface.** Only WARN+ or `Markers.OPS`-marked events leave the box; plain INFO stays local. Keep egressed messages categorical (ids and counts, not free text). The OTEL appender captures attached throwables' messages + stacks, so for exceptions whose message embeds user/model content (e.g. Jackson parse failures echoing model output) log categorically with **no** throwable attached and put detail at DEBUG. For infrastructure exceptions (DB, IO, upstream) the throwable arg is the right call.
- **Bind business context with `LogContext`, don't repeat ids in every message.** Async jobs start with empty MDC — open `try (var ctx = LogContext...)` at the top of the async entrypoint; executors propagate MDC via `MdcTaskDecorator`. New MDC keys must be added to both lists in `app/src/main/resources/logback-spring.xml` (`includeMdcKeyName` + `captureMdcAttributes`) or they're silently dropped.
- **Mark safe-to-ship operational INFO with `Markers.OPS`** — only events whose *every* field is PII-free and bounded.
- **Platform OTel / Langfuse names are static kebab product verbs** (`rca-run`, `layer2-triage`, …) — never entity display names; put identity in observation metadata. Full inventory + rules: [`devdocs/reference/telemetry-naming.md`](../devdocs/reference/telemetry-naming.md).

## Pipeline (DB-backed, project-scoped)

- **The DB is the runtime source of truth.** The `.tessary/` bundle is an *import format* only — `POST .../import` classifies the multipart shards into a `Pipeline`; `PipelineRepository.upsert(...)` writes them.
- **Reading: always go through `pipelineService.getPipeline(projectId)`.** Returns `Pipeline.empty()` for fresh projects; treat empty as a normal state.
- **The bundle still carries shards this tree cannot use.** The plugin is public and keeps emitting grader and quality-dimension shards; `BundleAssembler` routes them to `Shard.IGNORE` rather than rejecting the bundle. Do not "fix" that into a hard reject — it would make every existing bundle un-importable and buy nothing.
- **There is no curation overlay.** `curation_entry`, `CurationService` and the accept/edit/reject model went with graders. An imported pipeline is what the bundle says it is.

## Auth + tenancy

Factual inventory: [`../devdocs/reference/auth-and-mcp.md`](../devdocs/reference/auth-and-mcp.md).

- **WorkOS AuthKit** drives sign-in. Local AES-GCM sealed cookie session (`SessionCipher`, name `tessary-session`, SameSite=Lax); no JWKS fetch on the request path — refresh against WorkOS only past expiry.
- **Tenancy model**: `app_user → org_membership → organization → project`. There is no scope below a project — the `environment` concept was removed outright. Every domain table carries `project_id NOT NULL`. Cross-project leaks are guarded by `TenantPathResolver` at the controller boundary — a controller resolves `resolver.requireProject(...)` before touching data.
- **MCP + headless API** share one `api_key` store via `tenant/ApiKeyService` (`tsy_w_` / `tsy_q_` / `tsy_a_…`). Settings → MCP tokens and the plugin device-link mint admin-scoped keys; Settings → API keys mint scoped keys. `AuthFilter` populates `TenantContext`; every MCP tool reads `ctx.projectId()`.
- **`AuthFilter.shouldNotFilter`** bypasses `/auth/login|callback|logout`, `/auth/link/start|poll` (plugin device handshake; the secret `device_code` is the credential), and `/actuator/health` plus its two probes, enumerated exactly — **not** the rest of `/actuator/`, **not** health groups/components (`show-details: always` must not publish `/actuator/health/db`), and **not** the bare `/actuator` index, which Spring Boot serves as a HAL listing of every exposed endpoint and which `startsWith("/actuator/")` does not match. Widening `management.endpoints.web.exposure` therefore cannot widen the unauthenticated surface. `/mcp` is bearer-only (cookies ignored). An unconfigured WorkOS does NOT by itself bypass anything: the filter additionally requires `TESSARY_AUTH_DISABLED=true`, and without it every guarded path answers 401. Absent configuration is the normal state of an open-edition self-host, so it is not read as consent. In the `production` profile `AuthRequiredInProdGuard` refuses to boot without `TESSARY_AUTH_COOKIE_PASSWORD`.
- **Org membership** CRUD lives in `OrganizationController` (owner-gated; last-owner demotion/removal blocked). Inviting an email that has never authenticated creates a pending `org_invitation`, consumed into a membership on that address's first sign-in (`TenantService#consumePendingInvitations`, matched case-insensitively).
- **Sign-up policy**: `SignupPolicyService#admit` runs after a provider has authenticated someone and before any principal, org or project exists for them, on all three creation paths (`POST /auth/signup` ahead of the password provider's insert, `POST /auth/login`'s first-time path, `GET /auth/callback`). Invariants: an existing principal is never refused; the first account is always admitted; a pending invitation admits in every mode; the policy is instance-wide and lives on the install's first organization's `settings.signupPolicy`, written only through `PUT /api/orgs/{slug}/signup-policy` (the raw settings PATCH refuses it). A refusal is `AUTH.SIGNUP_REFUSED` and writes nothing.

## Static analysis

The conventions above are **machine-enforced** — `mvn -B verify` (and CI) hard-fails on a violation. All tools are build-time only.

- **Spotless** (palantir-java-format) — `task backend:format` fixes formatting in place.
- **ArchUnit** (`app/src/test/.../arch/ArchitectureRulesTest`) — the package-by-feature rules as JUnit tests (controllers-in-features, `web/`-plumbing-only, `model/`-pure-data, error codes in `open/errors/`, loggers `private static final`, `JdbcClient`-only-in-`*Repository`), plus the `ErrorCatalog.REGISTERED` parity check. **Add a rule when you add a convention.**
- **forbidden-apis** — bans default-charset/locale APIs (pass `Locale.ROOT`), `System.out/err`, `java.util.logging`.
- **SpotBugs** + **PMD** (`backend/config/`) — fix real findings, don't add silent suppressions.
- **Error Prone + NullAway** — compile-time; NullAway package set is the `AnnotatedPackages` arg in the parent `backend/pom.xml` (widen one package at a time as packages get JSpecify `@Nullable`).
- **OpenAPI drift**: any controller/DTO change that shifts the wire contract requires `task contract:openapi` to regenerate the checked-in spec — `OpenApiSpecDriftTest` fails `backend:check` until you do.

## Testing

Two tiers. Keep them clean — don't mix. When to add or change a test: root [`AGENTS.md`](../AGENTS.md#tests).

### Tier 1 — unit tests

`*Test.java` colocated with the class under test, no Spring context, milliseconds. **The default:** pure logic never boots Spring; build the class with `new` and hand it its collaborators. Use for pure logic with non-trivial branching: cipher round-trips, slug/ULID generators, JSON-RPC dispatch, overlay merging, content-block parsing. JUnit 5 + plain `assertX`.

### Tier 2 — integration tests

`@SpringBootTest`-driven, and only when the behavior needs the database, the filter chain, or bean wiring. Each Spring context gets a uniquely-named database in the JVM-singleton pgvector Testcontainers Postgres (`db/TestPostgres`, wired by `TestcontainersPostgresInitializer` via `META-INF/spring.factories`); Liquibase applies per context boot. **Requires Docker.** Prefer two classes with distinct concerns over one giant fixture. Bootstrap tenants via `testsupport.TenantFixture.bootstrap(...)` — widen the helper rather than fork it.

**A distinct `@SpringBootTest(properties = …)` or unique `@DynamicPropertySource` fingerprint is a distinct Spring context, and each one costs a fresh database plus a full Liquibase run (~15s).** Reuse the shared fingerprint unless the properties are the point of the test — that single decision dominates how long the suite takes. Cost model: [`../devdocs/reference/test-suite.md`](../devdocs/reference/test-suite.md).

MockMvc in Boot 4: build manually (`MockMvcBuilders.webAppContextSetup(wac)`) and **explicitly add filters** (`addFilters(authFilter)`) — Boot 4 dropped `@AutoConfigureMockMvc`.

### Writing the test

- **Assert state, not calls.** Assert the return value, the row read back, or the response body. Use `verify(...)` only when the call is the outcome (a notification sent, an upstream call skipped), and match only the arguments that rule is about.
- **Doubles, in order: real object, fake, mock.** Real when it is in-process and fast; a hand-written fake for a port we own; Mockito last. Mock a type we don't own (`HttpClient`, provider SDKs) only to feed canned responses, then assert what our code does with them.
- **Mocks are strict and local.** `@ExtendWith(MockitoExtension.class)`, no `lenient()`. Declare only the mocks this class drives; don't copy another test's `@Mock` block.
- **Public API only.** No reflection, `setAccessible`, or visibility widened for a test. If the outcome is invisible to a caller, the design lacks a seam: raise it.
- **Time is injected.** Code that reads time takes a `java.time.Clock`; tests pass `Clock.fixed(...)`. No `Thread.sleep`: wait on the condition with a deadline.
- **Assert exact values.** Compare whole records with `assertEquals`. No `assertTrue(s.contains(..))` on log lines, exception text, or prompt prose: assert the `ErrorCode`, the structured field, the parsed value.
- **Setup lives in the test.** A reader sees the inputs that matter in the method. Share builders, not hidden state. Use `@ParameterizedTest` for input tables; loops belong only in seeded simulations.

### What to test

Anything that could silently break a pipeline: identifiers/slugs (collisions, truncation edges), crypto round-trips (tampered ciphertext, wrong key), token verification (revoked rejection, `last_used_at`), tenant isolation (every cross-org/cross-project path), idempotency (auth callback twice must not duplicate rows), JSON-RPC wire semantics (protocol codes vs `result.isError`), repository round-trips (**insert a fully populated row, read it back, assert every field** — the one repository bug class that consistently bites).

### What NOT to test

Record constructors/getters; constants, enum values, or config defaults restated as asserts; log wording; controllers that are pure glue; framework wiring (`ContextLoadsTest` covers it once); negative paths already guarded by the type system.

### Conventions

- One class per test class, `<ClassUnderTest>Test` (no `*Tests`); `@Nested` only when it maps to nested behaviour.
- Method names read as sentences and state the rule, not the change: `verify_rejectsRevokedToken`, never `…NowIncludesX`. No `testX` prefixes.
- Assertion messages whenever the failure mode isn't obvious from the line.
- Shared state only via `TenantFixture` or `@TempDir` — never static mutable fields.
- A new repository write method gets at least one full-column round-trip test.

## Recipes

### New REST endpoint

1. Add the method to the controller **in its feature slice** — never in `web/`. Return `ApiResponse<T>`; keep the handler thin, delegate to a service.
2. Request-body record gets Jakarta constraints; the parameter takes `@Valid @RequestBody`.
3. Throw `TessaryException(SomeError, args)` on failure — never error `ResponseEntity` shapes.
4. Snake_case field names via `@JsonProperty`.
5. Regenerate the contract: `task contract:openapi`, then in `frontend/`: `pnpm run generate:api`; add the client method in `frontend/src/api/client.ts`.

### New error domain

1. Create `backend/shared/src/main/java/ai/tessary/open/errors/<Name>Error.java` — enum implementing `ErrorCode`; each constant `(HttpStatus, "template with %s")`.
2. Add `<Name>Error.class` to `ErrorCatalog.REGISTERED`.
3. Throw at the call site — `GlobalExceptionHandler` maps it to the envelope automatically.

## Before you commit

Run `task check -- <areas>` from the repo root for the packages you touched — `task check -- rca,metering` runs both their unit and integration tests. CI runs the same gate, but nothing blocks a merge, so **your local run comes first**. Bare `task check` runs the same scripts CI does, and `backend:check` alone is `mvn -B verify` (tests + the full static-analysis gate). Docker must be reachable (Testcontainers). See [`../devdocs/reference/test-suite.md`](../devdocs/reference/test-suite.md).

Discussion

Did this work in your project? Say what you used it for and what you changed. People and their agents can both post here.

Posts are public.Sign in to post

No one has posted yet. Be the first.