From efef9529a714d8e5f7451074f89e95f4b41f4770 Mon Sep 17 00:00:00 2001 From: Gregor Jarisch Date: Wed, 12 Aug 2026 02:35:31 +0200 Subject: [PATCH 1/6] fix: audit ledger self-tamper defect, plus review fixes across CI and docs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Split out of #675 so automated review can actually run — that PR carried a 263-file mechanical refactor alongside these changes, and both CodeRabbit (>100 files) and Copilot (>300) declined to review it. The refactor follows in a stacked PR. The one that matters: AuditLedgerService capped its sequence table at 50,000 conversations and cleared the whole thing on overflow, reasoning that re-seeding from countByConversation was "correct, only slower". It is not. Entries sit in the queue for up to a flush interval, so a conversation with queued entries has consumed positions the store cannot see yet, and the re-seed hands the same position out twice. The verifier grades a duplicate exactly like a gap (BROKEN) and, unlike a gap, `undelivered` cannot exculpate it — so the ledger accused the deployment of deleting audit records because its own bookkeeping wrapped around. On a busy deployment the queue is never empty, so essentially every overflow produced them. Eviction now drops only counters whose positions are all accounted for. A ReentrantReadWriteLock spans "position consumed" to "entry visible in the queue" against eviction, and flush() publishes its in-flight batch under the same lock — writing the tests showed that publishing after the drain left a window where entries were in neither collection. Also: - AgentGroupStore rejected HUMAN_DECIDES as needing "human group members (I6), which are not available yet", 150 lines below its own I6 matrix that validates them. They shipped in 10c; the missing piece is the resume path. - SafeHttpClient claimed a wall-clock timeout across all hops, but the budget is only checked between hops, so a hop that trickles its body hung forever. - ToolRateLimiter.refill() overflowed after ~107 idle days, latching the bucket shut. - ConversationStepRunner registered the in-flight conversation outside the try whose finally unregisters it. - install.sh/install.ps1 (the README's `curl | bash` path) were in no CI path filter and had no lint at all. New shell-lint job. - auto-approve treated an absent check as passing; a conflicted PR that never triggers CI would have been approved as "all checks passed". - 38 broken doc links, 32 from one cause (planning/ computing ../ as if under docs/). Dead code and a committed 1.4 MB grafana.db removed; an orphaned richer dashboard rescued into docs/monitoring/ and wired up. Every fix has a mutation-checked regression test, plus two class-of-bug guards: DocumentationLinksTest (which caught a link that only resolved because Windows is case-insensitive) and the MCP tool-class drift check. --- .gitattributes | 6 + .github/PULL_REQUEST_TEMPLATE.md | 4 +- .github/workflows/auto-approve-copilot.yml | 16 ++ .github/workflows/ci.yml | 66 +++++ .gitignore | 5 + docker-compose.monitoring.yml | 4 + docs/SUMMARY.md | 2 + docs/changelog.md | 231 ++++++++++++++++++ docs/conversations.md | 6 +- docs/creating-your-first-agent/README.md | 35 ++- .../creating-your-first-agent-1.md | 6 +- .../creating-your-first-agent.md | 6 +- docs/httpcalls.md | 6 +- .../monitoring/eddi-operations-dashboard.json | 0 grafana-data/grafana.db | Bin 1441792 -> 0 bytes .../provisioning/dashboards/dashboard.yml | 13 - .../provisioning/datasources/prometheus.yml | 10 - install.ps1 | 3 +- install.sh | 1 + planning/agentic-improvements-plan.md | 48 ++-- planning/documentation-updates-plan.md | 6 +- planning/memory-architecture-plan.md | 2 +- planning/multi-agent-ux-improvements.md | 10 +- planning/observability-and-pipeline-plan.md | 10 +- planning/security-hardening-remaining.md | 14 +- .../configs/groups/mongo/AgentGroupStore.java | 7 +- .../labs/eddi/engine/api/ILogoutEndpoint.java | 36 --- .../eddi/engine/audit/AuditLedgerService.java | 149 +++++++++-- .../engine/httpclient/SafeHttpClient.java | 53 +++- .../internal/ConversationStepRunner.java | 11 +- .../engine/internal/RestAgentManagement.java | 7 +- .../exceptions/CannotExecuteException.java | 14 -- .../labs/eddi/engine/memory/model/Data.java | 8 +- .../modules/llm/tools/ToolRateLimiter.java | 28 ++- .../output/impl/OutputGenerationTask.java | 6 +- .../groups/mongo/AgentGroupStoreTest.java | 15 +- .../eddi/docs/DocumentationLinksTest.java | 218 +++++++++++++++++ .../engine/audit/AuditLedgerServiceTest.java | 133 +++++++++- .../httpclient/SafeHttpClientTimeoutTest.java | 120 +++++++++ .../engine/mcp/McpToolFilterCoverageTest.java | 42 +++- .../llm/tools/ToolRateLimiterTest.java | 69 ++++++ 41 files changed, 1256 insertions(+), 170 deletions(-) rename grafana-data/dashboards/eddi-operations.json => docs/monitoring/eddi-operations-dashboard.json (100%) delete mode 100644 grafana-data/grafana.db delete mode 100644 grafana-data/provisioning/dashboards/dashboard.yml delete mode 100644 grafana-data/provisioning/datasources/prometheus.yml delete mode 100644 src/main/java/ai/labs/eddi/engine/api/ILogoutEndpoint.java delete mode 100644 src/main/java/ai/labs/eddi/engine/lifecycle/exceptions/CannotExecuteException.java create mode 100644 src/test/java/ai/labs/eddi/docs/DocumentationLinksTest.java create mode 100644 src/test/java/ai/labs/eddi/engine/httpclient/SafeHttpClientTimeoutTest.java diff --git a/.gitattributes b/.gitattributes index 824801e82..738b0bf31 100644 --- a/.gitattributes +++ b/.gitattributes @@ -10,3 +10,9 @@ src/main/resources/META-INF/resources/assets/** linguist-generated # Install scripts -- enforce correct line endings per platform *.sh text eol=lf *.ps1 text eol=crlf + +# Git hooks have no extension, so *.sh above does not reach them. They are LF in +# the repo today only by luck: a CRLF hook is not merely untidy, it fails to +# execute on Linux and macOS ("bad interpreter"), which would silently disarm the +# force-push guard for everyone who activated it. +.githooks/** text eol=lf diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md index e8102e208..fad5837a8 100644 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -36,10 +36,10 @@ Closes # ## Checklist -- [ ] My code follows the project's [code style](CONTRIBUTING.md#code-style) +- [ ] My code follows the project's [code style](https://github.com/labsai/EDDI/blob/main/CONTRIBUTING.md#code-style) - [ ] I have added tests that prove my fix/feature works - [ ] Existing tests pass locally (`./mvnw clean verify -DskipITs`) - [ ] I have updated documentation if needed -- [ ] My commit messages follow [conventional commits](CONTRIBUTING.md#commit-convention) +- [ ] My commit messages follow [conventional commits](https://github.com/labsai/EDDI/blob/main/CONTRIBUTING.md#commit-convention) - [ ] I have not committed any secrets, API keys, or tokens - [ ] This PR has a clear, focused scope (one concern per PR) diff --git a/.github/workflows/auto-approve-copilot.yml b/.github/workflows/auto-approve-copilot.yml index 264f0c4d3..c945d18b6 100644 --- a/.github/workflows/auto-approve-copilot.yml +++ b/.github/workflows/auto-approve-copilot.yml @@ -208,6 +208,22 @@ jobs: continue; } + // "Every check that ran was green" is not the same claim as "CI + // ran". A merge-conflicted PR never triggers CI/CD at all, so the + // loop above sees only CodeQL et al. and finds nothing failing — + // and the approval body would then assert that all CI checks + // passed on a commit that was never built. Require the gating + // jobs to be PRESENT by name, so absence is a reason to wait + // rather than a silent pass. + const REQUIRED_CHECKS = ['Build & Test', 'Integration Tests']; + const present = new Set(relevant.map(c => c.name)); + const absent = REQUIRED_CHECKS.filter(name => !present.has(name)); + if (absent.length > 0) { + core.info(`PR #${number}: required check(s) never reported: ${absent.join(', ')} ` + + `(merge conflict, or CI did not trigger). Not approving.`); + continue; + } + await github.rest.pulls.createReview({ owner, repo, pull_number: number, event: 'APPROVE', body: 'Auto-approved: GitHub Copilot reviewed with no unresolved comments and all CI checks passed.\n\n' diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e97ba4753..3bf2ae647 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -26,6 +26,7 @@ jobs: runs-on: ubuntu-latest outputs: code: ${{ steps.result.outputs.code }} + scripts: ${{ steps.result.outputs.scripts }} steps: - name: Checkout uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 @@ -49,6 +50,15 @@ jobs: - 'helm/**' - 'mvnw*' - '.mvn/**' + # The installers are the README's headline `curl | bash` path and were + # in NO filter at all, so a PR touching only them skipped the whole + # pipeline — and a skipped required check still satisfies branch + # protection. They do not need the Java build, just the shell lint. + scripts: + - 'install.sh' + - 'install.ps1' + - 'scripts/**' + - '.githooks/**' - name: Resolve id: result @@ -61,8 +71,10 @@ jobs: # with failing tests builds nothing. if [[ "$GITHUB_REF" == refs/tags/* ]]; then echo "code=true" >> $GITHUB_OUTPUT + echo "scripts=true" >> $GITHUB_OUTPUT else echo "code=${{ steps.filter.outputs.code }}" >> $GITHUB_OUTPUT + echo "scripts=${{ steps.filter.outputs.scripts }}" >> $GITHUB_OUTPUT fi - name: Log result @@ -76,6 +88,60 @@ jobs: echo "- ⏭️ Skipping build/test/docker (docs/licenses/config-only change)" >> $GITHUB_STEP_SUMMARY fi + # ─── Job 0b: Shell Lint ───────────────────────────────────────── + # install.sh is 55 KB, install.ps1 is 40 KB, and they are what the README tells + # people to pipe into a shell. Until this job existed nothing checked them at + # all — not a linter, not a test, not even a syntax parse. + shell-lint: + name: Shell Lint + runs-on: ubuntu-latest + needs: detect-changes + if: needs.detect-changes.outputs.scripts == 'true' + + steps: + - name: Checkout + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + + - name: Bash syntax check + # Catches the class of error that makes `curl | bash` fail at the worst + # possible moment — on a user's machine, halfway through an install. + run: | + for f in install.sh .githooks/*; do + [ -f "$f" ] || continue + head -n1 "$f" | grep -qE '^#!.*(bash|sh)' || continue + echo "checking $f" + bash -n "$f" + done + + - name: ShellCheck + # Uses the shellcheck preinstalled on ubuntu-latest rather than a + # third-party action, so there is no extra supply-chain edge to pin. + run: | + shellcheck --version + shellcheck --severity=warning --shell=bash install.sh + + - name: PowerShell syntax check + shell: pwsh + run: | + # Parse-only: never dot-source or run the installer in CI. + $ErrorActionPreference = 'Stop' + $errors = $null + [System.Management.Automation.Language.Parser]::ParseFile( + (Resolve-Path ./install.ps1), [ref]$null, [ref]$errors) | Out-Null + if ($errors) { + $errors | ForEach-Object { Write-Host "::error file=install.ps1,line=$($_.Extent.StartLineNumber)::$($_.Message)" } + exit 1 + } + Write-Host "install.ps1 parses cleanly" + + - name: PSScriptAnalyzer + shell: pwsh + run: | + Install-Module PSScriptAnalyzer -Force -Scope CurrentUser -ErrorAction Stop + $found = Invoke-ScriptAnalyzer -Path ./install.ps1 -Severity Error,Warning + $found | Format-Table -AutoSize | Out-String | Write-Host + if ($found | Where-Object { $_.Severity -eq 'Error' }) { exit 1 } + # ─── Job 1: Build & Test ──────────────────────────────────────── build-and-test: name: Build & Test diff --git a/.gitignore b/.gitignore index 616ceb341..50ae64c0e 100644 --- a/.gitignore +++ b/.gitignore @@ -57,6 +57,11 @@ fabric.properties docker/data/ /tmp/ +# Grafana runtime state. The monitoring stack uses a named Docker volume and +# provisions from docs/monitoring/, so anything written here is local scratch — +# grafana.db (a 1.4 MB SQLite file) used to be committed from the bind-mount era. +/grafana-data/ + # Kubernetes — user-specific values and Helm packaging output helm/eddi/charts/ *.tgz diff --git a/docker-compose.monitoring.yml b/docker-compose.monitoring.yml index d51619320..dda5eb808 100644 --- a/docker-compose.monitoring.yml +++ b/docker-compose.monitoring.yml @@ -40,6 +40,10 @@ services: - "3000:3000" volumes: - ./docs/monitoring/eddi-grafana-dashboard.json:/var/lib/grafana/dashboards/eddi.json:ro + # The provider globs this directory, so a second dashboard needs no + # provisioning change. Was stranded in a top-level grafana-data/ left over + # from the bind-mount era and provisioned by nothing. + - ./docs/monitoring/eddi-operations-dashboard.json:/var/lib/grafana/dashboards/eddi-operations.json:ro - ./docs/monitoring/grafana-provisioning/datasources:/etc/grafana/provisioning/datasources:ro - ./docs/monitoring/grafana-provisioning/dashboards:/etc/grafana/provisioning/dashboards:ro - grafana-data:/var/lib/grafana diff --git a/docs/SUMMARY.md b/docs/SUMMARY.md index 023ca9f9c..ca681237a 100644 --- a/docs/SUMMARY.md +++ b/docs/SUMMARY.md @@ -68,6 +68,7 @@ - [EU AI Act Compliance](eu-ai-act-compliance.md) - [Compliance Data Flow](compliance-data-flow.md) - [Incident Response Plan](incident-response.md) +- [Security Review](security-review.md) - [Privacy & Data Processing](../PRIVACY.md) ## Advanced Concepts @@ -84,6 +85,7 @@ - [Setting Up EDDI on AWS with MongoDB Atlas](setup-eddi-on-aws-with-mongodb-atlas.md) - [Release & Versioning Strategy](release-versioning.md) - [Release Signing & Verification](release-signing.md) +- [Release Notes — 6.0.2](release-notes-6.0.2.md) - [Metrics & Monitoring](metrics.md) - [Monitoring & Tracing Guide](monitoring/monitoring-guide.md) - [Log Administration](log-administration.md) diff --git a/docs/changelog.md b/docs/changelog.md index 0ab81c811..2212ad461 100644 --- a/docs/changelog.md +++ b/docs/changelog.md @@ -7,6 +7,237 @@ +## 🧪 test: regression cover for every fix in this branch, and a bug the coverage work found (2026-08-12) + +**Repo:** EDDI (`fix/code-review-defects-and-docs`) + +Writing the tests found a defect in the fix they were written for, which is the argument for writing +them. + +**The flush window.** The eviction fix retains any conversation whose chain positions are still +in flight — queued, in the flush batch, or dead-lettered. But `flush()` published its batch to +`inFlightBatch` *after* draining the queue. Between those two statements the entries were in +**neither** collection, so an eviction landing in that window read their conversations as fully +persisted and re-seeded them — reintroducing the exact duplicate the fix exists to prevent, through +a narrower door. The drain and the publish now happen under the same read lock submitters take, so +eviction cannot observe the intermediate state. No I/O is inside the lock. + +**Every fix in this branch now has a mutation-checked regression test.** Not merely "a test that +passes" — in each case the fix was reverted and the test was confirmed to fail, with the message it +would print to whoever broke it: + +| Fix | Test | Reverting the fix produces | +| --- | --- | --- | +| Sequence eviction | `sequenceEvictionKeepsQueuedConversationsUnique` | `expected <[0, 1]> but was <[0, 0]>` — the duplicate itself | +| In-flight batch retention | `sequenceEvictionRetainsTheInFlightBatch` | `expected <1> but was <-1>` (UNSEQUENCED) | +| Eviction still reclaims | `sequenceEvictionReclaimsPersistedConversations` | guards the opposite failure — a "fix" that never evicts | +| Rate-limiter overflow | `idleBucketRefillsRatherThanLatchingShut` | a bucket that denies every call after a long idle | +| `HUMAN_DECIDES` message | `votePhase_humanDecidesIsRejectedPendingResumePath` | fails if the message claims HUMAN members are unavailable | +| MCP class-list drift | `everyToolClassInThePackageIsListed` | names the `Mcp*Tools` class missing from `TOOL_CLASSES` | +| Link rot | `everyRelativeLinkResolves` | names the file and the target that does not resolve | +| ToC drift | `everyDocIsListedInSummary` | names the unreachable page | +| Inline FQNs | `noInlineFullyQualifiedNames` | names file, line and the offending name | + +Two of these deserve note as *class-of-bug* guards rather than single-defect regressions. + +`DocumentationLinksTest` walks every markdown file in the repository and resolves every relative +link. Link rot is invisible to every other check in this build — markdown compiles to nothing, so a +wrong path is indistinguishable from a right one until a human clicks it, which is how 38 of them +accumulated. It found one immediately that the initial sweep had missed: the README banner uses a +repository-root-relative `/screenshots/…`, which a naive resolver sends to the filesystem root. The +link was fine; the resolver was wrong, and now handles the leading `/` the way the forge does. +Documentation *of* link syntax (the `` `![alt](uri)` `` rows in the output-format tables) is excluded +by stripping code spans and fences, not by an ignore list that would rot in turn. + +`ImportStyleTest` enforces AGENTS.md §4.7. These accumulate precisely because nothing fails when one +is added — the code compiles either way, so the rule was advice a reviewer had to catch by eye. +Writing it exposed that the original audit had **under-counted**: its pattern required a package +segment after `java.util`, so `java.util.List` never matched. The real total was 575, not 141, and +the remaining 209 (mostly `java.util.Objects` in `equals`/`hashCode`) are now cleaned up too. The +one genuine exception AGENTS.md allows — `mongo.HistorizedResourceStore extends +datastore.HistorizedResourceStore` and its Modifiable twin — is an explicit allowlist, so adding to +it is a reviewable act rather than silent drift. + +**Two seams were widened for testability, both deliberately.** `RateLimitBucket` became +package-private with a `backdateLastRefill` hook, because a ~107-day idle bucket cannot be reached +through the public API and reflecting into a private field pins the field name rather than the +behaviour. `SafeHttpClient.withDefaultTimeout` became package-private so its five cases can run +without an embedded server — the existing `SafeHttpClientTest` binds a loopback socket in +`@BeforeEach` and therefore only runs where those are available. + +**And a proof rather than an assurance about the 575-name refactor.** Every string literal in all +273 mechanically-changed files was extracted and compared against `origin/main`: byte-identical. +A rewrite that reached inside a literal — a reflective class name, a config key, a log format — +would compile, pass every test, and show up nowhere else. The only six files whose literals changed +are the hand-edited ones, and each change is a message this branch intended to change. + +--- + + + +## 🧹 chore: close the gaps outside the build — installer CI, link rot, dead code, 366 inline FQNs (2026-08-12) + +**Repo:** EDDI (`fix/code-review-defects-and-docs`) + +The second half of the repository review. The pattern in it is worth naming: the engineering +*inside* the pipeline is strong, and nearly every problem found sat in something no automated check +covered. + +**`install.sh` and `install.ps1` had no verification of any kind.** 95 KB of shipped script, the +README's headline `curl … | bash` path, and they were in **no** path filter — so a PR touching only +the installer skipped the entire pipeline, and a skipped required check still satisfies branch +protection. There was no shellcheck, no lint, not even a syntax parse anywhere in `.github/`. A new +`shell-lint` job now runs `bash -n`, ShellCheck (`--severity=warning`, using the runner's own +binary rather than adding a third-party action to pin), a PowerShell **parse-only** check, and +PSScriptAnalyzer. A `scripts` path filter drives it, so installer-only PRs get CI instead of a free +pass. Verified rather than assumed: `install.sh` is already clean at warning severity, so the gate +lands green and any regression is the script's own. + +**The auto-approve workflow treated an absent check as a passing one.** It required every check-run +present on the head commit to be green, but never asserted the gating jobs had *run*. A +merge-conflicted PR never triggers CI/CD at all, so the loop saw only CodeQL et al., found nothing +failing, and would have approved with a body asserting "all CI checks passed" on a commit that was +never built. It now requires `Build & Test` and `Integration Tests` to be present by name. Note this +deliberately still auto-approves docs-only PRs: a job skipped by its `if` still reports a check-run, +so absence means *CI did not run*, not *CI had nothing to do*. + +**A 1.4 MB SQLite file, and a dashboard nobody could see.** The top-level `grafana-data/` was left +over from a bind-mount era — the monitoring stack has since moved to a named Docker volume +provisioned from `docs/monitoring/`, so nothing referenced the directory at all. It held +`grafana.db` (runtime state, committed), superseded provisioning copies, and +`eddi-operations.json`: a genuinely different, richer dashboard ("Operations Command Center", 21 +panels) that was being maintained while being provisioned by nothing. Deleting it would have thrown +away the useful part, so it moved to `docs/monitoring/eddi-operations-dashboard.json`, is mounted +alongside the existing dashboard (the provider globs the directory, so no provisioning change), and +is downloaded by both installers. The rest is gone and the path is now git-ignored. + +**Dead code.** `CannotExecuteException` had no reference anywhere in main or test. `ILogoutEndpoint` +was a JAX-RS interface declaring `/user/isAuthenticated` and `/user/securityType` with **no +implementing class** — the only `@Path("/user")` in the codebase, so those endpoints were advertised +to OpenAPI and served by nothing. Both removed. + +**366 inline fully-qualified names across 168 files**, against AGENTS.md §4.7's own rule. These were +not the permitted disambiguation case — `PendingApprovalSummary`, `HitlDecision`, +`ToolApprovalsConfig`, `ConversationMemorySnapshot` and `ControlSignal` each resolve to exactly one +class, and `IConversationService` imported `java.util.List` on line 17 while writing +`java.util.List<…>` on line 355. The two genuine cases — `mongo.HistorizedResourceStore extends +datastore.HistorizedResourceStore` and its Modifiable twin — were detected and left alone. Verified +with a **clean** `test-compile`, since an incremental build reuses stale classes and hides exactly +this kind of break. + +**Link rot: 38 broken links, now zero.** Every `planning/*.md` file computed `../` and `../../` as +though it lived under `docs/planning/`, but the directory is at the repo root — so `../../AGENTS.md` +pointed outside the repository. That one mistake accounted for 32 of them; two more were genuinely +stale paths (`LifecycleManager` moved to `lifecycle/internal/`, and a `WebScraperToolSsrfTest` that +no longer exists). The `.gitbook/assets/` directory referenced by the tutorials does not exist at +all, which broke the **onboarding** path specifically: the "creating your first agent" pages linked +to a Postman collection, and `conversations.md`/`httpcalls.md` to sample agents. Rather than +re-point at files that are gone, those now tell the reader to import EDDI's own `/openapi` into +Postman — generated from the running build, so it cannot go stale the way a committed collection +did. The first diagram a new user meets was a broken image; it is now a Mermaid diagram of the +actual config-and-pipeline model (Mermaid already renders in `docs/architecture.md`). + +`docs/SUMMARY.md` was missing `security-review.md` and `release-notes-6.0.2.md`; every page under +`docs/` is now in the table of contents. The PR template's two `CONTRIBUTING.md` links resolve +correctly in a rendered PR body but 404 in the file's own blob view — neither relative form is right +in both, so they are absolute now. + +**Smaller items.** `.githooks/**` gained a `text eol=lf` attribute: `*.sh` does not match an +extensionless hook, so the force-push guard was LF-in-repo by luck, and a CRLF hook does not merely +look untidy — it fails to execute on Linux and macOS, silently disarming itself. Three +`new Random()` allocations on request paths in application-scoped beans became +`ThreadLocalRandom.current()`. + +**And the guard that guards the guard.** `McpToolFilterCoverageTest` pins both directions of the MCP +allowlist, but both start from a hand-maintained `TOOL_CLASSES` list — so a brand-new `Mcp*Tools` +class nobody added would have its tools invisible *and* leave every assertion green, which is the +exact failure mode the file exists to prevent, one level up. The file documented this as the one +thing it could not check. It can: the compiled classes are already on disk next to the ones under +test, so counting them needs no indexing dependency. Mutation-checked by dropping `McpDocTools` from +the list — the new test fails, and it names the missing class. + +--- + + + +## 🛡️ fix: the audit ledger could report itself as tampered, plus four smaller defects from a full-repo review (2026-08-12) + +**Repo:** EDDI (`fix/code-review-defects-and-docs`) + +A critical review of the whole repository. Most of what it looked for was not there — no swallowed +exceptions, no non-thread-safe statics, no mutable state in the singleton lifecycle tasks, zero +`@Disabled` tests across 14,301 of them — so the findings are few but one of them matters. + +**The audit ledger manufactured `ChainStatus.BROKEN` under load.** `AuditLedgerService` caps its +sequence table at 50,000 conversations and used to `clear()` the whole thing on overflow, on the +stated reasoning that re-seeding from `countByConversation` was *"correct, only slower"*. It is not. +Entries sit in the in-memory queue for up to a flush interval (longer while a failing store is being +retried), so a conversation with queued entries has consumed chain positions the store cannot see +yet. Re-seeding from the store count therefore **hands the same position out twice** — and the +verifier grades a duplicate exactly like a gap: *"Reporting INTACT here would hand an auditor a +false assurance."* The `undelivered` table exists precisely so the ledger's own back-pressure cannot +read as tampering, but it only exculpates **gaps**; duplicates had no such channel. On a busy +deployment the queue is never empty, so essentially every overflow produced them. + +The fix replaces the wholesale `clear()` with an eviction that only drops counters whose positions +are all accounted for somewhere a re-seed can see them — persisted in the store, or attributed in +`undelivered`. Conversations still represented in the queue, in the in-flight flush batch, or in the +undelivered table are retained. Three supporting changes make that sound rather than merely +plausible: + +- A `ReentrantReadWriteLock` spans "position consumed" → "entry visible in the queue" on the + submit path (read lock — submitters never contend with each other) against eviction (write lock). + Without it, eviction could still read a conversation as idle while a submitter held a number for + it that nothing could see yet. The window was not theoretical: it contains HMAC and Ed25519 + signing. +- `flush()` publishes the batch it has polled but not yet persisted, because between the poll and a + successful append those positions exist in neither the queue nor the store. It is now + `synchronized` too — the scheduled writer and the `@PreDestroy` final flush could otherwise poll + interleaved halves of the queue into two batches. +- When the table is *still* full after eviction (every counter genuinely in flight), new + conversations get `UNSEQUENCED` rather than a re-seeded collision. That degrades the window to + `UNAVAILABLE` — "the chain cannot be established" — which is honest, where a duplicate is an + accusation. + +One residual case is left deliberately: past `MAX_TRACKED_UNDELIVERED` the undelivered table stops +recording, so a dead-lettered position may be reused. That window already reports `BROKEN` by the +documented fail-strict rule, so the verdict is unchanged — only its reason is. + +Two regression tests, both mutation-checked. With the retain set emptied (simulating the old +`clear()`), `sequenceEvictionKeepsQueuedConversationsUnique` fails with `expected <[0, 1]> but was +<[0, 0]>` — the duplicate itself. Its counterpart pins the opposite direction, so the fix cannot +"pass" by simply never evicting and stranding every later conversation on `UNSEQUENCED`. + +**`HUMAN_DECIDES` blamed a feature that ships.** `AgentGroupStore` rejected the tie policy with +*"needs human group members (I6), which are not available yet"* — roughly 150 lines below its own +"I6 save-time matrix for HUMAN members", which accepts them, validates their `displayName` and warns +about HUMAN moderators. Humans as group members shipped in 10c; what is actually missing is the +resume path a paused tie-break would need. The message now says that. The test pinned the word +"I6", so it was rewritten to assert on the offered alternatives and to fail if the message ever +claims HUMAN members are unavailable again. + +**`SafeHttpClient` documented a guarantee it did not provide.** The class claimed an "overall +wall-clock timeout enforced across all hops", but the budget is only checked *between* hops, so a +single hop that accepts the connection and then trickles its body hung indefinitely and the budget +never fired. Redirect hops already had a 15 s fallback; the initial request had whatever the caller +set, or nothing. Both are now bounded by one `DEFAULT_REQUEST_TIMEOUT`, and the Javadoc states what +is actually true — a per-hop response timeout plus a budget checked between hops. Every in-tree +caller already set its own timeout, so this is a backstop for the next one that does not. + +**A rate-limit bucket could lock shut permanently.** `ToolRateLimiter.refill()` computed +`elapsedNanos * limit` in long arithmetic, which overflows after ~107 idle days at the default limit +of 1000; the wrapped negative drives `tokens` below zero and `tryAcquire` refuses every subsequent +call. One cast. + +**Hardening.** `ConversationStepRunner` registered the in-flight conversation one statement above +the `try` whose `finally` unregisters it. Only an `Error` could strand the entry — the intervening +call swallows `Exception` — but a stranded entry keeps a finished turn's memory reachable and makes +a later cancel signal a dead pipeline, so the registration moved inside. + +--- + + + ## 🔢 docs(mcp): the MCP tool catalogue was eight tools short, and a count sweep of both READMEs (2026-08-11) **Repo:** EDDI (`docs/group-collaboration-refresh`) diff --git a/docs/conversations.md b/docs/conversations.md index dd691cb85..d7cbb6393 100644 --- a/docs/conversations.md +++ b/docs/conversations.md @@ -641,4 +641,8 @@ Developer tests: ## Sample Agent -Download the [Weather Agent v2 (Postman collection)](.gitbook/assets/weather_bot_v2.zip) to try the full example. +> **Run it yourself.** The GitBook-hosted Postman collections that used to be linked here +> were lost in the migration. You do not need them: every request is shown inline above, +> and Postman can import EDDI's own spec directly — **Import → Link →** +> `http://localhost:7070/openapi`, which is generated from the running build and so is +> never out of date. The same spec is browsable at `/q/swagger-ui`. diff --git a/docs/creating-your-first-agent/README.md b/docs/creating-your-first-agent/README.md index 587d4310f..4dcdc0037 100644 --- a/docs/creating-your-first-agent/README.md +++ b/docs/creating-your-first-agent/README.md @@ -6,7 +6,40 @@ _Prerequisites: Up and Running instance of **EDDI** (see:_ [_Getting started_](. In order to build an Agent with **EDDI**, you will have to create a few configuration files and `POST` them to the corresponding REST APIs. -![](<../.gitbook/assets/eddi-tech-overview-2 (2).jpg>) +An **Agent** points at one or more **Workflows**, and each Workflow lists the +lifecycle steps to run in order. Every step reads its own JSON configuration, so +what the agent *does* lives in configuration rather than in code: + +```mermaid +flowchart TD + subgraph cfg ["What you POST (JSON configuration)"] + direction LR + A["Agent"] --> W["Workflow
which steps, in what order"] + W --> D["Dictionary"] + W --> B["Behavior Rules"] + W --> H["Http Connector"] + W --> O["Output"] + end + + subgraph run ["What happens on every user message"] + direction TB + I["User input"] --> P["Parser
text → expressions"] + P --> R["Behavior Rules
conditions → actions"] + R --> C["Http Connector
call an API"] + R --> G["Output Generation
pick a reply"] + C --> G + G --> Resp["Reply to the user"] + end + + D -.configures.-> P + B -.configures.-> R + H -.configures.-> C + O -.configures.-> G +``` + +The **actions** emitted by Behavior Rules are the whole orchestration mechanism: +steps never call each other directly, they just react to actions. That is why +adding a capability usually means adding a rule and an output, not writing Java. A agent can consists of the following elements: diff --git a/docs/creating-your-first-agent/creating-your-first-agent-1.md b/docs/creating-your-first-agent/creating-your-first-agent-1.md index 792bbcf43..e975f5257 100644 --- a/docs/creating-your-first-agent/creating-your-first-agent-1.md +++ b/docs/creating-your-first-agent/creating-your-first-agent-1.md @@ -610,7 +610,11 @@ By the way you can use the attached **postman collection** below to do all of th 7. Create conversation 8. Say Hello to the agent -Download the [Postman collection](../.gitbook/assets/Creating%20and%20chatting%20with%20a%20bot.postman_collection.json) to run through all the steps above. +> **Run it yourself.** The GitBook-hosted Postman collections that used to be linked here +> were lost in the migration. You do not need them: every request is shown inline above, +> and Postman can import EDDI's own spec directly — **Import → Link →** +> `http://localhost:7070/openapi`, which is generated from the running build and so is +> never out of date. The same spec is browsable at `/q/swagger-ui`. ### External Links diff --git a/docs/creating-your-first-agent/creating-your-first-agent.md b/docs/creating-your-first-agent/creating-your-first-agent.md index 3ac3ac5fe..c371160ab 100644 --- a/docs/creating-your-first-agent/creating-your-first-agent.md +++ b/docs/creating-your-first-agent/creating-your-first-agent.md @@ -163,7 +163,11 @@ By the way you can use the attached **postman collection** below to do all of th 5. Create conversation 6. Say Hello to the agent -Download the [Postman collection](../.gitbook/assets/Creating%20and%20chatting%20with%20a%20bot.postman_collection.json) to run through all the steps above. +> **Run it yourself.** The GitBook-hosted Postman collections that used to be linked here +> were lost in the migration. You do not need them: every request is shown inline above, +> and Postman can import EDDI's own spec directly — **Import → Link →** +> `http://localhost:7070/openapi`, which is generated from the running build and so is +> never out of date. The same spec is browsable at `/q/swagger-ui`. ### External Links diff --git a/docs/httpcalls.md b/docs/httpcalls.md index df6d57f71..966a025a9 100644 --- a/docs/httpcalls.md +++ b/docs/httpcalls.md @@ -790,4 +790,8 @@ _Response Code_ ## Full Example -Download the [Weather Agent Postman Collection](.gitbook/assets/EDDI%20-%20Weather%20bot.postman_collection.json) to run the full example. +> **Run it yourself.** The GitBook-hosted Postman collections that used to be linked here +> were lost in the migration. You do not need them: every request is shown inline above, +> and Postman can import EDDI's own spec directly — **Import → Link →** +> `http://localhost:7070/openapi`, which is generated from the running build and so is +> never out of date. The same spec is browsable at `/q/swagger-ui`. diff --git a/grafana-data/dashboards/eddi-operations.json b/docs/monitoring/eddi-operations-dashboard.json similarity index 100% rename from grafana-data/dashboards/eddi-operations.json rename to docs/monitoring/eddi-operations-dashboard.json diff --git a/grafana-data/grafana.db b/grafana-data/grafana.db deleted file mode 100644 index f1dd17ac0b821ae570f35437a30036e74a0032bd..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 1441792 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�bVpJI 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 **Scope.** Capability-based A2A routing, cryptographic agent identity, multimodal context attachments, behavioral counterweights, MCP governance, and session safety (snapshots + forking). > -> **Governing Principles.** All work MUST conform to the Nine Pillars in [`docs/project-philosophy.md`](../project-philosophy.md) and the engine rules in [`AGENTS.md`](../../AGENTS.md). Java is the engine; configuration is logic; security is architecture. +> **Governing Principles.** All work MUST conform to the Nine Pillars in [`docs/project-philosophy.md`](../docs/project-philosophy.md) and the engine rules in [`AGENTS.md`](../AGENTS.md). Java is the engine; configuration is logic; security is architecture. > > **Out of scope (tracked elsewhere).** Memory architecture (see [`memory-architecture-plan.md`](memory-architecture-plan.md)), DAG pipeline (Phase 9), HITL framework (Phase 9b), guardrails (`guardrails-architecture.md`), multi-channel adapters (Phase 11b), visual builder (Phase 13), native image (`native-image-migration.md`). @@ -10,7 +10,7 @@ ## 0. How to read this document -This plan is split into six **Waves** (delivery order) that map to six **Improvements** (topical grouping). The two numberings are kept deliberately distinct to avoid collision with the main roadmap's "Phase N" numbering in [`AGENTS.md` §3](../../AGENTS.md). +This plan is split into six **Waves** (delivery order) that map to six **Improvements** (topical grouping). The two numberings are kept deliberately distinct to avoid collision with the main roadmap's "Phase N" numbering in [`AGENTS.md` §3](../AGENTS.md). | Wave | Improvement | Code status (verified 2026-04-17) | Est. effort | | ------ | ----------------------------------------- | --------------------------------- | ----------- | @@ -21,7 +21,7 @@ This plan is split into six **Waves** (delivery order) that map to six **Improve | Wave 5 | Improvement 3 — Multimodal Attachments | Model only, no pipeline/REST | Medium | | Wave 6 | Improvement 2 — Cryptographic Identity | Signing primitive only | High | -**Verification method.** Every "implemented" claim below has been checked against [src/main/java](../../src/main/java) on branch `fix/security-hardening-6.0.2`. Claims are annotated ✅ (present and wired), ⚠️ (present but not wired end-to-end), ❌ (not present). +**Verification method.** Every "implemented" claim below has been checked against [src/main/java](../src/main/java) on branch `fix/security-hardening-6.0.2`. Claims are annotated ✅ (present and wired), ⚠️ (present but not wired end-to-end), ❌ (not present). --- @@ -31,12 +31,12 @@ This plan is split into six **Waves** (delivery order) that map to six **Improve | Component | Location | Status | Notes | | -------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------- | ------ | ---------------------------------------------------------------------------------------------- | -| [`CapabilityRegistryService`](../../src/main/java/ai/labs/eddi/configs/agents/CapabilityRegistryService.java) | `ai.labs.eddi.configs.agents` | ✅ | In-memory skill index. Rebuilds on agent CRUD. | -| [`CapabilityMatchCondition`](../../src/main/java/ai/labs/eddi/modules/rules/impl/conditions/CapabilityMatchCondition.java) | `ai.labs.eddi.modules.rules.impl.conditions` | ✅ | Behavior-rule condition; wired in `RuleDeserialization`. | -| [`ContentTypeMatcher`](../../src/main/java/ai/labs/eddi/modules/rules/impl/conditions/ContentTypeMatcher.java) | `ai.labs.eddi.modules.rules.impl.conditions` | ✅ | Matches on attachment MIME type. Wired. | -| [`Attachment`](../../src/main/java/ai/labs/eddi/engine/memory/model/Attachment.java) | `ai.labs.eddi.engine.memory.model` | ⚠️ | Metadata record exists. **Not wired to REST input; not fetched by `LlmTask` for forwarding.** | -| [`AgentSigningService`](../../src/main/java/ai/labs/eddi/configs/agents/AgentSigningService.java) | `ai.labs.eddi.configs.agents` | ⚠️ | Ed25519 sign/verify with vault-backed key storage. **Primitive only — no call sites wire it.** | -| [`ToolResponseTruncator`](../../src/main/java/ai/labs/eddi/modules/llm/impl/ToolResponseTruncator.java) | `ai.labs.eddi.modules.llm.impl` | ✅ | Character-based truncation with size note. | +| [`CapabilityRegistryService`](../src/main/java/ai/labs/eddi/configs/agents/CapabilityRegistryService.java) | `ai.labs.eddi.configs.agents` | ✅ | In-memory skill index. Rebuilds on agent CRUD. | +| [`CapabilityMatchCondition`](../src/main/java/ai/labs/eddi/modules/rules/impl/conditions/CapabilityMatchCondition.java) | `ai.labs.eddi.modules.rules.impl.conditions` | ✅ | Behavior-rule condition; wired in `RuleDeserialization`. | +| [`ContentTypeMatcher`](../src/main/java/ai/labs/eddi/modules/rules/impl/conditions/ContentTypeMatcher.java) | `ai.labs.eddi.modules.rules.impl.conditions` | ✅ | Matches on attachment MIME type. Wired. | +| [`Attachment`](../src/main/java/ai/labs/eddi/engine/memory/model/Attachment.java) | `ai.labs.eddi.engine.memory.model` | ⚠️ | Metadata record exists. **Not wired to REST input; not fetched by `LlmTask` for forwarding.** | +| [`AgentSigningService`](../src/main/java/ai/labs/eddi/configs/agents/AgentSigningService.java) | `ai.labs.eddi.configs.agents` | ⚠️ | Ed25519 sign/verify with vault-backed key storage. **Primitive only — no call sites wire it.** | +| [`ToolResponseTruncator`](../src/main/java/ai/labs/eddi/modules/llm/impl/ToolResponseTruncator.java) | `ai.labs.eddi.modules.llm.impl` | ✅ | Character-based truncation with size note. | | `AgentConfiguration.identity`, `.security`, `.capabilities` | `configs/agents/model/AgentConfiguration.java` | ⚠️ | POJO fields exist; `security.*` flags have no consumer. | ### 1.2 Not present (❌) @@ -62,7 +62,7 @@ Every task in every Wave MUST satisfy these rules. Reviewers should reject PRs t ### 2.1 Template syntax -EDDI v6 uses **Qute** (see [`AGENTS.md` §5.1](../../AGENTS.md)). In every config example in this plan and in test fixtures: +EDDI v6 uses **Qute** (see [`AGENTS.md` §5.1](../AGENTS.md)). In every config example in this plan and in test fixtures: - Use **single braces**: `{properties.x}`, not `{{properties.x}}`. - `properties.*` returns **raw values**. Never use `.valueString`, `.valueObject`, etc. @@ -79,7 +79,7 @@ All new fields added to `AgentConfiguration`, `LlmConfiguration`, or any other J - Be optional (nullable, default-valued). - Use `@JsonInclude(JsonInclude.Include.NON_NULL)` on getters (or class-level) so old configs round-trip byte-identical. -- Pass an import round-trip test for a pre-v6 ZIP (use one of the fixtures under [`docs/agent-configs`](../agent-configs/)). +- Pass an import round-trip test for a pre-v6 ZIP (use one of the fixtures under [`docs/agent-configs`](../docs/agent-configs/)). - Be absent from the ZIP export if left at default. ### 2.4 Security of new config fields @@ -488,7 +488,7 @@ When building the chat message, iterate `memory.getCurrentStep().getAttachments( ### 7.5 Behavior rule matching -`ContentTypeMatcher` already matches attachments on `currentStep`. Document in [`docs/behavior-rules.md`](../behavior-rules.md) with example routing `image/*` to an external OCR MCP tool. +`ContentTypeMatcher` already matches attachments on `currentStep`. Document in [`docs/behavior-rules.md`](../docs/behavior-rules.md) with example routing `image/*` to an external OCR MCP tool. ### 7.6 Cost accounting @@ -647,12 +647,12 @@ UI tasks live in **EDDI-Manager**, not this repo. Track in `EDDI-Manager/AGENTS. ### 9.2 Documentation updates (per Wave) -- [`docs/architecture.md`](../architecture.md) — new components and flows. -- [`docs/behavior-rules.md`](../behavior-rules.md) — new condition types with examples. -- [`docs/langchain.md`](../langchain.md) — counterweight, tool-loading strategy, response limits. -- [`docs/security.md`](../security.md) — signing model, replay protection, key rotation. -- [`docs/compliance-data-flow.md`](../compliance-data-flow.md) — attachments in flow diagrams. -- [`docs/changelog.md`](../changelog.md) — per-PR entries per [`AGENTS.md` §2](../../AGENTS.md). +- [`docs/architecture.md`](../docs/architecture.md) — new components and flows. +- [`docs/behavior-rules.md`](../docs/behavior-rules.md) — new condition types with examples. +- [`docs/langchain.md`](../docs/langchain.md) — counterweight, tool-loading strategy, response limits. +- [`docs/security.md`](../docs/security.md) — signing model, replay protection, key rotation. +- [`docs/compliance-data-flow.md`](../docs/compliance-data-flow.md) — attachments in flow diagrams. +- [`docs/changelog.md`](../docs/changelog.md) — per-PR entries per [`AGENTS.md` §2](../AGENTS.md). ### 9.3 MCP server surface additions @@ -712,7 +712,7 @@ A Wave is "done" only when: - [ ] Manager UI surface is designed (implementation may lag). - [ ] Documentation updates merged. - [ ] `docs/changelog.md` entry added. -- [ ] At least one end-to-end demo agent config in [`docs/agent-configs/`](../agent-configs/) exercises the feature. +- [ ] At least one end-to-end demo agent config in [`docs/agent-configs/`](../docs/agent-configs/) exercises the feature. - [ ] Metrics visible on `docs/monitoring/eddi-grafana-dashboard.json`. ### 10.3 Rollback strategy @@ -732,14 +732,14 @@ Each Wave is deployable independently. Feature flags: For any agent picking this up mid-stream: -1. Read [`docs/project-philosophy.md`](../project-philosophy.md) first — non-negotiable. -2. Read [`AGENTS.md`](../../AGENTS.md), especially §4 (Java guidelines) and §5 (agent config authoring). +1. Read [`docs/project-philosophy.md`](../docs/project-philosophy.md) first — non-negotiable. +2. Read [`AGENTS.md`](../AGENTS.md), especially §4 (Java guidelines) and §5 (agent config authoring). 3. Run `git status`, `git branch --show-current`, `git log -5 --oneline`. -4. Check [`docs/changelog.md`](../changelog.md) for recent entries on any of the six Waves. +4. Check [`docs/changelog.md`](../docs/changelog.md) for recent entries on any of the six Waves. 5. Do NOT trust historical "implemented" callouts in older plan revisions without re-verifying via `file_search` / `grep_search`. 6. Do NOT commit to `main`. Create a `feature/agentic-` branch. 7. Every commit MUST build (`./mvnw compile`) and new tests MUST pass (`./mvnw test -Dtest=`). -8. When finishing or switching context, append an entry to [`docs/changelog.md`](../changelog.md) describing what shipped, what's in progress, and what's next. +8. When finishing or switching context, append an entry to [`docs/changelog.md`](../docs/changelog.md) describing what shipped, what's in progress, and what's next. --- @@ -749,7 +749,7 @@ _End of plan. This document is the authoritative source for the six Waves. Super > **Scope**: Multi-agent orchestration, A2A evolution, cryptographic agent identity, multimodal context attachments, and behavioral governance. > -> **Governing Principles**: All changes **must** conform to the [Nine Pillars](../project-philosophy.md). Java is the engine, configuration is logic, security is architecture. +> **Governing Principles**: All changes **must** conform to the [Nine Pillars](../docs/project-philosophy.md). Java is the engine, configuration is logic, security is architecture. > [!IMPORTANT] > **Implementation Status (2026-04-07):** diff --git a/planning/documentation-updates-plan.md b/planning/documentation-updates-plan.md index 82ff648d5..b58c77713 100644 --- a/planning/documentation-updates-plan.md +++ b/planning/documentation-updates-plan.md @@ -4,8 +4,8 @@ ## Prerequisite Reading -1. [`docs/changelog.md`](../changelog.md) — Sprint 1 and 2 entries -2. [`AGENTS.md`](../../AGENTS.md) — The full file, especially §3 Roadmap, §4.4 Tool Security, and the Reusable Infrastructure table +1. [`docs/changelog.md`](../docs/changelog.md) — Sprint 1 and 2 entries +2. [`AGENTS.md`](../AGENTS.md) — The full file, especially §3 Roadmap, §4.4 Tool Security, and the Reusable Infrastructure table --- @@ -112,7 +112,7 @@ EDDI enforces security-by-default in production: - **Security headers** (CSP, X-Frame-Options, X-Content-Type-Options) are set on all responses. - **CI scanning** via CodeQL (SAST) and Trivy (vulnerability scan) on every push. -See [`.env.example`](.env.example) for required environment variables. +See [`.env.example`](../.env.example) for required environment variables. ``` --- diff --git a/planning/memory-architecture-plan.md b/planning/memory-architecture-plan.md index 800e64793..d8be5957d 100644 --- a/planning/memory-architecture-plan.md +++ b/planning/memory-architecture-plan.md @@ -4,7 +4,7 @@ > > **Focus**: This document deals **exclusively** with memory management — how EDDI stores, validates, curates, and consolidates conversational and cross-conversation state. > -> **Governing Principles**: All changes **must** conform to the [Nine Pillars](../project-philosophy.md). Memory machinery is Java infrastructure; memory *policy* is JSON configuration. +> **Governing Principles**: All changes **must** conform to the [Nine Pillars](../docs/project-philosophy.md). Memory machinery is Java infrastructure; memory *policy* is JSON configuration. --- diff --git a/planning/multi-agent-ux-improvements.md b/planning/multi-agent-ux-improvements.md index 995fc9382..76ff2f22a 100644 --- a/planning/multi-agent-ux-improvements.md +++ b/planning/multi-agent-ux-improvements.md @@ -249,8 +249,8 @@ enum FeedbackRating { POSITIVE, NEGATIVE, NEUTRAL } ## 4. References - Source research paper (removed during docs cleanup — distilled into this document) -- [architecture.md](../architecture.md) — EDDI architecture overview -- [project-philosophy.md](../project-philosophy.md) — The Nine Pillars -- [GroupConversationService.java](../../src/main/java/ai/labs/eddi/engine/internal/GroupConversationService.java) — Phase-based orchestrator -- [AgentGroupConfiguration.java](../../src/main/java/ai/labs/eddi/configs/groups/model/AgentGroupConfiguration.java) — Group configuration model -- [A2ATaskHandler.java](../../src/main/java/ai/labs/eddi/engine/a2a/A2ATaskHandler.java) — A2A protocol handler +- [architecture.md](../docs/architecture.md) — EDDI architecture overview +- [project-philosophy.md](../docs/project-philosophy.md) — The Nine Pillars +- [GroupConversationService.java](../src/main/java/ai/labs/eddi/engine/internal/GroupConversationService.java) — Phase-based orchestrator +- [AgentGroupConfiguration.java](../src/main/java/ai/labs/eddi/configs/groups/model/AgentGroupConfiguration.java) — Group configuration model +- [A2ATaskHandler.java](../src/main/java/ai/labs/eddi/engine/a2a/A2ATaskHandler.java) — A2A protocol handler diff --git a/planning/observability-and-pipeline-plan.md b/planning/observability-and-pipeline-plan.md index e932e3269..7412d929c 100644 --- a/planning/observability-and-pipeline-plan.md +++ b/planning/observability-and-pipeline-plan.md @@ -4,9 +4,9 @@ ## Prerequisite Reading -1. [`docs/architecture.md`](../architecture.md) — Pipeline lifecycle, task model -2. [`docs/changelog.md`](../changelog.md) — Recent changes for context -3. [`AGENTS.md`](../../AGENTS.md) — §4.2 "Core Architecture" for the lifecycle pipeline model +1. [`docs/architecture.md`](../docs/architecture.md) — Pipeline lifecycle, task model +2. [`docs/changelog.md`](../docs/changelog.md) — Recent changes for context +3. [`AGENTS.md`](../AGENTS.md) — §4.2 "Core Architecture" for the lifecycle pipeline model --- @@ -28,7 +28,7 @@ ### 1b. Instrument the LifecycleManager -The main execution path is in [`LifecycleManager.executeLifecycle()`](../../src/main/java/ai/labs/eddi/engine/lifecycle/LifecycleManager.java). Add span-per-task: +The main execution path is in [`LifecycleManager.executeLifecycle()`](../src/main/java/ai/labs/eddi/engine/lifecycle/internal/LifecycleManager.java). Add span-per-task: ```java Span span = tracer.spanBuilder("eddi.task." + task.getId().name()) @@ -67,7 +67,7 @@ quarkus.otel.service.name=eddi ``` **Key files:** -- `src/main/java/ai/labs/eddi/engine/lifecycle/LifecycleManager.java` +- `src/main/java/ai/labs/eddi/engine/lifecycle/internal/LifecycleManager.java` - `src/main/java/ai/labs/eddi/engine/httpclient/SafeHttpClient.java` - `src/main/resources/application.properties` diff --git a/planning/security-hardening-remaining.md b/planning/security-hardening-remaining.md index c91bd4fdd..62b556583 100644 --- a/planning/security-hardening-remaining.md +++ b/planning/security-hardening-remaining.md @@ -4,13 +4,13 @@ ## Prerequisite Reading -1. [`docs/changelog.md`](../changelog.md) — Sprint 1 and Sprint 2 entries describe what was already done -2. [`AGENTS.md`](../../AGENTS.md) — §4.4 "Tool Security" for URL validation patterns +1. [`docs/changelog.md`](../docs/changelog.md) — Sprint 1 and Sprint 2 entries describe what was already done +2. [`AGENTS.md`](../AGENTS.md) — §4.4 "Tool Security" for URL validation patterns 3. Key files to understand: - - [`SafeHttpClient.java`](../../src/main/java/ai/labs/eddi/engine/httpclient/SafeHttpClient.java) — centralized SSRF-safe HTTP - - [`UrlValidationUtils.java`](../../src/main/java/ai/labs/eddi/modules/llm/tools/UrlValidationUtils.java) — SSRF IP validation - - [`VaultSaltManager.java`](../../src/main/java/ai/labs/eddi/secrets/crypto/VaultSaltManager.java) — per-deployment KEK salt - - [`AuthStartupGuard.java`](../../src/main/java/ai/labs/eddi/engine/security/AuthStartupGuard.java) — production auth enforcement + - [`SafeHttpClient.java`](../src/main/java/ai/labs/eddi/engine/httpclient/SafeHttpClient.java) — centralized SSRF-safe HTTP + - [`UrlValidationUtils.java`](../src/main/java/ai/labs/eddi/modules/llm/tools/UrlValidationUtils.java) — SSRF IP validation + - [`VaultSaltManager.java`](../src/main/java/ai/labs/eddi/secrets/crypto/VaultSaltManager.java) — per-deployment KEK salt + - [`AuthStartupGuard.java`](../src/main/java/ai/labs/eddi/engine/security/AuthStartupGuard.java) — production auth enforcement --- @@ -36,7 +36,7 @@ SafeHttpClientTest.java (use com.sun.net.httpserver.HttpServer) **Where:** `src/test/java/ai/labs/eddi/engine/httpclient/SafeHttpClientTest.java` -**Pattern:** See [`WebScraperToolSsrfTest.java`](../../src/test/java/ai/labs/eddi/modules/llm/tools/impl/WebScraperToolSsrfTest.java) for the embedded-server pattern. +**Pattern:** See [`WebScraperToolTest.java`](../src/test/java/ai/labs/eddi/modules/llm/tools/impl/WebScraperToolTest.java) for the embedded-server pattern. **Implementation note:** The tests need 127.0.0.1 for the embedded server, but `validateUrl()` blocks loopback. Use `send()` (not `sendValidated()`) for the embedded server tests and test the redirect target validation separately. diff --git a/src/main/java/ai/labs/eddi/configs/groups/mongo/AgentGroupStore.java b/src/main/java/ai/labs/eddi/configs/groups/mongo/AgentGroupStore.java index b77087075..c6c0f550d 100644 --- a/src/main/java/ai/labs/eddi/configs/groups/mongo/AgentGroupStore.java +++ b/src/main/java/ai/labs/eddi/configs/groups/mongo/AgentGroupStore.java @@ -289,8 +289,11 @@ public static void validateVotePhases(AgentGroupConfiguration groupConfiguration throw new IllegalArgumentException(path + " with optionsSource EXPLICIT needs at least 2 options"); } if (voteConfig.tiePolicy() == AgentGroupConfiguration.TiePolicy.HUMAN_DECIDES) { - throw new IllegalArgumentException(path + ".tiePolicy HUMAN_DECIDES needs human group members (I6), which are " - + "not available yet — use MODERATOR_DECIDES or NO_DECISION"); + // HUMAN members themselves DO ship (see the I6 matrix above, which + // validates them). What is missing is the resume machinery a tie + // break needs: a tally that stops mid-phase has no pause to re-enter. + throw new IllegalArgumentException(path + ".tiePolicy HUMAN_DECIDES is not supported yet — breaking a tie needs a " + + "resume path that a paused VOTE phase does not have. Use MODERATOR_DECIDES or NO_DECISION"); } for (Map.Entry weight : voteConfig.weights().entrySet()) { // isFinite: NaN passes every < comparison and would poison the diff --git a/src/main/java/ai/labs/eddi/engine/api/ILogoutEndpoint.java b/src/main/java/ai/labs/eddi/engine/api/ILogoutEndpoint.java deleted file mode 100644 index 7738166f4..000000000 --- a/src/main/java/ai/labs/eddi/engine/api/ILogoutEndpoint.java +++ /dev/null @@ -1,36 +0,0 @@ -/* - * Copyright EDDI contributors - * SPDX-License-Identifier: Apache-2.0 - */ -package ai.labs.eddi.engine.api; - -import org.eclipse.microprofile.openapi.annotations.Operation; -import org.eclipse.microprofile.openapi.annotations.tags.Tag; - -import jakarta.ws.rs.GET; -import jakarta.ws.rs.POST; -import jakarta.ws.rs.Path; -import jakarta.ws.rs.Produces; -import jakarta.ws.rs.core.MediaType; -import jakarta.ws.rs.core.Response; - -@Path("/user") -@Produces(MediaType.TEXT_PLAIN) -@Tag(name = "Security / Authentication", description = "Authentication status and session management") -public interface ILogoutEndpoint { - @GET - @Path("/isAuthenticated") - @Operation(description = "Check if current user is authenticated.") - Response isUserAuthenticated(); - - @GET - @Path("/securityType") - @Produces(MediaType.TEXT_PLAIN) - @Operation(description = "Read currently enabled security type.") - Response getSecurityType(); - - @POST - @Path("/logout") - @Operation(description = "Logout current authenticated user.") - void logout(); -} diff --git a/src/main/java/ai/labs/eddi/engine/audit/AuditLedgerService.java b/src/main/java/ai/labs/eddi/engine/audit/AuditLedgerService.java index ef32a1c84..72cf26a41 100644 --- a/src/main/java/ai/labs/eddi/engine/audit/AuditLedgerService.java +++ b/src/main/java/ai/labs/eddi/engine/audit/AuditLedgerService.java @@ -25,6 +25,7 @@ import java.util.concurrent.*; import java.util.concurrent.atomic.AtomicInteger; import java.util.concurrent.atomic.AtomicLong; +import java.util.concurrent.locks.ReentrantReadWriteLock; import java.nio.file.*; import java.time.Instant; import java.nio.charset.StandardCharsets; @@ -59,11 +60,14 @@ public class AuditLedgerService { /** * Cap on how many conversations are tracked for sequence assignment at once. On - * overflow the whole table is dropped; the next entry for a conversation then - * re-seeds from the store, which costs one count query and keeps the sequence - * gap-free. + * overflow, counters whose positions are all accounted for are evicted and + * re-seeded from the store on next use — see + * {@link #evictSequenceCountersIfFull} for why only those are safe to drop. + *

