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
2 changes: 1 addition & 1 deletion core/__tests__/registryManifestValidation.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ import {
*
* These fixtures are `.txt` rather than `.ts` on purpose: a manifest declaring
* an invalid status or network is exactly what the type system rejects, and
* `tsconfig.json` includes `**/*.ts`, so typed fixtures could not express the
* `tsconfig.json` includes every TypeScript file, so typed fixtures could not express the
* invalid shapes at all.
*/
const FIXTURE_DIR = path.resolve(process.cwd(), "scripts/__tests__/fixtures/registry-manifests");
Expand Down
38 changes: 38 additions & 0 deletions core/operations/__tests__/recovery.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,14 @@ import { describe, expect, it } from "vitest";
import type { Result } from "@/core/result/result";
import {
abandonOperation,
abandonOperationAuthorized,
createOperationStorage,
decideStep,
defaultIdempotencyKey,
nextActionFor,
operationMachine,
resolveInterruptedStep,
resolveInterruptedStepAuthorized,
runOperation,
type OperationDefinition,
type OperationErrorCode,
Expand Down Expand Up @@ -382,6 +384,42 @@ describe("runOperation", () => {
});
});

describe("authorized recovery decisions", () => {
it("fails closed without changing the checkpoint", async () => {
const storage = createOperationStorage();
const definition: OperationDefinition<Ctx> = {
kind: "ticket_flow",
steps: [{ id: "submit", run: () => ({ status: "interrupted", errorCode: "timeout", committed: true }) }]
};
await runOperation(definition, options(storage));
const before = storage.read("op-1");

expect(resolveInterruptedStepAuthorized(
{ role: "viewer", userId: "eve" }, "op-1", "submit", "applied", { storage }
)).toEqual({ ok: false, code: "permission_denied" });
expect(abandonOperationAuthorized({ role: "service" }, "op-1", { storage })).toEqual({
ok: false,
code: "permission_denied"
});
expect(storage.read("op-1")).toEqual(before);
});

it("allows a maintainer to resolve and resume", async () => {
const storage = createOperationStorage();
const definition: OperationDefinition<Ctx> = {
kind: "ticket_flow",
steps: [{ id: "submit", run: () => ({ status: "interrupted", errorCode: "timeout", committed: true }) }]
};
await runOperation(definition, options(storage));

const resolved = resolveInterruptedStepAuthorized(
{ role: "maintainer", userId: "ada" }, "op-1", "submit", "applied", { storage }
);
expect(resolved.ok && resolved.value.steps[0].status).toBe("succeeded");
expect(progress(await runOperation(definition, options(storage, 1))).state).toBe("completed");
});
});

describe("resolveInterruptedStep", () => {
it('marks a confirmed effect as applied and lets the flow continue', async () => {
const log = recorder();
Expand Down
2 changes: 2 additions & 0 deletions core/operations/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,11 +9,13 @@ export {

export {
abandonOperation,
abandonOperationAuthorized,
decideStep,
defaultIdempotencyKey,
nextActionFor,
operationMachine,
resolveInterruptedStep,
resolveInterruptedStepAuthorized,
runOperation,
type ResolveOperationOptions,
type StepDecision,
Expand Down
35 changes: 35 additions & 0 deletions core/operations/recovery.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,8 @@ import {
type TransitionContext
} from "@/core/lifecycle/lifecycle";
import { newCorrelationId } from "@/core/telemetry/telemetry";
import type { AuthContext } from "@/core/auth/middleware";
import { hasPermission } from "@/core/auth/types";
import { err, isErr, ok, type Result } from "@/core/result/result";
import {
OPERATION_STATES,
Expand Down Expand Up @@ -504,6 +506,26 @@ export function resolveInterruptedStep(
return ok(next);
}

/**
* Server-side boundary for the destructive recovery decision. The original
* resolver remains available for trusted/internal callers; request handlers
* should use this function so a viewer cannot assert that an external effect
* did or did not land.
*/
export function resolveInterruptedStepAuthorized(
auth: AuthContext,
operationId: string,
stepId: string,
resolution: StepResolution,
options: ResolveOperationOptions
): Result<OperationRecord, OperationErrorCode> {
if (!hasPermission(auth.role, "admin:recovery")) return err("permission_denied");
return resolveInterruptedStep(operationId, stepId, resolution, {
...options,
actor: options.actor ?? `maintainer:${auth.userId ?? "unknown"}`
});
}

/** Gives up on an operation on purpose. The record is kept for the audit trail. */
export function abandonOperation(
operationId: string,
Expand All @@ -524,3 +546,16 @@ export function abandonOperation(
if (!persist(options.storage, advanced.value)) return err("storage_error");
return ok(advanced.value);
}

/** Authorization boundary for abandoning a recoverable operation. */
export function abandonOperationAuthorized(
auth: AuthContext,
operationId: string,
options: ResolveOperationOptions
): Result<OperationRecord, OperationErrorCode> {
if (!hasPermission(auth.role, "admin:recovery")) return err("permission_denied");
return abandonOperation(operationId, {
...options,
actor: options.actor ?? `maintainer:${auth.userId ?? "unknown"}`
});
}
1 change: 1 addition & 0 deletions core/operations/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -198,4 +198,5 @@ export type OperationErrorCode =
| "unknown_operation"
| "unknown_step"
| "terminal_operation"
| "permission_denied"
| "storage_error";
19 changes: 19 additions & 0 deletions core/policy/__tests__/maintainerSimulation.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,11 @@ describe("runPolicySimulation", () => {
expect(JSON.stringify({ POLICY, records })).toBe(before);
});

it("returns an identical preview when a dry run is retried", () => {
const request = { policy: POLICY, records: [tx("tx-1", 5000)], now: 1_700_000_000_000 };
expect(run(request)).toEqual(run(request));
});

it("rejects an invalid policy with every problem listed", () => {
const bad = { ...POLICY, id: "", version: 0, parameters: { maxBaseFeeStroops: -1 } };
const result = runPolicySimulation(MAINTAINER, { policy: bad, records: RECORDS });
Expand All @@ -81,6 +86,20 @@ describe("runPolicySimulation", () => {
expect(!result.ok && result.detail).toEqual(['duplicate record id "tx-1"', 'record "x" has an unknown type']);
});

it("rejects malformed preview bounds without evaluating", () => {
const duplicateSelection = runPolicySimulation(MAINTAINER, {
policy: POLICY,
records: RECORDS,
recordIds: ["tx-1", "tx-1"],
maxExamples: -1
});
expect(duplicateSelection).toMatchObject({ ok: false, code: "invalid_records" });
expect(!duplicateSelection.ok && duplicateSelection.detail).toEqual([
"recordIds must not contain duplicates",
"maxExamples must be a non-negative integer"
]);
});

it("denies roles without config access", () => {
for (const role of ["viewer", "service"] as const) {
expect(runPolicySimulation({ role }, { policy: POLICY, records: RECORDS })).toEqual({
Expand Down
24 changes: 23 additions & 1 deletion core/policy/maintainerSimulation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -134,6 +134,26 @@ function recordIssues(records: unknown): string[] {
return issues;
}

function requestIssues(request: MaintainerSimulationRequest): string[] {
const issues: string[] = [];
if (request.recordIds !== undefined) {
if (!Array.isArray(request.recordIds) || !request.recordIds.every(isNonEmptyString)) {
issues.push("recordIds must be an array of non-empty strings");
} else if (new Set(request.recordIds).size !== request.recordIds.length) {
issues.push("recordIds must not contain duplicates");
}
}
if (request.maxExamples !== undefined &&
(!Number.isInteger(request.maxExamples) || request.maxExamples < 0)) {
issues.push("maxExamples must be a non-negative integer");
}
const now = request.now instanceof Date ? request.now.getTime() : request.now;
if (now !== undefined && (!Number.isFinite(now) || !Number.isSafeInteger(now))) {
issues.push("now must be a valid Date or integer timestamp");
}
return issues;
}

function buildExamples(report: PolicySimulationReport): AffectedRecordExample[] {
const byRecord = new Map<string, AffectedRecordExample>();
for (const effect of report.sideEffects) {
Expand All @@ -151,7 +171,7 @@ function buildExamples(report: PolicySimulationReport): AffectedRecordExample[]
});
}
return [...byRecord.values()].sort(
(a, b) => SEVERITY_RANK[b.severity] - SEVERITY_RANK[a.severity] || (a.id < b.id ? -1 : 1)
(a, b) => SEVERITY_RANK[b.severity] - SEVERITY_RANK[a.severity] || a.id.localeCompare(b.id)
);
}

Expand All @@ -165,6 +185,8 @@ export function runPolicySimulation(
if (policyProblems.length > 0) return err("invalid_policy", policyProblems);
const recordProblems = recordIssues(request.records);
if (recordProblems.length > 0) return err("invalid_records", recordProblems);
const selectionProblems = requestIssues(request);
if (selectionProblems.length > 0) return err("invalid_records", selectionProblems);

const wanted = request.recordIds ? new Set(request.recordIds) : undefined;
const selected = wanted ? request.records.filter((record) => wanted.has(record.id)) : request.records;
Expand Down
4 changes: 3 additions & 1 deletion core/policy/simulation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,9 @@ export class PolicySimulator {
? new Date(options.now).toISOString()
: new Date().toISOString();

const simulationId = `sim-${policy.id}-v${policy.version}-${Date.now().toString(16)}`;
// Derive the id from the injected evaluation time. Retrying the same dry
// run against the same snapshot can therefore be compared byte-for-byte.
const simulationId = `sim-${policy.id}-v${policy.version}-${new Date(timestamp).getTime().toString(16)}`;
const sideEffects: SimulationSideEffect[] = [];
const affectedRecordIds = new Set<string>();

Expand Down
45 changes: 45 additions & 0 deletions core/sorting/__tests__/mixedStatus.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
import { describe, expect, it } from "vitest";
import { sortMixedStatusEvents, sortMixedStatusRecords } from "@/core/sorting";

describe("mixed-status deterministic sorting", () => {
it("orders records by actionable status and deterministic tie-breakers", () => {
const input = [
{ id: "done", status: "completed", updatedAt: "2026-01-03T00:00:00Z" },
{ id: "failure-b", status: "failed", updatedAt: "2026-01-02T00:00:00Z", sequence: 1 },
{ id: "pending", status: "pending", updatedAt: "2026-01-04T00:00:00Z" },
{ id: "failure-a", status: "failed", updatedAt: "2026-01-02T00:00:00Z", sequence: 2 }
];
expect(sortMixedStatusRecords(input).map(({ id }) => id)).toEqual([
"failure-a", "failure-b", "pending", "done"
]);
expect(input.map(({ id }) => id)).toEqual(["done", "failure-b", "pending", "failure-a"]);
});

it("keeps events reverse chronological across statuses", () => {
const events = [
{ id: "a", status: "completed", at: 10, sequence: 1 },
{ id: "c", status: "running", at: 20, sequence: 1 },
{ id: "b", status: "failed", at: 10, sequence: 1 }
];
expect(sortMixedStatusEvents(events).map(({ id }) => id)).toEqual(["c", "b", "a"]);
});

it("sorts unknown statuses and invalid dates without relying on input order", () => {
const records = [
{ id: "z", status: "future", updatedAt: "bad" },
{ id: "a", status: "future", updatedAt: null },
{ id: "m", status: "experimental", updatedAt: undefined }
];
expect(sortMixedStatusRecords(records).map(({ id }) => id)).toEqual(["m", "a", "z"]);
expect(sortMixedStatusRecords([...records].reverse()).map(({ id }) => id)).toEqual(["m", "a", "z"]);
});

it("only returns the caller-authorized input subset", () => {
const all = [
{ id: "visible", status: "pending", updatedAt: 1, owner: "ada" },
{ id: "secret", status: "failed", updatedAt: 2, owner: "eve" }
];
const authorized = all.filter((record) => record.owner === "ada");
expect(sortMixedStatusRecords(authorized).map(({ id }) => id)).toEqual(["visible"]);
});
});
9 changes: 9 additions & 0 deletions core/sorting/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
export {
STATUS_PRIORITY,
compareMixedStatusEvents,
compareMixedStatusRecords,
sortMixedStatusEvents,
sortMixedStatusRecords,
type MixedStatusEvent,
type MixedStatusRecord
} from "./mixedStatus";
76 changes: 76 additions & 0 deletions core/sorting/mixedStatus.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
/** A stable, total order for operational records and their event streams. */

export interface MixedStatusRecord {
readonly id: string;
readonly status: string;
readonly updatedAt?: string | number | Date | null;
readonly sequence?: number | null;
}

export interface MixedStatusEvent {
readonly id: string;
readonly status: string;
readonly at?: string | number | Date | null;
readonly sequence?: number | null;
}

/** Actionable/unsafe states precede in-flight and terminal states. */
export const STATUS_PRIORITY: Readonly<Record<string, number>> = Object.freeze({
failed: 0,
dead_lettered: 0,
interrupted: 1,
retrying: 1,
pending: 2,
queued: 2,
running: 3,
succeeded: 4,
completed: 4,
resolved: 4,
skipped: 5,
abandoned: 6,
cancelled: 6
});

function text(a: string, b: string): number {
return a === b ? 0 : a < b ? -1 : 1;
}

function time(value: string | number | Date | null | undefined): number {
if (value === null || value === undefined) return Number.NEGATIVE_INFINITY;
const parsed = value instanceof Date ? value.getTime() : typeof value === "number" ? value : Date.parse(value);
return Number.isFinite(parsed) ? parsed : Number.NEGATIVE_INFINITY;
}

function priority(status: string): number {
return STATUS_PRIORITY[status] ?? 7;
}

function sequence(value: number | null | undefined): number {
return typeof value === "number" && Number.isFinite(value) ? value : Number.NEGATIVE_INFINITY;
}

/** Status first, then newest update, highest sequence, status text and id. */
export function compareMixedStatusRecords<T extends MixedStatusRecord>(a: T, b: T): number {
return priority(a.status) - priority(b.status) ||
time(b.updatedAt) - time(a.updatedAt) ||
sequence(b.sequence) - sequence(a.sequence) ||
text(a.status, b.status) ||
text(a.id, b.id);
}

/** Newest event first, then status, sequence and id for timestamp ties. */
export function compareMixedStatusEvents<T extends MixedStatusEvent>(a: T, b: T): number {
return time(b.at) - time(a.at) ||
priority(a.status) - priority(b.status) ||
sequence(b.sequence) - sequence(a.sequence) ||
text(a.status, b.status) ||
text(a.id, b.id);
}

export function sortMixedStatusRecords<T extends MixedStatusRecord>(records: readonly T[]): T[] {
return [...records].sort(compareMixedStatusRecords);
}

export function sortMixedStatusEvents<T extends MixedStatusEvent>(events: readonly T[]): T[] {
return [...events].sort(compareMixedStatusEvents);
}
Loading
Loading