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