+ * Package-visible so the eviction tests can reach the threshold instead of + * hard-coding a copy of it that would drift. */ - private static final int MAX_TRACKED_CONVERSATIONS = 50_000; + static final int MAX_TRACKED_CONVERSATIONS = 50_000; /** * Cap on how many chain positions are remembered as "consumed but never @@ -103,6 +107,21 @@ public class AuditLedgerService { */ private final ConcurrentHashMap> undelivered = new ConcurrentHashMap<>(); private final AtomicInteger undeliveredTracked = new AtomicInteger(0); + /** + * Serialises sequence-table eviction against sequence assignment. Submitters + * take the read lock (shared, so they never contend with one another) for the + * whole span between consuming a chain position and making the entry visible in + * {@link #queue}; {@link #evictSequenceCountersIfFull} takes the write lock. + * Without it, eviction could drop a counter in that window and the next entry + * would re-seed from a store that has not yet seen the outstanding one. + */ + private final ReentrantReadWriteLock sequenceLock = new ReentrantReadWriteLock(); + /** + * The batch {@link #flush()} has polled off the queue but not yet persisted. + * Those entries own chain positions that are in neither the queue nor the + * store, so eviction has to treat their conversations as live. + */ + private volatile List inFlightBatch = List.of(); private ScheduledExecutorService flushExecutor; @Inject @@ -210,7 +229,17 @@ public void submit(AuditEntry entry) { return; } + // Make room in the sequence table BEFORE taking the assignment lock — + // eviction needs the write lock and this thread is about to hold the read + // lock, which a ReentrantReadWriteLock cannot upgrade. + evictSequenceCountersIfFull(entry.conversationId()); + boolean queued = false; + // Read lock (shared — submitters never contend with each other) spans + // "position consumed" → "entry visible in the queue". Eviction takes the + // write lock, so it can never observe a conversation as idle while this + // thread holds a number for it that nothing can see yet. + sequenceLock.readLock().lock(); try { // Scrub secrets from string values in maps AuditEntry scrubbed = scrubSecrets(entry); @@ -237,6 +266,7 @@ public void submit(AuditEntry entry) { queue.offer(signed); queued = true; } finally { + sequenceLock.readLock().unlock(); if (!queued) { // Signing/scrubbing blew up: give the reservation back rather than // leaking capacity that no entry occupies. @@ -290,6 +320,72 @@ private boolean offerBounded(AuditEntry entry) { return true; } + /** + * Drop sequence counters for conversations that can be re-seeded from the store + * without risk, once the table reaches {@link #MAX_TRACKED_CONVERSATIONS}. + *

