Skip to content

Commit c746313

Browse files
committed
fix(orb): require telemetry export opt-in
1 parent 480f2c3 commit c746313

4 files changed

Lines changed: 41 additions & 24 deletions

File tree

.env.example

Lines changed: 12 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -179,19 +179,19 @@ GITTENSORY_REVIEW_DRAFT=false
179179
# # 1024-dimensional (e.g. bge-m3 or mxbai-embed-large via Ollama).
180180
# # Used only when RAG is enabled (GITTENSORY_REVIEW_RAG + allowlist).
181181

182-
# --- Gittensory Orb (#1255; ALWAYS-ON fleet-calibration telemetry) ---
183-
# TELEMETRY NOTICE: running this self-hosted image contributes anonymized gate-calibration data to
184-
# gittensory's central collector. This is ON BY DEFAULT and has no opt-out flag — it is part of the
185-
# self-hosting contract: install the GitHub App, and your instance reports fleet-calibration signal so the
186-
# gate can be tuned from real outcomes across all self-hosters. It activates automatically once your App is
187-
# configured (no App = nothing is sent). There is NO separate Orb App and NO setup wizard.
182+
# --- Gittensory Orb (#1255; opt-in fleet-calibration telemetry) ---
183+
# TELEMETRY NOTICE: Orb can contribute anonymized gate-calibration data from self-hosted instances to
184+
# gittensory's central collector, but outbound export is OFF unless you explicitly opt in with ORB_ENABLED.
185+
# It also requires the GitHub App private key to be configured (no App = nothing is sent). There is NO
186+
# separate Orb App and NO setup wizard.
188187
#
189-
# WHAT IS SENT (per resolved PR, hourly): the gate verdict, the realized outcome (merged/closed), a reversal
190-
# flag, a bucketed reason category, and cycle time. NEVER sent: repo/owner/PR names, commit SHAs, code,
191-
# diffs, comments, or logins. Repo/PR identifiers are HMAC-anonymized with a DEDICATED key derived from YOUR
192-
# OWN App private key (GITHUB_APP_PRIVATE_KEY) — high-entropy and independent of your webhook secret, so even
193-
# gittensory (running the collector) can never de-anonymize them.
194-
# The export carries no shared key; the collector treats it as untrusted, rate-limited, aggregate-only data.
188+
# WHAT IS SENT (per resolved PR, hourly, only when ORB_ENABLED is truthy): the gate verdict, the realized
189+
# outcome (merged/closed), a reversal flag, a bucketed reason category, and cycle time. NEVER sent by default:
190+
# repo/owner/PR names, commit SHAs, code, diffs, comments, or logins. Repo/PR identifiers are HMAC-anonymized
191+
# with a DEDICATED per-instance key stored in system_flags, so gittensory (running the collector) can never
192+
# de-anonymize them. The export carries no shared key; the collector treats it as untrusted, rate-limited,
193+
# aggregate-only data.
194+
# ORB_ENABLED=false # set true/1/yes/on to opt in to outbound Orb export
195195
# ORB_AIR_GAP=false # air-gapped/OFFLINE deployments only: compute locally, never send
196196
# ORB_ANONYMIZE=true # HMAC-hash repo/PR before export (default true; false = raw names)
197197
# ORB_COLLECTOR_URL=https://gittensory-api.aethereal.dev/v1/orb/ingest # gittensory's hosted collector (default; override for your own)

src/selfhost/orb-collector.ts

Lines changed: 10 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -3,10 +3,11 @@
33
// engine's outcomes-wire. This ships an anonymized, reversal-aware signal UP to gittensory's central
44
// collector so the gate can be calibrated across the whole self-host fleet.
55
//
6-
// Export is ALWAYS ON once the GitHub App is configured (the fleet-telemetry contract of self-hosting) —
7-
// there is no opt-out flag. It self-gates on a configured App private key (no App → no review data to
8-
// export anyway) and anonymizes with a DEDICATED, per-instance secret generated once and persisted in
9-
// system_flags (never the App private key or the webhook-verification secret — key separation).
6+
// Export is opt-in: set ORB_ENABLED=true after reviewing the telemetry contract. It also self-gates
7+
// on a configured App private key (no App → no review data to export anyway) and anonymizes with a
8+
// DEDICATED, per-instance secret generated once and persisted in system_flags (never the App private
9+
// key or the webhook-verification secret — key separation).
10+
// ORB_ENABLED=true — opt in to fleet-calibration export (default: false)
1011
// ORB_COLLECTOR_URL=<url> — endpoint (default: gittensory's hosted collector)
1112
// ORB_AIR_GAP=true — air-gapped/offline deployments only: compute locally, never send
1213
// ORB_ANONYMIZE=true — HMAC-hash repo/PR before export (default: true)
@@ -149,11 +150,13 @@ function cycleTimeMs(decidedAt: string, outcomeAt: string): number | null {
149150

150151
/**
151152
* Export newly-resolved PR outcomes (since this instance's watermark) to the central collector. Reads from
152-
* review_audit (de-noised, reversal-aware), anonymizes, signs, POSTs, then advances the cursor. Always on.
153-
* Returns the number of events exported (0 if air-gapped, the App isn't configured, or nothing new).
153+
* review_audit (de-noised, reversal-aware), anonymizes, signs, POSTs, then advances the cursor.
154+
* Returns the number of events exported (0 if disabled, air-gapped, the App isn't configured, or nothing new).
154155
*/
155156
export async function exportOrbBatch(db: D1Database, batchSize = 200, fetchFn: typeof fetch = fetch): Promise<number> {
156-
// Always on (no opt-out). Air-gapped/offline deployments may suppress the outbound call.
157+
if (!/^(1|true|yes|on)$/i.test(process.env.ORB_ENABLED ?? "")) return 0;
158+
159+
// Air-gapped/offline deployments may suppress the outbound call even after opting in.
157160
if ((process.env.ORB_AIR_GAP ?? "").toLowerCase() === "true") return 0;
158161

159162
// No App configured → no review data to export anyway. Gate export on the App being set up.

src/server.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -355,8 +355,8 @@ async function main(): Promise<void> {
355355
);
356356
}, intervalMs);
357357

