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
7 changes: 7 additions & 0 deletions docs/RUNBOOK.md
Original file line number Diff line number Diff line change
Expand Up @@ -131,6 +131,13 @@ not emit this log line, so log volume tracks failures, not request volume.

### Investigation and evidence

For the common case where the list already reports `state=venue_acknowledged`,
the Admin UI offers **Resolve confirmed mutations**. It loads each mutation's
detail, selects an eligible correlated terminal ER, and submits
`venue_acknowledged` / `terminal_er` / `terminal_er_verified` in one confirmed
operator action. Mutations without eligible evidence remain unresolved and
must use the advanced flow below.

1. List unresolved rows with
`GET /api/admin/outbound-mutations/?requiresReconciliation=true`, then fetch the
mutation timeline by its opaque mutation UUID. Never paste customer values
Expand Down
23 changes: 23 additions & 0 deletions frontend/css/styles.css
Original file line number Diff line number Diff line change
Expand Up @@ -592,6 +592,29 @@ tr.row-stale > td.row-stale-actions { opacity: 1; }
border-radius: 4px; font: 11px/1.4 ui-monospace, Menlo, Consolas, monospace;
white-space: pre-wrap; word-break: break-word; max-height: 240px; overflow: auto;
}
.outbound-guided-resolution {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--space-5);
margin: 0 0 var(--space-4);
padding: var(--space-4);
border: 1px solid rgba(45, 166, 111, .55);
border-radius: var(--radius-md);
background: rgba(45, 166, 111, .10);
}
.outbound-guided-resolution[hidden] { display: none; }
.outbound-guided-resolution p { margin: .25rem 0 0; color: var(--muted); }
.outbound-advanced-reconciliation {
margin-top: var(--space-4);
padding-top: var(--space-3);
border-top: 1px solid var(--border);
}
.outbound-advanced-reconciliation > summary {
cursor: pointer;
color: var(--muted);
font-weight: 600;
}
.killed-tag {
font-size: .65rem; margin-right: var(--space-3);
}
Expand Down
78 changes: 45 additions & 33 deletions frontend/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -800,6 +800,15 @@ <h3>Outbound mutation reconciliation</h3>
</label>
<button type="submit" class="btn btn-secondary btn-sm">Filter</button>
</form>
<div id="outbound-guided-resolution" class="outbound-guided-resolution" hidden>
<div>
<strong id="outbound-guided-resolution-title"></strong>
<p>Each mutation already has a venue terminal Execution Report. The evidence and reason will be selected automatically.</p>
</div>
<button id="outbound-resolve-confirmed-batch" type="button" class="btn btn-primary btn-sm">
Resolve confirmed mutations
</button>
</div>
<div class="table-scroll">
<table>
<thead>
Expand All @@ -813,39 +822,42 @@ <h3>Outbound mutation reconciliation</h3>
</div>
<div id="outbound-mutation-detail" class="outbound-mutation-detail" hidden>
<h4>Mutation <code id="outbound-detail-id"></code></h4>
<pre id="outbound-detail-summary" class="admin-eod-output"></pre>

<h5>Register authoritative evidence</h5>
<form id="outbound-evidence-form" class="admin-inline-form form-surface">
<select id="outbound-evidence-source-type" aria-label="Evidence source type">
<option value="venue_mass_action">venue_mass_action</option>
<option value="official_extract">official_extract</option>
</select>
<input id="outbound-evidence-reference" required maxlength="256" placeholder="evidence reference" aria-label="Evidence reference" />
<input id="outbound-evidence-coverage-start" required type="datetime-local" aria-label="Evidence coverage start (UTC)" />
<input id="outbound-evidence-coverage-end" required type="datetime-local" aria-label="Evidence coverage end (UTC)" />
<input id="outbound-evidence-attestation" required maxlength="256" placeholder="attestation reference" aria-label="Attestation reference" />
<button type="submit" class="btn btn-secondary btn-sm">Register evidence</button>
</form>

