AOC architecture

Stella Ops AOC (Aggregation-Only Contract) — enforcement via a runtime write-guard library plus Roslyn analyzers.

Scope. Library architecture for AOC (Aggregation-Only Contracts). AOC is a shared library (src/Aoc/) — it has no HTTP service or runtime host of its own. It ships:

  • a runtime guard (IAocGuard / AocWriteGuard) that validates ingestion documents before they are persisted, and
  • Roslyn analyzers that catch forbidden-field writes at compile time.

The guard is consumed by ingestion services (Concelier, Excititor), which expose any HTTP surface (see §7). The canonical platform term is Aggregation-Only Contract; some source comments still say “Append-Only Contract” — they refer to the same concept.


0) Mission & boundaries

Mission. Enforce aggregation-only contract rules during data ingestion. Prevent connectors from writing to fields that should only be computed by downstream merge/decisioning pipelines (severity, CVSS, effective status, risk scores), and ensure every ingested document preserves its raw upstream provenance and signature metadata.

Boundaries.


1) Solution & project layout

src/Aoc/
 ├─ StellaOps.Aoc.sln
 │
 ├─ __Analyzers/
 │   └─ StellaOps.Aoc.Analyzers/           # Roslyn DiagnosticAnalyzers
 │       └─ AocForbiddenFieldAnalyzer.cs   # Single analyzer (AOC0001/0002/0003)
 │
 ├─ __Libraries/
 │   ├─ StellaOps.Aoc/                     # Core runtime guard + abstractions
 │   │   ├─ IAocGuard.cs                   # interface: Validate(JsonElement, options)
 │   │   ├─ AocWriteGuard*.cs              # partial impl (TopLevel/Content/Upstream/Signature/Base64)
 │   │   ├─ AocViolationCode.cs            # enum → ERR_AOC_001..010
 │   │   ├─ AocViolationCodeExtensions.cs  # enum → "ERR_AOC_00x" mapping
 │   │   ├─ AocForbiddenKeys.cs            # forbidden + derived key sets
 │   │   ├─ AocGuardOptions.cs             # required/allowed fields, signature/tenant policy
 │   │   ├─ AocGuardResult.cs / AocViolation.cs / AocError.cs
 │   │   ├─ AocGuardException.cs / AocGuardExtensions.cs (ValidateOrThrow)
 │   │   └─ ServiceCollectionExtensions.cs # AddAocGuard()
 │   │
 │   └─ StellaOps.Aoc.AspNetCore/          # Minimal-API integration
 │       ├─ Routing/AocGuardEndpointFilter*.cs   # IEndpointFilter + RequireAocGuard<T>()
 │       └─ Results/AocHttpResults.cs             # RFC 7807 problem responses
 │
 └─ __Tests/
     ├─ StellaOps.Aoc.Analyzers.Tests/
     ├─ StellaOps.Aoc.AspNetCore.Tests/
     └─ StellaOps.Aoc.Tests/

There is no StellaOps.Aoc.Cli project. Manual verification is provided by a CLI plugin that lives outside this module, at src/Cli/__Libraries/StellaOps.Cli.Plugins.Aoc/ (AocCliCommandModule, command stella aoc verify). See §8.

Both libraries target net10.0 with TreatWarningsAsErrors. StellaOps.Aoc depends only on Microsoft.Extensions.DependencyInjection.Abstractions; StellaOps.Aoc.AspNetCore adds a FrameworkReference to Microsoft.AspNetCore.App.


2) Core concept

2.1 Forbidden fields (runtime guard)

The runtime guard (AocForbiddenKeys.IsForbiddenTopLevel) treats these top-level keys as forbidden — they are computed by the decisioning pipeline and must never appear in an ingested document:

FieldReason
severityComputed from CVSS + context
cvssNormalized from multiple sources
cvss_vectorParsed/validated post-merge
merged_fromProvenance tracking
consensus_providerVEX provider selection
reachabilityRuntime analysis result
asset_criticalityPolicy engine computation
risk_scoreFinal risk calculation

A forbidden top-level field emits AocViolationCode.ForbiddenFieldERR_AOC_001.

2.2 Derived fields

Any field prefixed with effective_(AocForbiddenKeys.IsDerivedField) is treated as a derived field. The runtime guard reports it separately as AocViolationCode.DerivedFindingDetectedERR_AOC_006, with its own message (“must not be written during ingestion”). It is not in the forbidden-key set above.

Runtime vs. analyzer field sets differ. The Roslyn analyzer’s ForbiddenTopLevel set additionally lists effective_status and effective_range explicitly (alongside the effective_ prefix rule), whereas the runtime guard relies solely on the effective_ prefix to catch derived fields. Both produce equivalent verdicts in practice; the difference is an implementation detail of the two checkers.

2.3 Document shape enforced by the guard

Beyond forbidden/derived fields, AocGuardOptions.Default enforces a full ingestion-document shape:

The guard returns an AocGuardResult (IsValid + an immutable list of AocViolation). It collects all violations rather than throwing; IAocGuard.ValidateOrThrow(...) (extension) wraps a failing result in AocGuardException.


3) Violation codes

3.1 Runtime guard codes (AocViolationCodeERR_AOC_00x)

The runtime guard is the authoritative code space. AocViolationCodeExtensions.ToErrorCode maps the enum to stable string codes:

Enum valueError codeRaised when
ForbiddenFieldERR_AOC_001A forbidden top-level field is present
MergeAttemptERR_AOC_002Reserved (enum member; not emitted by AocWriteGuard)
IdempotencyViolationERR_AOC_003Reserved (enum member; not emitted by AocWriteGuard)
MissingProvenanceERR_AOC_004upstream.content_hash / upstream.signature / content.raw missing
SignatureInvalidERR_AOC_005Signature marked present but sig payload missing/blank
DerivedFindingDetectedERR_AOC_006An effective_* (derived) field is present
UnknownFieldERR_AOC_007A top-level field outside the allow-list
MissingRequiredFieldERR_AOC_008A required top-level field (or content / linkset) is missing
InvalidTenantERR_AOC_009tenant is absent or not a non-empty string
InvalidSignatureMetadataERR_AOC_010Signature metadata shape invalid (present/format/sig encoding/key_id)

(None maps to the fallback ERR_AOC_000.) Codes ERR_AOC_002/003 exist in the enum but are not produced by the current AocWriteGuard validation path.

3.2 Compile-time analyzer diagnostics (AocForbiddenFieldAnalyzer)

The single Roslyn analyzer (StellaOps.Aoc.Analyzers) defines three diagnostics. These IDs are AOC000x, a separate namespace from the runtime ERR_AOC_00x codes (the analyzer messages cross-reference the runtime code for context):

IDSeverityDescription (referenced runtime code)
AOC0001ErrorForbidden field write detected (refs ERR_AOC_001)
AOC0002ErrorDerived effective_* field write detected (refs ERR_AOC_006)
AOC0003WarningUnguarded database write without IAocGuard validation (refs ERR_AOC_007)

AOC0003 recognises EF Core (DbContext.SaveChanges*, DbSet.Add/Update*), ADO.NET (DbCommand/NpgsqlCommand.ExecuteNonQuery*), and MongoDB (IMongoCollection.InsertOne/UpdateOne/ReplaceOne/BulkWrite…) write calls, and suppresses the warning when the write is lexically within an IAocGuard.Validate/ValidateOrThrow scope or the enclosing method takes an IAocGuard parameter.


4) Usage

4.1 Analyzer Reference

Add analyzer reference to connector projects:

<ItemGroup>
  <PackageReference Include="StellaOps.Aoc.Analyzers" PrivateAssets="all" />
</ItemGroup>

4.2 Runtime guard usage

Register the guard in DI (AddAocGuard() binds IAocGuardAocWriteGuard as a singleton), then validate the document before persisting. The guard is synchronous and operates on a System.Text.Json.JsonElement — there is no ValidateAsync:

services.AddAocGuard();   // StellaOps.Aoc.ServiceCollectionExtensions

public sealed class MyConnector
{
    private readonly IAocGuard _aocGuard;

    public void Ingest(JsonElement document)
    {
        // Validate-and-throw: AocGuardException carries the AocGuardResult/violations.
        _aocGuard.ValidateOrThrow(document);   // or: var result = _aocGuard.Validate(document);
        _repository.Insert(document);
    }
}

Validate(JsonElement, AocGuardOptions?) returns an AocGuardResult listing all violations. Use the ValidateOrThrow extension when you want a fail-closed AocGuardException instead of inspecting the result.

4.3 ASP.NET Core minimal-API integration

StellaOps.Aoc.AspNetCore adds an endpoint filter so a host can guard a route without touching the handler body:

// Generic illustration — the selector projects the request to the payload(s) to validate.
app.MapPost("/ingest/advisory", Handler)
   .RequireAocGuard<TRequest>(req => req.Payload);   // payload selector

There are two overloads of RequireAocGuard<TRequest>(...): one taking Func<TRequest, object?> (single payload) and one taking Func<TRequest, IEnumerable<object?>> (multiple payloads); both accept optional JsonSerializerOptions and AocGuardOptions. The selector returns any CLR object — the filter serializes it to a JsonElement before validating, so it does not require a pre-built JsonElement/Document field. (Concelier’s real call on /ingest/advisory builds the payload from the AdvisoryIngestRequest sub-records — Source/Upstream/Content/Identifiers/Linkset — rather than reading a single Document property.)

RequireAocGuard<TRequest>(...) (in AocGuardEndpointFilterExtensions) resolves IAocGuard from DI, resolves AocGuardOptions (preferring the per-call guardOptions, then IOptions<AocGuardOptions>, else AocGuardOptions.Default), extracts one or more payloads via the selector, validates each, and on the first failing payload short-circuits with an RFC 7807 problem response built by AocHttpResults.Problem(...). Error-code → HTTP status mapping (AocHttpResults.MapErrorCodeToStatus):

