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.
- package/dist/data/standards-guard-index.json +1 -1
- package/dist/data/standards-guard-index.meta.json +2 -2
- package/dist/data/standards-registry.meta.json +1 -1
- package/package.json +1 -1
- package/scripts/standards-coverage.mjs +103 -9
- package/scripts/standards-direction-guard.mjs +439 -0
- package/src/data/builtin-manifest.json +2 -2
- package/src/data/standards-guard-index.json +1 -1
- package/src/data/standards-guard-index.meta.json +2 -2
- package/src/data/standards-registry.meta.json +1 -1
- package/upgrades/1.3.1179.md +55 -0
- package/upgrades/side-effects/phaseb-s5-rule-direction.md +154 -0
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
"schemaVersion": 1,
|
|
3
3
|
"generatedFrom": "source-tree",
|
|
4
4
|
"registrySha256": "81b53363a440e832672618965540b3e507ae0d93adcc67ec2b93daf7933b3ab4",
|
|
5
|
-
"packageVersion": "1.3.
|
|
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": "
|
|
2
|
+
"sha256": "44d5cab21f32cf4e49f8d3d8bbcfb270282dae4260df4eee3a31fc01a8a3ce09",
|
|
3
3
|
"registrySha256": "81b53363a440e832672618965540b3e507ae0d93adcc67ec2b93daf7933b3ab4",
|
|
4
|
-
"packageVersion": "1.3.
|
|
4
|
+
"packageVersion": "1.3.1179"
|
|
5
5
|
}
|
package/package.json
CHANGED
|
@@ -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
|
|
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
|
|
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,
|
|
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
|
-
|
|
1633
|
-
|
|
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
|
-
|
|
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}/${
|
|
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:
|
|
5
|
-
"instarVersion": "1.3.
|
|
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.
|
|
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": "
|
|
2
|
+
"sha256": "44d5cab21f32cf4e49f8d3d8bbcfb270282dae4260df4eee3a31fc01a8a3ce09",
|
|
3
3
|
"registrySha256": "81b53363a440e832672618965540b3e507ae0d93adcc67ec2b93daf7933b3ab4",
|
|
4
|
-
"packageVersion": "1.3.
|
|
4
|
+
"packageVersion": "1.3.1179"
|
|
5
5
|
}
|
|
@@ -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 }`.
|