<h5>Propose resolution</h5>
<form id="outbound-resolve-form" class="admin-inline-form form-surface">
<select id="outbound-resolve-decision" aria-label="Resolution decision">
<option value="venue_acknowledged">venue_acknowledged</option>
<option value="venue_absent">venue_absent (requires 2nd admin approval)</option>
<option value="leave_ambiguous">leave_ambiguous</option>
</select>
<select id="outbound-resolve-evidence-type" aria-label="Resolution evidence type">
<option value="terminal_er">terminal_er</option>
<option value="contracted_not_applied">contracted_not_applied</option>
<option value="venue_mass_action">venue_mass_action</option>
<option value="official_extract">official_extract</option>
<option value="manual_annotation">manual_annotation</option>
</select>
<input id="outbound-resolve-evidence-reference" required maxlength="256" placeholder="evidence reference" aria-label="Resolution evidence reference" />
<input id="outbound-resolve-reason" required maxlength="256" placeholder="reason" aria-label="Resolution reason" />
<button type="submit" class="btn btn-danger btn-sm">Propose resolution</button>
</form>
<details class="outbound-advanced-reconciliation">
<summary>Advanced reconciliation</summary>
<pre id="outbound-detail-summary" class="admin-eod-output"></pre>

<h5>Register authoritative evidence</h5>
<form id="outbound-evidence-form" class="admin-inline-form form-surface">
<select id="outbound-evidence-source-type" aria-label="Evidence source type">
<option value="venue_mass_action">venue_mass_action</option>
<option value="official_extract">official_extract</option>
</select>
<input id="outbound-evidence-reference" required maxlength="256" placeholder="evidence reference" aria-label="Evidence reference" />
<input id="outbound-evidence-coverage-start" required type="datetime-local" aria-label="Evidence coverage start (UTC)" />
<input id="outbound-evidence-coverage-end" required type="datetime-local" aria-label="Evidence coverage end (UTC)" />
<input id="outbound-evidence-attestation" required maxlength="256" placeholder="attestation reference" aria-label="Attestation reference" />
<button type="submit" class="btn btn-secondary btn-sm">Register evidence</button>
</form>

<h5>Propose resolution</h5>
<form id="outbound-resolve-form" class="admin-inline-form form-surface">
<select id="outbound-resolve-decision" aria-label="Resolution decision">
<option value="venue_acknowledged">venue_acknowledged</option>
<option value="venue_absent">venue_absent (requires 2nd admin approval)</option>
<option value="leave_ambiguous">leave_ambiguous</option>
</select>
<select id="outbound-resolve-evidence-type" aria-label="Resolution evidence type">
<option value="terminal_er">terminal_er</option>
<option value="contracted_not_applied">contracted_not_applied</option>
<option value="venue_mass_action">venue_mass_action</option>
<option value="official_extract">official_extract</option>
<option value="manual_annotation">manual_annotation</option>
</select>
<input id="outbound-resolve-evidence-reference" required maxlength="256" placeholder="evidence reference" aria-label="Resolution evidence reference" />
<input id="outbound-resolve-reason" required maxlength="256" placeholder="reason" aria-label="Resolution reason" />
<button type="submit" class="btn btn-danger btn-sm">Propose resolution</button>
</form>
</details>

