Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions core/admin-config/__tests__/contract.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
import { describe, expect, it } from "vitest";
import { compareConfigurationContracts } from "@/core/admin-config/contract";

describe("configuration contracts", () => {
it("requires the same critical keys in every environment", () => {
const result = compareConfigurationContracts([{ environment: "test", values: {} }, { environment: "prod", values: {} }]);
expect(result.valid).toBe(false);
expect(result.missing).toContain("prod:network.endpoint");
});
});
23 changes: 23 additions & 0 deletions core/admin-config/contract.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import type { AdminConfigKey } from "./types";

export const REQUIRED_CONFIGURATION: readonly AdminConfigKey[] = [
"network.endpoint", "network.passphrase", "role.default", "health.checkInterval"
];

export interface ConfigurationSnapshot { readonly environment: string; readonly values: Readonly<Record<string, unknown>>; }
export interface ConfigurationContractResult { readonly valid: boolean; readonly missing: readonly string[]; readonly invalid: readonly string[]; }

export function validateConfigurationContract(snapshot: ConfigurationSnapshot): ConfigurationContractResult {
const missing = REQUIRED_CONFIGURATION.filter((key) => !(key in snapshot.values));
const invalid = Object.keys(snapshot.values).filter((key) => !REQUIRED_CONFIGURATION.includes(key as AdminConfigKey));
return { valid: missing.length === 0 && invalid.length === 0, missing, invalid };
}

export function compareConfigurationContracts(snapshots: readonly ConfigurationSnapshot[]): ConfigurationContractResult {
const results = snapshots.map(validateConfigurationContract);
return {
valid: results.every((result) => result.valid),
missing: results.flatMap((result, index) => result.missing.map((key) => `${snapshots[index].environment}:${key}`)),
invalid: results.flatMap((result, index) => result.invalid.map((key) => `${snapshots[index].environment}:${key}`))
};
}
2 changes: 2 additions & 0 deletions core/admin-config/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
export * from "./types";
export * from "./contract";
11 changes: 11 additions & 0 deletions core/audit/__tests__/evidence-bundle.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
import { describe, expect, it } from "vitest";
import { createAuditEvidenceBundle, verifyAuditEvidenceBundle } from "@/core/audit/evidence-bundle";

describe("audit evidence bundles", () => {
it("detects changes after export", () => {
const bundle = createAuditEvidenceBundle({ scope: "maintainer", events: [] });
expect(verifyAuditEvidenceBundle(bundle)).toBe(true);
const changed = { ...bundle, eventCount: 1 };
expect(verifyAuditEvidenceBundle(changed)).toBe(false);
});
});
48 changes: 48 additions & 0 deletions core/audit/evidence-bundle.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
import type { AuditEvent } from "./audit";

export interface AuditEvidenceBundle {
readonly schemaVersion: "1.0";
readonly generatedAt: string;
readonly scope: "own" | "maintainer";
readonly eventCount: number;
readonly events: readonly AuditEvent[];
/** Digest of the canonical, redacted event payload. */
readonly digest: string;
}

function canonical(value: unknown): string {
if (Array.isArray(value)) return `[${value.map(canonical).join(",")}]`;
if (value && typeof value === "object") {
return `{${Object.keys(value as Record<string, unknown>).sort().map((key) => `${JSON.stringify(key)}:${canonical((value as Record<string, unknown>)[key])}`).join(",")}}`;
}
return JSON.stringify(value) ?? "null";
}

function digest(value: unknown): string {
let hash = 2166136261;
for (const character of canonical(value)) {
hash ^= character.charCodeAt(0);
hash = Math.imul(hash, 16777619);
}
return (hash >>> 0).toString(16).padStart(8, "0");
}

export function createAuditEvidenceBundle(input: {
events: readonly AuditEvent[];
scope: "own" | "maintainer";
generatedAt?: string;
}): AuditEvidenceBundle {
const events = input.events.map((event) => ({ ...event, before: event.before && { ...event.before }, after: event.after && { ...event.after } }));
return {
schemaVersion: "1.0",
generatedAt: input.generatedAt ?? new Date().toISOString(),
scope: input.scope,
eventCount: events.length,
events,
digest: digest(events)
};
}

export function verifyAuditEvidenceBundle(bundle: AuditEvidenceBundle): boolean {
return bundle.schemaVersion === "1.0" && bundle.eventCount === bundle.events.length && bundle.digest === digest(bundle.events);
}
1 change: 1 addition & 0 deletions core/audit/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,3 +37,4 @@ export {
type RetentionPolicy,
type RetentionSweepResult
} from "./retention";
export { createAuditEvidenceBundle, verifyAuditEvidenceBundle, type AuditEvidenceBundle } from "./evidence-bundle";
14 changes: 14 additions & 0 deletions core/operations/__tests__/dispatcher.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
import { describe, expect, it } from "vitest";
import { createSideEffectDispatcher, type DispatchJob, type DispatchStore } from "@/core/operations/dispatcher";

