create-agent-rig 0.8.0 → 0.9.1
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/CHANGELOG.md +152 -1
- package/README.md +99 -4
- package/package.json +4 -3
- package/packages/cli/dist/commands/init.js +43 -7
- package/packages/cli/dist/commands/memory.js +182 -0
- package/packages/cli/dist/commands/setup.js +45 -0
- package/packages/cli/dist/commands/upgrade.js +22 -4
- package/packages/cli/dist/index.js +110 -3
- package/packages/cli/dist/lib/manifest.js +23 -5
- package/packages/cli/dist/lib/subsystems.js +269 -0
- package/packages/cli/dist/lib/version.js +15 -0
- package/packages/cli/dist/policy/benchmark/corpus.js +165 -0
- package/packages/cli/dist/policy/core/coverage.js +253 -0
- package/packages/cli/dist/policy/core/decision-record.js +130 -44
- package/packages/cli/dist/policy/core/declaration.js +58 -17
- package/packages/cli/dist/policy/core/evidence-matrix.js +94 -0
- package/packages/cli/dist/policy/core/probe.js +442 -0
- package/packages/cli/dist/policy/core/validation.js +194 -1
- package/packages/cli/dist/policy/core/vocabulary.js +70 -3
- package/packages/cli/dist/policy/harness/claude.js +9 -1
- package/packages/cli/dist/policy/harness/codex.js +48 -1
- package/packages/cli/dist/policy/harness/shared-hooks.js +18 -0
- package/packages/cli/dist/policy/index.js +9 -2
- package/templates/agent-os/stack/aws-cdk/.claude/agents/cdk-diff-reviewer.md +2 -0
- package/templates/agent-os/stack/aws-cdk/.codex/agents/cdk-diff-reviewer.toml +2 -0
- package/templates/agent-os/subagent-routing.json +32 -0
- package/templates/agent-os/universal/.agents/skills/loop/SKILL.md +41 -4
- package/templates/agent-os/universal/.claude/agents/code-reviewer.md +2 -0
- package/templates/agent-os/universal/.claude/agents/prose-reviewer.md +2 -0
- package/templates/agent-os/universal/.claude/agents/security-scanner.md +2 -0
- package/templates/agent-os/universal/.claude/agents/test-writer.md +2 -0
- package/templates/agent-os/universal/.claude/hooks/guard-subagent-model.mjs +234 -0
- package/templates/agent-os/universal/.claude/hooks/lib/edit-input.mjs +75 -32
- package/templates/agent-os/universal/.claude/hooks/warn-subagent-routing.mjs +120 -0
- package/templates/agent-os/universal/.claude/rules/workflow.md +5 -0
- package/templates/agent-os/universal/.claude/scripts/preflight.mjs +27 -3
- package/templates/agent-os/universal/.claude/scripts/queue/core.mjs +43 -0
- package/templates/agent-os/universal/.claude/scripts/queue/gate-rounds.mjs +70 -2
- package/templates/agent-os/universal/.claude/scripts/queue/index.mjs +12 -4
- package/templates/agent-os/universal/.claude/scripts/queue/jira.mjs +18 -1
- package/templates/agent-os/universal/.claude/scripts/reconcile-external-prs.mjs +269 -32
- package/templates/agent-os/universal/.claude/scripts/unattended-flag.mjs +64 -1
- package/templates/agent-os/universal/.claude/settings.json +16 -0
- package/templates/agent-os/universal/.claude/skills/loop/SKILL.md +41 -4
- package/templates/agent-os/universal/.codex/agents/code-reviewer.toml +2 -0
- package/templates/agent-os/universal/.codex/agents/prose-reviewer.toml +2 -0
- package/templates/agent-os/universal/.codex/agents/security-scanner.toml +2 -0
- package/templates/agent-os/universal/.codex/agents/test-writer.toml +2 -0
- package/templates/agent-os/universal/.codex/config.toml +3 -0
- package/templates/agent-os/universal/docs/decisions/codex-adapter.md +31 -5
- package/templates/agent-os/universal/docs/decisions/subagent-routing.md +142 -0
- package/templates/agent-os/universal/layers.json +4 -0
- package/templates/hash-history.json +71 -22
- package/templates/release-ledger.json +3 -1
- package/templates/skeleton/node-service/services/api/test/artifact.test.ts +3 -4
- package/templates/skeleton/node-service/services/api/test/package-manager.test.ts +40 -0
- package/templates/skeleton/node-service/services/api/test/package-manager.ts +51 -0
- package/templates/skeleton/node-service/services/api/test/static-dir.test.ts +9 -8
|
@@ -16,12 +16,86 @@
|
|
|
16
16
|
* - every evidence kind the policy requires must be present;
|
|
17
17
|
* - a policy that redacts must not be recorded with unredacted diagnostics;
|
|
18
18
|
* - the timestamp is supplied by the caller and must be an ISO-8601 date-time
|
|
19
|
-
* with seconds and an explicit zone (`ISO_8601`
|
|
19
|
+
* with seconds and an explicit zone (`ISO_8601` in `./validation.ts`, which
|
|
20
|
+
* every shape recording an observation time reads; a bare date is
|
|
20
21
|
* refused) — no clock here.
|
|
21
22
|
*
|
|
22
23
|
* Each rule is one test in `packages/cli/test/policy-declaration.test.ts`
|
|
23
24
|
* under "validating a decision record".
|
|
24
25
|
*
|
|
26
|
+
* 🔴 EVERY field of the input is read through `carriesField`/`ownField`
|
|
27
|
+
* (`./validation.ts`) — own and enumerable, the set `Object.keys` walks — and
|
|
28
|
+
* never off the record directly. Presence and value travel through the same
|
|
29
|
+
* predicate: `'qualifier' in value` was the load-bearing half of this defect,
|
|
30
|
+
* because `unknownKeys` judges the same record by `Object.keys` while the reads
|
|
31
|
+
* walked the prototype chain, so the closed-shape check and the field reads
|
|
32
|
+
* disagreed about what the record contained — with the reads being the wider of
|
|
33
|
+
* the two, which is the direction that passes. A decision record is the
|
|
34
|
+
* artifact a later reader audits, and the shape that made this worth fixing
|
|
35
|
+
* before it had a caller is a record whose verdict qualifier is only inherited:
|
|
36
|
+
* it validates as qualified, and what it then writes out is `{"outcome":
|
|
37
|
+
* "allow","reason":…}` — the reason survives, the QUALIFIER is what is lost,
|
|
38
|
+
* which is exactly the silent pass an `UNSUPPORTED` capability state exists to
|
|
39
|
+
* prevent. Held over every reading site in this module, in both shapes —
|
|
40
|
+
* inherited, and own but not enumerable — in
|
|
41
|
+
* `packages/cli/test/policy-declaration.test.ts` › "refuses an UNSUPPORTED
|
|
42
|
+
* record whose verdict qualifier is %s, because what it writes out is a silent
|
|
43
|
+
* pass", with the other direction held by › "still accepts a record whose every
|
|
44
|
+
* field is defined through Object.defineProperty as own and enumerable". The
|
|
45
|
+
* `%s` is the name as the `it.each` case DECLARES it; quoting one expanded case
|
|
46
|
+
* gives a pointer no grep lands on.
|
|
47
|
+
*
|
|
48
|
+
* 🔴 And an unnarrowed outside value reaches a MESSAGE only through `quote`
|
|
49
|
+
* (`./validation.ts`), never through bare `String` or `JSON.stringify`.
|
|
50
|
+
* Two qualifications, because an earlier version of this sentence claimed more
|
|
51
|
+
* than the file delivers. First, `operation` and `capabilityState` are still
|
|
52
|
+
* interpolated bare into messages, safely: `member` has narrowed each to its
|
|
53
|
+
* closed vocabulary before the message is built. Second — and this is the part
|
|
54
|
+
* the sentence used to hide — a `Problem`'s **`field`** is not a message and is
|
|
55
|
+
* not escaped: `unknownKeys` (`./validation.ts`) puts the outside key name
|
|
56
|
+
* there verbatim, and `./declaration.ts` › `definePolicy` renders a report as
|
|
57
|
+
* `${field}: ${message}`, so an unknown key whose NAME carries a newline still
|
|
58
|
+
* forges a line. Escaping a `field` would break it as the machine-readable
|
|
59
|
+
* pointer it is, so the fix belongs in the rendering; it is RP-160.
|
|
60
|
+
* Both unsafe spellings inside messages were here: a qualifier
|
|
61
|
+
* carrying a newline forged two `field:
|
|
62
|
+
* message` lines of its own in the rendered problem list — the exact shape
|
|
63
|
+
* `./declaration.ts` › `definePolicy` throws — while the neighbouring line
|
|
64
|
+
* escaped the same value; and a circular value crashed the validator with a
|
|
65
|
+
* `TypeError` where `quote` degrades. Held by ›
|
|
66
|
+
* "escapes a verdict qualifier carrying a newline, so it cannot forge a line of
|
|
67
|
+
* the refusal report" and › "refuses a record whose %s is a circular value,
|
|
68
|
+
* rather than throwing while it renders the refusal".
|
|
69
|
+
*
|
|
70
|
+
* ⚠ What this does NOT do, and the limits are stated rather than implied:
|
|
71
|
+
*
|
|
72
|
+
* - the `ok: true` value is the input object itself, not a snapshot of the
|
|
73
|
+
* fields that were certified. Those are the same thing for a value whose
|
|
74
|
+
* fields are plain data, which is every record `JSON.parse` can produce; they
|
|
75
|
+
* are not the same for one carrying a live accessor, which validates on one
|
|
76
|
+
* read and serialises from another. RP-157 owns that, for this module and
|
|
77
|
+
* `./declaration.ts` together;
|
|
78
|
+
* - nothing here caps the cost, and the amplifier is the PROBLEM LIST rather
|
|
79
|
+
* than the input: one `Problem` is pushed per bad entry of `observedFacts` or
|
|
80
|
+
* `evidence`, so a parsed record carrying a million bad entries produces a
|
|
81
|
+
* million objects. That is reachable from ordinary `JSON.parse` input. The
|
|
82
|
+
* first version of this bullet was wrong in every clause — it said
|
|
83
|
+
* `Array.prototype.forEach` VISITS a sparse array's holes, that
|
|
84
|
+
* `new Array(1e7)` cost seconds and gigabytes "before the refusal", and that
|
|
85
|
+
* the cost was unreachable from a parsed record. `forEach` SKIPS holes, a
|
|
86
|
+
* sparse `observedFacts` is not refused at all, and the dense case is the
|
|
87
|
+
* reachable one. Corrected rather than softened, because a stale limit sells
|
|
88
|
+
* cover that is not there (`rules/invariants.md`, "State the limits — and
|
|
89
|
+
* test them");
|
|
90
|
+
* - an array HOLE is consequently read by nothing, while `JSON.stringify`
|
|
91
|
+
* writes it out as `null`: `observedFacts: [{…}, , ,]` serialises as
|
|
92
|
+
* `[{…},null,null]` and validates. That is this module's own rule — read what
|
|
93
|
+
* the serialisation carries — failing one level BELOW the field, which is
|
|
94
|
+
* where this change did not look. RP-161;
|
|
95
|
+
* - `quote` itself re-throws for a value whose `JSON.stringify` and `String`
|
|
96
|
+
* both throw. The direction is a crash, never an `ok: true`, so nothing
|
|
97
|
+
* malformed is certified through it — RP-160.
|
|
98
|
+
*
|
|
25
99
|
* ⚠ `diagnostics.redacted` is the emitter's claim, and this validator enforces
|
|
26
100
|
* the claim's presence, not the property: a record marked redacted whose
|
|
27
101
|
* `diagnostics.text`, `observedFacts[].value` or `evidence[].value` still
|
|
@@ -31,8 +105,8 @@
|
|
|
31
105
|
* before it persists a record.
|
|
32
106
|
*/
|
|
33
107
|
import { compatibilityOf, findPolicy } from './registry.js';
|
|
34
|
-
import { CAPABILITY_STATES, DECISION_OUTCOMES, EVIDENCE_KINDS, OPERATIONS, VERDICT_QUALIFIERS, } from './vocabulary.js';
|
|
35
|
-
import { isRecord, member, nonEmptyString, unknownKeys } from './validation.js';
|
|
108
|
+
import { CAPABILITY_STATES, UNENFORCEABLE_STATES, DECISION_OUTCOMES, EVIDENCE_KINDS, OPERATIONS, VERDICT_QUALIFIERS, } from './vocabulary.js';
|
|
109
|
+
import { ISO_8601, carriesField, isRecord, member, nonEmptyString, ownField, quote, unknownKeys, } from './validation.js';
|
|
36
110
|
export const DECISION_RECORD_SCHEMA_VERSION = 1;
|
|
37
111
|
const KEYS = [
|
|
38
112
|
'schemaVersion',
|
|
@@ -48,9 +122,12 @@ const KEYS = [
|
|
|
48
122
|
'diagnostics',
|
|
49
123
|
'recordedAt',
|
|
50
124
|
];
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
125
|
+
/**
|
|
126
|
+
* Read from `./vocabulary.ts` rather than restated here: `./coverage.ts` ›
|
|
127
|
+
* `qualifierFor` answers from the same list, and a second copy is how the two
|
|
128
|
+
* come to disagree about which verdicts may pass unqualified.
|
|
129
|
+
*/
|
|
130
|
+
const NEVER_SILENT_PASS = UNENFORCEABLE_STATES;
|
|
54
131
|
const namedPairs = (problems, field, value, keys) => {
|
|
55
132
|
if (!Array.isArray(value)) {
|
|
56
133
|
problems.push({ field, message: 'must be a list' });
|
|
@@ -63,7 +140,7 @@ const namedPairs = (problems, field, value, keys) => {
|
|
|
63
140
|
}
|
|
64
141
|
unknownKeys(problems, entry, keys, `${field}[${index}]`);
|
|
65
142
|
for (const key of keys)
|
|
66
|
-
nonEmptyString(problems, `${field}[${index}].${key}`, entry
|
|
143
|
+
nonEmptyString(problems, `${field}[${index}].${key}`, ownField(entry, key));
|
|
67
144
|
});
|
|
68
145
|
};
|
|
69
146
|
const checkVerdict = (problems, value, capabilityState) => {
|
|
@@ -72,22 +149,23 @@ const checkVerdict = (problems, value, capabilityState) => {
|
|
|
72
149
|
return;
|
|
73
150
|
}
|
|
74
151
|
unknownKeys(problems, value, ['outcome', 'qualifier', 'reason'], 'verdict');
|
|
75
|
-
member(problems, 'verdict.outcome', value
|
|
76
|
-
const
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
152
|
+
member(problems, 'verdict.outcome', ownField(value, 'outcome'), DECISION_OUTCOMES);
|
|
153
|
+
const qualifier = ownField(value, 'qualifier');
|
|
154
|
+
const reason = ownField(value, 'reason');
|
|
155
|
+
if (carriesField(value, 'qualifier')) {
|
|
156
|
+
member(problems, 'verdict.qualifier', qualifier, VERDICT_QUALIFIERS);
|
|
157
|
+
if (typeof reason !== 'string' || reason.trim() === '') {
|
|
80
158
|
problems.push({
|
|
81
159
|
field: 'verdict.reason',
|
|
82
|
-
message: `a ${
|
|
160
|
+
message: `a ${quote(qualifier)} verdict must say why`,
|
|
83
161
|
});
|
|
84
162
|
}
|
|
85
163
|
}
|
|
86
|
-
else if ('reason'
|
|
164
|
+
else if (carriesField(value, 'reason') && typeof reason !== 'string') {
|
|
87
165
|
problems.push({ field: 'verdict.reason', message: 'must be a string when present' });
|
|
88
166
|
}
|
|
89
167
|
if (capabilityState !== null && NEVER_SILENT_PASS.includes(capabilityState)) {
|
|
90
|
-
if (
|
|
168
|
+
if (qualifier !== 'UNVERIFIABLE') {
|
|
91
169
|
problems.push({
|
|
92
170
|
field: 'verdict.qualifier',
|
|
93
171
|
message: `capabilityState ${capabilityState} never yields a silent pass: ` +
|
|
@@ -108,10 +186,11 @@ const checkEvidence = (problems, value, required) => {
|
|
|
108
186
|
return;
|
|
109
187
|
}
|
|
110
188
|
unknownKeys(problems, entry, ['kind', 'value'], `evidence[${index}]`);
|
|
111
|
-
|
|
112
|
-
|
|
189
|
+
const kind = ownField(entry, 'kind');
|
|
190
|
+
if (member(problems, `evidence[${index}].kind`, kind, EVIDENCE_KINDS)) {
|
|
191
|
+
present.add(kind);
|
|
113
192
|
}
|
|
114
|
-
nonEmptyString(problems, `evidence[${index}].value`, entry
|
|
193
|
+
nonEmptyString(problems, `evidence[${index}].value`, ownField(entry, 'value'));
|
|
115
194
|
});
|
|
116
195
|
for (const kind of required) {
|
|
117
196
|
if (!present.has(kind)) {
|
|
@@ -125,16 +204,17 @@ const checkDiagnostics = (problems, value, mustRedact) => {
|
|
|
125
204
|
return;
|
|
126
205
|
}
|
|
127
206
|
unknownKeys(problems, value, ['redacted', 'text'], 'diagnostics');
|
|
128
|
-
|
|
207
|
+
const redacted = ownField(value, 'redacted');
|
|
208
|
+
if (typeof redacted !== 'boolean') {
|
|
129
209
|
problems.push({ field: 'diagnostics.redacted', message: 'must be a boolean' });
|
|
130
210
|
}
|
|
131
|
-
else if (mustRedact && !
|
|
211
|
+
else if (mustRedact && !redacted) {
|
|
132
212
|
problems.push({
|
|
133
213
|
field: 'diagnostics.redacted',
|
|
134
214
|
message: 'the policy redacts matched values, so its diagnostics must be recorded redacted',
|
|
135
215
|
});
|
|
136
216
|
}
|
|
137
|
-
if (typeof value
|
|
217
|
+
if (typeof ownField(value, 'text') !== 'string') {
|
|
138
218
|
problems.push({ field: 'diagnostics.text', message: 'must be a string' });
|
|
139
219
|
}
|
|
140
220
|
};
|
|
@@ -145,54 +225,60 @@ export function validateDecisionRecord(input) {
|
|
|
145
225
|
}
|
|
146
226
|
const problems = [];
|
|
147
227
|
unknownKeys(problems, input, KEYS);
|
|
148
|
-
|
|
228
|
+
const schemaVersion = ownField(input, 'schemaVersion');
|
|
229
|
+
if (schemaVersion !== DECISION_RECORD_SCHEMA_VERSION) {
|
|
149
230
|
problems.push({
|
|
150
231
|
field: 'schemaVersion',
|
|
151
|
-
message: `must be ${DECISION_RECORD_SCHEMA_VERSION}, got ${
|
|
232
|
+
message: `must be ${DECISION_RECORD_SCHEMA_VERSION}, got ${quote(schemaVersion)}`,
|
|
152
233
|
});
|
|
153
234
|
}
|
|
154
|
-
const
|
|
235
|
+
const policyId = ownField(input, 'policyId');
|
|
236
|
+
const policyVersion = ownField(input, 'policyVersion');
|
|
237
|
+
const policy = typeof policyId === 'string' ? findPolicy(policyId) : null;
|
|
155
238
|
if (policy === null) {
|
|
156
239
|
problems.push({
|
|
157
240
|
field: 'policyId',
|
|
158
|
-
message: `${
|
|
241
|
+
message: `${quote(policyId)} is not a registered policy`,
|
|
159
242
|
});
|
|
160
243
|
}
|
|
161
|
-
else if (typeof
|
|
162
|
-
const compatibility = compatibilityOf(policy.policyId,
|
|
244
|
+
else if (typeof policyVersion === 'string') {
|
|
245
|
+
const compatibility = compatibilityOf(policy.policyId, policyVersion);
|
|
163
246
|
if (compatibility !== 'compatible') {
|
|
164
247
|
problems.push({
|
|
165
248
|
field: 'policyVersion',
|
|
166
|
-
message: `${
|
|
249
|
+
message: `${quote(policyVersion)} is ${compatibility} with ${policy.policyId} ${policy.policyVersion}`,
|
|
167
250
|
});
|
|
168
251
|
}
|
|
169
252
|
}
|
|
170
253
|
else {
|
|
171
|
-
nonEmptyString(problems, 'policyVersion',
|
|
254
|
+
nonEmptyString(problems, 'policyVersion', policyVersion);
|
|
172
255
|
}
|
|
173
|
-
nonEmptyString(problems, 'harness', input
|
|
174
|
-
|
|
175
|
-
|
|
256
|
+
nonEmptyString(problems, 'harness', ownField(input, 'harness'));
|
|
257
|
+
const operation = ownField(input, 'operation');
|
|
258
|
+
if (member(problems, 'operation', operation, OPERATIONS) && policy !== null) {
|
|
259
|
+
if (!policy.operations.includes(operation)) {
|
|
176
260
|
problems.push({
|
|
177
261
|
field: 'operation',
|
|
178
|
-
message: `${policy.policyId} does not apply to ${
|
|
262
|
+
message: `${policy.policyId} does not apply to ${operation}`,
|
|
179
263
|
});
|
|
180
264
|
}
|
|
181
265
|
}
|
|
182
|
-
const
|
|
183
|
-
|
|
266
|
+
const state = ownField(input, 'capabilityState');
|
|
267
|
+
const capabilityState = member(problems, 'capabilityState', state, CAPABILITY_STATES)
|
|
268
|
+
? state
|
|
184
269
|
: null;
|
|
185
|
-
namedPairs(problems, 'observedFacts', input
|
|
186
|
-
checkVerdict(problems, input
|
|
187
|
-
checkEvidence(problems, input
|
|
188
|
-
nonEmptyString(problems, 'artifactVersion', input
|
|
189
|
-
checkDiagnostics(problems, input
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
270
|
+
namedPairs(problems, 'observedFacts', ownField(input, 'observedFacts'), ['name', 'value']);
|
|
271
|
+
checkVerdict(problems, ownField(input, 'verdict'), capabilityState);
|
|
272
|
+
checkEvidence(problems, ownField(input, 'evidence'), policy?.requiredEvidence ?? []);
|
|
273
|
+
nonEmptyString(problems, 'artifactVersion', ownField(input, 'artifactVersion'));
|
|
274
|
+
checkDiagnostics(problems, ownField(input, 'diagnostics'), policy !== null && policy.redaction !== 'none');
|
|
275
|
+
const recordedAt = ownField(input, 'recordedAt');
|
|
276
|
+
if (typeof recordedAt !== 'string' ||
|
|
277
|
+
!ISO_8601.test(recordedAt) ||
|
|
278
|
+
Number.isNaN(Date.parse(recordedAt))) {
|
|
193
279
|
problems.push({
|
|
194
280
|
field: 'recordedAt',
|
|
195
|
-
message: `must be an ISO-8601 date-time with seconds and an explicit zone, got ${
|
|
281
|
+
message: `must be an ISO-8601 date-time with seconds and an explicit zone, got ${quote(recordedAt)}`,
|
|
196
282
|
});
|
|
197
283
|
}
|
|
198
284
|
if (problems.length > 0)
|
|
@@ -12,10 +12,51 @@
|
|
|
12
12
|
* The shape is closed: a field this interface does not name is refused, so a
|
|
13
13
|
* field added by mistake cannot travel unnoticed into a record somebody later
|
|
14
14
|
* audits. Every enumerated field draws from `./vocabulary.ts`.
|
|
15
|
+
*
|
|
16
|
+
* 🔴 `validateDeclaration` reads every field of its input through `ownField`
|
|
17
|
+
* (`./validation.ts`) — own and enumerable, the set `Object.keys` walks — and
|
|
18
|
+
* never off the record directly. `unknownKeys` on the line above already judges
|
|
19
|
+
* the record by `Object.keys`, so a wider read made the closed-shape check and
|
|
20
|
+
* the field reads disagree about what the record contains, with the reads being
|
|
21
|
+
* the wider of the two — the direction that passes. The consequence here is one
|
|
22
|
+
* step worse than in `./decision-record.ts`, because this validator's answer is
|
|
23
|
+
* kept: `definePolicy` copies the validated declaration by spread, and a spread
|
|
24
|
+
* copies own-enumerable keys only. So a declaration whose `tier` and `redaction`
|
|
25
|
+
* were only inherited used to be accepted and then FROZEN into the registry
|
|
26
|
+
* with no `tier` key and no `redaction` key at all — reading either yields
|
|
27
|
+
* `undefined` — a malformed entry produced by the function whose whole job is to
|
|
28
|
+
* refuse malformed ones. Held over all fifteen fields, in both shapes (inherited,
|
|
29
|
+
* and own but not enumerable), in `packages/cli/test/policy-declaration.test.ts`
|
|
30
|
+
* › "refuses a declaration whose %s is %s, because the declaration it writes out
|
|
31
|
+
* carries no such field" and, for the frozen-entry consequence, › "refuses a
|
|
32
|
+
* declaration whose tier is %s, rather than freezing an entry with no tier at
|
|
33
|
+
* all". The `%s` are the names as the `it.each` cases DECLARE them — quoting an
|
|
34
|
+
* expanded case instead is a pointer no grep lands on, which is what
|
|
35
|
+
* `test/template/evidence-pointers.test.ts` treats as a wildcard and what
|
|
36
|
+
* `rules/invariants.md` means by "the test's whole name".
|
|
37
|
+
*
|
|
38
|
+
* ⚠ What this does NOT do: `validateDeclaration` returns the input object
|
|
39
|
+
* itself (`input as unknown as PolicyDeclaration`), so what a caller gets back
|
|
40
|
+
* is the record it passed in, not a snapshot of the fields that were certified.
|
|
41
|
+
* Those are the same thing for a value whose fields are plain DATA, which is
|
|
42
|
+
* every declaration `JSON.parse` can produce; they are not the same for one
|
|
43
|
+
* carrying a live accessor. **An object literal is not the line** — a literal
|
|
44
|
+
* can carry a getter, and an earlier version of this sentence put literals on
|
|
45
|
+
* the safe side, which named the one shape that reaches the gap as the shape
|
|
46
|
+
* that avoids it. Here the consequence is kept rather than merely returned:
|
|
47
|
+
* `definePolicy` spreads the validated value, so a getter validated on read 1
|
|
48
|
+
* is FROZEN into the registry from read 2. That gap is RP-157, and
|
|
49
|
+
* `./decision-record.ts` states the identical limit at its own return.
|
|
15
50
|
*/
|
|
16
51
|
import { AUTONOMY_TIERS, DECISION_OUTCOMES, ENFORCEMENT_TIMINGS, EVIDENCE_KINDS, FAILURE_SEMANTICS, HARNESS_CAPABILITIES, LIFECYCLE_STATES, OPERATIONS, REDACTION_RULES, } from './vocabulary.js';
|
|
17
|
-
import { isRecord, matching, member, members, nonEmptyString, unknownKeys } from './validation.js';
|
|
18
|
-
|
|
52
|
+
import { isRecord, matching, member, members, nonEmptyString, ownField, unknownKeys, } from './validation.js';
|
|
53
|
+
/**
|
|
54
|
+
* The closed set of fields a declaration may carry — exported so a test can
|
|
55
|
+
* check the fixture against THIS list by name rather than by counting it.
|
|
56
|
+
* A count agrees with a set that has drifted; `rules/invariants.md`, "One
|
|
57
|
+
* mechanism, one implementation".
|
|
58
|
+
*/
|
|
59
|
+
export const KEYS = [
|
|
19
60
|
'policyId',
|
|
20
61
|
'policyVersion',
|
|
21
62
|
'lifecycle',
|
|
@@ -41,23 +82,23 @@ export function validateDeclaration(input) {
|
|
|
41
82
|
}
|
|
42
83
|
const problems = [];
|
|
43
84
|
unknownKeys(problems, input, KEYS);
|
|
44
|
-
matching(problems, 'policyId', input
|
|
45
|
-
matching(problems, 'policyVersion', input
|
|
46
|
-
member(problems, 'lifecycle', input
|
|
47
|
-
nonEmptyString(problems, 'invariant', input
|
|
48
|
-
member(problems, 'tier', input
|
|
49
|
-
members(problems, 'operations', input
|
|
50
|
-
member(problems, 'timing', input
|
|
51
|
-
member(problems, 'requiredCapability', input
|
|
52
|
-
matching(problems, 'mechanism', input
|
|
53
|
-
members(problems, 'outcomes', input
|
|
54
|
-
member(problems, 'onInternalError', input
|
|
55
|
-
member(problems, 'onUnreadableInput', input
|
|
56
|
-
members(problems, 'requiredEvidence', input
|
|
85
|
+
matching(problems, 'policyId', ownField(input, 'policyId'), KEBAB_CASE, 'kebab-case');
|
|
86
|
+
matching(problems, 'policyVersion', ownField(input, 'policyVersion'), POLICY_VERSION, 'MAJOR.MINOR');
|
|
87
|
+
member(problems, 'lifecycle', ownField(input, 'lifecycle'), LIFECYCLE_STATES);
|
|
88
|
+
nonEmptyString(problems, 'invariant', ownField(input, 'invariant'));
|
|
89
|
+
member(problems, 'tier', ownField(input, 'tier'), AUTONOMY_TIERS);
|
|
90
|
+
members(problems, 'operations', ownField(input, 'operations'), OPERATIONS, { nonEmpty: true });
|
|
91
|
+
member(problems, 'timing', ownField(input, 'timing'), ENFORCEMENT_TIMINGS);
|
|
92
|
+
member(problems, 'requiredCapability', ownField(input, 'requiredCapability'), HARNESS_CAPABILITIES);
|
|
93
|
+
matching(problems, 'mechanism', ownField(input, 'mechanism'), KEBAB_CASE, 'kebab-case');
|
|
94
|
+
members(problems, 'outcomes', ownField(input, 'outcomes'), DECISION_OUTCOMES, { nonEmpty: true });
|
|
95
|
+
member(problems, 'onInternalError', ownField(input, 'onInternalError'), FAILURE_SEMANTICS);
|
|
96
|
+
member(problems, 'onUnreadableInput', ownField(input, 'onUnreadableInput'), FAILURE_SEMANTICS);
|
|
97
|
+
members(problems, 'requiredEvidence', ownField(input, 'requiredEvidence'), EVIDENCE_KINDS, {
|
|
57
98
|
nonEmpty: false,
|
|
58
99
|
});
|
|
59
|
-
member(problems, 'redaction', input
|
|
60
|
-
nonEmptyString(problems, 'statedIn', input
|
|
100
|
+
member(problems, 'redaction', ownField(input, 'redaction'), REDACTION_RULES);
|
|
101
|
+
nonEmptyString(problems, 'statedIn', ownField(input, 'statedIn'));
|
|
61
102
|
if (problems.length > 0)
|
|
62
103
|
return { ok: false, problems };
|
|
63
104
|
// Every field above was checked against the shape, so the narrowing is earned
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The capability evidence matrix (RP-36, absorbing the discovery component of
|
|
3
|
+
* the item it supersedes): one row per concrete harness surface, saying what
|
|
4
|
+
* was observed, on exactly which version, when, and where the proof is.
|
|
5
|
+
*
|
|
6
|
+
* The rule that gives the matrix its value is that its validator refuses an
|
|
7
|
+
* incomplete row: a row without an exact version or an observed
|
|
8
|
+
* date-time reads like evidence and is not one, because neither "which build
|
|
9
|
+
* was this" nor "was this before or after the change" can be answered from it.
|
|
10
|
+
* This module owns validation only; a caller that persists rows owns storage.
|
|
11
|
+
* Both halves are checked — `packages/cli/test/policy-coverage.test.ts` ›
|
|
12
|
+
* "refuses the harness version %j, because it names a range or a moving target
|
|
13
|
+
* rather than a build" and › "refuses an observedAt that is not an ISO-8601
|
|
14
|
+
* date-time with a zone (%j)". Documentation claims are not rows; a row records
|
|
15
|
+
* live behaviour that was seen.
|
|
16
|
+
*
|
|
17
|
+
* A row whose status is anything but `SUPPORTED` must say why, and a
|
|
18
|
+
* `SUPPORTED` row must not — the shape is closed in both directions, per ›
|
|
19
|
+
* "refuses a %s row that does not say why it is not supported".
|
|
20
|
+
*
|
|
21
|
+
* Every field of the shape is checked and every problem is reported at once,
|
|
22
|
+
* the way `./declaration.ts` does it, so a caller fixes a row in one pass: ›
|
|
23
|
+
* "reports every problem at once rather than stopping at the first".
|
|
24
|
+
*
|
|
25
|
+
* Why the row is shaped this way, and what it deliberately does not check, is
|
|
26
|
+
* `docs/decisions/capability-coverage.md`.
|
|
27
|
+
*/
|
|
28
|
+
import { CAPABILITY_STATES } from './vocabulary.js';
|
|
29
|
+
import { carriesField, exactVersion, ISO_8601, isRecord, matching, member, nonBlankString, ownField, unknownKeys, } from './validation.js';
|
|
30
|
+
const KEYS = [
|
|
31
|
+
'harness',
|
|
32
|
+
'surface',
|
|
33
|
+
'harnessVersion',
|
|
34
|
+
'os',
|
|
35
|
+
'observedAt',
|
|
36
|
+
'mechanism',
|
|
37
|
+
'observableSignal',
|
|
38
|
+
'status',
|
|
39
|
+
'downgradeReason',
|
|
40
|
+
'evidencePointer',
|
|
41
|
+
];
|
|
42
|
+
const REQUIRED_TEXT = [
|
|
43
|
+
'harness',
|
|
44
|
+
'surface',
|
|
45
|
+
'harnessVersion',
|
|
46
|
+
'os',
|
|
47
|
+
'mechanism',
|
|
48
|
+
'observableSignal',
|
|
49
|
+
'evidencePointer',
|
|
50
|
+
];
|
|
51
|
+
/** Validate an unknown value as one matrix row, reporting every problem at once. */
|
|
52
|
+
export function validateEvidenceRow(input) {
|
|
53
|
+
if (!isRecord(input)) {
|
|
54
|
+
return { ok: false, problems: [{ field: '', message: 'an evidence row is an object' }] };
|
|
55
|
+
}
|
|
56
|
+
const problems = [];
|
|
57
|
+
unknownKeys(problems, input, KEYS);
|
|
58
|
+
// EVERY field is read through `ownField`, not off the record directly. A row
|
|
59
|
+
// is evidence only insofar as it CARRIES what it was validated on: a value
|
|
60
|
+
// reached through the prototype chain, or held in a non-enumerable own
|
|
61
|
+
// property, validates and then serialises to nothing — "a row that reads like
|
|
62
|
+
// evidence and is not one", which is the shape this module exists to refuse.
|
|
63
|
+
// `unknownKeys` already judges the row by `Object.keys`, and `ownField` is
|
|
64
|
+
// that same notion, so the closed-shape check and the field reads cannot
|
|
65
|
+
// disagree about what the row contains.
|
|
66
|
+
for (const field of REQUIRED_TEXT)
|
|
67
|
+
nonBlankString(problems, field, ownField(input, field));
|
|
68
|
+
exactVersion(problems, 'harnessVersion', ownField(input, 'harnessVersion'));
|
|
69
|
+
matching(problems, 'observedAt', ownField(input, 'observedAt'), ISO_8601, 'an ISO-8601 date-time with a zone');
|
|
70
|
+
const status = ownField(input, 'status');
|
|
71
|
+
const known = member(problems, 'status', status, CAPABILITY_STATES);
|
|
72
|
+
const carriesReason = carriesField(input, 'downgradeReason');
|
|
73
|
+
if (known && status !== 'SUPPORTED') {
|
|
74
|
+
if (carriesReason)
|
|
75
|
+
nonBlankString(problems, 'downgradeReason', ownField(input, 'downgradeReason'));
|
|
76
|
+
else
|
|
77
|
+
problems.push({ field: 'downgradeReason', message: 'must be a non-blank string' });
|
|
78
|
+
}
|
|
79
|
+
// The shape is closed in both directions: a supported row has no reason to
|
|
80
|
+
// give, so a `downgradeReason` on one is refused rather than ignored. Left
|
|
81
|
+
// unchecked, the field went unvalidated on that branch and the narrowing
|
|
82
|
+
// below handed back a value typed `string` that was not one.
|
|
83
|
+
if (known && status === 'SUPPORTED' && carriesReason) {
|
|
84
|
+
problems.push({
|
|
85
|
+
field: 'downgradeReason',
|
|
86
|
+
message: 'a SUPPORTED row has nothing to explain, so it carries no downgrade reason',
|
|
87
|
+
});
|
|
88
|
+
}
|
|
89
|
+
if (problems.length > 0)
|
|
90
|
+
return { ok: false, problems };
|
|
91
|
+
// Every field above was checked against the shape, so the narrowing is the
|
|
92
|
+
// type the checks just established rather than an assertion over them.
|
|
93
|
+
return { ok: true, value: input };
|
|
94
|
+
}
|