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 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).
710// ORB_COLLECTOR_URL=<url> — endpoint (default: gittensory's hosted collector)
8- // ORB_AIR_GAP=true — keep everything local , never send externally
11+ // ORB_AIR_GAP=true — air-gapped/offline deployments only: compute locally , never send
912// ORB_ANONYMIZE=true — HMAC-hash repo/PR before export (default: true)
1013//
1114// No diffs, no code, no comments, no logins, no commit SHAs — only verdict + outcome + reversal + a bucketed
12- // reason category + cycle time, with repo/PR identifiers HMAC'd by THIS instance's own secret (the collector
13- // holds no instance secret, so it can never de-anonymize).
14- import { createHash , createHmac } from "node:crypto" ;
15+ // reason category + cycle time, with repo/PR identifiers HMAC'd by a key the collector never holds (so it
16+ // can never de-anonymize).
17+ import { createHash , createHmac , randomBytes } from "node:crypto" ;
1518import { incr } from "./metrics" ;
1619
20+ /** Key under which the per-instance anonymization secret is persisted in system_flags. */
21+ const ANON_SECRET_FLAG = "orb:anon_secret" ;
22+
1723/** One de-noised, resolved-PR row read from review_audit (the join below). */
1824interface FleetRow {
1925 project : string ; // repo full name (review_audit.project)
@@ -55,6 +61,33 @@ function hmacField(value: string, secret: string): string {
5561 return createHmac ( "sha256" , secret ) . update ( value ) . digest ( "hex" ) . slice ( 0 , 24 ) ;
5662}
5763
64+ /**
65+ * The instance's DEDICATED anonymization secret: a 256-bit random key generated once and persisted in
66+ * system_flags, then reused on every export. Stable across restarts so a repo/PR always hashes the same
67+ * way (the collector can dedup), per-instance, and SINGLE-PURPOSE — never the App private key or the
68+ * webhook-verification secret (key separation). The collector never holds it, so it cannot de-anonymize.
69+ */
70+ export async function getOrCreateAnonSecret ( db : D1Database ) : Promise < string > {
71+ const read = async ( ) : Promise < string | undefined > => {
72+ const row = await db
73+ . prepare ( `SELECT value FROM system_flags WHERE key = ?` )
74+ . bind ( ANON_SECRET_FLAG )
75+ . first < { value : string } > ( ) ;
76+ return row ?. value ;
77+ } ;
78+ const existing = await read ( ) ;
79+ if ( existing ) return existing ;
80+ const generated = randomBytes ( 32 ) . toString ( "hex" ) ; // 256-bit, 64 hex chars
81+ // Race-safe across instances sharing a Postgres DB: OR IGNORE keeps the first writer's key; the re-read
82+ // returns whichever value won, so every instance converges on the same secret.
83+ await db
84+ . prepare ( `INSERT OR IGNORE INTO system_flags (key, value, updated_at) VALUES (?, ?, CURRENT_TIMESTAMP)` )
85+ . bind ( ANON_SECRET_FLAG , generated )
86+ . run ( ) ;
87+ /* v8 ignore next -- a row always exists after INSERT OR IGNORE, so the ?? fallback is unreachable */
88+ return ( await read ( ) ) ?? generated ;
89+ }
90+
5891/** Map the gate's free-text reasonCode to a fixed, low-cardinality category — done at the source so the raw
5992 * (possibly repo-specific) reason string never leaves the instance. */
6093export function bucketReasonCode ( summary : string | null | undefined ) : string {
@@ -69,12 +102,6 @@ export function bucketReasonCode(summary: string | null | undefined): string {
69102 return "other" ;
70103}
71104
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-
78105// Latest gate_decision + latest pr_outcome per target_id, plus any reversal — portable (window functions +
79106// CASE, no SQLite-only bare-column-with-MAX) so it runs on the self-host SQLite OR Postgres backend.
80107const FLEET_QUERY = `
@@ -122,17 +149,22 @@ function cycleTimeMs(decidedAt: string, outcomeAt: string): number | null {
122149
123150/**
124151 * 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).
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).
127154 */
128155export async function exportOrbBatch ( db : D1Database , batchSize = 200 , fetchFn : typeof fetch = fetch ) : Promise < number > {
129- if ( ! orbEnabled ( ) ) return 0 ;
156+ // Always on (no opt-out). Air-gapped/offline deployments may suppress the outbound call.
130157 if ( ( process . env . ORB_AIR_GAP ?? "" ) . toLowerCase ( ) === "true" ) return 0 ;
131158
132- // 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.
159+ // No App configured → no review data to export anyway. Gate export on the App being set up.
160+ if ( ! ( process . env . GITHUB_APP_PRIVATE_KEY ?? "" ) ) return 0 ;
161+
162+ // gittensory's hosted collector. No shared secret is sent: repo/PR identifiers are HMAC'd with this
163+ // instance's DEDICATED anonymization secret (a 256-bit random key generated once and persisted in
164+ // system_flags — see getOrCreateAnonSecret), single-purpose and never the App key, so the collector
165+ // (which never holds it) can never de-anonymize them.
134166 const collectorUrl = process . env . ORB_COLLECTOR_URL ?? "https://gittensory-api.aethereal.dev/v1/orb/ingest" ;
135- const secret = process . env . ORB_WEBHOOK_SECRET ?? "" ;
167+ const secret = await getOrCreateAnonSecret ( db ) ;
136168 const anonymize = ( process . env . ORB_ANONYMIZE ?? "true" ) . toLowerCase ( ) !== "false" ;
137169 const instance = instanceId ( ) ;
138170
0 commit comments