<!-- A second, genuinely separate admin action — never a same-user
double-click. Hidden/disabled client-side (as a UX hint only;
Expand Down
69 changes: 69 additions & 0 deletions frontend/js/adminUi.js
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ let onLoadOutboundMutations = () => {};
let onLoadOutboundMutationDetail = () => {};
let onRegisterOutboundEvidence = () => {};
let onResolveOutboundMutation = () => {};
let onResolveVenueConfirmedBatch = () => {};
let onApproveOutboundMutation = () => {};
let currentUsername = null;

Expand All @@ -34,6 +35,7 @@ export function setAdminHandlers(handlers) {
onLoadOutboundMutationDetail = handlers.onLoadOutboundMutationDetail ?? onLoadOutboundMutationDetail;
onRegisterOutboundEvidence = handlers.onRegisterOutboundEvidence ?? onRegisterOutboundEvidence;
onResolveOutboundMutation = handlers.onResolveOutboundMutation ?? onResolveOutboundMutation;
onResolveVenueConfirmedBatch = handlers.onResolveVenueConfirmedBatch ?? onResolveVenueConfirmedBatch;
onApproveOutboundMutation = handlers.onApproveOutboundMutation ?? onApproveOutboundMutation;
// Used only as a client-side UX hint (hide/disable Approve for the
// proposer's own session) — the server independently rejects
Expand Down Expand Up @@ -165,6 +167,24 @@ function bindOutboundMutationsUi() {
renderOutboundMutationDetail();
});

$("outbound-resolve-confirmed-batch").addEventListener("click", async () => {
const candidates = venueConfirmedCandidates();
if (candidates.length === 0) return;
if (!window.confirm(
`Resolve ${candidates.length} venue-confirmed mutation${candidates.length === 1 ? "" : "s"} using the terminal Execution Reports already recorded by the platform?`,
)) return;
const button = $("outbound-resolve-confirmed-batch");
const originalLabel = button.textContent;
button.disabled = true;
button.textContent = "Resolving…";
try {
await onResolveVenueConfirmedBatch(candidates.map(mutation => mutation.mutationId));
} finally {
button.disabled = false;
button.textContent = originalLabel;
}
});

$("outbound-evidence-form").addEventListener("submit", async (e) => {
e.preventDefault();
if (!expandedMutationId) return;
Expand Down Expand Up @@ -349,6 +369,7 @@ function renderOutboundMutations() {
return;
}
const list = om.mutations ?? [];
renderGuidedResolution();
if (list.length === 0) {
body.innerHTML = `<tr><td colspan="7" class="muted">no matching mutations</td></tr>`;
return;
Expand All @@ -373,6 +394,54 @@ function renderOutboundMutations() {
}).join("");
}

function venueConfirmedCandidates() {
const list = getState().outboundMutations?.mutations ?? [];
return list.filter(mutation =>
mutation.requiresReconciliation === true
&& mutation.state === "venue_acknowledged"
&& mutation.pendingApproval !== true
);
}

function renderGuidedResolution() {
const panel = $("outbound-guided-resolution");
if (!panel) return;
const candidates = venueConfirmedCandidates();
panel.hidden = candidates.length === 0;
if (candidates.length === 0) return;
setText(
"outbound-guided-resolution-title",
`${candidates.length} venue-confirmed mutation${candidates.length === 1 ? "" : "s"} can be resolved safely.`,
);
const button = $("outbound-resolve-confirmed-batch");
if (button && !button.disabled) {
button.textContent = `Resolve ${candidates.length} confirmed mutation${candidates.length === 1 ? "" : "s"}`;
}
}

const TERMINAL_EXECUTION_REPORT_KINDS = new Set([
"rejected",
"canceled",
"fill",
"replaced",
"expired",
]);

export function findTerminalExecutionReportEvidence(detail) {
return (detail?.inboundEvidence ?? []).find(evidence => {
const evidenceId = evidence.evidenceId ?? evidence.EvidenceId;
const disposition = String(evidence.disposition ?? "").toLowerCase();
const messageKind = String(evidence.messageKind ?? "").toLowerCase();
const authoritativeDisposition = disposition === "matched"
|| (disposition === "conflicting"
&& evidence.authoritativeTerminalContradiction === true);
return evidence.kind === "execution_report"
&& authoritativeDisposition
&& TERMINAL_EXECUTION_REPORT_KINDS.has(messageKind)
&& /^[0-9a-f]{64}$/i.test(evidenceId ?? "");
}) ?? null;
}

function renderOutboundMutationDetail() {
const panel = $("outbound-mutation-detail");
if (!panel) return;
Expand Down
69 changes: 69 additions & 0 deletions frontend/js/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -198,6 +198,7 @@ async function init() {
onLoadOutboundMutationDetail: handleLoadOutboundMutationDetail,
onRegisterOutboundEvidence: handleRegisterOutboundEvidence,
onResolveOutboundMutation: handleResolveOutboundMutation,
onResolveVenueConfirmedBatch: handleResolveVenueConfirmedBatch,
onApproveOutboundMutation: handleApproveOutboundMutation,
});
operationsUi.setOperationsHandlers({
Expand Down Expand Up @@ -2519,6 +2520,74 @@ async function handleResolveOutboundMutation(mutationId, payload) {
}
}

async function handleResolveVenueConfirmedBatch(mutationIds) {
if (!session || !Array.isArray(mutationIds) || mutationIds.length === 0) return;
const captured = session;
let resolved = 0;
const unresolved = [];

for (const mutationId of mutationIds) {
if (session !== captured) return;
try {
const detail = await getOutboundMutation(
captured.backend,
captured.token,
mutationId,
);
// Re-check the freshly-fetched state rather than trusting the list
// snapshot the batch was built from — a mutation can flip out of
// venue_acknowledged (e.g. a late contradictory ER reopens it as
// ambiguous) between the list render and this call, and the guided
// "one click" path must only ever touch mutations that are still
// exactly the safe, common case it was designed for.
const detailMutation = detail?.mutation;
if (detailMutation?.state !== "venue_acknowledged"
|| detailMutation?.requiresReconciliation !== true
|| detailMutation?.pendingApproval === true) {
unresolved.push(mutationId);
continue;
}
const evidence = adminUi.findTerminalExecutionReportEvidence(detail);
const evidenceReference = evidence?.evidenceId ?? evidence?.EvidenceId;
if (!evidenceReference) {
unresolved.push(mutationId);
continue;
}
await resolveOutboundMutation(
captured.backend,
captured.token,
mutationId,
{
decision: "venue_acknowledged",
evidenceType: "terminal_er",
evidenceReference,
reason: "terminal_er_verified",
},
);
resolved += 1;
} catch (err) {
if (err.status === 401) {
logout();
return;
}
unresolved.push(mutationId);
}
}

await handleLoadOutboundMutations({ requiresReconciliation: true });
if (unresolved.length === 0) {
adminUi.setOutboundMutationsFeedback(
`${resolved} venue-confirmed mutation${resolved === 1 ? "" : "s"} resolved.`,
"ok",
);
return;
}
adminUi.setOutboundMutationsFeedback(
`${resolved} resolved; ${unresolved.length} still require advanced review.`,
"error",
);
}

async function handleApproveOutboundMutation(mutationId, proposalId) {
if (!session || !mutationId || !proposalId) return;
try {
Expand Down
46 changes: 46 additions & 0 deletions frontend/test/fixtures/outbound-mutations.json
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,22 @@
"command": null,
"pendingApproval": false
},
{
"mutationId": "44444444-4444-4444-4444-444444444444",
"firmId": "FIRM01",
"kind": "replace",
"state": "venue_acknowledged",
"origin": "trader",
"primaryClOrdId": "1003",
"originalClOrdId": "1001",
"recordedAtUtc": "2026-07-25T14:15:00Z",
"stateChangedAtUtc": "2026-07-26T17:01:05Z",
"requiresReconciliation": true,
"ambiguityReason": "conflicting_venue_evidence",
"encryptedFieldReferences": { "endClient": "alice", "fields": [] },
"command": null,
"pendingApproval": false
},
{
"mutationId": "22222222-2222-2222-2222-222222222222",
"firmId": "FIRM01",
Expand Down Expand Up @@ -82,6 +98,36 @@
"legacyEvidence": [],
"resolution": null
},
"detailVenueConfirmed": {
"mutation": {
"mutationId": "44444444-4444-4444-4444-444444444444",
"state": "venue_acknowledged",
"requiresReconciliation": true,
"pendingApproval": false
},
"attempts": [],
"inboundEvidence": [
{
"evidenceId": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
"kind": "execution_report",
"disposition": "matched",
"authoritativeTerminalContradiction": false,
"messageKind": "Replaced"
},
{
"evidenceId": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb",
"kind": "not_applied",
"disposition": "conflicting",
"authoritativeTerminalContradiction": false,
"messageKind": "NotApplied"
}
],
"proposals": [],
"authoritativeEvidence": [],
"operatorEvidence": [],
"legacyEvidence": [],
"resolution": null
},
"evidence": {
"evidenceReference": "extract-001",
"evidenceDigest": "sha256:abc",
Expand Down
Loading
Loading