Tester Architecture (ops-tester QA harness)

Verified against commit 942a18727c (2026-09-26, DOC-2 of SPRINT_20260926_001). Re-verify by reading the evidence paths listed in the Evidence section.

Technical architecture specification for src/Tester/— the stellaops-tester service.

STOP: this is not a product surface. stellaops-tester is a QA-isolation harness. It ships in a single opt-in compose overlay, it authenticates nobody, and it exposes credential-relay and fixture-injection endpoints. It must never run in a production stack. Everything below documents that boundary as much as the service itself.


0) Mission & boundaries

Mission. Own all QA fixture-import and per-scenario action endpoints, so those endpoints can be physically removed from production module images instead of living inside them behind an env-flag (src/Tester/AGENTS.md “Module Scope & Working Directory”). The premise is stated there bluntly: the presence of test endpoints inside production binaries “means a misconfigured prod deployment can expose test surface area”.

It also hosts the determinism oracle — POST /api/v1/tester/replay-and-compare — which fetches the same canonical artefacts for two run identifiers, canonicalises and hashes them, and returns identical, drift, or indeterminate (the scope’s upstream is not run-scoped, so no determinism claim can be made — see §5.1).

Boundaries.


1) Why it has its own dossier (and why it is not folded into Replay or Tools)

Three properties force a standalone home:

  1. It is a deployable service — its own image (stellaops/tester:dev), its own Dockerfile (devops/docker/Dockerfile.tester), its own compose overlay, its own Router serviceName (tester) and its own route surface. tools/ documents CLI utilities with no service surface; this is not one.
  2. Only one of its endpoint families is replay-related. Folding it into replay/ would orphan the Notify / Scanner / Findings / Evidence / Concelier / Authority / audit / cleanup families.
  3. Most importantly: Replay is a production module. Documenting a “must never reach production” harness inside a production module’s dossier invites exactly the mistake the isolation invariant exists to prevent.

2) Source layout

src/Tester/
├─ AGENTS.md # Production-isolation invariants (binding)
├─ StellaOps.Tester.WebService/
│ ├─ Program.cs # Host, named HttpClients, Router HELLO, route mapping, GET /api/v1/tester/ping
│ └─ Endpoints/
│ ├─ ReplayCompareEndpoints.cs # /replay-and-compare + CanonicalJson
│ ├─ FixtureReadbackEndpoints.cs # advanced-assurance-golden readback (seed-dir backed)
│ ├─ NotifyFixtureEndpoints.cs # notify-import, timeline-readiness, delivery-state
│ ├─ ScannerFixtureEndpoints.cs # reachability-import, sbom-import, self-sbom-import
│ ├─ FindingsFixtureEndpoints.cs # findings-ledger-append
│ ├─ EvidenceFixtureEndpoints.cs # evidence-import, portable-pack-build
│ ├─ ReleaseFixtureEndpoints.cs # release-seal, release-rollback
│ ├─ AuditFixtureEndpoints.cs # audit inject-batch, synth-escalation-chain
│ ├─ AuthorityFixtureEndpoints.cs # proxy-login (X-Forwarded-For injection)
│ └─ TesterCleanupEndpoints.cs # cleanup/tenant/{tenantId}
└─ __Tests/StellaOps.Tester.WebService.Tests/ # downstreams are stubbed (see §10)

3) Deployment: how the isolation is actually enforced

