yuzu

Response Store

The response store persists every instruction result returned by agents. It provides filtering, aggregation, and export capabilities so operators can analyze fleet-wide data without an external database.

Storage

Responses are stored in the server’s PostgreSQL substrate (schema response_store, ADR-0039 — see the “Storage substrate (PostgreSQL)” section below for the migration and retention details). Each response is recorded as a StoredResponse containing the agent identity, instruction metadata, execution status, result payload, and timing information.

SettingDefaultDescription
Retention period90 daysResponses older than this are deleted by the clock-guarded retention reap (response_retention_days)
Reap cadence~1 hourHow often the shared maintenance thread runs the expired-response reap

Response structure

Every stored response contains:

FieldTypeDescription
idintegerUnique identifier for the response
instruction_idstringIdentifier of the instruction that was executed
agent_idstringAgent that produced the response
timestampintegerUnix timestamp when the server recorded the response
statusintegerCommandResponse::Status enum value (0 = success, etc.)
outputstringCommand output text
error_detailstringError message if the execution failed

Typed result schemas

Every instruction definition declares a result schema specifying column names and types. This enables structured querying and ensures clean export to downstream analytics platforms.

TypeDescriptionExample value
boolBooleantrue
int3232-bit signed integer42
int6464-bit signed integer1099511627776
stringUTF-8 text"Windows 11 Pro"
datetimeISO 8601 timestamp"2024-03-19T14:30:00Z"
guidUUID"550e8400-e29b-41d4-a716-446655440000"
clobLarge text (configurable truncation)Full file contents, log output

Typed schemas make it straightforward to create ClickHouse tables, Splunk sourcetypes, or Excel pivot tables from exported data.

REST API

GET /api/responses/{instruction_id}

Retrieve responses for a specific instruction execution with optional filtering and pagination.

Forensics-tier rows are served only to a caller who holds Forensics:Read. The rows of app_usage, browser_inventory, execution_artifacts, privacy_permissions and remote_access_audit are absent, with no marker, for any other caller, and counts and truncation flags cover only the rows that caller may read, so an empty result does not prove that no data exists. A stored response is labelled with the plugin the server recorded when it dispatched the command; one whose command id has no server record is stored as ~unverified, which is served under the same tier. See “Forensics-tier rows” in the REST API guide. The dashboard’s create-group-from-results action reads facets through the same tier; when the server cannot determine the caller’s authority it creates nothing, answers HTTP 200, and shows an error toast (“The permission check for this action could not be completed. Nothing was created. Retry shortly.”) through an HX-Trigger showToast header, because the create-group form swaps no response body into the page; its store-degraded case answers 503 with its own error toast on the same channel and also creates nothing. The action writes group.create_from_results but no response.forensics.read row, even when the matching agents come from Forensics-tier rows (a documented gap in audit coverage; see the audit log guide).

Path parameters:

ParameterTypeDescription
instruction_idstringThe instruction execution to query

Query parameters:

ParameterTypeDescription
agent_idstringFilter by agent
statusintegerFilter by status (integer enum value)
sinceintegerOnly responses at or after this Unix timestamp (inclusive); omit for no lower bound, 0 is accepted and matches the same rows
untilintegerOnly responses at or before this Unix timestamp (inclusive); omit for no upper bound, must be 1 or more (0 is 400)
limitintegerNumber of responses to return (default 100, at most 1000; zero or below means the default). Asking for more than 1000 and getting a full page sets result_truncated_by_cap in the envelope
offsetintegerOffset for pagination (default 0)

Response envelope:

{
  "responses": [ ... ],
  "count": 42
}

When the caller asked for more than 1000 rows and the page came back full (exactly 1000 rows), the envelope also carries "result_truncated_by_cap": true: more rows may exist past the ceiling. The field is absent otherwise.

Example --- fetch all responses for an instruction:

curl -s -b cookies.txt \
  'http://localhost:8080/api/responses/instr-20240319-001' | jq .

Example --- filter by agent and status:

curl -s -b cookies.txt \
  'http://localhost:8080/api/responses/instr-20240319-001?agent_id=agent-007&status=0' | jq .

Example --- paginate through large result sets:

# Page 1 (first 100)
curl -s -b cookies.txt \
  'http://localhost:8080/api/responses/instr-20240319-001?limit=100&offset=0' | jq .

