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
36 changes: 29 additions & 7 deletions actions/publish-runtime-execution-evidence/action.yml
Original file line number Diff line number Diff line change
Expand Up @@ -103,12 +103,34 @@ runs:
--output "$snapshot_path" \
"${report_args[@]}"

if ! curl --fail --silent --show-error \
--request POST "${INPUT_SYNC_URL}/api/internal/sync-execution-evidence-source" \
--header "Authorization: Bearer ${EXECUTION_EVIDENCE_SYNC_TOKEN}" \
--header "Content-Type: application/json" \
--data-binary "@$snapshot_path" \
>/dev/null 2>&1; then
echo "Unable to sync the read-only execution-evidence snapshot." >&2
receipt_id="$(python3 -c 'from uuid import uuid4; print(uuid4())')"
receipt_path="$work_dir/execution-evidence-receipt.json"
if ! http_status="$(printf 'Authorization: Bearer %s\n' "$EXECUTION_EVIDENCE_SYNC_TOKEN" |
curl --fail --silent --show-error --max-time 30 --max-filesize 4096 \
--request POST "${INPUT_SYNC_URL}/api/internal/sync-execution-evidence-source" \
--header @- \
--header "Content-Type: application/json" \
--header "X-QSL-Execution-Evidence-Receipt: $receipt_id" \
--data-binary "@$snapshot_path" \
--output "$receipt_path" --write-out '%{http_code}' 2>/dev/null)" ||
[[ ! "$http_status" =~ ^2[0-9][0-9]$ ]]; then
echo "Unconfirmed execution-evidence upload. Do not replay this upload." >&2
exit 1
fi

if ! deployment_count="$(jq -en --slurpfile request "$snapshot_path" \
--slurpfile response "$receipt_path" --arg receipt_id "$receipt_id" '
$request[0] as $snapshot
| select(($response | length) == 1)
| $response[0]
| select(.ok == true and .receipt_id == $receipt_id
and .source_id == $snapshot.source_id and .schema_version == $snapshot.schema_version
and .deployment_count == ($snapshot.deployments | length)
and has("generated_at") and .generated_at == $snapshot.generated_at)
| .deployment_count
' 2>/dev/null)"; then
echo "Unverified execution-evidence receipt. Do not replay this upload." >&2
exit 1
fi
printf 'Execution-evidence receipt: http=%s deployments=%s id=%s\n' \
"$http_status" "$deployment_count" "$receipt_id"
37 changes: 31 additions & 6 deletions docs/console-api.md
Original file line number Diff line number Diff line change
Expand Up @@ -107,12 +107,10 @@ null and do not establish amount availability or cost inclusion.
Dietz cells also include `cashflow_method: "modified_dietz"` and an explicit
`approximate` boolean, true only when a contributing interval contains a flow
before its end. Stock daily groups can include weekend intervals; the boolean
covers those contributing intervals, not the whole return chain. Native daily
replacement sets its own method and removes inherited Dietz flags. Untouched
legacy/unavailable cells have no new metadata. Monthly cells retain their existing
values and shape; no monthly Dietz calculation or metadata is added. These are
additive backend fields; frontend consumers may continue reading the existing
fields, and frontend types and rendering are unchanged by this contract addition.
covers those contributing intervals, not the whole return chain. Calendar cells
replaced by native TWR carry the same method and unit fields at both daily and
monthly granularity and omit Dietz flags. Incomplete native months retain null
values and their coverage reason. Legacy cells retain their existing fields.

`limit` defaults to 25 and accepts 1–100. `next_cursor` is an opaque string or
`null`. Pass it unchanged as `cursor`. Histories sort newest first. Decision
Expand Down Expand Up @@ -612,6 +610,20 @@ keep every failure and do not retry failed acceptance into a pass. A timeout sto
acceptance/promotion. This guard does not claim global propagation is atomic and
does not remove transient platform responses before readiness.

### Historical positions

Authenticated `GET /api/account-facts/history?platform=…&account_key=…&projection=positions`
returns account-bound daily observations from verified history. Optional `from` and
`to` are inclusive ISO dates. Each observation contains the actual
`observed_finished_at`, `positions`, `positions_scope` and `positions_complete`.
Position entries carry the stored symbol, quantity, market value and currency.
They include `currency_source` when the stored observation specifies one.
A null `positions` means the source did not report positions; an empty array with
`positions_complete: true` is a reported empty inventory within its stated scope.
A null completeness value does not establish a complete inventory. Observation
instants do not assert end-of-day holdings. Only existing observations within the
history retention limit are returned; missing days are not interpolated.

### External-flow coverage inspection

Authenticated `GET /api/account-facts/history?platform=…&account_key=…&projection=external-flow-coverage`
Expand Down Expand Up @@ -832,3 +844,16 @@ UNRECONCILED dimensions. Render unknown fields without zeros or inferred dates,
in zh-CN/en and light/dark. Backend/schema/tests remain owned by the backend
maintainer; frontend files, actions, task-list workflows and assets are outside
this patch. Runtime collection, deployed adoption and delivery remain NOT_PROVEN.

