diff --git a/editorial/production/README.md b/editorial/production/README.md index 57ee7d8..03edac7 100644 --- a/editorial/production/README.md +++ b/editorial/production/README.md @@ -1,6 +1,6 @@ # Производство редакционных партий -На 31 июля 2026 года строгий аудит проходит 268 из 358 созданных материалов. Остальные 90 не считаются «почти готовыми»: их нужно заменить, а не косметически удлинить. +На 31 июля 2026 года строгий аудит проходит 271 из 358 созданных материалов. Остальные 87 не считаются «почти готовыми»: их нужно заменить, а не косметически удлинить. ## Одна партия diff --git a/editorial/reviews/2025-06-draft.md b/editorial/reviews/2025-06-draft.md new file mode 100644 index 0000000..3baf664 --- /dev/null +++ b/editorial/reviews/2025-06-draft.md @@ -0,0 +1,119 @@ +# P88 · 2025-06 · Удобство внутреннего инструмента как наблюдаемая инженерная задача — три прохода ревью + +## Граница самостоятельного draft-пакета + +- Overlay меняет только три June 2025 slug через web/scripts/upgrade-2025-06.mjs. +- Все task journeys, роли, stages, timestamps, wait buckets, observations, support signals, owners, changes, follow-up и results — versioned fixed synthetic JavaScript literals в памяти с версией fixed-synthetic-developer-experience-2025-06-v1. +- Модуль не читает файлы, не делает запросы, не использует часы, не касается реального инструмента, пользователя, telemetry, support ticket, Git, CI, network, production или внешнего API. Положительный decision означает только валидность учебного контракта; effectClaim остаётся not-established, а productionEffect — false. +- Registry, README, очередь, articles.json, app-страницы, production build, staging, commit и push намеренно не входят в этот пакет. + +## База исследования и историческая граница + +Все четыре URL ниже проверены обычным HTTPS GET без TLS bypass и без авторизации: каждый вернул HTTP 200. Меняющиеся источники закреплены immutable commit, датированным стандартом или immutable Internet Archive capture, существовавшим к 30 июня 2025. Для двух GOV.UK руководств не используется текущая страница: source list ведёт на captures 2024-07-05 и 2023-05-12. Источники используются только для узких утверждений о форме event/time record, комбинировании видов evidence, пути задачи и обратной связи. + +| Источник | Версия / дата | Узкое утверждение в статьях | Явная граница | +| --- | --- | --- | --- | +| [OpenTelemetry Trace API v1.31.0](https://raw.githubusercontent.com/open-telemetry/opentelemetry-specification/3985e212f0ef5439daf9b10f0ee490e349c69368/specification/trace/api.md) | immutable commit 3985e212f0ef5439daf9b10f0ee490e349c69368, tag v1.31.0, 13.03.2024 | Span описан как операция со start/end timestamps и timestamped events; event имеет name, timestamp и optional attributes. | Спецификация задаёт форму observability record. Она не измеряет удобство, не назначает владельца и не даёт доказательства эффекта. | +| [W3C Performance Timeline](https://www.w3.org/TR/2025/CRD-performance-timeline-20250521/) | Candidate Recommendation Draft, 21.05.2025 | PerformanceEntry несёт name, entry type, start time и duration; временная запись имеет явную форму. | Это web performance standard, а не методика исследования внутреннего сервиса и не DX-score. | +| [GOV.UK: Measuring the success of your service](https://web.archive.org/web/20240705183252id_/https://www.gov.uk/service-manual/measuring-success/measuring-the-success-of-your-service) | immutable capture 05.07.2024 официальной страницы с public update 06.08.2018 | Для транзакции руководство сочетает performance metrics и user research, предостерегает от одного digital-канала; для journey упоминает completion и время задачи при повторном сравнении. | Методическое руководство не создаёт общий KPI, не определяет схему данных команды и не доказывает эффект учебного изменения. | +| [GOV.UK: Set up and manage user support](https://web.archive.org/web/20230512135336id_/https://www.gov.uk/service-manual/helping-people-to-use-your-service/set-up-and-manage-user-support) | immutable capture 12.05.2023 официальной страницы с public update 24.11.2016 | Feedback support служит входом в улучшение; сигналы разумно связывать с группой, способной действовать. | Support signal указывает направление проверки, но не измеряет масштаб, причинность или качество решения. | + +Историческая оговорка: в текст не добавлены поздние product claims, имена инструментов, современные vendor terms или реальные DX benchmarks. Перед применением в конкретной среде команда должна заново проверить доступы, privacy boundary, допустимые источники, retention и владельцев. + +## Проход 1 — факты, историческая граница и техника + +Проверены все внешние факты против таблицы. OpenTelemetry и W3C используются только как примеры дисциплины event/time contract; GOV.UK — только как основание не сводить путь к одному цифровому каналу и считать support input отдельным источником. Ни один источник не используется для утверждения о реальном человеке, очереди, стоимости, telemetry, causal effect или конкретном внутреннем инструменте. + +Технический contract жёстко разделяет: + +1. ux-observation — сформулированное непонимание declared role. +2. instrumented-event — stage, timestamps, wait bucket и источник записи. +3. support-signal — категория фиксированной карточки и owner, который может продолжить вопрос. +4. candidateChange — hypothesis, target stage, owner, bounded window и stop condition. +5. claimedEffect — строго not-established, пока не существует сопоставимого повторного evidence. + +Fixture проверяет exact contracts, missing/extra fields, forged event source, forged role, forged result claim, missing wait bucket, sparse array, cyclic value и нарушенный порядок времени. Любая ошибка возвращает closed decision с safe stop; model не выполняет реальное действие. + +~~~text +node --check scripts/upgrade-2025-06.mjs +PASS + +node scripts/upgrade-2025-06.mjs --verify-fixture +PASS fixture: 33/33 assertions +~~~ + +Ограничение прохода: положительный result доказывает только согласованность fixed in-memory literals. Он не измеряет user experience, cognitive cost, support demand, latency, effect size или безопасность реальной instrumentation. + +## Проход 2 — редактура, M8 и усиленный draft audit + +Первые два абзаца каждой статьи называют конкретную проблему и цену ошибки: очередь ожидания, ручные обходы, потерю контекста, повторную работу и решение на неверном доказательстве. Плотность соответствует уровню М8: текст не обещает «улучшить DX», а отделяет симптом, причину, проверку и действие; owner и safe stop названы до любого вывода об эффекте. + +Перед финальным audit проведена cross-article проверка только основного текста, без раздела источников: нет одинаковых абзацев длиной от 160 знаков и нет общих 12-словных фрагментов между practice, mechanism и field. Общий scope-блок удалён. Теперь каждая статья имеет собственную границу: practice ограничивает порядок постановки вопроса, mechanism — валидатор контрактов, field — учебный случай и его comparison boundary. + +| Статья | Основной текст | Редакторская проверка | +| --- | ---: | --- | +| Practice | 8 415 знаков | Путь одной задачи, пять видов evidence, wait buckets, owner, compact contract и следующий шаг. | +| Mechanism | 8 867 знаков | Exact fields, fail-closed evaluator, distinction occurred/observed time, known unknowns и comparison boundary. | +| Field | 8 694 знака | Fixed case, карта фактов и unknowns, forged result, bounded follow-up и safe stop. | + +~~~text +npm run audit:draft -- scripts/upgrade-2025-06.mjs +PASS editorial-2025-06-practice-developer-experience: 8415 body chars +PASS editorial-2025-06-mechanism-developer-experience: 8867 body chars +PASS editorial-2025-06-field-developer-experience: 8694 body chars +~~~ + +Усиленный audit также подтвердил для каждого текста: 5 000–15 000 знаков, пять смысловых разделов, HTML table с thead, figure с meaningful alt/caption, code example, ordered sequence, минимум две ссылки, стартовую постановку проблемы и цену ошибки в первых двух paragraphs. Текст специально не вводит общий score, реальные цифры, выдуманный инцидент или неподтверждённый causal claim. + +Ограничение прохода: качество языка и полнота HTML не делают hypothesis истинной. Текст оставляет real research, data governance и comparison design следующему разрешённому этапу. + +## Проход 3 — SVG, fixture и выпускная граница + +| Asset | Sharp render 375 px | Ручная проверка | +| --- | --- | --- | +| developer-experience-2025-task-journey.svg | 375 × 700 | Все пять карточек, стрелки и safe stop читаемы. В первом render заголовок подходил к правой границе; он сокращён до «Одна задача, пять видов данных», затем render повторён. | +| developer-experience-2025-wait-time-histogram.svg | 375 × 620 | Buckets, ось и правило чтения различимы. В первом render длинные title/subtitle уходили вправо; title сокращён, subtitle разбит на две строки, после чего render повторён. | +| developer-experience-2025-feedback-loop.svg | 375 × 670 | Observe → Decide → Change → Recheck → Effect evidence и красная safe-stop ветка полностью видимы. Правка не потребовалась. | + +~~~text +xmllint --noout <3 SVG> +PASS + +SVG safety scan for script, foreignObject, javascript:, data:image and event handlers +PASS + +Sharp 375 px + manual visual review +PASS after two narrow-screen typography fixes +~~~ + +Полная production build, registry update, staging, commit и push не запускались: они запрещены границей самостоятельного P88 draft-пакета. До будущей публикации нужен отдельный интеграционный выпускной проход с подключением overlay к registry и повторной проверкой уже собранных статей. + +## Независимый выпускной проход — 31.07.2026 + +Перед подключением draft был прочитан отдельным редактором целиком. В выпускном проходе найдены и исправлены две границы, которые self-review не должен был скрывать: + +1. Две живые GOV.UK ссылки могли измениться после июня 2025. Они заменены в source list и в таблице ревью на immutable Internet Archive captures 2024-07-05 и 2023-05-12; утверждения повторно сверены только с этими снимками. +2. Примеры ссылались на приватные `cloneFixed` и `fixedCases`. В модуль добавлен публичный `createFixedJourneyInput`; каждый показанный import повторно запущен буквально и дал ожидаемый closed result. + +После этих правок отдельно прочитаны practice, mechanism и field. У них нет одинаковых абзацев от 160 знаков и общих 12-словных фрагментов: practice задаёт порядок исследования, mechanism проверяет fail-closed contract, field ограничивает вывод учебным кейсом. На мобильных 375 px ещё раз просмотрены три SVG; текст и safe-stop ветки читаемы. + +~~~text +node --check scripts/upgrade-2025-06.mjs +PASS + +node scripts/upgrade-2025-06.mjs --verify-fixture +PASS fixture: 33/33 assertions + +npm run audit:draft -- scripts/upgrade-2025-06.mjs +PASS practice: 8415 body chars +PASS mechanism: 8867 body chars +PASS field: 8694 body chars + +npm run audit:articles -- <3 June slugs> +PASS: each article has 1 table, 1 code example and 2/2/3 figures + +npm run build +PASS: 374 static pages +~~~ + +Overlay подключён в `editorial-revisions.mjs` между May и July. Реестр после подключения содержит 262 уникальных slug. Выпускной проход не меняет границу статей: fixed literals остаются учебными, а положительный результат не является результатом production, telemetry, исследования человека или claim об эффекте. diff --git a/web/data/editorial-revisions.mjs b/web/data/editorial-revisions.mjs index 77853b3..afe33a6 100644 --- a/web/data/editorial-revisions.mjs +++ b/web/data/editorial-revisions.mjs @@ -84,6 +84,7 @@ import { revisions as february2025Revisions } from '../scripts/upgrade-2025-02.m import { revisions as march2025Revisions } from '../scripts/upgrade-2025-03.mjs'; import { revisions as april2025Revisions } from '../scripts/upgrade-2025-04.mjs'; import { revisions as may2025Revisions } from '../scripts/upgrade-2025-05.mjs'; +import { revisions as june2025Revisions } from '../scripts/upgrade-2025-06.mjs'; import { revisions as july2025Revisions } from '../scripts/upgrade-2025-07.mjs'; // This layer replaces archived source entries without losing their stable slug and date. @@ -174,5 +175,6 @@ export const editorialRevisions = [ ...march2025Revisions, ...april2025Revisions, ...may2025Revisions, + ...june2025Revisions, ...july2025Revisions, ]; diff --git a/web/public/assets/editorial/2025/developer-experience-2025-feedback-loop.svg b/web/public/assets/editorial/2025/developer-experience-2025-feedback-loop.svg new file mode 100644 index 0000000..e608d9f --- /dev/null +++ b/web/public/assets/editorial/2025/developer-experience-2025-feedback-loop.svg @@ -0,0 +1,30 @@ + diff --git a/web/public/assets/editorial/2025/developer-experience-2025-task-journey.svg b/web/public/assets/editorial/2025/developer-experience-2025-task-journey.svg new file mode 100644 index 0000000..9c895f5 --- /dev/null +++ b/web/public/assets/editorial/2025/developer-experience-2025-task-journey.svg @@ -0,0 +1,33 @@ + diff --git a/web/public/assets/editorial/2025/developer-experience-2025-wait-time-histogram.svg b/web/public/assets/editorial/2025/developer-experience-2025-wait-time-histogram.svg new file mode 100644 index 0000000..aba9393 --- /dev/null +++ b/web/public/assets/editorial/2025/developer-experience-2025-wait-time-histogram.svg @@ -0,0 +1,25 @@ + diff --git a/web/scripts/upgrade-2025-06.mjs b/web/scripts/upgrade-2025-06.mjs new file mode 100644 index 0000000..96832b9 --- /dev/null +++ b/web/scripts/upgrade-2025-06.mjs @@ -0,0 +1,608 @@ +const MODEL_VERSION = 'fixed-synthetic-developer-experience-2025-06-v1'; +const TASK_KIND = 'fixed-synthetic-task-journey/v1'; +const ROLE_KIND = 'fixed-synthetic-declared-role/v1'; +const STAGE_KIND = 'fixed-synthetic-stage-event/v1'; +const OBSERVATION_KIND = 'fixed-synthetic-ux-observation/v1'; +const SUPPORT_KIND = 'fixed-synthetic-support-signal/v1'; +const CHANGE_KIND = 'fixed-synthetic-candidate-change/v1'; +const FOLLOW_UP_KIND = 'fixed-synthetic-bounded-follow-up/v1'; +const CLAIM_KIND = 'fixed-synthetic-effect-claim/v1'; +const DECISION_KIND = 'fixed-synthetic-journey-decision/v1'; + +const TASK_KEYS = Object.freeze(['kind', 'taskId', 'modelVersion', 'syntheticOnly', 'declaredRole', 'objective', 'expectedResult', 'stageEvents', 'uxObservations', 'supportSignals', 'candidateChange', 'knownUnknowns', 'boundedFollowUp', 'claimedEffect']); +const ROLE_KEYS = Object.freeze(['kind', 'roleId', 'modelVersion', 'syntheticOnly', 'label', 'permittedStages', 'boundary']); +const STAGE_KEYS = Object.freeze(['kind', 'eventId', 'taskId', 'modelVersion', 'syntheticOnly', 'stage', 'occurredAt', 'observedAt', 'waitBucket', 'evidenceKind', 'evidenceSource', 'claim', 'outcome']); +const OBSERVATION_KEYS = Object.freeze(['kind', 'observationId', 'taskId', 'modelVersion', 'syntheticOnly', 'evidenceKind', 'evidenceSource', 'statement', 'knownUnknowns']); +const SUPPORT_KEYS = Object.freeze(['kind', 'signalId', 'taskId', 'modelVersion', 'syntheticOnly', 'evidenceKind', 'evidenceSource', 'category', 'statement', 'actionableBy']); +const CHANGE_KEYS = Object.freeze(['kind', 'changeId', 'taskId', 'modelVersion', 'syntheticOnly', 'ownerRoleId', 'hypothesis', 'targetStage', 'expectedEvidence', 'boundedWindow', 'stopCondition']); +const FOLLOW_UP_KEYS = Object.freeze(['kind', 'followUpId', 'taskId', 'modelVersion', 'syntheticOnly', 'comparisonBoundary', 'evidenceSource', 'ownerRoleId', 'boundedWindow', 'stopCondition', 'nextAction']); +const CLAIM_KEYS = Object.freeze(['kind', 'claimId', 'taskId', 'modelVersion', 'syntheticOnly', 'status', 'statement', 'evidenceRefs', 'stopCondition']); +const DECISION_KEYS = Object.freeze(['kind', 'decisionId', 'accepted', 'syntheticOnly', 'modelVersion', 'reason', 'evidenceBoundary', 'ownerRoleId', 'nextAction', 'stopCondition', 'effectClaim', 'productionEffect']); + +const STAGE_ORDER = Object.freeze(['task.opened', 'request.submitted', 'approval.wait.started', 'approval.received', 'result.confirmed']); +const EXPECTED_SOURCE_BY_KIND = Object.freeze({ + 'ux-observation': 'synthetic-ux-card-v1', + 'instrumented-event': 'synthetic-stage-ledger-v1', + 'support-signal': 'synthetic-support-card-v1', + 'effect-evidence': 'synthetic-comparison-sheet-v1', +}); + +function isPlainRecord(value) { + if (!value || typeof value !== 'object' || Array.isArray(value)) return false; + const prototype = Object.getPrototypeOf(value); + return prototype === Object.prototype || prototype === null; +} + +function hasExactKeys(value, expected) { + if (!isPlainRecord(value) || Object.getOwnPropertySymbols(value).length > 0) return false; + const keys = Object.keys(value); + return keys.length === expected.length && expected.every((key) => Object.hasOwn(value, key)) && keys.every((key) => expected.includes(key)); +} + +function hasDenseArray(value) { + if (!Array.isArray(value)) return false; + const ownKeys = Reflect.ownKeys(value); + return ownKeys.length === value.length + 1 && ownKeys.includes('length') && Array.from({ length: value.length }, (_, index) => Object.hasOwn(value, index)).every(Boolean); +} + +export function canonicalJson(value) { + const visited = new WeakSet(); + function visit(current) { + if (current === null) return 'null'; + if (typeof current === 'string' || typeof current === 'boolean') return JSON.stringify(current); + if (typeof current === 'number') return Number.isFinite(current) ? JSON.stringify(current) : null; + if (Array.isArray(current)) { + if (!hasDenseArray(current) || visited.has(current)) return null; + visited.add(current); + const values = current.map((item) => visit(item)); + visited.delete(current); + return values.every((item) => item !== null) ? '[' + values.join(',') + ']' : null; + } + if (!isPlainRecord(current) || visited.has(current)) return null; + visited.add(current); + const values = Object.keys(current).sort().map((key) => { + const nested = visit(current[key]); + return nested === null ? null : JSON.stringify(key) + ':' + nested; + }); + visited.delete(current); + return values.every((item) => item !== null) ? '{' + values.join(',') + '}' : null; + } + return visit(value); +} + +function hasSameCanonicalJson(left, right) { + const leftJson = canonicalJson(left); + const rightJson = canonicalJson(right); + return leftJson !== null && rightJson !== null && leftJson === rightJson; +} + +function deepFreeze(value, seen = new WeakSet()) { + if (!value || typeof value !== 'object' || seen.has(value)) return value; + seen.add(value); + for (const child of Object.values(value)) deepFreeze(child, seen); + return Object.freeze(value); +} + +function cloneFixed(value) { + const json = canonicalJson(value); + if (json === null) throw new Error('fixed synthetic value must be canonical'); + return JSON.parse(json); +} + +function isIsoStamp(value) { + return typeof value === 'string' && /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z$/.test(value); +} + +function isNonEmptyString(value) { + return typeof value === 'string' && value.trim().length > 0; +} + +const fixedRoles = deepFreeze({ + 'synthetic-role-platform-engineer-v1': { + kind: ROLE_KIND, + roleId: 'synthetic-role-platform-engineer-v1', + modelVersion: MODEL_VERSION, + syntheticOnly: true, + label: 'synthetic-platform-engineer', + permittedStages: [...STAGE_ORDER], + boundary: 'Role is a declared label for the model; it is not a real account, permission or identity.', + }, + 'synthetic-role-journey-owner-v1': { + kind: ROLE_KIND, + roleId: 'synthetic-role-journey-owner-v1', + modelVersion: MODEL_VERSION, + syntheticOnly: true, + label: 'synthetic-journey-owner', + permittedStages: ['request.submitted', 'approval.wait.started', 'approval.received'], + boundary: 'Owner may choose a bounded synthetic change; the label does not authorize a production action.', + }, +}); + +const fixedJourney = deepFreeze({ + kind: TASK_KIND, + taskId: 'synthetic-task-sandbox-access-v1', + modelVersion: MODEL_VERSION, + syntheticOnly: true, + declaredRole: cloneFixed(fixedRoles['synthetic-role-platform-engineer-v1']), + objective: 'Complete one synthetic request for a sandbox access result without using a real tool.', + expectedResult: 'synthetic-result-confirmed', + stageEvents: [ + { + kind: STAGE_KIND, eventId: 'synthetic-event-opened-v1', taskId: 'synthetic-task-sandbox-access-v1', modelVersion: MODEL_VERSION, syntheticOnly: true, + stage: 'task.opened', occurredAt: '2025-06-12T09:00:00Z', observedAt: '2025-06-12T09:00:00Z', waitBucket: 'none', + evidenceKind: 'instrumented-event', evidenceSource: 'synthetic-stage-ledger-v1', claim: 'One declared task entered the fixed journey.', outcome: 'opened', + }, + { + kind: STAGE_KIND, eventId: 'synthetic-event-submitted-v1', taskId: 'synthetic-task-sandbox-access-v1', modelVersion: MODEL_VERSION, syntheticOnly: true, + stage: 'request.submitted', occurredAt: '2025-06-12T09:03:00Z', observedAt: '2025-06-12T09:03:00Z', waitBucket: 'none', + evidenceKind: 'instrumented-event', evidenceSource: 'synthetic-stage-ledger-v1', claim: 'The fixed request moved from entry to submission.', outcome: 'submitted', + }, + { + kind: STAGE_KIND, eventId: 'synthetic-event-wait-started-v1', taskId: 'synthetic-task-sandbox-access-v1', modelVersion: MODEL_VERSION, syntheticOnly: true, + stage: 'approval.wait.started', occurredAt: '2025-06-12T09:04:00Z', observedAt: '2025-06-12T09:04:00Z', waitBucket: '30m-1h', + evidenceKind: 'instrumented-event', evidenceSource: 'synthetic-stage-ledger-v1', claim: 'The fixed path entered the approval wait stage.', outcome: 'waiting', + }, + { + kind: STAGE_KIND, eventId: 'synthetic-event-approved-v1', taskId: 'synthetic-task-sandbox-access-v1', modelVersion: MODEL_VERSION, syntheticOnly: true, + stage: 'approval.received', occurredAt: '2025-06-12T09:41:00Z', observedAt: '2025-06-12T09:41:00Z', waitBucket: '30m-1h', + evidenceKind: 'instrumented-event', evidenceSource: 'synthetic-stage-ledger-v1', claim: 'The fixed path left the approval wait stage.', outcome: 'approved', + }, + { + kind: STAGE_KIND, eventId: 'synthetic-event-confirmed-v1', taskId: 'synthetic-task-sandbox-access-v1', modelVersion: MODEL_VERSION, syntheticOnly: true, + stage: 'result.confirmed', occurredAt: '2025-06-12T09:45:00Z', observedAt: '2025-06-12T09:45:00Z', waitBucket: 'none', + evidenceKind: 'instrumented-event', evidenceSource: 'synthetic-stage-ledger-v1', claim: 'The fixed result was confirmed inside the model.', outcome: 'confirmed', + }, + ], + uxObservations: [{ + kind: OBSERVATION_KIND, observationId: 'synthetic-observation-owner-unclear-v1', taskId: 'synthetic-task-sandbox-access-v1', modelVersion: MODEL_VERSION, syntheticOnly: true, + evidenceKind: 'ux-observation', evidenceSource: 'synthetic-ux-card-v1', + statement: 'The declared role cannot tell which owner is responsible after submission.', + knownUnknowns: ['The card does not establish how many people have the same uncertainty.', 'The card does not show whether the owner label would remove the wait.'], + }], + supportSignals: [{ + kind: SUPPORT_KIND, signalId: 'synthetic-support-routing-v1', taskId: 'synthetic-task-sandbox-access-v1', modelVersion: MODEL_VERSION, syntheticOnly: true, + evidenceKind: 'support-signal', evidenceSource: 'synthetic-support-card-v1', category: 'routing-unclear', + statement: 'A fixed support card groups the question: where does this request go?', actionableBy: 'synthetic-role-journey-owner-v1', + }], + candidateChange: { + kind: CHANGE_KIND, changeId: 'synthetic-change-owner-before-submit-v1', taskId: 'synthetic-task-sandbox-access-v1', modelVersion: MODEL_VERSION, syntheticOnly: true, + ownerRoleId: 'synthetic-role-journey-owner-v1', targetStage: 'request.submitted', boundedWindow: 'synthetic-follow-up-window-v1', + hypothesis: 'Showing the responsible owner before submit may remove a routing question; it does not promise a shorter approval wait.', + expectedEvidence: 'Same fixed task, declared role, stage contract and comparison boundary after the bounded change.', + stopCondition: 'stop-if-comparable-evidence-is-missing-or-claim-exceeds-it', + }, + knownUnknowns: [ + 'No population, actual workload or user satisfaction is represented.', + 'The fixed wait bucket does not establish a causal source of delay.', + 'No post-change comparison exists, so effect remains not established.', + 'No support volume, severity or actual ticket is represented.', + ], + boundedFollowUp: { + kind: FOLLOW_UP_KIND, followUpId: 'synthetic-follow-up-owner-label-v1', taskId: 'synthetic-task-sandbox-access-v1', modelVersion: MODEL_VERSION, syntheticOnly: true, + comparisonBoundary: 'same declared role + task objective + stage order + evidence labels', evidenceSource: 'synthetic-comparison-sheet-v1', + ownerRoleId: 'synthetic-role-journey-owner-v1', boundedWindow: 'synthetic-follow-up-window-v1', + stopCondition: 'stop-if-comparable-evidence-is-missing-or-claim-exceeds-it', + nextAction: 'Collect only a new fixed comparison record; otherwise preserve the unresolved claim.', + }, + claimedEffect: { + kind: CLAIM_KIND, claimId: 'synthetic-claim-effect-pending-v1', taskId: 'synthetic-task-sandbox-access-v1', modelVersion: MODEL_VERSION, syntheticOnly: true, + status: 'not-established', statement: 'The model does not claim that the candidate change improves convenience, wait time or support demand.', + evidenceRefs: [], stopCondition: 'stop-if-comparable-evidence-is-missing-or-claim-exceeds-it', + }, +}); + +const fixedCases = deepFreeze({ baseline: fixedJourney }); + +function decision(accepted, reason, ownerRoleId = 'synthetic-role-journey-owner-v1') { + return Object.freeze({ + kind: DECISION_KIND, + decisionId: 'synthetic-decision-' + (accepted ? 'candidate-change-ready-v1' : 'safe-stop-v1'), + accepted, + syntheticOnly: true, + modelVersion: MODEL_VERSION, + reason, + evidenceBoundary: 'This result is only a validation of a fixed in-memory contract; it is not evidence about an actual internal tool or people.', + ownerRoleId, + nextAction: accepted ? 'Run only the bounded synthetic follow-up contract; do not make an effect claim yet.' : 'Safe stop: preserve the rejected claim, repair the fixed contract, and do not infer an effect.', + stopCondition: 'stop-if-comparable-evidence-is-missing-or-claim-exceeds-it', + effectClaim: 'not-established', + productionEffect: false, + }); +} + +function checkRole(role) { + if (!hasExactKeys(role, ROLE_KEYS)) return false; + const fixed = fixedRoles[role.roleId]; + return Boolean(fixed) && hasSameCanonicalJson(role, fixed); +} + +function checkStage(event, expectedIndex, taskId, lastOccurredAt) { + if (!hasExactKeys(event, STAGE_KEYS)) return 'invalid-stage-contract'; + if (event.kind !== STAGE_KIND || event.taskId !== taskId || event.modelVersion !== MODEL_VERSION || event.syntheticOnly !== true) return 'stage-boundary-mismatch'; + if (event.stage !== STAGE_ORDER[expectedIndex]) return 'stage-order-mismatch'; + if (!isIsoStamp(event.occurredAt) || !isIsoStamp(event.observedAt) || event.occurredAt > event.observedAt) return 'invalid-stage-time'; + if (lastOccurredAt && event.occurredAt < lastOccurredAt) return 'non-monotonic-stage-time'; + if (event.evidenceKind !== 'instrumented-event' || event.evidenceSource !== EXPECTED_SOURCE_BY_KIND[event.evidenceKind]) return 'forged-stage-evidence'; + const expectedBucket = event.stage.startsWith('approval.') ? '30m-1h' : 'none'; + if (event.waitBucket !== expectedBucket) return 'invalid-wait-bucket'; + if (!isNonEmptyString(event.eventId) || !isNonEmptyString(event.claim) || !isNonEmptyString(event.outcome)) return 'invalid-stage-value'; + return null; +} + +function checkObservation(observation, taskId) { + if (!hasExactKeys(observation, OBSERVATION_KEYS)) return 'invalid-observation-contract'; + if ( + observation.kind !== OBSERVATION_KIND || observation.taskId !== taskId || observation.modelVersion !== MODEL_VERSION || observation.syntheticOnly !== true + || observation.evidenceKind !== 'ux-observation' || observation.evidenceSource !== EXPECTED_SOURCE_BY_KIND[observation.evidenceKind] + || !isNonEmptyString(observation.statement) || !hasDenseArray(observation.knownUnknowns) || observation.knownUnknowns.length === 0 || !observation.knownUnknowns.every(isNonEmptyString) + ) return 'invalid-ux-observation'; + return null; +} + +function checkSupportSignal(signal, taskId) { + if (!hasExactKeys(signal, SUPPORT_KEYS)) return 'invalid-support-contract'; + if ( + signal.kind !== SUPPORT_KIND || signal.taskId !== taskId || signal.modelVersion !== MODEL_VERSION || signal.syntheticOnly !== true + || signal.evidenceKind !== 'support-signal' || signal.evidenceSource !== EXPECTED_SOURCE_BY_KIND[signal.evidenceKind] + || signal.actionableBy !== 'synthetic-role-journey-owner-v1' || !isNonEmptyString(signal.category) || !isNonEmptyString(signal.statement) + ) return 'invalid-support-signal'; + return null; +} + +function checkChange(change, taskId) { + if (!hasExactKeys(change, CHANGE_KEYS)) return 'invalid-change-contract'; + if ( + change.kind !== CHANGE_KIND || change.taskId !== taskId || change.modelVersion !== MODEL_VERSION || change.syntheticOnly !== true + || change.ownerRoleId !== 'synthetic-role-journey-owner-v1' || !Object.hasOwn(fixedRoles, change.ownerRoleId) + || change.targetStage !== 'request.submitted' || change.boundedWindow !== 'synthetic-follow-up-window-v1' + || change.stopCondition !== 'stop-if-comparable-evidence-is-missing-or-claim-exceeds-it' || !isNonEmptyString(change.hypothesis) || !isNonEmptyString(change.expectedEvidence) + ) return 'invalid-candidate-change'; + return null; +} + +function checkFollowUp(followUp, taskId) { + if (!hasExactKeys(followUp, FOLLOW_UP_KEYS)) return 'invalid-follow-up-contract'; + if ( + followUp.kind !== FOLLOW_UP_KIND || followUp.taskId !== taskId || followUp.modelVersion !== MODEL_VERSION || followUp.syntheticOnly !== true + || followUp.evidenceSource !== EXPECTED_SOURCE_BY_KIND['effect-evidence'] || followUp.ownerRoleId !== 'synthetic-role-journey-owner-v1' + || followUp.boundedWindow !== 'synthetic-follow-up-window-v1' || followUp.stopCondition !== 'stop-if-comparable-evidence-is-missing-or-claim-exceeds-it' + || !isNonEmptyString(followUp.comparisonBoundary) || !isNonEmptyString(followUp.nextAction) + ) return 'invalid-bounded-follow-up'; + return null; +} + +function checkClaim(claim, taskId) { + if (!hasExactKeys(claim, CLAIM_KEYS)) return 'invalid-effect-claim-contract'; + if ( + claim.kind !== CLAIM_KIND || claim.taskId !== taskId || claim.modelVersion !== MODEL_VERSION || claim.syntheticOnly !== true + || claim.status !== 'not-established' || !hasDenseArray(claim.evidenceRefs) || claim.evidenceRefs.length !== 0 + || claim.stopCondition !== 'stop-if-comparable-evidence-is-missing-or-claim-exceeds-it' || !isNonEmptyString(claim.statement) + ) return 'effect-claim-not-evidenced'; + return null; +} + +function validateJourney(input) { + if (canonicalJson(input) === null || !hasExactKeys(input, TASK_KEYS)) return 'invalid-task-contract'; + if ( + input.kind !== TASK_KIND || input.modelVersion !== MODEL_VERSION || input.syntheticOnly !== true || input.taskId !== 'synthetic-task-sandbox-access-v1' + || !checkRole(input.declaredRole) || !isNonEmptyString(input.objective) || input.expectedResult !== 'synthetic-result-confirmed' + ) return 'task-boundary-mismatch'; + if (!hasDenseArray(input.stageEvents) || input.stageEvents.length !== STAGE_ORDER.length) return 'invalid-stage-list'; + let previousOccurredAt = null; + for (const [index, event] of input.stageEvents.entries()) { + const issue = checkStage(event, index, input.taskId, previousOccurredAt); + if (issue) return issue; + previousOccurredAt = event.occurredAt; + } + if (!hasDenseArray(input.uxObservations) || input.uxObservations.length !== 1) return 'invalid-observation-list'; + for (const observation of input.uxObservations) { + const issue = checkObservation(observation, input.taskId); + if (issue) return issue; + } + if (!hasDenseArray(input.supportSignals) || input.supportSignals.length !== 1) return 'invalid-support-list'; + for (const signal of input.supportSignals) { + const issue = checkSupportSignal(signal, input.taskId); + if (issue) return issue; + } + const changeIssue = checkChange(input.candidateChange, input.taskId); + if (changeIssue) return changeIssue; + if (!hasDenseArray(input.knownUnknowns) || input.knownUnknowns.length < 2 || !input.knownUnknowns.every(isNonEmptyString)) return 'known-unknowns-missing'; + const followUpIssue = checkFollowUp(input.boundedFollowUp, input.taskId); + if (followUpIssue) return followUpIssue; + const claimIssue = checkClaim(input.claimedEffect, input.taskId); + if (claimIssue) return claimIssue; + return null; +} + +export function evaluateJourney(input) { + const issue = validateJourney(input); + return issue ? decision(false, issue) : decision(true, 'fixed-contract-valid; candidate change remains hypothesis-only'); +} + +export function createFixedJourneyInput() { + return cloneFixed(fixedCases.baseline); +} + +function expect(assertions, condition, label) { + assertions.count += 1; + if (!condition) throw new Error('fixture assertion failed: ' + label); +} + +export function verifyFixture() { + const assertions = { count: 0 }; + const baseline = evaluateJourney(cloneFixed(fixedCases.baseline)); + expect(assertions, baseline.accepted === true, 'baseline accepts the exact contract'); + expect(assertions, hasExactKeys(baseline, DECISION_KEYS), 'baseline preserves the exact decision contract'); + expect(assertions, baseline.syntheticOnly === true, 'baseline remains synthetic'); + expect(assertions, baseline.effectClaim === 'not-established', 'baseline does not invent an effect'); + expect(assertions, baseline.productionEffect === false, 'baseline has no production effect'); + expect(assertions, baseline.ownerRoleId === 'synthetic-role-journey-owner-v1', 'baseline preserves owner'); + expect(assertions, baseline.reason.includes('fixed-contract-valid'), 'baseline keeps a bounded reason'); + expect(assertions, fixedCases.baseline.stageEvents[2].waitBucket === '30m-1h', 'wait bucket is explicit'); + expect(assertions, fixedCases.baseline.uxObservations[0].evidenceKind === 'ux-observation', 'UX observation stays separate'); + expect(assertions, fixedCases.baseline.supportSignals[0].evidenceKind === 'support-signal', 'support signal stays separate'); + expect(assertions, fixedCases.baseline.candidateChange.stopCondition.includes('comparable-evidence'), 'candidate change has a stop condition'); + expect(assertions, fixedCases.baseline.knownUnknowns.length === 4, 'known unknowns remain declared'); + + const missingUnknowns = cloneFixed(fixedCases.baseline); + delete missingUnknowns.knownUnknowns; + const missingUnknownsDecision = evaluateJourney(missingUnknowns); + expect(assertions, missingUnknownsDecision.accepted === false, 'missing field rejects the task'); + expect(assertions, missingUnknownsDecision.reason === 'invalid-task-contract', 'missing field names the contract failure'); + expect(assertions, missingUnknownsDecision.nextAction.startsWith('Safe stop:'), 'missing field uses safe stop'); + + const extraField = cloneFixed(fixedCases.baseline); + extraField.unboundedScore = 99; + const extraFieldDecision = evaluateJourney(extraField); + expect(assertions, extraFieldDecision.accepted === false, 'extra field rejects the task'); + expect(assertions, extraFieldDecision.reason === 'invalid-task-contract', 'extra field does not become a hidden metric'); + + const forgedEvent = cloneFixed(fixedCases.baseline); + forgedEvent.stageEvents[2].evidenceSource = 'synthetic-forged-ledger-v1'; + const forgedEventDecision = evaluateJourney(forgedEvent); + expect(assertions, forgedEventDecision.accepted === false, 'forged event rejects the task'); + expect(assertions, forgedEventDecision.reason === 'forged-stage-evidence', 'forged event preserves its reason'); + expect(assertions, forgedEventDecision.effectClaim === 'not-established', 'forged event cannot establish effect'); + + const forgedRole = cloneFixed(fixedCases.baseline); + forgedRole.declaredRole.label = 'synthetic-unbounded-admin'; + const forgedRoleDecision = evaluateJourney(forgedRole); + expect(assertions, forgedRoleDecision.accepted === false, 'forged role rejects the task'); + expect(assertions, forgedRoleDecision.reason === 'task-boundary-mismatch', 'forged role cannot expand identity'); + + const forgedResult = cloneFixed(fixedCases.baseline); + forgedResult.claimedEffect.status = 'established'; + forgedResult.claimedEffect.evidenceRefs = ['synthetic-one-record-is-not-a-comparison']; + const forgedResultDecision = evaluateJourney(forgedResult); + expect(assertions, forgedResultDecision.accepted === false, 'forged effect rejects the task'); + expect(assertions, forgedResultDecision.reason === 'effect-claim-not-evidenced', 'effect needs a comparable proof'); + expect(assertions, forgedResultDecision.nextAction.includes('Safe stop'), 'forged effect uses safe stop'); + + const missingBucket = cloneFixed(fixedCases.baseline); + delete missingBucket.stageEvents[2].waitBucket; + const missingBucketDecision = evaluateJourney(missingBucket); + expect(assertions, missingBucketDecision.accepted === false, 'missing event field rejects the task'); + expect(assertions, missingBucketDecision.reason === 'invalid-stage-contract', 'missing wait bucket keeps exact stage contract'); + + const sparseEvents = cloneFixed(fixedCases.baseline); + delete sparseEvents.stageEvents[1]; + const sparseDecision = evaluateJourney(sparseEvents); + expect(assertions, sparseDecision.accepted === false, 'sparse stage list rejects the task'); + expect(assertions, sparseDecision.reason === 'invalid-task-contract', 'sparse list has no canonical model'); + + const cyclicJourney = cloneFixed(fixedCases.baseline); + cyclicJourney.stageEvents.push(cyclicJourney); + const cyclicDecision = evaluateJourney(cyclicJourney); + expect(assertions, cyclicDecision.accepted === false, 'cyclic value rejects the task'); + expect(assertions, cyclicDecision.reason === 'invalid-task-contract', 'cyclic value has no canonical model'); + + const reversedStage = cloneFixed(fixedCases.baseline); + reversedStage.stageEvents[3].occurredAt = '2025-06-12T09:02:00Z'; + const reversedDecision = evaluateJourney(reversedStage); + expect(assertions, reversedDecision.accepted === false, 'non-monotonic event time rejects the task'); + expect(assertions, reversedDecision.reason === 'non-monotonic-stage-time', 'time order is not inferred'); + + if (assertions.count !== 33) throw new Error('fixture assertion count drifted: ' + assertions.count); + return 'PASS fixture: ' + assertions.count + '/' + assertions.count + ' assertions'; +} + +function escapeHtml(value) { + return String(value).replaceAll('&', '&').replaceAll('<', '<').replaceAll('>', '>').replaceAll('"', '"').replaceAll("'", '''); +} +function h2(value) { return '
' + value + '
'; } +function figure(asset, alt, caption) { return '| ' + header + ' | ').join('') + '
|---|
| ' + cell + ' | ').join('') + '
' + escapeHtml(value) + ''; }
+
+function sourceList() {
+ const sources = [
+ {
+ title: 'OpenTelemetry Trace API',
+ version: 'v1.31.0, immutable commit 3985e212f0ef5439daf9b10f0ee490e349c69368, 13 March 2024',
+ href: 'https://raw.githubusercontent.com/open-telemetry/opentelemetry-specification/3985e212f0ef5439daf9b10f0ee490e349c69368/specification/trace/api.md',
+ fact: 'Версия v1.31.0 описывает span как операцию со start/end timestamps, attributes и timestamped events; event содержит name, timestamp и optional attributes.',
+ boundary: 'Это контракт observability API. Он не определяет UX, не назначает owner и не доказывает, что конкретная команда правильно измеряет путь.',
+ },
+ {
+ title: 'W3C Performance Timeline',
+ version: 'Candidate Recommendation Draft, 21 May 2025',
+ href: 'https://www.w3.org/TR/2025/CRD-performance-timeline-20250521/',
+ fact: 'Рекомендация определяет PerformanceEntry и поля name, entryType, startTime и duration для наблюдения временных записей.',
+ boundary: 'Стандарт относится к web performance API; здесь он служит только примером явного имени, типа и времени записи, не рецептом измерения внутреннего сервиса.',
+ },
+ {
+ title: 'GOV.UK Service Manual: Measuring the success of your service',
+ version: 'immutable Internet Archive capture 2024-07-05T18:32:52Z of official GOV.UK guidance; public update 6 August 2018',
+ href: 'https://web.archive.org/web/20240705183252id_/https://www.gov.uk/service-manual/measuring-success/measuring-the-success-of-your-service',
+ fact: 'Закреплённый снимок предлагает сочетать performance metrics с user research, не полагаться только на digital analytics и для пути смотреть completion/time периодически.',
+ boundary: 'Это публичная методическая рекомендация, а не универсальный KPI, договор об измерениях или доказательство эффекта этой учебной модели.',
+ },
+ {
+ title: 'GOV.UK Service Manual: Set up and manage user support',
+ version: 'immutable Internet Archive capture 2023-05-12T13:53:36Z of official GOV.UK guidance; public update 24 November 2016',
+ href: 'https://web.archive.org/web/20230512135336id_/https://www.gov.uk/service-manual/helping-people-to-use-your-service/set-up-and-manage-user-support',
+ fact: 'Закреплённый снимок предлагает использовать feedback user support для улучшения, группировать запросы и связывать их с внутренней командой, способной действовать.',
+ boundary: 'Support signal указывает направление проверки; он не заменяет исследование, не показывает причинность и не является готовым решением.',
+ },
+ ];
+ return 'not-established и остановиться.occurredAt — когда факт задан в модели, и observedAt — когда он зафиксирован. Их равенство в fixture не обещает, что так будет в настоящем инструменте; это просто делает допущение явным.'),
+ p('OpenTelemetry Trace API полезен здесь только как пример дисциплины данных: span имеет start/end timestamps и набор timestamped events, а event имеет name, timestamp и optional attributes. В model мы не строим trace и не отправляем telemetry. Мы заимствуем ровно одно свойство: без имени события, времени и источника невозможно проверить, что сравнивают. W3C Performance Timeline таким же образом показывает, почему у записи должны быть name, type, startTime и duration, но не подсказывает, какую бизнес-метрику считать.'),
+ h2('Поля контракта и их неочевидные границы'),
+ table(
+ ['Поле', 'Зачем нужно', 'Проверка', 'Запрещённый вывод'],
+ [
+ ['declaredRole', 'Отделяет путь одной роли от общего «пользователя».', 'Exact match с fixed role record.', 'Роль не равна реальной учётной записи или праву.'],
+ ['stage + order', 'Делает видимым переход и место задержки.', 'Пять допустимых стадий в строгом порядке.', 'Порядок не объясняет причину ожидания.'],
+ ['occurredAt / observedAt', 'Разделяет время факта и время его фиксации.', 'ISO-формат, occurredAt ≤ observedAt, хронология.', 'Время не измеряет cognitive cost.'],
+ ['waitBucket', 'Помечает диапазон ожидания, не создавая ложную точность.', 'Для approval stages только 30m–1h.', 'Корзина не является общим DX-score.'],
+ ['evidenceKind / source', 'Не даёт observation выдать за event или signal за доказательство.', 'Фиксированная пара kind → source.', 'Источник не делает утверждение причинным.'],
+ ['knownUnknowns', 'Сохраняет пробелы модели рядом с решением.', 'Непустой dense array обязательных строк.', 'Unknown нельзя заменить удобным предположением.'],
+ ],
+ ),
+ p('Exact contract — это не бюрократия ради JSON. Он задаёт точку, в которой evaluator обязан отказать. Лишнее поле unboundedScore отвергается так же, как недостающее knownUnknowns. Удалённый элемент array становится sparse и тоже отвергается. Циклический объект нельзя канонизировать — значит его нельзя использовать как незаметный носитель контекста. Эти проверки защищают не от злого пользователя, а от тихого расползания смыслов между заметкой, таблицей и решением.'),
+ h2('Пять слов «свидетельства» означают пять разных операций'),
+ p('UX-observation — наблюдение о том, как declared role понимает шаг; в примере это неясный owner после submit. Instrumented event — контрактный факт о переходе. Support signal — группировка формулировки вопроса, которой может воспользоваться команда. Decision — выбор owner: показать owner до submit и проверить именно этот этап. Effect evidence — сопоставимый результат после изменения. Нельзя переставить их местами: support signal не устанавливает эффект, а event не заменяет разговор о смысле задачи.'),
+ figure('/assets/editorial/2025/developer-experience-2025-feedback-loop.svg', 'Вертикальная петля Observe, Decide, Change, Recheck и Effect evidence, в которой UX-observation, event и support signal разделены, а без сопоставимого evidence выбран safe stop.', 'Рисунок 3. Решение появляется после наблюдений, а доказательство эффекта — только после повторной проверки по той же границе.'),
+ p('Руководство GOV.UK советует не опираться только на digital analytics, а сочетать метрики с user research. Для support оно отдельно предлагает использовать feedback в улучшении и связывать сигналы с внутренней группой, которая способна действовать. В этой статье из этого следует узкий инженерный вывод: signal должен иметь owner и следующий вопрос, но не получает статус универсальной метрики или готового backlog item.'),
+ h2('Fail-closed evaluator: отказ лучше выдуманного эффекта'),
+ p('Evaluator сначала проверяет, что весь input — canonical JSON из plain records и dense arrays. Затем он сверяет exact keys, model version, declared role, event source, порядок и wait bucket. После этого он проверяет change, bounded follow-up и effect claim. В нормальном case decision разрешает только candidate change hypothesis; поле effectClaim остаётся not-established, а productionEffect — false.'),
+ pre("import { createFixedJourneyInput, evaluateJourney } from './upgrade-2025-06.mjs';\n\nconst forged = createFixedJourneyInput();\nforged.claimedEffect.status = 'established';\nforged.claimedEffect.evidenceRefs = ['one-record-is-not-a-comparison'];\n\nconst result = evaluateJourney(forged);\nconsole.log(result.reason);\n// effect-claim-not-evidenced\nconsole.log(result.nextAction);\n// Safe stop: preserve the rejected claim ..."),
+ p('Этот отрицательный путь важнее happy path. Если одна fixed record уже названа эффектом, следующая команда почти неизбежно перенесёт вывод на другие роли и задачи. Без comparison boundary она не заметит, что изменились инструкция, входная роль, порядок этапов или канал обращения. Поэтому claim сначала должен быть слабым и точно сформулированным: «effect not established».'),
+ h2('Почему wait bucket не равен когнитивной стоимости'),
+ p('Cognitive cost проявляется не только во времени. Человек может ждать недолго и всё же переключаться между системами, искать ответ, сомневаться в результате или выполнять ручный обход. И наоборот, долгий этап может быть ожидаемым внешним ограничением. В model wait bucket помечает approval wait, а UX-observation фиксирует непонятного owner. Эти объекты лежат рядом, но evaluator не умножает их и не строит synthetic score.'),
+ p('Разделение occurredAt и observedAt защищает ещё от одной подмены. Если источник заметил событие позже самого перехода, это может быть свойством доставки записи, а не свойством задачи. Fixture задаёт одинаковые значения, чтобы не моделировать транспорт, но evaluator всё равно требует явный порядок. В production-подобной системе надо отдельно решить, какой timestamp отвечает на вопрос о пути, а какой — на вопрос о наблюдателе; текст не переносит это решение автоматически.'),
+ p('Не следует также превращать отсутствие сигнала в доказательство удобства. У роли может не быть доступного канала, у support может быть другая классификация, а событие может не покрывать ручной шаг. Поэтому отрицательный результат выглядит скромно: «в этой границе доказательства нет». Для техлида это полезный ответ — он сохраняет место для следующего измерения и не тратит квартал на спор о метрике, которой никто не может объяснить.'),
+ figure('/assets/editorial/2025/developer-experience-2025-wait-time-histogram.svg', 'Учебная гистограмма ожидания с тремя временными корзинами и подписью, что корзина показывает место исследования, но не объясняет причину или удобство.', 'Рисунок 4. Время помогает выбрать участок пути для проверки. Оно не заменяет evidence о понимании, поддержке или эффекте.'),
+ h2('Как применить контракт без ложной универсальности'),
+ p('Переносить следует не значения из fixture, а порядок работы. Реальная команда отдельно определяет права на сбор данных, защиту идентификаторов, допустимые sources, retention и owner. Сначала ей нужен task boundary, потом event naming, затем разрешённый user research и support categorisation. После изменения она повторяет ту же задачу для сопоставимой роли и заранее знает, что сделает, если evidence недостаточно.'),
+ 'synthetic-platform-engineer открывает одну fixed задачу. В 09:03 она отправляет request, в 09:04 путь входит в approval wait, в 09:41 получает confirmation и в 09:45 фиксирует synthetic result. Между submit и received задан bucket 30m–1h. Рядом есть UX-observation: роль не понимает, кто отвечает за следующий шаг. Отдельно лежит support signal с категорией routing-unclear.'),
+ p('Важно, что четыре объекта не дублируют друг друга. Event не говорит, что ожидание плохо. Observation не сообщает, что так происходит у всех. Support signal не измеряет число обращений. Candidate change не доказывает эффект. Это намеренное ограничение: одна задача может обнаружить дырку в вопросе, но не может без повторной проверки объявить исправление успешным.'),
+ figure('/assets/editorial/2025/developer-experience-2025-task-journey.svg', 'Схема одной учебной задачи от входа к результату: approval wait, неясный owner, support signal, candidate change и безопасная остановка эффекта без сопоставимого evidence.', 'Рисунок 5. В кейсе время ожидания и неопределённость owner встречаются на одном пути, но остаются разными видами данных.'),
+ h2('Карта случая: факт, гипотеза и неизвестное'),
+ table(
+ ['Артефакт fixed model', 'Что можно сказать', 'Чего нельзя сказать', 'Кто продолжает'],
+ [
+ ['stage event approval.wait.started → received', 'В учебной записи есть этап с bucket 30m–1h.', 'Почему ждём, насколько это часто и насколько это дорого.', 'Journey owner проверяет путь.'],
+ ['UX-observation owner-unclear', 'Declared role не видит ответственного после submit.', 'Что label гарантированно снизит время или frustration.', 'Research owner уточняет вопрос.'],
+ ['support signal routing-unclear', 'Есть фиксированная формулировка категории обращения.', 'Что это самый частый запрос или причина задержки.', 'Support + journey owner выбирают проверку.'],
+ ['candidate change owner-before-submit', 'Есть гипотеза о маршрутизации и один target stage.', 'Что изменение уже улучшило инструмент.', 'Journey owner запускает bounded follow-up.'],
+ ['claimed effect not-established', 'Модель честно оставляет вывод открытым.', 'Что эффект отсутствует в действительности.', 'Owner сохраняет stop path до нового evidence.'],
+ ],
+ ),
+ p('GOV.UK Service Manual предлагает смотреть на end-to-end journey через задачи, completion и время, периодически сравнивая результаты, а не объявлять качество по одному цифровому сигналу. Для support руководство предлагает использовать feedback в улучшении и различать команды, которые могут действовать. В кейсе эти факты не становятся готовой методикой: они объясняют, почему route, signal и owner должны быть видны отдельно.'),
+ h2('Что именно проверяет fixture'),
+ p('Fixture не «прогоняет инструмент». Она делает обратное: атакует учебный договор. Baseline принимается, потому что в нём exact keys, fixed version, declared role, пять ordered events, source для каждого вида evidence, непустые unknowns, owner, bounded follow-up и unresolved claim. Затем fixture подделывает event source, role и result; удаляет fields; создаёт sparse array и cyclic value; нарушает временной порядок. Каждый такой input должен завершиться closed decision.'),
+ pre("import { createFixedJourneyInput, evaluateJourney, verifyFixture } from './upgrade-2025-06.mjs';\n\nconsole.log(verifyFixture());\n// PASS fixture: 33/33 assertions\n\nconst input = createFixedJourneyInput();\ndelete input.knownUnknowns;\nconsole.log(evaluateJourney(input).reason);\n// invalid-task-contract"),
+ p('Отдельный test на forged result особенно важен. Он меняет claimedEffect.status на established и добавляет одну ссылку на несопоставимую запись. Evaluator отвечает effect-claim-not-evidenced. Он не пытается «оценить, похоже ли на правду»: единственный допустимый путь — safe stop, пока не появятся одинаковые task boundary, role, stage order, evidence labels и bounded window.'),
+ h2('Почему manual workaround — сигнал, а не готовая причина'),
+ p('В реальном расследовании ручной обход часто выглядит как очевидное доказательство плохого UX. Но он может быть следствием срочности, местной привычки, старой инструкции или ограничения процесса, которого интерфейс честно не может снять. Поэтому в model обход не записан как причинный факт. Вместо этого support signal указывает на routing question, UX-observation формулирует непонимание owner, а candidate change ограничен тем, что может проверить: показать ответственного до submit.'),
+ p('Такая формулировка экономит время. Она не просит сначала переписать весь flow. Она требует выяснить, существует ли сравнимое свидетельство: тот же role проходит тот же task после небольшой change и фиксирует тот же набор полей. Если нет — решение не объявляют неудачным и не объявляют удачным. Его статус остаётся hypothesis-only.'),
+ p('Сравнимость — не декоративное слово. Если после изменения мы поменяли роль, результат задачи, источник события или правило bucket, разница может появиться в данных без изменения самого пути. Поэтому comparisonBoundary перечисляет role, objective, order и evidence labels. Он не делает эксперимент причинным, но не позволяет незаметно сравнить разные процессы. Бounded window тоже не обещает статистический срок: он ограничивает, когда owner обязан вернуться к вопросу или признать evidence недостаточным.'),
+ p('В кейсе safe stop не означает «ничего не делать». Он означает конкретное действие без эффекта claim: исправить broken contract, уточнить source, провести разрешённую проверку или закрыть hypothesis. Такая дисциплина особенно полезна для внутренних инструментов, где один очень громкий отзыв легко перетягивает приоритет, а тихая, но массовая задержка остаётся между системами. Сначала появляется проверяемый путь, потом — обоснование инвестиции.'),
+ h2('Петля: от сигнала к повторной проверке'),
+ figure('/assets/editorial/2025/developer-experience-2025-feedback-loop.svg', 'Вертикальная петля с наблюдением, решением владельца, ограниченным изменением, повторной проверкой и safe stop при отсутствии сопоставимых доказательств.', 'Рисунок 6. Цикл запрещает перескочить от support signal к заявлению об эффекте.'),
+ p('Владелец в кейсе — synthetic-journey-owner. Это не право на production change, а явный адрес ответственности внутри модели. Его decision описывает hypothesis, target stage, expected evidence, bounded window и stop condition. Research owner, support owner и technical owner в реальном мире могут быть разными людьми; модель не заставляет слить их в одну роль. Она только не позволяет оставить вопрос «кто продолжает» без ответа.'),
+ 'not-established.knownUnknowns заранее: нет population denominator, нет объяснения wait bucket, нет post-change comparison, нет реального support volume. Это превращает «кажется, неудобно» в план исследования, а не в необратимый вывод.'),
+ figure('/assets/editorial/2025/developer-experience-2025-wait-time-histogram.svg', 'Учебная гистограмма трёх корзин ожидания с предупреждением, что временная корзина указывает место для проверки, а не объясняет удобство или его причину.', 'Рисунок 7. Wait bucket видит задержку в пути, но не захватывает весь cognitive cost.'),
+ h2('Граница кейса и следующий проверяемый шаг'),
+ p('Граница кейса. Время 09:03–09:45, synthetic role и формулировка support card — декорации одного учебного прохода, а не вырезка из реального workflow. Кейс не образует cohort, не содержит ticket, file или telemetry record и не даёт основания утверждать, что такая же история есть у другой роли. Его единственная проверяемая ценность — показать, где decision обязан остановить неподтверждённый result.'),
+ p('Кейс не содержит реального workflow, документации, заявок, пользователей, telemetry, support tickets или network. Он не устанавливает цену ожидания и не даёт разрешение собирать такие данные. Его положительный decision — лишь согласованность JavaScript literals, а не одобрение изменения в интерфейсе.'),
+ p('Следующий проверяемый шаг. Перед любой реальной работой сформируйте с owner один research question: «видит ли эта role ответственного до submit и остаётся ли вопрос маршрутизации после этого?» Затем согласуйте допустимый источник evidence и условие safe stop. Если источник или comparison boundary не определены, не начинайте с метрики — оставьте claim открытым.'),
+ ]),
+]);
+
+if (process.argv.includes('--verify-fixture')) {
+ process.stdout.write(verifyFixture() + '\n');
+} else if (process.argv.includes('--print-revisions')) {
+ process.stdout.write(JSON.stringify(revisions));
+}