Skip to content

Commit 8b628fe

Browse files
committed
feat(orb): make fleet telemetry always-on; key anonymization off the App secret
Remove the ORB_ENABLED opt-in — Orb fleet-calibration export is now ALWAYS ON (the contract of self-hosting gittensory). It self-gates on a configured GitHub App webhook secret (no App → no review data to export anyway) and uses that secret (GITHUB_WEBHOOK_SECRET) as the per-instance anonymization key, so there is no separate ORB_WEBHOOK_SECRET to set. ORB_AIR_GAP stays for air-gapped/offline deployments only. .env.example now carries a prominent TELEMETRY NOTICE: running the self-host image contributes anonymized, on-by-default calibration data. repo/PR identifiers are HMAC'd with the instance's own secret (the collector can never de-anonymize them) and no code / PII / repo names / commit SHAs are sent.
1 parent 5c3d9a1 commit 8b628fe

4 files changed

Lines changed: 42 additions & 56 deletions

File tree

.env.example

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

164-
# --- Gittensory Orb (#1255; opt-in fleet-calibration export) ---
165-
# Orb is the central collector + analytics that aggregates anonymized gate-calibration data UP from
166-
# self-hosted instances. There is NO separate Orb GitHub App and NO setup wizard: your existing main App
167-
# already records de-noised outcomes (merged/closed + reversals) locally — flip ORB_ENABLED to ship an
168-
# anonymized signal to gittensory's collector. That's it: no second App, no extra secret, no wizard.
164+
# --- Gittensory Orb (#1255; ALWAYS-ON fleet-calibration telemetry) ---
165+
# TELEMETRY NOTICE: running this self-hosted image contributes anonymized gate-calibration data to
166+
# gittensory's central collector. This is ON BY DEFAULT and has no opt-out flag — it is part of the
167+
# self-hosting contract: install the GitHub App, and your instance reports fleet-calibration signal so the
168+
# gate can be tuned from real outcomes across all self-hosters. It activates automatically once your App is
169+
# configured (no App = nothing is sent). There is NO separate Orb App and NO setup wizard.
169170
#
170-
# SECURITY MODEL (this image is self-hosted by many independent maintainers):
171-
# • The image bakes NO secrets. repo/PR identifiers are HMAC-anonymized with YOUR own ORB_WEBHOOK_SECRET
172-
# (a stable per-instance string), so even gittensory (running the collector) can never de-anonymize them.
173-
# • Export carries NO shared key. The collector accepts the batch as untrusted, rate-limited, aggregate-only
174-
# telemetry. Nothing in the container, if leaked, can compromise the collector, other operators, or any App.
175-
# ORB_ENABLED=false # master switch: set to true to export fleet-calibration signal (default off)
176-
# ORB_WEBHOOK_SECRET=<stable-random-string> # the per-instance HMAC key used to anonymize repo/PR identifiers
177-
# ORB_AIR_GAP=false # set to true to compute locally but never send to the collector
171+
# WHAT IS SENT (per resolved PR, hourly): the gate verdict, the realized outcome (merged/closed), a reversal
172+
# flag, a bucketed reason category, and cycle time. NEVER sent: repo/owner/PR names, commit SHAs, code,
173+
# diffs, comments, or logins. Repo/PR identifiers are HMAC-anonymized with YOUR OWN GitHub App webhook
174+
# secret (GITHUB_WEBHOOK_SECRET), so even gittensory (running the collector) can never de-anonymize them.
175+
# The export carries no shared key; the collector treats it as untrusted, rate-limited, aggregate-only data.
176+
# ORB_AIR_GAP=false # air-gapped/OFFLINE deployments only: compute locally, never send
178177
# ORB_ANONYMIZE=true # HMAC-hash repo/PR before export (default true; false = raw names)
179178
# 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 & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -3,9 +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-
// ORB_ENABLED=true — activates export (off by default)
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 webhook secret (no App → no review data to
8+
// export anyway), and uses that secret as the per-instance anonymization key.
79
// ORB_COLLECTOR_URL=<url> — endpoint (default: gittensory's hosted collector)
8-
// ORB_AIR_GAP=true — keep everything local, never send externally
10+
// ORB_AIR_GAP=true — air-gapped/offline deployments only: compute locally, never send
911
// ORB_ANONYMIZE=true — HMAC-hash repo/PR before export (default: true)
1012
//
1113
// No diffs, no code, no comments, no logins, no commit SHAs — only verdict + outcome + reversal + a bucketed
@@ -69,12 +71,6 @@ export function bucketReasonCode(summary: string | null | undefined): string {
6971
return "other";
7072
}
7173

72-
/** Returns true only when Orb export is explicitly enabled. */
73-
export function orbEnabled(): boolean {
74-
const v = (process.env.ORB_ENABLED ?? "").toLowerCase();
75-
return v === "true" || v === "1" || v === "yes";
76-
}
77-
7874
// Latest gate_decision + latest pr_outcome per target_id, plus any reversal — portable (window functions +
7975
// CASE, no SQLite-only bare-column-with-MAX) so it runs on the self-host SQLite OR Postgres backend.
8076
const FLEET_QUERY = `
@@ -122,17 +118,18 @@ function cycleTimeMs(decidedAt: string, outcomeAt: string): number | null {
122118

123119
/**
124120
* Export newly-resolved PR outcomes (since this instance's watermark) to the central collector. Reads from
125-
* review_audit (de-noised, reversal-aware), anonymizes, signs, POSTs, then advances the cursor.
126-
* Returns the number of events exported (0 if air-gap, disabled, or nothing new).
121+
* review_audit (de-noised, reversal-aware), anonymizes, signs, POSTs, then advances the cursor. Always on.
122+
* Returns the number of events exported (0 if air-gapped, the App isn't configured, or nothing new).
127123
*/
128124
export async function exportOrbBatch(db: D1Database, batchSize = 200, fetchFn: typeof fetch = fetch): Promise<number> {
129-
if (!orbEnabled()) return 0;
125+
// Always on (no opt-out). Air-gapped/offline deployments may suppress the outbound call.
130126
if ((process.env.ORB_AIR_GAP ?? "").toLowerCase() === "true") return 0;
131127

132128
// gittensory's hosted collector. No shared secret is sent: repo/PR identifiers are HMAC'd with THIS
133-
// instance's own ORB_WEBHOOK_SECRET, and the collector accepts the batch as untrusted, rate-limited telemetry.
129+
// instance's OWN GitHub App webhook secret, so the collector can never de-anonymize them.
134130
const collectorUrl = process.env.ORB_COLLECTOR_URL ?? "https://gittensory-api.aethereal.dev/v1/orb/ingest";
135-
const secret = process.env.ORB_WEBHOOK_SECRET ?? "";
131+
const secret = process.env.GITHUB_WEBHOOK_SECRET ?? "";
132+
if (!secret) return 0; // App not configured yet — no anonymization key, and no review data to export
136133
const anonymize = (process.env.ORB_ANONYMIZE ?? "true").toLowerCase() !== "false";
137134
const instance = instanceId();
138135

src/server.ts

Lines changed: 9 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ import worker from "./index";
1414
import { processJob } from "./queue/processors";
1515
import { createSelfHostAi } from "./selfhost/ai";
1616
import { credentialsToEnv, exchangeManifestCode, renderSetupPage } from "./selfhost/setup-wizard";
17-
import { orbEnabled, exportOrbBatch } from "./selfhost/orb-collector";
17+
import { exportOrbBatch } from "./selfhost/orb-collector";
1818
import { createD1Adapter, nodeSqliteDriver } from "./selfhost/d1-adapter";
1919
import { readiness } from "./selfhost/health";
2020
import { gauge, incr, renderMetrics } from "./selfhost/metrics";
@@ -285,16 +285,14 @@ async function main(): Promise<void> {
285285
);
286286
}, intervalMs);
287287

288-
// Orb hourly export — batch-send pending outcome signals to the central collector.
289-
// No-op when ORB_ENABLED is not set or ORB_AIR_GAP=true.
290-
if (orbEnabled()) {
291-
const runExport = () =>
292-
exportOrbBatch(backend.db)
293-
.then((n) => { if (n > 0) console.log(JSON.stringify({ event: "selfhost_orb_export", exported: n })); })
294-
.catch(() => undefined);
295-
void runExport(); // flush any pending events from a previous run at startup
296-
setInterval(runExport, 3_600_000); // then hourly
297-
}
288+
// Orb fleet-telemetry export — ALWAYS ON (the fleet-calibration contract of self-hosting). Self-gates
289+
// inside exportOrbBatch: a no-op until the GitHub App is configured, or when ORB_AIR_GAP=true.
290+
const runOrbExport = () =>
291+
exportOrbBatch(backend.db)
292+
.then((n) => { if (n > 0) console.log(JSON.stringify({ event: "selfhost_orb_export", exported: n })); })
293+
.catch(() => undefined);
294+
void runOrbExport(); // flush any pending events at startup
295+
setInterval(runOrbExport, 3_600_000); // then hourly
298296

299297
// Graceful shutdown: stop accepting HTTP, let the queue finish, close the backend.
300298
let shuttingDown = false;

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

Lines changed: 11 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { DatabaseSync } from "node:sqlite";
22
import { describe, expect, it, beforeEach, afterEach } from "vitest";
33
import { createD1Adapter, nodeSqliteDriver } from "../../src/selfhost/d1-adapter";
4-
import { bucketReasonCode, exportOrbBatch, orbEnabled } from "../../src/selfhost/orb-collector";
4+
import { bucketReasonCode, exportOrbBatch } from "../../src/selfhost/orb-collector";
55
import { resetMetrics, renderMetrics } from "../../src/selfhost/metrics";
66

77
/** In-memory DB with the review_audit + orb_export_cursor tables the exporter reads. */
@@ -44,32 +44,25 @@ describe("bucketReasonCode()", () => {
4444
});
4545
});
4646

47-
describe("orbEnabled()", () => {
48-
afterEach(() => { delete process.env.ORB_ENABLED; });
49-
it("true only for truthy values", () => {
50-
for (const v of ["true", "1", "Yes"]) { process.env.ORB_ENABLED = v; expect(orbEnabled()).toBe(true); }
51-
for (const v of ["", "false", "no"]) { process.env.ORB_ENABLED = v; expect(orbEnabled()).toBe(false); }
52-
delete process.env.ORB_ENABLED; expect(orbEnabled()).toBe(false);
53-
});
54-
});
55-
56-
describe("exportOrbBatch() — reads review_audit, ships anonymized reversal-aware signal", () => {
47+
describe("exportOrbBatch() — always-on; reads review_audit, ships anonymized reversal-aware signal", () => {
5748
beforeEach(() => {
5849
resetMetrics();
59-
process.env.ORB_ENABLED = "true";
60-
process.env.ORB_WEBHOOK_SECRET = "test-secret";
50+
process.env.GITHUB_WEBHOOK_SECRET = "test-secret"; // the App secret doubles as the anonymization key
6151
process.env.ORB_APP_ID = "555";
6252
process.env.ORB_ANONYMIZE = "true";
6353
delete process.env.ORB_AIR_GAP;
6454
delete process.env.ORB_COLLECTOR_URL;
6555
});
6656
afterEach(() => {
67-
for (const k of ["ORB_ENABLED", "ORB_WEBHOOK_SECRET", "ORB_APP_ID", "ORB_ANONYMIZE", "ORB_AIR_GAP", "ORB_COLLECTOR_URL", "GITHUB_APP_ID"]) delete (process.env as NodeJS.Dict<string>)[k];
57+
for (const k of ["GITHUB_WEBHOOK_SECRET", "ORB_APP_ID", "ORB_ANONYMIZE", "ORB_AIR_GAP", "ORB_COLLECTOR_URL", "GITHUB_APP_ID"]) delete (process.env as NodeJS.Dict<string>)[k];
6858
});
6959

70-
it("returns 0 when disabled", async () => {
71-
process.env.ORB_ENABLED = "false";
72-
expect(await exportOrbBatch(makeDb(), 200, async () => new Response(null, { status: 200 }))).toBe(0);
60+
it("returns 0 when the App secret is not configured (App not set up → nothing to export)", async () => {
61+
delete (process.env as NodeJS.Dict<string>).GITHUB_WEBHOOK_SECRET;
62+
const db = makeDb();
63+
await audit(db, "o/r", 1, "gate_decision", "merge", "2026-01-01T00:00:00Z");
64+
await audit(db, "o/r", 1, "pr_outcome", "merged", "2026-01-01T01:00:00Z");
65+
expect(await exportOrbBatch(db, 200, async () => new Response(null, { status: 200 }))).toBe(0);
7366
});
7467

7568
it("returns 0 in air-gap mode", async () => {
@@ -164,9 +157,8 @@ describe("exportOrbBatch() — reads review_audit, ships anonymized reversal-awa
164157
expect(sig).toMatch(/^sha256=[a-f0-9]{64}$/);
165158
});
166159

167-
it("falls back to GITHUB_APP_ID for the instance id and applies secret/anonymize defaults when ORB_* are unset", async () => {
160+
it("falls back to GITHUB_APP_ID for the instance id and applies the anonymize default when ORB_* are unset", async () => {
168161
delete process.env.ORB_APP_ID; // → falls through to GITHUB_APP_ID
169-
delete process.env.ORB_WEBHOOK_SECRET; // → secret defaults to ""
170162
delete process.env.ORB_ANONYMIZE; // → defaults to "true"
171163
(process.env as NodeJS.Dict<string>).GITHUB_APP_ID = "999";
172164
const db = makeDb();

0 commit comments

Comments
 (0)