Files
portal/tools/judge-seal-channel.mjs
T

85 lines
5.3 KiB
JavaScript

#!/usr/bin/env node
/**
* judge-seal-channel (K1 / F4 / F5 / F6, §6) — единственная разрешённая запись судьи:
* ПЕЧАТЬ на ГО. Печать ставит ТОЛЬКО этот канал одобрения гейта (ключ контроллеру
* недоступен — owner-provisioned keychain); путь «контроллер сам зовёт freeze» не строится.
* - F4: печать связывает хеш РОВНО судимого содержания, атомарно с вердиктом — судья
* одобрил X, контроллер подменил на X′ → хеш не сойдётся → печать НЕ ставится (пере-суд).
* - Carve-out (§6): деньги (осторожный детерминированный детектор) или 3-круговой тупик
* → печать ставит ВЛАДЕЛЕЦ, судья сам не печатает.
* - F6: возражение, снятое владельцем (вейвер), на нижних воротах не пере-блокируется.
* Само-freeze делегируется injected freezeImpl (в проде — freezeArtifact Машины 2);
* persist в runtime + триггер «одобрил→печать» — оркестрация 4-E.
*/
import { createHash } from 'node:crypto';
import { canonicalJson } from './receipt-sign.mjs';
/** Хеш РОВНО содержания (для F4-сверки «то ли судили»). */
export function contentHash(obj) {
return createHash('sha256').update(canonicalJson(obj)).digest('hex');
}
// Осторожно-широкий список денежных маркеров: при сомнении → деньги → владелец.
// O11: левая Unicode-граница слова (?<![\p{L}]) режет ложь несбалансированный→баланс,
// recharge/surcharge→charge. ПРАВУЮ границу НЕ ставим — русские стем-суффиксы остаются
// (списан→списание, начислен→начисление). Флаг u нужен для \p{L}.
const MONEY_RE = /(?<![\p{L}])(руб|₽|копе|тариф|списан|биллинг|баланс|оплат|плат[её]ж|выручк|начислен|деньг|payment|charge|refund|invoice|money)/iu;
/** Осторожный детерминированный детектор денег (НЕ «на глаз» модели). */
export function detectMoney(input) {
const text = typeof input === 'string' ? input : canonicalJson(input);
return MONEY_RE.test(String(text));
}
/** Печать ставит владелец (а не судья), если задеты деньги ИЛИ вердикт в тупике. */
export function requiresOwnerSeal({ artifact, verdict }) {
if (verdict && verdict.deadlock) return true;
return detectMoney(artifact);
}
/**
* Поставить печать на ГО. Возвращает {sealed, seal?} либо {sealed:false, reason|ownerRequired}.
* @param {object} a
* @param {object} a.artifact - содержание, которое печатаем
* @param {object} a.verdict - вердикт судьи: {decision:'GO'|'NO-GO', judged_hash, deadlock?, verdict_id}
* @param {string} a.key - ключ печати (контроллеру недоступен)
* @param {number} [a.nowMs]
* @param {Function} a.freezeImpl - ({artifact,key,nowMs}) => sealed-object. ОБЯЗАТЕЛЕН;
* в проде оркестрация 4-E передаёт freezeArtifact Машины 2 (ESM-импортом, не require).
*/
export function sealOnApproval({ artifact, verdict, key, nowMs, freezeImpl }) {
if (typeof freezeImpl !== 'function') {
return { sealed: false, reason: 'freezeImpl не передан — печать-движок Машины 2 не подключён' };
}
if (!verdict || verdict.decision !== 'GO') {
return { sealed: false, reason: 'нет GO судьи — печать не ставится' };
}
if (requiresOwnerSeal({ artifact, verdict })) {
return { sealed: false, ownerRequired: true, reason: 'деньги/тупик — печать ставит владелец (carve-out §6)' };
}
const currentHash = contentHash(artifact);
if (verdict.judged_hash !== currentHash) {
return { sealed: false, reason: 'содержание изменилось после суда (TOCTOU F4) — нужен пере-суд' };
}
const seal = freezeImpl({
artifact: { ...artifact, judged_by: verdict.verdict_id ?? null, judged_hash: currentHash },
key, nowMs,
});
return { sealed: true, seal };
}
/**
* F6: возражения, снятые владельцем (вейверы по anchor.ref), исключаются из активных
* на нижних воротах — судья не поднимает заново то, что владелец уже отвёл.
*/
export function applyWaivers(objections = [], waivers = []) {
const refs = new Set((waivers || []).map((w) => w && w.ref).filter(Boolean));
const active = [];
const waived = [];
for (const o of objections) {
const ref = o && o.anchor && o.anchor.ref;
(ref && refs.has(ref) ? waived : active).push(o);
}
return { active, waived };
}