Error codeHTTP status
ERR_AOC_003409 Conflict
ERR_AOC_004, ERR_AOC_005422 Unprocessable Entity
ERR_AOC_006403 Forbidden
all others400 Bad Request

The status is keyed off the response’s top-level code, which AocError.FromResult(...) selects as the first violation after sorting all violations by error code (ordinal), then path, then message. So when a payload has multiple violations, the lowest-sorted ERR_AOC_00x (e.g. ERR_AOC_001 before ERR_AOC_004) drives the HTTP status. The problem response carries code, a violations array (each {code, path, message}), and the full error object in its extensions; the title is “Aggregation-Only Contract violation” and the type URI is https://stella-ops.org/problems/aoc-violation. A status/type/title/detail/extensions override can be supplied to AocHttpResults.Problem(...) directly.


5) Configuration

5.1 Runtime guard (AocGuardOptions)

The guard’s behaviour is driven by AocGuardOptions (AocGuardOptions.Default used when none supplied):

OptionDefaultEffect
RequiredTopLevelFieldstenant, source, upstream, content, linksetMissing → ERR_AOC_008
AllowedTopLevelFieldsrequired set + _id, identifiers, attributes, supersedes, createdAt, created_at, ingestedAt, ingested_at, links, advisory_key, statementsOthers → ERR_AOC_007
RequireTenanttrueEnforces non-empty string tenant (ERR_AOC_009)
RequireSignatureMetadatatrueEnforces upstream.signature shape (ERR_AOC_005/ERR_AOC_010)
AllowedSignatureFormatsempty (any)When non-empty, restricts upstream.signature.format

5.2 Compile-time analyzer activation

AocForbiddenFieldAnalyzer only runs in an ingestion context. A symbol is treated as ingestion code when any of these hold (test/analyzer assemblies — *.Tests/*.Test/*.Testing/*.Analyzers — are always excluded):


6) Lineage invariant (sprint 20260501-023, audit pass-2 §E3)

The Roslyn analyzer covers compile-time safety. There is one further runtime invariant that the analyzer cannot enforce: the bytes that were signed are the bytes that flow into normalization. A connector whose NormalizeAsync fabricates, mutates, or copies claims from a different fetched document would satisfy every analyzer rule and still violate AOC.

For VEX, IVexRawWriteGuard.EnsureLineage(VexRawDocument raw, VexClaimBatch normalized) closes that gap. It runs between IVexNormalizerRouter.NormalizeAsync and the consensus/linkset write, and rejects any claim whose document digest does not match the raw document’s digest. Lineage stamps are written by VexConnectorBase.CreateRawDocumentAsync after signature verification succeeds (aoc.raw.digest, aoc.raw.length, aoc.raw.signedBy). The stable error code for lineage breakage is aoc.lineage.broken; the corresponding exception is StellaOps.Excititor.Core.Aoc.VexAocLineageBrokenException. See ../excititor/architecture.md §4.2.3 for the full failure-mode table.

The analogous Concelier surface (AdvisoryRawWriteGuard) is currently serialization-shape-only; backfilling a lineage check there is tracked as a separate audit follow-up.


7) Enforcement points (who consumes AOC)

AOC is a library; enforcement happens in the ingestion services that depend on it. The current consumers:

7.1 The /aoc/verify endpoint is host-provided

AOC does not host /aoc/verify. Both Concelier and Excititor WebServices expose their own POST /aoc/verify endpoint (tenant-scoped, audited, authorization-policy-gated when Authority is configured) that re-scans recently ingested raw documents and reports aggregate AOC compliance. The Concelier handler delegates to IAdvisoryRawService.VerifyAsync(...); the Excititor handler queries IVexRawStore directly and re-runs the guard per document.

Authorization (when Authority is configured):

Request (Concelier AocVerifyRequest): since, until, limit (default 20), sources[], codes[] — all optional; the default window is the last 24h, and until < since is rejected with 400. Excititor’s request (VexAocVerifyRequest) has the same fields but defaults limit to 100 (clamped to 1–500) and silently shifts the window (since = until - 1h) when since >= until.

Response (Concelier AocVerifyResponse): tenant, window {from,to}, checked {advisories,vex} (the Concelier handler always reports vex: 0 in checked since it scans advisories only), violations[] {code,count,examples[{source,documentId,contentHash,path}]}, metrics {ingestion_write_total, aoc_violation_total}, truncated.


8) CLI surface

There is no CLI project inside src/Aoc/. The operator CLI is a plugin module at src/Cli/__Libraries/StellaOps.Cli.Plugins.Aoc/ (AocCliCommandModule, module name stellaops.cli.plugins.aoc). It registers the command group stella aoc with one subcommand: