FN-9132: order approval audits by lifecycle on timestamp ties

Ensure approval audit histories preserve lifecycle chronology when events share a timestamp.

- rank tied audit events by the declared approval lifecycle before audit ID
- cover tied and distinct timestamps through module and public store surfaces
- document the diagnosed satellite assertion and add a patch changeset

Files changed:
 .changeset/fn-9132-approval-audit-order.md         |  7 +++
 .../suite-only-flakes-observed-register.md         | 27 +++++++++++
 ...oval-request-audit-project-isolation.pg.test.ts |  2 +-
 .../postgres/approval-request-lifecycle.pg.test.ts | 53 +++++++++++++++++++++-
 .../async-stores/async-approval-request-store.ts   | 17 +++++--
 5 files changed, 100 insertions(+), 6 deletions(-)

Fusion-Task-Id: FN-9132

Fusion-Task-Lineage: 2f49e896-69bf-48c8-b813-4b1b2b72fc65

Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
This commit is contained in:
gsxdsm
2026-08-16 16:04:18 -07:00
parent a453245715
commit 2160f7500c
5 changed files with 100 additions and 6 deletions

View File

@@ -0,0 +1,7 @@
---
"@runfusion/fusion": patch
---
summary: Keep approval audit timelines in lifecycle order when events share a timestamp.
category: fix
dev: `getApprovalAuditHistory` now applies an event lifecycle-rank tiebreak before audit ID.

View File

@@ -295,3 +295,30 @@ It is the companion to entries 4, 5, and 8: FN-8936 fixed detached test-node han
| `pnpm lint`, `pnpm verify:fast`, `pnpm build` | **passed** | | `pnpm lint`, `pnpm verify:fast`, `pnpm build` | **passed** |
No UI surface changed; this was a state-ownership and regression-coverage repair. The existing patch changeset remains applicable because Planning Mode behavior is user-visible. No UI surface changed; this was a state-ownership and regression-coverage repair. The existing patch changeset remains applicable because Planning Mode behavior is user-visible.
## 12. Satellite approval audit lifecycle ordering assertion
- **File:** `packages/core/src/__tests__/postgres/satellite-stores.pg.test.ts`
- **Exact test:** `PostgreSQL satellite stores (U6 consolidated, shared harness) > PostgreSQL satellite DB-injected stores (VAL-DATA-016) > ApprovalRequestStore: replayed/conflicting decisions 409, grants expire, ownership enforced`
- **Owner:** FN-9132
- **Observed tree/SHA:** deterministic pre-fix reproduction on `b31be1ba7c7415b9ee20c4c76875c961be73a0c3`; structural fix begins at `c3e3a2648a`.
- **Observed frequency:** co-observed in retained FN-9125 12-worker PostgreSQL-directory, FN-9129 4-worker full-core run 1, and FN-9130 loaded-measurement evidence.
Verbatim observed failure:
```
FAIL src/__tests__/postgres/satellite-stores.pg.test.ts > PostgreSQL satellite stores (U6 consolidated, shared harness) > PostgreSQL satellite DB-injected stores (VAL-DATA-016) > ApprovalRequestStore: replayed/conflicting decisions 409, grants expire, ownership enforced
AssertionError: expected [ 'approved', 'created' ] to deeply equal [ 'created', 'approved' ]
```
| run | result |
|---|---|
| retained FN-9125 PostgreSQL directory, 12 workers | **failed** with the verbatim ordering assertion |
| retained FN-9129 full core, 4 workers run 1 | **failed** with the verbatim ordering assertion |
| FN-9132 deterministic one-worker frozen-Date repro, pre-fix | **failed**; both rows existed with identical `createdAt` values |
| FN-9132 targeted lifecycle, project-isolation, satellite, and dashboard-route suites | **passed** post-fix |
| FN-9132 PostgreSQL directory, 12 workers | **passed**; 173 files, 1370 tests passed, 1 skipped |
**Resolved 2026-08-16 (FN-9132):** This was a product ordering defect in `getApprovalAuditHistory`, not PostgreSQL DDL contention, harness identity reuse, or test timing. `appendAuditEvent` creates deterministic IDs containing the event type, while the read ordered tied timestamps by `id ASC`; that lexically placed `approved` before `created`. The read now applies a lifecycle rank derived from `APPROVAL_REQUEST_AUDIT_EVENT_TYPES`, followed by ID only as a final total-order tiebreak. Regression coverage freezes `Date` around real create/decide/complete writes and proves tied approved, denied, and completed states, distinct timestamps, mixed ties, project isolation, and the public store delegate. No timeout, retry, worker-count, skip, assertion weakening, or quarantine change was made; `quarantinedCoreTests` remains empty.
This resolves the previously unclassified “unrelated satellite-store ordering failure” mentions in entry 1's 12-worker verification table, entry 2's 12-worker verification table, and entry 11's FN-9129 4-worker run table. Those sightings are now classified separately from their entries' identity and DDL investigations.

View File

@@ -178,6 +178,6 @@ pgDescribe("approval request audit project isolation", () => {
{ projectId: "order-project", id: "c", requestId: "apr-order", eventType: "completed", actorId: "agent", actorType: "agent", actorName: "Agent", createdAt: "2026-08-12T16:00:00.000Z" }, { projectId: "order-project", id: "c", requestId: "apr-order", eventType: "completed", actorId: "agent", actorType: "agent", actorName: "Agent", createdAt: "2026-08-12T16:00:00.000Z" },
]); ]);
expect((await getApprovalAuditHistory(h.layer().db, "apr-order", "order-project")).map((event) => event.id)) expect((await getApprovalAuditHistory(h.layer().db, "apr-order", "order-project")).map((event) => event.id))
.toEqual(["a", "b", "c"]); .toEqual(["b", "a", "c"]);
}); });
}); });

View File

@@ -1,4 +1,5 @@
import { it, expect, beforeAll, beforeEach, afterEach, afterAll } from "vitest"; import { it, expect, vi, beforeAll, beforeEach, afterEach, afterAll } from "vitest";
import { ApprovalRequestStore } from "../../agents/approval-request-store.js";
import type { AsyncDataLayer } from "../../postgres/data-layer.js"; import type { AsyncDataLayer } from "../../postgres/data-layer.js";
import { import {
pgDescribe, pgDescribe,
@@ -65,6 +66,56 @@ pgDescribe("approval request lifecycle security (PostgreSQL)", () => {
return store; return store;
} }
it("orders audit history by lifecycle within timestamp ties across store surfaces", async () => {
const store = await import("../../async-stores/async-approval-request-store.js");
const publicStore = new ApprovalRequestStore(null, { asyncLayer: ctx.layer });
const targetAction = { category: "shell", action: "exec", summary: "run cmd", resourceType: "host", resourceId: "local", context: { cmd: "ls" } };
const assertHistory = async (id: string, expectedTypes: string[]) => {
const moduleHistory = await store.getApprovalAuditHistory(ctx.layer.db, id);
const delegatedHistory = await publicStore.getAuditHistory(id);
expect(moduleHistory.map((event) => event.eventType)).toEqual(expectedTypes);
expect(delegatedHistory.map((event) => event.eventType)).toEqual(expectedTypes);
};
const create = (id: string) => store.createApprovalRequest(ctx.layer, { id, requester: REQUESTER, targetAction });
vi.useFakeTimers({ toFake: ["Date"] });
try {
const tiedAt = new Date("2026-08-16T22:26:00.000Z");
vi.setSystemTime(tiedAt);
await create("apr-tied-approved");
await store.decideApprovalRequest(ctx.layer, "apr-tied-approved", "approved", { actor: DECIDER });
const tiedApproved = await store.getApprovalAuditHistory(ctx.layer.db, "apr-tied-approved");
expect(tiedApproved.map((event) => event.createdAt)).toEqual([tiedAt.toISOString(), tiedAt.toISOString()]);
await assertHistory("apr-tied-approved", ["created", "approved"]);
await create("apr-tied-denied");
await store.decideApprovalRequest(ctx.layer, "apr-tied-denied", "denied", { actor: DECIDER });
await assertHistory("apr-tied-denied", ["created", "denied"]);
await create("apr-tied-completed");
await store.decideApprovalRequest(ctx.layer, "apr-tied-completed", "approved", { actor: DECIDER });
await store.markApprovalRequestCompleted(ctx.layer, "apr-tied-completed", { actor: DECIDER });
await assertHistory("apr-tied-completed", ["created", "approved", "completed"]);
vi.setSystemTime(new Date("2026-08-16T22:27:00.000Z"));
await create("apr-distinct");
vi.setSystemTime(new Date("2026-08-16T22:28:00.000Z"));
await store.decideApprovalRequest(ctx.layer, "apr-distinct", "approved", { actor: DECIDER });
vi.setSystemTime(new Date("2026-08-16T22:29:00.000Z"));
await store.markApprovalRequestCompleted(ctx.layer, "apr-distinct", { actor: DECIDER });
await assertHistory("apr-distinct", ["created", "approved", "completed"]);
vi.setSystemTime(new Date("2026-08-16T22:30:00.000Z"));
await create("apr-mixed");
await store.decideApprovalRequest(ctx.layer, "apr-mixed", "approved", { actor: DECIDER });
vi.setSystemTime(new Date("2026-08-16T22:31:00.000Z"));
await store.markApprovalRequestCompleted(ctx.layer, "apr-mixed", { actor: DECIDER });
await assertHistory("apr-mixed", ["created", "approved", "completed"]);
} finally {
vi.useRealTimers();
}
});
it("a same-verdict replay is rejected as an invalid transition", async () => { it("a same-verdict replay is rejected as an invalid transition", async () => {
const store = await seed("apr-replay"); const store = await seed("apr-replay");
await store.decideApprovalRequest(ctx.layer, "apr-replay", "approved", { actor: DECIDER }); await store.decideApprovalRequest(ctx.layer, "apr-replay", "approved", { actor: DECIDER });

View File

@@ -24,6 +24,7 @@ import { projectScopeFor, type AsyncDataLayer, type DbTransaction } from "../pos
// FNXC:ApprovalLifecycleSecurity 2026-07-26-12:25: // FNXC:ApprovalLifecycleSecurity 2026-07-26-12:25:
import { isApprovalRequestExpired } from "../types/agents/agents.js"; import { isApprovalRequestExpired } from "../types/agents/agents.js";
import { import {
APPROVAL_REQUEST_AUDIT_EVENT_TYPES,
isValidApprovalRequestTransition, isValidApprovalRequestTransition,
normalizeApprovalRequestActionCategory, normalizeApprovalRequestActionCategory,
type ApprovalRequest, type ApprovalRequest,
@@ -357,7 +358,12 @@ export async function markApprovalRequestCompleted(
} }
/** /**
* Get the audit history for a request, ordered by createdAt ASC. * Get the audit history for a request ordered by timestamp, then lifecycle rank, then ID.
*
* FNXC:ApprovalAuditOrdering 2026-08-16-22:26:
* Deterministic audit IDs embed the event type. Sorting same-millisecond records by ID
* therefore put `approved` before `created`, narrating an approval before its request.
* Derive the tiebreak rank from the declared lifecycle order; never infer lifecycle from IDs.
* *
* FNXC:ApprovalAuditProjectIsolation 2026-08-12-15:37: * FNXC:ApprovalAuditProjectIsolation 2026-08-12-15:37:
* Owner and superuser connections can enable `fusion.project_bypass`, so RLS cannot backstop this bare request-id lookup. Scope in SQL from the public ApprovalRequestStore layer binding; projectScopeFor intentionally treats blank and whitespace-only bindings as unbound even though fusion_assign_project_id preserves whitespace writes literally. * Owner and superuser connections can enable `fusion.project_bypass`, so RLS cannot backstop this bare request-id lookup. Scope in SQL from the public ApprovalRequestStore layer binding; projectScopeFor intentionally treats blank and whitespace-only bindings as unbound even though fusion_assign_project_id preserves whitespace writes literally.
@@ -367,12 +373,15 @@ export async function getApprovalAuditHistory(
requestId: string, requestId: string,
projectId?: string, projectId?: string,
): Promise<ApprovalRequestAuditEvent[]> { ): Promise<ApprovalRequestAuditEvent[]> {
const eventType = schema.project.approvalRequestAuditEvents.eventType;
const lifecycleRank = sql`CASE ${eventType} ${sql.join(
APPROVAL_REQUEST_AUDIT_EVENT_TYPES.map((type, rank) => sql`WHEN ${type} THEN ${rank}`),
sql` `,
)} ELSE ${APPROVAL_REQUEST_AUDIT_EVENT_TYPES.length} END`;
const rows = await handle const rows = await handle
.select() .select()
.from(schema.project.approvalRequestAuditEvents) .from(schema.project.approvalRequestAuditEvents)
.where(and(eq(schema.project.approvalRequestAuditEvents.requestId, requestId), projectScopeFor(schema.project.approvalRequestAuditEvents.projectId, projectId))) .where(and(eq(schema.project.approvalRequestAuditEvents.requestId, requestId), projectScopeFor(schema.project.approvalRequestAuditEvents.projectId, projectId)))
.orderBy( .orderBy(sql`${schema.project.approvalRequestAuditEvents.createdAt} ASC, ${lifecycleRank} ASC, ${schema.project.approvalRequestAuditEvents.id} ASC`);
sql`${schema.project.approvalRequestAuditEvents.createdAt} ASC, ${schema.project.approvalRequestAuditEvents.id} ASC`,
);
return rows.map((row) => rowToAuditEvent(row as ApprovalRequestAuditEventRow)); return rows.map((row) => rowToAuditEvent(row as ApprovalRequestAuditEventRow));
} }