ores.reporting.report_error_event

Table of Contents

+#+updated: 2026-10-17

One error a report run produced, held against the run. A report run is one execution of a report definition, and a run whose analytics fail is still a run that completed: the engine finished, wrote its report, and said in its own log that a trade would not build. Those errors are facts about the run, they belong to it, and they are what this table holds.

An error is a fact about an instant: it is written once and never changed. The model therefore states :current_state: and :immutable: in the * SQL ** Flags drawer, as ores.reporting.report_trigger_event does. A current-state immutable table has no validity window and no audit tail: there is no version, no valid_from=/=valid_to, and no modified_by, performed_by, change_reason_code or change_commentary. Because it has no audit tail, the time is a column of its own, event_time.

event_time is the moment the errors were collected from the run's output. The canonical rows do state the engine's own time, as the text the engine wrote, and the service's log row keeps it; this column does not hold it. The recorder writes the collection instant because it is an instant the service can state about its own act, and storing the engine's text as a timestamp here would put two different facts in one column. A story that needs the engine's instant adds a column for it.

A row says four things: how bad the error is, what it is about, what it said, and which subject it names. The first is severity. The second is scope_code, drawn from the engine's own vocabulary, and it is what places the error: Trade on the trade, Analytics on the analytic, Curve on the curve, and Report on the run itself. The third is message, the engine's own words.

The fourth is subject and subject_id, a discriminator and a value, holding the identifier of the thing the error names: trade and a trade id, analytic and an analytic type, curve and a curve id. It is a discriminator and a value rather than one column because the ids are of different kinds — a trade id is a UUID the portfolio builder minted, a curve id is a free-text name such as [IndexCurve(GBP-SONIA,Yield/GBP/GBP1D)] — and one column holding both would be a text column no reader can key on. It is not a database foreign key for the same reason: the engine names trades by its own ids and curves by their ORE names, and neither is a key any table here holds. The pair is stored so that a screen can find every error about one subject, and it is a discriminator rather than a fixed column per kind because the engine adds sub-fields to the groups it already has, as the corpus shows it doing when an Analytics error grew a tradeId.

scope_code and subject are not the same question. The scope is the group the engine emitted, which is what the error is about at the level the engine named it; the subject is the particular thing, and it can be narrower than the scope. An Analytics error that names a tradeId is about that trade and not about the analytic as a whole, and its subject says so.

A run that fails before the engine sees it, because a workflow step did not complete, produces a row too, with scope Report. That is why the table is one table rather than two: a reader opening a report wants its errors in one place.

1. Flags

2. Columns

2.1. id

UUID uniquely identifying this error row.

2.2. report_instance_id

The run the error belongs to. It is a column and not part of the key: a run's errors are its own rows, and the id of an error is what addresses one.

2.3. party_id

Party that owns this error (inherited from the run it belongs to).

ctx.generate_uuid()

2.4. scope_code

What the error is about, as the engine grouped it. Refers to ores_reporting_report_error_scopes_tbl by its code. The column is scope_code rather than scope because scope is a reserved query parameter of the generated list read, and a filterable member may not take its name.

A synthetic row states the one scope that needs no subject, because a subject cannot be synthesized: the run a trade id belongs to is not a thing a fixture can invent.

std::string("Report")

2.5. severity

How bad the error is: error or warning. The engine states this per message as its category, and an ALERT the engine wrote is an error here. A warning is an error the engine chose to continue past, which is why it is the same table: the repair is the reader's to judge, and hiding it would be the mistake that produced this task.

A synthetic row is a warning rather than an error: the severity is a closed set the store checks, and a fixture that drew one at random would be refused.

std::string("warning")

2.6. message

The engine's own words for the error, unaltered.

2.7. subject

What kind of thing subject_id names: trade, analytic, curve or configuration. Absent when the error is about the run as a whole, which the database enforces: an absent subject is admitted only under the Report scope.

2.8. subject_id

Identifier of the subject, in the kind subject states. Absent with subject.

2.9. subject_version

The version of the subject the run read, when the subject is versioned. A trade is one of many rows under one id, so a link to the trade that caused an error names the version as well as the id, and resolves to the row the run saw rather than to whatever the trade is now.

2.10. subject_as_of

The instant the subject was current at, when the subject is versioned. It reads beside subject_version: the version alone names a row, and the instant says when that row was the live one.

2.11. cause

What the error is about, said in a person's words rather than the engine's: the curve that would not build, or the curve a failed trade needed. It is worked out once, where the engine's log is read, because the engine states a trade's curve only inside the message it writes. A screen that repeated that reading would be a second implementation of the engine's format, and the shell would have none at all.

2.12. detail

The failure's own fields for the message, as a JSON object, when the node stated any: the exception that says which failure this is, and the kind of warning when it was one. The message alone does not carry them, and they are what tells two errors of one scope apart, so they are kept rather than dropped.

2.13. event_time

When the errors were collected from the run's output. There is no engine instant to copy: the structured log states no time, so this is the moment of collection, written by the recorder.

3. SQL

3.1. Flags

3.2. Checks

