Governed substrate for autonomous agents: scoped identity (passports), audited actions, MCP workspace. Infra IPs and secrets redacted for public release.
199 lines
7 KiB
JavaScript
199 lines
7 KiB
JavaScript
// ADR-0003 §C-5 — Release gate automation.
|
|
//
|
|
// Reads `bdd_doc_gate` (Phase 5 substrate view, §6.3) after every criterion
|
|
// state change and, if all Must-priority criteria for the spec attached to
|
|
// a document are `verified`, transitions that document
|
|
// draft → published (and fires C-14 via onDocumentStatusTransition)
|
|
// Reverse: once a Must criterion on a published doc regresses, demote
|
|
// published → regressed-published
|
|
// Returning from `regressed-published` to `published` is NOT auto — the AC
|
|
// §4 explicitly requires architect approval + manual /verify of every
|
|
// regressed row.
|
|
//
|
|
// Idempotent. Audit-log entries on every transition.
|
|
|
|
import { dbGet, dbRun, sqlNow } from '../../database/connection.js';
|
|
import { generateBaseId } from '../../utils/baseId.js';
|
|
import { apiLogger } from '../../utils/logger.js';
|
|
import { onDocumentStatusTransition } from '../documents/SnapshotWriter.js';
|
|
import { resolveStatusId, hasStatusIdColumn } from '../documents/statusResolver.js';
|
|
|
|
const BDD_SPACE_ID = 11;
|
|
|
|
const bddTableIds = new Map();
|
|
async function bddTableId(name) {
|
|
if (bddTableIds.has(name)) return bddTableIds.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) bddTableIds.set(name, row.id);
|
|
return row?.id || null;
|
|
}
|
|
|
|
/**
|
|
* Find the documents_registry row that contains `docId`. We don't know the
|
|
* registry table id up-front (there may be several), so we join through
|
|
* universal_tables.table_type. Returns { id, table_id, data } or null.
|
|
*/
|
|
async function findRegistryRow(docId) {
|
|
const row = await dbGet(`
|
|
SELECT tr.id, tr.table_id, tr.data
|
|
FROM table_rows tr
|
|
JOIN universal_tables ut ON ut.id = tr.table_id
|
|
WHERE ut.table_type = 'documents_registry' AND tr.id = ?
|
|
LIMIT 1
|
|
`, [docId]);
|
|
if (!row) return null;
|
|
row.data = typeof row.data === 'string' ? JSON.parse(row.data) : (row.data || {});
|
|
return row;
|
|
}
|
|
|
|
/**
|
|
* Aggregate gate state for a single document. `bdd_doc_gate` is keyed by
|
|
* (spec_id, source_doc_id) — a document may have >1 spec, so we aggregate.
|
|
* A doc is `ready` iff EVERY row for that source_doc_id is ready.
|
|
*/
|
|
async function readGate(docId) {
|
|
try {
|
|
const row = await dbGet(`
|
|
SELECT
|
|
COALESCE(SUM(must_total)::int, 0) AS must_total,
|
|
COALESCE(SUM(must_verified)::int, 0) AS must_verified,
|
|
COALESCE(SUM(must_failed)::int, 0) AS must_failed,
|
|
COUNT(*)::int AS spec_count,
|
|
COALESCE(BOOL_AND(ready), FALSE) AS ready
|
|
FROM bdd_doc_gate
|
|
WHERE source_doc_id = ?
|
|
`, [docId]);
|
|
return row || { must_total: 0, must_verified: 0, must_failed: 0, spec_count: 0, ready: false };
|
|
} catch (err) {
|
|
apiLogger.warn({ err: err.message, docId }, 'bdd_doc_gate view unavailable');
|
|
return null;
|
|
}
|
|
}
|
|
|
|
async function writeAuditLog(entry) {
|
|
try {
|
|
const tid = await bddTableId('bdd_audit_log');
|
|
if (!tid) return;
|
|
await dbRun(
|
|
`INSERT INTO table_rows (table_id, base_id, data, created_at, updated_at)
|
|
VALUES (?, ?, ?::jsonb, ${sqlNow()}, ${sqlNow()})`,
|
|
[tid, generateBaseId(), JSON.stringify({
|
|
ts: new Date().toISOString(),
|
|
actor_kind: 'system',
|
|
...entry,
|
|
})]
|
|
);
|
|
} catch (err) {
|
|
apiLogger.error({ err: err.message }, 'releaseGate writeAuditLog failed (non-fatal)');
|
|
}
|
|
}
|
|
|
|
async function updateRegistryStatus(reg, newStatus, extra = {}) {
|
|
const newData = {
|
|
...reg.data,
|
|
status: newStatus,
|
|
...extra,
|
|
};
|
|
if (await hasStatusIdColumn(reg.table_id)) {
|
|
const sid = await resolveStatusId(newStatus);
|
|
if (sid) newData.status_id = sid;
|
|
}
|
|
await dbRun(
|
|
`UPDATE table_rows
|
|
SET data = ?::jsonb, updated_at = ${sqlNow()}
|
|
WHERE id = ? AND table_id = ?`,
|
|
[JSON.stringify(newData), reg.id, reg.table_id]
|
|
);
|
|
return newData;
|
|
}
|
|
|
|
/**
|
|
* The entrypoint invoked from the BDD hooks (finalizeCriterion,
|
|
* regressCriterion). Idempotent: running on an already-published doc with
|
|
* a green gate is a no-op; running on an already-regressed-published doc
|
|
* with a red gate is a no-op.
|
|
*
|
|
* @returns {Promise<{published?:boolean, demoted?:boolean, noop?:boolean, skipped?:string}>}
|
|
*/
|
|
export async function maybeTransitionDocumentStatus(docId, { causedBy = null, userId = null } = {}) {
|
|
if (!Number.isFinite(docId)) return { skipped: 'invalid docId' };
|
|
const reg = await findRegistryRow(docId);
|
|
if (!reg) return { skipped: 'doc not found in documents_registry' };
|
|
const oldStatus = String(reg.data.status || 'draft');
|
|
const gate = await readGate(docId);
|
|
if (!gate) return { skipped: 'bdd_doc_gate unavailable' };
|
|
if (gate.spec_count === 0) return { skipped: 'no bdd_specs for doc', oldStatus };
|
|
|
|
// Forward: draft → published
|
|
if (gate.ready && oldStatus === 'draft') {
|
|
const newData = await updateRegistryStatus(reg, 'published', {
|
|
published_at: new Date().toISOString(),
|
|
});
|
|
await writeAuditLog({
|
|
action: 'document_publish',
|
|
doc_id: docId,
|
|
table_id: reg.table_id,
|
|
from_status: oldStatus,
|
|
to_status: 'published',
|
|
user_id: userId,
|
|
caused_by: causedBy || 'bdd_doc_gate.ready',
|
|
});
|
|
onDocumentStatusTransition(reg.table_id, reg.id, reg.data, newData);
|
|
await dbRun(`SELECT pg_notify(?, ?)`, [
|
|
'document.published',
|
|
JSON.stringify({
|
|
event: 'document.published',
|
|
doc_id: docId,
|
|
table_id: reg.table_id,
|
|
caused_by: causedBy || null,
|
|
must_total: gate.must_total,
|
|
must_verified: gate.must_verified,
|
|
}),
|
|
]);
|
|
apiLogger.info(
|
|
{ docId, tableId: reg.table_id, must_total: gate.must_total },
|
|
'BDD release gate: document auto-published'
|
|
);
|
|
return { published: true, must_total: gate.must_total, must_verified: gate.must_verified };
|
|
}
|
|
|
|
// Reverse: published → regressed-published when gate flips red
|
|
if (!gate.ready && oldStatus === 'published' && gate.must_total > 0) {
|
|
const newData = await updateRegistryStatus(reg, 'regressed-published', {
|
|
regressed_published_at: new Date().toISOString(),
|
|
});
|
|
await writeAuditLog({
|
|
action: 'document_regress',
|
|
doc_id: docId,
|
|
table_id: reg.table_id,
|
|
from_status: oldStatus,
|
|
to_status: 'regressed-published',
|
|
user_id: userId,
|
|
caused_by: causedBy || 'must_criterion_regressed',
|
|
});
|
|
await dbRun(`SELECT pg_notify(?, ?)`, [
|
|
'document.regressed',
|
|
JSON.stringify({
|
|
event: 'document.regressed',
|
|
doc_id: docId,
|
|
table_id: reg.table_id,
|
|
caused_by: causedBy || null,
|
|
must_total: gate.must_total,
|
|
must_verified: gate.must_verified,
|
|
}),
|
|
]);
|
|
apiLogger.info(
|
|
{ docId, tableId: reg.table_id },
|
|
'BDD release gate: document demoted to regressed-published'
|
|
);
|
|
return { demoted: true, must_total: gate.must_total, must_verified: gate.must_verified };
|
|
}
|
|
|
|
return { noop: true, oldStatus, ready: gate.ready };
|
|
}
|
|
|
|
export const __test__ = { findRegistryRow, readGate, updateRegistryStatus };
|