godcrm/backend/services/bdd/regressionWatcher.js
GOD CRM Release f89e074dd1
Some checks failed
CI / Lint / Typecheck / Test / Build (push) Has been cancelled
CI / PostgreSQL Integration Tests (push) Has been cancelled
GOD CRM — public scrubbed snapshot
Governed substrate for autonomous agents: scoped identity (passports),
audited actions, MCP workspace. Infra IPs and secrets redacted for public release.
2026-08-10 04:01:45 +03:00

197 lines
7 KiB
JavaScript

// ADR-0003 §C-3 — Regression detection watcher.
//
// When a ticket leaves the `done` state (→ anything non-done), sweep all
// bdd_criteria linked to that ticket via bdd_links. For each criterion
// currently in state `verified`, transition it to `regressed`, write an
// append-only entry to `bdd_audit_log`, and emit
// `pg_notify('bdd.criterion.regressed', …)`.
//
// Re-verify is NOT automatic when the ticket returns to done (per AC §2):
// a human must re-issue a TOTP-signed /verify.
//
// The hook is fire-and-forget — failures are logged, never thrown. It
// runs inside tableRowMutateController.js after the tickets row has been
// committed.
import { dbGet, dbAll, dbRun, sqlNow } from '../../database/connection.js';
import { generateBaseId } from '../../utils/baseId.js';
import { apiLogger } from '../../utils/logger.js';
import { maybeTransitionDocumentStatus } from './releaseGate.js';
// Hard-pinned to the Development space tickets table (see CLAUDE.md table
// registry). If/when tickets live in multiple spaces, resolve dynamically
// by checking the table's state-column FK to 1706.
const TICKETS_TABLE_ID = 1708;
const DONE_STATE_ID = 24278; // Ticket States row 24278 "done" (is_final=true)
const BDD_SPACE_ID = 11;
const tableIdCache = new Map();
async function bddTableId(name) {
if (tableIdCache.has(name)) return tableIdCache.get(name);
const row = await dbGet(`
SELECT ut.id
FROM universal_tables ut
JOIN projects p ON p.id = ut.project_id
WHERE p.space_id = ? AND ut.name = ?
LIMIT 1
`, [BDD_SPACE_ID, name]);
if (row?.id) tableIdCache.set(name, row.id);
return row?.id || null;
}
async function findLinkedCriteriaForTicket(ticketId) {
const linksTid = await bddTableId('bdd_links');
if (!linksTid) return [];
// bdd_links stores (from_kind, from_id) ↔ (to_kind, to_id). We accept both
// orientations — the convention is `from=criterion, to=ticket` but
// defensive lookup catches legacy rows.
const rows = await dbAll(`
SELECT
CASE
WHEN data->>'from_kind' = 'criterion' THEN (data->>'from_id')::bigint
ELSE (data->>'to_id')::bigint
END AS crit_id,
data->>'relation' AS relation
FROM table_rows
WHERE table_id = ?
AND (
(data->>'from_kind' = 'criterion' AND data->>'to_kind' = 'ticket' AND data->>'to_id' = ?)
OR
(data->>'to_kind' = 'criterion' AND data->>'from_kind' = 'ticket' AND data->>'from_id' = ?)
)
`, [linksTid, String(ticketId), String(ticketId)]);
const ids = rows
.map(r => Number(r.crit_id))
.filter(n => Number.isFinite(n) && n > 0);
return Array.from(new Set(ids));
}
async function regressCriterion(critId, ticketId) {
const critTid = await bddTableId('bdd_criteria');
if (!critTid) return { skipped: 'no bdd_criteria table' };
const crit = await dbGet(
`SELECT id, data FROM table_rows WHERE table_id = ? AND id = ?`,
[critTid, critId]
);
if (!crit) return { skipped: 'criterion row not found' };
const data = typeof crit.data === 'string' ? JSON.parse(crit.data) : (crit.data || {});
// AC §1 — only regress criteria that were actually verified. Pending /
// failed / orphaned are left alone; reopening a ticket linked to an
// unverified criterion is a no-op.
if (data.status !== 'verified') return { skipped: `status=${data.status || 'unset'}` };
const now = new Date().toISOString();
const patch = {
status: 'regressed',
regressed_at: now,
regressed_by_ticket_id: ticketId,
};
await dbRun(
`UPDATE table_rows
SET data = COALESCE(data,'{}'::jsonb) || ?::jsonb,
updated_at = ${sqlNow()}
WHERE table_id = ? AND id = ?`,
[JSON.stringify(patch), critTid, critId]
);
const auditTid = await bddTableId('bdd_audit_log');
if (auditTid) {
await dbRun(
`INSERT INTO table_rows (table_id, base_id, data, created_at, updated_at)
VALUES (?, ?, ?::jsonb, ${sqlNow()}, ${sqlNow()})`,
[auditTid, generateBaseId(), JSON.stringify({
criterion_id: critId,
spec_id: data.spec_id ?? null,
doc_id: data.source_doc_id ?? null,
action: 'regress',
from_status: 'verified',
to_status: 'regressed',
user_id: null,
actor_kind: 'system',
totp_hash: null,
reason: null,
caused_by: `ticket:${ticketId}:reopen`,
ip: null,
ts: now,
})]
);
}
await dbRun(
`SELECT pg_notify(?, ?)`,
['bdd.criterion.regressed', JSON.stringify({
event: 'bdd.criterion.regressed',
criterion_id: critId,
spec_id: data.spec_id ?? null,
doc_id: data.source_doc_id ?? null,
caused_by_ticket_id: ticketId,
at: now,
})]
);
// ADR-0003 §C-5: if the regressed criterion belongs to a published doc and
// is Must-priority, the document demotes to `regressed-published`. Safe to
// call on any regression — no-op when the gate stays green.
const docId = Number(data.source_doc_id);
if (Number.isFinite(docId) && docId > 0) {
maybeTransitionDocumentStatus(docId, {
causedBy: `bdd.criterion.regressed:${critId}:ticket:${ticketId}`,
}).catch((err) => {
apiLogger.error({ err: err.message, docId, critId }, 'release gate demote hook failed (non-fatal)');
});
}
return { regressed: true };
}
/**
* Fire-and-forget hook for tableRowMutateController PUT handler. Detects
* `done → non-done` ticket transitions and regresses linked verified
* criteria. No-op for every other table / transition.
*/
export function onTicketStateTransition(tableId, rowId, oldData = {}, newData = {}) {
if (Number(tableId) !== TICKETS_TABLE_ID) return;
const oldState = Number(oldData?.state);
const newState = Number(newData?.state);
if (oldState !== DONE_STATE_ID) return;
if (newState === DONE_STATE_ID) return;
// Reject-state (43438) is also a final state — AC explicitly targets the
// `done → in-progress/backlog/review/…` reopen flow. Going `done → rejected`
// is a semantic close, not a regression. Ignore.
const REJECTED_STATE_ID = 43438;
if (newState === REJECTED_STATE_ID) return;
(async () => {
try {
const critIds = await findLinkedCriteriaForTicket(Number(rowId));
if (!critIds.length) return;
const results = [];
for (const cid of critIds) {
const r = await regressCriterion(cid, Number(rowId));
results.push({ criterion_id: cid, ...r });
}
const regressed = results.filter(r => r.regressed).length;
if (regressed > 0) {
apiLogger.info(
{ ticketId: rowId, oldState, newState, regressed, total: results.length },
'BDD regression sweep fired'
);
}
} catch (err) {
apiLogger.error(
{ err: err.message, ticketId: rowId, oldState, newState },
'onTicketStateTransition sweep failed'
);
}
})();
}
// Exported for smoke tests that want deterministic semantics.
export const __test__ = {
findLinkedCriteriaForTicket,
regressCriterion,
TICKETS_TABLE_ID,
DONE_STATE_ID,
};