expression
("subject" is null) = ("subject_id" is null)
"severity" in ('error', 'warning')
("subject" is not null) or ("subject_version" is null and "subject_as_of" is null)

The first is the invariant that matters to a reader: a row names a subject in full or names none, and never half of one, and the version and the instant it was current at belong to the subject rather than stand alone, so a screen that looks a row up by its subject either finds it or is told the row is about the run. That a row naming no subject is a row about the run is what the scope says, and the writer that reads the engine's log is where the two are kept in step.

3.3. Indexes

name columns unique current_only where_extra
subject tenant_id, subject, subject_id false true  
run_subject tenant_id, report_instance_id, scope_code, subject, subject_id, message, detail true false subject_id is not null and severity = 'error'
run_report tenant_id, report_instance_id, scope_code, message, detail true false subject_id is null and severity = 'error'

The two unique indexes say what makes one error row the same as another. A row that names a subject is the same row when the run, the scope, the subject kind, the subject id, the message and the detail agree. The detail is what tells one analytic's failure from another's on the same trade. A row that names no subject is the same row when the run and the scope agree, and its subject is now a scope code so that two different scopes cannot collide.

Only errors are made unique. A warning the engine writes twice is a warning about two things, and the run keeps both.

They exist because the step that reads a run's output can run more than once at the same time. The recorder asks the database whether the run already holds its errors, and two callers can both find none; the index lets one of them win and the other fail rather than write the reasons twice.

4. Foreign keys

4.1. report_instance_id

A database foreign key, because the run is written before its errors are: the errors are collected from the run's own output, which exists only once the run has been admitted. The column is on the parent's key and not a value beside it, so the cascade reaches the errors when the run goes.

An error carries no version bump: a report instance's version says which facts about the run are current, and an error is not one of them.

4.2. scope_code

The scope vocabulary is system reference data, so the check reads that tenant whatever tenant owns the error.

5. Delete cascade

No table. A real foreign key is what the run's own delete reaches, and the errors are not children the run must close itself: the store holds the constraint, and the delete rule that closes a row already runs.

6. Messages

The count of a run's problems, so a screen that draws a run pays one row for its problems rather than one row per error. The aggregation happens in the store, and the error rows themselves never leave it.

6.1. report_error_count

One run's problems, counted apart by severity. The severity is a closed text column, so the split needs no vocabulary read.

6.1.1. report_instance_id

/** @brief The run the counts are of. */

6.1.2. errors

/** @brief How many of the run's problems are errors. */

6.1.3. warnings

/** @brief How many are warnings. */

6.2. get_report_error_counts_request

/**
 * @brief Counts the problems of each named run in one call.
 *
 * The caller asks once with the runs a read of the latest runs returned, so a
 * list of forty definitions pays one row per run rather than every error of
 * every run. The request names no party: the session scopes the answer through
 * the row-level security on the table.
 */

6.2.1. report_instance_ids

/** @brief The runs to count the problems of, in any order. */

6.3. get_report_error_counts_response

6.3.1. result

6.3.2. counts

/**
 * @brief One entry per run that recorded a problem, and no entry for a run
 * that recorded none.
 *
 * The absence is the screen's zero: nothing was found against that run.
 */

7. C++

7.1. Flags

7.2. Repository

7.3. Domain includes

#include <chrono>
#include <optional>
#include <string>
#include <boost/uuid/uuid.hpp>

7.4. Entity includes

#include <chrono>
#include <optional>
#include <string>
#include <boost/uuid/uuid.hpp>

7.5. Conventions

7.6. Table display

column header
report_instance_id Report Instance
scope_code Scope
severity Severity
subject Subject
subject_id Subject Id
message Message
event_time Event Time

7.7. Presentation

7.7.1. Detail fields

field label type is_key is_required placeholder
scope_code Scope line_edit   true Error scope
severity Severity line_edit   true error or warning
subject Subject line_edit     trade, analytic, …
subject_id Subject Id line_edit     Identifier of the subject
message Message text_edit   true The engine's own words
event_time Event Time line_edit   true When it was collected

7.7.2. Columns

enum_name field header type width
ReportInstanceId report_instance_id Report Instance uuid 200
ScopeCode scope_code Scope string 140
Severity severity Severity string 100
Subject subject Subject string 140
SubjectId subject_id Subject Id string 250
Message message Message string 400
EventTime event_time Event Time timestamp 160

7.8. Custom repository methods

7.8.1. read_counts_by_report_instance_ids

Counts a run's problems by severity, one row per run that recorded any. The generated list answers rows, so a caller that wanted a count would read every error of every run and count them in C++; the store answers the question in one statement instead, and the rows never leave it.

/**
 * @brief Counts the errors and the warnings of each run named.
 *
 * A run that recorded no problem has no entry. The rows of other parties
 * are not the caller's to read: the policy on the table decides that, not
 * this method.
 */
std::vector<messaging::report_error_count>
read_counts_by_report_instance_ids(context ctx,
                                   const std::vector<boost::uuids::uuid>& report_instance_ids);
