Governed substrate for autonomous agents: scoped identity (passports), audited actions, MCP workspace. Infra IPs and secrets redacted for public release.
197 lines
7 KiB
JavaScript
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,
|
|
};
|