godcrm/backend/services/verification/validateOverride.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

242 lines
11 KiB
JavaScript

// ADR-0011 · Phase E · tighten-only override validator (C-8).
//
// Per ADR-0011 §Consumers → ADR-0003:
// Document-level override (`verification_settings` atom) may tighten a
// column's effective config but MAY NOT loosen any value set by the
// column itself. Loosening = reject.
//
// Tightening rules (explicitly called out by C-8 + ADR-0003 §override):
// - `cooldown_seconds` may only RAISE (override ≥ base)
// - `required_methods` may only RAISE (override ≥ base, capped at
// length(available_methods) of the override itself)
// - `guards` must be a SUPERSET of base (no guard removal)
//
// Inferred tightening rules (same spirit — all loosening forbidden):
// - `available_methods` must be a SUBSET of base (removing options with
// the same `required_methods` makes verify strictly
// harder; adding new options is loosening)
// - `ttl_seconds` may only SHORTEN. null means "no expiry". So:
// base=null → override may set any positive TTL (tighten)
// base=N → override may set M where M <= N
// override=null with base=N → REJECT (loosen)
// - `rate_limit` tightening = FEWER attempts in a given wall-clock
// span. Reject if override raises max_attempts or
// shrinks window_seconds. (Removing rate_limit when
// base has one = loosen → reject.)
//
// Not policed here (free to override / add):
// - `locks_on_statuses`, `unlocks_on_statuses` — per-document workflow
// controls, not a tightening axis.
// - `policy` — a transition from `any_n` → `all` strictly tightens, but
// `all` → `any_n` strictly loosens. Reject the latter.
// - Extra fields not in ADR-0011 (`required_reviewer_ids`,
// `screenshot_atom`, `diff_hash_match` from ADR-0003) — pass through
// unchanged; they add constraints and are tightening by construction.
//
// Input: both `base` and `override` must already be normalized through
// validateVerificationConfig (so cooldown_ms/cooldown_seconds etc. are
// canonical). The `override` config is partial — any field omitted means
// "inherit base".
const TIGHTEN_ONLY_POLICIES = { all: 2, any_n: 1 };
/**
* @param {object} base Normalized column config (validateVerificationConfig output)
* @param {object} override Partial override atom payload (may be unnormalized)
* @returns {{ ok: true, effective: object } | { ok: false, error: string, field?: string }}
*/
export function validateVerificationOverride(base, override) {
if (!base || typeof base !== 'object') {
return { ok: false, error: 'base config is required' };
}
if (override === null || override === undefined) {
return { ok: true, effective: { ...base } };
}
if (typeof override !== 'object' || Array.isArray(override)) {
return { ok: false, error: 'override must be an object' };
}
const effective = { ...base };
// ---------- cooldown_seconds — may only RAISE ----------
const overrideCooldown = readNumber(override, ['cooldown_seconds', 'cooldown_ms']);
if (overrideCooldown.present) {
const nextSec = overrideCooldown.key === 'cooldown_ms'
? Math.round(overrideCooldown.value / 1000)
: overrideCooldown.value;
if (nextSec < base.cooldown_seconds) {
return {
ok: false,
field: 'cooldown_seconds',
error: `override cooldown_seconds (${nextSec}) must be >= base (${base.cooldown_seconds})`,
};
}
effective.cooldown_seconds = nextSec;
effective.cooldown_ms = nextSec * 1000;
}
// ---------- available_methods — must be SUBSET of base ----------
if (Array.isArray(override.available_methods)) {
if (override.available_methods.length === 0) {
return { ok: false, field: 'available_methods', error: 'override.available_methods must not be empty' };
}
const baseSet = new Set(base.available_methods || []);
for (const m of override.available_methods) {
if (!baseSet.has(m)) {
return {
ok: false,
field: 'available_methods',
error: `override.available_methods adds '${m}' which is not in base — only subsets allowed`,
};
}
}
effective.available_methods = Array.from(new Set(override.available_methods));
}
// ---------- required_methods — may only RAISE ----------
if (override.required_methods !== undefined && override.required_methods !== null) {
if (typeof override.required_methods !== 'number' || !Number.isInteger(override.required_methods)) {
return { ok: false, field: 'required_methods', error: 'required_methods must be an integer' };
}
if (override.required_methods < base.required_methods) {
return {
ok: false,
field: 'required_methods',
error: `override required_methods (${override.required_methods}) must be >= base (${base.required_methods})`,
};
}
if (override.required_methods > effective.available_methods.length) {
return {
ok: false,
field: 'required_methods',
error: `override required_methods (${override.required_methods}) exceeds available_methods length (${effective.available_methods.length})`,
};
}
effective.required_methods = override.required_methods;
} else if (effective.required_methods > effective.available_methods.length) {
// Base required_methods carries over but available_methods shrank → infeasible.
return {
ok: false,
field: 'required_methods',
error: `override shrinks available_methods below base required_methods (${base.required_methods}) — raise required_methods or expand available_methods`,
};
}
// ---------- guards — must be SUPERSET of base ----------
if (Array.isArray(override.guards)) {
const overrideSet = new Set(override.guards);
for (const g of base.guards || []) {
if (!overrideSet.has(g)) {
return {
ok: false,
field: 'guards',
error: `override.guards drops base guard '${g}' — guards may only be added, not removed`,
};
}
}
effective.guards = Array.from(new Set(override.guards));
}
// ---------- ttl_seconds — may only SHORTEN (null = looser than any finite) ----------
if ('ttl_seconds' in override || 'ttl_ms' in override) {
let nextTtl;
if ('ttl_seconds' in override) {
nextTtl = override.ttl_seconds;
} else {
nextTtl = override.ttl_ms == null ? null : override.ttl_ms / 1000;
}
if (nextTtl !== null && (typeof nextTtl !== 'number' || !Number.isFinite(nextTtl) || nextTtl <= 0)) {
return { ok: false, field: 'ttl_seconds', error: 'ttl_seconds must be a positive number or null' };
}
if (nextTtl === null && base.ttl_seconds !== null && base.ttl_seconds !== undefined) {
return { ok: false, field: 'ttl_seconds', error: 'override cannot clear ttl_seconds once base sets it' };
}
if (nextTtl !== null && base.ttl_seconds != null && nextTtl > base.ttl_seconds) {
return {
ok: false,
field: 'ttl_seconds',
error: `override ttl_seconds (${nextTtl}) must be <= base (${base.ttl_seconds})`,
};
}
effective.ttl_seconds = nextTtl;
effective.ttl_ms = nextTtl == null ? null : nextTtl * 1000;
}
// ---------- rate_limit — fewer attempts per longer window is tighter ----------
if ('rate_limit' in override) {
const rl = override.rate_limit;
if (rl === null) {
if (base.rate_limit) {
return { ok: false, field: 'rate_limit', error: 'override cannot clear rate_limit once base sets it' };
}
effective.rate_limit = null;
} else {
if (typeof rl !== 'object' || Array.isArray(rl)) {
return { ok: false, field: 'rate_limit', error: 'rate_limit must be an object { window_seconds, max_attempts } or null' };
}
if (typeof rl.window_seconds !== 'number' || rl.window_seconds <= 0) {
return { ok: false, field: 'rate_limit', error: 'rate_limit.window_seconds must be a positive number' };
}
if (typeof rl.max_attempts !== 'number' || !Number.isInteger(rl.max_attempts) || rl.max_attempts <= 0) {
return { ok: false, field: 'rate_limit', error: 'rate_limit.max_attempts must be a positive integer' };
}
if (base.rate_limit) {
if (rl.max_attempts > base.rate_limit.max_attempts) {
return {
ok: false,
field: 'rate_limit.max_attempts',
error: `override rate_limit.max_attempts (${rl.max_attempts}) must be <= base (${base.rate_limit.max_attempts})`,
};
}
if (rl.window_seconds < base.rate_limit.window_seconds) {
return {
ok: false,
field: 'rate_limit.window_seconds',
error: `override rate_limit.window_seconds (${rl.window_seconds}) must be >= base (${base.rate_limit.window_seconds}) — shorter window = more bursts allowed`,
};
}
}
effective.rate_limit = { window_seconds: rl.window_seconds, max_attempts: rl.max_attempts };
}
}
// ---------- policy — 'all' > 'any_n' ----------
if (override.policy !== undefined && override.policy !== null) {
const baseRank = TIGHTEN_ONLY_POLICIES[base.policy];
const overrideRank = TIGHTEN_ONLY_POLICIES[override.policy];
if (overrideRank === undefined) {
return { ok: false, field: 'policy', error: `policy must be 'all' or 'any_n'` };
}
if (baseRank !== undefined && overrideRank < baseRank) {
return {
ok: false,
field: 'policy',
error: `override policy '${override.policy}' is looser than base '${base.policy}'`,
};
}
effective.policy = override.policy;
}
// ---------- pass-through (ADR-0003 additions, lock statuses, method_config) ----------
for (const k of ['locks_on_statuses', 'unlocks_on_statuses']) {
if (Array.isArray(override[k])) effective[k] = [...override[k]];
}
if (override.method_config && typeof override.method_config === 'object' && !Array.isArray(override.method_config)) {
effective.method_config = { ...(base.method_config || {}), ...override.method_config };
}
for (const k of ['required_reviewer_ids', 'screenshot_atom', 'diff_hash_match']) {
if (k in override) effective[k] = override[k];
}
return { ok: true, effective };
}
function readNumber(obj, keys) {
for (const key of keys) {
if (obj[key] === undefined || obj[key] === null) continue;
if (typeof obj[key] === 'number' && Number.isFinite(obj[key])) {
return { present: true, key, value: obj[key] };
}
}
return { present: false };
}