Skip to content
Closed
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
24 changes: 12 additions & 12 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -179,19 +179,19 @@ GITTENSORY_REVIEW_DRAFT=false
# # 1024-dimensional (e.g. bge-m3 or mxbai-embed-large via Ollama).
# # Used only when RAG is enabled (GITTENSORY_REVIEW_RAG + allowlist).

# --- Gittensory Orb (#1255; ALWAYS-ON fleet-calibration telemetry) ---
# TELEMETRY NOTICE: running this self-hosted image contributes anonymized gate-calibration data to
# gittensory's central collector. This is ON BY DEFAULT and has no opt-out flag — it is part of the
# self-hosting contract: install the GitHub App, and your instance reports fleet-calibration signal so the
# gate can be tuned from real outcomes across all self-hosters. It activates automatically once your App is
# configured (no App = nothing is sent). There is NO separate Orb App and NO setup wizard.
# --- Gittensory Orb (#1255; opt-in fleet-calibration telemetry) ---
# TELEMETRY NOTICE: Orb can contribute anonymized gate-calibration data from self-hosted instances to
# gittensory's central collector, but outbound export is OFF unless you explicitly opt in with ORB_ENABLED.
# It also requires the GitHub App private key to be configured (no App = nothing is sent). There is NO
# separate Orb App and NO setup wizard.
#
# WHAT IS SENT (per resolved PR, hourly): the gate verdict, the realized outcome (merged/closed), a reversal
# flag, a bucketed reason category, and cycle time. NEVER sent: repo/owner/PR names, commit SHAs, code,
# diffs, comments, or logins. Repo/PR identifiers are HMAC-anonymized with a DEDICATED key derived from YOUR
# OWN App private key (GITHUB_APP_PRIVATE_KEY) — high-entropy and independent of your webhook secret, so even
# gittensory (running the collector) can never de-anonymize them.
# The export carries no shared key; the collector treats it as untrusted, rate-limited, aggregate-only data.
# WHAT IS SENT (per resolved PR, hourly, only when ORB_ENABLED is truthy): the gate verdict, the realized
# outcome (merged/closed), a reversal flag, a bucketed reason category, and cycle time. NEVER sent by default:
# repo/owner/PR names, commit SHAs, code, diffs, comments, or logins. Repo/PR identifiers are HMAC-anonymized
# with a DEDICATED per-instance key stored in system_flags, so gittensory (running the collector) can never
# de-anonymize them. The export carries no shared key; the collector treats it as untrusted, rate-limited,
# aggregate-only data.
# ORB_ENABLED=false # set true/1/yes/on to opt in to outbound Orb export
# ORB_AIR_GAP=false # air-gapped/OFFLINE deployments only: compute locally, never send
# ORB_ANONYMIZE=true # HMAC-hash repo/PR before export (default true; false = raw names)
# ORB_COLLECTOR_URL=https://gittensory-api.aethereal.dev/v1/orb/ingest # gittensory's hosted collector (default; override for your own)
17 changes: 10 additions & 7 deletions src/selfhost/orb-collector.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,11 @@
// engine's outcomes-wire. This ships an anonymized, reversal-aware signal UP to gittensory's central
// collector so the gate can be calibrated across the whole self-host fleet.
//
// Export is ALWAYS ON once the GitHub App is configured (the fleet-telemetry contract of self-hosting) —
// there is no opt-out flag. It self-gates on a configured App private key (no App → no review data to
// export anyway) and anonymizes with a DEDICATED, per-instance secret generated once and persisted in
// system_flags (never the App private key or the webhook-verification secret — key separation).
// Export is opt-in: set ORB_ENABLED=true after reviewing the telemetry contract. It also self-gates
// on a configured App private key (no App → no review data to export anyway) and anonymizes with a
// DEDICATED, per-instance secret generated once and persisted in system_flags (never the App private
// key or the webhook-verification secret — key separation).
// ORB_ENABLED=true — opt in to fleet-calibration export (default: false)
// ORB_COLLECTOR_URL=<url> — endpoint (default: gittensory's hosted collector)
// ORB_AIR_GAP=true — air-gapped/offline deployments only: compute locally, never send
// ORB_ANONYMIZE=true — HMAC-hash repo/PR before export (default: true)
Expand Down Expand Up @@ -149,11 +150,13 @@ function cycleTimeMs(decidedAt: string, outcomeAt: string): number | null {

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

// Air-gapped/offline deployments may suppress the outbound call even after opting in.
if ((process.env.ORB_AIR_GAP ?? "").toLowerCase() === "true") return 0;

// No App configured → no review data to export anyway. Gate export on the App being set up.
Expand Down
4 changes: 2 additions & 2 deletions src/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -355,8 +355,8 @@ async function main(): Promise<void> {
);
}, intervalMs);

// Orb fleet-telemetry export — ALWAYS ON (the fleet-calibration contract of self-hosting). Self-gates
// inside exportOrbBatch: a no-op until the GitHub App is configured, or when ORB_AIR_GAP=true.
// Orb fleet-telemetry export — opt-in inside exportOrbBatch; also a no-op until the GitHub App
// is configured, or when ORB_AIR_GAP=true.
const runOrbExport = () =>
exportOrbBatch(backend.db)
.then((n) => { if (n > 0) console.log(JSON.stringify({ event: "selfhost_orb_export", exported: n })); })
Expand Down
20 changes: 17 additions & 3 deletions test/unit/selfhost-orb-collector.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -66,17 +66,31 @@ describe("getOrCreateAnonSecret()", () => {
});
});

describe("exportOrbBatch() — always-on; reads review_audit, ships anonymized reversal-aware signal", () => {
describe("exportOrbBatch() — opt-in; reads review_audit, ships anonymized reversal-aware signal", () => {
beforeEach(() => {
resetMetrics();
(process.env as NodeJS.Dict<string>).GITHUB_APP_PRIVATE_KEY = "test-private-key"; // gates export (App configured); not the anon key
process.env.ORB_ENABLED = "true";
process.env.ORB_APP_ID = "555";
process.env.ORB_ANONYMIZE = "true";
delete process.env.ORB_AIR_GAP;
delete process.env.ORB_COLLECTOR_URL;
});
afterEach(() => {
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];
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];
});

it("returns 0 unless Orb export is explicitly enabled", async () => {
delete process.env.ORB_ENABLED;
const db = makeDb();
await audit(db, "o/r", 1, "gate_decision", "merge", "2026-01-01T00:00:00Z");
await audit(db, "o/r", 1, "pr_outcome", "merged", "2026-01-01T01:00:00Z");
expect(await exportOrbBatch(db, 200, async () => new Response(null, { status: 200 }))).toBe(0);

for (const off of ["", "false", "no", "0", "off"]) {
process.env.ORB_ENABLED = off;
expect(await exportOrbBatch(db, 200, async () => new Response(null, { status: 200 }))).toBe(0);
}
});

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

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