Normalize actionable dashboard payload vocabulary
All checks were successful
deploy / deploy (push) Successful in 34s

Proof: Operator-facing dashboard payloads no longer expose the forbidden actionable decision vocabulary while preserving lifecycle truth and executor-versus-strategy separation.
Assumptions: Existing stored decision records may still use actionable internally, so the dashboard layer must normalize them before exposure.
Still fake: Downstream venue completion evidence is still unavailable for submission-only rows, so submitted remains a non-terminal evidence state.
This commit is contained in:
philipp 2026-04-09 18:05:54 +02:00
parent e35bb9ab8f
commit 715a0aec50
2 changed files with 82 additions and 4 deletions

View file

@ -131,7 +131,7 @@ const CONTROL_DEFINITIONS = [
method: 'POST',
path: '/arm',
label: 'Arm Executor',
description: 'Persistently arm trade-executor so actionable decisions can submit.',
description: 'Persistently arm trade-executor so strategy-approved decisions can submit.',
page: 'system',
risk_class: 'safe',
},
@ -736,6 +736,7 @@ const HUMAN_REASON_TEXT = {
quote_response_ok: 'Quote response accepted by the relay.',
reason_unknown: 'Reason not recorded.',
stale_reference_price: 'Reference price is stale.',
strategy_approved: 'Strategy approved the quote.',
strategy_disarmed: 'Strategy is disarmed.',
submission_failed: 'Submission failed.',
unsupported_pair: 'Unsupported pair.',
@ -910,7 +911,7 @@ function finalizeLifecycleRow(row) {
} else if (decision?.decision) {
lifecycle_state = 'evaluated';
lifecycle_label = 'Approved by strategy';
reason_code = normalizeLifecycleToken(decision?.decision_reason || 'actionable');
reason_code = normalizeLifecycleToken(decision?.decision_reason || 'strategy_approved');
reason_text = 'Strategy approved the quote, but no durable execute command is recorded yet.';
}
@ -1206,6 +1207,7 @@ function normalizeTradeForUi({ config, trade }) {
return {
...trade,
decision_reason: normalizeDecisionReason(trade.decision_reason),
asset_in_symbol: assetIn?.symbol || trade.asset_in,
asset_out_symbol: assetOut?.symbol || trade.asset_out,
amount_in_display: formatUnits(trade.amount_in || '0', assetIn?.decimals || 0),
@ -1341,8 +1343,8 @@ function normalizeDecision(decision) {
pair: decision.pair || null,
direction: decision.direction || null,
request_kind: decision.request_kind || null,
decision: decision.decision || null,
decision_reason: decision.decision_reason || null,
decision: normalizeDecisionVerdict(decision.decision),
decision_reason: normalizeDecisionReason(decision.decision_reason),
gross_edge_pct: decision.gross_edge_pct || null,
threshold_pct: decision.threshold_pct || null,
max_notional_eure: decision.max_notional_eure || null,
@ -1352,6 +1354,16 @@ function normalizeDecision(decision) {
};
}
function normalizeDecisionVerdict(value) {
if (value === 'actionable') return 'approved';
return value || null;
}
function normalizeDecisionReason(value) {
if (value === 'actionable') return 'strategy_approved';
return value || null;
}
function normalizeAlertList(alerts) {
return (alerts || []).map(normalizeAlert).sort((left, right) => sortTimestamps(
right.raised_at || right.first_raised_at || right.cleared_at,

View file

@ -299,6 +299,7 @@ test('strategy approval no longer renders the forbidden actionable label', () =>
assert.equal(rows[0].lifecycle_state, 'evaluated');
assert.equal(rows[0].lifecycle_label, 'Approved by strategy');
assert.notEqual(rows[0].lifecycle_label, 'Actionable');
assert.equal(rows[0].reason_code, 'strategy_approved');
});
test('bootstrap aggregation keeps Funds as default and carries live control state', () => {
@ -496,6 +497,71 @@ test('bootstrap aggregation keeps Funds as default and carries live control stat
assert.equal(bootstrap.strategy.strategy_state.recent_lifecycle_rows[0].reason_code, 'strategy_disarmed');
});
test('bootstrap normalizes actionable decision vocabulary before exposing it to the dashboard', () => {
const config = buildConfig();
const bootstrap = buildDashboardBootstrap({
config,
auth: { authenticated: true, subject: 'local-operator', mode: 'stub', roles: ['operator'] },
portfolioMetric: null,
inventorySnapshot: null,
marketPrice: null,
recentQuotes: [],
submissionPage: {
page: 1,
page_size: 20,
total: 1,
total_pages: 1,
items: [{
quote_id: 'quote-1',
pair: config.activePair,
observed_at: '2026-04-04T09:10:03.000Z',
status: 'submitted',
result_code: 'quote_response_ok',
decision_reason: 'actionable',
}],
},
submissionSummary: {
total: 1,
last_submission_at: '2026-04-04T09:10:03.000Z',
},
fundingObservations: [],
recentDepositStatuses: [],
recentTradeDecisions: [{
observed_at: '2026-04-04T09:10:00.000Z',
payload: {
decision_id: 'decision-1',
quote_id: 'quote-1',
pair: config.activePair,
decision: 'actionable',
decision_reason: 'actionable',
},
}],
recentExecuteTradeCommands: [{
observed_at: '2026-04-04T09:10:01.000Z',
command_id: 'cmd-1',
decision_id: 'decision-1',
quote_id: 'quote-1',
pair: config.activePair,
}],
recentExecutionResults: [{
command_id: 'cmd-1',
decision_id: 'decision-1',
quote_id: 'quote-1',
pair: config.activePair,
result_at: '2026-04-04T09:10:03.000Z',
status: 'submitted',
result_code: 'quote_response_ok',
}],
recentAlertTransitions: [],
serviceSnapshots: [],
});
assert.equal(bootstrap.funds.submission_ledger.items[0].decision_reason, 'strategy_approved');
assert.equal(bootstrap.strategy.strategy_state.recent_decisions[0].decision, 'approved');
assert.equal(bootstrap.strategy.strategy_state.recent_lifecycle_rows[0].reason_code, 'quote_response_ok');
assert.doesNotMatch(JSON.stringify(bootstrap), /Actionable/);
});
test('system service health uses sentinel-derived severity so stale ingest is never shown healthy', () => {
const config = buildConfig();
const bootstrap = buildDashboardBootstrap({