diff --git a/docs/changelog.md b/docs/changelog.md
index 8e589bd2ff..2cb2568ab4 100644
--- a/docs/changelog.md
+++ b/docs/changelog.md
@@ -3,6 +3,240 @@
> **Purpose:** Living document tracking all changes, decisions, and reasoning during implementation. Updated as work progresses for easy reference and review.
+---
+
+## ๐ More PR review follow-ups (2026-07-22)
+
+**Repo:** EDDI (`fix/orphan-scan-and-quota-defects`)
+
+- **`UsageSnapshotSerializationTest.productionMapper()` stopped mirroring the real REST mapper.** It called `SerializationCustomizer.configureObjectMapper(...)` directly โ which, since the persistence/REST mapper split earlier in this branch, builds the *persistence* recipe (no `Instant` override), not the REST/CDI one. The assertions still passed, because `costMonth` is a `YearMonth` with `@JsonFormat` on the field directly and is unaffected by the `Instant` `configOverride` โ but the test's stated purpose ("pins the REST wire shape") was no longer true, and it provided zero coverage of the actual production mapper. Same class of defect fixed earlier in `SerializationCustomizerInstantFormatTest` (`a186dc903`): the fixture now builds the mapper via `new SerializationCustomizer(false).customize(mapper)`, matching how Quarkus actually constructs it.
+- **Inline FQN in `DescriptorStoreTest`.** `new java.util.ArrayList<>()` where `java.util.List` was already imported. Added the top-level import; this file came in via the `origin/main` merge, not authored on this branch, but the convention applies regardless of origin.
+
+---
+
+## ๐ Merge `origin/main` into `fix/orphan-scan-and-quota-defects` โ changelog conflict resolution (2026-07-21)
+
+**Repo:** EDDI (`fix/orphan-scan-and-quota-defects`)
+
+Brought the branch up to date with `origin/main`, which had picked up the `fix/descriptor-store-limit-semantics` PR (`limit=0` now means "unlimited" in `DescriptorStore.readDescriptors`, with a `MAX_RESULT_LIMIT` safety ceiling) via a background task spawned earlier in this effort. Both that PR and this branch touch `DescriptorStore.readDescriptors`, so `docs/changelog.md` conflicted at the top (both sides prepend); `DescriptorStore.java` and its test merged automatically without conflict โ this branch's `includeDeleted` inclusion-flag fix and main's `resolveDescriptorLimit`/ceiling-warning logic touch disjoint parts of the same method. Kept both changelog blocks whole, this branch's newest-first on top, main's independent entry below.
+
+## ๐ PR review follow-ups โ a vacuous test found and fixed (2026-07-21)
+
+**Repo:** EDDI (`fix/orphan-scan-and-quota-defects`)
+
+Addressing CodeRabbit and Copilot review comments. One of them exposed a test that could not fail.
+
+**The `MAX_PAGES` ceiling test was vacuous.** CodeRabbit noted the new page ceiling had no coverage. Adding a test made it pass immediately โ but mutation-checking it (disabling the ceiling so the walk truncates silently) showed it *still* passed. Cause: the fixture left `agentStore.read` unstubbed, so the traversal NPE'd on a null config, marked the scan incomplete, and produced the expected 409 **for the wrong reason**. Stubbing the agent read so the ceiling is the only possible failure source, plus asserting the refusal message names it, makes the test load-bearing โ the mutant now dies with "Expected WebApplicationException to be thrown, but nothing was thrown."
+
+**Copilot's `WRITE_DATES_AS_TIMESTAMPS` finding: premise wrong, instinct right.** It claimed the produced `@PersistenceMapper` could change the on-disk shape. It cannot โ Jackson enables that feature by default, so the producer already emitted numeric. But the real defect was next door: `SerializationCustomizerInstantFormatTest` *reconstructed* the producer instead of calling it, and set the flag itself โ so it would have passed even if the producer were broken. The test now builds the mapper via `new PersistenceMapperProducer().persistenceMapper()`, and the producer states the flag explicitly. Relying on a library default for a persistence-format guarantee is precisely what `dc117cddc` was reverted for. Mutation-checked: flipping the producer to ISO now fails two tests.
+
+**TOCTOU on the agent quota โ documented, not fixed, and the earlier claim corrected.** CodeRabbit correctly flagged that concurrent deploys observing `count == limit - 1` all pass. An internal note had called this "self-correcting"; that was wrong โ once over, the gate merely refuses further deploys until an undeploy brings the count down. The javadoc now states the bound honestly and explains why a per-tenant lock is *not* used: it would serialize within one JVM while the count spans the shared store and every node's registry, giving the appearance of a hard guarantee exactly where it would not hold. Accepted because deploys are rare admin operations and the gate's purpose โ stopping runaway growth such as an LLM creating sub-agents in a loop โ survives a small transient overrun.
+
+**Log injection.** `sanitize(conversationId)` added to the new quota-denial log line in `RestAgentEngine`, matching the rest of the class (lines 331, 368, 389, 436).
+
+**Not actioned:** the advisory note that the orphan endpoint blocks a request thread. Pre-existing, explicitly raised as advice rather than a blocker, and moving it to `AsyncResponse` with a polling status endpoint is a separate change.
+
+---
+
+## ๐ข LLM โ stop discarding agent-mode token usage (2026-07-21)
+
+**Repo:** EDDI (`fix/orphan-scan-and-quota-defects`)
+
+`AgentOrchestrator` sums `TokenUsage` across every model call in the tool loop and returns it on `ExecutionResult.responseMetadata()` โ on both the live path and the resume path. `LlmTask` then read `.response()` and `.trace()` from that result and **never `.responseMetadata()`**, so agent-mode token accounting was computed and dropped on the floor. Only the legacy-chat and cascade branches surfaced theirs.
+
+Net effect: any agent with tools enabled reported `{}` for `responseMetadataObjectName`, and no per-turn token figure existed for the paths that dominate real usage. This is the prerequisite for monthly cost metering โ there was nothing to meter.
+
+Both agent branches now read the metadata, and `executeResume` surfaces it the same way `executeTask` does (it previously built no metadata map at all).
+
+### Known gap, deliberately documented rather than papered over
+
+A turn that pauses for tool approval loses its pre-pause usage: `ToolApprovalRequiredException` escapes before the metadata is assembled and carries no usage, and `resumeToolLoop` starts a fresh accumulator. So a paused turn under-reports by its pre-pause segment. Closing that requires threading the step into `runToolCallLoop` so usage is written incrementally โ out of scope here.
+
+### Safety check
+
+`responseMetadata` also feeds `applyResponseValidation`, which branches on `warning` and `streamingTimeout`. `AgentOrchestrator` puts **only** `tokenUsage` into the map (`AgentOrchestrator.java:470,826`), so both of those keys stay absent exactly as they were with the previously-empty map โ validation behaviour is unchanged.
+
+### Verification limits
+
+Not unit-covered: `LlmTask` constructs its `AgentOrchestrator` internally rather than receiving it injected, so `executeIfToolsEnabled` cannot be stubbed to return a non-null result at the `LlmTask` unit level, and every existing `LlmTask` test therefore exercises the legacy branch. The change was verified by reading both sides of the contract and by the safety check above; all 184 `LlmTask` tests stay green. Making this properly testable means injecting `AgentOrchestrator` โ a worthwhile refactor of an already 29-argument constructor, but a separate change.
+
+---
+
+## ๐ Serialization โ split the persistence mapper from the REST mapper; Instant is ISO-8601 on the wire (2026-07-21)
+
+**Repo:** EDDI (`fix/orphan-scan-and-quota-defects`)
+
+โ ๏ธ **REST contract change**, and it requires a companion EDDI-Manager fix (below).
+
+Every `java.time.Instant` on every endpoint rendered as a **1970 date** in the Manager. With `quarkus.jackson.write-dates-as-timestamps=true` (`application.properties:174`) plus `JavaTimeModule`, an `Instant` serializes as fractional epoch **seconds** (`1719964800.123`), while clients call `new Date(value)`, which expects **millis**. Affected `nextFire`, `lastFired`, `pausedAt`, `createdAt`, `updatedAt`, transcript timestamps โ essentially every timestamp in the UI.
+
+The obvious fix โ a `configOverride(Instant.class)` in the shared `configureObjectMapper` โ is the trap. `JsonSerialization` `@Inject`s the **same CDI `ObjectMapper`**, and it backs `DocumentBuilder` for every Mongo write, every Postgres JSONB column, the backup/export writer, and the `{json:serialize}` Qute extension available to agent authors. That would change **on-disk formats**. Concretely, `GroupConversation.lastModified` is a persisted `Instant` that `GroupConversationStore` sorts **server-side** on: Mongo's BSON cross-type ordering ranks all Doubles before all Strings, and Postgres does `ORDER BY data->>'lastModified'` lexicographically โ so old numeric rows and new ISO rows would interleave wrongly, silently, with no backfill. Commit `dc117cddc` ("keep numeric date format") already reverted a broader version of this once for breaking `findDueSchedules`.
+
+So the two mappers are now separated:
+
+- New `@PersistenceMapper` qualifier + `PersistenceMapperProducer`, built from the same `configureObjectMapper` recipe **without** the date override. `JsonSerialization` injects that.
+- The `Instant` โ ISO override moved into `SerializationCustomizer.customize()` โ the REST/CDI path only, never the shared static.
+
+### Design decisions
+
+- **`java.util.Date` deliberately untouched.** It already emits epoch millis and its consumers (e.g. `DocumentDescriptor.lastModifiedOn`) are correct today; widening the override would break working paths. The wire therefore carries two encodings by design: `Instant` = ISO-8601 string, `Date` = epoch-millis number.
+- **`application.properties:174` must stay.** Quarkus's own default for `write-dates-as-timestamps` is `false`, so that line is what *prevents* Quarkus disabling the feature globally. Deleting it as "redundant documentation" would flip persistence to ISO โ the exact break this change avoids.
+- **A qualified CDI producer, not `new ObjectMapper()` inside `JsonSerialization`.** Keeps the persistence mapper a first-class, overridable bean and leaves the existing direct-construction test fixtures working unchanged.
+- **Deserialization is unaffected in both directions** โ `InstantDeserializer` dispatches on the JSON token type, not the shape hint โ so rows written numerically still parse.
+
+### Required companion change (EDDI-Manager repo)
+
+The Schedules dashboard sorts arithmetically: `(a.nextFire ?? 0) - (b.nextFire ?? 0)`, which yields `NaN` on ISO strings, leaving the "next fire" tile showing an arbitrary schedule. Change it to `new Date(a.nextFire) - new Date(b.nextFire)`, which works with **both** encodings and can therefore land before or after this commit. Every other consumer (`new Date(x).toLocaleString()`) becomes correct automatically. The vendored bundle under `META-INF/resources/assets/` is a build artifact and was deliberately not hand-edited.
+
+### Verification limits
+
+CDI wiring for the new qualified producer **cannot be validated locally** โ `quarkus:build` augmentation needs a loopback socket, which this environment refuses, and the repo has only one non-IT `@QuarkusTest`. CI is the gate for that specific aspect. The format behaviour itself is fully covered by unit tests.
+
+### Tests
+
+New `SerializationCustomizerInstantFormatTest` (7 tests) asserts both halves: the REST mapper emits ISO and keeps `Date` numeric; the persistence mapper stays numeric and **does not inherit** the REST override despite sharing `configureObjectMapper`; and both still read the old numeric form.
+
+---
+
+## ๐ซ Tenancy โ enforce `maxAgentsPerTenant` on deploy (2026-07-21)
+
+**Repo:** EDDI (`fix/orphan-scan-and-quota-defects`)
+
+`TenantQuota.maxAgentsPerTenant` was persisted end-to-end by all three stores and round-tripped through the REST API, but **nothing ever read it** โ `TenantQuotaService` had no agent method at all. Operators could set the limit and get silent no-enforcement.
+
+New `TenantQuotaService.checkAgentQuota(tenantId, currentDistinctAgents)` gates `RestAgentAdministration.deployAgent`, denying with `QuotaExceededException` โ **429** `{"error":"quota_exceeded"}` via the existing mapper.
+
+### Design decisions
+
+- **A read-only gate, not an atomic counter.** The deployed-agent count is a *stock* derived by counting current deployments, not a per-window *flow*. A stored counter would drift irrecoverably: the 10s re-deploy sweep, the 24h old-version undeploy, `TeardownAgentTool`, `GroupConversationService` and the lazy re-deploy on first use all add or remove deployments without passing any acquire/release point. Modelled on `checkCostBudget`, so no `ITenantQuotaStore` method was added and the three store implementations are untouched.
+- **Placement is forced, not stylistic.** The gate sits between the null-checks and the `try`. Inside the `try`, `catch (Exception) โ InternalServerErrorException` would convert the 429 into a **500**; inside the submitted `Callable` it runs off the request thread and could never produce a status code at all.
+- **Counts distinct agent ids**, so redeploys and version bumps are free โ required because the old-version undeploy sweep legitimately keeps two versions of one agent deployed while the previous drains.
+- **The count unions persisted rows with live agents, and needs both.** `autoDeploy=false` never writes a `deployed` row, but the in-memory deploy is unconditional โ so a rows-only count let a caller deploy unlimited agents with one query parameter. Those agents are genuinely live: `getLatestReadyAgent` serves them without consulting the store, and `ConversationService.getAgent` lazily re-deploys them after a restart, so the bypass is **durable**, not merely transient. Conversely, a live-agents-only count would be per-JVM and would miss other cluster nodes.
+- **Fails open.** A store outage must not block deployments; the denial is logged and metered either way.
+- **The two CDI callers surface the reason.** `AgentSetupService.deployAndWait` and `McpAdminTools.deployAgent` call the bean directly, so the exception mapper never runs โ both now return the quota reason instead of "check server logs", which an agent designer (or a model driving `create_sub_agent`) cannot act on and would retry in a loop.
+- **Scheduled sweeps stay ungated by construction** โ they call `IAgentFactory` directly, so lowering the limit never undeploys anything. The limit gates *new* agents only.
+
+### Not addressed
+
+Single-tenant only: the gate resolves `getDefaultTenantId()`, exactly as the conversation and api-call quotas already do. This is a deployment-wide cap, not per-organisation, until the multi-tenancy plan's Phase 1 lands. Do not describe it as multi-tenant enforcement.
+
+### Tests
+
+New `RestAgentAdministrationQuotaTest` (8 tests): denial throws `QuotaExceededException` and submits nothing to the runtime; two versions of one agent count once; redeploy skips the quota entirely; **live agents with no `deployed` row still count** (the loophole); no double counting; non-`READY` agents skipped; null agent ids skipped; store error fails open. Mutation-checked โ reverting to a rows-only count fails exactly the loophole test. `TenantQuotaServiceTest` gains 5 tests including parity with `checkCostBudget` on not inflating the `allowed` counter. Both existing `RestAgentAdministration*Test` classes updated for the new constructor arg in the same commit.
+
+---
+
+## ๐งน Orphan admin โ `includeDeleted` becomes a true inclusion flag; purge default flipped (2026-07-21)
+
+**Repo:** EDDI (`fix/orphan-scan-and-quota-defects`)
+
+โ ๏ธ **REST behaviour change on `DELETE /administration/orphans`.**
+
+`DescriptorStore.readDescriptors` treated `includeDeleted` as an **equality** filter โ `eq("deleted", includeDeleted)` โ so `includeDeleted=true` matched *only* soft-deleted descriptors rather than adding them to the live ones. Two consequences:
+
+- The parameter did not mean what its name, its `@Parameter` text, and `docs/deployment-management-of-agents.md` all said it meant.
+- The shipped Manager scans with `includeDeleted=false` and purges with `includeDeleted=true`, so **the set shown to the user and the set deleted were disjoint**. The UI listed live orphans and then purged soft-deleted ones.
+
+`true` now drops the `deleted` constraint entirely (live **and** soft-deleted); `false` constrains to live only. Every other caller in `src/main` passes a literal `false` โ verified by enumerating all ~35 call sites โ so their behaviour is bit-for-bit unchanged.
+
+**`purgeOrphans`'s `@DefaultValue` flipped from `true` to `false`.** Left at `true`, the semantics fix would have made the parameterless `DELETE` dramatically *more* destructive โ combined with the page-walk fix two commits earlier, from "purge โค200 already-soft-deleted rows" to "permanently wipe every unreferenced resource, unbounded". Flipping the default makes the bare call the conservative one and matches `scanOrphans`, so a scan and a purge with no parameters now describe the same set.
+
+### Client impact
+
+A client that relied on the old default now purges **less**: live-but-unreferenced orphans only, not soft-deleted ones. Pass `includeDeleted=true` explicitly to also purge soft-deleted resources. EDDI-Manager should send the flag explicitly, matching whichever set it is displaying.
+
+### Tests
+
+`DescriptorStoreTest` gains two tests capturing the actual `QueryFilters` and asserting the `deleted` constraint is present for `false` and **absent** for `true`; mutation-checked by restoring the equality filter, which fails the second. `RestOrphanAdminSafetyTest` gains two tests pinning the flag's propagation. All 86 tests across every descriptor-store consumer green.
+
+---
+
+## ๐ Review follow-ups on the orphan/tenancy fixes (2026-07-21)
+
+**Repo:** EDDI (`claude/eddi-backend-manager-coverage-0598fe`)
+
+Self-review of the three preceding commits found three defects, all fixed here.
+
+- **The 409 refusal carried no body.** `purgeOrphans` threw `new WebApplicationException(String, Response.Status)`, whose response has **no entity** โ so an operator saw a bare 409 and the reason existed only in the server log. Confirmed empirically by mutation: reverting to that constructor makes the new `hasEntity()` assertion fail. Now builds the `Response` explicitly with `{"error":"incomplete_scan","message":โฆ}`.
+- **Broken javadoc link.** The page-walk javadoc still referenced `buildReferencedUrisSet()`, renamed to `scanReferencedUris()` in the same commit. Also updated three now-inaccurate `@DisplayName`s in `RestOrphanAdminBranchTest`.
+- **Postgres had no at-limit test.** The `>` โ `>=` change was mutation-verified on Mongo but not Postgres. Added `PostgresTenantQuotaStoreTest.exactlyAtLimit` and mutation-checked it too.
+
+Also documented why a `MAX_PAGES` trip during orphan *collection* is safely swallowed while the same failure in the *reference* scan blocks the purge: a type that cannot be enumerated yields fewer delete candidates (under-delete), whereas a missing reference promotes a live resource to "orphan" (over-delete).
+
+**Verification:** full clean suite `tests=11273 failures=8 errors=288 skipped=3` across the same 15 classes as the pre-change baseline โ all environmental (loopback sockets, network egress, model downloads). Zero regressions. Checkstyle clean on every changed file; `javadoc:javadoc` builds.
+
+---
+
+## ๐ฆ Engine โ return 429 instead of 500 when the api-call quota denies a turn (2026-07-21)
+
+**Repo:** EDDI (`claude/eddi-backend-manager-coverage-0598fe`)
+
+`ConversationService.say`/`sayStreaming` throw `QuotaExceededException` when `acquireApiCallSlot()` denies, and `QuotaExceededExceptionMapper` maps that to **429** with `{"error":"quota_exceeded"}` and `Retry-After: 60`. But `say()` is resumed through a JAX-RS `AsyncResponse`, so the exception is caught inside `RestAgentEngine.sayInternal` and never reaches the `@Provider` mapper. Its catch chain lists `AgentMismatchException`, `AgentNotReadyException`, `ConversationEndedException`, `ConversationAwaitingApprovalException`, `ProcessingRestrictedException` and `ResourceNotFoundException` โ but not `QuotaExceededException` โ so the denial fell through to `catch (Exception e)` and surfaced as **500 "An internal error occurred"**.
+
+Only the *conversation-start* quota ever produced a real 429: `startConversationWithContext` is synchronous and its narrower catch block lets the exception escape to the mapper. The per-minute API rate limit โ the quota an operator is most likely to actually hit โ was indistinguishable from a server fault, so clients had no way to back off correctly.
+
+Added an explicit `catch (QuotaExceededException)` that resumes with the same status, body and `Retry-After` header as the mapper, so both quota denials look identical on the wire.
+
+### Design decisions
+
+- **Mirror the mapper rather than re-throw.** There is no way to route an already-captured async exception back through the provider chain, so the branch duplicates the mapper's three-line response. Kept adjacent constants and a comment so the two stay in sync.
+- **Streaming is not covered here.** `sayStreaming` throws the same exception, but by then the SSE response has already committed HTTP 200, so no status code can be sent โ it currently emits a generic `error` event. Giving that event a distinguishable quota type is a separate, client-visible change and is tracked, not slipped in.
+
+### Tests
+
+New `RestAgentEngineTest.quotaExceeded` asserts 429, the `Retry-After: 60` header and the exact entity map. Mutation-checked: replacing the new catch with an unrelated exception type makes it fail with `InternalServerError An internal error occurred` โ reproducing the original bug โ so the test is not vacuous. All 43 `RestAgentEngineTest` tests green.
+
+---
+
+## ๐ต Tenancy โ align the at-limit cost comparison and fix costMonth JSON shape (2026-07-21)
+
+**Repo:** EDDI (`claude/eddi-backend-manager-coverage-0598fe`)
+
+Two independent defects in the tenant cost-budget surface.
+
+**1. The two production quota stores disagreed with the gate at exactly the limit.** `TenantQuotaService.checkCostBudget` denies on `currentCost >= limit`, and `InMemoryTenantQuotaStore.tryAddCost` matches it โ but `MongoTenantQuotaStore` and `PostgresTenantQuotaStore` used `totalCost > limit`. At exactly the budget the pre-call gate denied while post-call accounting allowed. `docs/changelog.md` records the in-memory store being deliberately moved to `>=` for this reason; the two DB stores were never updated. Both now use `>=`.
+
+Verified by mutation: reverting the Mongo comparison to `>` makes the new `exactlyAtLimit` test fail with `expected:
+ * Completeness is reported rather than assumed: every failure here removes
+ * entries from the set, and a missing entry promotes a live resource to
+ * "orphan". Read-only callers may use a partial set; the purge may not.
+ *
+ * The two must not be the same instance. The CDI/REST mapper deliberately emits + * {@code java.time.Instant} as an ISO-8601 string so clients render timestamps + * correctly, but the storage format has to stay numeric: several stores write + * date fields as epoch-millis for range queries, sort server-side on persisted + * timestamps, and read existing rows that were written numerically. Changing + * the stored shape would silently reorder query results and strand old + * documents rather than failing loudly. + *
+ * + * @see SerializationCustomizer + */ +@Qualifier +@Retention(RUNTIME) +@Target({TYPE, METHOD, FIELD, PARAMETER}) +public @interface PersistenceMapper { +} diff --git a/src/main/java/ai/labs/eddi/datastore/serialization/PersistenceMapperProducer.java b/src/main/java/ai/labs/eddi/datastore/serialization/PersistenceMapperProducer.java new file mode 100644 index 0000000000..398c2582c9 --- /dev/null +++ b/src/main/java/ai/labs/eddi/datastore/serialization/PersistenceMapperProducer.java @@ -0,0 +1,43 @@ +/* + * Copyright EDDI contributors + * SPDX-License-Identifier: Apache-2.0 + */ +package ai.labs.eddi.datastore.serialization; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.SerializationFeature; +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.enterprise.inject.Produces; + +/** + * Produces the {@link PersistenceMapper}-qualified {@code ObjectMapper}. + * + *+ * Built from the same {@link SerializationCustomizer#configureObjectMapper} + * recipe as the CDI/REST mapper, but without the REST-only + * {@code Instant} format override that {@code customize()} applies. Everything + * that affects how a document is shaped โ null inclusion, unknown-property + * tolerance, {@code JavaTimeModule} โ is shared, so stored documents keep + * exactly the shape they have today. + *
+ */ +@ApplicationScoped +public class PersistenceMapperProducer { + + @Produces + @ApplicationScoped + @PersistenceMapper + public ObjectMapper persistenceMapper() { + ObjectMapper mapper = SerializationCustomizer.configureObjectMapper(new ObjectMapper(), false); + + // Redundant against today's Jackson default (WRITE_DATES_AS_TIMESTAMPS is + // enabled out of the box), but stated explicitly on purpose: the numeric + // storage format is a correctness requirement, not a preference. Stores write + // epoch-millis for range queries and sort server-side on persisted timestamps, + // so silently inheriting a flipped library default would reorder query results + // rather than fail. This repo has already been bitten once by an implicit date + // format (commit dc117cddc). + mapper.configure(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS, true); + return mapper; + } +} diff --git a/src/main/java/ai/labs/eddi/datastore/serialization/SerializationCustomizer.java b/src/main/java/ai/labs/eddi/datastore/serialization/SerializationCustomizer.java index 26eb3a999c..6921c8c595 100644 --- a/src/main/java/ai/labs/eddi/datastore/serialization/SerializationCustomizer.java +++ b/src/main/java/ai/labs/eddi/datastore/serialization/SerializationCustomizer.java @@ -4,6 +4,7 @@ */ package ai.labs.eddi.datastore.serialization; +import com.fasterxml.jackson.annotation.JsonFormat; import com.fasterxml.jackson.annotation.JsonInclude; import com.fasterxml.jackson.databind.DeserializationFeature; import com.fasterxml.jackson.databind.ObjectMapper; @@ -15,6 +16,8 @@ import jakarta.inject.Inject; import jakarta.inject.Singleton; +import java.time.Instant; + import static com.fasterxml.jackson.databind.SerializationFeature.INDENT_OUTPUT; @Singleton @@ -29,6 +32,34 @@ public SerializationCustomizer(@ConfigProperty(name = "json.prettyPrint") Boolea @Override public void customize(ObjectMapper objectMapper) { configureObjectMapper(objectMapper, prettyPrint); + + // REST-ONLY. Emit java.time.Instant as an ISO-8601 string instead of the + // fractional epoch SECONDS that quarkus.jackson.write-dates-as-timestamps=true + // produces via JavaTimeModule. Clients call new Date(value), which expects + // MILLIS, so ~1.7e9 rendered as a 1970 date on every timestamp in the UI. + // + // This deliberately lives here and NOT in configureObjectMapper: that static is + // the shared recipe, and the @PersistenceMapper mapper is built from it. + // Storage + // must keep the numeric shape โ MongoScheduleStore normalizes dates to + // epoch-millis for range queries, GroupConversationStore sorts server-side on a + // persisted Instant (mixed numeric/string rows would interleave wrongly in both + // Mongo and Postgres), and existing rows were written numerically. See commit + // dc117cddc, which reverted a broader version of this change for breaking + // findDueSchedules. + // + // configOverride lives in MapperConfig._configOverrides, not the + // SerializationFeature bitmask, so it is order-independent with respect to when + // Quarkus applies write-dates-as-timestamps: JSR310FormattedSerializerBase + // .createContextual consults the per-type override first and only falls back to + // the feature flag when none exists. Deserialization is unaffected โ + // InstantDeserializer dispatches on the JSON token type, not the shape hint โ + // so + // numeric values written before this change still parse. + // + // Scoped to Instant only: java.util.Date already emits epoch millis and its + // consumers are correct today, so widening this would break working paths. + objectMapper.configOverride(Instant.class).setFormat(JsonFormat.Value.forShape(JsonFormat.Shape.STRING)); } public static ObjectMapper configureObjectMapper(ObjectMapper objectMapper, Boolean prettyPrint) { diff --git a/src/main/java/ai/labs/eddi/engine/api/IRestAgentAdministration.java b/src/main/java/ai/labs/eddi/engine/api/IRestAgentAdministration.java index 845b16d9c1..10819f0127 100644 --- a/src/main/java/ai/labs/eddi/engine/api/IRestAgentAdministration.java +++ b/src/main/java/ai/labs/eddi/engine/api/IRestAgentAdministration.java @@ -28,9 +28,12 @@ public interface IRestAgentAdministration { @POST @Path("/{environment}/deploy/{agentId}") - @Operation(summary = "Deploy an agent", description = "Deploys the specified agent version to the given environment.") + @Operation(summary = "Deploy an agent", description = "Deploys the specified agent version to the given environment. " + + "Subject to the tenant's maxAgentsPerTenant quota, which counts distinct agent ids โ " + + "redeploying an agent or bumping its version never consumes additional capacity.") @APIResponse(responseCode = "200", description = "Agent deployed (or accepted if async).") @APIResponse(responseCode = "404", description = "Agent not found.") + @APIResponse(responseCode = "429", description = "Tenant agent quota exceeded; undeploy an agent before deploying another.") // @formatter:off Response deployAgent(@PathParam("environment") Environment environment, @PathParam("agentId") String agentId, diff --git a/src/main/java/ai/labs/eddi/engine/internal/RestAgentAdministration.java b/src/main/java/ai/labs/eddi/engine/internal/RestAgentAdministration.java index 60ab7e9e37..21024f23df 100644 --- a/src/main/java/ai/labs/eddi/engine/internal/RestAgentAdministration.java +++ b/src/main/java/ai/labs/eddi/engine/internal/RestAgentAdministration.java @@ -22,6 +22,8 @@ import ai.labs.eddi.engine.runtime.internal.IDeploymentListener; import ai.labs.eddi.engine.runtime.model.DeploymentEvent; import ai.labs.eddi.engine.runtime.service.ServiceException; +import ai.labs.eddi.engine.tenancy.QuotaExceededException; +import ai.labs.eddi.engine.tenancy.TenantQuotaService; import ai.labs.eddi.utils.RuntimeUtilities; import jakarta.enterprise.context.ApplicationScoped; import jakarta.inject.Inject; @@ -50,15 +52,18 @@ public class RestAgentAdministration implements IRestAgentAdministration { private final IDeploymentListener deploymentListener; private final IScheduleStore scheduleStore; private final IRuntime runtime; + private final TenantQuotaService tenantQuotaService; private static final Logger log = Logger.getLogger(RestAgentAdministration.class); @Inject public RestAgentAdministration(IRuntime runtime, IAgentFactory agentFactory, IDeploymentStore deploymentStore, IConversationMemoryStore conversationMemoryStore, IRestConversationStore restConversationStore, - IDocumentDescriptorStore documentDescriptorStore, IDeploymentListener deploymentListener, IScheduleStore scheduleStore) { + IDocumentDescriptorStore documentDescriptorStore, IDeploymentListener deploymentListener, IScheduleStore scheduleStore, + TenantQuotaService tenantQuotaService) { this.runtime = runtime; this.agentFactory = agentFactory; + this.tenantQuotaService = tenantQuotaService; this.deploymentStore = deploymentStore; this.conversationMemoryStore = conversationMemoryStore; this.restConversationStore = restConversationStore; @@ -75,6 +80,12 @@ public Response deployAgent(final Deployment.Environment environment, final Stri RuntimeUtilities.checkNotNull(version, "version"); RuntimeUtilities.checkNotNull(autoDeploy, "autoDeploy"); + // MUST sit before the try below: the catch(Exception) there rethrows as + // InternalServerErrorException, which would turn the mapper's 429 into a 500. + // It must also stay on the request thread โ anything inside the submitted + // Callable runs on the runtime executor and can never produce a status code. + enforceAgentQuota(environment, agentId); + try { Future+ * Counts distinct agent ids, so redeploying an agent or bumping its + * version is always free โ necessary because the old-version undeploy sweep + * legitimately keeps two versions of one agent deployed while the previous one + * drains. + *
+ * + *+ * The count unions two sources, and needs both. Persisted {@code deployed} rows + * are the source of truth across restarts, but they are only written when + * {@code autoDeploy=true} โ while the in-memory deploy is unconditional (see + * {@link #deploy}). Counting rows alone would therefore let a caller deploy + * unlimited agents with {@code autoDeploy=false}, and those agents are + * genuinely live: {@code getLatestReadyAgent} serves them without ever + * consulting the deployment store, and a lazy re-deploy re-materialises them + * after a restart. Counting live agents alone would be per-JVM and would miss + * deployments owned by other cluster nodes. + *
+ * + *+ * Fails open: a store outage must not block deployments. The denial is + * logged and metered either way. + *
+ * + *+ * Known limit โ bounded overshoot under concurrency. The count + * is read, checked, and only then acted on, with no lock spanning the read and + * the eventual write. Concurrent deploys that all observe {@code count == limit + * - 1} will all pass, so the cap can be exceeded by up to the number of + * simultaneous requests. This does not heal on its own: once over, the + * gate simply refuses further deploys until an undeploy brings the count back + * down. + *
+ * + *+ * A per-tenant lock is deliberately not used, because it would only serialize + * within one JVM while the count spans the shared deployment store and every + * node's in-memory registry โ giving the appearance of a hard guarantee in + * exactly the clustered deployments where it would not hold. A real guarantee + * needs a distributed lock or a storage-level constraint, and there is no + * single row to constrain since the count is derived from two sources. The + * overshoot is accepted instead: deploys are rare, human- or agent-initiated + * admin operations rather than a hot path, and the gate's purpose โ stopping + * runaway growth such as an LLM creating sub-agents in a loop โ survives a + * small transient overrun. + *
+ */ + private void enforceAgentQuota(Deployment.Environment environment, String agentId) { + Set+ * Deliberately shaped like {@link #checkCostBudget} rather than + * {@code acquire*Slot()}: the deployed-agent count is a stock derived + * by counting current deployments, not a per-window flow. A stored + * counter would drift irrecoverably, because several paths add or remove + * deployments without passing through any acquire/release point โ the scheduled + * re-deploy sweep, the old-version undeploy sweep, teardown tooling, and the + * lazy re-deploy on first use. Passing the count in also keeps this package + * free of a dependency on the deployment store and keeps the method trivially + * testable. + *
+ * Like {@code checkCostBudget}, this does NOT increment + * {@code quotaAllowedCounter} on the happy path โ that counter is documented as + * counting slot acquisitions only. + * + * @param currentDistinctAgents + * number of distinct agent ids currently deployed for this tenant, + * excluding the agent being deployed + */ + public QuotaCheckResult checkAgentQuota(String tenantId, int currentDistinctAgents) { + TenantQuota quota = quotaStore.getQuota(tenantId); + if (quota == null || !quota.enabled()) { + return QuotaCheckResult.OK; + } + + int limit = quota.maxAgentsPerTenant(); + if (limit >= 0 && currentDistinctAgents >= limit) { + quotaDeniedCounter.increment(); + meterRegistry.counter("eddi.tenant.quota.denied", "tenant", tenantId, "type", "agent").increment(); + String reason = String.format("Agent limit (%d) reached for tenant '%s' โ undeploy an agent before deploying another", limit, + tenantId); + LOGGER.warn(reason); + return QuotaCheckResult.denied(reason); + } + + return QuotaCheckResult.OK; + } + // โโโ Cost Budget โโโ /** diff --git a/src/main/java/ai/labs/eddi/engine/tenancy/model/UsageSnapshot.java b/src/main/java/ai/labs/eddi/engine/tenancy/model/UsageSnapshot.java index a16e64740d..a45c516961 100644 --- a/src/main/java/ai/labs/eddi/engine/tenancy/model/UsageSnapshot.java +++ b/src/main/java/ai/labs/eddi/engine/tenancy/model/UsageSnapshot.java @@ -4,6 +4,8 @@ */ package ai.labs.eddi.engine.tenancy.model; +import com.fasterxml.jackson.annotation.JsonFormat; + import java.time.Instant; import java.time.YearMonth; import java.time.ZoneOffset; @@ -34,7 +36,12 @@ public record UsageSnapshot( double monthlyCostUsd, Instant minuteWindowStart, Instant dayStart, - YearMonth costMonth) { + // Without an explicit shape, Jackson's YearMonthSerializer takes the + // useTimestamp branch under quarkus.jackson.write-dates-as-timestamps=true + // and emits the ARRAY [2026,7] rather than "2026-07". Both quota stores + // already persist this as an ISO string (YearMonth.toString() / + // YearMonth.parse), so only the REST representation disagreed. + @JsonFormat(shape = JsonFormat.Shape.STRING) YearMonth costMonth) { /** * Creates an empty snapshot (zero counters) for an unknown tenant. diff --git a/src/main/java/ai/labs/eddi/modules/llm/impl/LlmTask.java b/src/main/java/ai/labs/eddi/modules/llm/impl/LlmTask.java index ba6efc0f85..50574ef7f6 100644 --- a/src/main/java/ai/labs/eddi/modules/llm/impl/LlmTask.java +++ b/src/main/java/ai/labs/eddi/modules/llm/impl/LlmTask.java @@ -572,6 +572,10 @@ private void executeTask(IConversationMemory memory, Task task, IWritableConvers if (agentResult != null) { responseContent = agentResult.response(); toolTrace = agentResult.trace(); + // AgentOrchestrator sums TokenUsage across every model call in the tool + // loop and returns it here; not reading it dropped all agent-mode token + // accounting on the floor while the legacy branches below kept theirs. + responseMetadata = agentResult.responseMetadata(); usedToolMode = true; if (eventSink != null && responseContent != null && !addToOutputExplicitlyFalse) { eventSink.onToken(responseContent); @@ -597,6 +601,9 @@ private void executeTask(IConversationMemory memory, Task task, IWritableConvers // available responseContent = agentResult.response(); toolTrace = agentResult.trace(); + // See the skipCascade branch above: without this, agent-mode token usage + // is computed by AgentOrchestrator and then silently discarded. + responseMetadata = agentResult.responseMetadata(); usedToolMode = true; // Stream the final agent response if streaming is active if (eventSink != null && responseContent != null && !addToOutputExplicitlyFalse) { @@ -886,8 +893,22 @@ private void executeResume(IConversationMemory memory, LlmConfiguration llmConfi String responseContent = result != null ? result.response() : null; List