instar 1.3.1178 → 1.3.1179

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -2,7 +2,7 @@
2
2
  "schemaVersion": 1,
3
3
  "generatedFrom": "source-tree",
4
4
  "registrySha256": "81b53363a440e832672618965540b3e507ae0d93adcc67ec2b93daf7933b3ab4",
5
- "packageVersion": "1.3.1178",
5
+ "packageVersion": "1.3.1179",
6
6
  "guards": [
7
7
  {
8
8
  "ref": "docs/audits/phase-b/f10-triage.md",
@@ -1,5 +1,5 @@
1
1
  {
2
- "sha256": "f029879dbc7cd1b446fd6a7d35ae5c625f714f0c373fc8cc56dcd9dd36d3d756",
2
+ "sha256": "44d5cab21f32cf4e49f8d3d8bbcfb270282dae4260df4eee3a31fc01a8a3ce09",
3
3
  "registrySha256": "81b53363a440e832672618965540b3e507ae0d93adcc67ec2b93daf7933b3ab4",
4
- "packageVersion": "1.3.1178"
4
+ "packageVersion": "1.3.1179"
5
5
  }
@@ -2,5 +2,5 @@
2
2
  "sha256": "81b53363a440e832672618965540b3e507ae0d93adcc67ec2b93daf7933b3ab4",
3
3
  "articleCount": 88,
4
4
  "generatedFrom": "docs/STANDARDS-REGISTRY.md",
5
- "packageVersion": "1.3.1178"
5
+ "packageVersion": "1.3.1179"
6
6
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "instar",
3
- "version": "1.3.1178",
3
+ "version": "1.3.1179",
4
4
  "description": "Coherence infrastructure for self-evolving AI agents — on the Claude Code or Codex subscription you already have.",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
@@ -54,6 +54,13 @@ import crypto from 'node:crypto';
54
54
  import { fileURLToPath } from 'node:url';
55
55
  import yaml from 'js-yaml';
56
56
  import { articleIds, parseRegistryStructure } from './standards-registry-article-core.mjs';
57
+ import {
58
+ evaluateStandardsDirection,
59
+ readCandidateApproverKey,
60
+ readDirectionApprovalLedger,
61
+ resolveProtectedApproverKey,
62
+ resolveProtectedBaseRegistry,
63
+ } from './standards-direction-guard.mjs';
57
64
  import { parseFrontmatter, validateAuditReport } from './write-audit-convergence.mjs';
58
65
 
59
66
  const __dirname = path.dirname(fileURLToPath(import.meta.url));
@@ -105,6 +112,8 @@ const AREA_AUDITS_PATH = path.join(ROOT, 'docs', 'standards-registry-area-audits
105
112
  const AREA_MODEL_AUDIT_PATH = path.join(ROOT, 'docs', 'standards-registry-area-model-audit.json');
106
113
  const CI_WORKFLOW_PATH = path.join(ROOT, '.github', 'workflows', 'ci.yml');
107
114
  const OUT_PATH = path.join(ROOT, '.instar', 'standards-coverage.json');
115
+ const DIRECTION_APPROVALS_PATH = path.join(ROOT, 'docs', 'standards-direction-approvals.json');
116
+ const DIRECTION_APPROVER_KEY_PATH = path.join(ROOT, '.github', 'keyrings', 'telegram-principal-pub.pem');
108
117
 
109
118
  // ── Hardcoded committed floors (the read baseline; output file is never it) ──
110
119
  const numEnv = (env, def) => {
@@ -575,6 +584,9 @@ function validateRootSelfWiring() {
575
584
  const checkEnv = {
576
585
  STANDARDS_AREA_AUDIT_BASE_FILE: '${{ runner.temp }}/standards-area-audits-base.json',
577
586
  STANDARDS_AREA_AUDIT_BASE_REQUIRED: '${{ steps.area-audit-base.outputs.required }}',
587
+ STANDARDS_DIRECTION_BASE_FILE: '${{ runner.temp }}/standards-registry-base.md',
588
+ STANDARDS_DIRECTION_BASE_APPROVER_KEY_FILE: '${{ runner.temp }}/standards-direction-approver-base.pem',
589
+ STANDARDS_DIRECTION_BASE_REVISION: "${{ github.event.pull_request.base.sha || github.event.before || format('{0}^', github.sha) }}",
578
590
  };
579
591
  if (!exactKeys(job, ['name', 'runs-on', 'steps']) ||
580
592
  job.name !== 'Standards Enforcement Coverage' || job['runs-on'] !== 'ubuntu-latest' ||
@@ -595,6 +607,8 @@ function validateRootSelfWiring() {
595
607
  'else',
596
608
  ' echo "required=0" >> "$GITHUB_OUTPUT"',
597
609
  'fi',
610
+ 'git show "$BASE_SHA:docs/STANDARDS-REGISTRY.md" > "$RUNNER_TEMP/standards-registry-base.md"',
611
+ 'git show "$BASE_SHA:.github/keyrings/telegram-principal-pub.pem" > "$RUNNER_TEMP/standards-direction-approver-base.pem"',
598
612
  '',
599
613
  ].join('\n');
600
614
  const expectedBaseSha = "${{ github.event.pull_request.base.sha || github.event.before || format('{0}^', github.sha) }}";
@@ -1237,9 +1251,70 @@ function compute() {
1237
1251
  capturedSections: 0,
1238
1252
  unrecognizedSections: [],
1239
1253
  },
1254
+ directionGuard: {
1255
+ status: ALLOW_PARTIAL_REGISTRY ? 'not-assessed' : 'not-proven',
1256
+ errors: ALLOW_PARTIAL_REGISTRY ? [] : ['candidate standards registry is unavailable'],
1257
+ changes: [],
1258
+ trustRoot: { origin: 'protected-base', source: null, revision: null, candidateTreeIgnored: true },
1259
+ population: { protectedBase: 0, candidate: 0, continuity: 0, additions: [], removals: [], byFamily: {} },
1260
+ },
1240
1261
  };
1241
1262
  }
1242
1263
 
1264
+ const directionGuard = (() => {
1265
+ if (ALLOW_PARTIAL_REGISTRY) {
1266
+ return {
1267
+ status: 'not-assessed', errors: [], changes: [],
1268
+ trustRoot: { origin: 'not-assessed', source: null, revision: null, candidateTreeIgnored: true },
1269
+ population: { protectedBase: 0, candidate: 0, continuity: 0, additions: [], removals: [], byFamily: {} },
1270
+ };
1271
+ }
1272
+ const base = resolveProtectedBaseRegistry({
1273
+ root: ROOT,
1274
+ explicitFile: Object.hasOwn(process.env, 'STANDARDS_DIRECTION_BASE_FILE')
1275
+ ? process.env.STANDARDS_DIRECTION_BASE_FILE
1276
+ : undefined,
1277
+ explicitRevision: process.env.STANDARDS_DIRECTION_BASE_REVISION,
1278
+ });
1279
+ if (base.errors.length > 0 || base.markdown === null) {
1280
+ return {
1281
+ status: 'not-proven', errors: base.errors, changes: [],
1282
+ trustRoot: { origin: 'protected-base', source: null, revision: null, candidateTreeIgnored: true },
1283
+ population: { protectedBase: 0, candidate: 0, continuity: 0, additions: [], removals: [], byFamily: {} },
1284
+ };
1285
+ }
1286
+ const approvals = readDirectionApprovalLedger(DIRECTION_APPROVALS_PATH);
1287
+ const candidateKey = readCandidateApproverKey(DIRECTION_APPROVER_KEY_PATH);
1288
+ const key = resolveProtectedApproverKey({
1289
+ root: ROOT,
1290
+ explicitFile: Object.hasOwn(process.env, 'STANDARDS_DIRECTION_BASE_APPROVER_KEY_FILE')
1291
+ ? process.env.STANDARDS_DIRECTION_BASE_APPROVER_KEY_FILE
1292
+ : undefined,
1293
+ explicitRevision: process.env.STANDARDS_DIRECTION_BASE_REVISION,
1294
+ });
1295
+ if (key.revision !== base.revision) {
1296
+ key.errors.push('protected-base registry and approver trust root resolved from different revisions');
1297
+ }
1298
+ const assessed = evaluateStandardsDirection({
1299
+ baseMarkdown: base.markdown,
1300
+ candidateMarkdown: markdown,
1301
+ approvalLedger: approvals.value,
1302
+ approverPublicKeyPem: key.pem,
1303
+ candidateApproverPublicKeyPem: candidateKey.pem,
1304
+ baseRevision: base.revision ?? 'unknown-protected-base',
1305
+ });
1306
+ assessed.errors.unshift(...approvals.errors, ...candidateKey.errors, ...key.errors);
1307
+ if (assessed.errors.length > 0) assessed.status = 'not-proven';
1308
+ assessed.trustRoot = {
1309
+ origin: 'protected-base',
1310
+ source: key.source,
1311
+ revision: key.revision,
1312
+ candidateTreeIgnored: true,
1313
+ candidateTreeDriftBlocked: true,
1314
+ };
1315
+ return assessed;
1316
+ })();
1317
+
1243
1318
  const { articles, enforcementScope, areaSha256, areaSectionCounts } = parseRegistry(canonicalText(markdown));
1244
1319
  const routeTable = loadRouteTable();
1245
1320
  const extracted = articles.map((a) => ({ a, refs: extractRefs(a) }));
@@ -1297,7 +1372,9 @@ function compute() {
1297
1372
 
1298
1373
  const total = articles.length;
1299
1374
  const enforced = byKind.ratchet + byKind.gate + byKind.lint;
1300
- const enforcedRatio = total === 0 ? 1 : Number((enforced / total).toFixed(4));
1375
+ const continuityTotal = directionGuard.population?.continuity || total;
1376
+ const currentPopulationEnforcedRatio = total === 0 ? 1 : Number((enforced / total).toFixed(4));
1377
+ const enforcedRatio = continuityTotal === 0 ? 1 : Number((enforced / continuityTotal).toFixed(4));
1301
1378
  const areaNames = [...areaTallies.keys()].sort();
1302
1379
  const loadedAreaAudits = loadAreaAuditLedger(areaNames);
1303
1380
  const loadedAreaModelAudit = loadAreaModelAudit(areaNames);
@@ -1323,7 +1400,9 @@ function compute() {
1323
1400
  const audit = isPlainObject(loadedAreaAudits.ledger?.areas?.[areaName])
1324
1401
  ? loadedAreaAudits.ledger.areas[areaName]
1325
1402
  : null;
1326
- const ratio = tally.total === 0 ? 1 : Number((tally.enforced / tally.total).toFixed(4));
1403
+ const areaContinuityTotal = directionGuard.population?.byFamily?.[areaName]?.continuity || tally.total;
1404
+ const currentPopulationRatio = tally.total === 0 ? 1 : Number((tally.enforced / tally.total).toFixed(4));
1405
+ const ratio = areaContinuityTotal === 0 ? 1 : Number((tally.enforced / areaContinuityTotal).toFixed(4));
1327
1406
  const auditCurrent = typeof audit?.areaSha256 === 'string' && audit.areaSha256 === areaSha256[areaName];
1328
1407
  if (audit && !auditCurrent) {
1329
1408
  areaAuditErrors.push(
@@ -1333,9 +1412,11 @@ function compute() {
1333
1412
  if (auditCurrent) currentAreaAudits += 1;
1334
1413
  areas[areaName] = {
1335
1414
  total: tally.total,
1415
+ continuityTotal: areaContinuityTotal,
1336
1416
  enforced: tally.enforced,
1337
1417
  byKind: tally.byKind,
1338
1418
  refResolutionRatio: ratio,
1419
+ currentPopulationRefResolutionRatio: currentPopulationRatio,
1339
1420
  gaps: tally.gaps,
1340
1421
  currentAreaSha256: areaSha256[areaName],
1341
1422
  lastAuditedAt: typeof audit?.lastAuditedAt === 'string' ? audit.lastAuditedAt : null,
@@ -1365,7 +1446,8 @@ function compute() {
1365
1446
  generatedAt: new Date().toISOString(),
1366
1447
  registryFound: true,
1367
1448
  rootSelfWiring,
1368
- total, byKind, enforcedRatio, gaps, enforcementScope, areas,
1449
+ total, continuityTotal, byKind, enforcedRatio, currentPopulationEnforcedRatio,
1450
+ gaps, enforcementScope, areas, directionGuard,
1369
1451
  areaAudit: {
1370
1452
  status: areaAuditErrors.length === 0 ? 'current' : 'invalid',
1371
1453
  path: path.relative(ROOT, AREA_AUDITS_PATH),
@@ -1470,7 +1552,7 @@ function recordAreaAudit(report, selection, auditRef) {
1470
1552
  if (canonicalTimestamp(oldTimestamp) && Date.parse(lastAuditedAt) < Date.parse(oldTimestamp)) {
1471
1553
  throw new Error(`lastAuditedAt for ${area} may not move backward`);
1472
1554
  }
1473
- const measuredFloor = { enforced: measurement.enforced, total: measurement.total };
1555
+ const measuredFloor = { enforced: measurement.enforced, total: measurement.continuityTotal ?? measurement.total };
1474
1556
  const oldFloor = existing.areas?.[area]?.refResolutionFloor;
1475
1557
  const rebaselining = typeof REBASELINE_REASON === 'string' && REBASELINE_REASON.trim().length > 0;
1476
1558
  const refResolutionFloor = !rebaselining
@@ -1600,15 +1682,21 @@ function main() {
1600
1682
  process.stdout.write(JSON.stringify(report, null, 2) + '\n');
1601
1683
  } else if (!QUIET) {
1602
1684
  console.error(`[standards-coverage] registry=${report.registryFound} total=${report.total} ` +
1685
+ `continuity-total=${report.continuityTotal ?? report.total} ` +
1603
1686
  `enforced-ratio=${report.enforcedRatio} (ratchet ${report.byKind.ratchet} / gate ${report.byKind.gate} / ` +
1604
1687
  `lint ${report.byKind.lint} / spec-only ${report.byKind['spec-only']} / gap ${report.byKind['documented-only']}) ` +
1605
1688
  `false-claims=${report.falseClaimCount} ` +
1606
1689
  `dangling=${report.danglingCount} ` +
1607
1690
  `unrecognized-sections=${report.enforcementScope.unrecognizedSections.length}`);
1608
1691
  console.error(`[standards-coverage] floors: enforced-ratio>=${FLOORS.enforcedRatio} dangling<=${FLOORS.danglingCeiling} false-claims<=${FLOORS.falseClaimCeiling} unrecognized-sections<=${FLOORS.unrecognizedSectionCeiling}`);
1692
+ console.error(`[standards-coverage] direction-guard=${report.directionGuard.status} ` +
1693
+ `base=${report.directionGuard.baseRevision ?? 'not-assessed'} ` +
1694
+ `trust-root=${report.directionGuard.trustRoot?.origin ?? 'unknown'} ` +
1695
+ `candidate-pin-ignored-as-authority=${report.directionGuard.trustRoot?.candidateTreeIgnored === true} ` +
1696
+ `candidate-pin-drift-blocked=${report.directionGuard.trustRoot?.candidateTreeDriftBlocked === true}`);
1609
1697
  for (const [area, measurement] of Object.entries(report.areas)) {
1610
1698
  console.error(
1611
- `[standards-coverage] area="${area}" total=${measurement.total} ` +
1699
+ `[standards-coverage] area="${area}" total=${measurement.total} continuity-total=${measurement.continuityTotal ?? measurement.total} ` +
1612
1700
  `ref-resolution-ratio=${measurement.refResolutionRatio} ` +
1613
1701
  `floor=${measurement.refResolutionFloor ? `${measurement.refResolutionFloor.enforced}/${measurement.refResolutionFloor.total}` : 'missing'} ` +
1614
1702
  `last-audited=${measurement.lastAuditedAt ?? 'missing'} audit-ref=${measurement.auditRef ?? 'missing'} ` +
@@ -1621,6 +1709,9 @@ function main() {
1621
1709
  for (const error of report.areaModelAudit.errors) {
1622
1710
  console.error(`[standards-coverage] AREA MODEL AUDIT — ${error}`);
1623
1711
  }
1712
+ for (const error of report.directionGuard.errors) {
1713
+ console.error(`[standards-coverage] DIRECTION GUARD — ${error}`);
1714
+ }
1624
1715
  for (const fc of report.falseClaims) {
1625
1716
  console.error(`[standards-coverage] FALSE CLAIM — "${fc.standard}" asserts running machinery (${fc.claims.map((c) => `"${c}"`).join(', ')}) but names no resolvable guard.`);
1626
1717
  }
@@ -1629,15 +1720,18 @@ function main() {
1629
1720
  if (CHECK) {
1630
1721
  const failures = [];
1631
1722
  const aggregateEnforced = report.byKind.ratchet + report.byKind.gate + report.byKind.lint;
1632
- if (report.total > 0 && ratioBelowNumericFloor(aggregateEnforced, report.total, FLOORS.enforcedRatio)) {
1633
- failures.push(`enforced ratio ${aggregateEnforced}/${report.total} (${report.enforcedRatio}) < floor ${FLOORS.enforcedRatio}`);
1723
+ const continuityDenominator = report.continuityTotal ?? report.total;
1724
+ if (continuityDenominator > 0 && ratioBelowNumericFloor(aggregateEnforced, continuityDenominator, FLOORS.enforcedRatio)) {
1725
+ failures.push(`enforced ratio ${aggregateEnforced}/${continuityDenominator} (${report.enforcedRatio}) < floor ${FLOORS.enforcedRatio}`);
1634
1726
  }
1727
+ for (const error of report.directionGuard.errors) failures.push(`direction guard: ${error}`);
1635
1728
  for (const error of report.areaAudit.errors) failures.push(error);
1636
1729
  for (const error of report.areaModelAudit.errors) failures.push(error);
1637
1730
  for (const [area, measurement] of Object.entries(report.areas)) {
1638
- if (ratioBelowFloor(measurement.enforced, measurement.total, measurement.refResolutionFloor)) {
1731
+ const areaContinuityDenominator = measurement.continuityTotal ?? measurement.total;
1732
+ if (ratioBelowFloor(measurement.enforced, areaContinuityDenominator, measurement.refResolutionFloor)) {
1639
1733
  failures.push(
1640
- `area "${area}" ref-resolution ratio ${measurement.enforced}/${measurement.total} < floor ` +
1734
+ `area "${area}" ref-resolution ratio ${measurement.enforced}/${areaContinuityDenominator} < floor ` +
1641
1735
  `${measurement.refResolutionFloor.enforced}/${measurement.refResolutionFloor.total}`,
1642
1736
  );
1643
1737
  }
@@ -0,0 +1,439 @@
1
+ // safe-git-allow: CI bootstrap uses read-only merge-base/show before TypeScript is compiled.
2
+ /**
3
+ * Direction-aware constitutional amendment guard.
4
+ *
5
+ * Mechanical facts (article identity, addition, removal, population) come from
6
+ * the protected base and candidate registries. Semantic direction for an edit
7
+ * is declared, then independently ratified with an Ed25519 signature over the
8
+ * exact before/after bytes. A repository file written by the changer is never
9
+ * authority by itself.
10
+ */
11
+ import crypto from 'node:crypto';
12
+ import fs from 'node:fs';
13
+ import { execFileSync } from 'node:child_process';
14
+ import { ARTICLE_ID_RE, articleIds, parseRegistryStructure } from './standards-registry-article-core.mjs';
15
+
16
+ export const DIRECTION_APPROVAL_SCHEMA_VERSION = 1;
17
+ export const DIRECTION_GUARD_SYMBOL = 'evaluateStandardsDirection';
18
+ const FIELD_RE = /^\*\*(.+?)\.\*\*\s*(.*)$/;
19
+ const DIRECTIONS = new Set(['add', 'remove', 'strengthen', 'neutral', 'weaken']);
20
+ const SHA256_RE = /^[a-f0-9]{64}$/;
21
+ const RFC3339_UTC_RE = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/;
22
+
23
+ const canonicalText = (value) => String(value).replace(/\r\n?/g, '\n');
24
+ const sha256 = (value) => crypto.createHash('sha256').update(value).digest('hex');
25
+ const isObject = (value) => value !== null && typeof value === 'object' && !Array.isArray(value);
26
+ const exactKeys = (value, keys) => isObject(value) &&
27
+ JSON.stringify(Object.keys(value).sort()) === JSON.stringify([...keys].sort());
28
+ const familyName = (heading) => heading.split(/\s+[—–-]\s+/)[0].trim();
29
+
30
+ function stableValue(value) {
31
+ if (Array.isArray(value)) return value.map(stableValue);
32
+ if (!isObject(value)) return value;
33
+ return Object.fromEntries(Object.keys(value).sort().map((key) => [key, stableValue(value[key])]));
34
+ }
35
+
36
+ export function canonicalDirectionPayload(payload) {
37
+ return `standards-direction-ratification-v1\0${JSON.stringify(stableValue(payload))}`;
38
+ }
39
+
40
+ function slug(value) {
41
+ return value.normalize('NFKD')
42
+ .replace(/[\u0300-\u036f]/g, '')
43
+ .toLowerCase()
44
+ .replace(/[^a-z0-9]+/g, '-')
45
+ .replace(/^-+|-+$/g, '')
46
+ .slice(0, 80);
47
+ }
48
+
49
+ function fieldsFor(block) {
50
+ const fields = [];
51
+ let current = null;
52
+ const flush = () => {
53
+ if (!current) return;
54
+ fields.push({ heading: current.heading, text: current.lines.join('\n').trim() });
55
+ current = null;
56
+ };
57
+ for (const line of block.visibleLines) {
58
+ if (line === null) continue;
59
+ const match = line.match(FIELD_RE);
60
+ if (match) {
61
+ flush();
62
+ current = { heading: match[1].trim(), lines: [match[2]] };
63
+ } else if (current) current.lines.push(line);
64
+ }
65
+ flush();
66
+ return fields;
67
+ }
68
+
69
+ /** Resolve every Rule-bearing article to a closed, unique identity set. */
70
+ export function inventoryStandardsArticles(markdown) {
71
+ const text = canonicalText(markdown);
72
+ const articles = [];
73
+ const errors = [];
74
+ const identities = new Set();
75
+ let parsedRuleMarkers = 0;
76
+
77
+ for (const section of parseRegistryStructure(text)) {
78
+ const family = familyName(section.heading);
79
+ for (const block of section.blocks) {
80
+ const fields = fieldsFor(block);
81
+ const rules = fields.filter((field) => field.heading === 'Rule');
82
+ if (rules.length === 0) continue;
83
+ parsedRuleMarkers += rules.length;
84
+ if (rules.length !== 1) {
85
+ errors.push(`article "${block.name}" must contain exactly one Rule field (found ${rules.length})`);
86
+ continue;
87
+ }
88
+ const explicitIds = articleIds(block);
89
+ if (explicitIds.length > 1) {
90
+ errors.push(`article "${block.name}" has duplicate Article ID declarations`);
91
+ continue;
92
+ }
93
+ if (explicitIds.length === 1 && !ARTICLE_ID_RE.test(explicitIds[0])) {
94
+ errors.push(`article "${block.name}" has invalid Article ID "${explicitIds[0]}"`);
95
+ continue;
96
+ }
97
+ const id = explicitIds[0] ?? `legacy/${slug(family)}/${slug(block.name)}`;
98
+ if (identities.has(id)) {
99
+ errors.push(`article identity collision for "${id}"`);
100
+ continue;
101
+ }
102
+ identities.add(id);
103
+ const rule = rules[0].text;
104
+ articles.push({
105
+ id,
106
+ family,
107
+ name: block.name,
108
+ rule,
109
+ ruleSha256: sha256(`standards-rule-v1\0${rule}`),
110
+ articleSha256: sha256(`standards-article-v1\0${canonicalText(block.raw)}`),
111
+ });
112
+ }
113
+ }
114
+
115
+ const rawRuleMarkers = text.split('\n').filter((line) => /^\*\*Rule\.\*\*/.test(line)).length;
116
+ if (rawRuleMarkers !== parsedRuleMarkers || parsedRuleMarkers !== articles.length) {
117
+ errors.push(
118
+ `article enumeration is open: raw Rule fields=${rawRuleMarkers}, parsed Rule fields=${parsedRuleMarkers}, identities=${articles.length}`,
119
+ );
120
+ }
121
+ if (articles.length === 0) errors.push('standards article population is empty (NOT-PROVEN, never 0/0 clean)');
122
+ return { articles, errors };
123
+ }
124
+
125
+ function articleSummary(article) {
126
+ if (!article) return null;
127
+ return {
128
+ id: article.id,
129
+ family: article.family,
130
+ name: article.name,
131
+ ruleSha256: article.ruleSha256,
132
+ articleSha256: article.articleSha256,
133
+ };
134
+ }
135
+
136
+ function changeKind(before, after) {
137
+ if (!before) return 'add';
138
+ if (!after) return 'remove';
139
+ return 'edit';
140
+ }
141
+
142
+ function directionLabel(direction, kind) {
143
+ if (kind === 'remove') return 'REMOVAL';
144
+ if (kind === 'add') return 'ADDITION';
145
+ if (direction === 'weaken') return 'WEAKENING';
146
+ if (direction === 'strengthen') return 'STRENGTHENING';
147
+ if (direction === 'neutral') return 'NEUTRAL EDIT';
148
+ return 'DIRECTION UNDECLARED';
149
+ }
150
+
151
+ function validTimestamp(value) {
152
+ if (typeof value !== 'string' || !RFC3339_UTC_RE.test(value)) return false;
153
+ const parsed = new Date(value);
154
+ return !Number.isNaN(parsed.getTime()) && parsed.toISOString() === value;
155
+ }
156
+
157
+ function verifyApprovalSignature(payload, signature, publicKeyPem) {
158
+ if (typeof signature !== 'string' || signature.length < 40 || typeof publicKeyPem !== 'string') return false;
159
+ try {
160
+ const key = crypto.createPublicKey(publicKeyPem);
161
+ return crypto.verify(
162
+ null,
163
+ Buffer.from(canonicalDirectionPayload(payload), 'utf8'),
164
+ key,
165
+ Buffer.from(signature, 'base64'),
166
+ );
167
+ } catch {
168
+ return false;
169
+ }
170
+ }
171
+
172
+ function validateApprovalShape(entry) {
173
+ if (!exactKeys(entry, ['payload', 'signature'])) return 'must contain exactly payload and signature';
174
+ const payload = entry.payload;
175
+ if (!exactKeys(payload, [
176
+ 'schemaVersion', 'baseRevision', 'baseRegistrySha256', 'candidateRegistrySha256',
177
+ 'articleId', 'change', 'direction', 'before', 'after', 'approvedBy', 'approvedAt',
178
+ ])) return 'payload has unknown or missing fields';
179
+ if (payload.schemaVersion !== DIRECTION_APPROVAL_SCHEMA_VERSION) return 'payload schemaVersion is unsupported';
180
+ if (!['add', 'remove', 'edit'].includes(payload.change) || !DIRECTIONS.has(payload.direction)) return 'payload change/direction is invalid';
181
+ if (typeof payload.baseRevision !== 'string' || payload.baseRevision.length < 1 || payload.baseRevision.length > 160) return 'payload baseRevision is invalid';
182
+ if (!SHA256_RE.test(payload.baseRegistrySha256) || !SHA256_RE.test(payload.candidateRegistrySha256)) return 'payload registry digest is invalid';
183
+ if (typeof payload.articleId !== 'string' || payload.articleId.length < 3 || payload.articleId.length > 180) return 'payload articleId is invalid';
184
+ if (typeof payload.approvedBy !== 'string' || payload.approvedBy.trim().length < 2 || payload.approvedBy.length > 120) return 'payload approvedBy is invalid';
185
+ if (!validTimestamp(payload.approvedAt) || Date.parse(payload.approvedAt) > Date.now() + 5 * 60_000) return 'payload approvedAt is invalid';
186
+ for (const [which, summary] of [['before', payload.before], ['after', payload.after]]) {
187
+ if (summary === null) continue;
188
+ if (!exactKeys(summary, ['id', 'family', 'name', 'ruleSha256', 'articleSha256']) ||
189
+ typeof summary.id !== 'string' || typeof summary.family !== 'string' || typeof summary.name !== 'string' ||
190
+ !SHA256_RE.test(summary.ruleSha256) || !SHA256_RE.test(summary.articleSha256)) {
191
+ return `payload ${which} summary is invalid`;
192
+ }
193
+ }
194
+ return null;
195
+ }
196
+
197
+ function parseApprovalLedger(value) {
198
+ const errors = [];
199
+ if (!exactKeys(value, ['schemaVersion', 'approvals']) ||
200
+ value.schemaVersion !== DIRECTION_APPROVAL_SCHEMA_VERSION || !Array.isArray(value.approvals)) {
201
+ return { approvals: [], errors: ['direction approval ledger must contain exactly schemaVersion: 1 and approvals[]'] };
202
+ }
203
+ const approvals = [];
204
+ for (const [index, entry] of value.approvals.entries()) {
205
+ const error = validateApprovalShape(entry);
206
+ if (error) errors.push(`direction approval ${index} ${error}`);
207
+ else approvals.push(entry);
208
+ }
209
+ return { approvals, errors };
210
+ }
211
+
212
+ function familyPopulation(articles) {
213
+ const map = new Map();
214
+ for (const article of articles) {
215
+ if (!map.has(article.family)) map.set(article.family, new Set());
216
+ map.get(article.family).add(article.id);
217
+ }
218
+ return map;
219
+ }
220
+
221
+ /**
222
+ * Evaluate candidate constitutional direction against a protected base.
223
+ * This exact export is imported by the pipeline and its negative-control test.
224
+ */
225
+ export function evaluateStandardsDirection({
226
+ baseMarkdown,
227
+ candidateMarkdown,
228
+ approvalLedger = { schemaVersion: 1, approvals: [] },
229
+ approverPublicKeyPem = '',
230
+ candidateApproverPublicKeyPem = null,
231
+ baseRevision = 'unknown-protected-base',
232
+ }) {
233
+ const base = inventoryStandardsArticles(baseMarkdown);
234
+ const candidate = inventoryStandardsArticles(candidateMarkdown);
235
+ const ledger = parseApprovalLedger(approvalLedger);
236
+ const errors = [
237
+ ...base.errors.map((error) => `protected base: ${error}`),
238
+ ...candidate.errors.map((error) => `candidate: ${error}`),
239
+ ...ledger.errors,
240
+ ];
241
+ if (candidateApproverPublicKeyPem !== null && candidateApproverPublicKeyPem !== approverPublicKeyPem) {
242
+ errors.push(
243
+ 'APPROVER TRUST ROOT CHANGE is not self-authorizable: candidate pin differs from protected base; ' +
244
+ 'bootstrap or rotation requires external protected-main control-plane authorization',
245
+ );
246
+ }
247
+ const baseRegistrySha256 = sha256(canonicalText(baseMarkdown));
248
+ const candidateRegistrySha256 = sha256(canonicalText(candidateMarkdown));
249
+ const baseById = new Map(base.articles.map((article) => [article.id, article]));
250
+ const candidateById = new Map(candidate.articles.map((article) => [article.id, article]));
251
+ const ids = [...new Set([...baseById.keys(), ...candidateById.keys()])].sort();
252
+ const changes = [];
253
+
254
+ for (const id of ids) {
255
+ const before = baseById.get(id) ?? null;
256
+ const after = candidateById.get(id) ?? null;
257
+ if (before && after && before.articleSha256 === after.articleSha256 &&
258
+ before.family === after.family && before.name === after.name) continue;
259
+ changes.push({ id, kind: changeKind(before, after), before, after });
260
+ }
261
+
262
+ const basePopulation = familyPopulation(base.articles);
263
+ const candidatePopulation = familyPopulation(candidate.articles);
264
+ const families = [...new Set([...basePopulation.keys(), ...candidatePopulation.keys()])].sort();
265
+ const byFamily = {};
266
+ for (const family of families) {
267
+ const baseIds = basePopulation.get(family) ?? new Set();
268
+ const candidateIds = candidatePopulation.get(family) ?? new Set();
269
+ byFamily[family] = {
270
+ protectedBase: baseIds.size,
271
+ candidate: candidateIds.size,
272
+ continuity: new Set([...baseIds, ...candidateIds]).size,
273
+ };
274
+ }
275
+
276
+ for (const change of changes) {
277
+ const matching = ledger.approvals.filter(({ payload }) =>
278
+ payload.baseRegistrySha256 === baseRegistrySha256 &&
279
+ payload.candidateRegistrySha256 === candidateRegistrySha256 &&
280
+ payload.articleId === change.id);
281
+ const displayName = change.after?.name ?? change.before?.name ?? change.id;
282
+ if (matching.length === 0) {
283
+ errors.push(
284
+ `${directionLabel(null, change.kind)} "${displayName}" (${change.id}) requires an independently signed direction ratification`,
285
+ );
286
+ continue;
287
+ }
288
+ if (matching.length > 1) {
289
+ errors.push(`article "${displayName}" has multiple ratifications for the same protected-base/candidate pair`);
290
+ continue;
291
+ }
292
+ const approval = matching[0];
293
+ const direction = approval.payload.direction;
294
+ const expectedDirection = change.kind === 'add' ? 'add' : change.kind === 'remove' ? 'remove' : direction;
295
+ const expectedPayload = {
296
+ schemaVersion: DIRECTION_APPROVAL_SCHEMA_VERSION,
297
+ baseRevision,
298
+ baseRegistrySha256,
299
+ candidateRegistrySha256,
300
+ articleId: change.id,
301
+ change: change.kind,
302
+ direction: expectedDirection,
303
+ before: articleSummary(change.before),
304
+ after: articleSummary(change.after),
305
+ approvedBy: approval.payload.approvedBy,
306
+ approvedAt: approval.payload.approvedAt,
307
+ };
308
+ const label = directionLabel(direction, change.kind);
309
+ if (change.kind === 'edit' && !['strengthen', 'neutral', 'weaken'].includes(direction)) {
310
+ errors.push(`${label} "${displayName}" has invalid edit direction "${direction}"`);
311
+ continue;
312
+ }
313
+ if (JSON.stringify(stableValue(approval.payload)) !== JSON.stringify(stableValue(expectedPayload))) {
314
+ errors.push(`${label} "${displayName}" ratification does not bind the exact protected-base/candidate article bytes`);
315
+ continue;
316
+ }
317
+ if (!verifyApprovalSignature(approval.payload, approval.signature, approverPublicKeyPem)) {
318
+ errors.push(`${label} "${displayName}" lacks a valid different-principal signature`);
319
+ continue;
320
+ }
321
+ change.direction = direction;
322
+ change.approvedBy = approval.payload.approvedBy;
323
+ }
324
+
325
+ return {
326
+ status: errors.length === 0 ? 'passed' : 'not-proven',
327
+ errors,
328
+ baseRevision,
329
+ baseRegistrySha256,
330
+ candidateRegistrySha256,
331
+ changes: changes.map((change) => ({
332
+ articleId: change.id,
333
+ name: change.after?.name ?? change.before?.name ?? change.id,
334
+ familyBefore: change.before?.family ?? null,
335
+ familyAfter: change.after?.family ?? null,
336
+ change: change.kind,
337
+ direction: change.direction ?? null,
338
+ approvedBy: change.approvedBy ?? null,
339
+ })),
340
+ population: {
341
+ protectedBase: base.articles.length,
342
+ candidate: candidate.articles.length,
343
+ continuity: new Set([...baseById.keys(), ...candidateById.keys()]).size,
344
+ additions: changes.filter((change) => change.kind === 'add').map((change) => change.id),
345
+ removals: changes.filter((change) => change.kind === 'remove').map((change) => change.id),
346
+ byFamily,
347
+ },
348
+ };
349
+ }
350
+
351
+ function resolveProtectedBaseText({ root, explicitFile, explicitRevision, repoPath, noun, envHint }) {
352
+ if (explicitFile !== undefined) {
353
+ if (typeof explicitFile !== 'string' || explicitFile.length === 0) {
354
+ return { text: null, revision: null, source: 'explicit', errors: [`protected-base ${noun} path is empty`] };
355
+ }
356
+ try {
357
+ const stat = fs.lstatSync(explicitFile);
358
+ if (!stat.isFile() || stat.isSymbolicLink()) throw new Error('not a regular file');
359
+ return {
360
+ text: fs.readFileSync(explicitFile, 'utf8'),
361
+ revision: explicitRevision || 'explicit-protected-base',
362
+ source: explicitFile,
363
+ errors: [],
364
+ };
365
+ } catch (error) {
366
+ return { text: null, revision: null, source: explicitFile, errors: [`protected-base ${noun} is unavailable: ${error instanceof Error ? error.message : String(error)}`] };
367
+ }
368
+ }
369
+
370
+ for (const ref of ['refs/remotes/upstream/main', 'refs/remotes/origin/main']) {
371
+ try {
372
+ const revision = execFileSync('git', ['merge-base', 'HEAD', ref], { cwd: root, encoding: 'utf8' }).trim();
373
+ if (!revision) continue;
374
+ const text = execFileSync('git', ['show', `${revision}:${repoPath}`], { cwd: root, encoding: 'utf8' });
375
+ return { text, revision, source: ref, errors: [] };
376
+ } catch {
377
+ // Try the next protected main ref. Failure of all candidates is loud below.
378
+ }
379
+ }
380
+ return {
381
+ text: null,
382
+ revision: null,
383
+ source: null,
384
+ errors: [`protected-base ${noun} is unavailable (set ${envHint} or fetch upstream/main/origin/main)`],
385
+ };
386
+ }
387
+
388
+ /** Fail-closed protected-base registry acquisition for the real pipeline. */
389
+ export function resolveProtectedBaseRegistry({ root, explicitFile, explicitRevision }) {
390
+ const result = resolveProtectedBaseText({
391
+ root,
392
+ explicitFile,
393
+ explicitRevision,
394
+ repoPath: 'docs/STANDARDS-REGISTRY.md',
395
+ noun: 'registry',
396
+ envHint: 'STANDARDS_DIRECTION_BASE_FILE',
397
+ });
398
+ return { ...result, markdown: result.text };
399
+ }
400
+
401
+ /** The approver pin is always read from the same protected base as the registry. */
402
+ export function resolveProtectedApproverKey({ root, explicitFile, explicitRevision }) {
403
+ const result = resolveProtectedBaseText({
404
+ root,
405
+ explicitFile,
406
+ explicitRevision,
407
+ repoPath: '.github/keyrings/telegram-principal-pub.pem',
408
+ noun: 'approver trust root',
409
+ envHint: 'STANDARDS_DIRECTION_BASE_APPROVER_KEY_FILE',
410
+ });
411
+ return { ...result, pem: result.text };
412
+ }
413
+
414
+ /** Candidate pin bytes are observed only to refuse drift; they never verify a signature. */
415
+ export function readCandidateApproverKey(file) {
416
+ try {
417
+ const stat = fs.lstatSync(file);
418
+ if (!stat.isFile() || stat.isSymbolicLink()) throw new Error('not a regular file');
419
+ return { pem: fs.readFileSync(file, 'utf8'), errors: [] };
420
+ } catch (error) {
421
+ return {
422
+ pem: null,
423
+ errors: [`candidate approver trust root is unavailable: ${error instanceof Error ? error.message : String(error)}`],
424
+ };
425
+ }
426
+ }
427
+
428
+ export function readDirectionApprovalLedger(file) {
429
+ try {
430
+ const stat = fs.lstatSync(file);
431
+ if (!stat.isFile() || stat.isSymbolicLink()) throw new Error('not a regular file');
432
+ return { value: JSON.parse(fs.readFileSync(file, 'utf8')), errors: [] };
433
+ } catch (error) {
434
+ return {
435
+ value: { schemaVersion: 1, approvals: [] },
436
+ errors: [`direction approval ledger is unavailable or malformed: ${error instanceof Error ? error.message : String(error)}`],
437
+ };
438
+ }
439
+ }
@@ -1,8 +1,8 @@
1
1
  {
2
2
  "$schema": "./builtin-manifest.schema.json",
3
3
  "schemaVersion": 1,
4
- "generatedAt": "2026-08-17T20:03:01.517Z",
5
- "instarVersion": "1.3.1178",
4
+ "generatedAt": "2026-08-17T20:39:01.790Z",
5
+ "instarVersion": "1.3.1179",
6
6
  "entryCount": 202,
7
7
  "entries": {
8
8
  "hook:session-start": {
@@ -2,7 +2,7 @@
2
2
  "schemaVersion": 1,
3
3
  "generatedFrom": "source-tree",
4
4
  "registrySha256": "81b53363a440e832672618965540b3e507ae0d93adcc67ec2b93daf7933b3ab4",
5
- "packageVersion": "1.3.1178",
5
+ "packageVersion": "1.3.1179",
6
6
  "guards": [
7
7
  {
8
8
  "ref": "docs/audits/phase-b/f10-triage.md",
@@ -1,5 +1,5 @@
1
1
  {
2
- "sha256": "f029879dbc7cd1b446fd6a7d35ae5c625f714f0c373fc8cc56dcd9dd36d3d756",
2
+ "sha256": "44d5cab21f32cf4e49f8d3d8bbcfb270282dae4260df4eee3a31fc01a8a3ce09",
3
3
  "registrySha256": "81b53363a440e832672618965540b3e507ae0d93adcc67ec2b93daf7933b3ab4",
4
- "packageVersion": "1.3.1178"
4
+ "packageVersion": "1.3.1179"
5
5
  }
@@ -2,5 +2,5 @@
2
2
  "sha256": "81b53363a440e832672618965540b3e507ae0d93adcc67ec2b93daf7933b3ab4",
3
3
  "articleCount": 88,
4
4
  "generatedFrom": "docs/STANDARDS-REGISTRY.md",
5
- "packageVersion": "1.3.1178"
5
+ "packageVersion": "1.3.1179"
6
6
  }
@@ -0,0 +1,55 @@
1
+ # Upgrade Guide — vNEXT
2
+
3
+ <!-- assembled-by: assemble-next-md -->
4
+ <!-- bump: patch -->
5
+
6
+ ## What Changed
7
+
8
+ The standards-coverage pipeline now treats every Rule-bearing article as a
9
+ stable identity across protected main and a candidate change. Additions and
10
+ removals are mechanical facts; edits carry an explicit direction ratified by an
11
+ independently held Ed25519 key over the exact before/after bytes. Aggregate and
12
+ family floors retain removed identities in their continuity denominator, so a
13
+ smaller constitution cannot improve its score.
14
+
15
+ The approver public key is read from protected main. Candidate key replacement
16
+ is never signature authority, and candidate pin drift is refused even when the
17
+ registry is unchanged, closing both one-change and two-change goalpost moves.
18
+ Legacy heading renames intentionally appear as remove-plus-add and require the
19
+ same independent review.
20
+
21
+ ## What to Tell Your User
22
+
23
+ This is an internal governance hardening change. It does not add or alter an
24
+ end-user action. It makes changes to Instar's own rulebook accountable: removing
25
+ or weakening a rule can no longer make the score look better or pass on the
26
+ changer's word alone.
27
+
28
+ ## Summary of New Capabilities
29
+
30
+ | Capability | How to Use |
31
+ |-----------|------------|
32
+ | Direction-aware rulebook review | Automatic in fleet pull-request checks |
33
+ | Removal-safe standards scoring | Automatic in the standards coverage report |
34
+ | Protected approver-key continuity | Automatic; candidate key drift is refused |
35
+
36
+ ## Evidence
37
+
38
+ On the live release baseline, deleting one article raised coverage from 0.7386
39
+ to 0.7471 and a foundational weakening passed after the changer refreshed its
40
+ own family attestation. Through the wired pipeline after this change, deletion
41
+ reports REMOVAL while retaining 65/88, weakening reports WEAKENING after the old
42
+ refresh, candidate key/signature replacement is refused, and a pin-only change
43
+ is refused before it can become the next trust root. The pristine pipeline
44
+ passes; the focused suites pass 53/53, including all four negative controls. The
45
+ type-preserving hollow compiles, runs all three behavioral contracts, and loses
46
+ on three assertion mismatches.
47
+
48
+ ## Known Limits
49
+
50
+ The repository currently carries a comments-only approver-key placeholder, so
51
+ standards amendments fail closed until a separately authorized protected-main
52
+ control-plane action installs a real public key. Its private key must remain
53
+ outside the repository, agent-readable credential stores, and build environments.
54
+ Cryptography proves who ratified exact bytes; it does not prove that the human
55
+ semantic judgment was wise.
@@ -0,0 +1,154 @@
1
+ # Side-Effects Review — Standards Direction Guard
2
+
3
+ **Version / slug:** `phaseb-s5-rule-direction`
4
+ **Date:** `2026-08-17`
5
+ **Author:** Instar-codey
6
+ **Second-pass reviewer:** pending independent reviewer entry below
7
+
8
+ ## Summary of the change
9
+
10
+ This change adds `scripts/standards-direction-guard.mjs`, integrates it into
11
+ `scripts/standards-coverage.mjs` and CI, stores direction approvals in
12
+ `docs/standards-direction-approvals.json`, and adds focused behavioral and
13
+ negative-control tests. Standards-change acceptance now compares stable article
14
+ identity against protected main, preserves removed identities in coverage
15
+ denominators, and requires exact independently signed direction ratification.
16
+
17
+ ## Decision-point inventory
18
+
19
+ - `evaluateStandardsDirection` — **add** — accepts or refuses additions,
20
+ removals, and edits against protected-base identities and signatures.
21
+ - `standards-coverage --check` — **modify** — consumes the direction result and
22
+ uses continuity denominators for aggregate and family floors.
23
+ - CI standards job — **modify** — extracts protected-base registry and approver
24
+ pin before invoking the existing check entry point.
25
+
26
+ ---
27
+
28
+ ## 1. Over-block
29
+
30
+ An ordinary heading rename on one of the 86 legacy articles changes its derived
31
+ identity and is treated as remove-plus-add, so it needs independent ratification.
32
+ Formatting anywhere inside an article also changes its article hash and needs a
33
+ declared, signed direction. These are known conservative costs: permissive rename
34
+ inference would reopen the identity-erasure path. With the repository's current
35
+ comments-only key placeholder, every standards amendment fails closed until a
36
+ real independently controlled public key is installed on protected main.
37
+
38
+ ## 2. Under-block
39
+
40
+ A legitimate approver can ratify a direction declaration that is semantically
41
+ wrong; cryptography proves principal and bytes, not judgment quality. If the
42
+ approver private key becomes readable to the changer, the principal separation
43
+ collapses. The operational requirement is explicit: keep the private key outside
44
+ the repository, agent credential stores, and build environments. Candidate key
45
+ replacement is closed because the pin is read from protected main and all
46
+ candidate pin drift is refused, including a pin-only first step.
47
+
48
+ ## 3. Level-of-abstraction fit
49
+
50
+ The guard operates at the correct split. Stable identity, before/after hashes,
51
+ population union, signature validity, and protected-base acquisition are closed
52
+ mechanical facts. Semantic direction remains a human declaration. The code does
53
+ not build a brittle prose classifier and does not compete with an LLM authority.
54
+
55
+ ## 4. Signal vs authority compliance
56
+
57
+ **Required reference:** [docs/signal-vs-authority.md](../../docs/signal-vs-authority.md)
58
+
59
+ - [ ] No — this change produces a signal consumed by an existing smart gate.
60
+ - [ ] No — this change has no block/allow surface.
61
+ - [ ] Yes — but the logic is a smart gate with full conversational context.
62
+ - [x] Deterministic hard-invariant authority over a closed governance protocol.
63
+
64
+ The generic template's first three safe boxes do not describe this case. The
65
+ guard has blocking authority, but it does not judge prose with brittle logic.
66
+ It verifies enumerable invariants: exact identities and hashes, allowed direction
67
+ tokens, signature validity, and protected-base provenance. Human semantic judgment
68
+ arrives as a signed declaration. This is the principle document's deterministic
69
+ policy-evaluator / hard-invariant exception, not a message-meaning heuristic.
70
+
71
+ ## 4b. Judgment-point check
72
+
73
+ No static heuristic is added at a competing-signals judgment point. The only
74
+ semantic choice, direction, is explicitly made and signed by an independent
75
+ principal. Code checks the declaration's closed protocol and never infers the
76
+ choice from competing evidence.
77
+
78
+ ## 5. Interactions
79
+
80
+ - **Shadowing:** the direction guard runs alongside the existing area-audit and
81
+ coverage floors. It adds errors; it does not prevent their evaluation or logs.
82
+ - **Double-fire:** a standards edit can produce both a stale area-audit objection
83
+ and a direction objection. This is intentional: one binds review freshness,
84
+ the other binds amendment direction and independent authority. Refreshing the
85
+ first does not clear the second.
86
+ - **Races:** all inputs are immutable Git bytes or a candidate JSON file during a
87
+ single CI process. There is no shared mutable runtime state.
88
+ - **Feedback loops:** none. The check never writes its approval ledger or registry.
89
+
90
+ ## 6. External surfaces
91
+
92
+ CI output gains direction status, protected-base revision, trust-root provenance,
93
+ an explicit candidate-pin-not-authoritative fact, and a pin-drift-blocked fact.
94
+ Standards authors gain a signed JSON approval record. No Telegram, Slack,
95
+ Cloudflare, database, conversation, timing, or user data surface changes. No
96
+ operator-facing action is added; key custody and protected-main pin installation
97
+ remain repository governance operations.
98
+
99
+ ## 6b. Operator-surface quality
100
+
101
+ No operator surface — not applicable.
102
+
103
+ ## 7. Multi-machine posture
104
+
105
+ **Replicated through Git.** The registry, candidate ledger, public trust pin,
106
+ guard, CI wiring, and tests are committed bytes. Every machine on the same commit
107
+ derives the same canonical hashes and verdict. The signing private key is
108
+ deliberately not replicated to agent machines. The feature emits no user-facing
109
+ notices, holds no runtime durable state that can strand on topic transfer, and
110
+ generates no URLs.
111
+
112
+ ## 8. Rollback cost
113
+
114
+ Revert the guard, coverage integration, workflow extraction, ledger, docs, and
115
+ tests as one patch and ship the next release. No data migration, agent-state
116
+ repair, secret rotation, or runtime cleanup is required. During rollback the old
117
+ self-attestation weakness returns, so rulebook amendments should remain paused.
118
+
119
+ ## Conclusion
120
+
121
+ The review found two deliberate friction points and no accidental runtime side
122
+ effect: legacy renames require ratification, and the comments-only protected pin
123
+ blocks amendments until independent custody is provisioned. The candidate-pin
124
+ goalpost attack is closed in both one-change and two-change forms by protected-base
125
+ verification plus an unconditional candidate-drift refusal. Normal CI can never
126
+ bootstrap or rotate the pin; that requires separate protected-main control-plane
127
+ authority. The change is ready for normal CI.
128
+
129
+ ## Second-pass review
130
+
131
+ **Reviewer:** independent Codex second-pass lane
132
+ **Independent read of the artifact:** concern resolved. The first review found
133
+ that protected-base verification stopped a same-change pin swap but allowed a
134
+ pin-only first step to become the next base. It also found the ELI16 overstated
135
+ malformed-placeholder behavior. Candidate pin drift is now always refused, the
136
+ pin-only regression is tested, bootstrap/rotation authority is stated explicitly,
137
+ and the malformed-key wording now matches the live pipeline. With those changes,
138
+ the review concurs on authority separation, denominators, rename friction,
139
+ multi-machine posture, and rollback.
140
+
141
+ ## Evidence pointers
142
+
143
+ - `scratchpad/phaseB/REPORT-S5.md`
144
+ - `tests/unit/standards-direction-guard.test.ts`
145
+ - `tests/unit/standards-direction-guard-contract.test.ts`
146
+ - `tests/unit/standards-coverage-ratchet.test.ts`
147
+
148
+ ## Class-Closure Declaration
149
+
150
+ `defectClass: claim-vs-evidence`, `closure: guard`, `guardEvidence:
151
+ { enforcementType: gate, citation: scripts/standards-direction-guard.mjs#evaluateStandardsDirection,
152
+ howCaught: the exact old self-authored family refresh has no independently signed
153
+ direction record, so deletion and weakening remain refused even after that claim
154
+ is refreshed }`.