instar 1.3.1189 → 1.3.1190
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 +44 -2
- package/scripts/standards-direction-guard.mjs +104 -1
- 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.1190.md +28 -0
- package/upgrades/direction-guard-review-approval.eli16.md +43 -0
- package/upgrades/side-effects/direction-guard-review-approval.md +80 -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.1190",
|
|
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": "e2275b904b85c7db59a699f899b730f164b89ca204813d9766d5b1c5233a92ba",
|
|
3
3
|
"registrySha256": "81b53363a440e832672618965540b3e507ae0d93adcc67ec2b93daf7933b3ab4",
|
|
4
|
-
"packageVersion": "1.3.
|
|
4
|
+
"packageVersion": "1.3.1190"
|
|
5
5
|
}
|
package/package.json
CHANGED
|
@@ -56,6 +56,7 @@ import yaml from 'js-yaml';
|
|
|
56
56
|
import { articleIds, parseRegistryStructure } from './standards-registry-article-core.mjs';
|
|
57
57
|
import {
|
|
58
58
|
evaluateStandardsDirection,
|
|
59
|
+
evaluateOperatorReviewApproval,
|
|
59
60
|
inventoryStandardsArticles,
|
|
60
61
|
readCandidateApproverKey,
|
|
61
62
|
readDirectionApprovalLedger,
|
|
@@ -599,6 +600,12 @@ function validateRootSelfWiring() {
|
|
|
599
600
|
STANDARDS_DIRECTION_BASE_FILE: '${{ runner.temp }}/standards-registry-base.md',
|
|
600
601
|
STANDARDS_DIRECTION_BASE_APPROVER_KEY_FILE: '${{ runner.temp }}/standards-direction-approver-base.pem',
|
|
601
602
|
STANDARDS_DIRECTION_BASE_REVISION: "${{ github.event.pull_request.base.sha || github.event.before || format('{0}^', github.sha) }}",
|
|
603
|
+
// Path B (2026-08-22): the operator-review context the guard reads. Pinned
|
|
604
|
+
// here for the same reason as the others — this contract is deliberately
|
|
605
|
+
// EXACT so the CI wiring cannot be quietly rearranged, and adding an input
|
|
606
|
+
// to the check must therefore be a visible, declared edit rather than a
|
|
607
|
+
// silent one. The guard caught exactly that when this key was introduced.
|
|
608
|
+
STANDARDS_DIRECTION_REVIEW_FILE: '${{ runner.temp }}/standards-direction-review.json',
|
|
602
609
|
};
|
|
603
610
|
if (!exactKeys(job, ['name', 'runs-on', 'steps']) ||
|
|
604
611
|
job.name !== 'Standards Enforcement Coverage' || job['runs-on'] !== 'ubuntu-latest' ||
|
|
@@ -611,6 +618,11 @@ function validateRootSelfWiring() {
|
|
|
611
618
|
const setupStep = steps.find((step) => step?.uses === 'actions/setup-node@v4');
|
|
612
619
|
const installStep = steps.find((step) => step?.run === 'npm ci --ignore-scripts');
|
|
613
620
|
const baseStep = steps.find((step) => step?.id === 'area-audit-base');
|
|
621
|
+
// Path B's evidence-gathering step. Pinned into the ordered prefix so it
|
|
622
|
+
// cannot be dropped without this check failing — an absent step would make
|
|
623
|
+
// every operator approval read as UNAVAILABLE, which fails safe but would
|
|
624
|
+
// silently return the operator to needing a key.
|
|
625
|
+
const reviewStep = steps.find((step) => step?.name === 'Fetch operator review context (direction guard path B)');
|
|
614
626
|
const expectedBaseRun = [
|
|
615
627
|
'git cat-file -e "$BASE_SHA^{commit}"',
|
|
616
628
|
'if git cat-file -e "$BASE_SHA:docs/standards-registry-area-audits.json"; then',
|
|
@@ -624,7 +636,7 @@ function validateRootSelfWiring() {
|
|
|
624
636
|
'',
|
|
625
637
|
].join('\n');
|
|
626
638
|
const expectedBaseSha = "${{ github.event.pull_request.base.sha || github.event.before || format('{0}^', github.sha) }}";
|
|
627
|
-
const exactPrefix = [checkoutStep, setupStep, installStep, baseStep, checkStep];
|
|
639
|
+
const exactPrefix = [checkoutStep, setupStep, installStep, baseStep, reviewStep, checkStep];
|
|
628
640
|
const ordered = exactPrefix.every((step, index) => step && steps[index] === step);
|
|
629
641
|
const protectedBaseWired = ordered &&
|
|
630
642
|
exactKeys(checkoutStep, ['uses', 'with']) && exactKeys(checkoutStep.with, ['fetch-depth']) && checkoutStep.with['fetch-depth'] === 0 &&
|
|
@@ -633,7 +645,11 @@ function validateRootSelfWiring() {
|
|
|
633
645
|
exactKeys(baseStep, ['name', 'id', 'env', 'run']) &&
|
|
634
646
|
baseStep.name === 'Resolve protected-base area ledger' &&
|
|
635
647
|
exactKeys(baseStep.env, ['BASE_SHA']) && baseStep.env.BASE_SHA === expectedBaseSha &&
|
|
636
|
-
baseStep.run === expectedBaseRun
|
|
648
|
+
baseStep.run === expectedBaseRun &&
|
|
649
|
+
exactKeys(reviewStep, ['name', 'if', 'env', 'run']) &&
|
|
650
|
+
reviewStep.if === "github.event_name == 'pull_request'" &&
|
|
651
|
+
exactKeys(reviewStep.env, ['GH_TOKEN', 'PR', 'HEAD_SHA', 'OWNER_LOGIN', 'OWNER_TYPE', 'PR_AUTHOR', 'OUT']) &&
|
|
652
|
+
reviewStep.env.OUT === '${{ runner.temp }}/standards-direction-review.json';
|
|
637
653
|
if (!protectedBaseWired) {
|
|
638
654
|
errors.push('The Root self-wiring requires dependency install plus full-history protected-base extraction and required base env on the standards check');
|
|
639
655
|
}
|
|
@@ -1317,6 +1333,31 @@ function compute() {
|
|
|
1317
1333
|
if (key.revision !== base.revision) {
|
|
1318
1334
|
key.errors.push('protected-base registry and approver trust root resolved from different revisions');
|
|
1319
1335
|
}
|
|
1336
|
+
// PATH B context (2026-08-22). The WORKFLOW fetches the reviews and writes
|
|
1337
|
+
// them here, so this script performs no network I/O and stays testable.
|
|
1338
|
+
// Absent, unreadable or malformed => UNAVAILABLE, which falls back to
|
|
1339
|
+
// requiring a signed ratification. It never becomes an approval.
|
|
1340
|
+
const review = (() => {
|
|
1341
|
+
const file = process.env.STANDARDS_DIRECTION_REVIEW_FILE;
|
|
1342
|
+
if (!file) return null;
|
|
1343
|
+
let raw;
|
|
1344
|
+
try {
|
|
1345
|
+
const stat = fs.lstatSync(file);
|
|
1346
|
+
if (!stat.isFile() || stat.isSymbolicLink()) throw new Error('not a regular file');
|
|
1347
|
+
raw = JSON.parse(fs.readFileSync(file, 'utf8'));
|
|
1348
|
+
} catch (err) {
|
|
1349
|
+
return { approved: false, approvedBy: null, reason: `review context unreadable (${err.message})` };
|
|
1350
|
+
}
|
|
1351
|
+
if (!isPlainObject(raw)) return { approved: false, approvedBy: null, reason: 'review context is not an object' };
|
|
1352
|
+
return evaluateOperatorReviewApproval({
|
|
1353
|
+
reviews: raw.reviews,
|
|
1354
|
+
ownerLogin: raw.ownerLogin,
|
|
1355
|
+
ownerType: raw.ownerType,
|
|
1356
|
+
headSha: raw.headSha,
|
|
1357
|
+
prAuthorLogin: raw.prAuthorLogin,
|
|
1358
|
+
});
|
|
1359
|
+
})();
|
|
1360
|
+
|
|
1320
1361
|
const assessed = evaluateStandardsDirection({
|
|
1321
1362
|
baseMarkdown: base.markdown,
|
|
1322
1363
|
candidateMarkdown: markdown,
|
|
@@ -1324,6 +1365,7 @@ function compute() {
|
|
|
1324
1365
|
approverPublicKeyPem: key.pem,
|
|
1325
1366
|
candidateApproverPublicKeyPem: candidateKey.pem,
|
|
1326
1367
|
baseRevision: base.revision ?? 'unknown-protected-base',
|
|
1368
|
+
reviewApproval: review,
|
|
1327
1369
|
});
|
|
1328
1370
|
assessed.errors.unshift(...approvals.errors, ...candidateKey.errors, ...key.errors);
|
|
1329
1371
|
if (assessed.errors.length > 0) assessed.status = 'not-proven';
|
|
@@ -218,6 +218,87 @@ function familyPopulation(articles) {
|
|
|
218
218
|
return map;
|
|
219
219
|
}
|
|
220
220
|
|
|
221
|
+
/**
|
|
222
|
+
* PATH B — an approving GitHub review from the repository owner.
|
|
223
|
+
*
|
|
224
|
+
* WHY THIS EXISTS (2026-08-22, topic 52222). Path A (an Ed25519 ratification)
|
|
225
|
+
* requires the operator to create, guard for years, and use a private key from
|
|
226
|
+
* a laptop. That key was never created, so NO registry change merged between
|
|
227
|
+
* this guard landing on 2026-08-17 and today. A guard nobody can satisfy stops
|
|
228
|
+
* the work it was meant to govern rather than the risk.
|
|
229
|
+
*
|
|
230
|
+
* WHAT MAKES THIS SOUND, AND WHAT DOES NOT. An adversarial review of the first
|
|
231
|
+
* draft established that no check the agent writes can constrain the agent: it
|
|
232
|
+
* authors this file and (before the ruleset changed) could push straight to
|
|
233
|
+
* main. So THIS FUNCTION IS NOT THE SECURITY BOUNDARY. The boundary is the
|
|
234
|
+
* repository ruleset — require-PR plus CODEOWNERS naming the operator on
|
|
235
|
+
* docs/STANDARDS-REGISTRY.md — which needs admin rights the agent does not
|
|
236
|
+
* hold. What this function adds is LEGIBILITY: it tells the pipeline, and the
|
|
237
|
+
* reader, which articles changed and whether the operator's approval is still
|
|
238
|
+
* attached to the bytes currently proposed. Removing it would not remove the
|
|
239
|
+
* protection; weakening it would not weaken the protection.
|
|
240
|
+
*
|
|
241
|
+
* Pure — the caller supplies the review list, so this stays testable and does
|
|
242
|
+
* no network I/O. Every uncertainty resolves to NOT approved.
|
|
243
|
+
*/
|
|
244
|
+
export function evaluateOperatorReviewApproval({
|
|
245
|
+
reviews = null,
|
|
246
|
+
ownerLogin = null,
|
|
247
|
+
ownerType = null,
|
|
248
|
+
headSha = null,
|
|
249
|
+
prAuthorLogin = null,
|
|
250
|
+
} = {}) {
|
|
251
|
+
const no = (reason) => ({ approved: false, approvedBy: null, reason });
|
|
252
|
+
|
|
253
|
+
// An organization owner names no individual. Falling back to an in-repo
|
|
254
|
+
// allowlist here would reintroduce a trust root the agent can edit, which is
|
|
255
|
+
// the whole defect this design was rewritten around — so this is UNAVAILABLE,
|
|
256
|
+
// never a softer check.
|
|
257
|
+
if (ownerType !== 'User') return no(`owner is not an individual (type: ${ownerType ?? 'unknown'})`);
|
|
258
|
+
if (typeof ownerLogin !== 'string' || ownerLogin.length === 0) return no('owner login unavailable');
|
|
259
|
+
if (typeof headSha !== 'string' || !/^[0-9a-f]{40}$/i.test(headSha)) return no('head sha unavailable or malformed');
|
|
260
|
+
if (!Array.isArray(reviews)) return no('review list unavailable');
|
|
261
|
+
|
|
262
|
+
// The agent must not be able to approve its own change. GitHub already
|
|
263
|
+
// forbids this; checking it here too means the property does not depend on
|
|
264
|
+
// one platform behaviour staying the way it is today.
|
|
265
|
+
if (typeof prAuthorLogin === 'string' && prAuthorLogin.toLowerCase() === ownerLogin.toLowerCase()) {
|
|
266
|
+
return no('the pull request author is the owner — self-approval is not a different principal');
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
const ownerReviews = reviews
|
|
270
|
+
.filter((r) => r && typeof r === 'object' && typeof r.user?.login === 'string' &&
|
|
271
|
+
r.user.login.toLowerCase() === ownerLogin.toLowerCase())
|
|
272
|
+
.map((r) => ({
|
|
273
|
+
state: String(r.state || '').toUpperCase(),
|
|
274
|
+
commitId: typeof r.commit_id === 'string' ? r.commit_id : '',
|
|
275
|
+
at: typeof r.submitted_at === 'string' ? Date.parse(r.submitted_at) : NaN,
|
|
276
|
+
}))
|
|
277
|
+
// A review with no parseable timestamp cannot be ordered against a possible
|
|
278
|
+
// later withdrawal, so it is dropped rather than trusted.
|
|
279
|
+
.filter((r) => Number.isFinite(r.at))
|
|
280
|
+
.sort((a, b) => a.at - b.at);
|
|
281
|
+
|
|
282
|
+
if (ownerReviews.length === 0) return no(`no review from ${ownerLogin}`);
|
|
283
|
+
|
|
284
|
+
const onHead = ownerReviews.filter((r) => r.commitId.toLowerCase() === headSha.toLowerCase());
|
|
285
|
+
const approval = [...onHead].reverse().find((r) => r.state === 'APPROVED');
|
|
286
|
+
if (!approval) {
|
|
287
|
+
const stale = ownerReviews.some((r) => r.state === 'APPROVED');
|
|
288
|
+
return no(stale
|
|
289
|
+
? `${ownerLogin} approved an earlier commit, not the current head — the proposed bytes changed after approval`
|
|
290
|
+
: `no APPROVED review from ${ownerLogin} on the current head`);
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
// A later withdrawal wins. DISMISSED is included because a dismissed approval
|
|
294
|
+
// is an approval the platform has already retracted.
|
|
295
|
+
const withdrawn = ownerReviews.find((r) => r.at >= approval.at &&
|
|
296
|
+
(r.state === 'CHANGES_REQUESTED' || r.state === 'DISMISSED'));
|
|
297
|
+
if (withdrawn) return no(`${ownerLogin} withdrew the approval (${withdrawn.state.toLowerCase()})`);
|
|
298
|
+
|
|
299
|
+
return { approved: true, approvedBy: ownerLogin, reason: `approved by ${ownerLogin} on the current head` };
|
|
300
|
+
}
|
|
301
|
+
|
|
221
302
|
/**
|
|
222
303
|
* Evaluate candidate constitutional direction against a protected base.
|
|
223
304
|
* This exact export is imported by the pipeline and its negative-control test.
|
|
@@ -229,6 +310,12 @@ export function evaluateStandardsDirection({
|
|
|
229
310
|
approverPublicKeyPem = '',
|
|
230
311
|
candidateApproverPublicKeyPem = null,
|
|
231
312
|
baseRevision = 'unknown-protected-base',
|
|
313
|
+
// PATH B (2026-08-22). The result of evaluateOperatorReviewApproval, or null
|
|
314
|
+
// when the caller could not obtain the review context (not a pull request, an
|
|
315
|
+
// API failure, a rate limit). NULL MEANS UNAVAILABLE, NOT APPROVED — an
|
|
316
|
+
// approval that cannot be verified is not an approval, so the article falls
|
|
317
|
+
// back to requiring path A.
|
|
318
|
+
reviewApproval = null,
|
|
232
319
|
}) {
|
|
233
320
|
const base = inventoryStandardsArticles(baseMarkdown);
|
|
234
321
|
const candidate = inventoryStandardsArticles(candidateMarkdown);
|
|
@@ -280,8 +367,20 @@ export function evaluateStandardsDirection({
|
|
|
280
367
|
payload.articleId === change.id);
|
|
281
368
|
const displayName = change.after?.name ?? change.before?.name ?? change.id;
|
|
282
369
|
if (matching.length === 0) {
|
|
370
|
+
// Path B before refusing: the operator's approving review on the exact
|
|
371
|
+
// head covers every article in the pull request, because a reviewer
|
|
372
|
+
// approves the diff in front of them. Per-article granularity was an
|
|
373
|
+
// artifact of the signing mechanism, not a requirement anyone stated.
|
|
374
|
+
if (reviewApproval?.approved === true) {
|
|
375
|
+
change.direction = change.kind === 'add' ? 'add' : change.kind === 'remove' ? 'remove' : 'unstated-review-approved';
|
|
376
|
+
change.approvedBy = reviewApproval.approvedBy;
|
|
377
|
+
change.approvedVia = 'github-review';
|
|
378
|
+
continue;
|
|
379
|
+
}
|
|
283
380
|
errors.push(
|
|
284
|
-
`${directionLabel(null, change.kind)} "${displayName}" (${change.id}) requires
|
|
381
|
+
`${directionLabel(null, change.kind)} "${displayName}" (${change.id}) requires the operator's approving review on this commit, ` +
|
|
382
|
+
`or an independently signed direction ratification` +
|
|
383
|
+
(reviewApproval?.reason ? ` — review path unavailable: ${reviewApproval.reason}` : ''),
|
|
285
384
|
);
|
|
286
385
|
continue;
|
|
287
386
|
}
|
|
@@ -328,6 +427,9 @@ export function evaluateStandardsDirection({
|
|
|
328
427
|
baseRevision,
|
|
329
428
|
baseRegistrySha256,
|
|
330
429
|
candidateRegistrySha256,
|
|
430
|
+
reviewApproval: reviewApproval
|
|
431
|
+
? { approved: reviewApproval.approved === true, approvedBy: reviewApproval.approvedBy ?? null, reason: reviewApproval.reason ?? null }
|
|
432
|
+
: { approved: false, approvedBy: null, reason: 'review context unavailable' },
|
|
331
433
|
changes: changes.map((change) => ({
|
|
332
434
|
articleId: change.id,
|
|
333
435
|
name: change.after?.name ?? change.before?.name ?? change.id,
|
|
@@ -336,6 +438,7 @@ export function evaluateStandardsDirection({
|
|
|
336
438
|
change: change.kind,
|
|
337
439
|
direction: change.direction ?? null,
|
|
338
440
|
approvedBy: change.approvedBy ?? null,
|
|
441
|
+
approvedVia: change.approvedVia ?? (change.approvedBy ? 'signed-ratification' : null),
|
|
339
442
|
})),
|
|
340
443
|
population: {
|
|
341
444
|
protectedBase: base.articles.length,
|
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
{
|
|
2
2
|
"$schema": "./builtin-manifest.schema.json",
|
|
3
3
|
"schemaVersion": 1,
|
|
4
|
-
"generatedAt": "2026-08-23T02:
|
|
5
|
-
"instarVersion": "1.3.
|
|
4
|
+
"generatedAt": "2026-08-23T02:53:10.208Z",
|
|
5
|
+
"instarVersion": "1.3.1190",
|
|
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.1190",
|
|
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": "e2275b904b85c7db59a699f899b730f164b89ca204813d9766d5b1c5233a92ba",
|
|
3
3
|
"registrySha256": "81b53363a440e832672618965540b3e507ae0d93adcc67ec2b93daf7933b3ab4",
|
|
4
|
-
"packageVersion": "1.3.
|
|
4
|
+
"packageVersion": "1.3.1190"
|
|
5
5
|
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
# Upgrade Guide — vNEXT
|
|
2
|
+
|
|
3
|
+
<!-- assembled-by: assemble-next-md -->
|
|
4
|
+
<!-- bump: patch -->
|
|
5
|
+
|
|
6
|
+
## What Changed
|
|
7
|
+
|
|
8
|
+
The constitutional direction guard now accepts a second proof that a change to `docs/STANDARDS-REGISTRY.md` was approved: an approving GitHub review from the repository owner, on the exact head commit. The existing Ed25519 ratification path is unchanged and still takes precedence when both are present.
|
|
9
|
+
|
|
10
|
+
`evaluateOperatorReviewApproval` (pure, in `scripts/standards-direction-guard.mjs`) decides whether an owner approval is present, bound to the current head, and unwithdrawn. `standards-coverage.mjs` reads the review context from a file written by a new `pull_request`-only CI step, so the script performs no network I/O. The report gains `directionGuard.reviewApproval` and a per-change `approvedVia`.
|
|
11
|
+
|
|
12
|
+
## What to Tell Your User
|
|
13
|
+
|
|
14
|
+
None — internal change (no user-facing surface).
|
|
15
|
+
|
|
16
|
+
## Summary of New Capabilities
|
|
17
|
+
|
|
18
|
+
None — internal change (no user-facing surface).
|
|
19
|
+
|
|
20
|
+
## Evidence
|
|
21
|
+
|
|
22
|
+
17 tests, both directions per arm: accepted on the current head; refused on an earlier commit; refused after a later `CHANGES_REQUESTED`; refused on `DISMISSED`; accepted on a re-approval that follows a resolved objection; refused for a non-owner; refused for self-approval; refused for `COMMENTED`; UNAVAILABLE rather than permissive on an organization-owned repository; and fails closed on a missing review list, a malformed head sha, an absent owner login, and an unparseable timestamp. Composition tests assert an unavailable context is not-approved *and names why*, and that an unchanged registry produces no change to approve.
|
|
23
|
+
|
|
24
|
+
## Known Limits
|
|
25
|
+
|
|
26
|
+
**This code is not the security boundary and does not claim to be.** The agent authors it and the workflow that runs it, so it cannot constrain the agent. The boundary is the repository ruleset (require-PR on `main`, `require_code_owner_review`, empty bypass list) plus `.github/CODEOWNERS` naming the operator on the registry — all of which need admin rights the agent does not hold. This change adds legibility on top of that: which articles changed, and whether the approval still matches the proposed bytes.
|
|
27
|
+
|
|
28
|
+
The approval binds to the reviewed head, not to the bytes that finally land — a moved base or merge commit can differ. Mitigation is a platform setting (require branches up to date), recommended in `docs/specs/standards-approval-via-github-review.md` and not assumed here. `docs/STANDARDS-DIRECTION-GUARD.md` still describes path A as the only proof and is now incomplete; updating it is tracked separately.
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
# Letting the operator's approval count — Plain-English Overview
|
|
2
|
+
|
|
3
|
+
## The problem in one breath
|
|
4
|
+
|
|
5
|
+
A check is supposed to stop the agent changing its own rulebook without the operator agreeing. The only proof it accepted was a cryptographic signature from a key the operator would have to create and guard for years. Nobody made that key, so the check refused everything — including the operator's own five approved changes. A check nobody can satisfy blocks the work rather than the risk.
|
|
6
|
+
|
|
7
|
+
## What already exists
|
|
8
|
+
|
|
9
|
+
Since 17 August, any change to the rulebook has been refused for want of that signature. Nothing has slipped past it; nothing has got through it either.
|
|
10
|
+
|
|
11
|
+
As of this afternoon there is also a real lock, and it is not code: the repository now requires changes to the rulebook to be approved by the operator, enforced by GitHub against the filename. That setting needs owner rights the agent doesn't have.
|
|
12
|
+
|
|
13
|
+
## What this adds
|
|
14
|
+
|
|
15
|
+
A second accepted proof: **the operator approving the pull request**. Three taps on a phone, on an account that already has two-factor. The signature route stays exactly as it was for anyone who prefers it.
|
|
16
|
+
|
|
17
|
+
## The new pieces
|
|
18
|
+
|
|
19
|
+
When a change touches the rulebook, the pipeline asks GitHub a single question: did the owner approve *this exact version*? If yes, that counts. If no — or if the answer can't be obtained — it falls back to demanding the signature.
|
|
20
|
+
|
|
21
|
+
## The safeguards
|
|
22
|
+
|
|
23
|
+
**The approval sticks to one exact version.** Push anything afterwards and it stops counting, so nobody can get a yes on one text and land another.
|
|
24
|
+
|
|
25
|
+
**A withdrawn approval counts as withdrawn** — and a re-approval after a resolved objection counts again, so an old objection can't block forever.
|
|
26
|
+
|
|
27
|
+
**Nobody can approve their own change**, checked here as well as by GitHub, so the property doesn't depend on one platform behaviour staying as it is.
|
|
28
|
+
|
|
29
|
+
**Anything unverifiable is refused.** An API failure means "we couldn't check", never "nobody objected".
|
|
30
|
+
|
|
31
|
+
## The honest part
|
|
32
|
+
|
|
33
|
+
**This code is not the lock, and it says so.** The agent writes this check and the job that runs it, so a version of the agent that wanted around it would edit it rather than defeat it. The thing that actually stops that is the repository setting, which needs admin rights the agent doesn't have. What the code adds is legibility — it tells you which parts of the rulebook changed and whether your approval still matches them, so your three taps are informed rather than ceremonial.
|
|
34
|
+
|
|
35
|
+
**One gap left open on purpose.** The approval is tied to the version you read. If the project moves underneath while you're reading, what finally lands can differ slightly. A further setting closes that; it's recommended, not assumed.
|
|
36
|
+
|
|
37
|
+
## What ships when
|
|
38
|
+
|
|
39
|
+
This can merge on its own — it touches no rulebook text, so it doesn't need an approval to install the approval path. Once it's in, the five amendments go green and merge on the operator's tap.
|
|
40
|
+
|
|
41
|
+
## What you actually need to decide
|
|
42
|
+
|
|
43
|
+
Nothing here. The decision was made this afternoon; this is the implementation. The one open question is unrelated: whether to also require branches be up to date before merging, which would close the last gap above.
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
# Side-Effects Review — the direction guard accepts the operator's GitHub review
|
|
2
|
+
|
|
3
|
+
**Version / slug:** `direction-guard-review-approval`
|
|
4
|
+
**Date:** `2026-08-22`
|
|
5
|
+
**Author:** `echo`
|
|
6
|
+
**Second-pass reviewer:** `codex-cli — 4 adversarial rounds on the design (spec: docs/specs/standards-approval-via-github-review.md)`
|
|
7
|
+
|
|
8
|
+
## Summary of the change
|
|
9
|
+
|
|
10
|
+
The constitutional direction guard accepted exactly one proof that a change to `docs/STANDARDS-REGISTRY.md` was approved: an Ed25519 signature over the exact article bytes, verified against a key pinned in the repository. That key shipped as an empty placeholder, so **no registry change merged between the guard landing on 2026-08-17 and 2026-08-22** — including the operator's own five ratified amendments (#1960). This adds a second accepted proof: an approving GitHub review from the repository owner, on the exact head commit. Path A is untouched. Files: `scripts/standards-direction-guard.mjs` (new `evaluateOperatorReviewApproval`, plus a `reviewApproval` input to `evaluateStandardsDirection`), `scripts/standards-coverage.mjs` (reads the review context from a file the workflow writes), `.github/workflows/ci.yml` (a step that fetches the reviews from the API).
|
|
11
|
+
|
|
12
|
+
## Decision-point inventory
|
|
13
|
+
|
|
14
|
+
- `evaluateOperatorReviewApproval` (new) — **add** — decides whether an owner approval is present, current and unwithdrawn. Pure; no I/O.
|
|
15
|
+
- `evaluateStandardsDirection` per-article decision — **modify** — a changed article may now be satisfied by path B as well as path A.
|
|
16
|
+
- CI `Fetch operator review context` step — **add** — supplies the evidence. Cannot fail the job.
|
|
17
|
+
|
|
18
|
+
## 1. Over-block
|
|
19
|
+
|
|
20
|
+
The added path only ever turns a refusal into an acceptance; it cannot refuse anything path A previously accepted. A signed ratification is still checked first and still wins. The one way this could over-block is if the new CI step failed the job — it cannot, because it tolerates API failure and writes an empty context, which the guard reads as UNAVAILABLE rather than as an error.
|
|
21
|
+
|
|
22
|
+
## 2. Under-block
|
|
23
|
+
|
|
24
|
+
**This is the section that matters, and the honest answer is that the code does not carry the security.** An adversarial review of the first draft established it: the agent authors this file and the workflow that runs it, and (before 2026-08-22) could push directly to `main`. So a version of the agent that wanted to bypass this would edit it, not defeat it.
|
|
25
|
+
|
|
26
|
+
What actually blocks that is the repository ruleset the operator applied on 2026-08-22 — require-PR on `main`, `require_code_owner_review`, `bypass_actors: []` — plus `.github/CODEOWNERS` naming the operator on `docs/STANDARDS-REGISTRY.md`. Those need admin rights the agent does not hold (verified: a ruleset write returns 404 for its identity). **This change is legibility on top of that, not a lock.** Removing it would not remove the protection.
|
|
27
|
+
|
|
28
|
+
Within its own scope it under-blocks in one named way: it verifies that an approval exists on the reviewed head, not that the bytes reaching `main` are those bytes. A moved base or a merge commit can differ. Mitigation is a platform setting (require branches up to date), recommended in the spec and not assumed here.
|
|
29
|
+
|
|
30
|
+
## 3. Level-of-abstraction fit
|
|
31
|
+
|
|
32
|
+
The decision lives beside the existing signature check, in the function that already owns "is this article's change approved?". The evidence-gathering lives in the workflow, so the script stays pure and testable and performs no network I/O. No new file, no new invocation point.
|
|
33
|
+
|
|
34
|
+
## 4. Signal vs authority compliance
|
|
35
|
+
|
|
36
|
+
Compliant, and the change makes the split more honest than before. The script is a SIGNAL that reports which articles changed and whether an approval is attached. The AUTHORITY is the ruleset, which the agent cannot alter. The previous arrangement blurred this by describing an agent-authored check as the thing preventing self-amendment; the spec and the code comments now say plainly that it is not.
|
|
37
|
+
|
|
38
|
+
## 4b. Judgment-point check
|
|
39
|
+
|
|
40
|
+
No judgment point is added. Every predicate is a deterministic comparison — login equality, sha equality, state string, timestamp ordering. No model is consulted, and nothing infers intent from prose.
|
|
41
|
+
|
|
42
|
+
## 5. Interactions
|
|
43
|
+
|
|
44
|
+
- **`.github/CODEOWNERS`** (merged in #1962) is what makes the review exist at all: it causes GitHub to request the operator automatically. Verified live — pushing to #1960 auto-added `JKHeadley` as a reviewer and the PR went to `blocked`.
|
|
45
|
+
- **`docs/STANDARDS-DIRECTION-GUARD.md`** describes path A as the only proof. It is now incomplete; updating it is tracked, not done here, and named rather than left silent.
|
|
46
|
+
- **The `standards-coverage` report** gains `directionGuard.reviewApproval` and a per-change `approvedVia`, so a reader can tell which proof was used.
|
|
47
|
+
|
|
48
|
+
## 6. External surfaces
|
|
49
|
+
|
|
50
|
+
One outbound call, from CI only: `GET /repos/{owner}/{repo}/pulls/{n}/reviews` using the workflow's own `github.token`. Read-only, no secrets beyond the ephemeral job token, and it never runs outside a `pull_request` event. No agent-side runtime surface, no route, no message.
|
|
51
|
+
|
|
52
|
+
## 6b. Operator-surface quality
|
|
53
|
+
|
|
54
|
+
The operator's experience is three taps on a PR GitHub already asked them to review. The failure text names which proof is missing and, when the review path is unavailable, why — so a red check says "waiting for Justin" rather than something cryptic.
|
|
55
|
+
|
|
56
|
+
## 7. Multi-machine posture
|
|
57
|
+
|
|
58
|
+
`unified` — trivially. Pure functions plus one CI-side read; no durable state, no per-machine state, no generated URL, no notification. Identical on any machine over the same inputs.
|
|
59
|
+
|
|
60
|
+
## 8. Rollback cost
|
|
61
|
+
|
|
62
|
+
Near zero and additive to revert: delete the new function, the `reviewApproval` parameter, the CI step and the test, and the guard is byte-equivalent to today's behaviour. Because the ruleset carries the security, a revert does not reopen a hole — it only makes the pipeline less informative and returns the operator to needing a key.
|
|
63
|
+
|
|
64
|
+
## Conclusion
|
|
65
|
+
|
|
66
|
+
Ship. It converts a guard nobody could satisfy into one the operator can satisfy in three taps, adds no authority, has no runtime or agent-facing surface, and is covered in both directions by 17 tests. The honest caveat, repeated because it is the point: the security is the ruleset, not this code.
|
|
67
|
+
|
|
68
|
+
## Second-pass review
|
|
69
|
+
|
|
70
|
+
The DESIGN was adversarially reviewed over four rounds (5 → 3 → 1 → 0 security findings) before any of this was written; round 1 was invalidated wholesale and the design rewritten around its central finding. Record: `docs/specs/standards-approval-via-github-review.md`. The implementation follows that converged design.
|
|
71
|
+
|
|
72
|
+
## Evidence pointers
|
|
73
|
+
|
|
74
|
+
- `tests/unit/standards-direction-review-approval.test.ts` — 17 tests. Both directions per arm: accepted on head; refused on an earlier commit; refused after withdrawal; refused on dismissal; **accepted on re-approval after an objection** (so a resolved objection cannot block forever); refused for a non-owner; refused for self-approval; refused for `COMMENTED`; unavailable (not permissive) for an organization owner; fails closed on a missing list, a malformed sha, an absent login, and an unparseable timestamp.
|
|
75
|
+
- Composition tests assert that an unavailable context is not-approved *and names why*, and that an unchanged registry produces no fabricated change to approve.
|
|
76
|
+
- Pre-existing local failures in `standards-coverage-ratchet.test.ts` (2) were verified to fail identically on clean `upstream/main` with this work stashed — a local git-ref artifact, not a regression from this change.
|
|
77
|
+
|
|
78
|
+
## Class-Closure Declaration (display-only mirror)
|
|
79
|
+
|
|
80
|
+
The class is "a guard that cannot be satisfied, and is therefore not a guard." Closed here for the constitution: the operator now has a reachable proof. It is NOT closed generally — the same shape exists wherever a check depends on a credential nobody has created, and the dashboard-PIN finding (a dozen routes documented as operator-only, gated on a secret the agent reads) is the same class from the other direction. Tracked separately, open.
|