358-
// Orb fleet-telemetry export — ALWAYS ON (the fleet-calibration contract of self-hosting). Self-gates
359-
// inside exportOrbBatch: a no-op until the GitHub App is configured, or when ORB_AIR_GAP=true.
358+
// Orb fleet-telemetry export — opt-in inside exportOrbBatch; also a no-op until the GitHub App
359+
// is configured, or when ORB_AIR_GAP=true.
360360
const runOrbExport = () =>
361361
exportOrbBatch(backend.db)
362362
.then((n) => { if (n > 0) console.log(JSON.stringify({ event: "selfhost_orb_export", exported: n })); })

test/unit/selfhost-orb-collector.test.ts

Lines changed: 17 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -66,17 +66,31 @@ describe("getOrCreateAnonSecret()", () => {
6666
});
6767
});
6868

69-
describe("exportOrbBatch() — always-on; reads review_audit, ships anonymized reversal-aware signal", () => {
69+
describe("exportOrbBatch() — opt-in; reads review_audit, ships anonymized reversal-aware signal", () => {
7070
beforeEach(() => {
7171
resetMetrics();
7272
(process.env as NodeJS.Dict<string>).GITHUB_APP_PRIVATE_KEY = "test-private-key"; // gates export (App configured); not the anon key
73+
process.env.ORB_ENABLED = "true";
7374
process.env.ORB_APP_ID = "555";
7475
process.env.ORB_ANONYMIZE = "true";
7576
delete process.env.ORB_AIR_GAP;
7677
delete process.env.ORB_COLLECTOR_URL;
7778
});
7879
afterEach(() => {
79-
for (const k of ["GITHUB_APP_PRIVATE_KEY", "ORB_APP_ID", "ORB_ANONYMIZE", "ORB_AIR_GAP", "ORB_COLLECTOR_URL", "GITHUB_APP_ID"]) delete (process.env as NodeJS.Dict<string>)[k];
80+
for (const k of ["GITHUB_APP_PRIVATE_KEY", "ORB_ENABLED", "ORB_APP_ID", "ORB_ANONYMIZE", "ORB_AIR_GAP", "ORB_COLLECTOR_URL", "GITHUB_APP_ID"]) delete (process.env as NodeJS.Dict<string>)[k];
81+
});
82+
83+
it("returns 0 unless Orb export is explicitly enabled", async () => {
84+
delete process.env.ORB_ENABLED;
85+
const db = makeDb();
86+
await audit(db, "o/r", 1, "gate_decision", "merge", "2026-01-01T00:00:00Z");
87+
await audit(db, "o/r", 1, "pr_outcome", "merged", "2026-01-01T01:00:00Z");
88+
expect(await exportOrbBatch(db, 200, async () => new Response(null, { status: 200 }))).toBe(0);
89+
90+
for (const off of ["", "false", "no", "0", "off"]) {
91+
process.env.ORB_ENABLED = off;
92+
expect(await exportOrbBatch(db, 200, async () => new Response(null, { status: 200 }))).toBe(0);
93+
}
8094
});
8195

8296
it("returns 0 when the App private key is not configured (App not set up → nothing to export)", async () => {
@@ -179,7 +193,7 @@ describe("exportOrbBatch() — always-on; reads review_audit, ships anonymized r
179193
expect(sig).toMatch(/^sha256=[a-f0-9]{64}$/);
180194
});
181195

182-
it("falls back to GITHUB_APP_ID for the instance id and applies the anonymize default when ORB_* are unset", async () => {
196+
it("falls back to GITHUB_APP_ID for the instance id and applies the anonymize default when optional ORB_* are unset", async () => {
183197
delete process.env.ORB_APP_ID; // → falls through to GITHUB_APP_ID
184198
delete process.env.ORB_ANONYMIZE; // → defaults to "true"
185199
(process.env as NodeJS.Dict<string>).GITHUB_APP_ID = "999";

0 commit comments

Comments
 (0)