fix обзвон: дверь итога сторожей проверяет связность того, что приняла

Дверь принимала итог, который сам себе противоречил, и записывала «всё
хорошо». Годный по форме случай: тело говорит «сторожей 3, красных 2», а в
списке лежит одна зелёная строка — двое сторожей исчезали молча.

Дыры было две:
- длина списка storozha ни с чем не сверялась;
- поле storozhey_krasnyh не читалось вовсе — машина присылала своё
  показание, а дверь его выбрасывала.

Теперь сверяются три показания о числе сторожей — настройка, объявленное
число и длина списка — плюс объявленное число красных против нашего
подсчёта по строкам. Красных считаем ровно по машинному правилу: код не
ноль или провалов не ноль. Иначе зелёный сторож с нулём проверок давал бы
ложную тревогу, потому что портал ругается на такого, а машина его красным
не считает.

Сверх заказанного закрыты два места того же рода. Поля, которых дверь не
знает, ищутся вычитанием, а не перечислением — и в итоге, и в строке
сторожа: тогда забытое поле оказывается под защитой, а не мимо неё. И
ловится список нужной длины, набитый повторами одного имени: числа при этом
сходятся все до одного, а гоняли одного сторожа трижды.

Каждая новая проверка показана красной вырезанием своего куска по одному за
раз. Без чтения storozhey_krasnyh случай с объявленной краснотой при трёх
зелёных строках не ловит ничто — список бед выходит пустым.

Машину не трогал, схему не трогал, остальную дверь не переписывал.

