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
6 changes: 6 additions & 0 deletions packages/gittensory-engine/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,12 @@ injected-clock semantics for local miners.
They only deny on small, explicit AI-contribution ban phrases in `AI-USAGE.md` or `CONTRIBUTING.md`; ambiguous,
missing, or empty policy text stays allowed so discovery does not invent a ban.

## Governor ledger

`normalizeGovernorLedgerEvent` validates append-only governor decision rows before the local miner persists them.
The vocabulary is fixed (`allowed`, `denied`, `throttled`, `kill_switch`) and unknown event types fail closed. This
module defines the storage contract only — it does not wire into live governor enforcement yet. (#2328)

## MinerGoalSpec

`MinerGoalSpec` is the type surface for a repo's `.gittensory-miner.yml` (miner-side analogue of `.gittensory.yml`).
Expand Down
85 changes: 85 additions & 0 deletions packages/gittensory-engine/src/governor-ledger.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
import { isDeepStrictEqual } from "node:util";

/** Immutable governor decision vocabulary — unknown values fail closed before insert. */
export const GOVERNOR_LEDGER_EVENT_TYPES = Object.freeze([
"allowed",
"denied",
"throttled",
"kill_switch",
] as const);

export type GovernorLedgerEventType = (typeof GOVERNOR_LEDGER_EVENT_TYPES)[number];

export type GovernorLedgerEvent = {
eventType: GovernorLedgerEventType;
repoFullName?: string | null | undefined;
actionClass: string;
decision: string;
reason: string;
payload?: Record<string, unknown> | undefined;
};

export type NormalizedGovernorLedgerEvent = {
eventType: GovernorLedgerEventType;
repoFullName: string | null;
actionClass: string;
decision: string;
reason: string;
payloadJson: string;
};

const governorEventTypeSet = new Set<string>(GOVERNOR_LEDGER_EVENT_TYPES);

/* v8 ignore start -- Normalization helpers are covered through normalizeGovernorLedgerEvent export tests. */
function normalizeRequiredString(value: unknown, code: string): string {
if (typeof value !== "string") throw new Error(code);
const trimmed = value.trim();
if (!trimmed) throw new Error(code);
return trimmed;
}

function normalizeOptionalRepoFullName(repoFullName: unknown): string | null {
if (repoFullName === undefined || repoFullName === null) return null;
if (typeof repoFullName !== "string") throw new Error("invalid_repo_full_name");
const [owner, repo, extra] = repoFullName.trim().split("/");
if (!owner || !repo || extra !== undefined) throw new Error("invalid_repo_full_name");
return `${owner}/${repo}`;
}

function serializePayload(payload: unknown): string {
if (payload === undefined) return "{}";
if (payload === null || typeof payload !== "object" || Array.isArray(payload)) {
throw new Error("invalid_payload");
}
let json: string;
try {
json = JSON.stringify(payload);
} catch {
throw new Error("invalid_payload");
}
if (!isDeepStrictEqual(JSON.parse(json), payload)) {
throw new Error("invalid_payload");
}
return json;
}
/* v8 ignore stop */

/**
* Validate and normalize a governor ledger row before append-only insert. Mirrors the structured-event shape of
* `logAudit` in `src/selfhost/audit.ts`, but for local SQLite storage. This module does NOT wire into live
* governor enforcement — it only defines the storage contract other issues will write into. (#2328)
*/
export function normalizeGovernorLedgerEvent(input: unknown): NormalizedGovernorLedgerEvent {
if (!input || typeof input !== "object") throw new Error("invalid_event");
const event = input as Partial<GovernorLedgerEvent>;
const eventType = normalizeRequiredString(event.eventType, "invalid_event_type");
if (!governorEventTypeSet.has(eventType)) throw new Error("invalid_event_type");
return {
eventType: eventType as GovernorLedgerEventType,
repoFullName: normalizeOptionalRepoFullName(event.repoFullName),
actionClass: normalizeRequiredString(event.actionClass, "invalid_action_class"),
decision: normalizeRequiredString(event.decision, "invalid_decision"),
reason: normalizeRequiredString(event.reason, "invalid_reason"),
payloadJson: serializePayload(event.payload),
};
}
7 changes: 7 additions & 0 deletions packages/gittensory-engine/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,13 @@ export {
type OpportunityRankInput,
} from "./opportunity-ranker.js";
export * from "./governor/rate-limit.js";
export {
GOVERNOR_LEDGER_EVENT_TYPES,
normalizeGovernorLedgerEvent,
type GovernorLedgerEvent,
type GovernorLedgerEventType,
type NormalizedGovernorLedgerEvent,
} from "./governor-ledger.js";
export * from "./plan-export.js";
export * from "./plan-templates.js";
export * from "./portfolio/queue.js";
Expand Down
4 changes: 4 additions & 0 deletions packages/gittensory-miner/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,10 @@ The package also includes a metadata-only ranker: `rankCandidateIssues` composes
(potential, feasibility, lane fit, freshness, dup risk) and returns fan-out candidates sorted by `rankScore`.
It never clones source and never writes to GitHub.

The package also includes an append-only governor decision ledger: `initGovernorLedger` / `appendGovernorEvent`
persist structured allow/deny/throttle/kill-switch outcomes in local SQLite for contributor audit. Insert-only —
no enforcement wiring yet. (#2328)

## Install

From a local checkout:
Expand Down
40 changes: 40 additions & 0 deletions packages/gittensory-miner/lib/governor-ledger.d.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
export type GovernorLedgerEntry = {
id: number;
ts: string;
eventType: string;
repoFullName: string | null;
actionClass: string;
decision: string;
reason: string;
payload: Record<string, unknown>;
};

export type AppendGovernorEventInput = {
eventType: string;
repoFullName?: string | null;
actionClass: string;
decision: string;
reason: string;
payload?: Record<string, unknown>;
};

export type ReadGovernorEventsFilter = {
repoFullName?: string | null;
};

export type GovernorLedger = {
dbPath: string;
appendGovernorEvent(event: AppendGovernorEventInput): GovernorLedgerEntry;
readGovernorEvents(filter?: ReadGovernorEventsFilter): GovernorLedgerEntry[];
close(): void;
};

export function resolveGovernorLedgerDbPath(env?: Record<string, string | undefined>): string;

export function initGovernorLedger(dbPath?: string): GovernorLedger;

export function appendGovernorEvent(event: AppendGovernorEventInput): GovernorLedgerEntry;

export function readGovernorEvents(filter?: ReadGovernorEventsFilter): GovernorLedgerEntry[];

export function closeDefaultGovernorLedger(): void;
139 changes: 139 additions & 0 deletions packages/gittensory-miner/lib/governor-ledger.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,139 @@
import { chmodSync, mkdirSync } from "node:fs";
import { homedir } from "node:os";
import { dirname, join } from "node:path";
import { DatabaseSync } from "node:sqlite";
import { normalizeGovernorLedgerEvent } from "@jsonbored/gittensory-engine";

// Append-only governor decision ledger (#2328): every allowed/denied/throttled/kill-switch outcome lands in a
// local SQLite table for contributor audit. IMMUTABILITY INVARIANT: INSERT + SELECT only — never UPDATE/DELETE.
// This module does not enforce governor policy; it only persists structured events other phases will emit.

const defaultDbFileName = "governor-ledger.sqlite3";
let defaultGovernorLedger = null;

export function resolveGovernorLedgerDbPath(env = process.env) {
const explicitPath = typeof env.GITTENSORY_MINER_GOVERNOR_LEDGER_DB === "string"
? env.GITTENSORY_MINER_GOVERNOR_LEDGER_DB.trim()
: "";
if (explicitPath) return explicitPath;

const explicitConfigDir = typeof env.GITTENSORY_MINER_CONFIG_DIR === "string"
? env.GITTENSORY_MINER_CONFIG_DIR.trim()
: "";
if (explicitConfigDir) return join(explicitConfigDir, defaultDbFileName);

const configHome = typeof env.XDG_CONFIG_HOME === "string" && env.XDG_CONFIG_HOME.trim()
? env.XDG_CONFIG_HOME.trim()
: join(homedir(), ".config");
return join(configHome, "gittensory-miner", defaultDbFileName);
}

function normalizeDbPath(dbPath) {
const path = (dbPath ?? resolveGovernorLedgerDbPath()).trim();
if (!path) throw new Error("invalid_governor_ledger_db_path");
return path;
}

function normalizeOptionalRepoFullName(repoFullName) {
if (repoFullName === undefined || repoFullName === null) return undefined;
if (typeof repoFullName !== "string") throw new Error("invalid_repo_full_name");
const [owner, repo, extra] = repoFullName.trim().split("/");
if (!owner || !repo || extra !== undefined) throw new Error("invalid_repo_full_name");
return `${owner}/${repo}`;
}

function rowToEntry(row) {
return {
id: row.id,
ts: row.ts,
eventType: row.event_type,
repoFullName: row.repo_full_name,
actionClass: row.action_class,
decision: row.decision,
reason: row.reason,
payload: JSON.parse(row.payload_json),
};
}

/**
* Opens the append-only governor ledger, creating the table on first use. Rows are returned in ascending `id`
* order (insertion order). (#2328)
*/
export function initGovernorLedger(dbPath = resolveGovernorLedgerDbPath()) {
const resolvedPath = normalizeDbPath(dbPath);
mkdirSync(dirname(resolvedPath), { recursive: true, mode: 0o700 });
const db = new DatabaseSync(resolvedPath);
chmodSync(resolvedPath, 0o600);
db.exec("PRAGMA busy_timeout = 5000");
db.exec(`
CREATE TABLE IF NOT EXISTS governor_events (
id INTEGER PRIMARY KEY AUTOINCREMENT,
ts TEXT NOT NULL,
event_type TEXT NOT NULL,
repo_full_name TEXT,
action_class TEXT NOT NULL,
decision TEXT NOT NULL,
reason TEXT NOT NULL,
payload_json TEXT NOT NULL
)
`);
db.exec("CREATE INDEX IF NOT EXISTS idx_governor_events_repo ON governor_events (repo_full_name, id)");

const appendStatement = db.prepare(`
INSERT INTO governor_events (ts, event_type, repo_full_name, action_class, decision, reason, payload_json)
VALUES (?, ?, ?, ?, ?, ?, ?)
`);
const getByIdStatement = db.prepare("SELECT * FROM governor_events WHERE id = ?");
const readAllStatement = db.prepare("SELECT * FROM governor_events ORDER BY id ASC");
const readByRepoStatement = db.prepare(
"SELECT * FROM governor_events WHERE repo_full_name = ? ORDER BY id ASC",
);

return {
dbPath: resolvedPath,
appendGovernorEvent(event) {
const normalized = normalizeGovernorLedgerEvent(event);
const ts = new Date().toISOString();
const result = appendStatement.run(
ts,
normalized.eventType,
normalized.repoFullName,
normalized.actionClass,
normalized.decision,
normalized.reason,
normalized.payloadJson,
);
return rowToEntry(getByIdStatement.get(Number(result.lastInsertRowid)));
},
readGovernorEvents(filter = {}) {
const repoFullName = normalizeOptionalRepoFullName(filter.repoFullName);
const rows =
repoFullName === undefined
? readAllStatement.all()
: readByRepoStatement.all(repoFullName);
return rows.map(rowToEntry);
},
close() {
db.close();
},
};
}

function getDefaultGovernorLedger() {
defaultGovernorLedger ??= initGovernorLedger();
return defaultGovernorLedger;
}

export function appendGovernorEvent(event) {
return getDefaultGovernorLedger().appendGovernorEvent(event);
}

export function readGovernorEvents(filter) {
return getDefaultGovernorLedger().readGovernorEvents(filter);
}

export function closeDefaultGovernorLedger() {
if (!defaultGovernorLedger) return;
defaultGovernorLedger.close();
defaultGovernorLedger = null;
}
2 changes: 1 addition & 1 deletion packages/gittensory-miner/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@
"lib"
],
"scripts": {
"build": "node --check bin/gittensory-miner.js && node --check lib/cli.js && node --check lib/deny-check.js && node --check lib/update-check.js && node --check lib/opportunity-fanout.js && node --check lib/ci-poller.js && node --check lib/run-state.js && node --check lib/deny-hooks.js && node --check lib/event-ledger.js && node --check lib/claim-ledger.js && node --check lib/portfolio-queue.js && node --check lib/opportunity-ranker.js && node --check lib/plan-store.js && node --check lib/rejection-templates.js"
"build": "node --check bin/gittensory-miner.js && node --check lib/cli.js && node --check lib/deny-check.js && node --check lib/update-check.js && node --check lib/opportunity-fanout.js && node --check lib/ci-poller.js && node --check lib/run-state.js && node --check lib/deny-hooks.js && node --check lib/event-ledger.js && node --check lib/claim-ledger.js && node --check lib/portfolio-queue.js && node --check lib/opportunity-ranker.js && node --check lib/plan-store.js && node --check lib/rejection-templates.js && node --check lib/governor-ledger.js"
},
"dependencies": {
"@jsonbored/gittensory-engine": "0.1.0"
Expand Down
Loading
Loading