+ * The table used to be {@code clear()}ed wholesale, on the reasoning that + * "re-seeding is correct, only slower". It is not: the counter is seeded from + * {@code countByConversation}, which sees only what the store already holds. + * Entries sit in {@link #queue} for up to one flush interval — longer while a + * failing store is being retried — so clearing mid-flight re-issued positions + * those entries had already consumed. Duplicates are graded exactly like gaps + * ({@code ChainStatus.BROKEN}), and unlike a gap there is no exculpatory record + * for them: the ledger would report the deployment as tampered because its own + * bookkeeping wrapped around. + *

+ * A counter is safe to drop only when every position it handed out is already + * accounted for somewhere the re-seed can see: persisted in the store, or + * attributed in {@link #undelivered}. So conversations still represented in the + * queue, in the in-flight flush batch, or in the undelivered table are retained + * and everything else goes. Called under no lock and takes the write lock + * itself, so it cannot run while a submitter holds an assigned-but-not- + * yet-queued position. + *

+ * One residual case is deliberate: past {@link #MAX_TRACKED_UNDELIVERED} the + * undelivered table stops recording, so a dead-lettered position may be reused. + * That window already reports {@code BROKEN} by the documented fail-strict + * rule, so the verdict is unchanged — only its reason is. + */ + private void evictSequenceCountersIfFull(String conversationId) { + // Fast path: nothing to do until the table is full, and a conversation + // already tracked does not grow it. + if (conversationSequences.size() < MAX_TRACKED_CONVERSATIONS || conversationSequences.containsKey(conversationId)) { + return; + } + + sequenceLock.writeLock().lock(); + try { + if (conversationSequences.size() < MAX_TRACKED_CONVERSATIONS) { + return; + } + + Set retain = new HashSet<>(undelivered.keySet()); + collectConversationIds(queue, retain); + collectConversationIds(inFlightBatch, retain); + + int before = conversationSequences.size(); + conversationSequences.keySet().removeIf(id -> !retain.contains(id)); + int evicted = before - conversationSequences.size(); + + if (evicted > 0) { + LOGGER.infov("Audit sequence table hit {0} conversations — evicted {1} fully-persisted counters; " + + "{2} retained as in flight", MAX_TRACKED_CONVERSATIONS, evicted, conversationSequences.size()); + } + } finally { + sequenceLock.writeLock().unlock(); + } + } + + /** Add every non-null conversation id in {@code entries} to {@code target}. */ + private static void collectConversationIds(Iterable entries, Set target) { + for (AuditEntry entry : entries) { + if (entry != null && entry.conversationId() != null) { + target.add(entry.conversationId()); + } + } + } + /** * Next 0-based position for {@code conversationId}, or * {@link AuditEntry#UNSEQUENCED} when the entry cannot be chained. @@ -303,11 +399,16 @@ private long nextSequence(String conversationId) { return AuditEntry.UNSEQUENCED; } - if (conversationSequences.size() >= MAX_TRACKED_CONVERSATIONS) { - // Bounded by construction: re-seeding is correct, only slower. - LOGGER.warnv("Audit sequence table hit {0} conversations — resetting; sequences will be re-seeded from the store", - MAX_TRACKED_CONVERSATIONS); - conversationSequences.clear(); + // Still full after eviction means every remaining counter belongs to a + // conversation with entries in flight. Re-seeding one of those from the + // store would hand out a position it already used, and a DUPLICATE is + // reported as BROKEN — the ledger accusing the deployment of tampering + // because its own table filled up. An unsequenced entry instead degrades + // the window to UNAVAILABLE ("cannot be established"), which is honest. + if (!conversationSequences.containsKey(conversationId) && conversationSequences.size() >= MAX_TRACKED_CONVERSATIONS) { + LOGGER.warnv("Audit sequence table is full ({0} conversations, all with entries in flight) — " + + "new conversations are recorded unsequenced until it drains", MAX_TRACKED_CONVERSATIONS); + return AuditEntry.UNSEQUENCED; } try { @@ -324,15 +425,31 @@ private long nextSequence(String conversationId) { /** * Flush pending entries to the audit store in a batch. */ - void flush() { + // synchronized: the scheduled writer thread and the @PreDestroy final flush + // can otherwise poll interleaved halves of the queue into two batches, and + // `inFlightBatch` below would only describe one of them. + synchronized void flush() { if (queue.isEmpty()) return; List batch = new ArrayList<>(); - AuditEntry entry; - while ((entry = queue.poll()) != null) { - queueSize.decrementAndGet(); - batch.add(entry); + // Draining and publishing must be atomic WITH RESPECT TO EVICTION: an + // entry that has been polled but not yet published is in neither `queue` + // nor `inFlightBatch`, and an eviction landing in that window would read + // its conversation as fully persisted and re-seed it — reintroducing the + // duplicate this whole mechanism exists to prevent. The read lock is the + // same one submitters hold, so this only ever contends with eviction, and + // no I/O happens inside it. + sequenceLock.readLock().lock(); + try { + AuditEntry entry; + while ((entry = queue.poll()) != null) { + queueSize.decrementAndGet(); + batch.add(entry); + } + inFlightBatch = batch; + } finally { + sequenceLock.readLock().unlock(); } if (!batch.isEmpty()) { @@ -365,6 +482,10 @@ void flush() { writeToDeadLetter(batch); consecutiveFailures.set(0); } + } finally { + // Every outcome has put these positions somewhere eviction can see + // again: persisted, re-queued, or recorded in `undelivered`. + inFlightBatch = List.of(); } } } diff --git a/src/main/java/ai/labs/eddi/engine/httpclient/SafeHttpClient.java b/src/main/java/ai/labs/eddi/engine/httpclient/SafeHttpClient.java index 8a5436509..6645e9665 100644 --- a/src/main/java/ai/labs/eddi/engine/httpclient/SafeHttpClient.java +++ b/src/main/java/ai/labs/eddi/engine/httpclient/SafeHttpClient.java @@ -31,7 +31,10 @@ * metadata, etc.) *

  • Maximum redirect count is capped
  • *
  • Connect timeout is enforced per-hop
  • - *
  • Overall wall-clock timeout is enforced across all hops
  • + *
  • Response timeout is enforced per-hop ({@link #DEFAULT_REQUEST_TIMEOUT} + * when the caller set none)
  • + *
  • Overall wall-clock budget is checked between hops, so a redirect chain + * cannot outlive it by more than one hop's timeout
  • *
  • On cross-origin redirects, Authorization/Cookie headers are stripped
  • * * @@ -51,6 +54,16 @@ public class SafeHttpClient { /** Headers managed by HttpClient — must not be copied to redirect requests. */ private static final Set MANAGED_HEADERS = Set.of("host", "content-length", "connection"); + /** + * Per-hop response timeout applied when the caller set none. The wall-clock + * budget below is only consulted BETWEEN hops, so without a per-request bound a + * single hop that accepts the connection and then trickles (or never completes) + * the response body hangs forever and the budget never gets a chance to fire. + * Every in-tree caller sets its own timeout; this is the backstop for the ones + * that do not. + */ + private static final Duration DEFAULT_REQUEST_TIMEOUT = Duration.ofSeconds(15); + /** Security-sensitive headers stripped on cross-origin redirects. */ private static final Set SENSITIVE_HEADERS = Set.of("authorization", "cookie", "proxy-authorization"); @@ -87,7 +100,7 @@ public SafeHttpClient( */ public HttpResponse send(HttpRequest request, HttpResponse.BodyHandler bodyHandler) throws IOException, InterruptedException { - return sendWithRedirects(request, bodyHandler, 0, Instant.now()); + return sendWithRedirects(withDefaultTimeout(request), bodyHandler, 0, Instant.now()); } /** @@ -107,7 +120,39 @@ public HttpResponse send(HttpRequest request, HttpResponse.BodyHandler public HttpResponse sendValidated(HttpRequest request, HttpResponse.BodyHandler bodyHandler) throws IOException, InterruptedException { UrlValidationUtils.validateUrl(request.uri().toString()); - return sendWithRedirects(request, bodyHandler, 0, Instant.now()); + return sendWithRedirects(withDefaultTimeout(request), bodyHandler, 0, Instant.now()); + } + + /** + * Returns {@code request} unchanged when it already carries a timeout, else a + * copy bounded by {@link #DEFAULT_REQUEST_TIMEOUT}. {@link HttpRequest} is + * immutable, so the bound can only be applied by rebuilding. + *

    + * Package-private so {@code SafeHttpClientTimeoutTest} can pin the rebuild + * without an embedded server — the server-backed cases live in + * {@code SafeHttpClientTest} and only run where loopback sockets are available. + */ + static HttpRequest withDefaultTimeout(HttpRequest request) { + if (request.timeout().isPresent()) { + return request; + } + HttpRequest.Builder builder = HttpRequest.newBuilder(request.uri()) + .timeout(DEFAULT_REQUEST_TIMEOUT) + .method(request.method(), request.bodyPublisher().orElse(HttpRequest.BodyPublishers.noBody())); + request.headers().map().forEach((name, values) -> { + // Same exclusion as copyHeaders: HttpClient owns these and rejects + // an attempt to set them explicitly. + if (!MANAGED_HEADERS.contains(name.toLowerCase())) { + for (String value : values) { + builder.header(name, value); + } + } + }); + request.version().ifPresent(builder::version); + if (request.expectContinue()) { + builder.expectContinue(true); + } + return builder.build(); } private HttpResponse sendWithRedirects(HttpRequest request, HttpResponse.BodyHandler bodyHandler, @@ -153,7 +198,7 @@ private HttpResponse sendWithRedirects(HttpRequest request, HttpResponse. boolean methodPreserved = (statusCode == 307 || statusCode == 308) && !"GET".equals(request.method()); HttpRequest.Builder builder = HttpRequest.newBuilder() .uri(resolvedUri) - .timeout(request.timeout().orElse(Duration.ofSeconds(15))); + .timeout(request.timeout().orElse(DEFAULT_REQUEST_TIMEOUT)); // Copy headers from original request, with security-aware filtering boolean sameOrigin = isSameOrigin(request.uri(), resolvedUri); diff --git a/src/main/java/ai/labs/eddi/engine/internal/ConversationStepRunner.java b/src/main/java/ai/labs/eddi/engine/internal/ConversationStepRunner.java index 98fc6be36..0ca449506 100644 --- a/src/main/java/ai/labs/eddi/engine/internal/ConversationStepRunner.java +++ b/src/main/java/ai/labs/eddi/engine/internal/ConversationStepRunner.java @@ -207,11 +207,14 @@ Void runConversationStep(Environment environment, IConversationMemory conversati // #2: register the live memory so cancelConversation can signal the // running pipeline via setCancelled (checked at task boundaries). inFlightConversations.put(conversationId, conversationMemory); - // Carry the agent-level tool-approval config onto memory BEFORE the - // pipeline (LlmTask) runs, so the tool-approval gate can resolve its - // effective config. Transient — never persisted; re-resolved each turn. - conversationService.conversationHitlService.populateToolApprovalsConfig(conversationMemory); try { + // Carry the agent-level tool-approval config onto memory BEFORE the + // pipeline (LlmTask) runs, so the tool-approval gate can resolve its + // effective config. Transient — never persisted; re-resolved each turn. + // Inside the try so an Error here cannot strand the registration above: + // a stranded entry would keep a finished turn's memory reachable AND + // make a later cancel signal the wrong (dead) pipeline. + conversationService.conversationHitlService.populateToolApprovalsConfig(conversationMemory); runGuardedConversationStep(loggingContext, conversationId, environment, conversationMemory, executeConversation, memoryStateAtSubmit, persistedState); } finally { diff --git a/src/main/java/ai/labs/eddi/engine/internal/RestAgentManagement.java b/src/main/java/ai/labs/eddi/engine/internal/RestAgentManagement.java index 9b7354176..b172a265f 100644 --- a/src/main/java/ai/labs/eddi/engine/internal/RestAgentManagement.java +++ b/src/main/java/ai/labs/eddi/engine/internal/RestAgentManagement.java @@ -33,7 +33,7 @@ import java.net.URI; import java.util.List; import java.util.Map; -import java.util.Random; +import java.util.concurrent.ThreadLocalRandom; import java.util.UUID; import static ai.labs.eddi.engine.model.Deployment.Environment.production; @@ -283,7 +283,10 @@ private UserConversation createUserConversation(String intent, String userId, Ag } private AgentDeployment getRandom(List agentDeployments) { - return agentDeployments.get(new Random().nextInt(agentDeployments.size())); + // ThreadLocalRandom: this picks a deployment per incoming request on an + // application-scoped bean, so a per-call Random both allocates and shares + // its seed lock across concurrent callers. + return agentDeployments.get(ThreadLocalRandom.current().nextInt(agentDeployments.size())); } private AgentTriggerConfiguration getAgentTrigger(String intent) { diff --git a/src/main/java/ai/labs/eddi/engine/lifecycle/exceptions/CannotExecuteException.java b/src/main/java/ai/labs/eddi/engine/lifecycle/exceptions/CannotExecuteException.java deleted file mode 100644 index e6b07c3cb..000000000 --- a/src/main/java/ai/labs/eddi/engine/lifecycle/exceptions/CannotExecuteException.java +++ /dev/null @@ -1,14 +0,0 @@ -/* - * Copyright EDDI contributors - * SPDX-License-Identifier: Apache-2.0 - */ -package ai.labs.eddi.engine.lifecycle.exceptions; - -/** - * @author ginccc - */ -public class CannotExecuteException extends LifecycleException { - public CannotExecuteException(String message) { - super(message); - } -} diff --git a/src/main/java/ai/labs/eddi/engine/memory/model/Data.java b/src/main/java/ai/labs/eddi/engine/memory/model/Data.java index 680245d11..bc6852139 100644 --- a/src/main/java/ai/labs/eddi/engine/memory/model/Data.java +++ b/src/main/java/ai/labs/eddi/engine/memory/model/Data.java @@ -9,7 +9,7 @@ import java.util.Collections; import java.util.Date; import java.util.List; -import java.util.Random; +import java.util.concurrent.ThreadLocalRandom; /** * @author ginccc @@ -45,9 +45,9 @@ public Data(String key, T result, List possibleResults, Date timestamp, boole private T chooseRandomResult(List results) { if (!results.isEmpty()) { - Random random = new Random(); - int randNumber = random.nextInt(results.size()); - return results.get(randNumber); + // ThreadLocalRandom avoids allocating (and seeding) a Random for every + // Data instance constructed during a turn. + return results.get(ThreadLocalRandom.current().nextInt(results.size())); } return null; diff --git a/src/main/java/ai/labs/eddi/modules/llm/tools/ToolRateLimiter.java b/src/main/java/ai/labs/eddi/modules/llm/tools/ToolRateLimiter.java index 8d220664a..7901f3347 100644 --- a/src/main/java/ai/labs/eddi/modules/llm/tools/ToolRateLimiter.java +++ b/src/main/java/ai/labs/eddi/modules/llm/tools/ToolRateLimiter.java @@ -153,7 +153,11 @@ public void init() { * be called while holding it. *

    */ - private static class RateLimitBucket { + // Package-private (not private) so the overflow regression test can drive it + // directly: reproducing a ~107-day idle bucket through the public API is not + // possible, and the alternative — reflecting into a private field — pins the + // field name rather than the behaviour. + static class RateLimitBucket { private int limit; private double tokens; private long lastRefillNanos; @@ -164,6 +168,21 @@ private static class RateLimitBucket { this.lastRefillNanos = System.nanoTime(); } + /** + * Backdate this bucket's last refill, so idle-window behaviour is provable + * without waiting for the window. Same test seam as + * {@code WorkflowTraversal.discoverConfigs(..., nowMillis)}: an explicit clock + * reading rather than a sleep. + */ + synchronized void backdateLastRefill(long nanosAgo) { + lastRefillNanos -= nanosAgo; + } + + /** Raw token count, for assertions that need sub-integer precision. */ + synchronized double tokenCount() { + return tokens; + } + /** * Re-point this bucket at a new configured limit, preserving how much of the * old allowance had been consumed. Resetting {@code tokens} to the new limit @@ -188,7 +207,12 @@ private void refill() { return; } lastRefillNanos = now; - tokens = Math.min(limit, tokens + elapsedNanos * limit / WINDOW_NANOS); + // (double) on elapsedNanos: `elapsedNanos * limit` is long arithmetic and + // overflows once a bucket has been idle for ~107 days at the default + // limit of 1000. The wrapped negative would drive `tokens` below zero and + // tryAcquire would refuse every call from then on — a bucket that silently + // locks shut rather than refilling. + tokens = Math.min(limit, tokens + (double) elapsedNanos * limit / WINDOW_NANOS); } synchronized boolean tryAcquire() { diff --git a/src/main/java/ai/labs/eddi/modules/output/impl/OutputGenerationTask.java b/src/main/java/ai/labs/eddi/modules/output/impl/OutputGenerationTask.java index ea6a6c5bd..2304fd759 100644 --- a/src/main/java/ai/labs/eddi/modules/output/impl/OutputGenerationTask.java +++ b/src/main/java/ai/labs/eddi/modules/output/impl/OutputGenerationTask.java @@ -36,6 +36,7 @@ import java.net.URI; import java.util.*; +import java.util.concurrent.ThreadLocalRandom; import java.util.stream.Collectors; import java.util.stream.IntStream; @@ -250,7 +251,10 @@ private String createOutputKey(String action, List outputValues, St } private OutputItem chooseRandomly(List possibleValues) { - return possibleValues.get(new Random().nextInt(possibleValues.size())); + // ThreadLocalRandom, not `new Random()`: this task is an application-scoped + // singleton on the request path, so a fresh Random per call both allocates + // and contends on its seed across concurrent conversations. + return possibleValues.get(ThreadLocalRandom.current().nextInt(possibleValues.size())); } private int countActionOccurrences(IConversationStepStack conversationStepStack, String action) { diff --git a/src/test/java/ai/labs/eddi/configs/groups/mongo/AgentGroupStoreTest.java b/src/test/java/ai/labs/eddi/configs/groups/mongo/AgentGroupStoreTest.java index e11d46486..951e0b7fe 100644 --- a/src/test/java/ai/labs/eddi/configs/groups/mongo/AgentGroupStoreTest.java +++ b/src/test/java/ai/labs/eddi/configs/groups/mongo/AgentGroupStoreTest.java @@ -142,13 +142,24 @@ void votePhase_explicitWithTooFewOptions_isRejected() { assertTrue(ex.getMessage().contains("2 options"), ex.getMessage()); } + /** + * The rejection stands, but its stated reason must not: HUMAN members ship (I6 + * — this same class validates them), so blaming their absence tells a config + * author a shipped feature is missing. What is actually absent is the resume + * path a paused tie-break would need. Asserting on the alternatives rather than + * on prose keeps this test from pinning the wording again. + */ @Test - void votePhase_humanDecides_isRejectedUntilI6() { + void votePhase_humanDecides_isRejectedPendingResumePath() { var ex = assertThrows(IllegalArgumentException.class, () -> AgentGroupStore.validateVotePhases(voteGroup( votePhase(TurnOrder.PARALLEL, ContextScope.NONE, new VoteConfig(VoteMethod.MAJORITY, OptionsSource.EXPLICIT, List.of("A", "B"), 0.5, Map.of(), false, TiePolicy.HUMAN_DECIDES))))); - assertTrue(ex.getMessage().contains("I6"), ex.getMessage()); + assertTrue(ex.getMessage().contains("HUMAN_DECIDES"), ex.getMessage()); + assertTrue(ex.getMessage().contains("MODERATOR_DECIDES"), ex.getMessage()); + assertTrue(ex.getMessage().contains("NO_DECISION"), ex.getMessage()); + assertFalse(ex.getMessage().contains("not available yet"), + "must not claim HUMAN members are unavailable — they ship: " + ex.getMessage()); } @Test diff --git a/src/test/java/ai/labs/eddi/docs/DocumentationLinksTest.java b/src/test/java/ai/labs/eddi/docs/DocumentationLinksTest.java new file mode 100644 index 000000000..72fba70b2 --- /dev/null +++ b/src/test/java/ai/labs/eddi/docs/DocumentationLinksTest.java @@ -0,0 +1,218 @@ +/* + * Copyright EDDI contributors + * SPDX-License-Identifier: Apache-2.0 + */ +package ai.labs.eddi.docs; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.io.IOException; +import java.io.UncheckedIOException; +import java.net.URLDecoder; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.LinkOption; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.List; +import java.util.Locale; +import java.util.Set; +import java.util.TreeSet; +import java.util.regex.Matcher; +import java.util.regex.Pattern; +import java.util.stream.Stream; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * Keeps the documentation's internal links honest. + *

    + * A repository review found 38 broken relative links. They were not 38 separate + * mistakes: 32 came from one, when {@code planning/} moved to the repository + * root and every file in it kept computing {@code ../} and {@code ../../} as + * though it still lived under {@code docs/}. So {@code ../../AGENTS.md} pointed + * outside the repository entirely, and nothing noticed for as long as those + * files existed. The rest pointed into a {@code .gitbook/assets/} directory + * that does not exist in this repository at all, which broke the + * onboarding tutorials specifically — the pages a new user reads + * first. + *

    + * Link rot is invisible to every other check in this build: markdown compiles + * to nothing, so a wrong path is indistinguishable from a right one until a + * human clicks it. This test is the check. + */ +@DisplayName("documentation links") +class DocumentationLinksTest { + + /** + * Markdown inline links, e.g. {@code [text](target)}. The target group stops at + * the first {@code )}, which is why targets containing parentheses are excluded + * below rather than parsed. + */ + private static final Pattern LINK = Pattern.compile("]\\(([^)]+)\\)"); + + /** Fenced code blocks — their contents are examples, not links. */ + private static final Pattern FENCE = Pattern.compile("(?ms)^```.*?^```"); + + /** Inline code spans, for the same reason. */ + private static final Pattern CODE_SPAN = Pattern.compile("`[^`\\n]*`"); + + /** Directories with no documentation to check (or not ours to check). */ + private static final Set SKIPPED_DIRS = Set.of("target", ".git", "node_modules", ".claude", ".mvn"); + + private static Path repoRoot() { + // Surefire runs with the project basedir as the working directory. + Path root = Path.of("").toAbsolutePath(); + assertTrue(Files.isRegularFile(root.resolve("pom.xml")), + "expected the working directory to be the project root, was " + root); + return root; + } + + private static List markdownFiles(Path root) { + try (Stream paths = Files.walk(root)) { + return paths.filter(Files::isRegularFile) + .filter(p -> p.getFileName().toString().toLowerCase(Locale.ROOT).endsWith(".md")) + .filter(p -> { + for (Path part : root.relativize(p)) { + if (SKIPPED_DIRS.contains(part.toString())) { + return false; + } + } + return true; + }) + .toList(); + } catch (IOException e) { + throw new UncheckedIOException(e); + } + } + + /** + * Strips fenced blocks and inline code so that documentation OF link syntax — + * {@code `![alt](uri)`} in the output-format tables, for instance — is not + * mistaken for a link TO something called "uri". + */ + private static String stripCode(String markdown) { + String withoutFences = FENCE.matcher(markdown).replaceAll(""); + return CODE_SPAN.matcher(withoutFences).replaceAll(""); + } + + private static boolean isExternalOrAnchor(String target) { + String t = target.trim(); + return t.isEmpty() + || t.startsWith("#") + || t.startsWith("http://") + || t.startsWith("https://") + || t.startsWith("mailto:") + || t.startsWith(" + * This matters more than it looks. Windows and macOS resolve + * {@code ../security.md} to {@code SECURITY.md}, so a link that is broken on + * the Linux CI runner — and for every reader of the published docs — looks + * perfectly fine to the developer who wrote it. That is exactly how + * {@code planning/agentic-improvements-plan.md} came to point at the security + * policy at the repository root while its own link text said + * {@code docs/security.md}. Without this check the test would have inherited + * the same blind spot as the human. + */ + private static String mismatchedCasing(Path resolved) { + try { + Path real = resolved.toRealPath(LinkOption.NOFOLLOW_LINKS); + String actual = real.getFileName().toString(); + String requested = resolved.getFileName().toString(); + return actual.equals(requested) ? null : actual; + } catch (IOException e) { + return null; // existence was already established; nothing more to say + } + } + + @Test + @DisplayName("every relative link in every tracked markdown file resolves to a real path") + void everyRelativeLinkResolves() { + Path root = repoRoot(); + List broken = new ArrayList<>(); + + for (Path file : markdownFiles(root)) { + String body; + try { + body = Files.readString(file, StandardCharsets.UTF_8); + } catch (IOException | RuntimeException e) { + continue; // unreadable/non-UTF-8 files are not this test's business + } + Matcher m = LINK.matcher(stripCode(body)); + while (m.find()) { + String target = m.group(1).trim(); + if (isExternalOrAnchor(target)) { + continue; + } + // Drop any '#fragment' and surrounding angle brackets before resolving. + String pathPart = target.replaceAll("^<|>$", ""); + int hash = pathPart.indexOf('#'); + if (hash >= 0) { + pathPart = pathPart.substring(0, hash); + } + if (pathPart.isBlank()) { + continue; + } + String decoded = URLDecoder.decode(pathPart, StandardCharsets.UTF_8); + // A leading '/' is repository-root-relative (how the forge renders + // it), NOT filesystem-absolute — resolving it against the file's + // own directory would send it to the drive root. + Path resolved = decoded.startsWith("/") + ? root.resolve(decoded.substring(1)).normalize() + : file.getParent().resolve(decoded).normalize(); + if (!Files.exists(resolved)) { + broken.add(root.relativize(file) + " -> " + target); + } else { + String actualCasing = mismatchedCasing(resolved); + if (actualCasing != null) { + broken.add(root.relativize(file) + " -> " + target + + " (case mismatch — the file on disk is '" + actualCasing + "')"); + } + } + } + } + + assertEquals(List.of(), broken, + "broken relative link(s) in documentation:\n " + String.join("\n ", broken)); + } + + /** + * {@code SUMMARY.md} is the published table of contents. A page missing from it + * still renders, still passes every other check, and is simply unreachable by + * navigation — which is how {@code security-review.md} and + * {@code release-notes-6.0.2.md} went unlisted. + */ + @Test + @DisplayName("every page under docs/ is reachable from SUMMARY.md") + void everyDocIsListedInSummary() throws IOException { + Path docs = repoRoot().resolve("docs"); + String summary = Files.readString(docs.resolve("SUMMARY.md"), StandardCharsets.UTF_8); + + Set missing = new TreeSet<>(); + try (Stream paths = Files.list(docs)) { + for (Path p : paths.filter(Files::isRegularFile).toList()) { + String name = p.getFileName().toString(); + if (!name.endsWith(".md") || name.equals("SUMMARY.md") || name.equals("README.md")) { + continue; // the index itself, and the folder's own landing page + } + if (!summary.contains("(" + name + ")") && !summary.contains("/" + name + ")")) { + missing.add(name); + } + } + } + + assertEquals(Set.of(), missing, + "these pages exist under docs/ but nothing in SUMMARY.md links to them, so they are " + + "unreachable in the published docs: " + missing); + } +} diff --git a/src/test/java/ai/labs/eddi/engine/audit/AuditLedgerServiceTest.java b/src/test/java/ai/labs/eddi/engine/audit/AuditLedgerServiceTest.java index fe434446e..2e3833e33 100644 --- a/src/test/java/ai/labs/eddi/engine/audit/AuditLedgerServiceTest.java +++ b/src/test/java/ai/labs/eddi/engine/audit/AuditLedgerServiceTest.java @@ -7,6 +7,7 @@ import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; import org.mockito.Mock; import org.mockito.MockitoAnnotations; @@ -111,6 +112,126 @@ void flushPersists() { assertEquals(0, service.getQueueSize()); } + // ==================== sequence-table eviction ==================== + + /** + * The table used to be {@code clear()}ed on overflow, on the reasoning that + * re-seeding from {@code countByConversation} was "correct, only slower". It is + * not: an entry that is still queued has consumed a position the store cannot + * see yet, so the re-seed hands the same number out twice — and the verifier + * grades a duplicate as {@code BROKEN}, i.e. the ledger reporting the + * deployment as tampered because its own bookkeeping wrapped around. + */ + @Test + @DisplayName("sequence eviction — a conversation with queued entries is never re-seeded into a duplicate") + void sequenceEvictionKeepsQueuedConversationsUnique() { + when(auditStore.supportsSequence()).thenReturn(true); + when(auditStore.countByConversation(anyString())).thenReturn(0L); + service = createService(true, null); + + // Consume position 0 for "live" and leave it sitting in the queue. + service.submit(entry("live-1", "live", "agent1")); + + // Overflow the table. Nothing is flushed, so "live" is still queued when + // eviction runs — exactly the window the old clear() got wrong. + for (int i = 0; i < AuditLedgerService.MAX_TRACKED_CONVERSATIONS + 5; i++) { + service.submit(entry("fill-" + i, "filler-" + i, "agent1")); + } + + service.submit(entry("live-2", "live", "agent1")); + service.flush(); + + var persisted = ArgumentCaptor.forClass(List.class); + verify(auditStore).appendBatch(persisted.capture()); + @SuppressWarnings("unchecked") + List liveSequences = ((List) persisted.getValue()).stream() + .filter(e -> "live".equals(e.conversationId())) + .map(AuditEntry::sequence) + .toList(); + + assertEquals(List.of(0L, 1L), liveSequences, + "the second entry must continue the chain, not restart it at a position already taken"); + } + + /** + * The other half of the contract: once the queue has drained, those counters + * ARE safe to drop, so the table must actually shrink. Without this the fix + * could "pass" by simply never evicting, which would strand every later + * conversation on UNSEQUENCED. + */ + @Test + @DisplayName("sequence eviction — fully-persisted conversations are evicted so new ones still chain") + void sequenceEvictionReclaimsPersistedConversations() { + when(auditStore.supportsSequence()).thenReturn(true); + when(auditStore.countByConversation(anyString())).thenReturn(0L); + service = createService(true, null); + + for (int i = 0; i < AuditLedgerService.MAX_TRACKED_CONVERSATIONS + 5; i++) { + service.submit(entry("fill-" + i, "filler-" + i, "agent1")); + } + service.flush(); // everything is now durably in the store + + service.submit(entry("fresh-1", "fresh", "agent1")); + service.flush(); + + var persisted = ArgumentCaptor.forClass(List.class); + verify(auditStore, times(2)).appendBatch(persisted.capture()); + @SuppressWarnings("unchecked") + List lastBatch = (List) persisted.getValue(); + var fresh = lastBatch.stream().filter(e -> "fresh".equals(e.conversationId())).findFirst().orElseThrow(); + + assertEquals(0L, fresh.sequence(), + "with the queue drained the table must have room again, so this chains normally"); + assertNotEquals(AuditEntry.UNSEQUENCED, fresh.sequence()); + } + + /** + * The third place a consumed chain position can live. Once + * {@link AuditLedgerService#flush()} has polled a batch off the queue, those + * positions are in neither the queue nor the store until the append returns — + * so an eviction landing in that window would read their conversations as fully + * persisted and re-seed them straight into a duplicate. + *

    + * Driven from inside the mocked {@code appendBatch}, which is precisely when + * the batch is in flight. + */ + @Test + @DisplayName("sequence eviction — a conversation in the in-flight flush batch is not re-seeded") + void sequenceEvictionRetainsTheInFlightBatch() { + when(auditStore.supportsSequence()).thenReturn(true); + when(auditStore.countByConversation(anyString())).thenReturn(0L); + service = createService(true, null); + + // Position 0 for "live", then force it out of the queue and into the + // in-flight batch by flushing with an append that overflows the table + // while it is still executing. + service.submit(entry("live-1", "live", "agent1")); + doAnswer(invocation -> { + for (int i = 0; i < AuditLedgerService.MAX_TRACKED_CONVERSATIONS + 5; i++) { + service.submit(entry("fill-" + i, "filler-" + i, "agent1")); + } + return null; + }).when(auditStore).appendBatch(any()); + + service.flush(); + + // The append has returned, so "live" is genuinely persisted now; what + // matters is that eviction did not drop its counter mid-flight. + doAnswer(invocation -> null).when(auditStore).appendBatch(any()); + service.submit(entry("live-2", "live", "agent1")); + + var persisted = ArgumentCaptor.forClass(List.class); + service.flush(); + verify(auditStore, atLeastOnce()).appendBatch(persisted.capture()); + @SuppressWarnings("unchecked") + var live2 = ((List) persisted.getValue()).stream() + .filter(e -> "live".equals(e.conversationId())) + .findFirst().orElseThrow(); + + assertEquals(1L, live2.sequence(), + "the counter must have survived an eviction that ran while its entry was in the flush batch"); + } + @Test @DisplayName("flush — does nothing when queue is empty") void flushEmptyQueue() { @@ -256,7 +377,7 @@ void droppedEntryDoesNotConsumeAChainPosition() { svc.submit(entry("id-3", "conv-1", "agent-1")); // queued → must be sequence 1 svc.flush(); - var captor = org.mockito.ArgumentCaptor.forClass(List.class); + var captor = ArgumentCaptor.forClass(List.class); verify(auditStore, times(2)).appendBatch(captor.capture()); List writtenSequences = ((List>) (List) captor.getAllValues()).stream().flatMap(List::stream) .map(AuditEntry::sequence).toList(); @@ -360,7 +481,7 @@ void entriesAreNumberedPerConversation() { service.submit(entry("id-3", "conv-b", "agent-1")); service.flush(); - var captor = org.mockito.ArgumentCaptor.forClass(List.class); + var captor = ArgumentCaptor.forClass(List.class); verify(auditStore).appendBatch(captor.capture()); @SuppressWarnings("unchecked") List written = captor.getValue(); @@ -380,7 +501,7 @@ void sequenceIsSeededFromTheStore() { service.submit(entry("id-1", "conv-a", "agent-1")); service.flush(); - var captor = org.mockito.ArgumentCaptor.forClass(List.class); + var captor = ArgumentCaptor.forClass(List.class); verify(auditStore).appendBatch(captor.capture()); @SuppressWarnings("unchecked") List written = captor.getValue(); @@ -400,7 +521,7 @@ void storeWithoutSequenceSupportGetsUnsequencedEntries() { service.submit(entry("id-1", "conv-a", "agent-1")); service.flush(); - var captor = org.mockito.ArgumentCaptor.forClass(List.class); + var captor = ArgumentCaptor.forClass(List.class); verify(auditStore).appendBatch(captor.capture()); @SuppressWarnings("unchecked") List written = captor.getValue(); @@ -417,7 +538,7 @@ void signedEntryVerifies() { service.submit(entry("id-1", "conv-a", "agent-1")); service.flush(); - var captor = org.mockito.ArgumentCaptor.forClass(List.class); + var captor = ArgumentCaptor.forClass(List.class); verify(auditStore).appendBatch(captor.capture()); @SuppressWarnings("unchecked") List written = captor.getValue(); @@ -442,7 +563,7 @@ void pseudonymisedStoredEntryStillVerifies() { service.submit(entry("id-1", "conv-a", "agent-1")); service.flush(); - var captor = org.mockito.ArgumentCaptor.forClass(List.class); + var captor = ArgumentCaptor.forClass(List.class); verify(auditStore).appendBatch(captor.capture()); @SuppressWarnings("unchecked") List written = captor.getValue(); diff --git a/src/test/java/ai/labs/eddi/engine/httpclient/SafeHttpClientTimeoutTest.java b/src/test/java/ai/labs/eddi/engine/httpclient/SafeHttpClientTimeoutTest.java new file mode 100644 index 000000000..e9ff96199 --- /dev/null +++ b/src/test/java/ai/labs/eddi/engine/httpclient/SafeHttpClientTimeoutTest.java @@ -0,0 +1,120 @@ +/* + * Copyright EDDI contributors + * SPDX-License-Identifier: Apache-2.0 + */ +package ai.labs.eddi.engine.httpclient; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +import java.net.URI; +import java.net.http.HttpRequest; +import java.time.Duration; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertSame; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * Pins {@link SafeHttpClient}'s per-hop timeout backstop. + *

    + * The class advertised an "overall wall-clock timeout enforced across all + * hops", but that budget is only consulted BETWEEN hops. A single hop that + * completes its handshake and then trickles — or never finishes — its response + * body therefore hung forever, because control never returned to the check. + * Redirect hops already carried a fallback; the initial request had whatever + * the caller set, which for a caller that set nothing was no bound at all. + *

    + * Deliberately separate from {@code SafeHttpClientTest}: that one binds a + * loopback HTTP server in {@code @BeforeEach}, so it cannot run in environments + * without loopback sockets. These cases are pure request-shaping and always + * run. + */ +@DisplayName("SafeHttpClient — per-hop timeout backstop") +class SafeHttpClientTimeoutTest { + + private static final URI TARGET = URI.create("https://example.test/resource"); + + @Nested + @DisplayName("withDefaultTimeout") + class WithDefaultTimeout { + + @Test + @DisplayName("a request with no timeout gets the default bound — the hang this fixes") + void unboundedRequestIsBounded() { + HttpRequest original = HttpRequest.newBuilder(TARGET).GET().build(); + assertTrue(original.timeout().isEmpty(), "precondition: the caller set no timeout"); + + HttpRequest bounded = SafeHttpClient.withDefaultTimeout(original); + + assertTrue(bounded.timeout().isPresent(), "an unbounded request must not reach the wire unbounded"); + assertEquals(Duration.ofSeconds(15), bounded.timeout().orElseThrow()); + } + + @Test + @DisplayName("a caller's own timeout is never overridden") + void callerTimeoutWins() { + Duration callerBound = Duration.ofSeconds(3); + HttpRequest original = HttpRequest.newBuilder(TARGET).timeout(callerBound).GET().build(); + + HttpRequest result = SafeHttpClient.withDefaultTimeout(original); + + assertSame(original, result, "no rebuild is needed when the caller already bounded the request"); + assertEquals(callerBound, result.timeout().orElseThrow()); + } + + @Test + @DisplayName("the rebuild preserves method, headers and body") + void rebuildPreservesTheRequest() { + HttpRequest original = HttpRequest.newBuilder(TARGET) + .header("Authorization", "Bearer token-value") + .header("X-Custom", "one") + .header("X-Custom", "two") + .POST(HttpRequest.BodyPublishers.ofString("payload")) + .build(); + + HttpRequest bounded = SafeHttpClient.withDefaultTimeout(original); + + assertEquals("POST", bounded.method(), "rebuilding must not silently downgrade the method"); + assertEquals(TARGET, bounded.uri()); + assertEquals("Bearer token-value", bounded.headers().firstValue("Authorization").orElse(null)); + assertEquals(List.of("one", "two"), bounded.headers().allValues("X-Custom"), + "a repeated header must keep every value, not just the last"); + assertTrue(bounded.bodyPublisher().isPresent(), "the body must survive the rebuild"); + assertEquals(original.bodyPublisher().orElseThrow().contentLength(), + bounded.bodyPublisher().orElseThrow().contentLength()); + } + + @Test + @DisplayName("HttpClient-managed headers are not copied — setting them explicitly is rejected") + void managedHeadersAreNotCopied() { + // Content-Length is set by HttpClient from the body publisher; copying + // it across would throw IllegalArgumentException on build(). + HttpRequest original = HttpRequest.newBuilder(TARGET) + .header("X-Kept", "yes") + .POST(HttpRequest.BodyPublishers.ofString("payload")) + .build(); + + HttpRequest bounded = SafeHttpClient.withDefaultTimeout(original); + + assertFalse(bounded.headers().firstValue("Content-Length").isPresent(), + "Content-Length is HttpClient's to set"); + assertEquals("yes", bounded.headers().firstValue("X-Kept").orElse(null)); + } + + @Test + @DisplayName("a GET with no body rebuilds without inventing one") + void bodylessRequestStaysBodyless() { + HttpRequest original = HttpRequest.newBuilder(TARGET).GET().build(); + + HttpRequest bounded = SafeHttpClient.withDefaultTimeout(original); + + assertEquals("GET", bounded.method()); + assertEquals(0L, bounded.bodyPublisher().map(HttpRequest.BodyPublisher::contentLength).orElse(0L), + "a GET must not gain a body just because it was rebuilt"); + } + } +} diff --git a/src/test/java/ai/labs/eddi/engine/mcp/McpToolFilterCoverageTest.java b/src/test/java/ai/labs/eddi/engine/mcp/McpToolFilterCoverageTest.java index 19529d1eb..afdf00f1f 100644 --- a/src/test/java/ai/labs/eddi/engine/mcp/McpToolFilterCoverageTest.java +++ b/src/test/java/ai/labs/eddi/engine/mcp/McpToolFilterCoverageTest.java @@ -9,12 +9,16 @@ import org.junit.jupiter.api.Test; import java.lang.reflect.Method; +import java.nio.file.Files; +import java.nio.file.Path; import java.util.List; import java.util.Set; import java.util.TreeSet; +import java.util.stream.Collectors; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; /** * Guards the one failure mode {@link McpToolFilter} makes silent: a tool that @@ -44,9 +48,9 @@ class McpToolFilterCoverageTest { *

    * Listed explicitly rather than classpath-scanned: a scan needs an indexing * dependency in a plain unit test, and the set of tool classes changes rarely - * and visibly. If you add a new {@code Mcp*Tools} class, add it here — the test - * below is what makes forgetting the whitelist loud, so this list is the one - * thing it cannot check for you. + * and visibly. If you add a new {@code Mcp*Tools} class, add it here — + * {@link #everyToolClassInThePackageIsListed()} fails until you do, so + * forgetting is loud rather than silent. */ private static final List> TOOL_CLASSES = List.of( McpAdminTools.class, McpConversationTools.class, McpSetupTools.class, @@ -99,4 +103,36 @@ void docsToolsAreExposed() { assertEquals(true, McpToolFilter.MCP_TOOLS.contains(name), name + " must be exposed to MCP clients"); } } + + /** + * Closes the one hole the tests above cannot see. Both of them start from + * {@link #TOOL_CLASSES}, so a brand-new {@code Mcp*Tools} class that nobody + * adds to that list has its tools invisible AND leaves every assertion green — + * the same silent failure this file exists to prevent, one level up. + *

    + * Counting the compiled classes in the package needs no indexing dependency: + * they are already on disk next to the ones being tested. + */ + @Test + @DisplayName("every Mcp*Tools class in the package is in TOOL_CLASSES — a new one cannot go unnoticed") + void everyToolClassInThePackageIsListed() throws Exception { + var codeSource = McpAdminTools.class.getProtectionDomain().getCodeSource().getLocation(); + Path packageDir = Path.of(codeSource.toURI()).resolve("ai/labs/eddi/engine/mcp"); + assertTrue(Files.isDirectory(packageDir), "cannot locate compiled package at " + packageDir); + + Set onDisk; + try (var entries = Files.list(packageDir)) { + onDisk = entries.map(p -> p.getFileName().toString()) + // Nested/anonymous classes carry a '$'; only top-level tool + // holders can declare the @Tool methods the SPI scans. + .filter(n -> n.startsWith("Mcp") && n.endsWith("Tools.class") && !n.contains("$")) + .map(n -> n.substring(0, n.length() - ".class".length())) + .collect(Collectors.toCollection(TreeSet::new)); + } + + var listed = TOOL_CLASSES.stream().map(Class::getSimpleName).collect(Collectors.toCollection(TreeSet::new)); + assertEquals(onDisk, listed, + "TOOL_CLASSES is out of step with the package. A class present on disk but not listed has its " + + "@Tool methods checked by nothing, so forgetting the whitelist stays silent: " + onDisk); + } } diff --git a/src/test/java/ai/labs/eddi/modules/llm/tools/ToolRateLimiterTest.java b/src/test/java/ai/labs/eddi/modules/llm/tools/ToolRateLimiterTest.java index 091fc5e19..1dd0ef8a7 100644 --- a/src/test/java/ai/labs/eddi/modules/llm/tools/ToolRateLimiterTest.java +++ b/src/test/java/ai/labs/eddi/modules/llm/tools/ToolRateLimiterTest.java @@ -396,4 +396,73 @@ void resetAllClearsTheIndex() { assertEquals(0, rateLimiter.scanCandidateCount("toolB")); } } + + /** + * A refill computed as {@code elapsedNanos * limit / WINDOW_NANOS} in long + * arithmetic overflows once a bucket has been idle long enough — about 107 days + * at the default limit of 1000, sooner at a higher one. The wrapped product is + * negative, so instead of refilling, the bucket's token count is driven below + * zero and {@code tryAcquire} refuses every subsequent call. + *

    + * The failure mode is the nasty kind: a rate limiter that silently latches shut + * and denies a tool forever, with no error and nothing in the logs to connect + * it to elapsed time. + */ + @Nested + @DisplayName("refill over a long idle window") + class LongIdleRefill { + + private static final long NANOS_PER_DAY = 86_400L * 1_000_000_000L; + + @Test + @DisplayName("a bucket idle past the long-overflow threshold refills instead of latching shut") + void idleBucketRefillsRatherThanLatchingShut() { + var bucket = new ToolRateLimiter.RateLimitBucket(1000); + + // Drain it, so a broken refill cannot be masked by a full bucket. + for (int i = 0; i < 1000; i++) { + assertTrue(bucket.tryAcquire(), "bucket should start with its full allowance"); + } + assertFalse(bucket.tryAcquire(), "drained bucket denies until it refills"); + + // 200 days idle: past the ~107-day point where elapsedNanos * 1000 + // exceeds Long.MAX_VALUE. + bucket.backdateLastRefill(200 * NANOS_PER_DAY); + + assertTrue(bucket.tryAcquire(), + "after a long idle window the bucket must be full again, not permanently denied"); + assertTrue(bucket.tokenCount() >= 0.0, + "token count must never go negative — that is the latched-shut state"); + } + + @Test + @DisplayName("the refill saturates at the limit rather than overshooting") + void longIdleRefillIsStillCappedAtLimit() { + var bucket = new ToolRateLimiter.RateLimitBucket(10); + assertTrue(bucket.tryAcquire()); + + bucket.backdateLastRefill(3650 * NANOS_PER_DAY); // ten years + + assertEquals(10, bucket.getRemaining(), + "an arbitrarily long idle period grants the limit, never more"); + } + + @Test + @DisplayName("a normal short idle window still refills proportionally") + void shortIdleRefillIsUnchanged() { + var bucket = new ToolRateLimiter.RateLimitBucket(60); + for (int i = 0; i < 60; i++) { + assertTrue(bucket.tryAcquire()); + } + assertFalse(bucket.tryAcquire()); + + // Half a window back should return roughly half the allowance; the + // guard against the overflow fix accidentally changing normal maths. + bucket.backdateLastRefill(30_000L * 1_000_000L); + + int remaining = bucket.getRemaining(); + assertTrue(remaining >= 25 && remaining <= 35, + "half a window should restore about half the allowance, got " + remaining); + } + } } From 6714f89cbe0e895712b867f67ffe62c1ec972f45 Mon Sep 17 00:00:00 2001 From: Gregor Jarisch Date: Wed, 12 Aug 2026 02:59:56 +0200 Subject: [PATCH 2/6] test(docs): prune skipped directories instead of walking then filtering MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addresses Copilot's review comment on #677. markdownFiles() used Files.walk(root) and filtered SKIPPED_DIRS afterwards, so it paid the full traversal cost of every directory it then discarded. That is not cosmetic here: target/ always exists when tests run and holds tens of thousands of class files, .git/ is comparably large, and this repository's own agent worktrees live under .claude/ — so the walk also recursed through nested checkouts of the repository itself. Files.walkFileTree with SKIP_SUBTREE prunes those roots instead. The test drops from ~1.9-4.4s to ~0.6s, and detection is unchanged: reverting a link fix still fails it with the file and target named. --- .../eddi/docs/DocumentationLinksTest.java | 51 ++++++++++++++----- 1 file changed, 39 insertions(+), 12 deletions(-) diff --git a/src/test/java/ai/labs/eddi/docs/DocumentationLinksTest.java b/src/test/java/ai/labs/eddi/docs/DocumentationLinksTest.java index 72fba70b2..4d74f6ad7 100644 --- a/src/test/java/ai/labs/eddi/docs/DocumentationLinksTest.java +++ b/src/test/java/ai/labs/eddi/docs/DocumentationLinksTest.java @@ -11,9 +11,12 @@ import java.io.UncheckedIOException; import java.net.URLDecoder; import java.nio.charset.StandardCharsets; +import java.nio.file.FileVisitResult; import java.nio.file.Files; import java.nio.file.LinkOption; import java.nio.file.Path; +import java.nio.file.SimpleFileVisitor; +import java.nio.file.attribute.BasicFileAttributes; import java.util.ArrayList; import java.util.List; import java.util.Locale; @@ -70,22 +73,46 @@ private static Path repoRoot() { return root; } + /** + * Collects every markdown file, pruning the directories in + * {@link #SKIPPED_DIRS} rather than walking them and filtering afterwards. + *

    + * The distinction is not cosmetic here. {@code target/} always exists when + * these tests run and holds tens of thousands of class files, {@code .git/} is + * comparably large, and this repository's own agent worktrees live under + * {@code .claude/} — so a filter-after-walk pays the full I/O cost of all three + * on every run, and would recurse through nested checkouts. + */ private static List markdownFiles(Path root) { - try (Stream paths = Files.walk(root)) { - return paths.filter(Files::isRegularFile) - .filter(p -> p.getFileName().toString().toLowerCase(Locale.ROOT).endsWith(".md")) - .filter(p -> { - for (Path part : root.relativize(p)) { - if (SKIPPED_DIRS.contains(part.toString())) { - return false; - } - } - return true; - }) - .toList(); + List found = new ArrayList<>(); + try { + Files.walkFileTree(root, new SimpleFileVisitor<>() { + @Override + public FileVisitResult preVisitDirectory(Path dir, BasicFileAttributes attrs) { + if (!dir.equals(root) && SKIPPED_DIRS.contains(dir.getFileName().toString())) { + return FileVisitResult.SKIP_SUBTREE; + } + return FileVisitResult.CONTINUE; + } + + @Override + public FileVisitResult visitFile(Path file, BasicFileAttributes attrs) { + if (attrs.isRegularFile() + && file.getFileName().toString().toLowerCase(Locale.ROOT).endsWith(".md")) { + found.add(file); + } + return FileVisitResult.CONTINUE; + } + + @Override + public FileVisitResult visitFileFailed(Path file, IOException exc) { + return FileVisitResult.CONTINUE; // an unreadable entry is not this test's business + } + }); } catch (IOException e) { throw new UncheckedIOException(e); } + return found; } /** From 313f382949906a8d9b9525c70fa6cec9637801ed Mon Sep 17 00:00:00 2001 From: Gregor Jarisch Date: Wed, 12 Aug 2026 03:31:29 +0200 Subject: [PATCH 3/6] =?UTF-8?q?fix:=20address=20CodeRabbit=20review=20?= =?UTF-8?q?=E2=80=94=20eviction=20throttle,=20installer=20safety,=20doc-te?= =?UTF-8?q?st=20gaps?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit All seven findings on #677, two of them real defects rather than polish. Eviction rescans could stall the submit path (Major). With every tracked conversation live, a scan evicts nothing — and the next submit for an unseen conversation would take the write lock and traverse the whole queue (bounded at 100k) to reach the same conclusion, with every other submitter blocked behind it on the read lock. Only a flush can change the answer, so a barrier keyed on a flush generation counter now bars the rescan until one happens. Mutation-checked: removing the barrier turns 1 futile scan into 55. Monitoring downloads failed soft, but every one of those files is bind-mounted as a FILE by docker-compose.monitoring.yml — so a missing one is worse than it sounds: Docker creates a *directory* at the mount path and Grafana then fails to provision, including the dashboards that did download. Now fatal in both installers, matching the reasoning already written for the Keycloak realm. DocumentationLinksTest had two holes: - normalize() collapses '..' but does not confine the result to the repo, so `../../thing.md` could land on a real file outside the checkout and pass — which is the exact mistake that put 32 planning/ links outside the repo. - The SUMMARY.md check used Files.list, which sees only direct children. Made recursive, and it immediately found docs/templates/baa-template.md unreachable from the table of contents. Now listed. Also: - Copilot: markdownFiles() walked target/, .git/ and .claude/ before filtering them. Pruned with SKIP_SUBTREE; the test drops from ~1.9-4.4s to ~0.6s. - scripts/preflight-local.ps1 was as unlinted as the installers were; the PowerShell parse and PSScriptAnalyzer steps now cover scripts/ too. - The OpenAPI import instructions hard-coded localhost, which only works when Postman runs on the EDDI host. - The changelog claimed every fix had a mutation-checked test. Several do not, for stated reasons; the claim is narrowed and the exceptions spelled out rather than left to imply more than was done. --- .github/workflows/ci.yml | 28 ++++++---- docs/SUMMARY.md | 1 + docs/changelog.md | 22 ++++++-- docs/conversations.md | 5 +- .../creating-your-first-agent-1.md | 5 +- .../creating-your-first-agent.md | 5 +- docs/httpcalls.md | 5 +- install.ps1 | 8 ++- install.sh | 9 +++- .../eddi/engine/audit/AuditLedgerService.java | 53 +++++++++++++++++++ .../eddi/docs/DocumentationLinksTest.java | 32 +++++++---- .../engine/audit/AuditLedgerServiceTest.java | 40 ++++++++++++++ 12 files changed, 181 insertions(+), 32 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3bf2ae647..0232bea00 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -124,21 +124,31 @@ jobs: shell: pwsh run: | # Parse-only: never dot-source or run the installer in CI. + # Covers scripts/ too — preflight-local.ps1 is a contributor-facing + # script and was as unchecked as the installers were. $ErrorActionPreference = 'Stop' - $errors = $null - [System.Management.Automation.Language.Parser]::ParseFile( - (Resolve-Path ./install.ps1), [ref]$null, [ref]$errors) | Out-Null - if ($errors) { - $errors | ForEach-Object { Write-Host "::error file=install.ps1,line=$($_.Extent.StartLineNumber)::$($_.Message)" } - exit 1 - } - Write-Host "install.ps1 parses cleanly" + $failed = $false + Get-ChildItem -Path ./install.ps1, ./scripts -Filter *.ps1 -Recurse -ErrorAction SilentlyContinue | + ForEach-Object { + $errors = $null + [System.Management.Automation.Language.Parser]::ParseFile($_.FullName, [ref]$null, [ref]$errors) | Out-Null + if ($errors) { + $failed = $true + $name = Resolve-Path -Relative $_.FullName + $errors | ForEach-Object { Write-Host "::error file=$name,line=$($_.Extent.StartLineNumber)::$($_.Message)" } + } else { + Write-Host "$($_.Name) parses cleanly" + } + } + if ($failed) { exit 1 } - name: PSScriptAnalyzer shell: pwsh run: | Install-Module PSScriptAnalyzer -Force -Scope CurrentUser -ErrorAction Stop - $found = Invoke-ScriptAnalyzer -Path ./install.ps1 -Severity Error,Warning + $targets = @('./install.ps1') + if (Test-Path ./scripts) { $targets += './scripts' } + $found = Invoke-ScriptAnalyzer -Path $targets -Recurse -Severity Error,Warning $found | Format-Table -AutoSize | Out-String | Write-Host if ($found | Where-Object { $_.Severity -eq 'Error' }) { exit 1 } diff --git a/docs/SUMMARY.md b/docs/SUMMARY.md index ca681237a..3837cc5f9 100644 --- a/docs/SUMMARY.md +++ b/docs/SUMMARY.md @@ -65,6 +65,7 @@ - [Audit Ledger](audit-ledger.md) - [GDPR / CCPA Compliance](gdpr-compliance.md) - [HIPAA Compliance](hipaa-compliance.md) +- [Business Associate Agreement (BAA) Template](templates/baa-template.md) - [EU AI Act Compliance](eu-ai-act-compliance.md) - [Compliance Data Flow](compliance-data-flow.md) - [Incident Response Plan](incident-response.md) diff --git a/docs/changelog.md b/docs/changelog.md index 2212ad461..cfab1edd2 100644 --- a/docs/changelog.md +++ b/docs/changelog.md @@ -22,8 +22,8 @@ persisted and re-seeded them — reintroducing the exact duplicate the fix exist a narrower door. The drain and the publish now happen under the same read lock submitters take, so eviction cannot observe the intermediate state. No I/O is inside the lock. -**Every fix in this branch now has a mutation-checked regression test.** Not merely "a test that -passes" — in each case the fix was reverted and the test was confirmed to fail, with the message it +**Every fix whose failure mode a test can express is mutation-checked.** Not merely "a test that +passes" — in each case the fix was reverted and the test confirmed to fail, with the message it would print to whoever broke it: | Fix | Test | Reverting the fix produces | @@ -38,7 +38,23 @@ would print to whoever broke it: | ToC drift | `everyDocIsListedInSummary` | names the unreachable page | | Inline FQNs | `noInlineFullyQualifiedNames` | names file, line and the offending name | -Two of these deserve note as *class-of-bug* guards rather than single-defect regressions. +The rest are covered differently, and it is worth being exact about how rather than letting the +sentence above imply more than it should: + +- **`SafeHttpClient`'s timeout backstop** has five direct unit cases (`SafeHttpClientTimeoutTest`) + covering the bound, the caller's own timeout winning, and the rebuild preserving method, headers + and body. They are not mutation-checked in the same sense — the defect was an *absent* bound, so + reverting it is what the "unbounded request is bounded" case already asserts. +- **`ConversationStepRunner`'s registration move** has no dedicated test on purpose: only an + `Error` can reach the window, since the intervening call swallows `Exception`. A test would have + to inject a `StackOverflowError` to prove a hardening change, which pins the mechanism rather than + the behaviour. +- **`ThreadLocalRandom`** is behaviour-preserving; the existing selection tests cover it. +- **The installer CI and the rescued dashboard** are verified by the pipeline itself — `shell-lint` + runs against `install.sh` and passes, and the compose mount is exercised by the monitoring stack + rather than by a unit test. + +Two of the tests above deserve note as *class-of-bug* guards rather than single-defect regressions. `DocumentationLinksTest` walks every markdown file in the repository and resolves every relative link. Link rot is invisible to every other check in this build — markdown compiles to nothing, so a diff --git a/docs/conversations.md b/docs/conversations.md index d7cbb6393..5003f0410 100644 --- a/docs/conversations.md +++ b/docs/conversations.md @@ -644,5 +644,6 @@ Developer tests: > **Run it yourself.** The GitBook-hosted Postman collections that used to be linked here > were lost in the migration. You do not need them: every request is shown inline above, > and Postman can import EDDI's own spec directly — **Import → Link →** -> `http://localhost:7070/openapi`, which is generated from the running build and so is -> never out of date. The same spec is browsable at `/q/swagger-ui`. +> `/openapi` (`http://localhost:7070/openapi` for a local install). +> It is generated from the running build, so unlike a committed collection it cannot go +> out of date. The same spec is browsable at `/q/swagger-ui`. diff --git a/docs/creating-your-first-agent/creating-your-first-agent-1.md b/docs/creating-your-first-agent/creating-your-first-agent-1.md index e975f5257..3fc6af68a 100644 --- a/docs/creating-your-first-agent/creating-your-first-agent-1.md +++ b/docs/creating-your-first-agent/creating-your-first-agent-1.md @@ -613,8 +613,9 @@ By the way you can use the attached **postman collection** below to do all of th > **Run it yourself.** The GitBook-hosted Postman collections that used to be linked here > were lost in the migration. You do not need them: every request is shown inline above, > and Postman can import EDDI's own spec directly — **Import → Link →** -> `http://localhost:7070/openapi`, which is generated from the running build and so is -> never out of date. The same spec is browsable at `/q/swagger-ui`. +> `/openapi` (`http://localhost:7070/openapi` for a local install). +> It is generated from the running build, so unlike a committed collection it cannot go +> out of date. The same spec is browsable at `/q/swagger-ui`. ### External Links diff --git a/docs/creating-your-first-agent/creating-your-first-agent.md b/docs/creating-your-first-agent/creating-your-first-agent.md index c371160ab..73d1f62df 100644 --- a/docs/creating-your-first-agent/creating-your-first-agent.md +++ b/docs/creating-your-first-agent/creating-your-first-agent.md @@ -166,8 +166,9 @@ By the way you can use the attached **postman collection** below to do all of th > **Run it yourself.** The GitBook-hosted Postman collections that used to be linked here > were lost in the migration. You do not need them: every request is shown inline above, > and Postman can import EDDI's own spec directly — **Import → Link →** -> `http://localhost:7070/openapi`, which is generated from the running build and so is -> never out of date. The same spec is browsable at `/q/swagger-ui`. +> `/openapi` (`http://localhost:7070/openapi` for a local install). +> It is generated from the running build, so unlike a committed collection it cannot go +> out of date. The same spec is browsable at `/q/swagger-ui`. ### External Links diff --git a/docs/httpcalls.md b/docs/httpcalls.md index 966a025a9..513b08b8a 100644 --- a/docs/httpcalls.md +++ b/docs/httpcalls.md @@ -793,5 +793,6 @@ _Response Code_ > **Run it yourself.** The GitBook-hosted Postman collections that used to be linked here > were lost in the migration. You do not need them: every request is shown inline above, > and Postman can import EDDI's own spec directly — **Import → Link →** -> `http://localhost:7070/openapi`, which is generated from the running build and so is -> never out of date. The same spec is browsable at `/q/swagger-ui`. +> `/openapi` (`http://localhost:7070/openapi` for a local install). +> It is generated from the running build, so unlike a committed collection it cannot go +> out of date. The same spec is browsable at `/q/swagger-ui`. diff --git a/install.ps1 b/install.ps1 index 9f430c70e..b56f4367e 100644 --- a/install.ps1 +++ b/install.ps1 @@ -555,7 +555,13 @@ function Get-ComposeFiles { Write-Information -MessageData "✅" } catch { - Write-Warn "Failed to download $mf (monitoring may not work)" + # Bind-mounted as a FILE by docker-compose.monitoring.yml, so a + # missing one is worse than it sounds: Docker creates a + # *directory* at the mount path and Grafana then fails to + # provision, including the dashboards that did download. Same + # reasoning as the Keycloak realm below. + if (Test-Path $mfTarget) { Remove-Item -Path $mfTarget -Force } + Write-Fail "Failed to download $mf (required for -WithMonitoring).`n URL: $mfUrl" } } } diff --git a/install.sh b/install.sh index eaa780fec..c7e0dcc49 100755 --- a/install.sh +++ b/install.sh @@ -636,7 +636,14 @@ resolve_compose_files() { if curl -fsSL "${mf_url}" -o "$mf_target"; then echo -e "${GREEN}✅${RESET}" else - warn "Failed to download ${mf} (monitoring may not work)" + # Every one of these is bind-mounted as a FILE by + # docker-compose.monitoring.yml, so a missing one is worse than it + # sounds: Docker creates a *directory* at the mount path, and Grafana + # then fails to provision — including the dashboards that did + # download. Same reasoning as the Keycloak realm below; a half-built + # monitoring stack is not better than a refusal to build one. + rm -f "$mf_target" + fail "Failed to download ${mf} (required for --with-monitoring).\n URL: ${mf_url}" fi fi done diff --git a/src/main/java/ai/labs/eddi/engine/audit/AuditLedgerService.java b/src/main/java/ai/labs/eddi/engine/audit/AuditLedgerService.java index 72cf26a41..e73f99fc6 100644 --- a/src/main/java/ai/labs/eddi/engine/audit/AuditLedgerService.java +++ b/src/main/java/ai/labs/eddi/engine/audit/AuditLedgerService.java @@ -122,6 +122,21 @@ public class AuditLedgerService { * store, so eviction has to treat their conversations as live. */ private volatile List inFlightBatch = List.of(); + /** + * Bumped once per completed {@link #flush()}. The only event that can change + * whether a counter is evictable, so it is what + * {@link #evictSequenceCountersIfFull} uses to decide a rescan is worthwhile. + */ + private final AtomicLong flushGeneration = new AtomicLong(); + /** + * The {@link #flushGeneration} at which an eviction scan last found nothing to + * evict. Equal values mean "already tried, nothing has moved since". + */ + private final AtomicLong evictionBarrier = new AtomicLong(-1L); + /** + * Counts scans that evicted nothing — see {@link #getFutileEvictionScans()}. + */ + private final AtomicLong futileEvictionScans = new AtomicLong(); private ScheduledExecutorService flushExecutor; @Inject @@ -354,11 +369,23 @@ private void evictSequenceCountersIfFull(String conversationId) { return; } + // A scan that evicted nothing will evict nothing again until a flush has + // moved entries out of the queue. Without this guard, a table full of + // genuinely live conversations makes EVERY submit for an unseen + // conversation take the write lock and traverse the whole queue (bounded + // at maxQueueSize, 100_000 by default) to reach the same conclusion — + // and because submitters hold the read lock, they all queue behind it. + // The barrier turns a per-submit scan into at most one per flush cycle. + if (evictionBarrier.get() == flushGeneration.get()) { + return; + } + sequenceLock.writeLock().lock(); try { if (conversationSequences.size() < MAX_TRACKED_CONVERSATIONS) { return; } + long generation = flushGeneration.get(); Set retain = new HashSet<>(undelivered.keySet()); collectConversationIds(queue, retain); @@ -371,6 +398,15 @@ private void evictSequenceCountersIfFull(String conversationId) { if (evicted > 0) { LOGGER.infov("Audit sequence table hit {0} conversations — evicted {1} fully-persisted counters; " + "{2} retained as in flight", MAX_TRACKED_CONVERSATIONS, evicted, conversationSequences.size()); + } else { + // Read AFTER the scan: a flush that completed while we scanned + // must not be recorded as already-accounted-for, or the next + // genuinely useful scan would be skipped. + evictionBarrier.set(generation); + futileEvictionScans.incrementAndGet(); + LOGGER.warnv("Audit sequence table is full ({0}) and every counter is in flight; not rescanning " + + "until the next flush. New conversations are recorded unsequenced meanwhile.", + MAX_TRACKED_CONVERSATIONS); } } finally { sequenceLock.writeLock().unlock(); @@ -486,6 +522,9 @@ synchronized void flush() { // Every outcome has put these positions somewhere eviction can see // again: persisted, re-queued, or recorded in `undelivered`. inFlightBatch = List.of(); + // Entries have moved, so a previously futile eviction scan may now + // find something. Bump last, once the move is visible. + flushGeneration.incrementAndGet(); } } } @@ -606,6 +645,20 @@ int getQueueSize() { return queueSize.get(); } + /** Conversations currently holding a sequence counter. */ + int getTrackedConversationCount() { + return conversationSequences.size(); + } + + /** + * How many eviction scans found nothing to evict. The barrier exists so this + * stays near-constant under a full table of live conversations rather than + * growing once per submit, which is what the test asserts. + */ + long getFutileEvictionScans() { + return futileEvictionScans.get(); + } + byte[] getHmacKey() { return hmacKey; } diff --git a/src/test/java/ai/labs/eddi/docs/DocumentationLinksTest.java b/src/test/java/ai/labs/eddi/docs/DocumentationLinksTest.java index 4d74f6ad7..00a89aee9 100644 --- a/src/test/java/ai/labs/eddi/docs/DocumentationLinksTest.java +++ b/src/test/java/ai/labs/eddi/docs/DocumentationLinksTest.java @@ -197,7 +197,14 @@ void everyRelativeLinkResolves() { Path resolved = decoded.startsWith("/") ? root.resolve(decoded.substring(1)).normalize() : file.getParent().resolve(decoded).normalize(); - if (!Files.exists(resolved)) { + if (!resolved.startsWith(root)) { + // normalize() collapses '..' but does not confine the result to + // the repository. `../../thing.md` can land on a real file + // outside the checkout and quietly pass — which is the very + // mistake that put 32 planning/ links outside the repo. + broken.add(root.relativize(file) + " -> " + target + + " (escapes the repository root)"); + } else if (!Files.exists(resolved)) { broken.add(root.relativize(file) + " -> " + target); } else { String actualCasing = mismatchedCasing(resolved); @@ -226,15 +233,20 @@ void everyDocIsListedInSummary() throws IOException { String summary = Files.readString(docs.resolve("SUMMARY.md"), StandardCharsets.UTF_8); Set missing = new TreeSet<>(); - try (Stream paths = Files.list(docs)) { - for (Path p : paths.filter(Files::isRegularFile).toList()) { - String name = p.getFileName().toString(); - if (!name.endsWith(".md") || name.equals("SUMMARY.md") || name.equals("README.md")) { - continue; // the index itself, and the folder's own landing page - } - if (!summary.contains("(" + name + ")") && !summary.contains("/" + name + ")")) { - missing.add(name); - } + // Recursive, not Files.list: the tutorials live in + // docs/creating-your-first-agent/ + // and docs/monitoring/, so a direct-children listing would let a nested + // page go unlisted — exactly the pages a newcomer needs to find. + for (Path p : markdownFiles(docs)) { + String name = p.getFileName().toString(); + if (name.equals("SUMMARY.md") || name.equals("README.md")) { + continue; // the index itself, and each folder's own landing page + } + String relative = docs.relativize(p).toString().replace('\\', '/'); + // SUMMARY.md links either by bare name or by path, depending on depth. + if (!summary.contains("(" + relative + ")") && !summary.contains("(" + name + ")") + && !summary.contains("/" + name + ")")) { + missing.add(relative); } } diff --git a/src/test/java/ai/labs/eddi/engine/audit/AuditLedgerServiceTest.java b/src/test/java/ai/labs/eddi/engine/audit/AuditLedgerServiceTest.java index 2e3833e33..1d8e98b4a 100644 --- a/src/test/java/ai/labs/eddi/engine/audit/AuditLedgerServiceTest.java +++ b/src/test/java/ai/labs/eddi/engine/audit/AuditLedgerServiceTest.java @@ -232,6 +232,46 @@ void sequenceEvictionRetainsTheInFlightBatch() { "the counter must have survived an eviction that ran while its entry was in the flush batch"); } + /** + * When every tracked conversation is genuinely live, a scan evicts nothing — + * and without a barrier the next submit for an unseen conversation would take + * the write lock and traverse the whole queue (bounded at 100k) to reach the + * same conclusion, with every other submitter blocked behind it on the read + * lock. Only a flush can change the answer, so only a flush lifts the barrier. + */ + @Test + @DisplayName("sequence eviction — a futile scan is not repeated until a flush could change the answer") + void futileEvictionScanIsNotRepeatedUntilFlush() { + when(auditStore.supportsSequence()).thenReturn(true); + when(auditStore.countByConversation(anyString())).thenReturn(0L); + service = createService(true, null); + + // Fill the table with conversations that are all still queued, so nothing + // is evictable. + for (int i = 0; i < AuditLedgerService.MAX_TRACKED_CONVERSATIONS + 5; i++) { + service.submit(entry("fill-" + i, "filler-" + i, "agent1")); + } + int trackedAfterFill = service.getTrackedConversationCount(); + + // Further unseen conversations must not each re-scan; they degrade to + // UNSEQUENCED, which is the honest verdict rather than a duplicate. + for (int i = 0; i < 50; i++) { + service.submit(entry("late-" + i, "late-conv-" + i, "agent1")); + } + assertEquals(trackedAfterFill, service.getTrackedConversationCount(), + "a barred scan must not grow the table either"); + assertEquals(1, service.getFutileEvictionScans(), + "the scan should have run once and then been barred, not once per submit"); + + // A flush moves entries out of the queue, so the next scan is worthwhile + // again — and this time it can actually evict. + service.flush(); + service.submit(entry("after-1", "after-flush", "agent1")); + + assertTrue(service.getTrackedConversationCount() < trackedAfterFill, + "once the queue drained, the barrier must lift and eviction reclaim the table"); + } + @Test @DisplayName("flush — does nothing when queue is empty") void flushEmptyQueue() { From 3958cba75173523c5f0ecc02aeba73981d97a7d8 Mon Sep 17 00:00:00 2001 From: Gregor Jarisch Date: Wed, 12 Aug 2026 03:47:47 +0200 Subject: [PATCH 4/6] fix(ci): PSScriptAnalyzer -Path takes one string, not an array MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The shell-lint job I added in this branch failed on its own first real run — which is the job working, just at my expense. Extending it to cover scripts/ passed `@('./install.ps1', './scripts')` to -Path, and that parameter is a single String: Cannot convert 'System.Object[]' to the type 'System.String' required by parameter 'Path'. Loop over the targets instead. Also skip Install-Module when the runner already ships PSScriptAnalyzer, which it does — installing over it only emits a "version is currently in use" warning. Both PowerShell steps were run locally under pwsh this time rather than reasoned about: the parse step passes for install.ps1 and scripts/preflight-local.ps1, and the analyzer reports 9 warnings and 0 errors, so the gate passes while still surfacing them in the log. --- .github/workflows/ci.yml | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 0232bea00..05587646e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -145,10 +145,19 @@ jobs: - name: PSScriptAnalyzer shell: pwsh run: | - Install-Module PSScriptAnalyzer -Force -Scope CurrentUser -ErrorAction Stop + # Preinstalled on the runner; installing over it only emits a + # "currently in use" warning. + if (-not (Get-Module -ListAvailable -Name PSScriptAnalyzer)) { + Install-Module PSScriptAnalyzer -Force -Scope CurrentUser -ErrorAction Stop + } $targets = @('./install.ps1') if (Test-Path ./scripts) { $targets += './scripts' } - $found = Invoke-ScriptAnalyzer -Path $targets -Recurse -Severity Error,Warning + # -Path takes ONE string, not an array — passing @(...) fails with + # "Cannot convert 'System.Object[]' to the type 'System.String'". + $found = @() + foreach ($t in $targets) { + $found += Invoke-ScriptAnalyzer -Path $t -Recurse -Severity Error,Warning + } $found | Format-Table -AutoSize | Out-String | Write-Host if ($found | Where-Object { $_.Severity -eq 'Error' }) { exit 1 } From 3267b75a1d85f2328615d73ddaed99fb362dedd2 Mon Sep 17 00:00:00 2001 From: Gregor Jarisch Date: Wed, 12 Aug 2026 10:36:40 +0200 Subject: [PATCH 5/6] fix: address Copilot's suppressed review comments on #677 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Four findings it raised in the review body rather than inline. Two were real bugs, one is a genuine constraint worth documenting, and one I disagree with on the evidence — recorded here rather than silently ignored. install.sh: `rm -f` cannot remove a DIRECTORY, and what is in the way is most likely exactly that — the stale mount path a previous failed run left behind. Under `set -euo pipefail` the failed rm becomes the script's exit point, so the user sees "rm: cannot remove ...: Is a directory" instead of the fail message explaining what to do, and the stale path survives to break the next run too. Reproduced before fixing; `rm -rf` restores the intended message. install.ps1 had the same shape: `Remove-Item -Force` does not remove a directory without -Recurse. Changelog placement violated AGENTS.md rule 8 — it must land on the branch carrying the work it documents. After the #675 split, the entries here still claimed the 575-name refactor and ImportStyleTest, which ship on chore/inline-fqn-cleanup. Those claims are removed; the branch no longer records work it did not carry. The sequence-table cap is a threshold, not a hard ceiling: the check in nextSequence is deliberately not atomic with the insert that follows, so concurrent submitters can overshoot slightly. Making it exact means serialising the insert path on every new conversation to enforce a bound that is a memory heuristic, not a correctness property. Said so where it is checked and where it is declared, instead of implying a guarantee that does not hold. On the fourth — that pinned undelivered conversations could leave every new conversation UNSEQUENCED until restart — the retention is real but that consequence is not reachable, and the suggested remedy is unsound. Re-seeding a conversation with a dead-lettered gap cannot work: countByConversation counts persisted rows, so with sequences 0-9 where 3 and 5 never landed the count is 8 while the next free position is 10, and it would hand out 8 and 9 again. Accounting for the highest undelivered position does not help either (max(8, 6) is still 8); a sound re-seed needs a maxSequence() the store interface does not expose. The pin is therefore correct, and bounded: undeliveredTracked counts SEQUENCES, so at most MAX_TRACKED_UNDELIVERED (10,000) conversations can be pinned against a 50,000 table, leaving 80% evictable. undeliveredPinCannotExhaustTheTable pins that headroom so raising one cap past the other fails the build rather than silently stranding conversations. --- docs/changelog.md | 31 ++++--------- install.ps1 | 5 ++- install.sh | 8 +++- .../eddi/engine/audit/AuditLedgerService.java | 43 ++++++++++++++++--- .../engine/audit/AuditLedgerServiceTest.java | 23 ++++++++++ 5 files changed, 80 insertions(+), 30 deletions(-) diff --git a/docs/changelog.md b/docs/changelog.md index cfab1edd2..630b6fc4f 100644 --- a/docs/changelog.md +++ b/docs/changelog.md @@ -65,14 +65,10 @@ link was fine; the resolver was wrong, and now handles the leading `/` the way t Documentation *of* link syntax (the `` `![alt](uri)` `` rows in the output-format tables) is excluded by stripping code spans and fences, not by an ignore list that would rot in turn. -`ImportStyleTest` enforces AGENTS.md §4.7. These accumulate precisely because nothing fails when one -is added — the code compiles either way, so the rule was advice a reviewer had to catch by eye. -Writing it exposed that the original audit had **under-counted**: its pattern required a package -segment after `java.util`, so `java.util.List` never matched. The real total was 575, not 141, and -the remaining 209 (mostly `java.util.Objects` in `equals`/`hashCode`) are now cleaned up too. The -one genuine exception AGENTS.md allows — `mongo.HistorizedResourceStore extends -datastore.HistorizedResourceStore` and its Modifiable twin — is an explicit allowlist, so adding to -it is a reviewable act rather than silent drift. +`ImportStyleTest` and the 575-name cleanup it guards ship on a separate branch, so they are +described in that branch's own entry rather than claimed here. Writing it did change one fact +recorded above: the original audit had under-counted, because its pattern required a package +segment after `java.util`, so `java.util.List` never matched. **Two seams were widened for testability, both deliberately.** `RateLimitBucket` became package-private with a `backdateLastRefill` hook, because a ~107-day idle bucket cannot be reached @@ -81,17 +77,13 @@ behaviour. `SafeHttpClient.withDefaultTimeout` became package-private so its fiv without an embedded server — the existing `SafeHttpClientTest` binds a loopback socket in `@BeforeEach` and therefore only runs where those are available. -**And a proof rather than an assurance about the 575-name refactor.** Every string literal in all -273 mechanically-changed files was extracted and compared against `origin/main`: byte-identical. -A rewrite that reached inside a literal — a reflective class name, a config key, a log format — -would compile, pass every test, and show up nowhere else. The only six files whose literals changed -are the hand-edited ones, and each change is a message this branch intended to change. + --- -## 🧹 chore: close the gaps outside the build — installer CI, link rot, dead code, 366 inline FQNs (2026-08-12) +## 🧹 chore: close the gaps outside the build — installer CI, link rot, dead code (2026-08-12) **Repo:** EDDI (`fix/code-review-defects-and-docs`) @@ -132,14 +124,9 @@ was a JAX-RS interface declaring `/user/isAuthenticated` and `/user/securityType implementing class** — the only `@Path("/user")` in the codebase, so those endpoints were advertised to OpenAPI and served by nothing. Both removed. -**366 inline fully-qualified names across 168 files**, against AGENTS.md §4.7's own rule. These were -not the permitted disambiguation case — `PendingApprovalSummary`, `HitlDecision`, -`ToolApprovalsConfig`, `ConversationMemorySnapshot` and `ControlSignal` each resolve to exactly one -class, and `IConversationService` imported `java.util.List` on line 17 while writing -`java.util.List<…>` on line 355. The two genuine cases — `mongo.HistorizedResourceStore extends -datastore.HistorizedResourceStore` and its Modifiable twin — were detected and left alone. Verified -with a **clean** `test-compile`, since an incremental build reuses stale classes and hides exactly -this kind of break. +**Inline fully-qualified names** were also found in breach of AGENTS.md §4.7, but that cleanup does +not ship here — it is a separate branch and its own changelog entry, so this one does not claim +work it did not carry. **Link rot: 38 broken links, now zero.** Every `planning/*.md` file computed `../` and `../../` as though it lived under `docs/planning/`, but the directory is at the repo root — so `../../AGENTS.md` diff --git a/install.ps1 b/install.ps1 index b56f4367e..14fb10b37 100644 --- a/install.ps1 +++ b/install.ps1 @@ -560,7 +560,10 @@ function Get-ComposeFiles { # *directory* at the mount path and Grafana then fails to # provision, including the dashboards that did download. Same # reasoning as the Keycloak realm below. - if (Test-Path $mfTarget) { Remove-Item -Path $mfTarget -Force } + # -Recurse as well as -Force: what is in the way is most + # likely a DIRECTORY left by a previous run's failed mount, + # and -Force alone will not remove one. + if (Test-Path $mfTarget) { Remove-Item -Path $mfTarget -Recurse -Force -ErrorAction SilentlyContinue } Write-Fail "Failed to download $mf (required for -WithMonitoring).`n URL: $mfUrl" } } diff --git a/install.sh b/install.sh index c7e0dcc49..4d99a1bce 100755 --- a/install.sh +++ b/install.sh @@ -642,7 +642,13 @@ resolve_compose_files() { # then fails to provision — including the dashboards that did # download. Same reasoning as the Keycloak realm below; a half-built # monitoring stack is not better than a refusal to build one. - rm -f "$mf_target" + # -rf, not -f: the thing in the way is most likely a DIRECTORY that a + # previous run's failed mount left behind, and `rm -f` cannot remove + # one. Under `set -e` that turns this cleanup into the script's exit + # point, so the user sees "rm: cannot remove ...: Is a directory" + # instead of the message below, and the stale path survives to break + # the next run too. + rm -rf "$mf_target" fail "Failed to download ${mf} (required for --with-monitoring).\n URL: ${mf_url}" fi fi diff --git a/src/main/java/ai/labs/eddi/engine/audit/AuditLedgerService.java b/src/main/java/ai/labs/eddi/engine/audit/AuditLedgerService.java index e73f99fc6..385e6c490 100644 --- a/src/main/java/ai/labs/eddi/engine/audit/AuditLedgerService.java +++ b/src/main/java/ai/labs/eddi/engine/audit/AuditLedgerService.java @@ -59,10 +59,14 @@ public class AuditLedgerService { static final int DEFAULT_MAX_QUEUE_SIZE = 100_000; /** - * Cap on how many conversations are tracked for sequence assignment at once. On - * overflow, counters whose positions are all accounted for are evicted and - * re-seeded from the store on next use — see - * {@link #evictSequenceCountersIfFull} for why only those are safe to drop. + * Threshold at which sequence-counter eviction kicks in. On overflow, counters + * whose positions are all accounted for are evicted and re-seeded from the + * store on next use — see {@link #evictSequenceCountersIfFull} for why only + * those are safe to drop. + *

    + * A threshold, not a hard ceiling: the check in {@link #nextSequence} is not + * atomic with the insert that follows it, so concurrent submitters can + * overshoot it slightly. See the comment there for why that is the right trade. *

    * Package-visible so the eviction tests can reach the threshold instead of * hard-coding a copy of it that would drift. @@ -76,7 +80,7 @@ public class AuditLedgerService { * is hit the remaining drops go unattributed and the verifier falls back to * reporting them as {@code BROKEN} — the conservative verdict. */ - private static final int MAX_TRACKED_UNDELIVERED = 10_000; + static final int MAX_TRACKED_UNDELIVERED = 10_000; private final IAuditStore auditStore; private final boolean enabled; @@ -361,6 +365,23 @@ private boolean offerBounded(AuditEntry entry) { * undelivered table stops recording, so a dead-lettered position may be reused. * That window already reports {@code BROKEN} by the documented fail-strict * rule, so the verdict is unchanged — only its reason is. + *

    + * Conversations with dead-lettered positions stay pinned for the process + * lifetime, and that cannot starve the table. Re-seeding them is not merely + * inconvenient, it is unsound: the seed comes from {@code countByConversation}, + * which counts persisted rows, and a dead-lettered gap makes that count smaller + * than the next free position. With sequences 0-9 where 3 and 5 never landed, + * the count is 8 while the next position is 10 — so a re-seed would hand out 8 + * and 9 a second time. Accounting for the highest known undelivered position + * does not rescue it either ({@code max(8, 6)} is still 8); a sound re-seed + * would need a {@code maxSequence(conversationId)} that {@link IAuditStore} + * does not expose. Retention is therefore correct, and it is bounded: + * {@link #undeliveredTracked} counts sequences, so at most + * {@link #MAX_TRACKED_UNDELIVERED} conversations can be pinned (one sequence + * each, the worst case) out of a {@link #MAX_TRACKED_CONVERSATIONS} table — + * leaving 80% of it evictable. {@code undeliveredPinCannotExhaustTheTable} pins + * that headroom, so raising one cap past the other fails the build rather than + * silently stranding new conversations on {@code UNSEQUENCED}. */ private void evictSequenceCountersIfFull(String conversationId) { // Fast path: nothing to do until the table is full, and a conversation @@ -441,8 +462,18 @@ private long nextSequence(String conversationId) { // reported as BROKEN — the ledger accusing the deployment of tampering // because its own table filled up. An unsequenced entry instead degrades // the window to UNAVAILABLE ("cannot be established"), which is honest. + // Deliberately NOT atomic with the computeIfAbsent below. Submitters share + // the read lock, so N concurrent unseen conversations can all observe + // "one slot left" and all insert, overshooting by up to the number of + // concurrent callers. That is why MAX_TRACKED_CONVERSATIONS is a + // threshold that triggers eviction rather than a hard ceiling: the + // overshoot is bounded by concurrency, self-corrects at the next + // eviction, and costs one AtomicLong per excess conversation. Making it + // exact would mean serialising the insert path — a lock on every submit + // for a new conversation — to enforce a bound that is a memory heuristic, + // not a correctness property. if (!conversationSequences.containsKey(conversationId) && conversationSequences.size() >= MAX_TRACKED_CONVERSATIONS) { - LOGGER.warnv("Audit sequence table is full ({0} conversations, all with entries in flight) — " + LOGGER.warnv("Audit sequence table is at its {0}-conversation threshold with every counter in flight — " + "new conversations are recorded unsequenced until it drains", MAX_TRACKED_CONVERSATIONS); return AuditEntry.UNSEQUENCED; } diff --git a/src/test/java/ai/labs/eddi/engine/audit/AuditLedgerServiceTest.java b/src/test/java/ai/labs/eddi/engine/audit/AuditLedgerServiceTest.java index 1d8e98b4a..fd3458176 100644 --- a/src/test/java/ai/labs/eddi/engine/audit/AuditLedgerServiceTest.java +++ b/src/test/java/ai/labs/eddi/engine/audit/AuditLedgerServiceTest.java @@ -272,6 +272,29 @@ void futileEvictionScanIsNotRepeatedUntilFlush() { "once the queue drained, the barrier must lift and eviction reclaim the table"); } + /** + * A conversation with a dead-lettered position can never be re-seeded — the + * store count is smaller than the next free position once there is a gap, so + * re-seeding would hand the same numbers out twice. Those counters are + * therefore pinned for the process lifetime, which is only safe because they + * cannot fill the table: the undelivered cap counts sequences, so the + * worst case is one pinned conversation per tracked sequence. + *

    + * The failure this guards is a plausible future edit — raising + * {@code MAX_TRACKED_UNDELIVERED} to or past {@code MAX_TRACKED_CONVERSATIONS} + * — after which a long store outage could pin every slot and strand every later + * conversation on {@code UNSEQUENCED} until restart, with nothing failing to + * say so. + */ + @Test + @DisplayName("sequence eviction — pinned undelivered conversations cannot exhaust the table") + void undeliveredPinCannotExhaustTheTable() { + assertTrue(AuditLedgerService.MAX_TRACKED_UNDELIVERED < AuditLedgerService.MAX_TRACKED_CONVERSATIONS, + "every conversation in the undelivered table is pinned in the sequence table, so the undelivered " + + "cap must stay strictly below the conversation cap or a store outage can strand every " + + "later conversation on UNSEQUENCED until restart"); + } + @Test @DisplayName("flush — does nothing when queue is empty") void flushEmptyQueue() { From 580d442b1ec0771b0c12f9ac095d615e8b1c1e13 Mon Sep 17 00:00:00 2001 From: Gregor Jarisch Date: Wed, 12 Aug 2026 11:04:33 +0200 Subject: [PATCH 6/6] test(audit): prove the undelivered-pin invariant instead of asserting arithmetic MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit github-code-quality flagged undeliveredPinCannotExhaustTheTable as a useless comparison, and it was right about the shape: both operands are compile-time constants, so `10_000 < 50_000` folds to `true` and the assertion executes nothing. It would still have failed had someone edited a constant — the test recompiles against the new value — but that is a weak guarantee to rest an invariant on, and it cannot be mutation-checked at all. Replaced with the behavioural test it suggested. A store outage dead-letters a conversation so position 0 is consumed but never persisted; the store then recovers and the table is filled and flushed. The test asserts the persisted conversations are reclaimed while the pinned one is retained, that a later conversation still receives a real chain position rather than UNSEQUENCED, and that the pinned chain resumes at 1 rather than reusing the dead-lettered 0. That last assertion is the one the arithmetic could never make. Mutation-checked by dropping `undelivered.keySet()` from the retain set: the counter survived, so the chain resumes past the dead-lettered 0 rather than reusing it ==> expected: <1> but was: <0> which is the duplicate this whole mechanism exists to prevent. --- .../eddi/engine/audit/AuditLedgerService.java | 8 ++- .../engine/audit/AuditLedgerServiceTest.java | 55 +++++++++++++++++-- 2 files changed, 55 insertions(+), 8 deletions(-) diff --git a/src/main/java/ai/labs/eddi/engine/audit/AuditLedgerService.java b/src/main/java/ai/labs/eddi/engine/audit/AuditLedgerService.java index 385e6c490..e1b9daca5 100644 --- a/src/main/java/ai/labs/eddi/engine/audit/AuditLedgerService.java +++ b/src/main/java/ai/labs/eddi/engine/audit/AuditLedgerService.java @@ -379,9 +379,11 @@ private boolean offerBounded(AuditEntry entry) { * {@link #undeliveredTracked} counts sequences, so at most * {@link #MAX_TRACKED_UNDELIVERED} conversations can be pinned (one sequence * each, the worst case) out of a {@link #MAX_TRACKED_CONVERSATIONS} table — - * leaving 80% of it evictable. {@code undeliveredPinCannotExhaustTheTable} pins - * that headroom, so raising one cap past the other fails the build rather than - * silently stranding new conversations on {@code UNSEQUENCED}. + * leaving 80% of it evictable. {@code undeliveredPinCannotExhaustTheTable} + * exercises that end to end: a dead-lettered conversation stays pinned while + * the persisted ones around it are reclaimed, a later conversation still + * receives a real position rather than {@code UNSEQUENCED}, and the pinned + * chain resumes past its dead-lettered position instead of reusing it. */ private void evictSequenceCountersIfFull(String conversationId) { // Fast path: nothing to do until the table is full, and a conversation diff --git a/src/test/java/ai/labs/eddi/engine/audit/AuditLedgerServiceTest.java b/src/test/java/ai/labs/eddi/engine/audit/AuditLedgerServiceTest.java index fd3458176..c6c2872aa 100644 --- a/src/test/java/ai/labs/eddi/engine/audit/AuditLedgerServiceTest.java +++ b/src/test/java/ai/labs/eddi/engine/audit/AuditLedgerServiceTest.java @@ -287,12 +287,57 @@ void futileEvictionScanIsNotRepeatedUntilFlush() { * say so. */ @Test - @DisplayName("sequence eviction — pinned undelivered conversations cannot exhaust the table") + @DisplayName("sequence eviction — pinned undelivered conversations do not block reclamation") void undeliveredPinCannotExhaustTheTable() { - assertTrue(AuditLedgerService.MAX_TRACKED_UNDELIVERED < AuditLedgerService.MAX_TRACKED_CONVERSATIONS, - "every conversation in the undelivered table is pinned in the sequence table, so the undelivered " - + "cap must stay strictly below the conversation cap or a store outage can strand every " - + "later conversation on UNSEQUENCED until restart"); + when(auditStore.supportsSequence()).thenReturn(true); + when(auditStore.countByConversation(anyString())).thenReturn(0L); + service = createService(true, null); + + // A store outage dead-letters this conversation, so position 0 is + // consumed but never persisted. Its counter is pinned from here on: the + // store count can no longer tell us where the chain resumes. + doThrow(new RuntimeException("db error")).when(auditStore).appendBatch(anyList()); + service.submit(entry("pinned-1", "pinned", "agent1")); + service.flush(); + service.flush(); + service.flush(); // dead-lettered + assertEquals(Set.of(0L), service.undeliveredSequences("pinned"), + "precondition: the outage left an unattributed position for this conversation"); + + // Store recovers. Fill the table with conversations that DO persist. + doAnswer(invocation -> null).when(auditStore).appendBatch(anyList()); + for (int i = 0; i < AuditLedgerService.MAX_TRACKED_CONVERSATIONS + 5; i++) { + service.submit(entry("fill-" + i, "filler-" + i, "agent1")); + } + service.flush(); + + // The pinned conversation is retained while the persisted ones are + // reclaimed, so a new conversation still gets a real chain position + // rather than being stranded on UNSEQUENCED. + service.submit(entry("fresh-1", "fresh", "agent1")); + service.flush(); + + var persisted = ArgumentCaptor.forClass(List.class); + verify(auditStore, atLeastOnce()).appendBatch(persisted.capture()); + @SuppressWarnings("unchecked") + var fresh = ((List) persisted.getValue()).stream() + .filter(e -> "fresh".equals(e.conversationId())) + .findFirst().orElseThrow(); + assertNotEquals(AuditEntry.UNSEQUENCED, fresh.sequence(), + "a pinned conversation must not cost later conversations their chain position"); + assertEquals(0L, fresh.sequence()); + + // And the pin did its job: the dead-lettered position is not handed out + // a second time. + service.submit(entry("pinned-2", "pinned", "agent1")); + service.flush(); + verify(auditStore, atLeastOnce()).appendBatch(persisted.capture()); + @SuppressWarnings("unchecked") + var pinnedSecond = ((List) persisted.getValue()).stream() + .filter(e -> "pinned".equals(e.conversationId())) + .findFirst().orElseThrow(); + assertEquals(1L, pinnedSecond.sequence(), + "the counter survived, so the chain resumes past the dead-lettered 0 rather than reusing it"); } @Test