Skip to content

Commit 32f9de0

Browse files
authored
Enforce release claim closeout contract (#95)
* fix: enforce release claim closeout contract * fix: scope release closeout contradictions * fix: harden repo closeout boundaries * fix: harden release closeout parsing * fix: align delivery-sensitive closeout contract * fix: fail closed on release metadata drift * fix: reject unknown release contradiction checks * fix: harden release contradiction validation
1 parent 2670c24 commit 32f9de0

10 files changed

Lines changed: 1612 additions & 77 deletions

bin/lib/evidence-contract.mjs

Lines changed: 216 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,11 @@
11
const EVIDENCE_KINDS = Object.freeze(['code', 'test', 'runtime', 'delivery', 'human']);
22
const DELIVERY_POSTURES = Object.freeze(['repo_only', 'delivery_sensitive']);
33
const CLOSURE_SURFACES = Object.freeze(['verify', 'audit-milestone', 'complete-milestone']);
4+
const RELEASE_CLAIM_POSTURES = Object.freeze([
5+
'repo_closeout',
6+
'runtime_validated_closeout',
7+
'delivery_supported_closeout',
8+
]);
49

510
const LEGACY_EVIDENCE_ALIASES = Object.freeze({
611
code: 'code',
@@ -22,8 +27,8 @@ const EVIDENCE_MATRIX = Object.freeze({
2227
blockedSoloKinds: Object.freeze(['human', 'delivery']),
2328
}),
2429
delivery_sensitive: Object.freeze({
25-
requiredKinds: Object.freeze(['code', 'runtime']),
26-
recommendedKinds: Object.freeze(['test', 'delivery', 'human']),
30+
requiredKinds: Object.freeze(['code', 'runtime', 'delivery']),
31+
recommendedKinds: Object.freeze(['test', 'human']),
2732
blockedSoloKinds: Object.freeze(['code', 'human']),
2833
}),
2934
}),
@@ -53,7 +58,45 @@ const EVIDENCE_MATRIX = Object.freeze({
5358
}),
5459
});
5560

56-
export { CLOSURE_SURFACES, DELIVERY_POSTURES, EVIDENCE_KINDS };
61+
const CONTRADICTION_CATEGORIES = Object.freeze([
62+
'evidence',
63+
'public_surface',
64+
'runtime',
65+
'delivery',
66+
'planning_drift',
67+
'generated_surface',
68+
]);
69+
70+
const CONTRADICTION_STATUSES = Object.freeze(['passed', 'failed', 'not_applicable']);
71+
72+
const RELEASE_CLAIM_MATRIX = Object.freeze({
73+
repo_closeout: Object.freeze({
74+
deliveryPosture: 'repo_only',
75+
requiredClaimKinds: Object.freeze([]),
76+
allowedClaim: 'Repo-local milestone or phase closeout is supported by planning and repository artifacts only.',
77+
invalidClaim: 'Do not imply runtime validation, delivery, publication, or public support from repo-local closeout alone.',
78+
}),
79+
runtime_validated_closeout: Object.freeze({
80+
deliveryPosture: 'repo_only',
81+
requiredClaimKinds: Object.freeze(['runtime']),
82+
allowedClaim: 'Runtime behavior or a runtime surface was directly executed and observed for the named runtime or surface.',
83+
invalidClaim: 'Do not generalize validation from one runtime or generated surface to another.',
84+
}),
85+
delivery_supported_closeout: Object.freeze({
86+
deliveryPosture: 'delivery_sensitive',
87+
requiredClaimKinds: Object.freeze([]),
88+
allowedClaim: 'Externally consumed release, support, install, or delivery claims are supported by the delivery-sensitive evidence bar.',
89+
invalidClaim: 'Do not imply merge, package, tag, GitHub Release, publication, generated-surface freshness, or public support without matching delivery evidence.',
90+
}),
91+
});
92+
93+
const CONTRADICTION_BLOCKERS_BY_POSTURE = Object.freeze({
94+
repo_closeout: Object.freeze(['evidence', 'public_surface', 'planning_drift']),
95+
runtime_validated_closeout: Object.freeze(['evidence', 'runtime', 'generated_surface', 'planning_drift']),
96+
delivery_supported_closeout: CONTRADICTION_CATEGORIES,
97+
});
98+
99+
export { CLOSURE_SURFACES, DELIVERY_POSTURES, EVIDENCE_KINDS, RELEASE_CLAIM_POSTURES };
57100