# Page 2 (next 100)
curl -s -b cookies.txt \
  'http://localhost:8080/api/responses/instr-20240319-001?limit=100&offset=100' | jq .

Example --- find all failures:

curl -s -b cookies.txt \
  'http://localhost:8080/api/responses/instr-20240319-001?status=1' | jq '.responses[] | {agent_id, error_detail}'

Aggregation

GET /api/responses/{instruction_id}/aggregate

The response store supports server-side aggregation so operators can compute fleet-wide statistics without downloading every row.

Query parameters:

ParameterTypeDescription
group_bystringColumn to group by (default: status). Common values: status, agent_id
opstringAggregation function: count (default), sum, avg, min, max
op_columnstringColumn for sum/avg/min/max operations
agent_idstringFilter by agent
statusintegerFilter by status
sinceintegerOnly responses at or after this Unix timestamp (inclusive); omit for no lower bound, 0 is accepted and matches the same rows
untilintegerOnly responses at or before this Unix timestamp (inclusive); omit for no upper bound, must be 1 or more (0 is 400)

Response:

{
  "instruction_id": "instr-20240319-001",
  "groups": [
    { "group_value": "0", "count": 35, "aggregate_value": 0.0 },
    { "group_value": "1", "count": 7, "aggregate_value": 0.0 }
  ],
  "total_groups": 2,
  "total_rows": 42
}

Supported functions

Functionop valueDescription
CountcountNumber of matching responses (default)
SumsumSum of a numeric column
AverageavgAverage of a numeric column
MinimumminMinimum value of a column
MaximummaxMaximum value of a column

Drill-down

Operators can click an aggregate row in the dashboard to drill down into the individual responses that compose it. The dashboard passes the group key as filter parameters to the response query endpoint.

Data export

GET /api/responses/{instruction_id}/export

The dedicated export endpoint supports both CSV and JSON formats with the same filter parameters as the query endpoint. It defaults to a higher limit (10,000 rows) for bulk exports. Numeric parameters must be one whole base-10 integer (since=1e9, limit=100abc, status=0x1 and since=1.5 are 400, not a different filter); omitting since or until means no bound on that side, since=0 is accepted and matches the same rows as omitting it, and until=0 or a negative value is 400.

Query parameters:

ParameterTypeDescription
formatstringExport format: csv or json (default: json)
agent_idstringFilter by agent
statusintegerFilter by status
sinceintegerOnly responses at or after this Unix timestamp (inclusive); omit for no lower bound, 0 is accepted and matches the same rows
untilintegerOnly responses at or before this Unix timestamp (inclusive); omit for no upper bound, must be 1 or more (0 is 400)
limitintegerNumber of responses to export (default and maximum 10,000; zero or below serves one row)

Example --- export instruction responses as CSV:

curl -s -b cookies.txt \
  'http://localhost:8080/api/responses/instr-20240319-001/export?format=csv' \
  -o responses.csv

Example --- export as JSON:

curl -s -b cookies.txt \
  'http://localhost:8080/api/responses/instr-20240319-001/export?format=json' \
  -o responses.json

The CSV format includes the columns: id, instruction_id, agent_id, timestamp, status, output, error_detail.

An export omits the Forensics-tier rows a caller may not read. For a caller without Forensics:Read, the rows of the five Forensics-tier plugins are left out of the file without a marker, so such an export can under-report and is not evidence that a plugin returned nothing. An export that does serve such rows writes a response.forensics.read audit row (see the Audit log guide).

