This commit is contained in:
@@ -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,
|
||||
];
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="375" height="670" viewBox="0 0 375 670" role="img" aria-labelledby="title desc">
|
||||
<title id="title">Петля наблюдения, решения и повторной проверки</title>
|
||||
<desc id="desc">Вертикальная петля отделяет наблюдение UX, инструментированное событие и сигнал поддержки от решения владельца и проверяемого доказательства эффекта. Внизу находится безопасная остановка.</desc>
|
||||
<rect width="375" height="670" fill="#0c1624" rx="18"/>
|
||||
<text x="24" y="36" fill="#f8fafc" font-family="Arial, sans-serif" font-size="18" font-weight="700">Не один KPI, а замкнутый цикл</text>
|
||||
<text x="24" y="58" fill="#b8c7d9" font-family="Arial, sans-serif" font-size="12">Каждый слой отвечает на другой вопрос</text>
|
||||
<rect x="24" y="86" width="327" height="72" rx="12" fill="#16263a" stroke="#7dd3fc"/>
|
||||
<text x="40" y="112" fill="#8ed0ff" font-family="Arial, sans-serif" font-size="12" font-weight="700">Observe</text>
|
||||
<text x="40" y="136" fill="#f8fafc" font-family="Arial, sans-serif" font-size="14">UX-observation + event + support signal</text>
|
||||
<path d="M187 159v22" stroke="#7dd3fc" stroke-width="3"/><path d="M181 174l6 7 6-7" fill="none" stroke="#7dd3fc" stroke-width="3"/>
|
||||
<rect x="24" y="189" width="327" height="72" rx="12" fill="#16263a" stroke="#c4a5fd"/>
|
||||
<text x="40" y="215" fill="#d7c2ff" font-family="Arial, sans-serif" font-size="12" font-weight="700">Decide</text>
|
||||
<text x="40" y="239" fill="#f8fafc" font-family="Arial, sans-serif" font-size="14">owner, hypothesis, bounded change, stop rule</text>
|
||||
<path d="M187 262v22" stroke="#c4a5fd" stroke-width="3"/><path d="M181 277l6 7 6-7" fill="none" stroke="#c4a5fd" stroke-width="3"/>
|
||||
<rect x="24" y="292" width="327" height="72" rx="12" fill="#16263a" stroke="#f0b44d"/>
|
||||
<text x="40" y="318" fill="#ffd18a" font-family="Arial, sans-serif" font-size="12" font-weight="700">Change</text>
|
||||
<text x="40" y="342" fill="#f8fafc" font-family="Arial, sans-serif" font-size="14">one path change; no claim of effect yet</text>
|
||||
<path d="M187 365v22" stroke="#f0b44d" stroke-width="3"/><path d="M181 380l6 7 6-7" fill="none" stroke="#f0b44d" stroke-width="3"/>
|
||||
<rect x="24" y="395" width="327" height="72" rx="12" fill="#16263a" stroke="#5ee0a0"/>
|
||||
<text x="40" y="421" fill="#88f0b9" font-family="Arial, sans-serif" font-size="12" font-weight="700">Recheck</text>
|
||||
<text x="40" y="445" fill="#f8fafc" font-family="Arial, sans-serif" font-size="14">same role, task boundary, evidence and window</text>
|
||||
<path d="M187 468v22" stroke="#5ee0a0" stroke-width="3"/><path d="M181 483l6 7 6-7" fill="none" stroke="#5ee0a0" stroke-width="3"/>
|
||||
<rect x="24" y="498" width="327" height="68" rx="12" fill="#163426" stroke="#5ee0a0"/>
|
||||
<text x="40" y="525" fill="#88f0b9" font-family="Arial, sans-serif" font-size="12" font-weight="700">Effect evidence</text>
|
||||
<text x="40" y="548" fill="#f8fafc" font-family="Arial, sans-serif" font-size="14">comparable evidence → update decision</text>
|
||||
<path d="M187 567v21" stroke="#fb7185" stroke-width="3"/><path d="M181 581l6 7 6-7" fill="none" stroke="#fb7185" stroke-width="3"/>
|
||||
<rect x="24" y="596" width="327" height="50" rx="12" fill="#3b1722" stroke="#fb7185"/>
|
||||
<text x="40" y="618" fill="#fecdd3" font-family="Arial, sans-serif" font-size="12" font-weight="700">Нет comparable evidence</text>
|
||||
<text x="40" y="637" fill="#f8fafc" font-family="Arial, sans-serif" font-size="13">safe stop: оставить claim «не установлен»</text>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 3.7 KiB |
@@ -0,0 +1,33 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="375" height="700" viewBox="0 0 375 700" role="img" aria-labelledby="title desc">
|
||||
<title id="title">Путь одной учебной задачи во внутреннем инструменте</title>
|
||||
<desc id="desc">Вертикальная схема показывает переход учебной задачи от входа через ожидание решения к результату, отдельно выделяя событие, наблюдение, владельца и неизвестное.</desc>
|
||||
<rect width="375" height="700" fill="#0c1624" rx="18"/>
|
||||
<text x="24" y="34" fill="#f8fafc" font-family="Arial, sans-serif" font-size="18" font-weight="700">Одна задача, пять видов данных</text>
|
||||
<text x="24" y="56" fill="#b8c7d9" font-family="Arial, sans-serif" font-size="12">Учебная in-memory модель, не данные инструмента</text>
|
||||
<rect x="24" y="84" width="327" height="82" rx="12" fill="#16263a" stroke="#6c97c6"/>
|
||||
<text x="40" y="110" fill="#8ed0ff" font-family="Arial, sans-serif" font-size="12" font-weight="700">1. Вход задачи</text>
|
||||
<text x="40" y="132" fill="#f8fafc" font-family="Arial, sans-serif" font-size="15">declared role → submit access request</text>
|
||||
<text x="40" y="151" fill="#b8c7d9" font-family="Arial, sans-serif" font-size="12">UX-observation: роль не видит владельца</text>
|
||||
<path d="M187 167v27" stroke="#7dd3fc" stroke-width="3"/><path d="M181 187l6 7 6-7" fill="none" stroke="#7dd3fc" stroke-width="3"/>
|
||||
<rect x="24" y="204" width="327" height="95" rx="12" fill="#16263a" stroke="#f0b44d"/>
|
||||
<text x="40" y="230" fill="#ffd18a" font-family="Arial, sans-serif" font-size="12" font-weight="700">2. Наблюдаемое ожидание</text>
|
||||
<text x="40" y="252" fill="#f8fafc" font-family="Arial, sans-serif" font-size="15">approval.wait.started → received</text>
|
||||
<text x="40" y="273" fill="#b8c7d9" font-family="Arial, sans-serif" font-size="12">instrumented event: 30m–1h bucket</text>
|
||||
<text x="40" y="289" fill="#b8c7d9" font-family="Arial, sans-serif" font-size="12">это факт модели, не причина и не эффект</text>
|
||||
<path d="M187 300v27" stroke="#f0b44d" stroke-width="3"/><path d="M181 320l6 7 6-7" fill="none" stroke="#f0b44d" stroke-width="3"/>
|
||||
<rect x="24" y="337" width="327" height="95" rx="12" fill="#16263a" stroke="#c4a5fd"/>
|
||||
<text x="40" y="363" fill="#d7c2ff" font-family="Arial, sans-serif" font-size="12" font-weight="700">3. Сигнал и решение</text>
|
||||
<text x="40" y="385" fill="#f8fafc" font-family="Arial, sans-serif" font-size="15">support signal → owner chooses a change</text>
|
||||
<text x="40" y="406" fill="#b8c7d9" font-family="Arial, sans-serif" font-size="12">signal ≠ доказательство масштаба проблемы</text>
|
||||
<text x="40" y="422" fill="#b8c7d9" font-family="Arial, sans-serif" font-size="12">decision: показать owner до отправки</text>
|
||||
<path d="M187 433v27" stroke="#c4a5fd" stroke-width="3"/><path d="M181 453l6 7 6-7" fill="none" stroke="#c4a5fd" stroke-width="3"/>
|
||||
<rect x="24" y="470" width="327" height="95" rx="12" fill="#16263a" stroke="#5ee0a0"/>
|
||||
<text x="40" y="496" fill="#88f0b9" font-family="Arial, sans-serif" font-size="12" font-weight="700">4. Повторная проверка</text>
|
||||
<text x="40" y="518" fill="#f8fafc" font-family="Arial, sans-serif" font-size="15">same journey, bounded follow-up window</text>
|
||||
<text x="40" y="539" fill="#b8c7d9" font-family="Arial, sans-serif" font-size="12">effect requires comparable evidence</text>
|
||||
<text x="40" y="555" fill="#b8c7d9" font-family="Arial, sans-serif" font-size="12">unknown stays visible until checked</text>
|
||||
<path d="M187 566v27" stroke="#fb7185" stroke-width="3"/><path d="M181 586l6 7 6-7" fill="none" stroke="#fb7185" stroke-width="3"/>
|
||||
<rect x="24" y="603" width="327" height="69" rx="12" fill="#3b1722" stroke="#fb7185"/>
|
||||
<text x="40" y="630" fill="#fecdd3" font-family="Arial, sans-serif" font-size="12" font-weight="700">Safe stop</text>
|
||||
<text x="40" y="652" fill="#f8fafc" font-family="Arial, sans-serif" font-size="14">Нет comparable evidence → не заявлять эффект</text>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 4.3 KiB |
@@ -0,0 +1,25 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="375" height="620" viewBox="0 0 375 620" role="img" aria-labelledby="title desc">
|
||||
<title id="title">Учебная гистограмма корзин ожидания</title>
|
||||
<desc id="desc">Схема показывает три корзины ожидания для учебного пути задачи и поясняет, что корзина времени не измеряет удобство и не объясняет причину.</desc>
|
||||
<rect width="375" height="620" fill="#0c1624" rx="18"/>
|
||||
<text x="24" y="36" fill="#f8fafc" font-family="Arial, sans-serif" font-size="18" font-weight="700">Ожидание — срез, не общий score</text>
|
||||
<text x="24" y="58" fill="#b8c7d9" font-family="Arial, sans-serif" font-size="12">Fixed synthetic buckets: не telemetry,</text>
|
||||
<text x="24" y="74" fill="#b8c7d9" font-family="Arial, sans-serif" font-size="12">не реальные пользователи</text>
|
||||
<line x1="64" y1="425" x2="345" y2="425" stroke="#7290b2" stroke-width="2"/>
|
||||
<line x1="64" y1="116" x2="64" y2="425" stroke="#7290b2" stroke-width="2"/>
|
||||
<text x="24" y="132" fill="#b8c7d9" font-family="Arial, sans-serif" font-size="11">stage observations</text>
|
||||
<rect x="87" y="336" width="56" height="89" rx="7" fill="#5ee0a0"/>
|
||||
<rect x="169" y="248" width="56" height="177" rx="7" fill="#f0b44d"/>
|
||||
<rect x="251" y="381" width="56" height="44" rx="7" fill="#7dd3fc"/>
|
||||
<text x="105" y="325" fill="#f8fafc" font-family="Arial, sans-serif" font-size="14" font-weight="700">2</text>
|
||||
<text x="187" y="237" fill="#f8fafc" font-family="Arial, sans-serif" font-size="14" font-weight="700">4</text>
|
||||
<text x="270" y="370" fill="#f8fafc" font-family="Arial, sans-serif" font-size="14" font-weight="700">1</text>
|
||||
<text x="86" y="450" fill="#d8e4f2" font-family="Arial, sans-serif" font-size="12">0–5m</text>
|
||||
<text x="161" y="450" fill="#d8e4f2" font-family="Arial, sans-serif" font-size="12">30m–1h</text>
|
||||
<text x="245" y="450" fill="#d8e4f2" font-family="Arial, sans-serif" font-size="12">1h+</text>
|
||||
<rect x="24" y="485" width="327" height="104" rx="12" fill="#16263a" stroke="#6c97c6"/>
|
||||
<text x="40" y="513" fill="#8ed0ff" font-family="Arial, sans-serif" font-size="12" font-weight="700">Правило чтения</text>
|
||||
<text x="40" y="535" fill="#f8fafc" font-family="Arial, sans-serif" font-size="14">Корзина показывает, где искать путь.</text>
|
||||
<text x="40" y="556" fill="#b8c7d9" font-family="Arial, sans-serif" font-size="12">Она не объясняет причину, не сравнивает роли</text>
|
||||
<text x="40" y="573" fill="#b8c7d9" font-family="Arial, sans-serif" font-size="12">и не доказывает эффект candidate change.</text>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 2.8 KiB |
@@ -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 '<h2>' + escapeHtml(value) + '</h2>'; }
|
||||
function p(value) { return '<p>' + value + '</p>'; }
|
||||
function figure(asset, alt, caption) { return '<figure><img src="' + asset + '" alt="' + escapeHtml(alt) + '"/><figcaption>' + caption + '</figcaption></figure>'; }
|
||||
function table(headers, rows) {
|
||||
return '<table><thead><tr>' + headers.map((header) => '<th scope="col">' + header + '</th>').join('') + '</tr></thead><tbody>'
|
||||
+ rows.map((row) => '<tr>' + row.map((cell) => '<td>' + cell + '</td>').join('') + '</tr>').join('') + '</tbody></table>';
|
||||
}
|
||||
function pre(value) { return '<pre><code>' + escapeHtml(value) + '</code></pre>'; }
|
||||
|
||||
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 '<ul>' + sources.map((source) => '<li><a href="' + source.href + '" target="_blank" rel="noopener noreferrer">' + escapeHtml(source.title) + '</a> <em>(' + escapeHtml(source.version) + ')</em>. ' + escapeHtml(source.fact) + ' Граница: ' + escapeHtml(source.boundary) + '</li>').join('') + '</ul>';
|
||||
}
|
||||
|
||||
function plainText(content) {
|
||||
return content.replace(/<[^>]+>/g, ' ').replace(/&(?:quot|amp|lt|gt|#039);/g, ' ').replace(/\s+/g, ' ').trim();
|
||||
}
|
||||
function bodyText(content) {
|
||||
return plainText(content.replace(/<h2>Проверяемые источники<\/h2>[\s\S]*?(?=<h2>|$)/, ''));
|
||||
}
|
||||
function revision(meta, parts) {
|
||||
const contentHtml = parts.join('\n') + '\n' + h2('Проверяемые источники') + '\n' + sourceList();
|
||||
const proseLength = bodyText(contentHtml).length;
|
||||
if (proseLength < 5000 || proseLength > 15000) throw new Error(meta.slug + ': основной текст вне диапазона 5 000–15 000 знаков: ' + proseLength);
|
||||
return Object.freeze({ ...meta, contentHtml });
|
||||
}
|
||||
|
||||
export const revisions = Object.freeze([
|
||||
revision({
|
||||
slug: 'editorial-2025-06-practice-developer-experience',
|
||||
title: 'Удобство внутреннего инструмента: наблюдать путь одной задачи',
|
||||
excerpt: 'Вместо единого DX-score — путь задачи, явные ожидания, отдельные типы свидетельств, owner и проверяемая стоп-ветка.',
|
||||
readingMinutes: 12,
|
||||
}, [
|
||||
p('<strong>Проблема.</strong> Внутренний инструмент может формально выдавать результат и всё же создавать очередь ожидания: человек отправляет запрос, не видит владельца, спрашивает в соседнем канале и ждёт ручного обхода. Цена ошибки — не только лишние минуты. Команда теряет время на повторные объяснения, а руководитель получает красивый средний показатель без места, где задача застряла.'),
|
||||
p('Поэтому «удобно» здесь не оценка и не один DX-score. Это наблюдаемая гипотеза о конкретном пути: какая роль вошла в задачу, какое событие зафиксировало переход, где возникло ожидание, какой сигнал пришёл от поддержки, кто может изменить путь и что будет считаться доказательством эффекта. Если хотя бы один слой неизвестен, правильный результат — не смелый вывод, а остановка утверждения.'),
|
||||
h2('Начать с одной задачи, а не с общей метрики'),
|
||||
p('Берём одну повторяемую задачу с ясным результатом. В учебной модели это запрос synthetic sandbox access: роль открыла задачу, отправила заявку, вошла в approval wait, получила подтверждение и увидела synthetic result. Такой маршрут мал, но в нём уже видны разные вопросы. Событие отвечает на вопрос «что и когда произошло». Наблюдение UX отвечает на вопрос «что человек не понял». Support signal показывает формулировку обращения. Решение владельца связывает сигнал с изменением. Доказательство эффекта появляется только после сопоставимой повторной проверки.'),
|
||||
p('Это не попытка свести работу людей к секундомеру. Даже точное время ожидания не говорит, почему оно появилось: причиной может быть очередь, недостающая подсказка, право, внешний процесс или неверно выбранная задача. Временная запись нужна как адрес для исследования. Она не заменяет разговор с ролью и не делает support card статистикой.'),
|
||||
figure('/assets/editorial/2025/developer-experience-2025-task-journey.svg', 'Вертикальная схема учебной задачи: вход, наблюдаемое ожидание, support signal, решение владельца, повторная проверка и safe stop без доказательства эффекта.', 'Рисунок 1. Путь одной fixed synthetic задачи. Каждый блок несёт разный вид свидетельства; красная ветка запрещает заявлять эффект без сопоставимой проверки.'),
|
||||
h2('Пять видов свидетельств не стоит склеивать'),
|
||||
p('Смешение слоёв начинается безобидно: карточка поддержки становится «проблемой всех», одна отметка в журнале — «доказательством боли», а решение владельца — «улучшением». В результате команда спорит о вкусе, потому что вопрос, источник и сила вывода потерялись. Короткая таблица возвращает границу каждому объекту.'),
|
||||
table(
|
||||
['Слой', 'Что он фиксирует', 'Чего он не доказывает', 'Следующее действие'],
|
||||
[
|
||||
['UX-observation', 'Роль не понимает, кому принадлежит следующий шаг.', 'Частоту, причину задержки и эффект будущего изменения.', 'Проверить формулировку задачи и задать вопрос роли.'],
|
||||
['Instrumented event', 'Имя этапа, порядок и fixed wait bucket.', 'Удовлетворённость, мотивацию, реальную нагрузку или причинность.', 'Проверить контракт события и место ожидания.'],
|
||||
['Support signal', 'Повторяемую категорию вопроса и команду, способную действовать.', 'Масштаб затронутых людей и корректность решения.', 'Собрать hypothesis для owner.'],
|
||||
['Decision', 'Owner, target stage, границу изменения и stop rule.', 'Фактический эффект от решения.', 'Запустить ограниченную повторную проверку.'],
|
||||
['Effect evidence', 'Сопоставление после изменения при той же границе.', 'Причинность вне этой границы и перенос на другие роли.', 'Обновить решение либо сохранить unresolved claim.'],
|
||||
],
|
||||
),
|
||||
p('Термин <em>instrumented event</em> здесь узкий: запись с именем этапа, временем, источником и контрактом полей. OpenTelemetry описывает event как имя, timestamp и optional attributes, а span — как операцию со временем начала и конца. Это помогает не оставлять в логе безымянное «что-то произошло», но не навязывает модель удобства. Аналогично, W3C Performance Timeline показывает ценность явных name, type, startTime и duration для временной записи; применимость стандарта заканчивается на форме записи.'),
|
||||
h2('Контракт задачи: короткий, но fail-closed'),
|
||||
p('Вместо свободного объекта фиксируем exact keys. У задачи есть declared role, цель и expected result; у каждого stage event — stage, occurredAt, observedAt, waitBucket, evidence kind и source. Unknowns не прячутся в заметке: они обязательное поле. Candidate change привязан к owner, одному этапу, bounded window и стоп-условию. Если поле пропало, роль подменена или кто-то объявил effect established, evaluator возвращает safe stop.'),
|
||||
pre("import { createFixedJourneyInput, evaluateJourney, verifyFixture } from './upgrade-2025-06.mjs';\n\nconst result = evaluateJourney(createFixedJourneyInput());\n\nconsole.log({\n accepted: result.accepted,\n effectClaim: result.effectClaim,\n productionEffect: result.productionEffect,\n});\nconsole.log(verifyFixture());\n// true, 'not-established', false\n// PASS fixture: 33/33 assertions"),
|
||||
p('Положительный результат не означает, что инструмент удобен. Он означает лишь, что учебный объект не потерял границы: событие принадлежит нужной задаче, порядок этапов не сломан, источник соответствует виду свидетельства, unknowns перечислены, а claim не превышает доказательство. Это полезнее ложной уверенности: ошибочный объект не попадает в отчёт и не превращается в бэклог «исправить UX вообще».'),
|
||||
h2('Как увидеть ожидание без игры в среднее'),
|
||||
p('Для маршрута полезны buckets, а не единственная средняя длительность. В model approval wait лежит в корзине 30m–1h. Она отмечает, где путь замедлился, но не говорит, что все остальные ожидания одинаковы. Рядом нужны роль, этап, причина, неизвестное и owner. Иначе короткие задачи могут скрыть один критичный стоп, а длинная задача — искусственно ухудшить агрегат.'),
|
||||
p('Корзина должна быть заранее объявлена как часть контракта, а не подобрана после просмотра результата. В этом примере диапазон 30m–1h не несёт точности до секунды и не претендует на норму. Он всего лишь связывает два approval events и делает очередь вопросом для owner. Если команда изменит границы bucket, она должна сохранить версию схемы и не сравнивать новые значения со старыми так, будто смысл поля остался прежним.'),
|
||||
p('Полезный контрпример: два одинаковых wait buckets могут означать разные действия. В одном случае роль не знает, что заявка уже в очереди; тогда проверяется ясность статуса. В другом owner виден, статус понятен, но этап зависит от заранее известного внешнего окна; тогда интерфейс не обязан обещать мгновенный результат. Событие в обоих случаях похоже, но UX question, решение и допустимый effect claim различаются.'),
|
||||
figure('/assets/editorial/2025/developer-experience-2025-wait-time-histogram.svg', 'Учебная гистограмма с корзинами 0–5 минут, 30 минут–1 час и более часа, снабжённая предупреждением: корзина ожидания не измеряет удобство и не объясняет причину.', 'Рисунок 2. Корзина времени — адрес проверки, а не итоговая оценка удобства. Числа в рисунке synthetic и не описывают реальный инструмент.'),
|
||||
p('Официальное руководство GOV.UK предлагает для транзакций соединять performance metrics с user research и не полагаться только на digital analytics. Для end-to-end пути оно отдельно называет completion и время выполнения задачи, повторяемые во времени. Это не предписание сделать один KPI: наоборот, источник даёт основание держать разные данные рядом и не выдавать один канал за картину целиком.'),
|
||||
h2('Минимальный цикл работы владельца'),
|
||||
p('Практика становится инженерной, когда у неё есть короткий порядок и право остановиться. Ниже нет запуска реального исследования или telemetry: это порядок вопросов, который затем можно применить в своей среде с отдельными правами и данными.'),
|
||||
'<ol><li>Назвать одну задачу и её видимый результат; не включать весь onboarding в один маршрут.</li><li>Зафиксировать declared role, этапы и события с ограниченными полями; отдельно записать source каждого факта.</li><li>Положить wait bucket рядом с этапом, а не выдавать его за причину.</li><li>Привязать UX-observation и support signal к вопросу, не к готовому решению.</li><li>Назвать owner, candidate change, comparison boundary и bounded follow-up.</li><li>Если сопоставимого evidence нет, оставить effect claim в состоянии <code>not-established</code> и остановиться.</li></ol>',
|
||||
h2('Где модель намеренно останавливается'),
|
||||
p('<strong>Граница практики.</strong> Эта статья описывает порядок постановки вопроса, а не способ незаметно собрать данные. Synthetic task, timestamps и buckets — опоры для разговора о пути; они не представляют журнал внутреннего инструмента. Код выполняет только проверку литералов в памяти: у него нет client, очереди, файлового ввода или отправки события.'),
|
||||
p('Модель не знает, сколько людей сталкивается с задачей, не измеряет cognitive cost, не хранит обращение и не различает реальный канал поддержки. Она также не может доказать, что показ owner до submit уменьшит ожидание: это может снять вопрос маршрутизации и не изменить approval queue. Такая граница — не недостаток текста, а защита от неверного решения.'),
|
||||
p('<strong>Следующий проверяемый шаг.</strong> В своём инструменте выберите одну роль и один path, согласуйте owner и минимальный event contract, затем заранее запишите условие, при котором вы откажетесь от вывода. Только после этого собирайте разрешённые данные и сопоставляйте тот же маршрут до и после изменения.'),
|
||||
]),
|
||||
revision({
|
||||
slug: 'editorial-2025-06-mechanism-developer-experience',
|
||||
title: 'Удобство внутреннего инструмента: контракт наблюдаемой задачи',
|
||||
excerpt: 'Как построить технический контракт для пути задачи: событие, ожидание, UX-observation, support signal, решение владельца и безопасная остановка claim.',
|
||||
readingMinutes: 13,
|
||||
}, [
|
||||
p('<strong>Проблема.</strong> Инструмент может успешно завершать запрос, но инженер всё равно ждёт неизвестного владельца, повторяет данные вручную и открывает обходной путь. Цена ошибки — очередь из невидимых переключений контекста: владелец видит только успешные ответы, а команда платит за уточнения, повторные заявки и решения, которые нельзя проверить.'),
|
||||
p('Техническая реакция часто слишком узка: добавить один таймер, построить среднее и назвать его DX. Это стирает разницу между событием, наблюдением, сигналом поддержки, решением и доказательством эффекта. Нужен не широкий dashboard, а строгий контракт одной задачи. Он делает неизвестное видимым, не позволяет подменить role или результат и заставляет остановить claim, когда сопоставимых данных нет.'),
|
||||
h2('Единица наблюдения — задача с границей'),
|
||||
p('Задача — не экран и не сервис целиком. Это переход от ясного входа к проверяемому результату для одной declared role. В fixed model входом служит открытие synthetic access request, результатом — synthetic confirmation. Между ними есть пять stage events. Каждый event содержит два времени: <code>occurredAt</code> — когда факт задан в модели, и <code>observedAt</code> — когда он зафиксирован. Их равенство в 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 обязан отказать. Лишнее поле <code>unboundedScore</code> отвергается так же, как недостающее <code>knownUnknowns</code>. Удалённый элемент 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 разрешает только <em>candidate change hypothesis</em>; поле <code>effectClaim</code> остаётся <code>not-established</code>, а <code>productionEffect</code> — <code>false</code>.'),
|
||||
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('Разделение <code>occurredAt</code> и <code>observedAt</code> защищает ещё от одной подмены. Если источник заметил событие позже самого перехода, это может быть свойством доставки записи, а не свойством задачи. 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 недостаточно.'),
|
||||
'<ol><li>Сформулируйте цель одной задачи и ожидаемый результат в наблюдаемой форме.</li><li>Назовите role, stages, event source и wait bucket; уберите поля, которые никто не сможет интерпретировать.</li><li>Отдельно запишите UX question, support category и known unknowns.</li><li>Назначьте owner candidate change и запретите эффект claim до bounded follow-up.</li><li>Повторите сравнение на той же границе или верните решение в safe stop.</li></ol>',
|
||||
h2('Ограничения и следующий тест'),
|
||||
p('<strong>Граница механизма.</strong> Evaluator проверяет структуру и семантические пары fixed records, а не правдивость внешнего мира. Он намеренно не содержит payload, user identifier, transport, часы или integration point. Даже accepted decision не запускает change: он только разрешает сохранить hypothesis для ограниченной следующей проверки.'),
|
||||
p('Даже строгая fixture не моделирует реальную задержку, человека, поддерживающую команду или качество интерфейса. Она не знает безопасность telemetry, не проверяет доступы и не подменяет user research. Её результат — только доказательство того, что неверный contract не станет положительным решением.'),
|
||||
p('<strong>Следующий проверяемый шаг.</strong> Возьмите один реальный, но разрешённый для исследования путь и составьте сначала пустой contract: какие fields вы обязуетесь собрать, какие unknowns ожидаете и какой result заставит остановить claim. Проверьте этот contract с owner до изменения интерфейса.'),
|
||||
]),
|
||||
revision({
|
||||
slug: 'editorial-2025-06-field-developer-experience',
|
||||
title: 'Удобство внутреннего инструмента: учебный кейс без ложного эффекта',
|
||||
excerpt: 'Разбор одной fixed synthetic задачи: где появляется ожидание, как отделить сигнал от решения и почему честный safe stop лучше выдуманного улучшения.',
|
||||
readingMinutes: 13,
|
||||
}, [
|
||||
p('<strong>Проблема.</strong> Новая возможность внутреннего инструмента может «работать»: заявка подтверждается, итог появляется, ошибки нет. Но путь оставляет человека в очереди между submit и approval, вынуждает искать владельца и создавать ручный обход. Цена ошибки — скрытая: потерянный контекст, повторная работа support и решение о redesign на основании одного яркого сообщения.'),
|
||||
p('Разберём такой случай без притворства, что у нас есть production telemetry или реальные пользователи. Учебный кейс использует только fixed in-memory literals. Его цель — показать форму доказательства: как разделить observation, event, support signal, decision и effect evidence; как записать unknown; и как остановить утверждение, если одно synthetic прохождение не даёт основания говорить «стало удобнее».'),
|
||||
h2('Случай: запрос sandbox access'),
|
||||
p('Declared role <code>synthetic-platform-engineer</code> открывает одну fixed задачу. В 09:03 она отправляет request, в 09:04 путь входит в approval wait, в 09:41 получает confirmation и в 09:45 фиксирует synthetic result. Между submit и received задан bucket 30m–1h. Рядом есть UX-observation: роль не понимает, кто отвечает за следующий шаг. Отдельно лежит support signal с категорией <code>routing-unclear</code>.'),
|
||||
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 особенно важен. Он меняет <code>claimedEffect.status</code> на <code>established</code> и добавляет одну ссылку на несопоставимую запись. Evaluator отвечает <code>effect-claim-not-evidenced</code>. Он не пытается «оценить, похоже ли на правду»: единственный допустимый путь — 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, разница может появиться в данных без изменения самого пути. Поэтому <code>comparisonBoundary</code> перечисляет 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('Владелец в кейсе — <code>synthetic-journey-owner</code>. Это не право на production change, а явный адрес ответственности внутри модели. Его decision описывает hypothesis, target stage, expected evidence, bounded window и stop condition. Research owner, support owner и technical owner в реальном мире могут быть разными людьми; модель не заставляет слить их в одну роль. Она только не позволяет оставить вопрос «кто продолжает» без ответа.'),
|
||||
'<ol><li>Сохранить исходную формулировку задачи, role и expected result.</li><li>Отметить stages и wait bucket без вывода о причине.</li><li>Приложить UX-observation и support signal как разные источники.</li><li>Назначить owner, одну candidate change и bounded comparison boundary.</li><li>После повторной проверки принять только comparable evidence; иначе оставить claim <code>not-established</code>.</li></ol>',
|
||||
h2('Откуда берётся скрытая cognitive cost'),
|
||||
p('Cognitive cost часто появляется между видимыми событиями: роль вспоминает, куда писать, сравнивает похожие страницы, ждёт ответа в другом канале или боится повторно отправить запрос. Ни event, ни support category сами по себе не измеряют это состояние. Поэтому полезно записать <code>knownUnknowns</code> заранее: нет 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('<strong>Граница кейса.</strong> Время 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('<strong>Следующий проверяемый шаг.</strong> Перед любой реальной работой сформируйте с 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));
|
||||
}
|
||||
Reference in New Issue
Block a user