Tester Architecture (ops-tester QA harness)

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 oraclePOST /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 19 routes. tools/ documents CLI utilities with no service surface; this is not one.
  2. Only 1 of its 9 endpoint families is replay-related. Folding it into replay/ would orphan the Notify / Scanner / Findings / EvidenceLocker / 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
│  └─ 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
│     ├─ ConcelierFixtureEndpoints.cs          # concelier-advisory-import
│     ├─ 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/  # 13 spec files; 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).

Runtime facts: container port 127.1.0.60:808080; 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)
Determinism oraclePOST /replay-and-compareEvidenceLocker, Findings, Timeline, Concelier
Fixture readbackGET /fixtures/advanced-assurance-golden, GET /assurance/cases/{caseId}none — reads the mounted seed dir locally (independent of Platform)
NotifyPOST /fixtures/notify-import, /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/sbom/upload, POST /api/v1/scans
FindingsPOST /fixtures/findings-ledger-appendFindings POST /api/v1/findings/ledger/advanced-assurance/append
EvidenceLockerPOST /fixtures/evidence-import, /fixtures/portable-pack-buildEvidenceLocker POST /api/v1/bundles/{id}/export
ConcelierPOST /fixtures/concelier-advisory-importConcelier POST /api/v1/concelier/advisories/import
ReleasePOST /fixtures/release-seal, /fixtures/release-rollbackReleaseOrchestrator public REST
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 delivery delete; ReleaseOrchestrator release delete. Concelier / Findings / EvidenceLocker 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.cs:32-33); proxy-login rejects CR/LF 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, ReplayCompareEndpoints.cs:735-847).
  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. Verified against the upstream route tables at HEAD on 2026-07-12 (W3-E, SPRINT_20260712_008). RunIdFiltered is declared per scope in ScopeContracts (ReplayCompareEndpoints.cs) and pinned by ReplayCompareEndpointsTests.ScopeContract_DeclaresExactlyOneRunScopedUpstream:

ScopeUpstreamRunIdFilteredReality
findingsFindings GET /api/v1/findings/ledger?runId=trueReal. Findings shipped this list surface for the oracle: it binds runId (LedgerListEndpoints.cs:64), filters (ListByRunIdAsync, :89-102), and returns 404 run_not_foundrather than an empty 200, so absent-run and empty-run stay distinguishable (:104-114).
evidence-packsEvidenceLocker GET /api/v1/evidence/packsfalseListPacks accepts no runId parameter at all — it lists every pack for the tenant (EvidenceAuditEndpoints.cs:88-105, repository.ListByTenantAsync). The ?runId= is ignored, both runs read the same unfiltered set, the digests match by construction.
timeline-eventsTimeline GET /api/v1/timeline/eventsfalseNo run-scoped list route exists. /timeline/events is POST-only (Timeline/Program.cs:345); the GETs are /timeline (list), /timeline/{eventId}, /timeline/{eventId}/evidence (:271,:307,:326). The GET list filters by eventType/source/correlationId/traceId/severity/since — never runId.
linksetsConcelier GET /api/v1/linksetsfalseConcelier serves /linksets at the root (no /api/v1 base) with only limit + cursor (Concelier/Program.cs:1880-1885). 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 actor tester— QA-driven events stay filterable out of compliance views.
  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:Concelier:BaseUriTESTER_CONCELIER_BASE_URIhttp://concelier.stella-ops.localConcelier egress
Tester:Scanner:BaseUriresolved per requestScanner egress
Tester:Findings:BaseUriresolved per requestFindings 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:BaseAddressSTELLAOPS_TESTER_EVIDENCE_LOCKER_BASE_ADDRESShttp://evidence-locker-webEvidenceLocker egress
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)

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/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

12) References