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.
- AOC provides enforcement at two layers:
- runtime —
AocWriteGuard.Validate(JsonElement)rejects non-conforming documents at the persistence boundary; - compile-time — Roslyn analyzers flag forbidden writes in ingestion code.
- runtime —
- AOC enforcement targets ingestion code (Connectors, Ingestion assemblies).
- AOC does not run on merge/decisioning code (those are allowed to write derived fields).
- AOC owns no schema and no service; it is a dependency of the services that ingest data.
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.Cliproject. Manual verification is provided by a CLI plugin that lives outside this module, atsrc/Cli/__Libraries/StellaOps.Cli.Plugins.Aoc/(AocCliCommandModule, commandstella 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:
| Field | Reason |
|---|---|
severity | Computed from CVSS + context |
cvss | Normalized from multiple sources |
cvss_vector | Parsed/validated post-merge |
merged_from | Provenance tracking |
consensus_provider | VEX provider selection |
reachability | Runtime analysis result |
asset_criticality | Policy engine computation |
risk_score | Final risk calculation |
A forbidden top-level field emits AocViolationCode.ForbiddenField → ERR_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.DerivedFindingDetected → ERR_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
ForbiddenTopLevelset additionally listseffective_statusandeffective_rangeexplicitly (alongside theeffective_prefix rule), whereas the runtime guard relies solely on theeffective_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:
- Required top-level fields:
tenant,source,upstream,content,linkset(missing →ERR_AOC_008; missingcontent/linksetare reported viaMissingRequiredField). - Allowed top-level fields: the required set plus
_id,identifiers,attributes,supersedes,createdAt/created_at,ingestedAt/ingested_at,links,advisory_key,statements. Any other unknown top-level key →ERR_AOC_007(UnknownField). - Tenant (when
RequireTenant, default on): must be a non-empty string, elseERR_AOC_009(InvalidTenant). - Provenance:
upstream.content_hashandcontent.rawmust be present, elseERR_AOC_004(MissingProvenance). - Signature (when
RequireSignatureMetadata, default on): theupstream.signatureobject must be present — a missing/non-object signature is reported asERR_AOC_004(MissingProvenance), notERR_AOC_005(seeAocWriteGuard.Upstream.cs). When the object is present andpresent:true, the guard requires apresentboolean, a non-emptyformat(optionally allow-listed viaAllowedSignatureFormats), a base64/base64url-encodedsig, and akey_id. Shape problems (missing/invalidpresent/format/key_id, or asigthat is present but not valid base64) →ERR_AOC_010(InvalidSignatureMetadata); a missing/blanksigpayload →ERR_AOC_005(SignatureInvalid). Whenpresent:false, no further signature checks run.
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 (AocViolationCode → ERR_AOC_00x)
The runtime guard is the authoritative code space. AocViolationCodeExtensions.ToErrorCode maps the enum to stable string codes:
| Enum value | Error code | Raised when |
|---|---|---|
ForbiddenField | ERR_AOC_001 | A forbidden top-level field is present |
MergeAttempt | ERR_AOC_002 | Reserved (enum member; not emitted by AocWriteGuard) |
IdempotencyViolation | ERR_AOC_003 | Reserved (enum member; not emitted by AocWriteGuard) |
MissingProvenance | ERR_AOC_004 | upstream.content_hash / upstream.signature / content.raw missing |
SignatureInvalid | ERR_AOC_005 | Signature marked present but sig payload missing/blank |
DerivedFindingDetected | ERR_AOC_006 | An effective_* (derived) field is present |
UnknownField | ERR_AOC_007 | A top-level field outside the allow-list |
MissingRequiredField | ERR_AOC_008 | A required top-level field (or content / linkset) is missing |
InvalidTenant | ERR_AOC_009 | tenant is absent or not a non-empty string |
InvalidSignatureMetadata | ERR_AOC_010 | Signature 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):
| ID | Severity | Description (referenced runtime code) |
|---|---|---|
AOC0001 | Error | Forbidden field write detected (refs ERR_AOC_001) |
AOC0002 | Error | Derived effective_* field write detected (refs ERR_AOC_006) |
AOC0003 | Warning | Unguarded 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 IAocGuard → AocWriteGuard 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 code | HTTP status |
|---|---|
ERR_AOC_003 | 409 Conflict |
ERR_AOC_004, ERR_AOC_005 | 422 Unprocessable Entity |
ERR_AOC_006 | 403 Forbidden |
| all others | 400 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):
| Option | Default | Effect |
|---|---|---|
RequiredTopLevelFields | tenant, source, upstream, content, linkset | Missing → ERR_AOC_008 |
AllowedTopLevelFields | required set + _id, identifiers, attributes, supersedes, createdAt, created_at, ingestedAt, ingested_at, links, advisory_key, statements | Others → ERR_AOC_007 |
RequireTenant | true | Enforces non-empty string tenant (ERR_AOC_009) |
RequireSignatureMetadata | true | Enforces upstream.signature shape (ERR_AOC_005/ERR_AOC_010) |
AllowedSignatureFormats | empty (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):
- It (or its type/assembly) carries an
[AocIngestion]/[AocIngestionContext]attribute marker. - Assembly name contains
.Connector., ends with.Connector, or contains.Ingestion; or namespace contains.Connector.or.Ingestion. - MSBuild/
.editorconfigopt-in:stellaops_aoc_ingestion/build_property.StellaOpsAocIngestion=trueorall(enable for the whole compilation),stellaops_aoc_ingestion_assemblies/build_property.StellaOpsAocIngestionAssemblies(semicolon/comma/space list, or*/all),stellaops_aoc_ingestion_namespace_prefixes/build_property.StellaOpsAocIngestionNamespacePrefixes(prefix list).
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:
- Concelier (
StellaOps.Concelier.WebService): callsAddAocGuard()(Program.cs) and guards the advisory ingestion route withRequireAocGuard<AdvisoryIngestRequest>(...)(/ingest/advisory). Concelier-side wiring lives inStellaOps.Concelier.Core/Aoc/AocServiceCollectionExtensions.cs, withAdvisoryRawWriteGuard/IAdvisoryRawWriteGuardas the persistence-sink serialization-shape gate. - Excititor (
StellaOps.Excititor.WebService+ Worker — located undersrc/Concelier/StellaOps.Excititor.*, notsrc/Excititor/): callsAddExcititorAocGuards()(StellaOps.Excititor.Core/Aoc/AocServiceCollectionExtensions.cs), which in turn callsAddAocGuard()and registers the VEX raw guard (IVexRawWriteGuard→VexRawWriteGuard).VexRawWriteGuardexposes two methods:EnsureValid(...)(serialization-shape gate at the persistence sink) andEnsureLineage(...)(the pre-normalize lineage gate described in §6).
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):
- Concelier gates the endpoint behind the
Concelier.Aoc.Verifypolicy, which requires both theadvisory:readandaoc:verifyscopes (StellaOpsScopes.AdvisoryRead+StellaOpsScopes.AocVerify); the call is alsoAudited("concelier", "verify", resourceType: "aoc_window"). The canonicalaoc:verifyscope is defined inStellaOpsScopes.AocVerify. - Excititor requires the
vex.adminscope (ScopeAuthorization.RequireScope(context, "vex.admin")), notaoc:verify.
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:
stella aoc verify— verifies AOC compliance for documents ingested since a point in time. Options:--since/-s(ISO-8601, required),--postgres/-p(connection string, required),--output/-o(JSON report),--ndjson/-n(one violation per line),--tenant/-t(filter),--dry-run(validate config without querying the DB). The Web UI exposes a parallel “AOC verification” action viaaoc.client.ts.
Related Documentation
- Concelier:
../concelier/architecture.md(uses AOC for connectors) - Excititor:
../excititor/architecture.md(uses AOC for VEX ingestion; §4.2.3 documents the pre-normalize lineage guard) - ADR:
../../architecture/decisions/ADR-019-excititor-vex-signature-verification-ingest-gate.md(sprint 020 — verification-before-storage contract that produces the lineage stamps the guard reads) - Determinism / telemetry:
../telemetry/(AOC ensures deterministic merge inputs)
