85 lines
5.3 KiB
JavaScript
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 };
|
|
}
|