# Remediation Module Architecture
Technical architecture for the Remediation module of the Stella Ops release control plane — a developer-facing marketplace for signed-PR fix templates that remediate known vulnerabilities. Audience: backend engineers working on the remediation API, persistence, and verification pipeline, plus reviewers tracking which parts of the design are live versus planned.
Status: Partially implemented. Persistence-backed CRUD-style APIs exist for templates, submissions, contributors, and marketplace sources (Postgres-only, tenant-scoped, auditable). What is real and what is still missing, verified against
src/Remediationon 2026-07-12:
- Authorization is enforced (SPRINT W1h). The three policies are real scope policies over
remediation:read/remediation:submit/remediation:manage— the oldRequireAssertion(_ => true)placeholders are gone. The three scope values are now catalogued and seeded (SPRINT_20260712_002 / AUTH-2): they areStellaOpsScopes.RemediationRead/RemediationSubmit/RemediationManage, andS038_remediation_and_scanner_local_scopes.sqlseeds the matchingauthority.permissionsrows, so the fail-closed 403 lockout described here previously is closed — see Authorization Policies.- A real proof validator exists (SPRINT W1h-2).
ScannerRemediationProofValidatorverifies the ReachGraph reachability-delta and the Attestor fix-chain DSSE envelope. A signature-valid Attestor envelope is accepted only when its payload is an in-toto Statement/v1 with the exact FixChain predicate type, the requested CVE, afixedverdict, and reachability proof that a positive pre-patch path count fell to zero. It is wired only when bothRemediation:ProofValidation:ReachGraphBaseUrland:AttestorBaseUrlare configured, otherwise the host degrades to the fail-closedUnavailableRemediationProofValidatorand logs a startup WARNING.- Still not wired: the verification pipeline is not orchestrated — nothing populates the scan/reachability/DSSE digests, advances
Status, or signs envelopes;RemediationVerifieronly evaluates digests already present on a submission.- No compose host. The module is not deployed in
devops/compose, so the scope-catalog lockout above is latent rather than live.
Overview
The Remediation module provides a developer-facing signed-PR remediation marketplace for the Stella Ops platform. It enables developers to discover, apply, and verify community-contributed or vendor-supplied fix templates for known vulnerabilities (CVEs).
Key Concepts
Fix Templates
Structured remediation patches tied to specific CVE + PURL combinations. Templates include unified diff content, version range applicability, and trust scores from contributor history.
PR Submissions
Tracks the lifecycle of a remediation pull request from submission through scanning, merging, and post-merge verification. Each submission produces attestation evidence including reachability deltas and fix-chain DSSE envelopes.
Contributors
Community members or vendors who submit fix templates. Each contributor has a trust score computed from their verification history (verified fixes, rejections).
Marketplace Sources
Curated collections of fix templates from community, partner, or vendor origins. Sources are rated independently and can be enabled or disabled per tenant.
Domain Model
FixTemplate (remediation.fix_templates)
|- TenantId (text, NOT NULL - tenant isolation key)
|- CveId (text, indexed)
|- Purl (text, indexed - pkg:type/name)
|- VersionRange (semver range)
|- PatchContent (unified diff)
|- Status (pending/verified/rejected)
|- TrustScore (0.0-1.0)
|- DsseDigest (nullable - signed envelope hash)
`- ContributorId / SourceId (foreign keys)
PrSubmission (remediation.pr_submissions)
|- TenantId (text, NOT NULL - tenant isolation key)
|- FixTemplateId (nullable FK -> fix_templates.id)
|- PrUrl, RepositoryUrl, SourceBranch, TargetBranch
|- CveId (text, indexed)
|- Status (text, default 'opened'; free-text column, indexed)
|- PreScanDigest, PostScanDigest
|- ReachabilityDeltaDigest, FixChainDsseDigest
|- Verdict (text, nullable)
|- ContributorId
`- CreatedAt, MergedAt, VerifiedAt
Contributor (remediation.contributors)
|- TenantId (text, NOT NULL - tenant isolation key)
|- Username (unique PER TENANT - ux_contributors_tenant_username)
|- VerifiedFixes, TotalSubmissions, RejectedSubmissions
`- TrustScore (computed)
MarketplaceSource (remediation.marketplace_sources)
|- Key (TEXT, UNIQUE - stored as a `<tenant>::<key>` composite for tenant isolation)
|- Name, Url (nullable)
|- SourceType (community/partner/vendor)
|- Enabled, TrustScore
`- CreatedAt, LastSyncAt
Note on status/verdict columns:
StatusandVerdictare free-textTEXTcolumns in PostgreSQL, not DB-enforced enums. The values listed elsewhere in this doc reflect the strings the current code produces or consumes; the schema does not constrain them. TheRemediationVerifieronly emitsfixed,not_fixed, orinconclusive— apartialverdict is not produced by the current verifier (see Verification Pipeline below).
Tenancy contract (claim-bound)
Tenant identity is the isolation key on every read and write, and it is resolved exclusively from the authenticated stellaops:tenant claim (via IStellaOpsTenantAccessor / the shared StellaOpsTenantResolver). It is never read from a query parameter, request body, or caller-supplied header.
| Table | Isolation mechanism |
|---|---|
fix_templates | tenant_id column; every repository read carries a tenant_id = @tenant predicate |
pr_submissions | tenant_id column; reads and UpdateStatusAsync are tenant-predicated |
contributors | tenant_id column; username is unique per tenant (ux_contributors_tenant_username), not globally |
marketplace_sources | <tenant>::<key> composite key (delimiter ::), stripped on read — no tenant_id column |
Enforcement details:
- The repository interfaces take
tenantIdas a required first parameter, so a call site that forgets the isolation key fails to compile rather than reading across tenants. - A blank tenant is rejected (
ArgumentException), never silently defaulted todefault— a silent default would reintroduce the cross-tenant read the column exists to prevent. - Endpoint groups carry
.RequireTenant(); a caller with no tenant claim (the compose bypass-network shape) is refused with400 tenant_missingbefore any handler runs, even when its scopes are valid.
History (TEN-4, sprint 20260712_001):
fix_templates,pr_submissionsandcontributorspreviously carried no tenant column and their repositories issued no tenant predicate, so any principal holdingremediation:readcould read every tenant’s fix templates, PR submissions (repository URLs, branch names, CVE ids) and contributor records. Migration002_tenant_scoped_registry.sqladdstenant_id(backfilled todefault) and scopes contributor-username uniqueness to the tenant. Proven byRemediationTenantIsolationTests(Postgres-backed) andRemediationTenancyEndpointTests(HTTP).
Trust Scoring
Contributor trust score formula:
score = clamp((verified * 1.0 - rejected * 0.5) / max(total, 1), 0, 1)
Trust tiers:
- trusted (> 0.8): Verified track record
- established (> 0.5): Growing history
- new (> 0.2): Recently joined
- untrusted (<= 0.2): Insufficient or negative history
API Surface
All endpoints are under /api/v1/remediation/ and require tenant context (RequireTenant()). All routes are lowercased (LowercaseUrls = true). List endpoints accept limit and offset query parameters: limit defaults to 50, is clamped to the range [1, 200]; offset defaults to 0 and is clamped to >= 0. The Count field in list responses is the number of items on the returned page, not a total row count.
Templates
GET /templates?cve=&purl=&limit=&offset=- List fix templates ->FixTemplateListResponse { Items, Count, Limit, Offset }ofFixTemplateSummary(Id, CveId, Purl, VersionRange, Status, TrustScore, Description, CreatedAt). Requiresremediation.read.GET /templates/{id:guid}- Get fullFixTemplate(404 if absent). Requiresremediation.read.POST /templates- Create template; bodyCreateFixTemplateRequest(CveId, Purl, VersionRange, PatchContent, Description?); returns201 Createdwith the persistedFixTemplate. Requiresremediation.submit. Emits audit actioncreate_fix_template(entityfix_template).
Submissions
GET /submissions?cve=&status=&limit=&offset=- List PR submissions ->PrSubmissionListResponse { Items, Count, Limit, Offset }ofPrSubmissionSummary(Id, PrUrl, CveId, Status, Verdict, CreatedAt). Requiresremediation.read.GET /submissions/{id:guid}- Get fullPrSubmission(404 if absent). Requiresremediation.read.POST /submissions- Submit PR; bodyCreatePrSubmissionRequest(PrUrl, RepositoryUrl, SourceBranch, TargetBranch, CveId, FixTemplateId?); returns201 Createdwith the persistedPrSubmission. Requiresremediation.submit. Emits audit actioncreate_pr_submission(entitypr_submission).GET /submissions/{id:guid}/status- Returns{ Id, Status, Verdict }only (404 if absent). Requiresremediation.read.
Matching
GET /match?cve=...&purl=...&version=...- Find applicable fix templates ->MatchResponse { Items, Count }ofFixTemplateSummary.cveis required; a missing/blankcvereturns400with body{ error: <localized "remediation.match.cve_required"> }. Requiresremediation.read.
Contributors
GET /contributors- List contributorsGET /contributors/{username}- Profile with trust score
Implemented contributor API contract (2026-04-24):
GET /api/v1/remediation/contributorslists persisted contributors fromremediation.contributorswith deterministic username ordering and standard limit/offset bounds.GET /api/v1/remediation/contributors/{username}resolves normalized usernames from PostgreSQL and returns404 contributor_not_foundonly when no persisted contributor exists.- Response trust score and trust tier are calculated from persisted verified/total/rejected submission counters.
- Contributor writes are currently repository-only; public contributor administration endpoints remain future scope.
Sources
GET /sources- List marketplace sources ->MarketplaceSourceListResponse { Items, Count }ofMarketplaceSourceSummary(Key, Name, Url, SourceType, Enabled, TrustScore, CreatedAt, LastSyncAt). Requiresremediation.read.GET /sources/{key}- Source detail. Invalid keys return400(validation problem); a missing source returns404as RFC7807ProblemDetails(typehttps://stellaops.dev/problems/remediation/source-not-found). Requiresremediation.read.POST /sources- Create/update source (requiresremediation.manage). Emits audit actionupsert_marketplace_source(entitymarketplace_source). Returns200 OKwith the upsertedMarketplaceSourceSummary(not201).
Implemented source API contract (2026-03-04):
- Request model for upsert (
POST /sources):key,name,url,sourceType,enabled,trustScore,lastSyncAt(UpsertMarketplaceSourceRequest). - Deterministic behavior:
- key normalization uses lowercase invariant
- list ordering is key-based ordinal ordering
- upsert is idempotent by tenant + source key
- Validation:
- key pattern:
^[a-z0-9][a-z0-9._-]{0,63}$ - sourceType allowed values:
community,partner,vendor - trustScore range:
0..1 - url must be an absolute
http/httpsURL when provided
- key pattern:
- Tenant isolation:
- all source endpoints require tenant context (
RequireTenant) - repository operations are tenant-scoped for list/get/upsert behavior
- all source endpoints require tenant context (
Authorization Policies
Each endpoint declares a named authorization policy via RequireAuthorization("..."). The ASP.NET policy name is dot-form; the scope claim value it requires is colon-form:
| Policy name | Required scope claim | Endpoints |
|---|---|---|
remediation.read | remediation:read | All GET endpoints (templates, submissions, contributors, sources, matches) |
remediation.submit | remediation:submit | POST /templates, POST /submissions |
remediation.manage | remediation:manage | POST /sources |
Enforcement is real (SPRINT W1h). The earlier
policy.RequireAssertion(_ => true)placeholders are gone.Program.cs:40wiresAddStellaOpsResourceServerAuthenticationoutsideTesting(JWT bearer + bypass evaluator + scope handler), andProgram.cs:57-59registers the three policies withAddStellaOpsScopePolicy(<policy>, <scope>), which installs a realStellaOpsScopeRequirement. Endpoints enforce them (RemediationRegistryEndpoints.cs,RemediationSourceEndpoints.cs,RemediationMatchEndpoints.cs) in addition toRequireTenant().✅ Scope-catalog lockout CLOSED (SPRINT_20260712_002 / AUTH-2, 2026-07-12). The three claims are now first-class catalog constants —
StellaOpsScopes.RemediationRead/RemediationSubmit/RemediationManage— andProgram.csbinds the policies to those constants rather than to magic strings.This mattered more than it looked.
StellaOpsScopes.All/KnownScopesare built by reflecting over the public string consts, and Authority callsoptions.RegisterScopes(…StellaOpsScopes.All)(StellaOps.Authority/Program.cs). An uncatalogued claim is therefore never registered with OpenIddict, so it could not be minted into a token by any path — not via the Console role editor, not via a config-defined role (AuthorityTenantRoleOptions.Validatethrows on!IsKnown), not even via a hand-writtenauthority.role_permissionsrow. Every Remediation endpoint would have returned 403 for every caller.
S038_remediation_and_scanner_local_scopes.sqlseeds the matchingauthority.permissionsrows (so the Console role editor offers them) and grants the trio to theadminrole so the module is operable on an existing install. Proven byS038RemediationAndScannerScopeCatalogTests(a role assembled through the Console/RBAC path carries all three scopes) andStellaOpsScopesTests.Auth2_SingleScopePolicyClaims_AreRegistered.
Runtime Storage Contract (updated 2026-04-24)
Remediation runtime storage is now selected through Remediation:Storage:Driver (or Storage:Driver) with explicit startup validation:
postgres(default):- Required settings:
ConnectionStrings:DefaultorRemediation:Storage:Postgres:ConnectionString. - Optional schema override:
Remediation:Storage:Postgres:SchemaName(defaults toremediation). - Behavior: repositories are wired with
RemediationDataSourceand Postgres-backed constructors. - Startup: embedded SQL migrations from
StellaOps.Remediation.Persistenceare applied automatically throughAddStartupMigrations(...). - Startup: fails fast when required connection configuration is missing.
- Required settings:
inmemory:- Removed from runtime wiring.
Storage:Driver=inmemoryfails startup in every environment. - Deterministic automated tests use test-only repository implementations under
src/Remediation/__Tests/**and override DI withWebApplicationFactory.
- Removed from runtime wiring.
Postgres repository classes are Postgres-only. They require RemediationDataSource; parameterless constructors and silent process-local repository state are not supported in production assemblies.
Migration notes:
- Existing deployments must provide a Postgres connection string.
- Fresh databases converge at service startup from embedded migrations; external init scripts are bootstrap fallbacks only.
- Integration tests that do not need a database should keep
Storage:Driver=postgreswith test-host DI overrides, rather than enabling a runtime in-memory driver.
Service Integration Baseline (2026-03-05)
- Router integration enabled (
serviceName: remediation) with endpoint refresh on startup. - Local alias binding/logging enabled via
remediation.stella-ops.local. - CORS and tenant middleware are part of the default request pipeline before endpoint execution.
Verification Pipeline
Design / not orchestrated. Steps 1-6 below describe the intended end-to-end flow, but there is no orchestration code in the Remediation module that drives it. Nothing populates
pre_scan_digest,post_scan_digest,reachability_delta_digest, orfix_chain_dsse_digestautomatically, advancesStatus, or signs envelopes.IRemediationRegistry.UpdateSubmissionStatusAsync(...)exists but is not exposed by any endpoint. What is implemented is the verdict half:RemediationVerifier.VerifyAsync(submission)evaluates digests already present on a submission (step 7), and — when configured —ScannerRemediationProofValidatorreally does fetch and cryptographically verify the ReachGraph delta and the Attestor fix-chain DSSE envelope behind it (see Proof-validation contract). The pipeline that scans, merges, and stamps those digests is still future scope.
- PR submitted (status:
opened) - Pre-merge scan captures baseline SBOM digest
- PR merged (status:
merged) - Post-merge scan captures updated SBOM digest
- Reachability delta computed between pre and post digests
- Fix-chain DSSE envelope signed
- Verdict determined by
RemediationVerifier:fixed,not_fixed, orinconclusive. (The verifier never emitspartial; that value appears only in older design notes.)
Proof-validation contract (updated 2026-04-28)
RemediationVerifier does not treat changed pre/post scan digests as proof that a vulnerability was fixed. A changed scan pair is only eligible for fixed after all of the following hold:
PreScanDigest,PostScanDigest,ReachabilityDeltaDigest, andFixChainDsseDigestare well-formedsha256:<64 hex>values.- The configured
IRemediationProofValidatorvalidates the reachability delta and fix-chain DSSE evidence. - The validator returns a verified result for the submission and may include affected-path evidence.
Which validator is registered is config-driven (RegisterProofValidation, Program.cs:210-329):
ScannerRemediationProofValidator(SPRINT W1h-2) — registered when bothRemediation:ProofValidation:ReachGraphBaseUrlandRemediation:ProofValidation:AttestorBaseUrlare set. It fetches the reachability delta from the ReachGraph CAS and the fix-chain DSSE envelope from the Attestor, and verifies both with the sharedIDsseEnvelopeVerifieragainst operator-supplied trust keys (Remediation:ProofValidation:TrustKeys:<keyId>= base64 SPKI). Signature verification is necessary but not sufficient: the Attestor payload must useapplication/vnd.in-toto+json,_type=https://in-toto.io/Statement/v1,predicateType=https://stella-ops.org/predicates/fix-chain/v1, apredicate.cveIdmatching the submission,predicate.verdict.status=fixed, andpredicate.reachabilityshowingprePathCount>0,postPathCount=0, andeliminated=true. Any mismatch returns the stable unavailable result. The Attestor client can carry a client-credentials bearer token (Remediation:ProofValidation:Authority:*, default scopeattest:read).UnavailableRemediationProofValidator— the degraded fallback when either URL is absent. It returnsinconclusivewith diagnostic keyremediation.proof_validation.unavailable, andProofValidationDegradedWarningServiceemits a startup WARNING so the degradation is not silent. Fail-closed by construction: an unconfigured host can never mint afixedverdict.
Missing or malformed proof material also returns inconclusive with stable diagnostic keys. The full set of diagnostic keys (RemediationProofDiagnostics) is:
remediation.proof.scan_digest_missingremediation.proof.scan_digest_malformedremediation.proof.reachability_delta_digest_missingremediation.proof.reachability_delta_digest_malformedremediation.proof.fix_chain_dsse_digest_missingremediation.proof.fix_chain_dsse_digest_malformedremediation.proof_validation.unavailable
Evaluation order in RemediationVerifier.DetermineVerdictAsync: missing or malformed pre/post scan digests -> inconclusive; pre/post digests equal (case-insensitive) -> not_fixed (short-circuit, before reachability/DSSE checks); otherwise missing/malformed reachability-delta or fix-chain DSSE digest -> inconclusive; only when all four digests are well-formed sha256:<64 hex> is the candidate verdict fixed, at which point IRemediationProofValidator.ValidateAsync(...) runs and a non-validated result downgrades the verdict back to inconclusive.
Matching pre/post scan digests may still return not_fixed, because that is a negative outcome and does not claim remediation proof success.
Webhook Integration
The RemediationPrWebhookHandler (src/Signals/StellaOps.Signals/Services/RemediationPrWebhookHandler.cs) in the Signals module exposes two pure helpers:
IsRemediationPr(title, labels)- detects remediation PRs by title prefixfix(CVE-(case-insensitive) or the labelstella-ops/remediation.ExtractCveId(title)- extracts the CVE id via regexfix\((CVE-\d{4}-\d+)\).
Detection only. This handler does not create submissions or call back into the Remediation module — it only classifies a PR title/labels and pulls out a CVE id. There is no wiring from Signals to
POST /api/v1/remediation/submissions. TheRepositoryUrl/SourceBranch/TargetBranchexample title conventionfix(CVE-XXXX-NNNNN): descriptionis the recognized format.
Audit Emission
Three state-changing endpoints emit audit events through StellaOps.Audit.Emission (.Audited(...)), under audit module remediation (AuditModules.Remediation):
| Endpoint | Action constant | Action value | Entity |
|---|---|---|---|
POST /templates | AuditActions.Remediation.CreateFixTemplate | create_fix_template | fix_template |
POST /submissions | AuditActions.Remediation.CreatePrSubmission | create_pr_submission | pr_submission |
POST /sources | AuditActions.Remediation.UpsertMarketplaceSource | upsert_marketplace_source | marketplace_source |
Audit emission is wired in Program.cs via AddAuditEmission(builder.Configuration). Read (GET) and match endpoints are not audited.
Module Location
src/Remediation/
|- StellaOps.Remediation.Core/ - Domain models, interfaces, services (verifier, trust scorer, proof validator)
|- StellaOps.Remediation.WebService/ - API endpoints, Program.cs, contract records
|- StellaOps.Remediation.Persistence/ - EF Core DbContext, Postgres repositories, embedded SQL migrations
|- __Tests/StellaOps.Remediation.Tests/ - Repository/domain unit tests (Postgres fixture-backed)
`- __Tests/StellaOps.Remediation.WebService.Tests/ - Endpoint, audit-emission, and startup-contract tests
Persistence is EF Core (RemediationDbContext) over Npgsql. Embedded migrations are applied at startup via AddStartupMigrations(schemaName, "Remediation", typeof(RemediationDataSource).Assembly), in filename order:
| Migration | Purpose |
|---|---|
Migrations/001_remediation_registry_schema.sql | Base schema: fix_templates, pr_submissions, contributors, marketplace_sources. |
Migrations/002_tenant_scoped_registry.sql | TEN-4: adds tenant_id to the first three tables (backfilled to default), replaces the global UNIQUE(username) on contributors with a per-tenant unique index, and adds tenant-leading composite indexes. |
Repositories (PostgresFixTemplateRepository, PostgresPrSubmissionRepository, PostgresMarketplaceSourceRepository, PostgresContributorRepository) are all Postgres-only, require RemediationDataSource, and take the tenant as a required first parameter (see the tenancy contract above).
Related Sprints
- SPRINT_20260220_010: Registry and persistence
- SPRINT_20260220_011: Signals webhook handler
- SPRINT_20260220_012: Verification pipeline
- SPRINT_20260220_013: Matching, sources, policy
- SPRINT_20260220_014: UI components
- SPRINT_20260220_015: Documentation
Related Contracts
Advisory Gap Status (2026-03-04 Batch)
Status:
- Advisory gap
REM-001is closed for marketplace sources.
Closed behaviors:
GET /api/v1/remediation/sourcesreturns persisted tenant-scoped sources with deterministic ordering.GET /api/v1/remediation/sources/{key}resolves persisted records (no unconditional stubsource_not_foundpath).POST /api/v1/remediation/sourcesperforms validated upsert and no longer returns501.- Marketplace source repository abstraction and implementation are wired through DI:
IMarketplaceSourceRepositoryPostgresMarketplaceSourceRepository
Verification evidence:
dotnet test src/Remediation/__Tests/StellaOps.Remediation.Tests/StellaOps.Remediation.Tests.csproj -m:1 -v minimal-28passed.dotnet test src/Remediation/__Tests/StellaOps.Remediation.WebService.Tests/StellaOps.Remediation.WebService.Tests.csproj -m:1 -v minimal-4passed.
Tracking sprint:
docs/implplan/SPRINT_20260304_308_Remediation_marketplace_sources_api_completion.md
