370 lines
11 KiB
TypeScript
370 lines
11 KiB
TypeScript
/**
|
|
* Mosaic Native Kanban — frozen recovery-posture contract v1.
|
|
* Recovery posture is configurable; SOT, write-health, Coordinator authority,
|
|
* and gate semantics are not fields and cannot be overridden.
|
|
*/
|
|
|
|
export const RECOVERY_POSTURE_CONTRACT_VERSION = '1.0.0' as const;
|
|
export const recoveryTiers = ['lite', 'standard', 'high-assurance'] as const;
|
|
export type RecoveryTier = (typeof recoveryTiers)[number];
|
|
|
|
export interface OffClusterStorageV1 {
|
|
required: true;
|
|
encrypted: true;
|
|
separateFailureDomain: true;
|
|
minimumCopies: number;
|
|
storageClass: 'encrypted-object-storage' | 'encrypted-backup-target';
|
|
}
|
|
|
|
export interface RecoveryPostureV1 {
|
|
contractVersion: typeof RECOVERY_POSTURE_CONTRACT_VERSION;
|
|
tier: RecoveryTier;
|
|
targetRpoMinutes: number;
|
|
targetRtoMinutes: number;
|
|
baseBackupIntervalHours: number;
|
|
/** null means WAL archival/PITR is disabled. */
|
|
walArchiveIntervalMinutes: number | null;
|
|
/** 0 means PITR is disabled. */
|
|
pitrRetentionDays: number;
|
|
restoreTestIntervalDays: number;
|
|
breakGlassDrillIntervalDays: number;
|
|
offClusterStorage: OffClusterStorageV1;
|
|
}
|
|
|
|
export const recoveryPostureDefaults: Readonly<Record<RecoveryTier, RecoveryPostureV1>> = {
|
|
lite: {
|
|
contractVersion: RECOVERY_POSTURE_CONTRACT_VERSION,
|
|
tier: 'lite',
|
|
targetRpoMinutes: 24 * 60,
|
|
targetRtoMinutes: 24 * 60,
|
|
baseBackupIntervalHours: 24,
|
|
walArchiveIntervalMinutes: null,
|
|
pitrRetentionDays: 0,
|
|
restoreTestIntervalDays: 90,
|
|
breakGlassDrillIntervalDays: 365,
|
|
offClusterStorage: {
|
|
required: true,
|
|
encrypted: true,
|
|
separateFailureDomain: true,
|
|
minimumCopies: 1,
|
|
storageClass: 'encrypted-backup-target',
|
|
},
|
|
},
|
|
standard: {
|
|
contractVersion: RECOVERY_POSTURE_CONTRACT_VERSION,
|
|
tier: 'standard',
|
|
targetRpoMinutes: 60,
|
|
targetRtoMinutes: 8 * 60,
|
|
baseBackupIntervalHours: 24,
|
|
walArchiveIntervalMinutes: 15,
|
|
pitrRetentionDays: 14,
|
|
restoreTestIntervalDays: 90,
|
|
breakGlassDrillIntervalDays: 180,
|
|
offClusterStorage: {
|
|
required: true,
|
|
encrypted: true,
|
|
separateFailureDomain: true,
|
|
minimumCopies: 1,
|
|
storageClass: 'encrypted-object-storage',
|
|
},
|
|
},
|
|
'high-assurance': {
|
|
contractVersion: RECOVERY_POSTURE_CONTRACT_VERSION,
|
|
tier: 'high-assurance',
|
|
targetRpoMinutes: 15,
|
|
targetRtoMinutes: 4 * 60,
|
|
baseBackupIntervalHours: 24,
|
|
walArchiveIntervalMinutes: 5,
|
|
pitrRetentionDays: 35,
|
|
restoreTestIntervalDays: 30,
|
|
breakGlassDrillIntervalDays: 90,
|
|
offClusterStorage: {
|
|
required: true,
|
|
encrypted: true,
|
|
separateFailureDomain: true,
|
|
minimumCopies: 1,
|
|
storageClass: 'encrypted-object-storage',
|
|
},
|
|
},
|
|
};
|
|
|
|
/** Shape schema. Normative cross-field semantics are enforced by validateRecoveryPostureV1. */
|
|
export const recoveryPostureJsonSchemaV1 = {
|
|
$id: 'https://mosaicstack.dev/contracts/recovery-posture.v1.schema.json',
|
|
$schema: 'https://json-schema.org/draft/2020-12/schema',
|
|
type: 'object',
|
|
additionalProperties: false,
|
|
required: [
|
|
'contractVersion',
|
|
'tier',
|
|
'targetRpoMinutes',
|
|
'targetRtoMinutes',
|
|
'baseBackupIntervalHours',
|
|
'walArchiveIntervalMinutes',
|
|
'pitrRetentionDays',
|
|
'restoreTestIntervalDays',
|
|
'breakGlassDrillIntervalDays',
|
|
'offClusterStorage',
|
|
],
|
|
properties: {
|
|
contractVersion: { const: RECOVERY_POSTURE_CONTRACT_VERSION },
|
|
tier: { enum: recoveryTiers },
|
|
targetRpoMinutes: { type: 'integer', minimum: 1 },
|
|
targetRtoMinutes: { type: 'integer', minimum: 1 },
|
|
baseBackupIntervalHours: { type: 'integer', minimum: 1 },
|
|
walArchiveIntervalMinutes: {
|
|
anyOf: [{ type: 'integer', minimum: 1 }, { type: 'null' }],
|
|
},
|
|
pitrRetentionDays: { type: 'integer', minimum: 0 },
|
|
restoreTestIntervalDays: { type: 'integer', minimum: 1 },
|
|
breakGlassDrillIntervalDays: { type: 'integer', minimum: 1 },
|
|
offClusterStorage: {
|
|
type: 'object',
|
|
additionalProperties: false,
|
|
required: ['required', 'encrypted', 'separateFailureDomain', 'minimumCopies', 'storageClass'],
|
|
properties: {
|
|
required: { const: true },
|
|
encrypted: { const: true },
|
|
separateFailureDomain: { const: true },
|
|
minimumCopies: { type: 'integer', minimum: 1 },
|
|
storageClass: {
|
|
enum: ['encrypted-object-storage', 'encrypted-backup-target'],
|
|
},
|
|
},
|
|
},
|
|
},
|
|
} as const;
|
|
|
|
export const recoveryValidationCodes = [
|
|
'INVALID_SHAPE',
|
|
'UNKNOWN_FIELD',
|
|
'PITR_REQUIRES_WAL',
|
|
'WAL_REQUIRES_PITR',
|
|
'RPO_BETTER_THAN_MECHANISM',
|
|
'OFF_CLUSTER_REQUIRED',
|
|
'HIGH_ASSURANCE_WEAKENED',
|
|
] as const;
|
|
export type RecoveryValidationCode = (typeof recoveryValidationCodes)[number];
|
|
|
|
export interface RecoveryValidationIssueV1 {
|
|
code: RecoveryValidationCode;
|
|
path: string;
|
|
message: string;
|
|
}
|
|
export type RecoveryValidationResultV1 =
|
|
| { ok: true; value: RecoveryPostureV1 }
|
|
| { ok: false; issues: RecoveryValidationIssueV1[] };
|
|
|
|
const topLevelFields = new Set([
|
|
'contractVersion',
|
|
'tier',
|
|
'targetRpoMinutes',
|
|
'targetRtoMinutes',
|
|
'baseBackupIntervalHours',
|
|
'walArchiveIntervalMinutes',
|
|
'pitrRetentionDays',
|
|
'restoreTestIntervalDays',
|
|
'breakGlassDrillIntervalDays',
|
|
'offClusterStorage',
|
|
]);
|
|
const storageFields = new Set([
|
|
'required',
|
|
'encrypted',
|
|
'separateFailureDomain',
|
|
'minimumCopies',
|
|
'storageClass',
|
|
]);
|
|
|
|
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
}
|
|
function isPositiveInteger(value: unknown): value is number {
|
|
return Number.isInteger(value) && Number(value) > 0;
|
|
}
|
|
function isNonnegativeInteger(value: unknown): value is number {
|
|
return Number.isInteger(value) && Number(value) >= 0;
|
|
}
|
|
|
|
/**
|
|
* Normative parser/refinement. Deployment code MUST call this function (or a
|
|
* byte-for-byte behaviorally equivalent generated validator), not JSON Schema
|
|
* shape validation alone.
|
|
*/
|
|
export function validateRecoveryPostureV1(input: unknown): RecoveryValidationResultV1 {
|
|
const issues: RecoveryValidationIssueV1[] = [];
|
|
if (!isRecord(input)) {
|
|
return {
|
|
ok: false,
|
|
issues: [{ code: 'INVALID_SHAPE', path: '$', message: 'posture must be an object' }],
|
|
};
|
|
}
|
|
|
|
for (const key of Object.keys(input)) {
|
|
if (!topLevelFields.has(key)) {
|
|
issues.push({ code: 'UNKNOWN_FIELD', path: `$.${key}`, message: 'unknown field' });
|
|
}
|
|
}
|
|
|
|
const tier = input['tier'];
|
|
const storage = input['offClusterStorage'];
|
|
const integerFields = [
|
|
'targetRpoMinutes',
|
|
'targetRtoMinutes',
|
|
'baseBackupIntervalHours',
|
|
'restoreTestIntervalDays',
|
|
'breakGlassDrillIntervalDays',
|
|
] as const;
|
|
|
|
if (input['contractVersion'] !== RECOVERY_POSTURE_CONTRACT_VERSION) {
|
|
issues.push({
|
|
code: 'INVALID_SHAPE',
|
|
path: '$.contractVersion',
|
|
message: `must equal ${RECOVERY_POSTURE_CONTRACT_VERSION}`,
|
|
});
|
|
}
|
|
if (!recoveryTiers.includes(tier as RecoveryTier)) {
|
|
issues.push({ code: 'INVALID_SHAPE', path: '$.tier', message: 'unknown recovery tier' });
|
|
}
|
|
for (const field of integerFields) {
|
|
if (!isPositiveInteger(input[field])) {
|
|
issues.push({
|
|
code: 'INVALID_SHAPE',
|
|
path: `$.${field}`,
|
|
message: 'must be a positive integer',
|
|
});
|
|
}
|
|
}
|
|
if (!isNonnegativeInteger(input['pitrRetentionDays'])) {
|
|
issues.push({
|
|
code: 'INVALID_SHAPE',
|
|
path: '$.pitrRetentionDays',
|
|
message: 'must be a nonnegative integer',
|
|
});
|
|
}
|
|
if (
|
|
input['walArchiveIntervalMinutes'] !== null &&
|
|
!isPositiveInteger(input['walArchiveIntervalMinutes'])
|
|
) {
|
|
issues.push({
|
|
code: 'INVALID_SHAPE',
|
|
path: '$.walArchiveIntervalMinutes',
|
|
message: 'must be null or a positive integer',
|
|
});
|
|
}
|
|
|
|
if (!isRecord(storage)) {
|
|
issues.push({
|
|
code: 'INVALID_SHAPE',
|
|
path: '$.offClusterStorage',
|
|
message: 'must be an object',
|
|
});
|
|
} else {
|
|
for (const key of Object.keys(storage)) {
|
|
if (!storageFields.has(key)) {
|
|
issues.push({
|
|
code: 'UNKNOWN_FIELD',
|
|
path: `$.offClusterStorage.${key}`,
|
|
message: 'unknown field',
|
|
});
|
|
}
|
|
}
|
|
if (
|
|
storage['required'] !== true ||
|
|
storage['encrypted'] !== true ||
|
|
storage['separateFailureDomain'] !== true
|
|
) {
|
|
issues.push({
|
|
code: 'OFF_CLUSTER_REQUIRED',
|
|
path: '$.offClusterStorage',
|
|
message: 'storage must be required, encrypted, and in a separate failure domain',
|
|
});
|
|
}
|
|
if (!isPositiveInteger(storage['minimumCopies'])) {
|
|
issues.push({
|
|
code: 'INVALID_SHAPE',
|
|
path: '$.offClusterStorage.minimumCopies',
|
|
message: 'must be a positive integer',
|
|
});
|
|
}
|
|
if (
|
|
storage['storageClass'] !== 'encrypted-object-storage' &&
|
|
storage['storageClass'] !== 'encrypted-backup-target'
|
|
) {
|
|
issues.push({
|
|
code: 'INVALID_SHAPE',
|
|
path: '$.offClusterStorage.storageClass',
|
|
message: 'unsupported storage class',
|
|
});
|
|
}
|
|
}
|
|
|
|
const wal = input['walArchiveIntervalMinutes'];
|
|
const pitr = input['pitrRetentionDays'];
|
|
if (pitr !== 0 && wal === null) {
|
|
issues.push({
|
|
code: 'PITR_REQUIRES_WAL',
|
|
path: '$.pitrRetentionDays',
|
|
message: 'PITR retention requires WAL archival',
|
|
});
|
|
}
|
|
if (wal !== null && pitr === 0) {
|
|
issues.push({
|
|
code: 'WAL_REQUIRES_PITR',
|
|
path: '$.walArchiveIntervalMinutes',
|
|
message: 'WAL archival requires positive PITR retention',
|
|
});
|
|
}
|
|
|
|
if (
|
|
isPositiveInteger(input['targetRpoMinutes']) &&
|
|
isPositiveInteger(input['baseBackupIntervalHours']) &&
|
|
(wal === null || isPositiveInteger(wal))
|
|
) {
|
|
const mechanismMinutes = wal === null ? input['baseBackupIntervalHours'] * 60 : wal;
|
|
if (mechanismMinutes > input['targetRpoMinutes']) {
|
|
issues.push({
|
|
code: 'RPO_BETTER_THAN_MECHANISM',
|
|
path: '$.targetRpoMinutes',
|
|
message: `configured mechanism can only support ${mechanismMinutes} minutes`,
|
|
});
|
|
}
|
|
}
|
|
|
|
if (tier === 'high-assurance') {
|
|
const weakened =
|
|
!isPositiveInteger(input['targetRpoMinutes']) ||
|
|
input['targetRpoMinutes'] > 15 ||
|
|
!isPositiveInteger(input['targetRtoMinutes']) ||
|
|
input['targetRtoMinutes'] > 4 * 60 ||
|
|
!isPositiveInteger(input['baseBackupIntervalHours']) ||
|
|
input['baseBackupIntervalHours'] > 24 ||
|
|
!isPositiveInteger(wal) ||
|
|
wal > 5 ||
|
|
!isNonnegativeInteger(pitr) ||
|
|
pitr < 35 ||
|
|
!isPositiveInteger(input['restoreTestIntervalDays']) ||
|
|
input['restoreTestIntervalDays'] > 30 ||
|
|
!isPositiveInteger(input['breakGlassDrillIntervalDays']) ||
|
|
input['breakGlassDrillIntervalDays'] > 90;
|
|
if (weakened) {
|
|
issues.push({
|
|
code: 'HIGH_ASSURANCE_WEAKENED',
|
|
path: '$',
|
|
message: 'high-assurance posture may be strengthened but not weakened',
|
|
});
|
|
}
|
|
}
|
|
|
|
if (issues.length > 0) return { ok: false, issues };
|
|
return { ok: true, value: input as unknown as RecoveryPostureV1 };
|
|
}
|
|
|
|
export interface RecoveryPostureOverrideAuditV1 {
|
|
actorId: string;
|
|
reason: string;
|
|
effectiveAt: string;
|
|
policyRevision: string;
|
|
previous: RecoveryPostureV1;
|
|
next: RecoveryPostureV1;
|
|
}
|