NIS2 Control Register Contract v1
Contract ID: CONTRACT-NIS2-CONTROL-REGISTER-V1-017 Status: Active (Stella Ops EU compliance sprint 040) Owner: Policy Guild Schema version: nis2-control-register-v1 Canonical producer: Policy
Part of the Stella Ops Contracts index. Audience: Policy implementers who own the control register, and Graph/Web/Assurance teams that read it through the Policy API to drive EU compliance dashboards and exports.
Purpose
The NIS2 control register maps Policy-owned gates, scopes, metrics, and runbooks to the thirteen Article 21 thematic areas used by the EU compliance closure plan. It is designed for offline replay: the same policy document, tenant id, Stella release, timestamp, and ordered control set must produce the same canonical snapshot hash.
This contract is the input for two downstream NIS2 contracts: the Statement of Applicability export and the KPI Telemetry Schema. The Graph/Web read boundary is documented in EU Runtime API Contracts v1.
Policy Document Block
Policy documents MAY include a root complianceMappings block:
complianceMappings:
schemaVersion: nis2-control-register-v1
controls:
- controlId: NIS2-08-SUPPLIER-EVIDENCE
thematicArea: 8
art21Refs: ["21(2)(d)", "21(2)(e)"]
policyDocRef: docs/modules/policy/guides/nis2-control-register.md#supplier
responsibleRole: vendor-risk-owner
implementingControls:
- kind: gate
ref: policy.gates.supplier-assurance
- kind: runbook
ref: runbooks/vendor-evidence-review
evidencePointers:
- ref: tenant-evidence://supplier/quarterly-attestation
sourceType: tenant-supplied
submittedByRole: vendor-risk-owner
collectedAt: 2026-04-30T10:00:00Z
lastReviewed: 2026-04-30
nextReview: 2026-07-30
Fields
| Field | Type | Required | Notes |
|---|---|---|---|
schemaVersion | string | no | Defaults to nis2-control-register-v1; other values are rejected. |
controls[] | array | yes | One or more NIS2 control mappings. |
controlId | string | yes | Stable tenant-visible id. Unique per tenant in persistence. |
thematicArea | integer | yes | Must be 1 through 13. |
art21Refs[] | string array | yes | Article 21 references such as 21(2)(d). |
policyDocRef | string | yes | Human-readable policy or module documentation reference. |
responsibleRole | string | yes | Owner role accountable for review and evidence freshness. |
implementingControls[] | array | yes | References to Policy gates, scopes, metrics, or runbooks. |
evidencePointer | string | conditional | Legacy single-pointer alias; treated as Stella-native evidence. |
evidencePointers[] | array | conditional | Preferred evidence pointer list. Required unless evidencePointer is present. |
lastReviewed | ISO date | yes | Date the mapping was last reviewed. |
nextReview | ISO date | yes | Must be on or after lastReviewed. |
evidenceMode | string | no | stella-native, tenant-supplied, or mixed; derived from pointers when omitted. |
metadata | object | no | String-normalized, sorted by key for canonical snapshots. |
implementingControls[].kind is one of gate, scope, metric, or runbook. evidencePointers[].sourceType is one of stella-native or tenant-supplied.
Thematic Areas
The contract reserves exactly thirteen thematic areas. Policy validates the numeric range and leaves customer-specific area labels to templates and UI sprint work.
Lifecycle Events
Policy persists lifecycle events in policy.control_register_events:
| Event | Trigger |
|---|---|
control:registered | First active write for a tenant/control id, or re-registering an archived control. |
control:updated | Active control mapping hash changes or metadata is rewritten. |
control:archived | Control is removed from active register reads. |
control:snapshot_published | Deterministic snapshot is stored with EvidenceLocker bundle and SoA export hashes. |
Events are tenant-scoped, sequence-ordered, and carry previous/new/snapshot hashes for replay. Event details_json also carries frameworkId: nis2, packId: nis2, and evidence-mode metadata for Assurance export routing. These additions do not change the NIS2 control fields or the compatibility route shape. When Policy is configured with EvidenceLocker regulatory persistence, lifecycle events are also projected to the EU regulatory audit contract. Control register/update/archive events project as evidence.eu.control.lifecycle_recorded with artifact type nis2-control-register-control, and control:snapshot_published events project as evidence.eu.control.snapshot_published (artifact types nis2-control-register-snapshot and the pinned nis2-statement-of-applicability hash). Regulatory events carry regime: nis2 (see RegulatoryRegimes.Nis2). The projection stores event ids, sequence numbers, hashes, control ids, and correlation refs only; Policy remains the source of truth for control state.
Persistence
The PostgreSQL migration 014_nis2_control_register.sql creates:
policy.control_register_entriespolicy.control_register_eventspolicy.control_register_snapshots
All tables enable row-level security using policy_app.require_current_tenant(). The migration is embedded in StellaOps.Policy.Persistence and applied by the existing Policy startup migration registration.
API Surface
Policy is the source of truth for NIS2 control register entries and snapshots. Graph and EU dashboards must use a Policy-owned read API or typed client, never the Policy persistence tables directly.
All routes are grouped under /api/policy/control-register and registered by Nis2ControlRegisterEndpoints.MapNis2ControlRegister (wired in StellaOps.Policy.Engine/Program.cs):
| Method + route | Scope | Purpose |
|---|---|---|
GET /api/policy/control-register/nis2 | policy:read | Read the NIS2 control register (rich Policy-side response). |
GET /api/policy/control-register/frameworks/{frameworkId} | policy:read | Framework-aware Assurance projection (nis2 only today). |
POST /api/policy/control-register/nis2 | policy:author | Register/update controls from a Policy document. |
DELETE /api/policy/control-register/nis2/{controlId} | policy:author | Soft-archive an active control. |
POST /api/policy/control-register/nis2/snapshot | policy:operate | Publish a deterministic SoA snapshot. |
Read API
The source-owned NIS2 compatibility read API is available at GET /api/policy/control-register/nis2 with policy:read. Tenant identity comes from the envelope-bound tenant claim, and an optional tenantId query must match that resolved tenant.
The framework-aware Assurance projection is available at GET /api/policy/control-register/frameworks/{frameworkId} with the same auth, tenant guard, filters, and cursor semantics. NIS2 is the only enabled framework adapter today: frameworkId must resolve to nis2. That route returns assurance-control-register-v1 with frameworkId: nis2, packId: nis2, and generic areaOrDomain/externalRefs fields. Unknown framework ids fail closed with HTTP 404 and code UNKNOWN_FRAMEWORK.
Write API
A Policy-owned write API is available at POST /api/policy/control-register/nis2 with policy:author. The request body carries document (a Policy document carrying complianceMappings.controls[]), an optional format (yaml default, or json), and an optional correlationId. The handler binds the document through the standard Policy binder, then projects each normalized control into the register via RegisterOrUpdateAsync, emitting a control:registered or control:updated lifecycle event per control. The response (Nis2ControlRegisterAuthorResponse) returns schemaVersion, tenantId, sourceOfTruth (Policy), registered (count), and a controls[] summary of controlId, thematicArea, status, evidenceMode, responsibleRole, and mappingHash.
Archive API
A Policy-owned archive API is available at DELETE /api/policy/control-register/nis2/{controlId} with policy:author. It soft-archives an active control (status -> archived) via ArchiveAsync, emits a control:archived lifecycle event, and returns 404 (CONTROL_NOT_FOUND) when the control is unknown or already archived.
Snapshot publish API
A Policy-owned Statement-of-Applicability publish API is available at POST /api/policy/control-register/nis2/snapshot with policy:operate. It builds a deterministic nis2-control-register-v1 snapshot from the tenant’s active controls and persists it via PublishSnapshotAsync, emitting a control:snapshot_published lifecycle event and the EvidenceLocker regulatory artifact-ledger rows. The request body is optional and accepts stellaRelease (defaults to Policy), evidenceLockerBundleRef (defaults to evidence-locker://bundles/{contentHash}), soaExportHash (defaults to the content hash), and correlationId. The response (Nis2ControlRegisterPublishResponse) returns schemaVersion, tenantId, sourceOfTruth (Policy), snapshotId, stellaRelease, generatedAt, contentHash, soaExportHash, evidenceLockerBundleRef, controlCount, and lifecycleEvent (control:snapshot_published). Scope rationale: the publish path is gated on policy:operate (not policy:publish) so the Console auth-code session and clean password-grant flows mint it without the Authority attestation triple (policy_reason/policy_ticket/policy_digest), which fires only for policy:publish/policy:promote; the snapshot itself is the audit artifact and is projected to the EU regulatory audit ledger.
SoA completeness gate: the publish fails closed with HTTP 422 (SOA_INCOMPLETE) when any of the thirteen NIS2 thematic areas lacks an explicit applicability decision. An area is “decided” only when at least one active control covering it carries a non-empty metadata.applicability value (the assurance projection defaults a missing key to applicable, so the gate inspects raw metadata). The 422 body carries code SOA_INCOMPLETE, totalAreas (13), and missingAreas[] ({ thematicArea, thematicAreaName }). An empty active register fails with HTTP 422 SOA_EMPTY.
Query filters and review status
The two read routes share these optional query filters: thematicArea (1–13), evidenceMode (stella-native | tenant-supplied | mixed), reviewStatus (current | due-soon | overdue), responsibleRole, limit (default 100, max 500), cursor (opaque base64url offset cursor), asOfDate (yyyy-MM-dd, defaults to today, used to compute review status), and tenantId. Review status is derived per control by comparing nextReview against asOfDate: a control is overdue when nextReview < asOfDate, due-soon when nextReview falls within 30 days, otherwise current.
Error responses
Invalid input fails closed with RFC 7807 problem responses carrying a code extension: TENANT_REQUIRED (401, missing tenant claim), TENANT_MISMATCH (403, tenantId query disagrees with the claim), INVALID_THEMATIC_AREA, INVALID_EVIDENCE_MODE, INVALID_REVIEW_STATUS, INVALID_AS_OF_DATE, INVALID_CURSOR (400), and for the framework route INVALID_FRAMEWORK_ID (400) and UNKNOWN_FRAMEWORK (404). The write route additionally returns DOCUMENT_REQUIRED, DOCUMENT_INVALID, and NO_CONTROL_MAPPINGS (400).
Consumer boundary (Graph/Web)
Graph/Web declare the consumer boundary (StellaOps.Graph.Api Contracts/EuRuntimeContracts.cs, INis2ControlRegisterClient) as a slimmer projection than the Policy read response:
- request:
Nis2ControlRegisterQueryRequest(tenantId, optionalthematicArea,cursor,limit) - response:
Nis2ControlRegisterResponse(schemaVersion,items[],cursor,sourceOfTruth=Policy) - per-item fields (
Nis2ControlRegisterItem): control id, thematic area, owner role, review status, evidence refs, and tenant override refs
The GET /api/policy/control-register/nis2 route returns a richer Policy-side response (the Nis2ControlRegisterResponse record internal to Nis2ControlRegisterEndpoints, distinct from the slimmer Graph-side type of the same name) that carries schemaVersion, tenantId, generatedAt, asOfDate, source (always live), sourceOfTruth (always Policy), thematicAreas[] (exactly thirteen area summaries, each with thematicArea, thematicAreaName, controlCount, reviewStatus, and ownerRoles[]), detailed controls[], a compact items[] projection, cursor, total, blockers[], and a nullable snapshotHash. Each detailed control includes both responsibleRole and an ownerRole alias (currently the same value), art21Refs, policyDocRef, implementingControls[], evidencePointers[], derived evidenceRefs[], lastReviewed, nextReview, reviewStatus, evidenceMode, tenantOverrideRefs[] (extracted from metadata keys such as tenantOverrideRef/tenantOverrideRefs), auditEventIds[] (sequence-ordered lifecycle event ids), mappingHash, and metadata. The Graph-side boundary is documented in eu-runtime-api-contracts-v1.md.
Snapshot Contract
Snapshots use Nis2ControlRegisterSnapshot with:
schemaVersiontenantIdstellaReleasegeneratedAtcontrols[]
Controls are sorted by thematicArea, then controlId; nested arrays are sorted deterministically before canonical JSON hashing. Snapshot hashes use the sha256: prefix. For Assurance consumers, Policy can project the same NIS2 snapshot into assurance-control-register-v1 without changing the stored NIS2 snapshot JSON. The projected shape includes frameworkId and packId. Published snapshots create EvidenceLocker regulatory artifact-ledger rows for nis2-control-register-snapshot and the pinned nis2-statement-of-applicability hash. Ledger source refs point to the Policy snapshot/event refs and the EvidenceLocker bundle ref, not a duplicate copy of the snapshot JSON.
Offline Fixtures
Focused executable fixtures live in:
src/Policy/__Tests/StellaOps.Policy.Tests/Compliance/ComplianceMappingsBinderTests.cssrc/Policy/__Tests/StellaOps.Policy.Persistence.Tests/ControlRegisterRepositoryTests.cs
These tests avoid network dependencies except for local PostgreSQL Testcontainers used by Policy persistence tests.