#include <boost/lexical_cast.hpp>
std::vector<messaging::report_error_count>
report_error_event_repository::read_counts_by_report_instance_ids(
    context ctx, const std::vector<boost::uuids::uuid>& report_instance_ids) {
    if (report_instance_ids.empty())
        return {};

    BOOST_LOG_SEV(lg(), debug) << "Counting the problems of "
                               << report_instance_ids.size() << " run(s).";

    // One bound array rather than one parameter per id, so a list of forty
    // runs is still one statement.
    std::string id_array("{");
    for (std::size_t i = 0; i < report_instance_ids.size(); ++i) {
        if (i > 0)
            id_array += ',';
        id_array += boost::uuids::to_string(report_instance_ids[i]);
    }
    id_array += '}';

    // The tenant and the policy on the table narrow the rows exactly as they do
    // for the generated reads; no party is stated here.
    const auto rows = query_builder::execute_parameterized_multi_column_query(
        ctx,
        "SELECT report_instance_id::text,"
        "       count(*) FILTER (WHERE severity = 'error')::text,"
        "       count(*) FILTER (WHERE severity = 'warning')::text"
        " FROM ores_reporting_report_error_events_tbl"
        " WHERE tenant_id = $1::uuid"
        "   AND report_instance_id = ANY($2::uuid[])"
        " GROUP BY report_instance_id",
        {ctx.tenant_id().to_string(), id_array},
        lg(),
        "Counting the problems of each run");

    std::vector<messaging::report_error_count> counts;
    counts.reserve(rows.size());
    for (const auto& row : rows) {
        if (row.size() < 3 || !row[0] || !row[1] || !row[2])
            continue;
        messaging::report_error_count count;
        count.report_instance_id = boost::lexical_cast<boost::uuids::uuid>(*row[0]);
        count.errors = std::stoll(*row[1]);
        count.warnings = std::stoll(*row[2]);
        counts.push_back(std::move(count));
    }
    return counts;
}

7.9. Custom service methods

7.9.1. get_report_error_counts

/**
 * @brief The problems of each named run, counted apart by severity.
 *
 * A run that recorded no problem has no entry, which is what the screen
 * draws as no problems rather than as a row of zeroes.
 */
messaging::get_report_error_counts_response
get_report_error_counts(const messaging::get_report_error_counts_request& request);
messaging::get_report_error_counts_response
report_error_event_service::get_report_error_counts(
    const messaging::get_report_error_counts_request& request) {
    messaging::get_report_error_counts_response response;
    // The cap is the same one the generated filters state, so one caller cannot
    // ask the store for more runs than the list reads allow.
    if (request.report_instance_ids.size() > 1000) {
        response.result = refuse(outcome_code::filter_too_large,
                                 {.field = "report_instance_ids", .limit = "1000"});
        return response;
    }
    response.counts = repo_.read_counts_by_report_instance_ids(ctx_, request.report_instance_ids);
    return response;
}

7.10. Custom NATS handler methods

/**
 * @brief Serves reporting.v1.ops.get_report_error_counts.
 *
 * A read of the errors of the party's runs, so it is gated by the same code
 * the generated error reads are gated by.
 */
void get_report_error_counts(ores::nats::message msg) {
    BOOST_LOG_SEV(report_error_event_handler_lg(), debug) << "Handling " << msg.subject;
    auto req_ctx_expected = ores::service::service::make_request_context(ctx_, msg, verifier_);
    if (!req_ctx_expected) {
        error_reply(nats_, msg, req_ctx_expected.error());
        return;
    }
    const auto& req_ctx = *req_ctx_expected;
    [[maybe_unused]] const auto correlation_id = ores::service::messaging::log_handler_entry(
        report_error_event_handler_lg(), msg, req_ctx.tenant_id().to_string());
    if (!has_permission(req_ctx, "reporting::report_error_events:read")) {
        error_reply(nats_, msg, ores::service::error_code::forbidden);
        return;
    }
    auto req = decode<get_report_error_counts_request>(msg);
    if (!req) {
        BOOST_LOG_SEV(report_error_event_handler_lg(), warn)
            << "Failed to decode: " << msg.subject;
        error_reply(nats_, msg, ores::service::error_code::bad_request);
        return;
    }
    service::report_error_event_service svc(req_ctx);
    try {
        auto response = svc.get_report_error_counts(*req);
        BOOST_LOG_SEV(report_error_event_handler_lg(), debug) << "Completed " << msg.subject;
        reply(nats_, msg, response);
    } catch (const std::exception& e) {
        BOOST_LOG_SEV(report_error_event_handler_lg(), error)
            << msg.subject << " failed: " << e.what();
        get_report_error_counts_response failure;
        failure.result.outcome = ores::utility::domain::outcome::failed;
        failure.result.code = "internal_error";
        failure.result.message = e.what();
        reply(nats_, msg, failure);
    }
}

7.11. Custom NATS subscriptions

subs.push_back(nats.queue_subscribe(
    get_report_error_counts_request::nats_subject,
    report_error_event_queue_group,
    [h](ores::nats::message msg) { h->get_report_error_counts(std::move(msg)); }));

8. See also

Emacs 29.3 (Org mode 9.6.15)