Полный прогон: всего 5207, зелёных 5203, красных 0, ошибок 0, пропущено 4.
This commit is contained in:
Дмитрий
2026-08-06 23:53:05 +03:00
parent c61178ab26
commit c351b0b6f5
4 changed files with 505 additions and 0 deletions
@@ -50,6 +50,34 @@ class LenaStorozhaWebhookController extends Controller
/** Насколько дата итога может опережать наши часы, прежде чем мы ей не поверим. */
private const ZAPAS_VPERYOD_MINUT = 10;
/**
* Поля итога, которые дверь знает ПОИМЁННО.
*
* 🔴 Список нужен, чтобы считать проверенное ВЫЧИТАНИЕМ, а не перечислением.
* Перечисление («проверяем вот эти поля») оставляет всё остальное мимо
* защиты: добавят на машине новое поле, забудут добавить сюда и оно
* молча проедет. Вычитание («всё, чего нет в этом списке, новость»)
* ставит забытое поле ПОД защиту: дверь заругается, пока человек не
* посмотрит на него и не впишет сюда осознанно.
*
* Цена решения названа честно: безобидная прибавка поля на машине красит
* итог до правки этого списка. Так же устроено и число ожидаемых сторожей
* в настройке прибавление подтверждает человек, а не догадка портала.
*/
private const ZNAKOMYE_POLYA_ITOGA = [
'shema', // разновидность итога — проверена отдельно
'sobrano_v', // дата сборки — разбирается в receive()
'mashina', // кто прислал — знаем, нарочно не проверяем
'dlilos_ms', // сколько длился обход — ложится в отметку
'storozhey_vsego', // объявленное число сторожей
'storozhey_krasnyh', // объявленное число красных
'slepok_etalonov', // слепок папки — сверить не с чем, лежит для истории
'storozha', // сами строки сторожей
];
/** Поля строки сторожа, которые дверь знает поимённо. Остальное — новость. */
private const ZNAKOMYE_POLYA_STOROZHA = ['imya', 'kod', 'proverok', 'provalov'];
public function receive(Request $request): JsonResponse
{
if (! $this->podpisVerna($request)) {
@@ -93,6 +121,25 @@ class LenaStorozhaWebhookController extends Controller
/**
* Что не так с итогом. Пустой список = всё зелено.
*
* 🔴 ЧТО ЗДЕСЬ ЛЕГКО ПОТЕРЯТЬ ПРИ ПРАВКЕ СВЯЗНОСТЬ ТОГО, ЧТО ПРИНЯЛИ.
* Первый заход этой двери стоял на допущении «список storozha ПОЛОН,
* поэтому красноту можно пересчитать по нему, а собственное число красных
* из итога читать незачем». Допущение оказалось ложным дважды:
*
* 1. Длина списка ни с чем не сверялась. Тело «сторожей 3», а в списке
* одна зелёная строка двое сторожей исчезали, и портал записывал
* «всё хорошо».
* 2. Поле storozhey_krasnyh не читалось вовсе. Машина присылала своё
* показание, а дверь его выбрасывала: тело «красных 3» при трёх
* зелёных строках проезжало насквозь.
*
* Поэтому здесь сверяются ТРИ независимых показания об одном и том же:
* сколько сторожей ждёт настройка, сколько объявил итог и сколько строк
* в списке на самом деле. Расхождение любых двух само по себе беда,
* даже когда каждая строка по отдельности зелёная. Это не дублирование:
* согласие трёх показаний и есть единственное доказательство, что список
* не обрезан и не подменён.
*
* @param array<string, mixed> $itog
* @return list<string>
*/
@@ -106,6 +153,13 @@ class LenaStorozhaWebhookController extends Controller
return $bedy; // Дальше разбирать нечего — устройство файла нам неизвестно.
}
// Вычитанием, а не перечислением: всё, чего дверь не знает поимённо, —
// новость, о которой надо сказать вслух. См. ZNAKOMYE_POLYA_ITOGA.
$novyePolya = array_values(array_diff(array_keys($itog), self::ZNAKOMYE_POLYA_ITOGA));
if ($novyePolya !== []) {
$bedy[] = 'в итоге поля, которых дверь не знает: '.implode(', ', $novyePolya);
}
$zhdyom = (int) config('storozha_mashiny.ozhidaem_storozhey');
$vsego = (int) ($itog['storozhey_vsego'] ?? -1);
@@ -122,25 +176,73 @@ class LenaStorozhaWebhookController extends Controller
return $bedy;
}
// Второе показание против первого: список обязан быть той длины,
// какую итог сам же и объявил. Короткий список = сторожа исчезли.
if (count($spisok) !== $vsego) {
$bedy[] = 'строк в списке сторожей '.count($spisok).", а объявлено {$vsego}";
}
$krasnyhPoSpisku = 0;
$imena = [];
$novyePolyaStrok = [];
foreach ($spisok as $s) {
if (! is_array($s)) {
$bedy[] = 'строка сторожа не разобрана';
// Не разобрали — считаем красной. Звать её зелёной не за что,
// и наш счёт красных обязан остаться сравнимым с машинным.
$krasnyhPoSpisku++;
continue;
}
$novyePolyaStrok = array_merge(
$novyePolyaStrok,
array_diff(array_keys($s), self::ZNAKOMYE_POLYA_STOROZHA),
);
$imya = (string) ($s['imya'] ?? '(без имени)');
$imena[] = $imya;
$kod = (int) ($s['kod'] ?? -1);
$proverok = (int) ($s['proverok'] ?? -1);
$provalov = (int) ($s['provalov'] ?? -1);
if ($kod !== 0 || $provalov !== 0) {
// 🔴 Красным считаем РОВНО ПО ТОМУ ЖЕ правилу, по какому считает
// обход на машине (код не ноль ИЛИ провалов не ноль). Иначе
// сверка двух чисел ниже ругалась бы на ровном месте.
$krasnyhPoSpisku++;
$bedy[] = "{$imya}: код {$kod}, проверок {$proverok}, красных {$provalov}";
} elseif ($proverok <= 0) {
// Зелёный сторож, не сделавший ни одной проверки, — не сторож.
// 🔴 В счёт красных НЕ идёт: машина такого красным не считает,
// а мы сверяемся именно с её счётом.
$bedy[] = "{$imya}: код 0, но проверок {$proverok} — сторож ничего не проверил";
}
}
if ($novyePolyaStrok !== []) {
$bedy[] = 'в строках сторожей поля, которых дверь не знает: '
.implode(', ', array_values(array_unique($novyePolyaStrok)));
}
// Длину списка можно добрать повторами, и тогда все числа сойдутся,
// а гоняли одного сторожа трижды. Имена сторожей — имена файлов на
// машине, повторяться они не могут.
$povtory = array_keys(array_filter(array_count_values($imena), static fn (int $skolko): bool => $skolko > 1));
if ($povtory !== []) {
$bedy[] = 'в списке сторожей повторяются имена: '.implode(', ', $povtory);
}
// Третье показание: машина сама сказала, скольких считает красными.
// Не сошлось с нашим счётом — беда, даже если каждая строка зелена.
if (! array_key_exists('storozhey_krasnyh', $itog)) {
$bedy[] = 'в итоге нет числа красных — наш счёт сверить не с чем';
} elseif ((int) $itog['storozhey_krasnyh'] !== $krasnyhPoSpisku) {
$objavleno = (int) $itog['storozhey_krasnyh'];
$bedy[] = "красных по списку {$krasnyhPoSpisku}, а объявлено {$objavleno}";
}
return $bedy;
}
@@ -208,6 +208,110 @@ it('краснеет на неизвестной разновидности ит
expect(otmetka()->last_error)->toContain('непонятная разновидность');
});
// ======================================= СВЯЗНОСТЬ ТОГО, ЧТО ПРИНЯЛИ ========
//
// 🔴 Нож владельца по допущению, на котором стояла защита: «список storozha
// ПОЛОН, поэтому красноту можно пересчитать по нему, а собственное число
// красных из итога читать незачем». Оба случая ниже годны по форме —
// разновидность 1, дата свежая, подпись верна, адрес разрешён — и оба
// проходили насквозь: портал записывал «всё хорошо», хотя машина в том же
// теле прямо сказала обратное.
it('🔴 краснеет, когда в списке сторожей МЕНЬШЕ строк, чем объявлено', function (): void {
// Случай Б ножа владельца: объявлено трое, в списке ОДИН зелёный.
// Двое сторожей просто исчезли, и до починки портал молчал.
$telo = (string) json_encode([
'shema' => 1,
'sobrano_v' => now()->toIso8601String(),
'mashina' => 'fhmg5qpo4jconna4s9jo',
'dlilos_ms' => 41000,
'storozhey_vsego' => 3,
'storozhey_krasnyh' => 2,
'slepok_etalonov' => str_repeat('c', 64),
'storozha' => [
['imya' => 'proverka-raspisaniya', 'kod' => 0, 'proverok' => 26, 'provalov' => 0],
],
], JSON_UNESCAPED_UNICODE);
postuchat($telo)->assertStatus(202);
// 🪤 Приводим к строке нарочно. На голом toContain() непочиненная дверь даёт
// last_error = NULL, и Pest падает «Expected [iterable]» — по такому
// красному не видно, дыра это или сторож сам сломан. Со строкой красный
// честный: «'' не содержит ...».
$o = otmetka();
expect((string) $o->last_error)->toContain('строк в списке сторожей 1, а объявлено 3');
expect($o->last_success_at)->toBeNull()
->and((int) $o->consecutive_failures)->toBe(1);
});
it('🔴 краснеет, когда объявленное число красных не сходится со списком', function (): void {
// Случай В ножа владельца: машина объявила «красных 3», а все три строки
// зелёные. Портал обязан заметить, что итог сам себе противоречит, а не
// выбрасывать показание, которое ему дали.
$telo = (string) json_encode([
'shema' => 1,
'sobrano_v' => now()->toIso8601String(),
'mashina' => 'fhmg5qpo4jconna4s9jo',
'dlilos_ms' => 41000,
'storozhey_vsego' => 3,
'storozhey_krasnyh' => 3,
'slepok_etalonov' => str_repeat('d', 64),
'storozha' => [
['imya' => 'proverka-raspisaniya', 'kod' => 0, 'proverok' => 26, 'provalov' => 0],
['imya' => 'proverka-tekstovogo-sleda', 'kod' => 0, 'proverok' => 46, 'provalov' => 0],
['imya' => 'proverka-chistki', 'kod' => 0, 'proverok' => 27, 'provalov' => 0],
],
], JSON_UNESCAPED_UNICODE);
postuchat($telo)->assertStatus(202);
expect((string) otmetka()->last_error)->toContain('красных по списку 0, а объявлено 3');
});
it('🔴 контроль ножа: связный зелёный итог остаётся зелёным', function (): void {
// Случай А ножа владельца. Без него две проверки выше доказывали бы только
// то, что дверь научилась ругаться, — а не то, что она ругается по делу.
$otvet = postuchat(itogSTremyaZelenymi());
$otvet->assertStatus(202)->assertJson(['status' => 'принят', 'bedy' => []]);
expect(otmetka()->last_error)->toBeNull();
});
it('🔴 краснеет на поле, о котором дверь не знает', function (): void {
// Список «под защитой» считается ВЫЧИТАНИЕМ: всё, чего дверь не знает
// поимённо, — новость, о которой надо сказать вслух. Иначе поле, добавленное
// на машине и забытое здесь, окажется мимо защиты, а не под ней.
$itog = json_decode(itogSTremyaZelenymi(), true);
$itog['storozhey_propushcheno'] = 2;
postuchat((string) json_encode($itog, JSON_UNESCAPED_UNICODE))->assertStatus(202);
expect((string) otmetka()->last_error)->toContain('storozhey_propushcheno');
});
it('🔴 краснеет на поле строки сторожа, о котором дверь не знает', function (): void {
$itog = json_decode(itogSTremyaZelenymi(), true);
$itog['storozha'][1]['propushcheno'] = 4;
postuchat((string) json_encode($itog, JSON_UNESCAPED_UNICODE))->assertStatus(202);
expect((string) otmetka()->last_error)->toContain('propushcheno');
});
it('🔴 краснеет на список нужной длины, набитый одним и тем же сторожем', function (): void {
// Второе место того же рода, о котором нож владельца не говорил: длину
// списка можно добрать повторами. Тогда числа сходятся все до одного, а
// на деле гоняли одного сторожа трижды.
$itog = json_decode(itogSTremyaZelenymi(), true);
$itog['storozha'][1]['imya'] = 'proverka-raspisaniya';
$itog['storozha'][2]['imya'] = 'proverka-raspisaniya';
postuchat((string) json_encode($itog, JSON_UNESCAPED_UNICODE))->assertStatus(202);
expect((string) otmetka()->last_error)->toContain('повторяются имена');
});
it('🔴 разбирает НАСТОЯЩЕЕ тело, снятое с машины, а не только своё выдуманное', function (): void {
// 🪤 Шов между двумя честными половинками. Все проверки выше кормят портал
// телом, которое сами же и составили. Так можно доказать что угодно и
+33
View File
@@ -805,6 +805,39 @@ bash bots/lena-golos/proverka-sutochnogo-obhoda.sh
3. дописать его в список эталонов сторожа обхода
```
### Прибавили поле в итог — тоже скажите порталу
С 06.08.2026 портал проверяет не только числа в итоге, но и **связность** того,
что принял. Три вещи, о которых стоит знать, прежде чем править вывод обхода:
```text
1. Длина списка обязана сойтись с объявленным числом.
Сказали "storozhey_vsego": 3, а строк в списке две —
портал заругается. Раньше двое сторожей исчезали молча.
2. Объявленное число красных обязано сойтись со списком.
Поле "storozhey_krasnyh" портал теперь ЧИТАЕТ и сверяет
со своим подсчётом по строкам. Считает он красным ровно
по тому же правилу, что и обход: код не ноль ИЛИ провалов
не ноль. Зелёного сторожа с нулём проверок обход красным
не считает — и портал в этом счёте тоже не считает.
3. Поля, которых портал не знает, — беда, а не мелочь.
Портал знает восемь полей итога:
shema, sobrano_v, mashina, dlilos_ms,
storozhey_vsego, storozhey_krasnyh,
slepok_etalonov, storozha
и четыре поля строки сторожа:
imya, kod, proverok, provalov
Всё остальное он назовёт новостью и покраснеет.
```
🔴 **Цена этого названа честно:** прибавите в итог безобидное поле — портал
станет красным, пока человек не впишет его в список знакомых полей на
портальной стороне. Это сделано нарочно, той же рукой, что и число ожидаемых
сторожей: забытое поле должно оказаться **под** защитой, а не мимо неё. Правится
одной строкой в списке знакомых полей в двери приёма итога.
## Как сверить копию настройки с сервером
`extensions-lena.conf` — копия `/etc/asterisk/extensions.conf` с сервера
@@ -0,0 +1,266 @@
# Отчёт помощника — З-0.10, доделка по ножу владельца
Дата: 06.08.2026
Ветка: `feat/prospects-manual-testing-kp`
Чинится: дверь `app/app/Http/Controllers/Api/LenaStorozhaWebhookController.php`, метод `chtoNeTak()`
Что чинилось до этого: коммит `67195f0bf` — «машина робота проверяет себя сама, портал следит за свежестью»
Пишется ПО ХОДУ работы, не задним числом.
## Задача
Владелец принял вчерашнюю работу и нашёл в ней дыру своим ножом. Дыр на самом деле две:
1. Длина списка `storozha` нигде не сверяется с объявленным `storozhey_vsego`.
Список может быть короче — сторожа тихо исчезают, портал молчит.
2. Поле `storozhey_krasnyh` не читается вообще. Машина прислала своё показание —
дверь его выбросила.
## Ход работы
### 1. Разведка — что уже есть
Прочитано:
- дверь `LenaStorozhaWebhookController.php` (260 строк);
- сторож `app/tests/Feature/Lena/StorozhaMashinyTest.php` (338 строк, 18 проверок);
- настройка `app/config/storozha_mashiny.php`;
- обход на машине `bots/lena-golos/sutochnyy-obhod.sh` — только чтением, машину не трогал;
- потребитель отметки `app/app/Console/Commands/LenaProverkaObhodaStorozhey.php`.
### 2. Замер ДО починки
Заведено 6 новых проверок, из них 3 — дословно ножом владельца
(случаи Б, В и контроль А). Прогон по непочиненной двери:
```
всего 27, зелёных 22, красных 5
```
Красные — ровно пять новых, и все с одним и тем же словом:
```
Failed asserting that '' [ASCII](length: 0) contains "строк в списке сторожей 1, а объявлено 3"
Failed asserting that '' [ASCII](length: 0) contains "красных по списку 0, а объявлено 3"
Failed asserting that '' [ASCII](length: 0) contains "storozhey_propushcheno"
Failed asserting that '' [ASCII](length: 0) contains "propushcheno"
Failed asserting that '' [ASCII](length: 0) contains "повторяются имена"
```
Пустая строка слева — это и есть дыра: `last_error` в отметке **пустой**,
портал записал «всё хорошо». Контроль А (случай владельца «3 зелёных»)
прошёл зелёным и до починки — значит красные краснеют по делу, а не потому,
что сторож сломан.
🪤 Первый заход этих проверок падал не так: на голом `toContain()` Pest
выдавал «Invalid expectation value type. Expected [iterable]», потому что
`last_error` был `NULL`. По такому красному не различить дыру и сломанного
сторожа. Приведение к строке сделало красный честным.
### 3. Починка
Правлен один метод — `chtoNeTak()` — и добавлены два списка знакомых полей.
Остальная дверь не тронута.
Теперь сверяются **три независимых показания** об одном и том же:
1. сколько сторожей ждёт настройка (`ozhidaem_storozhey`);
2. сколько объявил сам итог (`storozhey_vsego`);
3. сколько строк в списке `storozha` на самом деле.
Плюс четвёртое, отдельное: объявленное `storozhey_krasnyh` против нашего
подсчёта красных по строкам.
🔴 **Самое тонкое место починки, и оно же — ответ на вопрос владельца о ложных
тревогах.** Красных считаем **ровно по тому же правилу, что и обход на машине**:
код не ноль ИЛИ провалов не ноль. У портала правило строже машинного — он
ругается ещё и на зелёного сторожа с нулём проверок, а машина такого красным
НЕ считает. Сравни я объявленное число со счётом жалоб, а не со счётом красных
по машинному правилу — портал ругался бы «числа разошлись» на каждом таком
случае. Это и была бы та ложная тревога, которой владелец опасался.
Сверх заказанного добавлены два места того же рода — обоснование в разборе:
- список знакомых полей **строки сторожа**, а не только верхнего уровня;
- повторы имён в списке (длину можно добрать одним и тем же сторожем).
### 4. Замер ПОСЛЕ починки
Проверки одного файла сторожей:
```
result=passed всего 27 зелёных 27 красных 0
```
🔑 Важнее числа то, что **21 прежняя проверка осталась зелёной**, и среди них —
та, что кормит дверь НАСТОЯЩИМ телом, снятым с машины 06.08.2026. То есть
новая строгость не забраковала то, что машина шлёт сегодня.
### 5. Порча собственной починки
Каждая проверка показана красной вырезанием своего куска — по одному за раз,
чтобы видеть, что сторож охраняет именно своё, а не соседнее.
| что вырезал | стало красных | что сказал сторож |
|---|---|---|
| сверку длины списка | 1 | `'красных по списку 0, а объявлено 2' не содержит 'строк в списке сторожей 1, а объявлено 3'` |
| сверку числа красных | 1 | `'' не содержит 'красных по списку 0, а объявлено 3'` |
| вычитание полей итога + повторы имён | 2 | `'' не содержит 'storozhey_propushcheno'`, `'' не содержит 'повторяются имена'` |
| вычитание полей строки сторожа | 1 | `'' не содержит 'propushcheno'` |
Два наблюдения из этой таблицы:
- при вырезанной сверке длины случай Б всё равно краснеет — но **другой**
проверкой, сверкой числа красных. Две новые защиты ловят случай Б независимо
друг от друга, и это видно глазом в тексте красного.
- при вырезанной сверке числа красных случай В даёт **пустую** строку бед.
То есть без чтения `storozhey_krasnyh` этот случай не ловит НИЧЕГО. Это
прямое доказательство, что показание не лишнее.
**Возврат доказан слепком со снятием невидимых знаков конца строки:**
```
до порчи: 7473756072af309d0b203853fc8c208ca75464fe
после возврата: 7473756072af309d0b203853fc8c208ca75464fe
```
Плюс `grep "false &&"` по двери — порчи не осталось.
### 6. Полный прогон
Своя база `liderra_testing_z010fix`, заведена и промигрирована отдельно —
чужих баз не занимал.
```
всего 5207, зелёных 5203, красных 0, ошибок 0, пропущено 4
утверждений 16348, длилось 19 минут
```
Арифметика сходится: `5203 + 0 + 0 + 4 = 5207`.
🔴 Датчиком брал **число проверок**, а не код возврата: обёртка печатает
«выход 0» даже когда прогон умер от нехватки памяти. `php artisan test`
не звал вовсе, `composer test` тоже — он режет по 300 секунд, а прогон идёт 19
минут. Вывод складывал в файл перенаправлением, не трубой.
### 7. Соседи
- **моего имени нет ни в одном выводе ни одного сторожа** — проверено поиском
по всем моим правкам и по отчёту: ни метки смены, ни имени помощника, ни
имени моей базы в коде и в текстах проверок нет.
- **чужие файлы в дереве не тронуты** — я правил ровно четыре пути: дверь,
её проверки, README машины и этот отчёт. Соседи в это же время держат
правки в `app/.env.example`, `app/config/services.php`,
`docs/observer/STATUS.md`, `package.json`, `package-lock.json` и в чужом
приёмочном листе `stroyka-5` — ни одного из них я не открывал на запись и
ни один не попадёт в мой коммит: кладу поимённо.
### 8. Зовущие поимённо
Кто зовёт то, что я правил — смотрено глазами, не числом:
| что | кто зовёт |
|---|---|
| `LenaStorozhaWebhookController@receive` | одна строка `routes/web.php:736`, `POST /api/webhook/lena-storozha`. Больше ниоткуда: поиск по всему коду дал только саму дверь и эту строку |
| эту дорогу снаружи | обход на машине 51.250.1.97, шаг 5 в `sutochnyy-obhod.sh` (`curl` с подписью), по расписанию `/etc/cron.d/lena-sutochnyy-obhod` |
| `chtoNeTak()` | только `receive()` в той же двери, один вызов |
| отметку, которую кладёт дверь | команда `lena:proverka-obhoda`, а её зовёт `routes/console.php:471` ежедневно |
| текст бед, который я дописал | та же команда, полем `last_error` → в письмо тревоги. Она читает поле целиком, поимённо тексты не разбирает — значит новые беды доедут до письма сами |
### 9. Разметка, стиль, статанализ
- `markdownlint` по этому отчёту и по README машины — `0 error(s)`.
- `pint --test` по обоим моим файлам PHP — `passed`.
- `composer stan` — 6 ошибок, **все шесть в файлах соседа**
(`app/Services/Obzvon/ObzvonClient.php`, `tests/Feature/Obzvon/ObzvonClientShovTest.php`),
которые мне трогать запрещено. В моих файлах — ноль.
## Разбор задания
### Моя ошибка — первой
Первый заход новых сторожей падал негодным красным: на голом `toContain()`
Pest выдавал «Invalid expectation value type. Expected [iterable]», потому что
`last_error` был `NULL`. Такой красный не различает «дыра есть» и «сторож сам
сломан» — то есть сторож, показанный красным, ничего этим не доказывал. Поймал
сам, приведением к строке. Но написал я его сперва неправильно.
### Картина беды владельца — верна, обе дыры настоящие
Замерил своим прогоном: до починки оба случая давали `last_error` **пустым**
портал записывал «всё хорошо». Подтверждаю оба пункта.
### Ответ на прямой вопрос: читать ли `storozhey_krasnyh`
**Да, читать. Но не так, как напрашивается — иначе ложные тревоги были бы.**
Показание не лишнее: вырезав его, я получил случай В с **пустым** списком бед.
Больше его не ловит ничто.
А вот опасение владельца про ложные тревоги — обоснованное, и оно бы сбылось
при прямолинейной починке. У портала и у машины **разные определения красноты**:
портал ругается ещё и на зелёного сторожа с нулём проверок, машина такого
красным не считает. Сравнивать объявленное число со счётом жалоб нельзя —
разошлись бы на ровном месте. Сравнивать надо со счётом, посчитанным
машинным правилом. Так и сделано, и это записано в коде комментарием, потому
что при следующей правке это первое, что потеряется.
### 🔴 Где картина владельца ЗАВЫШЕНА — про подделку
В задании сказано: автор той работы оставил незакрытым, что «подделать свежий
зелёный итог с утёкшим словом односторонний канал не мешает», и что мой нож
показывает — «подделывать связный итог даже не нужно, хватит обрезанного
списка».
**Как довод про злоумышленника это неверно.** Чтобы стук вообще дошёл до
разбора тела, его надо подписать тайным словом и прийти с разрешённого адреса.
У того, кто уже владеет словом, нет никакой причины слать обрезанный список —
он пришлёт связный зелёный итог, и моя починка его не поймает. Порог для
подделки задаёт подпись, и он этой работой **не поднят ни на сколько**.
Настоящая цена починки — другая, и она честная: защита от **случайной** порчи
и от будущих правок. Обход на машине правится (сегодня же и правился), вывод
собирается склейкой строк в оболочке, где потерять кусок легко и незаметно.
Вот от этого защита появилась. Предлагаю так и записать в решении, а довод
про подделку из обоснования убрать — иначе он даст ложное чувство, что канал
защищён лучше, чем он защищён.
### Второе место того же рода, о котором задание не говорило
Длину списка можно добрать **повторами**. Пришли три строки с одним и тем же
именем — и сходится ВСЁ: и с настройкой, и с объявленным числом, и с числом
красных. А гоняли одного сторожа трижды, двое молчат. Проверка на повтор имён
добавлена; ложных тревог она дать не может — имена сторожей это имена файлов
на машине, повторяться они не умеют.
Так же добавлено вычитание по полям **строки сторожа**, а не только верхнего
уровня: правило владельца про «новое поле должно оказаться под защитой»
относится к строкам ровно так же.
### Что в этой починке стоит денег и от чего можно отказаться
Вычитание по полям — единственная часть с будущей ценой: прибавят на машине
безобидное поле, и портал станет красным, пока человек не впишет его в список.
Это сделано нарочно и той же рукой, что число ожидаемых сторожей. Но если
владелец решит, что цена не по нему, отказываться надо **именно от вычитания**,
а две сверки чисел оставить — они не стоят ничего и ловят настоящее.
## Что осталось незакрытым
1. **Живой машины я не касался** — запрещено заданием, и не касался. Значит
доказано только то, что дверь стала строже к телу, снятому 06.08.2026.
Что обход на машине сегодня шлёт ровно это тело — взято из чужой проверки,
своими руками не перемерено.
2. **Портальная половина не выкачена.** README машины прямо говорит: файла
настроек отправки на машине нет, обход пишет итог файлом и в портал не
стучится. То есть вся эта цепочка на бою пока не работает — ни до моей
починки, ни после. Моя работа готовит выкат, но выкатом не является.
3. **Список знакомых полей сверен глазами, а не программой.** Он списан с
`sutochnyy-obhod.sh` и с настоящего тела в проверке. Сторожа, который
ронял бы сборку при расхождении списка в PHP и вывода в оболочке, нет.
4. **Статанализ по репозиторию не зелёный** — 6 ошибок в файлах соседней
смены. Мои файлы чисты, но общий прогон `composer stan` сейчас красный не
по моей вине и не мною чинится.
5. **Числа проверок 26/46/27** в проверках взяты из вчерашней работы как есть.
Изменятся сторожа на машине — проверки начнут врать, и заметит это только
человек.