EU Runtime API Contracts v1
Contract ID: CONTRACT-EU-RUNTIME-API-V1-034 Version: eu-runtime-api.v1 Status: Draft Owner: Graph for asset registry API; source services own their records Last updated: 2026-05-30 (reconciled against implementation)
Purpose
This contract is for engineers building EU compliance consumers on top of the Stella Ops release control plane — Graph, Web, and any service or client that needs asset-registry data or the EU regulatory read APIs. It records the live runtime API boundaries those consumers may depend on, and the ownership rules that keep each record with its source of truth.
Graph owns asset-registry list/query/show/export. Authority, Notify, Policy, and EvidenceLocker remain the source of truth for tenant compliance profiles, reporting timelines, NIS2 control registers, and regulatory artifact ledger records, respectively.
Graph must not copy those upstream records into its own persistence. It may depend on typed clients that fail closed while the source-service read API is not available.
Per-record schema contracts referenced below are documented separately:
tenant-compliance-profile-v1.mdassurance-reporting-timeline-v1.mdnis2-control-register-v1.mdeu-regulatory-artifact-ledger-v1.mdasset-registry-v1.md
Graph Asset Registry Runtime API
All routes require a resolved tenant and the normal Graph header authentication path. Read routes accept graph:read or graph:query; export requires graph:export. (Scopes are enforced by GraphPolicies — GraphPolicies.ReadOrQuery accepts graph:read or graph:query; GraphPolicies.Export requires graph:export. See src/Graph/StellaOps.Graph.Api/Security/GraphPolicies.cs.)
The route group is mounted at /graph/assets (AssetRegistryEndpoints.MapAssetRegistryEndpoints). Both GET /graph/assets and GET /graph/assets/ are registered and behave identically.
The v1 asset type values are exactly: host, container, image, service, integration, and plugin (GraphAssetRegistryService.AssetTypes). Supplying any other type to GET or POST .../query raises an ASSET_REGISTRY_VALIDATION_FAILED 400.
GET /graph/assets
Query parameters:
type: comma-separated v1 asset types (one or more ofhost,container,image,service,integration,plugin).query: text search over asset id, type, criticality, and safe attribute values.criticality: criticality filter (case-insensitive equality).includeTombstones:trueto include removed assets.includeLineage:trueto includeASSET_VERSIONlineage.limit: defaults to 100, clamped to 1 through 500.cursor: opaque cursor token returned by the prior response.ordering:assetIdorders by asset id; any other value (or omitted) orders by assettypethenassetId.
POST /graph/assets/query
Request:
{
"types": ["service", "integration"],
"query": "payments",
"criticality": "high",
"filters": { "provider": "github" },
"includeTombstones": false,
"includeLineage": true,
"limit": 100,
"cursor": null,
"ordering": "assetId"
}
Response:
{
"schemaVersion": "asset-registry.v1",
"items": [
{
"schemaVersion": "asset-registry.v1",
"assetId": "gn:tenant-a:asset:...",
"type": "integration",
"tenantId": "tenant-a",
"attributes": {
"credential_ref": "cred:github-prod",
"provider": "github"
},
"ownership": {
"ownerActorRef": "actor:release-owner",
"ownerTeamRef": "team:platform",
"credentialRefs": ["cred:github-prod"]
},
"criticality": "high",
"dependsOn": [],
"firstSeen": "2026-04-30T08:00:00Z",
"lastSeen": "2026-04-30T08:05:00Z",
"tombstone": false,
"lineage": [
{
"edgeId": "ge:tenant-a:asset-version:...",
"eventType": "asset.created",
"previousAssetHash": null,
"currentAssetHash": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
"effectiveAt": "2026-04-30T08:00:00Z",
"tombstone": false
}
]
}
],
"cursor": null,
"total": 1
}
cursor is null on the final page. When more rows remain it is an object, not a string (AssetRegistryCursor):
{
"token": "<opaque-offset-token>",
"resumeUrl": "/graph/assets?cursor=<url-encoded-token>"
}
The opaque token is an offset cursor (CursorCodec); supply it back as the cursor query/body parameter to fetch the next page.
GET /graph/assets/{assetId}
Returns one asset-registry.v1 asset record for the resolved tenant. Query parameter includeLineage=true includes lineage. Lookup includes tombstoned assets. When no asset matches, the endpoint returns 404 with body { "error": "ASSET_NOT_FOUND", "message": "..." }.
POST /graph/assets/export
Request is the query request plus format. Supported formats are ndjson (default) and json; any other value yields an ASSET_REGISTRY_VALIDATION_FAILED 400. Export ignores limit and cursor (all matching assets are emitted). For json the body is an AssetRegistryListResponse (with items and total, no cursor); for ndjson the body is one serialized asset-registry.v1 record per line separated by \n.
The response is returned as a downloadable file (asset-registry-{tenantId}.{format}) with content type application/x-ndjson (ndjson) or application/json (json). Headers:
X-Content-SHA256: lowercase hex SHA-256 of response bytes.X-Asset-Count: exported asset count.
Ordering is deterministic: asset type ascending, then assetId ascending.
Opaque Reference Boundary
Asset API responses may include opaque references:
credential_refandcredentialRefsownerActorRefownerTeamRef
Responses must not include dereferenced credential material, tokens, passwords, secret values, person names, emails, or human-actor details. Those remain owned by Authority or secret-provider integrations.
Typed Source-Service Boundaries
Graph exposes typed contracts in src/Graph/StellaOps.Graph.Api/Contracts/EuRuntimeContracts.cs so consumers can compile against stable boundaries without direct database access.
Authority Tenant Compliance Profile
Source of truth: Authority.
Read contract:
- Authority route:
GET /api/v1/tenants/{tenantId}/compliance-profile(TenantComplianceProfileEndpointExtensions.MapTenantComplianceProfileEndpoints). This is a GET with atenantIdpath parameter and no request body. - authorization:
authority:tenants.read(enforced on the whole/api/v1/tenantsgroup viaRequireStellaOpsScopes(AuthorityTenantsRead)). - tenant cross-check: a
TenantHeaderFilteris applied to the group; when a resolved tenant header is present and does not match the{tenantId}path segment the endpoint returns403. An unknown tenant returns404(tenant_not_found); a blank id returns400(invalid_tenant_id). - Graph typed client request DTO:
TenantComplianceProfileLookupRequest(carries onlytenantId); used by the Graph-sideITenantComplianceProfileClientto address the Authority route. It is not an HTTP request body on the Authority endpoint itself. - response:
TenantComplianceProfileResponsewithschemaVersion,tenantId, nullableprofile, andsourceOfTruth = "Authority". The profile record fields arelei,legalEntityName,jurisdictionCode,regulatedRegimes[], andresponsibilityLocus. - nullable non-regulated tenants:
profileisnull
The same Authority group also exposes GET /api/v1/tenants/{tenantId}/assurance-packs and GET /api/v1/tenants/{tenantId}/submission-authorizations under the same authority:tenants.read scope; those are outside the EU runtime asset/registry boundary but share the tenant cross-check filter.
Status: available from Authority. Every read also writes an authority.tenant_compliance_profile.read audit event. The response includes only the normalized nullable tenant-compliance-profile.v1 payload and excludes operator settings, deployment posture, channel destinations, trust roots, signing provider, and secret references.
Notify Reporting Timeline
Source of truth: Notify/Timeline.
HTTP read API:
- route:
GET /api/v1/regulatory/reporting-timelines(ReportingTimelineEndpoints.MapReportingTimelineEndpoints). - tenant: resolved via
StellaOpsTenantResolver.TryResolveTenantIdfrom the envelope-bound StellaOps tenant claim, not from raw tenant headers. A missing tenant claim returns400. - authorization: requires the
notify.viewerscope. The group appliesRequireAuthorization(NotifierPolicies.NotifyViewer), which is registered as a direct scope policy (AddStellaOpsScopePolicy(NotifyViewer, notify.viewer)). An operator/admin token does not satisfy this unless it also carriesnotify.viewer. - filters: optional
regime, optionalincidentId, optionalevidenceRef, optional comma-delimitedstate, optionalactive, optionallimit, optionalcursor.regimeis lower-cased before matching.stateacceptsOPEN,EARLY_WARNING_SENT,NOTIFICATION_SENT,FINAL_REPORT_SENT,CLOSED(case/-/_-insensitive); an unsupported value returns400invalid_state. - active default: when
stateis omitted,active=trueand closed timelines are excluded. To query closed rows, passstate=CLOSED&active=false. - pagination:
limitdefaults to 100 and is clamped to 1 through 500;cursoris an opaque base64urloffset:<n>cursor returned by the prior response. An invalid cursor returns400invalid_cursor. - deterministic order: items are ordered by the persisted store (
ORDER BY COALESCE(current_deadline, infinity) ASC, opened_at ASC, incident_id ASC, regime_id ASC), so the current milestone deadline ascends first, then opened time, incident id, and regime; timelines with no remaining deadline sort last. Within each item, milestones, transmissions, evidence refs, ledger refs, and audit refs are sorted deterministically before emission (milestones/transmissions by deadline or sent time then name/id, refs ascending ordinal).
Response:
{
"schemaVersion": "eu-reporting-timeline.v1",
"items": [
{
"regime": "nis2",
"incidentId": "incident-nis2-advanced",
"state": "EARLY_WARNING_SENT",
"openedAt": "2026-04-30T09:00:00Z",
"closedAt": null,
"milestone": "notification",
"deadline": "2026-05-03T09:00:00Z",
"deadlineStatus": "MISSED",
"payloadHash": null,
"signerRef": null,
"replayHash": "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
"evidenceRefs": ["cas://evidence/impact-summary"],
"ledgerRefs": [
"ledger://notify/nis2/opened/advanced",
"ledger://notify/nis2/reported/early-warning"
],
"auditEventIds": ["INC-2026-0429"],
"milestones": [
{
"name": "early_warning",
"transmissionKind": "early_warning",
"deadline": "2026-05-01T09:00:00Z",
"deadlineStatus": "COMPLETED",
"completedAt": "2026-05-01T10:00:00Z",
"transmissionId": "irtx_...",
"payloadHash": "sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb",
"signerRef": "did:web:stella-ops.example#notify",
"ledgerEventKind": "nis2.incident.reported",
"requiredEvidence": ["impact_summary_ref", "incident_opened_event_ref"]
}
],
"transmissions": [
{
"transmissionId": "irtx_...",
"milestone": "early_warning",
"transmissionKind": "early_warning",
"ledgerEventKind": "nis2.incident.reported",
"sentAt": "2026-05-01T10:00:00Z",
"payloadHash": "sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb",
"signerRef": "did:web:stella-ops.example#notify",
"evidenceRefs": {
"impact_summary_ref": "cas://evidence/impact-summary",
"incident_opened_event_ref": "ledger://notify/nis2/opened/advanced"
},
"sourceEventRef": "ledger://notify/nis2/reported/early-warning",
"hlc": "2026-04-30T08:00:00Z/0002"
}
],
"alerts": [
{
"severity": "SEV-1",
"kind": "missed_deadline",
"milestone": "notification",
"deadline": "2026-05-03T09:00:00Z",
"observedAt": "2026-05-03T16:00:00Z",
"elapsedPercent": 1.0
}
],
"updatedAt": "2026-05-01T10:00:00Z"
}
],
"cursor": null,
"sourceOfTruth": "Notify"
}
The response sample above is the live Notify HTTP payload (ReportingTimelineResponse / ReportingTimelineItem in ReportingTimelineEndpoints.cs), which is the rich source-of-truth shape.
Graph typed boundary: IReportingTimelineClient remains the consumer-facing Graph interface. Implementations must call this Notify source-owned API and must not read Notify persistence directly. The typed Graph DTOs (ReportingTimelineResponse / ReportingTimelineItem in EuRuntimeContracts.cs) are a thinner projection of the payload above: the typed item carries only regime, milestone, deadline, state, payloadHash, signerRef, evidenceRefs[], and auditEventIds[] — it does not surface incidentId, replayHash, milestones[], transmissions[], alerts[], or the open/closed/updated timestamps.
Status: available from Notify for persisted reporting timelines. The Policy control-register source API is documented below.
Policy NIS2 Control Register
Source of truth: Policy.
Source endpoint:
GET /api/policy/control-register/nis2(Nis2ControlRegisterEndpoints.MapNis2ControlRegister)- authorization:
policy:read(enforced both byRequireStellaOpsScopes(PolicyRead)and an in-handlerScopeAuthorization.RequireScopecheck) - tenant: resolved from the envelope-bound
stellaops:tenantclaim; a missing claim returns401(TENANT_REQUIRED); an optionaltenantIdquery parameter must match the resolved tenant or the endpoint returns403(TENANT_MISMATCH)
Sibling endpoints in the same group (same policy:read/policy:author discipline):
GET /api/policy/control-register/frameworks/{frameworkId}— framework-aware Assurance projection (policy:read). NIS2 is currently the only enabled adapter; unknown frameworks fail closed with404(UNKNOWN_FRAMEWORK). Schema version isassurance-control-register-v1.POST /api/policy/control-register/nis2— authoring endpoint requiring thepolicy:authorscope; binds a Policy document carryingcomplianceMappings.controls[]and projects each control into the register.
Query parameters:
thematicArea: optional integer, 1 through 13evidenceMode: optionalstella-native,tenant-supplied, ormixedreviewStatus: optionalcurrent,due-soon, oroverdueresponsibleRole: optional exact role filterlimit: optional page size, default 100, clamped to 1 through 500cursor: optional opaque offset cursor returned by the prior responseasOfDate: optionalyyyy-MM-dddate used for deterministic review-status evaluation; defaults to the serviceTimeProviderUTC date
Graph typed boundary:
- request:
Nis2ControlRegisterQueryRequest - response:
Nis2ControlRegisterResponse - required fields: 13-area mapping, owner role, review status, evidence refs, and tenant override refs
Response shape (Nis2ControlRegisterResponse):
schemaVersion(valuenis2-control-register-v1),tenantId,generatedAt,asOfDate,source(valuelive),sourceOfTruth(valuePolicy)thematicAreas[]: exactly 13 entries withthematicArea,thematicAreaName,controlCount, aggregatereviewStatus, andownerRoles[]. Areas with no controls reportreviewStatus = "missing".controls[]: Policy-native rows withcontrolId,thematicArea,thematicAreaName,art21Refs[],policyDocRef,responsibleRole,ownerRole,implementingControls[],evidencePointers[],evidenceRefs[],evidenceMode,lastReviewed,nextReview,reviewStatus,tenantOverrideRefs[],auditEventIds[],mappingHash, andmetadataitems[]: Graph/Web projection (Nis2ControlRegisterItem) withcontrolId,thematicArea,ownerRole,reviewStatus,evidenceRefs[],tenantOverrideRefs[], andauditEventIds[]cursor: null when the page is complete, otherwise an opaque base64urloffset:<n>tokentotal: total filtered control count before page slicingblockers[]: currently always emitted empty by the live read APIsnapshotHash: currently alwaysnullon the live read API
reviewStatus is computed against asOfDate: overdue when nextReview is before asOfDate, due-soon within 30 days, otherwise current.
The Graph typed boundary DTOs (Nis2ControlRegisterResponse / Nis2ControlRegisterItem in EuRuntimeContracts.cs) are a thinner projection than the source response above: the typed Nis2ControlRegisterResponse carries only schemaVersion, items[], cursor, and sourceOfTruth, and its Nis2ControlRegisterItem carries controlId, thematicArea, ownerRole, reviewStatus, evidenceRefs[], and tenantOverrideRefs[].
Ordering is deterministic by thematicArea, then controlId. The cursor is derived after filtering, so repeated calls with the same tenant, filters, asOfDate, and register state return the same pages.
Status: available from Policy as the source-owned read API. Graph and Web consumers must call this API or a typed client over it; they must not read Policy persistence tables directly.
EvidenceLocker Regulatory Artifact Ledger
Source of truth: EvidenceLocker.
HTTP read API:
- route:
GET /api/v1/regulatory/artifact-ledger(RegulatoryArtifactLedgerEndpoints.MapRegulatoryArtifactLedgerEndpoints). - authorization: requires the
evidence:readscope (StellaOpsResourceServerPolicies.EvidenceRead, which maps toStellaOpsScopes.EvidenceRead). - tenant: the group applies
RequireTenant(); the resolved tenant is read fromIStellaOpsTenantAccessor(the envelope-bound tenant), not from a request body. The tenant id must be a non-empty GUID or the endpoint returns403. - filters: optional
regime, optionalartifactType, optionalreportingYear, optionalrequireMatch. - pagination:
limitdefaults to 100 (clamped/validated downstream by the repository query);cursoris an opaque plain-base64offset:<n>cursor returned by the prior response. An invalid cursor returns400invalid_cursor. (Note: unlike the Notify and Policy cursors, this cursor is standard base64, not base64url.) - deterministic order: reporting year descending, then artifact type ascending, artifact hash ascending, and artifact id ascending.
- fail closed: callers that need a required lookup, such as a prior-year RoI hash, pass
requireMatch=truewithregime,artifactType, andreportingYear. When any of those three is missing the endpoint returns400 invalid_request; when all three are supplied but no row matches it returns404 artifact_not_found. Error bodies are{ "code": "...", "message": "..." }.
Response:
{
"schemaVersion": "eu-regulatory-artifact-ledger.v1",
"items": [
{
"artifactId": "11111111-1111-5111-8111-111111111111",
"regime": "dora",
"artifactType": "dora-roi-snapshot",
"reportingYear": 2025,
"artifactHash": "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
"evidenceRefs": ["bundle:roi-source-a"],
"auditEventIds": ["22222222-2222-5222-8222-222222222222"],
"schemaPinId": "eba-dora-roi-reporting-framework-4.0-taxo-package-4.0-errata5",
"taxonomyPinId": "eba-dora-roi-reporting-framework-4.0-taxo-package-4.0-errata5",
"signatureRef": "dsse:roi-2025",
"signerKeyId": "stella-eu-signing-2026",
"retentionRecordId": "33333333-3333-5333-8333-333333333333",
"createdAt": "2025-12-31T23:59:00Z"
}
],
"cursor": null,
"sourceOfTruth": "EvidenceLocker"
}
The HTTP response (RegulatoryArtifactLedgerResponse) uses schemaVersion = "eu-regulatory-artifact-ledger.v1" and each item carries artifactId, regime, artifactType, reportingYear, artifactHash, evidenceRefs[], auditEventIds[], schemaPinId, taxonomyPinId, signatureRef, signerKeyId, retentionRecordId, and createdAt. The auditEventIds[] are the non-null approval, generation, and signing audit event ids.
Graph typed boundary: IArtifactLedgerClient remains the consumer-facing Graph interface. Implementations must call this EvidenceLocker source-owned API and must not read EvidenceLocker tables directly. The typed Graph DTOs (ArtifactLedgerResponse / ArtifactLedgerItem in EuRuntimeContracts.cs) are a thinner projection: the typed response uses schemaVersion = "regulatory-artifact-ledger.v1" (note the absent eu- prefix versus the live HTTP payload above) and its item carries only artifactId, regime, artifactType, reportingYear, artifactHash, evidenceRefs[], and auditEventIds[].
Determinism
- Asset lists and exports are ordered by deterministic keys.
- Reporting timeline lists are ordered by deterministic persisted deadline and identity keys; milestone, transmission, evidence, ledger, and audit refs are sorted before emission.
- Cursors are opaque offsets.
- Export hashes are computed from emitted bytes.
- Tests must run offline with local fixtures only.
