Skip to content

Commit e6ac155

Browse files
Pigbibicodex
andauthored
feat: publish bounded runtime execution evidence (#281)
Co-authored-by: Codex <noreply@openai.com>
1 parent efdeed5 commit e6ac155

4 files changed

Lines changed: 182 additions & 12 deletions

File tree

Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,114 @@
1+
name: Publish runtime execution evidence
2+
description: Build and publish a bounded, read-only execution-evidence source snapshot from runtime reports.
3+
4+
inputs:
5+
source-id:
6+
description: Stable, non-sensitive source identity.
7+
required: true
8+
report-platform:
9+
description: Runtime-report platform prefix (for example ibkr, longbridge, or schwab).
10+
required: true
11+
report-prefix:
12+
description: Read-only Cloud Storage prefix that contains runtime_report.v1 files.
13+
required: true
14+
sync-url:
15+
description: HTTPS Strategy Switch Console base URL.
16+
required: true
17+
max-reports:
18+
description: Maximum recent reports to read in one invocation (1-100).
19+
required: false
20+
default: "100"
21+
max-report-age-hours:
22+
description: Maximum report age accepted by the fail-closed projection (0.084-168).
23+
required: false
24+
default: "36"
25+
26+
runs:
27+
using: composite
28+
steps:
29+
- name: Build and sync bounded execution evidence
30+
shell: bash
31+
env:
32+
INPUT_SOURCE_ID: ${{ inputs.source-id }}
33+
INPUT_REPORT_PLATFORM: ${{ inputs.report-platform }}
34+
INPUT_REPORT_PREFIX: ${{ inputs.report-prefix }}
35+
INPUT_SYNC_URL: ${{ inputs.sync-url }}
36+
INPUT_MAX_REPORTS: ${{ inputs.max-reports }}
37+
INPUT_MAX_REPORT_AGE_HOURS: ${{ inputs.max-report-age-hours }}
38+
run: |
39+
set -euo pipefail
40+
41+
if [[ -z "${EXECUTION_EVIDENCE_SYNC_TOKEN:-}" ]]; then
42+
echo "EXECUTION_EVIDENCE_SYNC_TOKEN is required." >&2
43+
exit 2
44+
fi
45+
if [[ ! "$INPUT_SOURCE_ID" =~ ^[A-Za-z0-9._=-]{1,128}$ ]]; then
46+
echo "source-id must be a stable non-sensitive identity." >&2
47+
exit 2
48+
fi
49+
if [[ ! "$INPUT_REPORT_PLATFORM" =~ ^(alpaca|binance|firstrade|ibkr|longbridge|qmt|schwab)$ ]]; then
50+
echo "report-platform is unsupported." >&2
51+
exit 2
52+
fi
53+
if [[ ! "$INPUT_REPORT_PREFIX" =~ ^gs://[A-Za-z0-9._-]+(/[A-Za-z0-9._=-]+)*$ ]]; then
54+
echo "report-prefix must be a bounded Cloud Storage prefix." >&2
55+
exit 2
56+
fi
57+
if [[ ! "$INPUT_SYNC_URL" =~ ^https://[^/?#]+$ ]]; then
58+
echo "sync-url must be an HTTPS base URL without a path or query." >&2
59+
exit 2
60+
fi
61+
if [[ ! "$INPUT_MAX_REPORTS" =~ ^[1-9][0-9]?$ ]] || (( INPUT_MAX_REPORTS > 100 )); then
62+
echo "max-reports must be between 1 and 100." >&2
63+
exit 2
64+
fi
65+
if [[ ! "$INPUT_MAX_REPORT_AGE_HOURS" =~ ^[0-9]+([.][0-9]+)?$ ]]; then
66+
echo "max-report-age-hours must be numeric." >&2
67+
exit 2
68+
fi
69+
70+
work_dir="$(mktemp -d)"
71+
cleanup() { rm -rf "$work_dir"; }
72+
trap cleanup EXIT
73+
object_listing="$work_dir/objects.json"
74+
report_prefix="${INPUT_REPORT_PREFIX%/}/${INPUT_REPORT_PLATFORM}/"
75+
76+
if ! gcloud storage objects list "${report_prefix}**" \
77+
--sort-by='~update_time' \
78+
--limit="$INPUT_MAX_REPORTS" \
79+
--format='json(storage_url,update_time)' \
80+
>"$object_listing" 2>/dev/null; then
81+
echo "Unable to list runtime reports with the configured read-only identity." >&2
82+
exit 1
83+
fi
84+
85+
mapfile -t report_objects < <(jq -r '.[] | select((.storage_url // "") | endswith(".json")) | .storage_url' "$object_listing")
86+
87+
report_args=()
88+
index=0
89+
for object_name in "${report_objects[@]}"; do
90+
report_path="$work_dir/report-$index.json"
91+
if ! gcloud storage cp --quiet "$object_name" "$report_path" >/dev/null 2>&1; then
92+
echo "Unable to read a listed runtime report; no partial snapshot was published." >&2
93+
exit 1
94+
fi
95+
report_args+=(--runtime-report "$report_path")
96+
index=$((index + 1))
97+
done
98+
99+
snapshot_path="$work_dir/execution-evidence-source.json"
100+
python3 "$GITHUB_ACTION_PATH/../../python/scripts/execution_evidence_projection.py" \
101+
--source-id "$INPUT_SOURCE_ID" \
102+
--max-report-age-hours "$INPUT_MAX_REPORT_AGE_HOURS" \
103+
--output "$snapshot_path" \
104+
"${report_args[@]}"
105+
106+
if ! curl --fail --silent --show-error \
107+
--request POST "${INPUT_SYNC_URL}/api/internal/sync-execution-evidence-source" \
108+
--header "Authorization: Bearer ${EXECUTION_EVIDENCE_SYNC_TOKEN}" \
109+
--header "Content-Type: application/json" \
110+
--data-binary "@$snapshot_path" \
111+
>/dev/null 2>&1; then
112+
echo "Unable to sync the read-only execution-evidence snapshot." >&2
113+
exit 1
114+
fi

docs/execution_evidence_runtime_projection.md

Lines changed: 15 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -26,8 +26,18 @@ that reason every projected record sets `target_data` and `target_execution` to
2626
`target_execution_evidence_missing`. The projection never emits an autonomous
2727
paper/shadow recommendation or a live approval.
2828

29-
The script writes a local JSON file only. A future scheduled publisher must use
30-
a distinct `EXECUTION_EVIDENCE_SYNC_TOKEN` and a least-privilege read identity
31-
for the selected runtime-report prefix, then POST that file to
32-
`/api/internal/sync-execution-evidence-source`. Those credentials must remain
33-
in protected secret stores and must never be added to this repository.
29+
Reports older than its bounded freshness window (36 hours by default), or more
30+
than five minutes in the future, are discarded. The output's `generated_at`
31+
retains the oldest accepted report timestamp rather than the collector time, so
32+
collection cannot make old evidence appear current.
33+
34+
The script writes a local JSON file only. The reusable composite Action at
35+
`actions/publish-runtime-execution-evidence` performs the optional publishing
36+
step. Its caller must first authenticate with its existing GitHub OIDC
37+
workload identity, and must provide a distinct
38+
`EXECUTION_EVIDENCE_SYNC_TOKEN` through the protected Actions secret store.
39+
The Action lists at most 100 recent report objects, reads no report outside the
40+
configured platform prefix, and POSTs only the generated snapshot to
41+
`/api/internal/sync-execution-evidence-source`. It does not need or create a
42+
long-lived GCP key. Credentials and runtime-report object URLs must never be
43+
added to this repository or emitted to logs.

python/scripts/execution_evidence_projection.py

Lines changed: 36 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@
2020
import json
2121
import re
2222
from collections.abc import Iterable, Mapping
23-
from datetime import UTC, datetime
23+
from datetime import UTC, datetime, timedelta
2424
from pathlib import Path
2525
from typing import Any
2626

@@ -55,6 +55,7 @@ def build_execution_evidence_source_snapshot(
5555
*,
5656
source_id: str,
5757
now: datetime | None = None,
58+
max_report_age: timedelta = timedelta(hours=36),
5859
) -> dict[str, Any]:
5960
"""Project eligible runtime reports into the Worker source schema.
6061
@@ -63,7 +64,9 @@ def build_execution_evidence_source_snapshot(
6364
is copied to the output.
6465
"""
6566
normalized_source_id = _identity(source_id, "source_id")
66-
computed_at = _timestamp(now or datetime.now(UTC))
67+
computed_at_value = _normalize_now(now)
68+
if max_report_age < timedelta(minutes=5) or max_report_age > timedelta(days=7):
69+
raise ExecutionEvidenceProjectionError("max_report_age is outside safe bounds")
6770
latest_by_deployment: dict[str, tuple[datetime, dict[str, Any]]] = {}
6871
errors: set[str] = set()
6972

@@ -73,18 +76,28 @@ def build_execution_evidence_source_snapshot(
7376
except ExecutionEvidenceProjectionError as exc:
7477
errors.add(str(exc))
7578
continue
79+
if observed_at > computed_at_value + timedelta(minutes=5):
80+
errors.add("runtime_report_timestamp_future")
81+
continue
82+
if observed_at < computed_at_value - max_report_age:
83+
errors.add("runtime_report_stale")
84+
continue
7685
previous = latest_by_deployment.get(deployment["deployment_id"])
7786
if previous is None or observed_at > previous[0]:
7887
latest_by_deployment[deployment["deployment_id"]] = (observed_at, deployment)
7988

80-
deployments = [entry[1] for _, entry in sorted(latest_by_deployment.items())]
89+
selected = [entry for _, entry in sorted(latest_by_deployment.items())]
90+
deployments = [entry[1] for entry in selected]
8191
if not deployments:
8292
errors.add("runtime_report_no_eligible_records")
8393
return {
8494
"schema_version": SOURCE_SCHEMA_VERSION,
8595
"source_id": normalized_source_id,
86-
"generated_at": computed_at,
87-
"computed_at": computed_at,
96+
# The Worker uses the older of generated_at/computed_at for freshness.
97+
# Preserve the oldest included observation so a fresh collection cannot
98+
# make an old platform report appear current.
99+
"generated_at": _timestamp(min(entry[0] for entry in selected)) if selected else None,
100+
"computed_at": _timestamp(computed_at_value),
88101
"data_status": "ready" if deployments else "unavailable",
89102
"deployments": deployments,
90103
"errors": sorted(errors)[:20],
@@ -219,6 +232,13 @@ def _timestamp(value: datetime) -> str:
219232
return value.astimezone(UTC).replace(microsecond=0).isoformat().replace("+00:00", "Z")
220233

221234

235+
def _normalize_now(value: datetime | None) -> datetime:
236+
resolved = value or datetime.now(UTC)
237+
if resolved.tzinfo is None or resolved.utcoffset() is None:
238+
raise ExecutionEvidenceProjectionError("now must be timezone-aware")
239+
return resolved.astimezone(UTC)
240+
241+
222242
def _reject_duplicate_keys(pairs: list[tuple[str, Any]]) -> dict[str, Any]:
223243
result: dict[str, Any] = {}
224244
for key, value in pairs:
@@ -232,6 +252,12 @@ def _parse_args(argv: list[str] | None = None) -> argparse.Namespace:
232252
parser = argparse.ArgumentParser(description=__doc__)
233253
parser.add_argument("--source-id", required=True, help="stable non-sensitive source identity")
234254
parser.add_argument("--runtime-report", action="append", default=[], help="path to one runtime_report.v1 JSON document")
255+
parser.add_argument(
256+
"--max-report-age-hours",
257+
type=float,
258+
default=36,
259+
help="discard reports older than this bounded freshness window (default: 36)",
260+
)
235261
parser.add_argument("--output", required=True, help="path for the generated source snapshot")
236262
return parser.parse_args(argv)
237263

@@ -245,7 +271,11 @@ def main(argv: list[str] | None = None) -> int:
245271
reports.append(load_runtime_report(path))
246272
except ExecutionEvidenceProjectionError as exc:
247273
input_errors.append(str(exc))
248-
snapshot = build_execution_evidence_source_snapshot(reports, source_id=args.source_id)
274+
snapshot = build_execution_evidence_source_snapshot(
275+
reports,
276+
source_id=args.source_id,
277+
max_report_age=timedelta(hours=args.max_report_age_hours),
278+
)
249279
snapshot["errors"] = sorted(set([*snapshot["errors"], *input_errors]))[:20]
250280
output_path = Path(args.output)
251281
output_path.parent.mkdir(parents=True, exist_ok=True)

python/tests/test_execution_evidence_projection.py

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -55,7 +55,8 @@ def test_projects_only_attested_identity_and_keeps_execution_pending(self):
5555
)
5656
self.assertEqual(snapshot["schema_version"], "qsl_execution_evidence_source_snapshot.v1")
5757
self.assertEqual(snapshot["data_status"], "ready")
58-
self.assertEqual(snapshot["generated_at"], "2026-08-25T16:05:00Z")
58+
self.assertEqual(snapshot["generated_at"], "2026-08-25T16:00:00Z")
59+
self.assertEqual(snapshot["computed_at"], "2026-08-25T16:05:00Z")
5960
self.assertEqual(len(snapshot["deployments"]), 1)
6061
deployment = snapshot["deployments"][0]
6162
self.assertEqual(deployment["target"], {"platform": "longbridge", "environment": "paper"})
@@ -105,6 +106,21 @@ def test_keeps_only_the_latest_report_per_deployment(self):
105106
self.assertEqual(len(snapshot["deployments"]), 1)
106107
self.assertEqual(snapshot["deployments"][0]["strategy"]["strategy_revision"], "b" * 40)
107108

109+
def test_rejects_stale_and_future_reports_instead_of_refreshing_them(self):
110+
stale = self._report(finished_at="2026-08-23T16:00:00Z")
111+
future = self._report(finished_at="2026-08-25T16:11:00Z")
112+
snapshot = projection.build_execution_evidence_source_snapshot(
113+
[stale, future],
114+
source_id="runtime-reports",
115+
now=datetime(2026, 8, 25, 16, 5, tzinfo=UTC),
116+
)
117+
self.assertEqual(snapshot["data_status"], "unavailable")
118+
self.assertEqual(snapshot["errors"], [
119+
"runtime_report_no_eligible_records",
120+
"runtime_report_stale",
121+
"runtime_report_timestamp_future",
122+
])
123+
108124
def test_cli_rejects_duplicate_json_keys_and_writes_a_fail_closed_snapshot(self):
109125
with tempfile.TemporaryDirectory() as directory:
110126
root = Path(directory)

0 commit comments

Comments
 (0)