Compliance Remediation Plans
The evaluation of a compliance pack tells a tenant which items fail and which keys they name. A remediation plan turns those findings into work: for each failing item, the operation that fixes it, who owns it, by when, and whether it is done. The plan executes only operations the platform already has, only after a person previews and confirms them, and under that person's own permissions.
There is one plan per tenant and entitled pack. It is derived live from the same evaluation the evaluation screen and the evidence package show, so a finding is never stored twice and a plan is never out of date with the evaluation.
What the plan never does
- It never destroys a key. No step marks a key or a material version for destruction, destroys it, archives it, deletes it or suspends it. Destruction stays a separate key-lifecycle operation, outside the plan, because destroying a version can make customer data unreadable.
- It never re-encrypts or re-signs your data. ANKASecure does not hold customer data. After a rotation, data protected under the previous version stays readable; your applications move it to the new version with
reencryptDataandresignData, when and if they choose to. - It never runs on its own. Nothing executes without a preview and an explicit confirmation, and there are no automatic runs, reminders or notifications.
- It never changes the score by itself. A risk acceptance or an attestation is shown beside the score and never inside it. An action is resolved only when the evaluation stops failing the item.
The plan
The plan is computed from one live evaluation of the pack, with the same entitlement, key population and reference day as getCompliancePackEvaluation. It lists:
- Actions: one per item whose result is
FAIL, orWARNINGwith reasonBEFORE_EFFECTIVE_DAY, with the keys that item names. TheALGORITHM_CALENDARsummary control has no action, because each of its calendar entries is an action of its own. APASSitem has no entry. - Attestable items: one per item that is
NOT_EVALUATEDwith reasonCUSTOMER_ATTESTEDorUNVERIFIED. The platform cannot measure these; the tenant records attestations for them. - Resolved actions: an action that has stored workflow state and whose item no longer fails stays listed as
RESOLVED.
Each action has a stable action key: the item's controlId, or, for a calendar entry, calendar: followed by the lowercase hexadecimal SHA-256 of the entry's canonical content. A calendar action keeps its key, and so its owner, target date and history, when only the pack version changes.
The plan also serves:
| Member | Meaning |
|---|---|
scoreToday | The evaluation's score, unchanged |
projectedScore | The score the evaluation would give if every proposal of every AUTOMATIC and GUIDED action were applied. DECISION actions and actions under a risk acceptance are left as they are, and a NEUTRAL pack stays NEUTRAL |
scoreDelta | Per action, what its proposals add to the score |
steps and paths | Per action, every step offered, and the ways a key is moved: each path is a sequence of steps taken in order, and the paths are alternatives (a key takes one). A key-population action serves [ROTATE_KEY] and [CREATE_SUCCESSOR_KEY, REVOKE_SUPERSEDED_KEY]; a cryptoperiod action [SET_KEY_EXPIRY] and [ROTATE_KEY]. APPLY_ALGORITHM_TEMPLATE changes the tenant's policy, not a key, and is in no path. Which path applies to a key is answered by previewing the path's first step |
deadline | Per calendar action, the entry's effective day |
nextDeadline | The earliest effective day on or after the reference day among the WARNING and FAIL calendar actions, or null |
| Progress | Resolved actions over listed actions |
Every response repeats the evaluation's statement: a satisfied AUTOMATED item does not mean the regime is satisfied. A plan derived this way carries licensing: LICENSED.
The read-only plan of a pack that left the licence
A pack the licence no longer entitles (a locked pack) keeps its plan, read-only, with licensing: NOT_LICENSED. Nothing is evaluated and no key population is read, so the plan holds only what was recorded:
- each action that has stored workflow state, in pack order, with its statement, kind and closing rule from the pack definition, and its stored
owner,targetDate,riskAcceptance,migrationDeclarationandversion; - its
status: the stored one, orRISK_ACCEPTEDwhile an acceptance is in force, and neverRESOLVED, which only an evaluation decides.
scoreStatus, scoreToday, projectedScoreStatus, projectedScore, progress and nextDeadline are absent; an action carries no result, reason, kids, deadline or scoreDelta, and its steps is empty; attestableItems is empty. The executions and the attestations stay listed by their own reads, which carry the same licensing member. Updating an action, previewing or executing a step and recording an attestation are refused 403 Compliance Pack Not Licensed, with nothing written, audited or sent to the Core API; an execution already recorded under the same Idempotency-Key is still answered from storage (Retrying safely). The plan's licensing is LICENSED or NOT_LICENSED, never UNRESOLVED. If the licence cannot be read, the plan answers 502, because its shape depends on the verdict; the executions and attestations still answer, with licensing: UNRESOLVED.
The three kinds of action
| Kind | Meaning |
|---|---|
AUTOMATIC | The platform knows the fix and proposes it in full; a person confirms it |
GUIDED | The platform proposes a target and the person may choose another from the admissible set |
DECISION | Only the person can decide; the platform offers a step, or guidance only |
The catalogue
Each control type maps to one kind, a fixed set of steps and a closing rule. Every step is an existing Admin API operation, called with the caller's own identity:
| Step | Existing operation |
|---|---|
SET_KEY_EXPIRY | patchKeyMetadata (expiresAt, and softLimitExpiration when it must move) |
ROTATE_KEY | rotateKey, always with a target algorithm and an explicit expiresAt |
CREATE_SUCCESSOR_KEY | createKey |
REVOKE_SUPERSEDED_KEY | revokeKey, always with cause ALGORITHM_DEPRECATED |
APPLY_ALGORITHM_TEMPLATE | the service operation setAlgorithmPolicy calls, with templateName |
| Control type | Kind | Steps | Closes when |
|---|---|---|---|
CRYPTOPERIOD | AUTOMATIC | SET_KEY_EXPIRY, or ROTATE_KEY with the same algorithm when the key is past its cryptoperiod (below) | Every live key is within its maximum |
GOVERNANCE (CSWP39.POLICY-PROFILE) | AUTOMATIC | APPLY_ALGORITHM_TEMPLATE, with any of the platform's explicit algorithm policy templates (the control names none) | The tenant declares a template |
ALGORITHM_ADMISSIBILITY, MIN_SECURITY_LEVEL, HYBRID_USE, a calendar entry | GUIDED | ROTATE_KEY (same kid); where core-api refuses that rotation, CREATE_SUCCESSOR_KEY then REVOKE_SUPERSEDED_KEY (new kid). Optionally first, APPLY_ALGORITHM_TEMPLATE when the control names a template the tenant has not declared | The evaluation stops naming the key: at the rotation (same kid) or at the revocation (new kid) |
KEY_CUSTODY | DECISION | The person chooses ROTATE_KEY to an admissible stateless signature algorithm, or follows the guidance to replace the key with a public-only import | The evaluation stops naming the key |
EVIDENCE_INTEGRITY (CSWP39.EVIDENCE-INTEGRITY.PLATFORM) | DECISION | None: guidance to open a security incident | Never by the plan |
KEK_CRYPTOPERIOD (NIST.CRYPTOPERIOD.KEK) | DECISION | None: guidance naming who acts (below) | The tenant key-encryption key is within its cryptoperiod |
APPLY_ALGORITHM_TEMPLATE passes the same tenant-administrator edit gate as setAlgorithmPolicy: on a tenant whose allowsTenantOverride is false, the preview refuses it with TENANT_OVERRIDE_NOT_ALLOWED, naming the platform administrator as the one who can change it, and an execution records the key REFUSED with nothing written. Moving a tenant to a hardware backend is not a step: a tenant's backend changes only through a key custody move, which its operator schedules, and an HSM would not satisfy the stateful hash-based custody requirement anyway.
Rotation or a new kid
A crypto-agile migration rotates the same kid. ROTATE_KEY adds a new material version under the same Stable KID with the target algorithm. The previous version becomes RETIRED: it no longer encrypts or signs, and it still decrypts and verifies what it protected. Applications keep the same kid, their capability grants are unchanged, and new operations use the new version. See Key Rotation*.
A new kid only where core-api refuses the rotation. Rotation cannot turn a composite key into a simple one (reason code structure-composite-to-simple). For such a key the plan proposes CREATE_SUCCESSOR_KEY, named <oldKid>-<algorithm token> by default (editable, within the kid length bound), then REVOKE_SUPERSEDED_KEY on the old kid with cause ALGORITHM_DEPRECATED. That cause keeps historical decrypt and verify on the old kid while it refuses new encryption and signing. A CREATE_SUCCESSOR_KEY preview refuses a key core-api admits rotating under the same kid with the code ROTATION_ADMISSIBLE, and an execution naming one is refused 422 Compliance Remediation Rotation Admissible, with nothing reserved. The revocation is admitted only for a kid whose successor an execution of the plan created (a DONE CREATE_SUCCESSOR_KEY outcome; otherwise the preview refuses it NO_SUCCESSOR_CREATED), and only while, once revoked for ALGORITHM_DEPRECATED, every non-destroyed material version of the kid still admits DECRYPT and VERIFY (and the re-encryption and re-signature sources): the tenant's effective lifecycle policy AND-composed with the platform's lifecycle cause rule, per version, exactly as core-api admits a read. Otherwise the preview refuses the kid REVOKED_MATERIAL_UNREADABLE. A policy that denies both on a revoked key is lifted by the tenant administrator; a version already revoked for SECURITY_COMPROMISE keeps that cause through the revocation and stays unreadable under any policy, so a kid holding one is never revoked by the plan. The old material therefore always stays readable. No capability grant is copied to the successor: authorizing an application on the new kid stays an explicit, separate act, and the preview says so.
Targets
A key's candidate targets are the algorithms both rules accept: for the same kid, the rotation admissibility check admits it without a consent; for a new kid, the tenant's admission rule admits it for the key's purpose. In both cases, evaluating the pack with the key on that algorithm must stop the item failing. The proposal is the candidate with the highest security level, catalogue order breaking ties, and the caller may choose any other candidate. An empty candidate set is a refusal shown in the preview, NO_ADMISSIBLE_TARGET.
The plan asks core-api nothing: it projects each key on the target the tenant's admission rule proposes for the key's purpose, so reading a plan costs no service call per failing key. Whether core-api admits the move under the same kid is stated by the preview, for the keys it lists.
Preview and confirmation
Preview (previewComplianceRemediationAction) writes nothing: no database row, no HSM call, no Redis, Kafka or audit write. It lists the action's keys one page at a time, in the evaluation's order: page is zero-based and size is 1 to 100 (default 25), 100 being the most keys one execution names; the response states keyCount and totalPages. Only the listed keys are asked about, so a ROTATE_KEY preview makes one core-api read per listed key. For one step of one action it returns, per listed key:
- the current algorithm and material version, and the outcome the step would produce;
- the proposed target, the candidate set and the proposed expiry;
- a refusal and its reason, if any: the rotation
reasonCode,TENANT_OVERRIDE_NOT_ALLOWED,NO_ADMISSIBLE_TARGET,NO_CRYPTOPERIOD_MAXIMUM,EXPIRY_BELOW_FLOOR(aSET_KEY_EXPIRYpreview of a key past its cryptoperiod, or within 90 days of its end, whose step isROTATE_KEYinstead),NO_SUCCESSOR_CREATEDorREVOKED_MATERIAL_UNREADABLE(aREVOKE_SUPERSEDED_KEYpreview, see above),ROTATION_ADMISSIBLE(aCREATE_SUCCESSOR_KEYpreview of a key the rotation path moves); - the conflicts the listed keys' targets would cause, and the projected score change of those targets;
requiredScopes(the remediation write scope and the step's own) andcallerMayExecute: whether this caller holds every required scope and passes the tenant-owned boundary (falsefor a platform administrator addressing the tenant directly, who executes through an impersonation session).
For APPLY_ALGORITHM_TEMPLATE the preview lists no key: it shows the tenant's declared template, the proposed one, the candidate templates and any refusal.
It also returns an opaque basis: a SHA-256 over the reference day, the pack version, the action key, the step, and the kid, primary material version, algorithm and expiry of every key of the action, not only the listed page. An execution of any page echoes it while the state is unchanged.
Confirmation (executeComplianceRemediationAction) carries the step, the preview's basis, the conflicts the person acknowledged, and 1 to 100 keys. Each key may name a target (one of the preview's candidates for that kid; the proposal when omitted), a successorKid (any kid other than the superseded one; the proposal when omitted) and an expiresAt (the preview's proposedExpiresAt for that kid when omitted; when given, exactly that value). A proposed expiry does not depend on the instant of the request: it is anchored to 00:00 UTC of the reference day (see Maximum cryptoperiod), so it is the same value all day and an execution can echo it. The template step names no key and may carry a templateName from the preview's candidate templates. The basis is checked before the values: if anything the basis covers has changed since the preview, including the reference day (a preview served on another day), the request is refused with 409 Compliance Remediation Basis Changed, and the person previews again. Under a matching basis, a value outside the preview's is refused 400, and so is a REVOKE_SUPERSEDED_KEY key the preview refuses. In the console, the confirm button is enabled only after a preview, and each conflict needs its own checkbox.
Retrying safely
The confirmation requires an Idempotency-Key header (a UUID), as the IETF Idempotency-Key HTTP header draft (draft 07, §2.7) describes. The key is looked up before the basis is checked and before the licence is read, so a retry is answered from what was recorded, even after the pack left the licence (a replay writes, emits and calls nothing):
The same Idempotency-Key… | Answer |
|---|---|
| …with the same request, after the run completed | 200 with the stored execution; nothing is called again |
| …with the same request, while the first run is still in progress | 409 Idempotency Key In Progress; nothing is called |
| …with the same request, after a run that stopped before finishing | 200 with the outcomes recorded so far and state: INTERRUPTED |
| …with a different request | 422 Idempotency Key Reused |
| absent | 400 |
Before the first step the platform records an immutable execution header with a deadline (the number of keys plus one, times the per-step deadline). A run whose header has no completion record after that deadline is INTERRUPTED, so a crashed run is never repeated by a retry.
One run per action at a time. Whatever the Idempotency-Key, an action has at most one execution running. Its follow-up row is the serialization point: the first execution of an action stores that row (OPEN) when the action has none, and every execution locks it before recording its header. While another execution of the same action has no completion record and is before its deadline, a confirmation under a different Idempotency-Key is refused 409 Compliance Remediation Execution In Progress, with nothing reserved and nothing called, so no key is stepped by two runs of one action at once. The guarantee is per action, not per key: runs of different actions that name the same key, in the same pack or in another, are not serialized against each other, and a key whose state another run moved before its turn is SKIPPED_ALREADY_DONE. The wait for the follow-up row's lock is bounded to 5 seconds; a wait that expires is refused 503 Serialised Operation Timed Out with Retry-After: 5, nothing reserved and nothing called. It is never a 409: whether another execution was running was not evaluated. A new Idempotency-Key with the old request after a successful run does not run again: the run changed what the confirmation was built on, so it is refused 409 basis-changed, or 400 or 404 when the key or the action has left the plan.
How a run proceeds
Keys are processed one at a time, and one key's failure does not stop the others. Each key's operation commits with its own audit row, then its outcome is recorded:
| Outcome | Meaning |
|---|---|
DONE | The operation succeeded |
SKIPPED_ALREADY_DONE | The key already satisfied the step when its turn came, for example rotated by another session; nothing was changed |
REFUSED | The operation was refused; the outcome carries the problem type |
FAILED | The operation failed; the outcome carries the problem type and names the failing service by role, never its text |
UNKNOWN | A rotation was sent to the key service and no answer arrived, so it may have been applied; the outcome carries the problem type rotation-outcome-unknown and names the key service by role. Only a rotation step records it |
A rotation step rotates through the same path as a manual rotation, recorded with cause MANUAL and the confirming user as initiator, and names the material version the plan read as the version it advances from. The cause stays MANUAL (a person confirmed the plan; the cause is a signed, persisted value every reader of the previous release must recognise), and the admin rotation row of a remediation rotation carries remediation: {executionId, packId, actionKey}, naming the execution it belongs to. Nothing new is sent to the key service. The key service applies the rotation only if the key is still at that version, so a key another session rotated between the step's own check and the rotation is refused there and recorded SKIPPED_ALREADY_DONE too. An UNKNOWN key is never retried inside the run: a later execution of the action re-evaluates it, and a key the unknown rotation did advance is then SKIPPED_ALREADY_DONE.
A completion record closes the run as COMPLETED, or as INTERRUPTED when its deadline stopped it. The execution history is append-only and listed newest first by listComplianceRemediationExecutions.
Each DONE outcome of ROTATE_KEY, SET_KEY_EXPIRY or CREATE_SUCCESSOR_KEY states the expiry the step wrote (expiresAt); no other outcome carries one. The execution names its requester as the audit trail does: requestedByDisplayName (a user's login, an application's name) and requestedByPlane (TENANT, or PLATFORM for a platform operator acting in a support session, who is named by plane only and never by name or email). requestedBy keeps the caller's audit username as recorded; a support-session token carries no login, so for such a run it holds the subject of the tenant administrator the session mirrors and does not name the person. Executions recorded before 3.2.0 carry neither member nor expiresAt.
Who may do what
| Act | Scope | Roles holding it |
|---|---|---|
| Read the plan, a preview, the executions and the attestations | admin.tenant.analytics.read | Every tenant role that reads the evaluation, including TENANT_AUDITOR |
| Change an action's owner, target date or status; execute a step; record an attestation | admin.tenant.compliance.remediation.write | TENANT_ADMINISTRATOR, TENANT_KEY_MANAGER |
| Set or withdraw a risk acceptance | admin.tenant.compliance.risk.accept, in addition | TENANT_ADMINISTRATOR only |
The plan never confers more than the caller holds. Executing a step also requires that step's own scope: admin.keys.patch for SET_KEY_EXPIRY, admin.keys.rotate for ROTATE_KEY, admin.keys.orchestrate for CREATE_SUCCESSOR_KEY, admin.keys.revoke for REVOKE_SUPERSEDED_KEY and admin.policy.tenant.algorithm.write for APPLY_ALGORITHM_TEMPLATE. Accepting a risk is a governance act, which is why an operator who may execute steps may not accept a risk.
Every write is tenant-owned: a platform administrator signed in directly to the platform tenant is refused 403, and reaches a tenant's plan only through an impersonation session the tenant consented to.
Tracking an action
An action's workflow state is stored per tenant, pack and action key:
owner: free text, 1 to 128 characters, not blank;clearOwner: trueclears it.targetDate: not before the reference day;clearTargetDate: trueclears it.status: the person may setOPENorIN_PROGRESS.riskAcceptance: a reason (1 to 1,000 characters) and anuntildate after the reference day and at most 365 days after it;withdrawRiskAcceptance: truewithdraws it. Setting and withdrawing both requireadmin.tenant.compliance.risk.accept, even when there is nothing to withdraw.migrationDeclaredComplete:trueonly; see the migration tracker.
A member left out, or sent as null, is unchanged: nothing is cleared by a null, because a client that drops nulls could never send one. Clearing is the explicit boolean member, and a value sent together with its clearing member is refused 400. The free-text members (owner, the risk reason) refuse any control character: C0 (U+0000 to U+001F, tab, CR and LF included), DEL (U+007F) and C1 (U+0080 to U+009F, NEL U+0085 and the one-character CSI U+009B included), and the line and paragraph separators U+2028 and U+2029. These values are rendered into the evidence package's PDF report, the console and the tenant's SIEM, where each of those characters forges a line or starts a terminal sequence, so they are refused, never stripped. A value outside these bounds is refused 400 Validation. Each write carries the action's version; a stale value is refused 409 Concurrent Modification.
The status shown is derived on every read, and a read writes nothing:
RESOLVEDwhen the live evaluation no longer fails the item;- otherwise
RISK_ACCEPTEDwhile an acceptance is in force (itsuntilis after the reference day); - otherwise the stored state,
IN_PROGRESSorOPEN(the default).
An item that fails again after being resolved reopens to its stored state. The first execution of an action that has no follow-up row stores one, OPEN. An execution with a DONE outcome stores IN_PROGRESS on an action whose stored state is still OPEN; a state a person set is kept. A follow-up row is never deleted: the service's database role holds no DELETE or TRUNCATE on it. A risk acceptance changes neither the evaluation's result nor its score.
The migration tracker
After a rotation, the previous version still decrypts and verifies; after a new-kid migration, the old kid does. Whether applications still use it is something only they can stop. For each DONE outcome the action shows what was superseded, the (kid, material version) or the old kid → successor kid, and the console shows its use over the last 30 days, read from the Audit API's getKeyMaterialUsage*.
The tracker is informational. It changes no status and no score, and it never proposes retiring or destroying anything. Re-encrypting or re-signing the data is the application's call, through reencryptData and resignData. When the tenant is satisfied that the migration is complete, it may record migrationDeclaredComplete: true, which stores who declared it and when; the declaration is refused 422 Unprocessable Entity unless an execution of the action recorded a DONE ROTATE_KEY or REVOKE_SUPERSEDED_KEY outcome. A declaration is never withdrawn: false is refused 400.
What counts as a use of a version. The Audit API counts, per day, each successful operation once per distinct (kid, material version) among its keys whose role is a use: DECRYPTING_RECIPIENT, ENCRYPTING_RECIPIENT, SIGNER or VERIFIED_SIGNER. A recipient a re-encryption kept, a signer a re-signing kept, and the key a rotation acted on are not a use of that version and count nothing. A successful key-use operation (ENCRYPT, DECRYPT, SIGN, VERIFY, REENCRYPT, RESIGN, SIGN_ENCRYPT, DECRYPT_VERIFY or MIGRATION_CONVERT_PKCS7) that names the kid but records no key list has no known version: it is served apart as unattributedCount and never folded into a version. Key management operations that name the kid (creation, metadata reads, rotation, revocation) use no material and are never counted. Refused and failed operations count nothing.
The tenant key-encryption key
The NIST.CRYPTOPERIOD.KEK action is opened by a FAIL, and by a WARNING with reason KEK_ROTATION_DUE. It offers no step: the key-encryption key is not one of the tenant's keys, and no operation of the tenant's plan changes it. Executing or previewing a step for it is refused with 422 compliance-remediation-step-not-applicable, and nothing is written. Who acts depends on who rotates the key:
KEK_CRYPTOPERIOD_EXCEEDEDorKEK_ROTATION_DUEon a key-encryption key the platform rotates (on a PKCS#11 token): the platform operator rotates the tenant key-encryption key now, or shortens the carrier's rotation period.KEK_PROVIDER_ROTATION_OFF(an ANKA-managed Cloud KMS tier): the platform operator enables the provider's rotation, for example by giving the tier's credential the rotation permission; the platform alertkek.rotation.missingis raised meanwhile.KEK_CUSTOMER_ROTATION_OFF, orKEK_CRYPTOPERIOD_EXCEEDEDon a tenant's own key: the customer enables rotation at its provider, with a period of 730 days or less.
The action closes when the item passes.
Maximum cryptoperiod
The two CRYPTOPERIOD controls (NIST.CRYPTOPERIOD in NIST_PQC_TRANSITION, at pack version 2026-10-19, and CSWP39.ALGORITHM-TRANSITIONS in NIST_CSWP_39, at 2026-10-07) measure each live key against a maximum originator-usage period per key class, served as maximumCryptoperiods on the control. The figures are the upper end of the ranges in NIST SP 800-57 Part 1 Rev. 5 (May 2020), §5.3.6, Table 1 "Suggested cryptoperiods for key types", printed pages 45-46:
| Key class | Maximum | Table 1 |
|---|---|---|
SYMMETRIC | 24 months | Symmetric authentication, data-encryption and key-wrapping keys (rows 3, 6, 7): originator-usage period < 2 years |
ASYMMETRIC_SIGNING | 36 months | Private signature key (row 1): 1 to 3 years |
ASYMMETRIC_KEY_ESTABLISHMENT | 24 months | Private key-transport key (row 10): < 2 years; public key-transport and static key-agreement keys (rows 11, 13, 14): 1 to 2 years |
PUBLIC_VERIFICATION | none | Public signature-verification key (row 2): "several years (depends on key size)", no fixed figure |
The class is decided on the key's primary material: SYMMETRIC when its kty is oct; otherwise ASYMMETRIC_SIGNING when its keyOps contain sign; PUBLIC_VERIFICATION when they contain only verify; otherwise ASYMMETRIC_KEY_ESTABLISHMENT.
The rule. The control fails naming every live key whose expiry date is absent, or is later than the creation of its primary material plus its class maximum (calendar months, UTC). Otherwise it passes. A key created on 2026-01-15 for ML-DSA-87 signing passes with an expiry of 2029-01-15 and fails with 2029-02-15. A key created or imported without an expiry receives the creation of its primary material plus its class maximum (a PUBLIC_VERIFICATION key: the ASYMMETRIC_SIGNING maximum of its pair), so it passes; an expiry stated on create or import is accepted as stated, and fails when it is later. An imported key that carries a certificate never receives an expiry after the certificate's notAfter; a trust anchor keeps its notAfter.
The proposal. For each failing key the plan proposes the expiry E = creation of the primary material + maximum:
- When
Eis at least the reference day + 90 days, the step isSET_KEY_EXPIRYtoE. When the key'ssoftLimitExpirationis at or afterE, it moves too, keeping the key's current warning lead beforeEand never earlier than the reference day; the preview shows both values. This step never targets a key whose expiry is already reached, which the key-metadata patch refuses409key-protection-limit-reached(a reached expiry is final): the control names a key only when its expiry is absent or later thanE, andEis at least 90 days ahead, so the key's expiry is absent or still ahead. - Otherwise the key is past its cryptoperiod, and the step is
ROTATE_KEYwith the same algorithm andexpiresAt= 00:00 UTC of the reference day + maximum. A key whose expiry arrives stops encrypting and signing, so the plan gives a fresh cryptoperiod rather than leaving applications less than 90 days. ASET_KEY_EXPIRYpreview of such a key refuses it withEXPIRY_BELOW_FLOOR. - A
PUBLIC_VERIFICATIONkey without an expiry still fails, but has no maximum to propose from: the preview refuses it withNO_CRYPTOPERIOD_MAXIMUM, and the person sets an expiry withpatchKeyMetadata.
Every rotation the plan requests states expiresAt, because a rotation that omits it inherits the current validity period, which for a key whose expiry exceeds its maximum is the same excessive period. A rotation or a successor for another control takes 00:00 UTC of the reference day + the maximum of the target's key class; when that class has no maximum it keeps the key's current expiry, and a key with neither is refused NO_CRYPTOPERIOD_MAXIMUM.
Conflicts between packs
A target that satisfies one pack can fail another. For each chosen target, the platform re-evaluates every entitled pack with that key substituted, and once more with every chosen target substituted together, so an item that fails only in combination is found too. Each item that would newly fail is a conflict {packId, itemKey, statement, kids}, naming the keys it is attributed to.
For example, with NSA_CNSA_2_0 and BSI_TR_02102_1 both entitled, moving the composite vault-kem-hybrid to stand-alone ML-KEM-1024 satisfies CNSA 2.0 and newly fails BSI.HYBRID.KEM. The platform never picks a side, because each pack is an obligation of the customer's, not the platform's. An execution whose conflicts are not all acknowledged is refused 409 Compliance Remediation Conflict Unacknowledged, listing them.
Attestations
For the items the platform cannot measure, the tenant records attestations (createComplianceAttestation):
| Member | Rule |
|---|---|
itemKey | An attestable item of the pack; any other item is refused 422 Compliance Attestation Not Applicable |
statement | 1 to 2,000 characters, no control character or line separator (as for owner) |
attestedBy | 1 to 128 characters, no control character or line separator |
assessedOn | Not after the reference day |
expiresOn | After assessedOn |
evidence | 1 to 10 entries {uri, sha256}: an https URI of at most 2,048 characters with no control character or line separator, and the SHA-256 of the evidence as 64 lowercase hexadecimal characters |
The platform never fetches an evidence URI; it stores the link and its digest so that an auditor can retrieve the document and check it. Attestations are append-only: the newest one per item is in force until its expiresOn, and earlier ones are listed as superseded. An attestation is never scored: it changes no evaluation result and no score, and is shown beside them.
In the evidence package
The evidence package report carries a Remediation plan section: each action with its kind, status, owner, target date, risk acceptance (reason and until), its last execution with its outcome counts, and any migration declaration; and every attestation in force with its item, statement, attester, expiry, and each evidence uri and sha256. The section is part of report.pdf, so the signed report hash covers it, and the package still holds its six files.
Audit
Each underlying operation writes its own audit row as it always does. In addition, signed admin audit rows record the plan's own acts:
| Act | Event type | Entity |
|---|---|---|
| Each execution | admin.complianceremediation.create | ComplianceRemediation, the execution id; payload {packId, packVersion, actionKey, step, basis, acknowledgedConflicts, outcomes[{kid, outcome, fromVersion, toVersion, successorKid, expiresAt, problemType}]} (an outcome also carries relayedProblemType when the upstream's problem type exceeds 255 characters; its problemType then names upstream-rejected-request), with the same correlation id as the operations of the run |
| Each workflow change | admin.complianceremediation.update | ComplianceRemediation, with the changed members |
| Each attestation | admin.complianceattestation.create | ComplianceAttestation; payload {packId, itemKey, expiresOn, evidenceCount, contentSha256}, where contentSha256 is the SHA-256 of the statement, the attester, the assessment and expiry days and every evidence uri and sha256, so the signature covers the attested content and not only its count |
A refused request writes none of these rows. The admin rotation row each ROTATE_KEY step writes keeps cause MANUAL and carries remediation: {executionId, packId, actionKey} in its changes; the row of the other steps is joined to the execution by the shared correlation id. On a run confirmed in a support session every row names the operator in its impersonator fields and carries no actor_display_name, by design: the session's token names no principal of its own.
Operations
| Method | Endpoint (/api/v3/admin/tenants/{tenantId}/compliance-remediation/{packId} …) | operationId |
|---|---|---|
| GET | the base path itself (the plan) | getComplianceRemediationPlan |
| PATCH | /actions/{actionKey} | updateComplianceRemediationAction |
| GET | /actions/{actionKey}/preview?step=&page=&size= | previewComplianceRemediationAction |
| POST | /actions/{actionKey}/executions | executeComplianceRemediationAction |
| GET | /executions | listComplianceRemediationExecutions |
| POST | /attestations | createComplianceAttestation |
| GET | /attestations | listComplianceAttestations |
Status codes, scopes and refusals: Admin API endpoints*.
Related
- Compliance Packs — the packs and their evaluation
- Compliance Evidence — the signed evidence and the package
- Key Rotation & Revocation* — versions,
RETIREDand revocation causes