### Broker execution report intervals

Execution coverage remains partial and does not establish a complete trading history.
Each `reports` entry with `period_status=observed_report` identifies a received
report period. Repeated periods are deduplicated, retaining the latest observation.
At most 512 distinct periods per source binding are retained; `reports_truncated`
indicates older intervals were omitted. Existing stored aggregates predate interval
tracking: their `period_status=legacy_aggregate_bounds_unknown` marks bounds only,
not an individual report or proof of coverage within the bounds. New observations
are recorded alongside that legacy marker. If a rollback writes a changed
aggregate without recording intervals, the bounds are marked unknown again
while the previously observed intervals remain visible.
13 changes: 13 additions & 0 deletions docs/execution-evidence-runtime-projection.md
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,19 @@ configured platform prefix, and POSTs only the generated snapshot to
long-lived GCP key. Credentials and runtime-report object URLs must never be
added to this repository or emitted to logs.

The Action requests a receipt with a fresh, opaque
`X-QSL-Execution-Evidence-Receipt` identifier. The receiver echoes it as
`receipt_id` after saving the snapshot and includes it in the existing
best-effort audit entry. The Action validates the HTTP status and matches the
identifier, source, schema, deployment count, and report timestamp against the
upload. It logs only the status, count, and opaque identifier. A zero count
means an empty projection. An unverified or missing receipt leaves the upload
outcome uncertain. The Action does not retry that upload.

Deploy the compatible receiver before changing a caller to the new Action
version. Requests without the receipt header keep the previous response and
audit format. The audit remains bounded and can omit a successful upload.

## 生产端接入边界

执行回执是一个通用接口,但不允许平台为了“看起来完整”而猜测成交。当前接入的
Expand Down
112 changes: 112 additions & 0 deletions python/tests/test_execution_evidence_action_receipt.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
"""Exercise projection and receipt handling in the composite sender."""

import os
from pathlib import Path
import re
import subprocess
import sys
import tempfile
import unittest

ROOT = Path(__file__).resolve().parents[2]
ACTION = ROOT / "actions/publish-runtime-execution-evidence"

CURL_FIXTURE = r"""
import json
import os
from pathlib import Path
import sys

args = sys.argv[1:]
mode = os.environ["TEST_RESPONSE"]
# The former action discarded its body and did not ask curl for the status.
if "--output" not in args:
sys.exit(0)
assert "synthetic-bearer" not in " ".join(args)
header_args = [args[i + 1] for i, value in enumerate(args) if value == "--header"]
assert "@-" in header_args
header_lines = [line for line in header_args if line != "@-"] + sys.stdin.read().splitlines()
headers = dict(line.split(": ", 1) for line in header_lines)
assert headers["Authorization"] == "Bearer synthetic-bearer"
snapshot = json.loads(Path(args[args.index("--data-binary") + 1][1:]).read_text())
receipt = {
"ok": True,
"source_id": snapshot["source_id"],
"schema_version": snapshot["schema_version"],
"deployment_count": len(snapshot["deployments"]),
"generated_at": snapshot["generated_at"],
"receipt_id": headers["X-QSL-Execution-Evidence-Receipt"],
"ignored": "synthetic-untrusted-body",
}
if mode == "wrong-id":
receipt["receipt_id"] = "other"
if mode == "wrong-source":
receipt["source_id"] = "other"
if mode == "wrong-schema":
receipt["schema_version"] = "other"
if mode == "wrong-count":
receipt["deployment_count"] = 1
if mode == "wrong-time":
receipt["generated_at"] = "2020-01-01T00:00:00Z"
if mode == "missing-time":
del receipt["generated_at"]
if mode == "wrong-ok":
receipt["ok"] = False
body = json.dumps(receipt)
if mode == "html":
body = "<html>synthetic-untrusted-body</html>"
if mode == "multiple":
body += "\n" + body
Path(args[args.index("--output") + 1]).write_text(body)
print("302" if mode == "redirect" else "200", end="")
if mode == "transport-failure":
sys.exit(7)
"""


class ExecutionEvidenceActionReceiptTest(unittest.TestCase):
def run_sender(self, response):
action = (ACTION / "action.yml").read_text()
# Skip cloud enumeration and run the shipped projection and transport.
projection = action.split(" snapshot_path=", 1)[1]
script = (
'set -euo pipefail\nwork_dir="$TEST_WORK_DIR"\nreport_args=(--runtime-report "$work_dir/fixture.json")\n'
'snapshot_path=' + projection
)
with tempfile.TemporaryDirectory() as directory:
executable_dir = Path(directory)
(executable_dir / "fixture.json").write_text("{}")
path = executable_dir / "curl"
path.write_text(f"#!{sys.executable}\n" + CURL_FIXTURE)
path.chmod(0o700)
env = dict(
os.environ,
PATH=f"{executable_dir}{os.pathsep}{os.environ['PATH']}",
GITHUB_ACTION_PATH=str(ACTION),
INPUT_SOURCE_ID="synthetic-source",
INPUT_SYNC_URL="https://synthetic.example",
INPUT_MAX_REPORT_AGE_HOURS="24",
EXECUTION_EVIDENCE_SYNC_TOKEN="synthetic-bearer",
TEST_RESPONSE=response,
TEST_WORK_DIR=directory,
)
return subprocess.run(["bash", "-c", script], env=env, capture_output=True, text=True)