A bounded export can be cut. Besides the row limit, an export stops once the rows served carry 50 MiB of output plus error_detail (always on whole rows, and at least one row is served; the last row kept can run past the cap by up to its own size: about 4 MiB for text output, up to about 12 MiB for output dense in invalid bytes or NULs, because each field is cut to 2 MiB at ingest before invalid bytes and NULs are replaced by the 3-byte U+FFFD; this includes the error_detail written when a terminal frame closes a running row). The cap is not configurable. A cut export, whether by the row limit with more matching rows left, or by the byte cap, is marked, so check for it before trusting a bulk pull: the JSON envelope has a top-level "result_truncated_by_cap": true, and a CSV file ends with one extra trailer record, # result_truncated_by_cap cause=row_cap (or byte_cap) padded with empty fields to the header’s width. The trailer is the signal that reaches every consumer. Agent output is arbitrary, so a quoted cell can contain text that looks like the trailer: parse the file as CSV and read the final record, do not regex-match lines. Two out-of-body signals accompany it: a CSV response carries an X-Result-Truncated-By-Cap: true header, and the download is named responses-<instruction_id>-truncated.<json|csv> instead of responses-<instruction_id>.<json|csv>. A plain curl -o responses.csv ... keeps neither (curl picks the file name and drops the headers); curl -OJ keeps the name, and curl -sS -D - -o responses.csv ... prints the headers. An uncut export carries none of these. To read past a cap there is no cursor: pull once per agent_id, or set until to the oldest timestamp received (inclusive, so rows tied at that second return again: de-duplicate on id). In the JSON envelope, count is the number of rows served. The REST v1 twin (GET /api/v1/responses/{id}/export) marks a cut the same way, with the JSON flag under pagination and a 10-field CSV trailer.

Generic JSON-to-CSV export

POST /api/export/json-to-csv

A generic endpoint that accepts any JSON array in the request body and returns a CSV file. This can be used with data from any Yuzu API.

curl -s -b cookies.txt \
  'http://localhost:8080/api/v1/audit?limit=1000' \
  | jq -c '.data' \
  | curl -s -b cookies.txt \
      -H "Origin: http://localhost:8080" \
      -X POST \
      -H 'Content-Type: application/json' \
      -d @- \
      'http://localhost:8080/api/export/json-to-csv' -o audit.csv

Exportable data sources

The JSON-to-CSV endpoint accepts data from any Yuzu API. Common sources:

SourceHow to fetch
Instruction responsesGET /api/responses/{instruction_id}
Audit eventsGET /api/v1/audit (use .data from the envelope)
Inventory dataPer-plugin structured blobs via the REST API

Analytics integration

ClickHouse

Create a ClickHouse table matching the instruction’s typed result schema, then periodically load exported CSV or JSON files:

CREATE TABLE yuzu_disk_usage (
    agent_id    String,
    drive       String,
    total_gb    Int64,
    free_gb     Int64,
    collected   DateTime
) ENGINE = MergeTree()
ORDER BY (agent_id, collected);
# Export and load
curl -s -b cookies.txt \
  'http://localhost:8080/api/responses/disk-usage-001' \
  | curl -s -b cookies.txt \
      -H "Origin: http://localhost:8080" \
      -X POST -H 'Content-Type: application/json' -d @- \
      'http://localhost:8080/api/export/json-to-csv' \
  | clickhouse-client --query "INSERT INTO yuzu_disk_usage FORMAT CSV"

Splunk

Forward response data to Splunk via HEC:

curl -s -b cookies.txt \
  'http://localhost:8080/api/responses/instr-20240319-001' \
  | jq -c '.responses[]' \
  | while read -r row; do
      curl -s -X POST \
        -H "Authorization: Splunk YOUR-HEC-TOKEN" \
        -d "{\"sourcetype\": \"yuzu:response\", \"event\": ${row}}" \
        https://splunk.example.com:8088/services/collector/event
    done

Storage substrate (PostgreSQL, ADR-0039)

Since the Postgres migration (ADR-0006/0039), responses live in the server’s PostgreSQL database (schema response_store), not a local responses.db. Three operator-visible notes:

Retention and cleanup

Responses are retained for 90 days by default. The server reaps responses whose TTL has expired on a periodic guarded sweep (clock-guarded and capped, so a skewed clock or a large backlog cannot mass-delete or stall — the same retention-guard shape the audit log uses). Deletion is permanent.

To preserve data beyond the retention window, set up periodic exports using cron or a scheduled task:

#!/bin/bash
# /etc/cron.daily/yuzu-response-export
DATE=$(date +%Y-%m-%d)
for INSTR_ID in $(curl -s -b /opt/yuzu/cookies.txt \
  'http://localhost:8080/api/v1/instructions' | jq -r '.[].id'); do
    curl -s -b /opt/yuzu/cookies.txt \
      "http://localhost:8080/api/responses/${INSTR_ID}" \
      > "/var/log/yuzu/responses-${INSTR_ID}-${DATE}.json"
done

Planned features

FeatureRoadmapDescription
Response templatesPlannedPre-defined views with saved filters and column selections
Response offloadingPlannedForward response data to external HTTP endpoints automatically