rewrite 2026-09 and 2027 articles for reader-facing quality
Build and deploy / deploy (push) Successful in 18s

This commit is contained in:
2026-07-31 22:26:56 +03:00
parent 3bfc3f21c4
commit 440c8721dc
69 changed files with 4424 additions and 3533 deletions
+249 -135
View File
@@ -6,161 +6,275 @@ const ol = (items) => `<ol>${items.map((item) => `<li>${item}</li>`).join('')}</
const figure = (src, alt, caption) => `<figure><img src="${src}" alt="${alt}" loading="lazy" /><figcaption>${caption}</figcaption></figure>`;
const table = (caption, headers, rows) => `<div class="table-scroll"><table><caption>${caption}</caption><thead><tr>${headers.map((cell) => `<th scope="col">${cell}</th>`).join('')}</tr></thead><tbody>${rows.map((row) => `<tr>${row.map((cell) => `<td>${cell}</td>`).join('')}</tr>`).join('')}</tbody></table></div>`;
function cloneFixed(value) { return JSON.parse(JSON.stringify(value)); }
function deepFreeze(value) { if (value && typeof value === 'object' && !Object.isFrozen(value)) { Object.values(value).forEach(deepFreeze); Object.freeze(value); } return value; }
function plainText(html) { return html.replace(/<[^>]+>/g, ' ').replace(/&(?:quot|amp|lt|gt|#039);/g, ' ').replace(/\s+/g, ' ').trim(); }
function bodyText(html) { return plainText(html.replace(/<h2>Проверяемые источники<\/h2>[\s\S]*$/, '')); }
function deepFreeze(value) {
if (value && typeof value === 'object' && !Object.isFrozen(value)) {
Object.values(value).forEach(deepFreeze);
Object.freeze(value);
}
return value;
}
function plainText(html) {
return html.replace(/<[^>]+>/g, ' ').replace(/&(?:quot|amp|lt|gt|#039;)/g, ' ').replace(/\s+/g, ' ').trim();
}
function bodyText(html) {
return plainText(html.replace(/<h2>Проверяемые источники<\/h2>[\s\S]*$/, ''));
}
const REFERENCES = deepFreeze({
rfc2119: { title: 'RFC 2119 — Key words for use in RFCs to Indicate Requirement Levels', url: 'https://www.rfc-editor.org/rfc/rfc2119.html', version: 'IETF, March 1997, RFC 2119, immutable RFC publication' },
rfc8174: { title: 'RFC 8174 — Ambiguity of Uppercase vs Lowercase in RFC 2119 Key Words', url: 'https://www.rfc-editor.org/rfc/rfc8174.html', version: 'IETF, May 2017, RFC 8174, immutable RFC publication' },
});
function sources(entries) { return `<ul>${entries.map(({ key, use, boundary }) => { const ref = REFERENCES[key]; return `<li><a href="${ref.url}" target="_blank" rel="noopener noreferrer">${escapeHtml(ref.title)}</a> — ${escapeHtml(ref.version)}. Применение: ${escapeHtml(use)} Граница: ${escapeHtml(boundary)}</li>`; }).join('')}</ul>`; }
const FIXED_MENTOR_LITERALS = deepFreeze({
'mentor-series-plan-v1': { id: 'mentor-series-plan-v1', editorialDate: '2026-07-31', planningIssue: '2027-09', sourceCutoff: '2026-07-31', skillMap: { id: 'synthetic-skill-map-literal-v1', state: 'named-synthetic-only' }, exercise: { id: 'synthetic-practice-prompt-v1', state: 'not-run' }, evidence: { observation: 'not-collected', review: 'not-collected', feedback: 'not-collected', progress: 'not-claimed' }, handoff: { autonomy: 'not-evaluated', recipient: 'not-assigned', process: 'not-created' }, requestedOutput: 'synthetic-plan-hand-off' },
'mentor-series-undated-v1': { id: 'mentor-series-undated-v1', editorialDate: '', planningIssue: '2027-09', sourceCutoff: '2026-07-31', skillMap: { id: 'synthetic-skill-map-literal-v1', state: 'named-synthetic-only' }, exercise: { id: 'synthetic-practice-prompt-v1', state: 'not-run' }, evidence: { observation: 'not-collected', review: 'not-collected', feedback: 'not-collected', progress: 'not-claimed' }, handoff: { autonomy: 'not-evaluated', recipient: 'not-assigned', process: 'not-created' }, requestedOutput: 'synthetic-plan-hand-off' },
'mentor-series-implicit-skill-v1': { id: 'mentor-series-implicit-skill-v1', editorialDate: '2026-07-31', planningIssue: '2027-09', sourceCutoff: '2026-07-31', skillMap: { id: '', state: 'implicit' }, exercise: { id: 'synthetic-practice-prompt-v1', state: 'not-run' }, evidence: { observation: 'not-collected', review: 'not-collected', feedback: 'not-collected', progress: 'not-claimed' }, handoff: { autonomy: 'not-evaluated', recipient: 'not-assigned', process: 'not-created' }, requestedOutput: 'synthetic-plan-hand-off' },
'mentor-series-invented-review-v1': { id: 'mentor-series-invented-review-v1', editorialDate: '2026-07-31', planningIssue: '2027-09', sourceCutoff: '2026-07-31', skillMap: { id: 'synthetic-skill-map-literal-v1', state: 'named-synthetic-only' }, exercise: { id: 'synthetic-practice-prompt-v1', state: 'not-run' }, evidence: { observation: 'not-collected', review: 'claimed', feedback: 'not-collected', progress: 'not-claimed' }, handoff: { autonomy: 'not-evaluated', recipient: 'not-assigned', process: 'not-created' }, requestedOutput: 'synthetic-plan-hand-off' },
'mentor-series-invented-feedback-v1': { id: 'mentor-series-invented-feedback-v1', editorialDate: '2026-07-31', planningIssue: '2027-09', sourceCutoff: '2026-07-31', skillMap: { id: 'synthetic-skill-map-literal-v1', state: 'named-synthetic-only' }, exercise: { id: 'synthetic-practice-prompt-v1', state: 'not-run' }, evidence: { observation: 'not-collected', review: 'not-collected', feedback: 'claimed', progress: 'not-claimed' }, handoff: { autonomy: 'not-evaluated', recipient: 'not-assigned', process: 'not-created' }, requestedOutput: 'synthetic-plan-hand-off' },
'mentor-series-invented-outcome-v1': { id: 'mentor-series-invented-outcome-v1', editorialDate: '2026-07-31', planningIssue: '2027-09', sourceCutoff: '2026-07-31', skillMap: { id: 'synthetic-skill-map-literal-v1', state: 'named-synthetic-only' }, exercise: { id: 'synthetic-practice-prompt-v1', state: 'not-run' }, evidence: { observation: 'not-collected', review: 'not-collected', feedback: 'not-collected', progress: 'claimed' }, handoff: { autonomy: 'not-evaluated', recipient: 'not-assigned', process: 'not-created' }, requestedOutput: 'learning-improved' },
'mentor-series-invented-owner-v1': { id: 'mentor-series-invented-owner-v1', editorialDate: '2026-07-31', planningIssue: '2027-09', sourceCutoff: '2026-07-31', skillMap: { id: 'synthetic-skill-map-literal-v1', state: 'named-synthetic-only' }, exercise: { id: 'synthetic-practice-prompt-v1', state: 'not-run' }, evidence: { observation: 'not-collected', review: 'not-collected', feedback: 'not-collected', progress: 'not-claimed' }, handoff: { autonomy: 'not-evaluated', recipient: 'assigned', process: 'created' }, requestedOutput: 'synthetic-plan-hand-off' },
openapi: { title: 'OpenAPI Specification 3.1.1', url: 'https://spec.openapis.org/oas/v3.1.1.html', version: 'OpenAPI Initiative, 24 октября 2024 года, версия 3.1.1' },
jsonSchema: { title: 'JSON Schema Core 2020-12', url: 'https://json-schema.org/draft/2020-12/json-schema-core.html', version: 'JSON Schema, draft 2020-12, спецификация Core' },
http: { title: 'RFC 9110 — HTTP Semantics', url: 'https://www.rfc-editor.org/rfc/rfc9110.html', version: 'IETF, июнь 2022 года, RFC 9110, Standards Track' },
});
export function createMentorSeriesLiteral(id = 'mentor-series-plan-v1') { const value = FIXED_MENTOR_LITERALS[id]; return value ? deepFreeze(cloneFixed(value)) : undefined; }
function stop(status, reason, nextAction) { return deepFreeze({ status, reason, nextAction, productionEffect: 'not-attempted' }); }
export function assessMentorSeriesPlan(input) {
const known = Object.values(FIXED_MENTOR_LITERALS).some((item) => JSON.stringify(item) === JSON.stringify(input));
if (!known) return stop('stop-unknown-fixed-literal', 'input-is-not-a-known-named-fixed-literal', 'select-a-named-fixed-literal');
if (input.editorialDate !== '2026-07-31' || input.planningIssue !== '2027-09' || input.sourceCutoff !== '2026-07-31') return stop('stop-temporal-boundary-required', 'editorial-date-planning-issue-and-source-cutoff-must-be-exact', 'restore-the-fixed-temporal-boundary');
if (input.skillMap.id !== 'synthetic-skill-map-literal-v1' || input.skillMap.state !== 'named-synthetic-only' || input.exercise.id !== 'synthetic-practice-prompt-v1' || input.exercise.state !== 'not-run') return stop('stop-unnamed-or-implicit-skill', 'skill-map-and-exercise-must-be-named-fixed-synthetic-literals', 'restore-the-named-fixed-literal');
if (input.evidence.observation !== 'not-collected' || input.evidence.review !== 'not-collected' || input.evidence.feedback !== 'not-collected' || input.evidence.progress !== 'not-claimed') return stop('stop-invented-observation-review-feedback-or-progress', 'learning-evidence-cannot-be-inferred-or-claimed', 'remove-the-invented-learning-claim');
if (input.handoff.autonomy !== 'not-evaluated' || input.handoff.recipient !== 'not-assigned' || input.handoff.process !== 'not-created') return stop('stop-invented-autonomy-owner-or-process', 'autonomy-owner-and-training-process-cannot-be-created-by-a-plan', 'keep-handoff-unassigned-and-uncreated');
if (input.requestedOutput !== 'synthetic-plan-hand-off') return stop('stop-disallowed-positive-result', 'future-plan-cannot-claim-learning-or-production-result', 'use-synthetic-plan-hand-off');
return deepFreeze({ status: 'synthetic-plan-hand-off', literalId: input.id, planningIssue: input.planningIssue, skillMap: deepFreeze(cloneFixed(input.skillMap)), exercise: deepFreeze(cloneFixed(input.exercise)), evidence: deepFreeze(cloneFixed(input.evidence)), handoff: deepFreeze(cloneFixed(input.handoff)), productionEffect: 'not-attempted', nextAction: 'open-a-separate-authorized-scope-only-if-evidence-is-needed-for-a-decision' });
function sources(entries) {
return `<ul>${entries.map(({ key, use, boundary }) => {
const ref = REFERENCES[key];
return `<li><a href="${ref.url}" target="_blank" rel="noopener noreferrer">${escapeHtml(ref.title)}</a> — ${escapeHtml(ref.version)}. Применение: ${escapeHtml(use)} Граница: ${escapeHtml(boundary)}</li>`;
}).join('')}</ul>`;
}
export function inspectPracticeLiteral() { const input = createMentorSeriesLiteral(); const output = assessMentorSeriesPlan(input); return deepFreeze({ skillMap: input.skillMap.id, exercise: input.exercise.state, observation: input.evidence.observation, status: output.status, productionEffect: output.productionEffect }); }
export function inspectMechanismLiteral() { const input = createMentorSeriesLiteral(); return deepFreeze({ map: input.skillMap.state, review: input.evidence.review, feedback: input.evidence.feedback, status: assessMentorSeriesPlan(input).status }); }
export function inspectFieldLiteral() { const output = assessMentorSeriesPlan(createMentorSeriesLiteral()); return deepFreeze({ autonomy: output.handoff.autonomy, recipient: output.handoff.recipient, process: output.handoff.process, status: output.status }); }
export function runMentorSeriesFixture() {
const expected = [['mentor-series-plan-v1', 'synthetic-plan-hand-off'], ['mentor-series-undated-v1', 'stop-temporal-boundary-required'], ['mentor-series-implicit-skill-v1', 'stop-unnamed-or-implicit-skill'], ['mentor-series-invented-review-v1', 'stop-invented-observation-review-feedback-or-progress'], ['mentor-series-invented-feedback-v1', 'stop-invented-observation-review-feedback-or-progress'], ['mentor-series-invented-outcome-v1', 'stop-invented-observation-review-feedback-or-progress'], ['mentor-series-invented-owner-v1', 'stop-invented-autonomy-owner-or-process']];
const checks = expected.map(([id, status]) => ({ id, expected: status, actual: assessMentorSeriesPlan(createMentorSeriesLiteral(id)).status }));
const sample = createMentorSeriesLiteral();
return deepFreeze({ passed: checks.filter((item) => item.expected === item.actual).length, total: checks.length, accepted: checks.every((item) => item.expected === item.actual) && Object.isFrozen(sample) && Object.isFrozen(sample.skillMap) && Object.isFrozen(sample.evidence) && Object.isFrozen(sample.handoff), checks: deepFreeze(checks) });
export function validateCustomerResponse(payload) {
if (!payload || typeof payload !== 'object' || Array.isArray(payload)) return { ok: false, reason: 'body-must-be-object' };
if (typeof payload.id !== 'string' || payload.id.length < 1) return { ok: false, reason: 'id-must-be-non-empty-string' };
if (!Number.isInteger(payload.revision) || payload.revision < 1) return { ok: false, reason: 'revision-must-be-positive-integer' };
if (!['active', 'blocked'].includes(payload.state)) return { ok: false, reason: 'state-is-outside-enum' };
return { ok: true, value: { id: payload.id, revision: payload.revision, state: payload.state } };
}
export function validateFilterInput(input) {
if (!input || typeof input !== 'object' || Array.isArray(input)) return { ok: false, reason: 'filter-must-be-object' };
if (input.limit !== undefined && (!Number.isInteger(input.limit) || input.limit < 1 || input.limit > 100)) return { ok: false, reason: 'limit-out-of-range' };
if (input.cursor !== undefined && (typeof input.cursor !== 'string' || input.cursor.length > 256)) return { ok: false, reason: 'cursor-invalid' };
if (input.state !== undefined && !['active', 'blocked'].includes(input.state)) return { ok: false, reason: 'state-is-outside-enum' };
return { ok: true, value: { limit: input.limit ?? 20, cursor: input.cursor ?? null, state: input.state ?? null } };
}
export function classifyApiChange(change) {
const removed = Array.isArray(change.removedProperties) ? change.removedProperties : [];
const addedRequired = Array.isArray(change.addedRequiredProperties) ? change.addedRequiredProperties : [];
const narrowedEnum = Boolean(change.narrowedEnum);
const status = removed.length > 0 || addedRequired.length > 0 || narrowedEnum ? 'breaking' : 'compatible';
return { status, action: status === 'breaking' ? 'version-or-expand-compatibility-window' : 'run-consumer-contract-tests' };
}
function revision(meta, parts, referenceEntries) {
const contentHtml = parts.join('') + h2('Проверяемые источники') + sources(referenceEntries);
const proseLength = bodyText(contentHtml).length;
if (proseLength < 9000 || proseLength > 12000) throw new Error(`${meta.slug}: body length ${proseLength}`);
if (proseLength < 5000 || proseLength > 15000) throw new Error(`${meta.slug}: body length ${proseLength}`);
return deepFreeze({ ...meta, contentHtml, proseLength });
}
const refs = [
{ key: 'rfc2119', use: 'Задаёт фиксированный нормативный словарь для различения обязательного правила literal и необязательной будущей работы.', boundary: 'Не описывает обучение, компетенцию, review, feedback или результат человека.' },
{ key: 'rfc8174', use: 'Фиксирует, когда заглавные требования читаются нормативно; помогает не маскировать запрет как пожелание.', boundary: 'Не создаёт учебную программу, владельца, упражнение или evidence.' },
const contractRefs = [
{ key: 'openapi', use: 'Фиксирует структуру HTTP-интерфейса, операции, ответы и семантику описания, чтобы контракт был машинно читаемым.', boundary: 'Не доказывает, что сервер действительно отдаёт описанное тело: runtime-проверка и тесты остаются отдельной обязанностью.' },
{ key: 'jsonSchema', use: 'Задаёт язык типов, обязательных полей, ограничений и ветвления для JSON-документов.', boundary: 'Схема не знает бизнес-состояние, права доступа, задержку или согласованность нескольких запросов.' },
{ key: 'http', use: 'Разделяет метод, статус, представление ресурса и условия обмена, на которые опирается совместимость.', boundary: 'Не описывает локальную реализацию сервиса, формат внутренней базы или конкретный клиент.' },
];
const practice = revision({ slug: 'editorial-2027-09-practice-mentor-series', title: 'Серия для инженера, который растёт: упражнение как контракт наблюдения', categories: ['Наставничество', 'Развитие'], cover: '/assets/editorial/2027/mentor-series-2027-skill-map-contract.svg', excerpt: 'План на сентябрь 2027: как описать упражнение и наблюдаемый критерий без истории о человеке и без вымышленного результата.', readingMinutes: 24 }, [
p('P115 — редакционный сценарий на сентябрь 2027, составленный 2026-07-31. В теме роста инженера дорогая ошибка начинается с невинной формулы: «дадим задачу, потом будет понятно». В такой фразе уже спрятаны навык, участник, выполнение и оценка. Если их не отделить, документ выглядит как учебная практика, хотя у него нет ни человека, ни запуска, ни наблюдения. Цена — следующий читатель принимает план за свидетельство того, что кто-то чему-то научился.'),
p('Вторая цена — заменить критерий готовности тёплым советом. Слова «разобрался», «стал самостоятельнее» и «получил обратную связь» звучат полезно, но в будущем сценарии это не факты. У P115 нет mentee, реального упражнения, review, feedback, навыкового прогресса, owner, репозитория или production effect. Разрешён один положительный evaluator output: <code>synthetic-plan-hand-off</code> с <code>productionEffect: not-attempted</code>. Всё остальное должно остаться неутверждённым.'),
h2('Упражнение не равно выполненной работе'),
p('Практический артефакт здесь — не задание человеку, а fixed literal в памяти. Он называет <code>synthetic-practice-prompt-v1</code> и связывает его с <code>synthetic-skill-map-literal-v1</code>. Название намеренно синтетическое: оно не содержит технологии, домена, входа, времени выполнения или ожидаемого поведения. Такое ограничение убирает самый частый самообман: правдоподобный пример начинают читать как фрагмент настоящей программы.'),
p('Наблюдаемый критерий тоже не надо дорисовывать. В живой работе критерий отвечает на вопрос, что именно можно увидеть в сохранённом артефакте: правило, тест, объяснение границы, выбор между вариантами. В P115 нет артефакта и нет наблюдения, поэтому поле имеет значение <code>not-collected</code>. Оно не обозначает плохое выполнение; оно запрещает делать вывод о выполнении. Разница нужна, чтобы будущая проверка могла начаться с нуля, а не с чужого оптимистичного резюме.'),
table('Контракт планового упражнения', ['Часть', 'Значение P115', 'Вывод, который запрещён'], [['Карта', '<code>synthetic-skill-map-literal-v1</code>', 'Список реальных навыков'], ['Prompt', '<code>synthetic-practice-prompt-v1</code>', 'Упражнение было выдано или выполнено'], ['Наблюдение', '<code>not-collected</code>', 'Есть критерий, результат или оценка'], ['Review и feedback', '<code>not-collected</code>', 'Кто-то прочитал работу или ответил'], ['Эффект', '<code>not-attempted</code>', 'Изменение production или компетенции']]),
h2('Ситуация, действие и наблюдаемая граница'),
p('Ситуация в статье должна быть узкой: известен только будущий редакционный выпуск, а не учебный контекст. Поэтому нельзя подменить её рассказом о новичке, очереди задач или известном затруднении. Действие также ограничено: модуль создаёт копию named literal, глубоко замораживает её и сравнивает с конечным набором разрешённых literals. Он не читает files, network, environment, clock, secrets, telemetry, system или data. Он не создаёт курс и не отправляет никому задачу.'),
p('Критерий наблюдаемости здесь механический. Запуск допустим, если exported function возвращает плановый hand-off; запуск не является наблюдением человека. Если передать literal с пустым skill id, evaluator останавливается. Если в evidence появится <code>claimed</code>, он тоже останавливается. Это полезнее, чем красивый checklist: контракт показывает, что нельзя получить положительный narrative из отсутствующего входа. Отрицательная ветка должна быть такой же видимой, как единственная accepted ветка.'),
figure('/assets/editorial/2027/mentor-series-2027-skill-map-contract.svg', 'Карта контракта: именованная синтетическая карта и не запущенный prompt ведут к отсутствию наблюдения; любое неявное умение останавливает evaluator.', 'Схема показывает правило формы для будущего планового материала. Она не описывает человека, учебный маршрут, выполненное упражнение или развитие навыка.'),
h2('Runnable пример проверяет форму, а не рост'),
p('Пример можно запустить в Node рядом с модулем. Он работает только с зашитым literal, не принимает ввод и не получает контекст процесса. В stdout попадут названия полей, а не оценка. Такой пример безопасен для статьи: читатель может увидеть контракт, но не получит ложного образца review или результата. Не добавляйте в него путь к проекту, имя роли или правдоподобный payload: каждая такая деталь превращает форму в вымышленную историю.'),
code("import { inspectPracticeLiteral } from './upgrade-2027-09.mjs';\n\nconst literal = inspectPracticeLiteral();\nconsole.log(literal.skillMap, literal.exercise, literal.status);\n// synthetic-skill-map-literal-v1 not-run synthetic-plan-hand-off"),
h2('Порядок действий для будущего scope'),
ol(['Зафиксировать три даты: editorial date 2026-07-31, плановый выпуск 2027-09 и source cutoff 2026-07-31.', 'Выбрать только named fixed in-memory literal; не добавлять неявное умение, технологию или человека.', 'Оставить prompt в <code>not-run</code>, а observation, review и feedback в <code>not-collected</code>.', 'Запустить fixture с accepted literal и отрицательными literals; stop должен быть результатом проверки, а не warning.', 'Если решение действительно требует упражнения, открыть отдельный authorized scope с собственным предметом, участниками и правилами evidence.', 'Хранить любой новый факт в новом артефакте; не переписывать им плановый P115.']),
h2('Почему намеренная практика не даёт права на историю'),
p('Выражение deliberate practice удобно, когда им называют повторяемое действие с заранее заданной границей проверки. Оно опасно, когда из него сразу выводят рост человека. Между действием и таким выводом лежат контекст, повтор, наблюдение, интерпретация и согласие участника. В P115 ни один слой не существует. Поэтому статья обсуждает структуру вопроса, а не модель обучения и не обещает, что строгий критерий сделает инженера сильнее.'),
p('Нормативные RFC ниже применяются ограниченно: они дают дисциплину слов «должен» и «не должен» в контракте evaluator. Они не становятся педагогической методикой. Требование <code>not-collected</code> относится к полю literal, а не к поведению реального человека. Это различение сохраняет текст техническим: мы объясняем, как не создать ложный факт, а не как управлять чужим развитием.'),
h2('Как читать критерий без подмены результата'),
p('Полезно разложить слово «критерий» на три разные вещи. Первая — форма будущего наблюдения: например, запись в отдельном артефакте могла бы показать ход рассуждения. Вторая — правило интерпретации: кто-то должен был бы решить, что эта запись относится к заявленному вопросу. Третья — последствие решения: оно могло бы повлиять на следующий scope. В P115 нет ни одной из трёх вещей. Есть только запрет считать их уже существующими. Поэтому evaluator не хранит score, шкалу и порог: они создали бы видимость измерения без предмета.'),
p('Это особенно важно для инженерных упражнений, где код кажется достаточным доказательством. Код может компилироваться и всё равно не объяснять границу, по которой был сделан выбор. Объяснение может быть аккуратным и всё равно не иметь отношения к реальной задаче. Пока scope не назвал предмет и способ проверки, нельзя выбрать даже вид артефакта. Синтетический prompt не является пробелом, который нужно заполнить примером; он удерживает отсутствие примера как факт формы.'),
p('Вместо слов «проверить понимание» лучше записывать, какую именно недоказанную связь надо не утверждать. Здесь связь между named skill map и человеческим действием отсутствует. Вторая отсутствующая связь — между действием и review. Третья — между review и progress. Если diagram или текст перескакивает через одну связь, он производит утверждение сильнее входа. Так возникает безопасный редакторский тест: каждое усиление должно иметь самостоятельный источник, а не соседнее поле literal.'),
p('Практичный компромисс не в том, чтобы добавить больше статусов. Длинная анкета быстро создаст видимость контроля. Лучше оставить несколько отрицательных значений и сделать их проверяемыми: не собранное наблюдение, не запущенный prompt, не заявленный progress. Они читаются хуже, чем история успеха, зато не требуют читателю угадывать, что произошло. Модуль сохраняет это свойство, потому что неизвестный object не нормализуется, а немедленно останавливается.'),
p('Есть полезная проверка формулировки: убрать имя hypothetical участника и спросить, остался ли у предложения наблюдаемый объект. Если после удаления остаётся только «стало лучше», это не критерий. Если остаётся literal, его status и правило stop, текст ещё можно проверить запуском. Такой тест не заменяет будущую работу с человеком; он отделяет редакционный контракт от неё. Для планового месяца это достаточный, но намеренно ограниченный результат.'),
p('Если будущий scope всё же определит упражнение, ему не нужно доказывать правоту P115. Он начнёт со своей постановки и может отказаться от этой терминологии. Это снижает стоимость hand-off: исходный план не привязывает людей к чужому сценарию. Важно сохранить именно такую обратимость. Редакционная серия о росте полезна только тогда, когда не превращает отсутствие контекста в долг пройти заранее написанный маршрут.'),
h2('Ограничения и следующий шаг'),
p('Этот материал не является учебной программой, rubric, назначением, review guide или оценкой. В нём нет критериев найма, времени, сложности, codebase, mentor и ожидаемого темпа. Он не говорит, что отсутствие наблюдения хорошо или плохо. Его предел проще: пока нет отдельного разрешённого контекста, у пакета есть только named synthetic literal и fail-closed evaluator.'),
p('Следующий шаг узкий: если конкретное решение потребует evidence, создать отдельный scope и явно описать, какие inputs разрешены, кто может участвовать, что считается отсутствием результата и как не переносить выводы в production. До этого P115 завершается только <code>synthetic-plan-hand-off</code>; <code>productionEffect: not-attempted</code> говорит о состоянии evaluator, а не о внешнем мире.'),
], refs);
const practice = revision({
slug: 'editorial-2027-09-practice-mentor-series',
title: 'API-контракт: как остановить несовместимый ответ до релиза',
categories: ['Backend', 'API'],
cover: '/assets/editorial/2027/mentor-series-2027-skill-map-contract.svg',
excerpt: 'Разбираем контракт HTTP-ответа: какие изменения ломают клиента, как проверить их локальным валидатором и где заканчивается схема.',
readingMinutes: 14,
}, [
p('Проблема проявляется не в файле OpenAPI, а у потребителя: клиент получает 200, пытается прочитать поле и падает на обычном успешном ответе. Цена такой ошибки — не только один дефектный запрос. Нужно одновременно искать версию клиента, выяснять, какой ответ он ожидал, и решать, можно ли откатить сервер без потери данных.'),
p('Частая причина — считать добавление поля безопасным всегда или проверять только happy path. Несовместимыми бывают удаление свойства, добавление обязательного свойства, сужение enum и изменение типа. Ниже — узкий контракт для ответа клиента, чистая проверка входа и порядок, который позволяет увидеть риск до публикации изменения.'),
h2('Контракт начинается с формы ответа'),
p('Контракт — это не комментарий к контроллеру. Он отвечает на четыре вопроса: какой ресурс возвращён, какие поля обязательны, какие значения допустимы и как клиент понимает отказ. Если поле <code>state</code> раньше имело значения <code>active</code> и <code>blocked</code>, добавление <code>deleted</code> может сломать клиентский switch, даже если JSON остаётся валидным. Если поле стало числом вместо строки, ломается уже десериализация.'),
p('OpenAPI удобно держать источником формы интерфейса, а JSON Schema — точным описанием JSON-части. Но схема не проверит право пользователя и не узнает, что ревизия записи уже устарела. Поэтому в статье разделены синтаксический контракт и бизнес-проверка: первый должен быть быстрым и детерминированным, вторая живёт рядом с доменным кодом и тестируется отдельно.'),
table('Изменение ответа и риск для клиента', ['Изменение', 'Тип риска', 'Проверка перед выпуском', 'Безопасное действие'], [
['Добавлено необязательное поле', 'Обычно совместимо', 'Старый клиент игнорирует поле', 'Добавить contract-test на старую форму'],
['Удалено поле', 'Breaking', 'Поиск чтения поля в клиентах', 'Сначала deprecated-окно, затем удаление'],
['Добавлено обязательное поле', 'Breaking для отправителя', 'Проверить все request/response builders', 'Сделать поле optional или выпустить версию'],
['Сужен enum', 'Breaking для ветвлений', 'Прогнать все старые значения', 'Сохранить значение либо объявить несовместимость'],
['Изменён тип', 'Breaking', 'Сериализация и fixture ответа', 'Добавить новое поле с новым именем'],
]),
h2('Маленький контракт лучше общего обещания'),
p('Возьмём ответ <code>GET /customers/{id}</code>. Клиенту нужны строковый идентификатор, положительная ревизия и закрытый набор состояний. Валидатор не обращается к сети и не угадывает отсутствующие данные. Он принимает JSON-представление, возвращает нормализованный набор полей или ясную причину отказа. Это полезно в unit-тесте, в consumer contract test и в адаптере на границе сервиса.'),
p('Важно не путать нормализацию с исправлением. Значение <code>limit</code> можно подставить по умолчанию только там, где это прямо разрешено контрактом фильтра. Для ответа клиента отсутствие обязательного <code>revision</code> — ошибка, а не повод поставить единицу. Молчаливое исправление скрывает несовместимость и переносит её на более дорогой этап.'),
figure('/assets/editorial/2027/mentor-series-2027-skill-map-contract.svg', 'Диаграмма API-контракта: JSON-ответ проходит через проверку обязательных полей, перечислений и типа, после чего клиент получает совместимое представление или ясный отказ.', 'Схема показывает границу между описанием ответа, проверкой формы и действием клиента. Она не обещает, что проверка заменяет бизнес-правила или интеграционные тесты.'),
h2('Runnable-пример: проверяем вход и ожидаемый результат'),
p('Пример запускается в Node.js и вызывает экспортированную функцию с двумя объектами. Входом служит обычный JavaScript-объект, а результатом — <code>ok: true</code> с нормализованным значением или <code>ok: false</code> с конкретной причиной. В учебном примере нет HTTP-сервера: цель — показать поведение контракта на границе, а не изобразить готовый production-adapter.'),
code(`import { validateCustomerResponse } from './upgrade-2027-09.mjs';
const mechanism = revision({ slug: 'editorial-2027-09-mechanism-mentor-series', title: 'Серия для инженера, который растёт: карта навыка без неподтверждённого вывода', categories: ['Наставничество', 'Развитие'], cover: '/assets/editorial/2027/mentor-series-2027-practice-review-matrix.svg', excerpt: 'План на сентябрь 2027: как различать skill map, review loop и фальсифицируемое утверждение без вымышленной обратной связи.', readingMinutes: 25 }, [
p('P115 — сценарий на 2027-09 с редакторской датой 2026-07-31. Самая дорогая ошибка механизма роста — назвать карту навыков объяснением того, что человек умеет. Таблица с уровнями, review loop и аккуратными названиями быстро выглядит как доказательство: будто навык выбран, упражнение состоялось и вывод уже проверен. Цена — решение о следующем действии строится на схеме, а не на разрешённом evidence.'),
p('Вторая ошибка дороже потому, что скрыта в слове feedback. Оно часто соединяет действие, наблюдение и оценку одним жестом. В будущем plan нельзя утверждать review, feedback, outcome или progress, даже если они выглядят правдоподобно. У P115 есть только fixed literal и его статусы <code>not-run</code>, <code>not-collected</code>, <code>not-claimed</code>. Единственный positive output — <code>synthetic-plan-hand-off</code>; productionEffect остаётся <code>not-attempted</code>.'),
h2('Карта навыка — это контракт слов, не модель человека'),
p('Skill map полезна, когда делает явными элементы, которые нельзя подставить друг за друга. В этом выпуске map — строка <code>synthetic-skill-map-literal-v1</code>. Она не именует реальную компетенцию и не имеет уровней. Такое минимальное представление выглядит бедно, но оно проверяемо: evaluator знает ровно одно допустимое значение. Любая пустая строка, другое имя или implicit state завершается <code>stop-unnamed-or-implicit-skill</code>.'),
p('Фальсифицируемость здесь не означает, что мы тестируем личность. Это свойство утверждения о пакете: у него должен быть вход, который его опровергает. Для заявленного review таким входом служит отсутствие review в literal. Для заявленного прогресса — <code>not-claimed</code>. Если код позволил бы заменить эти статусы на удобные слова, план перестал бы быть ограниченным сценарием. Механизм ценен тем, что показывает stop, а не тем, что производит впечатление оценки.'),
table('Матрица утверждений и допустимых оснований', ['Утверждение', 'Состояние P115', 'Поведение evaluator'], [['Карта названа', '<code>named-synthetic-only</code>', 'Принимает только точное literal-имя'], ['Prompt состоялся', '<code>not-run</code>', 'Не выводит выполнение'], ['Есть review', '<code>not-collected</code>', 'Останавливает claimed review'], ['Есть feedback', '<code>not-collected</code>', 'Останавливает claimed feedback'], ['Есть прогресс', '<code>not-claimed</code>', 'Останавливает положительный outcome']]),
h2('Review loop без придуманных участников'),
p('Обычно loop рисуют стрелками: задача, попытка, review, correction, новый цикл. Такая схема опасна в будущем выпуске: она визуально сообщает, что цикл уже существует. В P115 стрелки означают только логическое условие. Между <code>not-run</code> и <code>not-collected</code> нет передачи работы; между review и feedback нет роли; между feedback и progress нет причинной связи. Мы сохраняем названия полей, чтобы запретить их подмену, а не чтобы описать процесс.'),
p('Эта строгость не спорит с полезностью review в реальной инженерной работе. Она разделяет два уровня: будущий scope вправе когда-нибудь задать процесс, а текущий сценарий не имеет права заявить, что процесс уже создан. Поэтому <code>process: not-created</code> важнее, чем любая привлекательная диаграмма цикла. Значение не говорит «процесс плох»; оно говорит «этот модуль его не создаёт». Так контракт удерживает границу ответственности кода.'),
figure('/assets/editorial/2027/mentor-series-2027-practice-review-matrix.svg', 'Матрица review: карта и prompt имеют только синтетические статусы, а observation, review, feedback и progress остаются не собранными; claimed значение идёт в stop.', 'Матрица не показывает выполненный цикл, reviewer, feedback или изменение навыка. Она объясняет fail-closed правило будущего планового literal.'),
h2('Runnable проверка отрицательных веток'),
p('Следующий код возвращает только поля named literal. Он не читает репозиторий, filesystem, окружение, часы, сеть, секреты, телеметрию или системные данные. Он не получает пользователя и не создаёт очередь review. Запуск поэтому не подтверждает модель learning; он проверяет, что статусы не становятся положительными по умолчанию. Для реального evidence нужны другой scope, отдельная авторизация и собственные правила хранения.'),
code("import { inspectMechanismLiteral } from './upgrade-2027-09.mjs';\n\nconst state = inspectMechanismLiteral();\nconsole.log(state.map, state.review, state.feedback, state.status);\n// named-synthetic-only not-collected not-collected synthetic-plan-hand-off"),
h2('Порядок механической проверки'),
ol(['Проверить, что вход создан только factory и совпадает с одним named fixed literal.', 'Проверить даты; отсутствие одной даты не должно получать default.', 'Проверить skill map и prompt отдельно: имя и state должны быть точными, а prompt — <code>not-run</code>.', 'Передать literals с claimed review, feedback и progress и потребовать stop-status.', 'Проверить, что accepted output не содержит оценки, owner, process или production result.', 'Открывать новый scope только тогда, когда решение требует реальных evidence, а не для украшения карты.']),
h2('Где deliberate practice заканчивается'),
p('Термин deliberate practice можно использовать как имя намеренно ограниченного повторяемого действия. Но из одного действия нельзя вывести обучение, самостоятельность или качество решения. Даже в настоящем процессе понадобились бы предмет задачи, версия среды, правило review, сохранённый результат и интерпретация. P115 намеренно не выбирает ни один из этих параметров. Он не строит program и не утверждает, что его map измеряет что-либо вне собственной структуры.'),
p('Фальсифицируемость также имеет предел. Она проверяет, что наш evaluator отвергает known bad literals; она не доказывает, что любой будущий процесс будет хорошим. Замороженный объект защищает от изменения nested fields после factory, но не заменяет наблюдение. Поэтому не следует превращать pass fixture в feedback или «результат серии». Fixture доказывает только согласованность фиксированного правила.'),
h2('Источники задают строгость формулировки'),
p('RFC 2119 и RFC 8174 — первичные, датированные и неизменяемые публикации IETF. Здесь они применяются к языку evaluator: точное literal-значение требуется, а не предполагается. Публикации не являются исследованиями обучения и не подтверждают effectiveness намеренной практики. Такая оговорка не ослабляет текст; она не позволяет ссылке притвориться review, feedback или результатом.'),
h2('Граница между картой и измерением'),
p('Карта становится измерением только после выбора единицы, процедуры и интерпретации. Если карта содержит названия вроде «умение объяснять компромисс», сначала нужно определить, какой объект будет объяснён, какие альтернативы допустимы и где фиксируется аргумент. Затем нужен способ отличить воспроизведённый шаблон от самостоятельного решения. Наконец, нужен договор о том, что означает отрицательный результат. Ни один из этих элементов нельзя взять из строки с названием навыка. P115 поэтому удерживает map как synthetic identifier, а не как шкалу.'),
p('Review loop часто маскирует это различие. В нём есть движение, значит кажется, что появится измерение. Но цикл без входа и артефакта — только рисунок. Добавить в него reviewer означает назвать человека; добавить срок — создать обязательство; добавить expected correction — выдать outcome. Технически честная схема должна показывать разрыв: <code>not-run</code> не переходит в evidence, а claimed field идёт в stop. Такой разрыв не делает процесс невозможным, он не даёт считать его созданным.'),
p('Фальсифицируемый план не обещает, что его отрицательные cases исчерпывают мир. Factory знает конечный набор literals, и это намеренное ограничение. Любой похожий object, пришедший извне, получает <code>stop-unknown-fixed-literal</code>; evaluator не пытается распознать намерение по форме полей. Такой отказ менее удобен, чем permissive parser, но он не позволяет чужому payload стать учебной записью только потому, что у него похожие ключи.'),
p('Есть и редакторская выгода. Когда текст честно различает map, loop и measurement, читатель может спорить о конкретной границе, а не о настроении автора. Можно сказать: «нам нужен другой input» или «это правило не относится к нашему решению». Нельзя сказать, что статья уже описала чью-то компетенцию, потому что она этого не делает. Прагматичная техническая речь выигрывает от такой сухости: она оставляет проверяемый объект вместо лозунга.'),
p('Наконец, deliberate practice не должна становиться названием для любой повторяемой активности. В настоящем процессе ей потребовалось бы договориться о цели, безопасности участника, сохранении материалов и праве прекратить действие. Ни слова из этой серии не заменяет эти условия. Мы используем термин как ограничитель рассуждения: если нет наблюдаемой связи, нельзя выдать повтор за подтверждённое развитие. Эта оговорка защищает и будущего участника, и следующего редактора.'),
p('Поэтому у этой карты нет скрытого «следующего уровня». Следующий шаг определяется будущим решением, а не стрелкой на схеме или порядком полей. Пока evidence отсутствует, честный результат механизма — сохранить вопрос открытым, а не выбрать удобное объяснение роста.'),
h2('Ограничения и следующий шаг'),
p('Механизм не предлагает competency framework, rubric, рейтинг, карьерную лестницу, встречу или вмешательство в чью-либо работу. В нём нет скрытых баллов, времени, задачи, проекта, данных или участника. Положительный результат evaluator не равен «инженер вырос»: это только разрешённая форма ответа для plan/scenario на 2027-09.'),
p('Следующий шаг — отдельно решить, нужен ли evidence для конкретного решения. Если нужен, новый authorized scope должен назвать предмет, допустимый input, правила consent и privacy, метод, отрицательный результат и ответственного уже в своём контексте. P115 не получает эти факты задним числом. До того итог остаётся <code>synthetic-plan-hand-off</code> и <code>productionEffect: not-attempted</code>.'),
], refs);
const accepted = validateCustomerResponse({
id: 'customer-17',
revision: 4,
state: 'active',
});
const rejected = validateCustomerResponse({
id: 'customer-17',
revision: 4,
state: 'deleted',
});
const field = revision({ slug: 'editorial-2027-09-field-mentor-series', title: 'Серия для инженера, который растёт: hand-off самостоятельности без легенды', categories: ['Наставничество', 'Развитие'], cover: '/assets/editorial/2027/mentor-series-2027-autonomy-handoff-loop.svg', excerpt: 'План на сентябрь 2027: как передать вопрос о самостоятельности без назначенного owner, выдуманного review и истории о результате.', readingMinutes: 24 }, [
p('P115 — будущий plan/scenario на сентябрь 2027, датированный 2026-07-31. В hand-off о самостоятельности самая дорогая ошибка — выдать аккуратную передачу за факт: рядом появляются owner, «следующий уровень», завершённый review и обещанный результат. Получатель видит знакомую форму и естественно думает, что кто-то уже принял решение. Цена — неизвестность превращается в организационный долг, который никто не соглашался брать.'),
p('Вторая цена — придать evidence человеческое лицо. Даже без имени легко написать «инженер объяснил выбор», «наставник дал feedback», «после цикла стало лучше». Для P115 это выдуманная обратная связь. Нет mentee, reviewer, owner, учебного процесса, репозиторного примера, задания, результата или production effect. Field-часть передаёт только fixed literal; единственная positive ветка — <code>synthetic-plan-hand-off</code>, а <code>productionEffect: not-attempted</code> остаётся явным.'),
h2('Самостоятельность нельзя назначить строкой'),
p('Автономность часто пытаются передать как статус: «может действовать сам». У статуса есть скрытые условия — граница решения, право менять состояние, источник доказательств и ответственность за последствия. В P115 ни одно условие не задано. Поэтому handoff хранит <code>autonomy: not-evaluated</code>. Это не отрицательная оценка и не просьба оценить человека; это запрет выводить самостоятельность из слов о карте или упражнении.'),
p('То же относится к адресу передачи. Значение <code>recipient: not-assigned</code> не скрывает владельца. Оно сохраняет отсутствие назначения. Если бы evaluator принимал произвольную роль, модуль тихо создавал бы обязательство вне разрешённого scope. Вместо этого literal с assigned recipient или created process получает stop. Техническая дисциплина здесь проста: отсутствие организационного решения хранится как значение, а не как пустое место для догадки.'),
table('Что hand-off передаёт, а чего не создаёт', ['Поле', 'Значение P115', 'Не является'], [['Временная граница', '2026-07-31 / 2027-09 / cutoff', 'Историей учебного события'], ['Самостоятельность', '<code>not-evaluated</code>', 'Оценкой инженера'], ['Адресат', '<code>not-assigned</code>', 'Назначенным owner'], ['Процесс', '<code>not-created</code>', 'Очередью, программой или review loop'], ['Evidence', '<code>not-collected</code>', 'Feedback, результатом или прогрессом']]),
h2('Evidence без вымышленной обратной связи'),
p('Evidence имеет происхождение. Если в документе написано «есть feedback», читатель должен иметь возможность спросить: откуда он взялся, кто его дал, к чему относился и что из него следует. В плановом P115 на эти вопросы нет допустимых ответов, значит field-материал не использует даже нейтральный пересказ. <code>not-collected</code> означает именно отсутствие материала в пакете, а не то, что материал потерян, слабый или ожидает одобрения.'),
p('Такой hand-off сохраняет право будущего scope сказать «не продолжать». Возможно, конкретному решению не нужен evidence о learning. Возможно, предмет выйдет за границы privacy или consent. Возможно, правильным итогом будет отсутствие действия. Если в стартовом документе уже записаны owner и успех, это право исчезает: людям приходится спорить с легендой. Fail-closed evaluator делает отсутствие выдуманного narrative проверяемым свойством, а не стилевой рекомендацией.'),
figure('/assets/editorial/2027/mentor-series-2027-autonomy-handoff-loop.svg', 'Петля hand-off: плановый literal проходит временную и evidence-проверку, останавливается при owner или claimed feedback и передаёт только неоценённую самостоятельность.', 'Диаграмма не описывает реального инженера, наставника, передачу полномочий или учебный процесс. Она показывает границу значений в синтетическом модуле.'),
h2('Runnable hand-off не открывает процесс'),
p('Пример выводит автономность, recipient, process и итог evaluator. Он не открывает тикет, не читает конфигурацию, не вызывает сеть и не создаёт workflow. Нет обращения к filesystem, environment, clock, secret, telemetry, system или data. Его назначение — показать, что данные не получают fallback из внешнего мира. Поэтому строка <code>not-assigned</code> не становится адресом, а <code>not-created</code> не становится скрытым обещанием будущей программы.'),
code("import { inspectFieldLiteral } from './upgrade-2027-09.mjs';\n\nconst handoff = inspectFieldLiteral();\nconsole.log(handoff.autonomy, handoff.recipient, handoff.process, handoff.status);\n// not-evaluated not-assigned not-created synthetic-plan-hand-off"),
h2('Порядок передачи без назначения'),
ol(['Прочитать temporal boundary как часть контракта: план относится к 2027-09 и не сообщает прошлые факты.', 'Оставить skill map и prompt именованными synthetic literals; не приписывать им пользователя или систему.', 'Сохранить autonomy в <code>not-evaluated</code>, recipient в <code>not-assigned</code>, process в <code>not-created</code>.', 'Сохранить observation, review и feedback в <code>not-collected</code>; progress — в <code>not-claimed</code>.', 'Запустить fixture с invented owner, review, feedback и outcome; каждая такая ветка должна остановиться.', 'Открыть другой authorized scope лишь при доказуемой потребности решения и не переносить его материалы в P115.']),
h2('Почему поле не становится программой'),
p('Полевой язык любит конкретность: назначить человека, выбрать cadence, завести карточку, запросить отчет. Это разумные действия только после того, как предмет и полномочия существуют. В P115 они отсутствуют намеренно. Добавить их «для наглядности» означает создать учебный процесс текстом. Даже synthetic name не следует превращать в реалистичный кейс: правдоподобная деталь быстро становится ложным доказательством того, что контекст был известен.'),
p('Внешние источники не решают эту проблему. RFC задают различение требований, но не дают права на персональную оценку. Они не описывают consent, не назначают owner и не доказывают, что review произошёл. Поэтому source section у статьи содержит URL, версию, дату, применение и boundary, а основной текст не использует их как авторитетный ответ о развитии человека.'),
h2('Провенанс отрицательных статусов'),
p('Отрицательный статус тоже нуждается в точном чтении. <code>not-evaluated</code> не означает, что оценка была начата и не завершилась; она не начиналась внутри этого пакета. <code>not-assigned</code> не означает, что владелец потерялся; владелец не назначался. <code>not-created</code> не означает, что workflow сломан; workflow не создавался. Без этих различий пустые поля быстро получают историю задним числом. Field-форма сохраняет происхождение отсутствия, чтобы следующий scope не принимал его за дефект данных.'),
p('Такая точность важна не только для privacy. Она ограничивает технический вывод. Если recipient не назначен, нельзя утверждать, что hand-off доставлен. Если process не создан, нельзя говорить о cadence. Если feedback не собран, нельзя выбирать correction. Каждый пропуск останавливает соответствующий narrative до того, как он станет планом действий для реальных людей. Код делает это буквально: значение <code>assigned</code> не деградирует до warning, а ведёт к отдельному stop-status.'),
p('Соблазн добавить owner обычно оправдывают удобством: документу нужен адресат. Но адресат — это решение с последствиями, а не метаданные. Его нельзя подставить как пример без риска создать ложную обязанность. Поэтому даже обобщённая роль не подходит. В P115 допускается передать вопрос без recipient; будущий контекст сам определит, существует ли необходимость кому-то его адресовать. Эта неполнота не ошибка hand-off, а его безопасная форма.'),
p('Evidence работает сходно. Файл, комментарий или правдоподобный fragment code могли бы сделать статью живее, но они стали бы synthetic evidence с неясным происхождением. Читатель начинает сравнивать свой случай с образцом и незаметно получает критерий, который никто не авторизовал. Поэтому field-статья оставляет только literal и output evaluator. Они не сообщают о внешнем состоянии, зато у них есть точное происхождение: они созданы внутри одного модуля из constants.'),
p('Для передачи важно и направление ответственности. P115 не просит получателя подтвердить, опровергнуть или продолжить что-либо. Он лишь не позволяет принять один набор слов за другой: план — за evidence, отсутствие owner — за назначение, pass fixture — за эффект. Получатель нового scope вправе сузить вопрос или закрыть его без действия. Такая возможность теряется, когда hand-off заранее обещает полезный результат или предполагает, что отказ будет признаком недостаточной самостоятельности.'),
p('Такой hand-off легко проверить на деградацию. Добавьте claimed feedback, progress или owner в known bad literal — fixture должен остановиться. Передайте object с новым полем — он тоже не будет принят как «почти тот же» plan. Это не бюрократия вокруг текста. Это способ не дать будущей редакционной задаче превратиться в скрытую систему управления людьми без решения, полномочий и доказательств.'),
p('Небольшая цена такой строгости — документ не может успокоить читателя историей успеха. Зато он не создаёт ложный факт, с которым потом приходится согласовываться. Для изолированного scope это важнее полноты внешнего narrative.'),
console.log(accepted.ok, accepted.value.state);
console.log(rejected.ok, rejected.reason);
// true active
// false state-is-outside-enum`),
h2('Порядок проверки изменения'),
ol([
'Сначала назовите endpoint, метод, статус и media type. Без этого слово «контракт» смешивает запрос, ответ и внутреннюю модель.',
'Снимите текущую форму ответа: обязательные поля, типы, enum, nullable и значения по умолчанию. Зафиксируйте один положительный и несколько отрицательных примеров.',
'Сравните diff схемы с реальными местами чтения. Особенно ищите удаление поля, изменение типа и сужение перечисления.',
'Запустите детерминированный валидатор на старой и новой форме. Ошибка должна содержать поле и причину, а не общий «invalid response».',
'Прогоните consumer contract tests для двух соседних версий клиента. Если старый клиент не проходит, выберите новое поле, совместимое расширение или отдельную версию.',
'После выпуска добавьте срок удаления deprecated-поля и проверяемый сигнал использования. Не удаляйте его по ощущению, если нет данных о потребителях.',
]),
h2('Где заканчивается схема'),
p('Схема не отвечает на вопрос, можно ли изменить запись. Ответ <code>state: active</code> может быть формально правильным, но устаревшим относительно команды обновления. Для этого нужны версия ресурса, условный запрос вроде <code>If-Match</code>, правила авторизации и транзакционная проверка. Эти условия следует описывать рядом с endpoint, но не выдавать за свойства JSON Schema.'),
p('Схема также не гарантирует одинаковое поведение всех реализаций. Сервер может вернуть правильный JSON только для одного кода пути, а ошибка сериализации останется в редком исключении. Поэтому проверка формы должна быть дополнена интеграционным тестом, который вызывает реальный handler, и тестом совместимости, который запускает старый клиент против нового ответа. Наличие двух тестов не делает контракт вечным: оно снижает конкретный риск в известной границе.'),
h2('Ограничения и следующий шаг'),
p('P115 не является assessment, mentoring policy, кадровым решением, учебным планом, review, feedback record или evidence package. Он не говорит, что человек автономен или не автономен, не рекомендует передачу полномочий и не содержит production change. Code fixture проверяет только замкнутый набор literals; он не проверяет организацию, навыки или качество инженерного решения.'),
p('Следующий шаг возможен только в отдельном authorized scope, когда конкретное решение потребует evidence. Там должны появиться собственные границы, разрешённые inputs, защита личных данных, owner, метод и допустимый отрицательный результат. P115 сохраняет исходную неизвестность: его завершение — <code>synthetic-plan-hand-off</code> с <code>productionEffect: not-attempted</code>, без выдуманного feedback и без названного владельца.'),
], refs);
p('Учебный валидатор не проверяет OpenAPI-документ, авторизацию, базу данных, компрессию и сетевые ошибки. Он также не доказывает, что всех потребителей нашли. Его задача уже: не пропустить неверный тип, обязательное поле или новое значение enum на границе JSON.'),
p('Следующий шаг — собрать один реальный endpoint и добавить к нему пару contract-тестов: старый потребитель должен пройти на расширенном ответе, а breaking diff должен завершаться осознанным решением о версии. Если правило нельзя выразить в форме, status или условии запроса, вынесите его в отдельный раздел доменного контракта, не прячьте в описании поля.'),
], contractRefs);
const mechanism = revision({
slug: 'editorial-2027-09-mechanism-mentor-series',
title: 'JSON Schema и бизнес-правила: где проходит граница валидации',
categories: ['Backend', 'Контракты данных'],
cover: '/assets/editorial/2027/mentor-series-2027-practice-review-matrix.svg',
excerpt: 'Почему валидная JSON Schema не гарантирует корректную операцию: разделяем форму данных, бизнес-инвариант и проверку состояния.',
readingMinutes: 15,
}, [
p('Проблема возникает, когда сервис принимает хорошо сформированный JSON, но отклоняет операцию позже: лимит оказался недоступен, курс валюты устарел, а ресурс уже изменился. Цена смешения слоёв — неясная ошибка 400/409, повторные попытки клиента и спор о том, где именно нарушен контракт.'),
p('Причина обычно в широком слове «валидировать». Им называют проверку JSON-типа, обязательных полей, доступа пользователя и текущего состояния базы одновременно. Такой обработчик трудно тестировать: непонятно, какой вход должен быть отклонён схемой, а какой — доменной проверкой. Разделим эти решения и соберём минимальный фильтр, который можно запустить без сервера.'),
h2('Три слоя, которые нельзя склеивать'),
p('Первый слой — структура: объект, строка, число, массив, обязательность, формат и перечисление. JSON Schema хорошо подходит для такого вопроса. Второй слой — локальный инвариант: например, <code>minAmount &lt;= maxAmount</code> или допустимый размер страницы. Его можно проверять кодом после разбора JSON, если правило зависит от нескольких полей. Третий слой — состояние системы: существует ли пользователь, не занят ли ресурс, не истёк ли токен. Этот слой требует доступа к данным и обычно возвращает другой класс ошибки.'),
p('Если все три проверки спрятаны в одной схеме, описание начинает обещать больше, чем может проверить. Если всё оставить коду контроллера, клиенты теряют раннюю документацию и точное сообщение о форме. Рабочая граница проходит там, где появляется внешний контекст: схема описывает сам документ, доменная функция — связь полей, сервис — состояние и права.'),
table('Что проверять схемой, а что — кодом', ['Слой', 'Пример', 'Результат ошибки', 'Подход'], [
['Тип и обязательность', '<code>limit</code> — integer, required', '400: malformed document', 'JSON Schema или генератор клиента'],
['Диапазон', '<code>1 ≤ limit ≤ 100</code>', '400: invalid value', 'Schema minimum/maximum плюс тест'],
['Связь полей', '<code>from &lt;= to</code>', '400: inconsistent filter', 'Чистая функция с двумя полями'],
['Состояние', 'ресурс не изменён после чтения', '409: state conflict', 'Версия, условный запрос, транзакция'],
['Право', 'роль может менять статус', '403: forbidden', 'Авторизация до изменения состояния'],
]),
h2('Schema не делает неизвестное допустимым'),
p('У JSON Schema есть важное свойство: ограничения должны быть явными. Для API-фильтра можно разрешить <code>limit</code>, <code>cursor</code> и <code>state</code>, а остальные свойства закрыть через <code>additionalProperties: false</code> в нужном месте схемы. Но закрытость должна соответствовать расширяемости интерфейса. Если команда добавляет служебное поле без версионирования, строгая схема станет источником неожиданных отказов.'),
p('Есть и другая ловушка — использовать <code>format</code> как доказательство полной корректности. Формат даты или URI задаёт синтаксическую подсказку, но не подтверждает, что дата разрешена для операции или что URI принадлежит доверенному домену. Слово «valid» в отчёте должно иметь уточнение: valid по схеме, valid для инварианта или valid в текущем состоянии.'),
figure('/assets/editorial/2027/mentor-series-2027-practice-review-matrix.svg', 'Матрица валидации: структура JSON, связь полей, состояние ресурса и право на действие проходят отдельные проверки с разными классами ошибок.', 'Схема помогает не выдавать успешный разбор JSON за разрешение операции. Каждый слой имеет собственный вход, сообщение и границу ответственности.'),
h2('Runnable-пример: форма и инвариант по отдельности'),
p('В следующем фрагменте функция принимает фильтр поиска. Она проверяет форму и диапазон, добавляет безопасные значения по умолчанию и возвращает нормализованный объект. Это не библиотека JSON Schema, а маленький учебный аналог, на котором видно место бизнес-правила. Состояние базы и право доступа намеренно не притворяются частью результата.'),
code(`import { validateFilterInput } from './upgrade-2027-09.mjs';
const good = validateFilterInput({ state: 'active', limit: 25 });
const bad = validateFilterInput({ state: 'active', limit: 250 });
const unknown = validateFilterInput({ state: 'active', region: 'eu' });
console.log(good.ok, good.value.limit, good.value.cursor);
console.log(bad.ok, bad.reason);
console.log(unknown.ok, unknown.value.state);
// true 25 null
// false limit-out-of-range
// true active`),
h2('Порядок разложения проверки'),
ol([
'Опишите JSON-документ отдельно от команды, которая его использует. Назовите поля, типы, обязательность и допустимые значения.',
'Выберите закрытую или расширяемую модель неизвестных полей. Решение должно быть одинаковым для сервера и клиентов, иначе один слой будет отвергать данные другого.',
'Вынесите связи нескольких полей в чистые функции. Каждая функция должна иметь отрицательный пример и возвращать имя нарушенного правила.',
'Присвойте класс ошибки: malformed input, invalid value, conflict или forbidden. Не превращайте конфликт состояния в повторную отправку 400.',
'Проверьте, какие правила требуют чтения базы или другого сервиса. Для них зафиксируйте порядок проверки и условия гонки.',
'Сверьте документацию и код на одном fixture-наборе. Расхождение между схемой и runtime-валидатором должно ломать сборку тестов.',
]),
h2('Почему 409 важнее ещё одного boolean'),
p('Когда форма запроса корректна, но состояние изменилось, клиенту нужна возможность выбрать действие: перечитать ресурс, показать конфликт или прекратить операцию. Boolean вроде <code>valid: false</code> стирает причину. HTTP-семантика и локальный API-контракт должны различать ошибку документа и невозможность применить правильный документ к текущему состоянию.'),
p('Это различие помогает и с повторными попытками. Ошибка схемы не станет правильной от второго запроса, а конфликт иногда исчезает после нового чтения. Если оба случая имеют один статус, клиент либо повторяет бесполезную отправку, либо молча теряет возможность безопасного разрешения. Хорошая валидация уменьшает число retry-циклов именно тем, что сообщает границу отказа.'),
h2('Ограничения и следующий шаг'),
p('Учебная функция не реализует полный draft 2020–12, не строит JSON Pointer к ошибке и не читает доменное состояние. Она показывает архитектурное разделение, а не заменяет валидатор библиотеки. В реальном API необходимо проверить выбранную библиотеку на <code>oneOf</code>, ссылки, форматы и поведение при неизвестных ключах.'),
p('Следующий шаг — взять один endpoint с конфликтом состояния и выписать три независимых теста: неправильная форма, нарушенный инвариант и устаревшая версия ресурса. После этого сравните их статусы и сообщения с документацией. Если один тест требует данных, которых нет в запросе, не расширяйте схему вслепую: это сигнал, что правило относится к сервисному слою.'),
], contractRefs);
const field = revision({
slug: 'editorial-2027-09-field-mentor-series',
title: 'Code review API-изменения: от diff до обратимой миграции',
categories: ['Code review', 'Миграции'],
cover: '/assets/editorial/2027/mentor-series-2027-autonomy-handoff-loop.svg',
excerpt: 'Полевой маршрут для API-diff: классифицируем несовместимость, проверяем потребителей и оставляем безопасное окно отката.',
readingMinutes: 15,
}, [
p('Проблема в code review API-изменения редко выглядит как красная строка. Автор добавляет обязательное поле, меняет enum или удаляет старый response-property, а reviewer видит только локально зелёные тесты. Цена ошибки появляется после публикации: разные версии клиента начинают спорить с одним сервером, а быстрый rollback уже не возвращает удалённое поле.'),
p('Причина — просматривать diff как изменение одного репозитория. API имеет потребителей, кэш, документацию, генераторы типов и иногда асинхронные события. Поэтому проверка должна начинаться с классификации изменения, продолжаться поиском потребителей и заканчиваться обратимой последовательностью. Ниже — практический маршрут, который можно применить к одному pull request.'),
h2('Сначала классификация, потом обсуждение кода'),
p('У каждой строки схемы есть направление совместимости. Добавление необязательного response-поля обычно расширяет контракт. Удаление поля сужает его. Добавление обязательного поля в request ломает старого отправителя, а изменение response-типа ломает десериализацию даже при том же имени. Эта классификация не заменяет review, но не даёт обсуждать все изменения одинаково.'),
p('Функция <code>classifyApiChange</code> ниже намеренно принимает уже выделенные факты diff. Она не пытается сама прочитать OpenAPI и не делает вывод о конкретной команде. Это удобная граница для теста: если генератор diff ошибся, его ошибка находится до классификатора; если классификатор выбрал <code>breaking</code>, reviewer получает повод проверить совместимость.'),
table('Минимальная карта API-diff', ['Вопрос', 'Признак', 'Что проверить', 'Решение'], [
['Старый клиент отправит запрос?', 'Новое required request-поле', 'Все builders и fixtures', 'Default, optional или новая версия'],
['Старый клиент прочитает ответ?', 'Удаление/переименование поля', 'Поиск доступа к property', 'Deprecated-период и новое поле'],
['Старое значение остаётся допустимым?', 'Сужение enum', 'Ветвления клиентов и событий', 'Расширить enum или сменить версию'],
['Сохранилась семантика?', 'Тот же тип, другое значение', 'Документация и consumer test', 'Явно описать смысл и миграцию'],
['Можно вернуть сервер?', 'Изменение хранения или записи', 'Backward read и rollback', 'Сначала expand, затем switch, потом contract'],
]),
h2('Обратимость начинается с данных'),
p('Откат бинарного файла не откатывает базу и сообщения в очереди. Если новый сервер записал только новый формат, старый сервер может не суметь прочитать данные. Поэтому для опасного API-изменения полезен expand/contract: сначала добавить совместимое поле или колонку, затем научить код читать и писать оба формата, переключить потребителей и только после подтверждения удалить старую форму.'),
p('На review стоит попросить не обещание «rollback возможен», а конкретную матрицу. Какие версии читают старую запись? Как выглядит запись после частичного переключения? Что произойдёт с повторной доставкой события? Где хранится сигнал, что старый consumer ещё жив? Ответы превращают риск в проверяемые условия, а не в уверенность по названию ветки.'),
figure('/assets/editorial/2027/mentor-series-2027-autonomy-handoff-loop.svg', 'Маршрут review API-изменения: diff проходит через классификацию совместимости, проверку потребителей и окно обратимой миграции перед удалением старой формы.', 'Диаграмма связывает локальный diff с потребителями и данными. Красная ветка означает остановку до удаления, если старый формат ещё нужен.'),
h2('Runnable-пример: классифицируем diff'),
p('Вход функции — объект с тремя признаками: удалённые свойства, новые обязательные свойства и сужение enum. На выходе — <code>breaking</code> или <code>compatible</code> и действие для review. Это не автоматическое разрешение pull request. Пример полезен как первая страховка, после которой нужны реальные consumer tests и проверка данных.'),
code(`import { classifyApiChange } from './upgrade-2027-09.mjs';
const additive = classifyApiChange({
removedProperties: [],
addedRequiredProperties: [],
narrowedEnum: false,
});
const risky = classifyApiChange({
removedProperties: ['displayName'],
addedRequiredProperties: [],
narrowedEnum: false,
});
console.log(additive.status, additive.action);
console.log(risky.status, risky.action);
// compatible run-consumer-contract-tests
// breaking version-or-expand-compatibility-window`),
h2('Порядок review для одного diff'),
ol([
'Скопируйте в описание изменения старую и новую форму запроса, ответа и события. Diff схемы без примеров заставляет reviewer восстанавливать смысл по именам.',
'Запустите классификатор и вручную проверьте каждый breaking-признак: удаление, required, enum, тип и изменение семантики.',
'Найдите потребителей по сгенерированным типам, сериализаторам, документации и тестовым fixture. Отдельно проверьте неизвестные внешние клиенты.',
'Составьте матрицу чтения и записи старой и новой формы. Укажите, что произойдёт при частичном rollout и повторной доставке события.',
'Добавьте отрицательные contract-тесты для старого клиента и положительные для нового. Тест должен падать на конкретном поле, а не на общем статусе.',
'Опишите условие удаления старой формы: сигнал использования, срок хранения и способ восстановления. Без этого «временное поле» становится вечным.',
]),
h2('Ограничения автоматической классификации'),
p('Классификатор не знает, что <code>displayName</code> обязателен для внешнего клиента, а внутренний клиент его игнорирует. Он не проверяет кэш, подписанные payload, очереди и генерацию SDK. Даже правильный статус <code>breaking</code> не говорит, как долго держать две версии. Это инструмент сортировки риска, не замена архитектурному решению.'),
p('Не всякая совместимая форма безопасна семантически. Поле может остаться строкой, но начать содержать другой часовой пояс или другую единицу измерения. Поэтому в review нужен отдельный вопрос о значении, а не только о типе. Если смысл изменился, новое имя часто дешевле, чем заставлять клиентов угадывать период перехода.'),
h2('Ограничения и следующий шаг'),
p('Статья не описывает конкретный CI, брокер или схему базы. Примеры синтетические и запускаются локально; они показывают форму решений, а не результат изменения внешнего API. Для опасных контрактов потребуется интеграция с registry схем, consumer tests и наблюдаемым сигналом использования старого поля.'),
p('Следующий шаг — выбрать один настоящий diff и заполнить четыре артефакта: старая/новая схема, таблица потребителей, тест частичного rollout и процедура удаления. Если хотя бы один потребитель неизвестен, оставьте расширение совместимым и не переходите к contract-фазе миграции.'),
], contractRefs);
export const revisions = deepFreeze([practice, mechanism, field]);
export function runApiContractFixture() {
const cases = [
['response-accepts-known-state', validateCustomerResponse({ id: 'c-1', revision: 1, state: 'active' }).ok, true],
['response-rejects-unknown-state', validateCustomerResponse({ id: 'c-1', revision: 1, state: 'deleted' }).reason, 'state-is-outside-enum'],
['filter-applies-default', validateFilterInput({ state: 'blocked' }).value.limit, 20],
['filter-rejects-large-limit', validateFilterInput({ limit: 101 }).reason, 'limit-out-of-range'],
['diff-detects-breaking', classifyApiChange({ removedProperties: ['name'] }).status, 'breaking'],
['diff-keeps-additive-change', classifyApiChange({ removedProperties: [], addedRequiredProperties: [], narrowedEnum: false }).status, 'compatible'],
];
const checks = cases.map(([id, actual, expected]) => ({ id, actual, expected, passed: actual === expected }));
return deepFreeze({ passed: checks.filter((item) => item.passed).length, total: checks.length, accepted: checks.every((item) => item.passed), checks });
}
export function verifyRevisionsAgainstFixture() {
const fixture = runMentorSeriesFixture();
const articleChecks = revisions.map((item) => { const text = bodyText(item.contentHtml); return text.length >= 9000 && text.length <= 12000 && /(цен[аы]|стоимост|дорог)/i.test(text.slice(0, 1400)) && /<table>/.test(item.contentHtml) && /<figure>/.test(item.contentHtml) && /<pre><code>/.test(item.contentHtml) && /<ol>/.test(item.contentHtml) && /2027-09/.test(text) && /2026-07-31/.test(text) && /productionEffect: not-attempted/.test(text); });
const fixture = runApiContractFixture();
const articleChecks = revisions.map((item) => {
const text = bodyText(item.contentHtml);
return text.length >= 5000 && text.length <= 15000 && /<table>/.test(item.contentHtml) && /<figure>/.test(item.contentHtml) && /<pre><code>/.test(item.contentHtml) && /<ol>/.test(item.contentHtml) && /Проблема/.test(text.slice(0, 900));
});
return deepFreeze({ passed: fixture.passed + articleChecks.filter(Boolean).length, total: fixture.total + articleChecks.length, accepted: fixture.accepted && articleChecks.every(Boolean), fixture, articleChecks, characters: Object.fromEntries(revisions.map((item) => [item.slug, bodyText(item.contentHtml).length])) });
}
if (process.argv.includes('--verify-fixture')) { const result = verifyRevisionsAgainstFixture(); process.stdout.write(JSON.stringify(result, null, 2) + '\n'); if (!result.accepted) process.exitCode = 1; }
if (process.argv.includes('--verify-fixture')) {
const result = verifyRevisionsAgainstFixture();
process.stdout.write(JSON.stringify(result, null, 2) + '\n');
if (!result.accepted) process.exitCode = 1;
}
if (process.argv.includes('--print-revisions')) process.stdout.write(JSON.stringify(revisions) + '\n');