58101
export function normalizeEvidenceKind(kind) {
59102
if (!kind) {
@@ -78,6 +121,11 @@ export function isClosureSurface(surface) {
78121
return CLOSURE_SURFACES.includes(surface);
79122
}
80123

124+
export function normalizeReleaseClaimPosture(posture) {
125+
if (!posture) return 'repo_closeout';
126+
return RELEASE_CLAIM_POSTURES.includes(posture) ? posture : null;
127+
}
128+
81129
export function getEvidenceContract(surface, deliveryPosture) {
82130
const matrix = EVIDENCE_MATRIX[surface];
83131
if (!matrix) {
@@ -108,5 +156,170 @@ export function describeEvidenceSurface(surface) {
108156
surface,
109157
supportedKinds: [...EVIDENCE_KINDS],
110158
deliveryPostures: DELIVERY_POSTURES.map((deliveryPosture) => getEvidenceContract(surface, deliveryPosture)),
159+
releaseClaimPostures: RELEASE_CLAIM_POSTURES.map((releaseClaimPosture) => getReleaseClaimContract(surface, releaseClaimPosture)),
160+
};
161+
}
162+
163+
function uniqueKinds(kinds) {
164+
return [...new Set(kinds)];
165+
}
166+
167+
function getDowngradePosture(observedKinds) {
168+
if (observedKinds.includes('runtime')) {
169+
return 'runtime_validated_closeout';
170+
}
171+
return 'repo_closeout';
172+
}
173+
174+
export function getReleaseClaimContract(surface, releaseClaimPosture = 'repo_closeout') {
175+
const posture = normalizeReleaseClaimPosture(releaseClaimPosture);
176+
if (!posture) {
177+
throw new Error(`Unsupported release claim posture: ${releaseClaimPosture}`);
178+
}
179+
const claim = RELEASE_CLAIM_MATRIX[posture];
180+
const evidence = getEvidenceContract(surface, claim.deliveryPosture);
181+
const requiredKinds = uniqueKinds([...evidence.requiredKinds, ...claim.requiredClaimKinds]);
182+
183+
return {
184+
surface,
185+
releaseClaimPosture: posture,
186+
deliveryPosture: claim.deliveryPosture,
187+
supportedKinds: [...EVIDENCE_KINDS],
188+
requiredKinds,
189+
requiredClaimKinds: [...claim.requiredClaimKinds],
190+
allowedClaim: claim.allowedClaim,
191+
invalidClaim: claim.invalidClaim,
192+
waiverRule: 'Waivers may only narrow the release claim posture or defer an unsupported claim; they never satisfy missing required evidence for the stronger claim.',
193+
deferralRule: 'Deferrals must name the unsupported claim, missing evidence kinds, and later workflow or milestone candidate when known.',
194+
contradictionCategories: [...CONTRADICTION_CATEGORIES],
195+
};
196+
}
197+
198+
export function evaluateReleaseClaimPosture({
199+
surface,
200+
releaseClaimPosture = 'repo_closeout',
201+
observedKinds = [],
202+
waivedKinds = [],
203+
} = {}) {
204+
const contract = getReleaseClaimContract(surface, releaseClaimPosture);
205+
const observed = normalizeEvidenceKinds(observedKinds);
206+
const waived = normalizeEvidenceKinds(waivedKinds);
207+
const missingKinds = contract.requiredKinds.filter((kind) => !observed.includes(kind));
208+
const invalidWaivers = waived.filter((kind) => missingKinds.includes(kind));
209+
const hasUnsupportedStrongClaim = contract.releaseClaimPosture !== 'repo_closeout' && missingKinds.length > 0;
210+
211+
return {
212+
surface: contract.surface,
213+
releaseClaimPosture: contract.releaseClaimPosture,
214+
deliveryPosture: contract.deliveryPosture,
215+
requiredKinds: [...contract.requiredKinds],
216+
observedKinds: observed,
217+
missingKinds,
218+
invalidWaivers,
219+
status: missingKinds.length === 0 && invalidWaivers.length === 0 ? 'supported' : 'unsupported',
220+
disposition: hasUnsupportedStrongClaim ? 'downgrade_or_defer' : missingKinds.length > 0 ? 'block_or_defer' : 'proceed',
221+
downgradeTo: hasUnsupportedStrongClaim ? getDowngradePosture(observed) : null,
222+
deferredClaims: hasUnsupportedStrongClaim
223+
? [{ claim: contract.releaseClaimPosture, missingKinds }]
224+
: [],
225+
};
226+
}
227+
228+
export function evaluateReleaseClaimCloseoutContract({
229+
surface,
230+
deliveryPosture = null,
231+
releaseClaimPosture = 'repo_closeout',
232+
observedKinds = [],
233+
waivedKinds = [],
234+
unsupportedClaims = [],
235+
deferrals = [],
236+
contradictionChecks = {},
237+
} = {}) {
238+
const posture = evaluateReleaseClaimPosture({
239+
surface,
240+
releaseClaimPosture,
241+
observedKinds,
242+
waivedKinds,
243+
});
244+
const missingContradictionChecks = CONTRADICTION_CATEGORIES.filter((name) => !(name in contradictionChecks));
245+
const failedContradictionChecks = Object.entries(contradictionChecks)
246+
.filter(([, status]) => status === 'failed')
247+
.map(([name]) => name);
248+
const unknownContradictionChecks = Object.keys(contradictionChecks)
249+
.filter((name) => !CONTRADICTION_CATEGORIES.includes(name));
250+
const invalidContradictionChecks = Object.entries(contradictionChecks)
251+
.filter(([, status]) => !CONTRADICTION_STATUSES.includes(status))
252+
.map(([name]) => name);
253+
const blockingContradictionChecks = failedContradictionChecks.filter((name) =>
254+
CONTRADICTION_BLOCKERS_BY_POSTURE[posture.releaseClaimPosture].includes(name)
255+
);
256+
const unresolvedUnsupportedClaims = unsupportedClaims.filter((claim) =>
257+
!deferrals.some((deferral) => namesUnsupportedClaim(deferral, claim))
258+
);
259+
const blockers = [];
260+
261+
if (deliveryPosture && deliveryPosture !== posture.deliveryPosture) {
262+
blockers.push({
263+
code: 'incompatible_release_claim_posture',
264+
details: [`${deliveryPosture} cannot support ${posture.releaseClaimPosture}; expected ${posture.deliveryPosture}`],
265+
});
266+
}
267+
268+
if (posture.missingKinds.length > 0) {
269+
blockers.push({ code: 'missing_required_release_evidence', details: posture.missingKinds });
270+
}
271+
if (posture.invalidWaivers.length > 0) {
272+
blockers.push({ code: 'invalid_release_waivers', details: posture.invalidWaivers });
273+
}
274+
if (unresolvedUnsupportedClaims.length > 0) {
275+
blockers.push({ code: 'unsupported_release_claims', details: unresolvedUnsupportedClaims });
276+
}
277+
if (missingContradictionChecks.length > 0) {
278+
blockers.push({ code: 'missing_release_contradiction_checks', details: missingContradictionChecks });
279+
}
280+
if (unknownContradictionChecks.length > 0) {
281+
blockers.push({ code: 'unknown_release_contradiction_checks', details: unknownContradictionChecks });
282+
}
283+
if (invalidContradictionChecks.length > 0) {
284+
blockers.push({ code: 'invalid_release_contradiction_checks', details: invalidContradictionChecks });
285+
}
286+
if (blockingContradictionChecks.length > 0) {
287+
blockers.push({ code: 'failed_release_contradiction_checks', details: blockingContradictionChecks });
288+
}
289+
290+
return {
291+
...posture,
292+
unsupportedClaims: [...unsupportedClaims],
293+
deferrals: [...deferrals],
294+
failedContradictionChecks: blockingContradictionChecks,
295+
allFailedContradictionChecks: failedContradictionChecks,
296+
missingContradictionChecks,
297+
unknownContradictionChecks,
298+
invalidContradictionChecks,
299+
unresolvedUnsupportedClaims,
300+
blockers,
301+
status: blockers.length === 0 ? 'supported' : 'unsupported',
111302
};
112303
}
304+
305+
function namesUnsupportedClaim(deferral, claim) {
306+
const normalizedDeferral = normalizeClaimText(deferral);
307+
const normalizedClaim = normalizeClaimText(claim);
308+
if (!normalizedDeferral || !normalizedClaim) return false;
309+
return normalizedDeferral.includes(normalizedClaim) && isStructuredDeferral(normalizedDeferral);
310+
}
311+
312+
function isStructuredDeferral(normalizedDeferral) {
313+
const namesEvidenceKind = EVIDENCE_KINDS.some((kind) => normalizedDeferral.includes(kind));
314+
const namesLaterTarget = /\b(later|next|future|workflow|milestone|phase|gsdd)\b/.test(normalizedDeferral);
315+
return namesEvidenceKind && namesLaterTarget;
316+
}
317+
318+
function normalizeClaimText(value) {
319+
return String(value || '')
320+
.trim()
321+
.toLowerCase()
322+
.replace(/[^a-z0-9]+/g, ' ')
323+
.replace(/\s+/g, ' ')
324+
.trim();
325+
}

0 commit comments

Comments
 (0)