function store(): DispatchStore<string> { const values = new Map<string, DispatchJob<string>>(); return { read: (id) => values.get(id), list: () => [...values.values()], write: (job) => void values.set(job.id, job) }; }
describe("side-effect dispatcher", () => {
it("deduplicates and dead-letters after the retry budget", async () => {
const dispatcher = createSideEffectDispatcher({ store: store(), maxAttempts: 2, newId: () => "job-1" });
const first = dispatcher.submit({ operation: "send", idempotencyKey: "key-1", payload: "x" });
expect(dispatcher.submit({ operation: "send", idempotencyKey: "key-1", payload: "x" }).replayed).toBe(true);
await dispatcher.run(first.job.id, () => { throw new Error("offline"); });
await dispatcher.run(first.job.id, () => { throw new Error("offline"); });
expect(dispatcher.replay()).toHaveLength(0);
});
});
22 changes: 22 additions & 0 deletions core/operations/dispatcher.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
export type DispatchStatus = "queued" | "running" | "succeeded" | "dead_lettered";
export interface DispatchJob<T = unknown> { readonly id: string; readonly operation: string; readonly idempotencyKey: string; readonly payload: T; readonly attempts: number; readonly status: DispatchStatus; readonly lastError?: string; }
export interface DispatchStore<T = unknown> { read(id: string): DispatchJob<T> | undefined; write(job: DispatchJob<T>): void; list(): readonly DispatchJob<T>[]; }
export interface SideEffectDispatcherOptions<T> { readonly store: DispatchStore<T>; readonly maxAttempts?: number; readonly newId?: () => string; }

export function createSideEffectDispatcher<T>({ store, maxAttempts = 3, newId = () => crypto.randomUUID() }: SideEffectDispatcherOptions<T>) {
const byKey = (key: string) => store.list().find((job) => job.idempotencyKey === key);
const submit = (input: { operation: string; idempotencyKey: string; payload: T }) => {
const existing = byKey(input.idempotencyKey);
if (existing) return { job: existing, replayed: true };
const job: DispatchJob<T> = { ...input, id: newId(), attempts: 0, status: "queued" };
store.write(job); return { job, replayed: false };
};
const run = async (id: string, effect: (job: DispatchJob<T>) => Promise<void> | void) => {
const current = store.read(id); if (!current || current.status === "succeeded" || current.status === "dead_lettered") return current;
const running = { ...current, status: "running" as const, attempts: current.attempts + 1 }; store.write(running);
try { await effect(running); const done = { ...running, status: "succeeded" as const }; store.write(done); return done; }
catch (error) { const failed = { ...running, status: running.attempts >= maxAttempts ? "dead_lettered" as const : "queued" as const, lastError: error instanceof Error ? error.message : String(error) }; store.write(failed); return failed; }
};
const replay = () => store.list().filter((job) => job.status === "queued");
return { submit, run, replay };
}
1 change: 1 addition & 0 deletions core/operations/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,3 +49,4 @@ export {
type StepRunInput,
type StepStatus
} from "@/core/operations/types";
export { createSideEffectDispatcher, type DispatchJob, type DispatchStore, type DispatchStatus, type SideEffectDispatcherOptions } from "./dispatcher";
9 changes: 9 additions & 0 deletions core/schema/__tests__/guardrails.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import { describe, expect, it } from "vitest";
import { checkSchemaEvolution, validateSchemaRecord, type SchemaDefinition } from "@/core/schema/guardrails";
const v1: SchemaDefinition = { name: "ticket", version: 1, required: ["id"], fields: { id: "string" } };
describe("schema guardrails", () => {
it("rejects breaking changes without a version bump", () => {
expect(checkSchemaEvolution(v1, { ...v1, required: ["owner"] }).compatible).toBe(false);
expect(validateSchemaRecord(v1, { id: "1" })).toBe(true);
});
});
15 changes: 15 additions & 0 deletions core/schema/guardrails.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
export interface SchemaDefinition { readonly name: string; readonly version: number; readonly required: readonly string[]; readonly fields: Readonly<Record<string, "string" | "number" | "boolean" | "object">>; }
export interface SchemaGuardResult { readonly compatible: boolean; readonly breaking: readonly string[]; }

export function validateSchemaRecord(definition: SchemaDefinition, value: unknown): boolean {
if (!value || typeof value !== "object") return false;
const record = value as Record<string, unknown>;
return definition.required.every((key) => key in record) && Object.entries(definition.fields).every(([key, type]) => !(key in record) || typeof record[key] === type);
}

export function checkSchemaEvolution(previous: SchemaDefinition, next: SchemaDefinition): SchemaGuardResult {
const breaking = previous.required.filter((key) => !next.required.includes(key) || !(key in next.fields));
for (const key of previous.required) if (next.fields[key] && previous.fields[key] !== next.fields[key]) breaking.push(`${key}:type`);
if (next.version <= previous.version) breaking.push("version must increase");
return { compatible: breaking.length === 0, breaking };
}
1 change: 1 addition & 0 deletions core/schema/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export * from "./guardrails";
10 changes: 10 additions & 0 deletions docs/OPERATIONAL_INTEGRITY.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
# Operational integrity guardrails

Issues 202–205 add four small, dependency-free contracts used at operational boundaries:

- `createAuditEvidenceBundle` exports a redacted audit slice with a deterministic digest. `verifyAuditEvidenceBundle` must pass before an investigation bundle is trusted.
- `compareConfigurationContracts` checks that every environment contains the same production-critical configuration keys and reports the environment/key pair that is missing or unexpected.
- `createSideEffectDispatcher` persists idempotency keys, replays an existing job instead of enqueueing a duplicate, and moves failures to a dead-letter state after the bounded retry budget.
- `checkSchemaEvolution` rejects non-increasing versions and changes to required field types. `validateSchemaRecord` protects stored records and API responses at their boundary.

All four APIs accept injected storage or deterministic inputs, so contract tests can run without a network, secrets, or a production database.