def test_emits_only_bound_receipt_metadata(self):
result = self.run_sender("ok")
self.assertEqual(result.returncode, 0, result.stderr)
self.assertTrue(re.fullmatch(
r"Execution-evidence receipt: http=200 deployments=0 id=[0-9a-f-]{36}\n",
result.stdout,
))
self.assertEqual(result.stderr, "")

def test_rejects_redirects_and_unbound_receipts_without_exposing_response(self):
for mode in ("redirect", "html", "multiple", "wrong-id", "wrong-source", "wrong-schema",
"wrong-count", "wrong-time", "missing-time", "wrong-ok", "transport-failure"):
with self.subTest(mode=mode):
result = self.run_sender(mode)
self.assertEqual(result.returncode, 1)
self.assertEqual(result.stdout, "")
self.assertIn("execution-evidence", result.stderr)
self.assertNotIn("synthetic", result.stderr)
37 changes: 37 additions & 0 deletions tests/account_facts_history_receiver_validation.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -204,6 +204,43 @@ try {
hkd.broker_reported_balances[0].currency = "HKD";
hkd.cash[0].currency = "HKD";
assert.equal((await post(first, hkd)).status, 200);
// Positions are observations at the stored instant, not end-of-day snapshots.
const schwab = fixtures.find(f => f.platform === "schwab");
const partial = { ...payload(schwab, 4, currentId), broker_reported_positions_scope: "strategy_symbols_only",
broker_reported_positions: [
{ symbol: "ABC", quantity: "2", market_value: "20", currency: "USD", avg_cost: "8" },
{ symbol: "XYZ", quantity: "3", market_value: "30", currency: "USD", currency_source: "owner_confirmed" },
] };
const empty = { ...payload(schwab, 5, currentId), broker_reported_positions_scope: "stocks_only",
broker_reported_positions_complete: true, broker_reported_positions: [] };
assert.equal((await post(schwab, partial)).status, 200);
assert.equal((await post(schwab, empty)).status, 200);
const positionsUrl = `https://synthetic.example/api/account-facts/history?platform=schwab&account_key=${schwab.option.key}&projection=positions`;
assert.equal((await worker.fetch(new Request(positionsUrl), env)).status, 401);
response = await worker.fetch(new Request(positionsUrl, { headers }), env);
const observations = (await response.json()).observations;
assert.deepEqual(observations.find(row => row.observation_date === partial.observation_date), {
observation_date: partial.observation_date, observed_finished_at: partial.observed_finished_at,
positions: [
{ symbol: "ABC", quantity: "2", market_value: "20", currency: "USD" },
{ symbol: "XYZ", quantity: "3", market_value: "30", currency: "USD", currency_source: "owner_confirmed" },
],
positions_scope: "strategy_symbols_only", positions_complete: null });
assert.deepEqual(observations.find(row => row.observation_date === empty.observation_date), {
observation_date: empty.observation_date, observed_finished_at: empty.observed_finished_at,
positions: [], positions_scope: "stocks_only", positions_complete: true });
assert.equal(observations.find(row => row.observation_date === dateAgo(10)).positions, null);
response = await worker.fetch(new Request(`${positionsUrl}&from=${partial.observation_date}&to=${partial.observation_date}`, { headers }), env);
assert.deepEqual((await response.json()).observations.map(row => row.observation_date), [partial.observation_date]);
assert.equal((await worker.fetch(new Request(`${positionsUrl}&from=2026-02-30`, { headers }), env)).status, 400);
assert.equal((await worker.fetch(new Request(`${positionsUrl}&from=${partial.observation_date}&to=${empty.observation_date}`, { headers }), env)).status, 400);
const otherUrl = `https://synthetic.example/api/account-facts/history?platform=longbridge&account_key=${first.option.key}&projection=positions`;
response = await worker.fetch(new Request(otherUrl, { headers }), env);
assert.ok((await response.json()).observations.every(row => row.positions === null));
await kv.put("account_options", JSON.stringify({ schwab: [{ ...schwab.option, account_scope: "paper" }] }));
assert.equal((await worker.fetch(new Request(positionsUrl, { headers }), env)).status, 409);
await kv.put("account_options", JSON.stringify(Object.fromEntries(fixtures.map(f => [f.platform, [f.option]]))));

const currenciesUrl = `https://synthetic.example/api/account-facts/history?platform=longbridge&account_key=${first.option.key}&projection=currencies`;
response = await worker.fetch(new Request(currenciesUrl), env);
assert.equal(response.status, 401, "currency enumeration retains session authentication");
Expand Down
Loading
Loading