MechanismWhereWhat it guarantees
Standalone overlaydevops/compose/docker-compose.tester.ymlThe container exists in no production compose file and in no include: list. It starts only when an operator explicitly passes -f docker-compose.tester.yml.
Networks declared externalsame fileThe overlay refuses to come up unless the base stack already created stellaops / frontdoor — it cannot be booted standalone by accident.
Labelsoverlay + Dockerfilecom.stellaops.production-safe: "false", com.stellaops.image.type=tester, com.stellaops.image.variant=qa-harness — machine-checkable exclusion.
Separate Dockerfile + narrow build contextdevops/docker/Dockerfile.testerThe tester is never co-built with the production all-services image; the context copies only src/Tester/ plus the handful of libraries it restores against, so the binary “cannot be accidentally promoted into a production-stack image”.
Router registrationProgram.cs AddRouterMicroservice(..., serviceName: "tester",...)Gateway exposes /api/v1/tester/* only while the container is running and HELLO-registered. With the overlay absent, the gateway has no tester instances and the prefix 503s. Router__Enabled must be explicitly true (the overlay sets it; StellaRouterOptionsBase.Enabled defaults to false).
No OpenAPI documentProgram.cs registers no AddOpenApi / MapOpenApiThe harness’s credential-relay and fixture-injection routes never enter the gateway’s product API document. While the overlay runs, the gateway OpenAPI fan-out reports tester as missing, and that status is deliberate. The OpenAPI aggregate registry records it as a settled ruling (SPRINT_20260924_002 OAR-1).

Runtime facts: container port 127.1.0.60:80 → 8080; network alias tester.stella-ops.local; seed volume stellaops-tester-seed mounted read-only at /var/lib/tester/seed (STELLAOPS_ADVANCED_ASSURANCE_GOLDEN_SEED_DIR); non-root UID/GID 10001.


4) Endpoint surface

All routes sit under /api/v1/tester/* (plus /health, /health/ready).

FamilyRoutesDownstream (public REST only)
PingGET /pingnone — returns { ok, service, version }
Determinism oraclePOST /replay-and-compareFindings (only run-scoped scope); Evidence, Timeline and Concelier scopes are declared but never fetched (§5.1)
Fixture readbackGET /fixtures/advanced-assurance-golden, GET /assurance/cases/{caseId}none — reads the mounted seed dir locally (independent of Platform)
Notify/fixtures/notify-import (POST), /fixtures/timeline-readiness, /fixtures/notify-delivery-stateNotify PUT /api/v2/notify/channels/{id}, `POST
ScannerPOST /fixtures/reachability-import, /fixtures/sbom-import, /fixtures/self-sbom-importScanner POST /api/v1/byos-sboms, POST /api/v1/scans
FindingsPOST /fixtures/findings-ledger-appendFindings POST /api/findings/v1/ledger/ledger/advanced-assurance/append
EvidencePOST /fixtures/evidence-import, /fixtures/portable-pack-buildEvidence service (src/Evidence/StellaOps.Evidence.WebService, route prefix /api/evidence/v1): POST /api/evidence/v1/bundles/{id}/export, POST /api/evidence/v1/evidence/snapshot
ReleasePOST /fixtures/release-seal, /fixtures/release-rollbackReleaseOrchestrator POST /api/v1/release-orchestrator/releases/seal, POST /api/v1/release-orchestrator/releases/{id}/rollback
Audit (insider-threat harness)POST /audit/inject-batch (NDJSON), POST /audit/synth-escalation-chainTimeline POST /api/v1/audit/ingest, GET /api/v1/audit/chain/verify
AuthorityPOST /authority/proxy-loginAuthority POST /connect/token
CleanupPOST /cleanup/tenant/{tenantId}Notify /api/v1/notify/deliveries delete; ReleaseOrchestrator /api/v1/release-orchestrator/releases/{id} delete. Concelier / Findings / evidence-locker are reported as no_public_delete_surface skips — the harness does not reach around them into the DB.

Ceilings worth knowing: audit inject-batch caps at 5 000 NDJSON lines and 512 chars per text field (AuditFixtureEndpoints.MaxBatchLines / MaxTextLength); proxy-login rejects CR, LF and commas in the injected X-Forwarded-For.


5) The determinism oracle — POST /api/v1/tester/replay-and-compare

Contract. Body { runIdA, runIdB, scope[] }; headers Authorization: Bearer … + X-StellaOps-TenantId. For each requested scope the oracle first checks whether that scope’s upstream is actually run-scoped (ScopeContract.RunIdFiltered). If it is not, the scope is not fetched and not hashed — it returns indeterminate with the reason (§5.1). Otherwise the oracle issues GET {scopePath}?runId={runId} against the owning service — twice, once per run id — then:

  1. Extracts records — root array, or the first matching envelope key (items / findings / events / packs / …), else the object itself as a single record.
  2. Strips volatile fields recursively, per a per-scope allow-list (timestamps, etag, exportId, correlationId, …). For findings the physical row ids chainId/eventId are stripped too — the in-code rationale is that two physical runs of logically identical findings necessarily assign different ids, while eventHash and every logical content field stay in the hash, so drift detection is not weakened.
  3. Canonicalises — RFC-8785-style: object keys sorted ordinal, no indentation, G17 for non-integral numbers, BOM stripped (CanonicalJson in ReplayCompareEndpoints.cs).
  4. Sorts records by the scope’s sort key, then by canonical form, and SHA-256s the canonical array.
  5. Compares the two digests → identical | drift; on drift it emits onlyInA / onlyInB / changed (with the changed field names).

Verdict precedence (top-level): drift > indeterminate > identical. The middle rung is what makes a false green impossible — the overall verdict can only be identical when every requested scope was actually measured against a run-scoped upstream. A real drift still outranks an unmeasurable scope, so indeterminate can never soften a genuine drift into “we could not tell”.

Error taxonomy (the response shape is a flat error object, not ProblemDetails): authorization_required 401 · tenant_required / request_required / run_id_required / scope_required / unsupported_scope 400 · run_not_found 404 (any upstream 404) · upstream_unavailable 502 (any other non-2xx, transport failure, or unparseable body).

5.1 Scope coverage — only findings is measurable (fixed: the rest now fail closed)

The oracle’s four scopes are not equally real. The upstream route tables were first checked on 2026-07-12 (W3-E, SPRINT_20260712_008) and re-checked for this dossier against commit 942a18727c. RunIdFiltered is declared per scope in ScopeContracts (ReplayCompareEndpoints.cs) and pinned by ReplayCompareEndpointsTests.ScopeContract_DeclaresExactlyOneRunScopedUpstream:

ScopeUpstreamRunIdFilteredReality
findingsFindings GET /api/findings/v1/ledger/ledger?runId=trueReal. Findings shipped this list surface for the oracle (src/Findings/__Libraries/StellaOps.Findings.Ledger.Application/Endpoints/LedgerListEndpoints.cs, mapped by src/Findings/StellaOps.Findings.WebService/Program.cs): it binds runId, filters (ListByRunIdAsync), and returns 404 run_not_foundrather than an empty 200, so absent-run and empty-run stay distinguishable.
evidence-packsEvidence GET /api/evidence/v1/evidence/packsfalseListPacks accepts no runId parameter at all — it lists every pack for the tenant (src/Evidence/__Libraries/StellaOps.EvidenceLocker.Application/Api/EvidenceAuditEndpoints.cs, repository.ListByTenantAsync, mapped under /api/evidence/v1 by src/Evidence/StellaOps.Evidence.WebService/Program.cs). A ?runId= would be ignored, both runs would read the same unfiltered set, and the digests would match by construction.
timeline-eventsTimeline GET /api/v1/timeline/eventsfalseNo run-scoped list route exists. /timeline/events is POST-only; the GETs are /timeline (list), /timeline/{eventId}, /timeline/{eventId}/evidence (src/Timeline/StellaOps.Timeline.WebService/Program.cs, mapped under both /api/v1 and the bare prefix). The GET list filters by eventType/source/correlationId/traceId/severity/since — never runId.
linksetsConcelier GET /api/v1/linksetsfalseThe Concelier host is retired (src/__Obsoleted/Concelier/); when it was live it served /linksets at the root with only limit + cursor. Advisory linksets are not a per-run artefact in the first place.

What this used to do (the defect W3-E closed). The oracle fetched every scope unconditionally and hashed whatever came back:

Behaviour now — fail closed. A scope whose upstream is not run-scoped is never fetched, never hashed, and never identical. It returns:

{ "scope": "evidence-packs", "verdict": "indeterminate", "runIdFilteringSupported": false,
 "service": "EvidenceLocker", "reason": "…accepts no runId parameter; it lists every pack for the tenant…" }

with no sha256A/sha256B/rowCountA/rowCountB— an unmeasured scope makes no claim about a run — and it degrades the overall verdict to indeterminate. PerScopeDiff.RunIdFilteringSupported (the flag that was designed to warn about exactly this and was itself declared and never assigned) is now populated on every diff.

Consequence for anyone reading a replay verdict: scope: ["findings"] is still the only scope that yields a determinism proof. The difference is that the oracle now says so instead of fabricating a green. To make another scope measurable, the upstream must accept and filter runId (and the one-word RunIdFiltered flip must be accompanied by re-verifying its route in the guard test).


6) Auth & tenancy — the harness model (and why it is disqualifying for production)

This is the section to read before anyone proposes “just enable the tester in staging”.


7) Invariants (binding — src/Tester/AGENTS.md)

  1. Stateless — no schema ownership, no migrations, no persistence libs; seed artefacts come from a read-only mount.
  2. Public REST only — no repository references, no internal write-throughs, no shared-schema reads into another module. A blocked scenario is resolved by the receiving module publishing a documented test-shaped API, never by reaching around it.
  3. Opt-in compose only — never added to docker-compose.stella-ops.yml / docker-compose.stella-services.yml or any include: list.
  4. Audit fixture attribution — AuditFixtureEndpoints preserves supplied actor_id values and forwards them in Timeline actor metadata; synthetic escalation rows use the requested scenario actor. That actor is distinct from the authenticated requester or an owner-generated audit record. QA filtering needs observed fixture/provenance or correlation evidence; the source does not guarantee that every fixture row has actor=tester.
  5. Any endpoint migrated out of a production module must land with Tier-2 integration tests proving parity before the legacy embedded path is removed.

8) Residual production footprint (status: the two headline residues are CLOSED)

The migration table in src/Tester/AGENTS.md marks all six stages DONE, but “DONE” there means the tester can now drive the scenario, not the production module has shed its test surface. The two residues recorded when this dossier was written were closed by SPRINT_20260712_008 / W3-A (2026-07-12); what remains is listed at the end.

8.1 QA fixture readback — now OFF by default in the canonical stack (FIXED)

Platform’s QaFixtureEndpoints.cs (and Notify’s / Scanner’s embedded fixture-import endpoints) still exist — the stage 6–7 soak (flag off, then delete) has not finished. But the default is fixed:

Guarded by QaFixtureEndpointsTests (Platform): the golden-readback, assurance-case and scenario-transaction routes all 503 when the flag is unset.

8.2 The stellaops-tester Authority client — disabled in the baseline (FIXED)

The original finding said this client shipped “with a guessable default secret”. That mechanism does not exist, and the truth was worse. Verified against src/ on 2026-07-12:

Proven by TesterClientSeedHardeningTests (StellaOps.Authority.Tests), which drives the real ValidateClientCredentialsHandler: the pre-S039 shape mints a token with no secret (characterization), the post-S039 shape is rejected invalid_client, and a re-enabled row still fails closed.

8.3 What is still open

The embedded fixture endpoints themselves (stages 6–7: delete). Notify’s AdvancedAssuranceFixtureImportEndpoints.cs and Scanner’s AdvancedAssuranceReachabilityFixtureEndpoints.cs / AdvancedAssuranceSbomFixtureEndpoints.cs carry no enable-flag at all — they are gated only by scope (notify.operator, scans:write), so they answer in a production stack for any caller holding those scopes. Platform’s flag does not cover them. Deleting them (or gating them behind the same flag) is a Notify/Scanner source change and is out of W3-A’s scope.


9) Configuration surface

Env prefix TESTER_; optional YAML at ../etc/tester.yaml / tester.yaml (Program.cs:34-43).

KeyEnvDefaultPurpose
Tester:Scanner:BaseUrioverlay TESTER_SCANNER_BASE_URIresolved per request (overlay default http://scanner.stella-ops.local)Scanner egress
Tester:Findings:BaseUrioverlay TESTER_FINDINGS_BASE_URIresolved per request (overlay default http://findings.stella-ops.local)Findings ledger-append egress
Tester:ReleaseOrchestrator:BaseUriTESTER_RELEASE_ORCHESTRATOR_BASE_URIunboundRelease seal/rollback egress
Tester:Notify:BaseAddressTESTER_NOTIFY_BASEADDRESShttp://notify.stella-ops.localNotify egress
Tester:Notify:BearerTokenTESTER_NOTIFY_BEARER_TOKENunsetPre-minted token injection (no token acquisition in-service)
Tester:EvidenceLocker:BaseAddress (fallback Tester:Downstream:EvidenceLocker:BaseUrl)STELLAOPS_TESTER_EVIDENCE_LOCKER_BASE_ADDRESS (fallback STELLAOPS_TESTER_EVIDENCE_LOCKER_URL)http://evidence.stella-ops.local:8080Evidence egress (fixtures and the evidence-packs scope contract)
Tester:Downstream:Findings:BaseUrlSTELLAOPS_TESTER_FINDINGS_URLhttp://findings.stella-ops.localOracle findings scope
Tester:Downstream:Timeline:BaseUrlSTELLAOPS_TESTER_TIMELINE_URLhttp://timeline.stella-ops.localOracle timeline-events scope + audit fixtures
Tester:Authority:BaseUriTESTER_AUTHORITY_BASE_URIhttp://authority.stella-ops.localproxy-login
Tester:Authority:ClientSecretTESTER_AUTHORITY_CLIENT_SECRETnone (no default since W3-A)Inert in code (§6). The stellaops-tester Authority client is disabled in the seed baseline (§8.2); supplying this only matters after an explicit bootstrapClients opt-in.
—STELLAOPS_ADVANCED_ASSURANCE_GOLDEN_SEED_DIR/var/lib/tester/seedSeed-dir mount for fixture readback
Tester:QaFixtures:EnabledSTELLAOPS_QA_FIXTURES_ENABLEDoverlay sets trueGates the readback endpoints (OR-ed with the seed dir)
Tester:QaFixtures:AdvancedAssuranceGoldenSeedDirectorySTELLAOPS_ADVANCED_ASSURANCE_GOLDEN_SEED_DIRoverlay sets /var/lib/tester/seedSeed directory read by the fixture readback endpoints

All defaults are in-cluster aliases — no public URL is a shipped runtime default.


10) Testing

dotnet build src/Tester/StellaOps.Tester.WebService/StellaOps.Tester.WebService.csproj
dotnet test src/Tester/__Tests/StellaOps.Tester.WebService.Tests/StellaOps.Tester.WebService.Tests.csproj
docker compose -f devops/compose/docker-compose.tester.yml config # overlay dry-run

Know what the suite does and does not prove. The specs run the real Program through WebApplicationFactory<Program> but replace every named HttpClient with a stub HttpMessageHandler (ReplayCompareEndpointsTests.ReplayStubHandler). They are a strong check on the oracle’s own logic — ReplayCompareDeterminismTests asserts that key-order + volatile-field differences still hash identically, that the same input hashes identically on every invocation, and that a real content change still drifts (no over-stripping).

A stub cannot prove an upstream exists — and this suite once asserted a world that did not. The original WithIdenticalRuns() stub keyed every scope’s response by runId, fabricating exactly the run-scoping that three of the four upstreams do not implement. The suite was green because it modelled the bug away; that is how the §5.1 false green survived. Two guards now stand in its place:

A stub still cannot tell you a route exists: any change to a scope contract needs a live-stack probe, not a green suite.


11) Known gaps / doc-vs-code drift

#GapEvidence
13 of 4 oracle scopes are not backed by a run-scoped upstream read; one of them (evidence-packs) returns a false identical. CLOSED in the oracle (W3-E) — the three unmeasurable scopes now fail closed (indeterminate, not fetched, not hashed) and degrade the overall verdict, so no false green is reachable. The upstream gap itself is unchanged and is now gap 6.§5.1
2RunIdFilteringSupported — the flag designed to warn about gap 1 — is declared and never assigned. CLOSED (W3-E) — populated on every PerScopeDiff from the scope contract.§5.1
3src/Tester/AGENTS.md invariant 3 asserts a client_credentials Authority flow the service does not implement. CLOSED (W3-A) — invariant 3 rewritten to the truth: no token flow, client disabled in the baseline, explicit opt-in documented. The secret is still read-and-discarded (Program.cs), but the hardcoded default is gone.Program.cs · src/Tester/AGENTS.md
4Platform’s QA-fixture flag is default-ON in the main compose; the tester Authority client is seeded enabled with a default secret. CLOSED (W3-A) — compose default flipped to false + opt-in overlay; client disabled and typed confidential by migration S039. The lead’s “guessable default secret” mechanism was refuted: the real hole was no secret at all.§8.1, §8.2
5Notify’s and Scanner’s embedded fixture endpoints carry no enable-flag — scope-gated only, so they answer in a production stack. Stages 6–7 (delete) still open.§8.3
6OPEN (upstream, not the oracle): only findings exposes a run-scoped read, so 3 of 4 scopes are permanently indeterminate. Making one measurable is an upstream change — EvidenceLocker must accept + filter runId on /api/evidence/v1/evidence/packs (the pack detail record already carries a promotion run id, so the column plausibly exists); Timeline would need a run-scoped event list (no runId anywhere in its query surface today); Concelier’s advisory linksets are not per-run at all and the scope is arguably better deleted than fixed. Owner: the respective module, not Tester.§5.1
7Historical closure (2026-09-27, PCD-73): the Concelier advisory-import forwarder, its test and HTTP client were withdrawn to src/__Obsoleted/Tester/, and its overlay variable was removed. No serving advisory-import contract was available. The linksets oracle scope remains explicitly not run-scoped and is never fetched (gap 6).Program.cs, devops/compose/docker-compose.tester.yml

12) References

Evidence

ClaimSource
Host, named clients, configuration keys, Router registration, route mappingsrc/Tester/StellaOps.Tester.WebService/Program.cs
Endpoint families and downstream pathssrc/Tester/StellaOps.Tester.WebService/Endpoints/*.cs
Scope contracts and verdict logicsrc/Tester/StellaOps.Tester.WebService/Endpoints/ReplayCompareEndpoints.cs
Fixture readback keyssrc/Tester/StellaOps.Tester.WebService/Endpoints/FixtureReadbackEndpoints.cs
Project referencessrc/Tester/StellaOps.Tester.WebService/StellaOps.Tester.WebService.csproj
Invariantssrc/Tester/AGENTS.md
Opt-in overlay (ports, alias, seed volume, labels, Router settings)devops/compose/docker-compose.tester.yml
Image labels, non-root userdevops/docker/Dockerfile.tester
Absent from the release bundle and the base estatedevops/release/bundle/docker-compose.yml, devops/compose/docker-compose.stella-services.yml (no tester service)
No OpenAPI document (settled ruling)src/__Tests/architecture/StellaOps.OpenApi.Conformance.Tests/Modules/AggregatorCoverageDiscoveryTests.cs
Findings run-scoped listsrc/Findings/__Libraries/StellaOps.Findings.Ledger.Application/Endpoints/LedgerListEndpoints.cs
Evidence packs list (no runId)src/Evidence/__Libraries/StellaOps.EvidenceLocker.Application/Api/EvidenceAuditEndpoints.cs, src/Evidence/StellaOps.Evidence.WebService/Program.cs
Timeline routessrc/Timeline/StellaOps.Timeline.WebService/Program.cs, Endpoints/UnifiedAuditEndpoints.cs
Concelier retiredsrc/__Obsoleted/Concelier/
Platform QA fixture flag defaultsrc/Platform/StellaOps.Platform.WebService/Endpoints/QaFixtureEndpoints.cs, devops/compose/docker-compose.stella-services.yml, devops/compose/docker-compose.qa-fixtures.override.yml
Tester Authority client disabledsrc/Authority/__Libraries/StellaOps.Authority.Persistence/Migrations/S039_disable_tester_client_baseline.sql
Embedded Notify/Scanner fixture endpoints still presentsrc/Notify/StellaOps.Notify.WebService/Endpoints/AdvancedAssuranceFixtureImportEndpoints.cs, src/Scanner/StellaOps.Scanner.WebService/Endpoints/AdvancedAssuranceReachabilityFixtureEndpoints.cs, AdvancedAssuranceSbomFixtureEndpoints.cs