backend-skeleton 1.6.0 → 1.7.0
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/bin/bskel.mjs +220 -12
- package/contracts/completeness.mjs +12 -1
- package/lib/cli.mjs +48 -2
- package/lib/decision-log.mjs +58 -0
- package/lib/gate-export.mjs +55 -4
- package/lib/impact.mjs +51 -3
- package/package.json +1 -1
- package/schemas/decision-event.schema.json +46 -0
- package/schemas/gate-export.schema.json +114 -38
package/bin/bskel.mjs
CHANGED
|
@@ -30,8 +30,9 @@ import { ADAPTERS, LOAD_ERRORS, adapterById } from '../scanners/registry.mjs';
|
|
|
30
30
|
import { COMMAND_CAPABILITIES, CAPABILITY_SATISFIERS, explainMissingCapability } from '../scanners/capabilities.mjs';
|
|
31
31
|
import { buildContract, selectModule, CONTRACT_SCHEMA_VERSION } from '../contracts/emit.mjs';
|
|
32
32
|
import { validateEnvelope, operationPayloadSchema } from '../contracts/validate.mjs';
|
|
33
|
-
import { evaluateResolution, loadResolution, saveResolution, requireWarningCode, warningKey, countByCode } from '../contracts/completeness.mjs';
|
|
33
|
+
import { evaluateResolution, loadResolution, saveResolution, requireWarningCode, warningKey, countByCode, isWaiverExpired } from '../contracts/completeness.mjs';
|
|
34
34
|
import { loadPatchApprovals, savePatchApprovals, approvalKey } from '../lib/patch-approvals.mjs';
|
|
35
|
+
import { appendDecisionEvent, readDecisionLog } from '../lib/decision-log.mjs';
|
|
35
36
|
import { proposeTransaction, approveTransaction, applyTransaction, rollbackTransaction, loadTransaction, listTransactions } from '../lib/patch-transactions.mjs';
|
|
36
37
|
import { getPatchKind, replanTransaction, PATCH_KIND_NAMES } from '../lib/patch-kinds.mjs';
|
|
37
38
|
import { generateKeypair, signPayload, verifyPayload, publicKeyIdFromPrivate, publicKeyIdFromPublic } from '../lib/attest.mjs';
|
|
@@ -42,7 +43,7 @@ import {
|
|
|
42
43
|
declareDependency, removeDependency, buildDependencyListReport,
|
|
43
44
|
} from '../lib/field-dependencies.mjs';
|
|
44
45
|
import {
|
|
45
|
-
ImpactOperationError, checkImpact, acceptImpact, recordDisposition, acknowledgeInbound,
|
|
46
|
+
ImpactOperationError, checkImpact, acceptImpact, recordDisposition, acknowledgeInbound, withdrawDisposition,
|
|
46
47
|
} from '../lib/impact.mjs';
|
|
47
48
|
import { buildImpactGraph } from '../lib/impact-graph.mjs';
|
|
48
49
|
import { toGraphifyExtraction, toMermaid } from '../lib/impact-export-graphify.mjs';
|
|
@@ -103,6 +104,7 @@ function usage() {
|
|
|
103
104
|
bskel scan repair --feature <id> [--json]
|
|
104
105
|
bskel scan cross-feature-check --feature <id> [--db [--database-url-env <NAME>] [--schema public]] [--json]
|
|
105
106
|
bskel scan cross-feature-waive --feature <id> --signal resource_type|table|operation_id|db_foreign_key --identifier <name> --other-feature <id> --reason "..."
|
|
107
|
+
bskel scan cross-feature-unwaive --feature <id> --signal resource_type|table|operation_id|db_foreign_key --identifier <name> --other-feature <id> --reason "..." [--json]
|
|
106
108
|
bskel feature init --slug <name>
|
|
107
109
|
bskel feature list [--all] [--json]
|
|
108
110
|
bskel feature show <id> [--json]
|
|
@@ -117,12 +119,13 @@ function usage() {
|
|
|
117
119
|
bskel contract validate --feature <id> --file <envelope.json>
|
|
118
120
|
bskel contract tool-schema --feature <id> --operation <operationId>
|
|
119
121
|
bskel contract waive --feature <id> --code <CODE> (--subject "VERB /path"|--all) --reason "..." [--expires <Nd>]
|
|
122
|
+
bskel contract unwaive --feature <id> --code <CODE> --subject "VERB /path" --reason "..." [--json]
|
|
120
123
|
bskel dependency declare --feature <id> --resource <Type> --field <name> --source-feature <id> --source-resource <Type> --source-field <name> --reason "..." [--memo "..."]
|
|
121
124
|
bskel dependency remove --feature <id> --resource <Type> --field <name> --source-feature <id> --source-resource <Type> --source-field <name> --reason "..."
|
|
122
125
|
bskel dependency list --feature <id> [--json]
|
|
123
126
|
bskel impact check --feature <id> [--all] [--json]
|
|
124
127
|
bskel impact accept --feature <id> [--json]
|
|
125
|
-
bskel impact disposition --feature <id> --change <change_key> --downstream <id> --mode <compatible|migrate|waive>
|
|
128
|
+
bskel impact disposition --feature <id> --change <change_key> --downstream <id> (--mode <compatible|migrate|waive> [--tracked-by "..."] [--expires-days <N>] | --withdraw) --reason "..." [--json]
|
|
126
129
|
bskel impact ack --feature <id> --from <id> --change <change_key> --reason "..." [--json]
|
|
127
130
|
bskel impact export --format <graphify|json|mermaid> [--out <path>] [--focus <id>] [--rings <N>]
|
|
128
131
|
bskel rules check --feature <id> [--init] [--json]
|
|
@@ -134,6 +137,7 @@ function usage() {
|
|
|
134
137
|
bskel handles plan --feature <id> [--module <name>] [--resource type1,type2] [--diff] [--ast]
|
|
135
138
|
bskel handles emit --feature <id> [--module <name>] [--resource type1,type2] [--force --reason "..."] [--check] [--diff] [--enforce-registry on|off --reason "..."]
|
|
136
139
|
bskel handles patch approve --feature <id> [--module <name>] --resource <Type> --field <name> --strategy patch-wrapper|null-means-unchanged --reason "..." [--json]
|
|
140
|
+
bskel handles patch unapprove --feature <id> --resource <Type> --field <name> --reason "..." [--json]
|
|
137
141
|
bskel handles audit --feature <id> --database-url-env <NAME> [--resource type1,type2] [--module <name>] [--check-registry-coverage] [--json]
|
|
138
142
|
bskel patch propose --feature <id> [--kind config-apply|ddl-apply|java-source-splice] --choice <stackChoiceId> --target <config_check target path> --database-url-env <NAME> --schema <name> --sql-file <path> --splice-file <path> [--json]
|
|
139
143
|
bskel patch approve --feature <id> --transaction <id> --reason "..." [--json]
|
|
@@ -1025,6 +1029,10 @@ function cmdScanCrossFeatureWaive(args) {
|
|
|
1025
1029
|
waivers: [...resolution.waivers.filter((w) => waiverKey(w) !== key), entry],
|
|
1026
1030
|
};
|
|
1027
1031
|
saveCrossFeatureResolution(root, flags.feature, next);
|
|
1032
|
+
appendDecisionEvent(root, flags.feature, {
|
|
1033
|
+
kind: 'cross_feature_waiver', action: 'record', at: entry.at, reason: entry.reason, feature_id: flags.feature,
|
|
1034
|
+
subject: { signal: entry.signal, identifier: entry.identifier, other_feature: entry.other_feature },
|
|
1035
|
+
});
|
|
1028
1036
|
return next;
|
|
1029
1037
|
});
|
|
1030
1038
|
|
|
@@ -1048,6 +1056,64 @@ function cmdScanCrossFeatureWaive(args) {
|
|
|
1048
1056
|
process.exit(evaluation.blocking ? EXIT.AWAITING_DISPOSITION : EXIT.PASS);
|
|
1049
1057
|
}
|
|
1050
1058
|
|
|
1059
|
+
// D-decision-event-log (D6): forward-only retraction, mirroring `gate revoke` -- removes the
|
|
1060
|
+
// waiver entry and appends a `withdraw` decision event. Never a snapshot restore.
|
|
1061
|
+
function cmdScanCrossFeatureUnwaive(args) {
|
|
1062
|
+
const flags = parseCommand('scan cross-feature-unwaive', args);
|
|
1063
|
+
if (flags.help) { console.log(renderCommandHelp('scan cross-feature-unwaive')); process.exit(0); }
|
|
1064
|
+
setContext('scan cross-feature-unwaive', flags);
|
|
1065
|
+
const root = requireRepoRoot();
|
|
1066
|
+
requireValidFeatureId(flags.feature);
|
|
1067
|
+
requireValidFeatureId(flags['other-feature']);
|
|
1068
|
+
if (!flags.reason || !flags.reason.trim()) {
|
|
1069
|
+
fail(EXIT_CODES.BAD_ARGS, 'BAD_ARGS', 'bskel scan cross-feature-unwaive requires --reason "..." -- every withdrawal must be auditable');
|
|
1070
|
+
}
|
|
1071
|
+
const report = loadCrossFeatureReport(root, flags.feature);
|
|
1072
|
+
if (!report) {
|
|
1073
|
+
fail(EXIT_CODES.NOT_PASSED, 'MISSING_ARTIFACT', `no cross-feature-report.json for feature "${flags.feature}"`);
|
|
1074
|
+
}
|
|
1075
|
+
const key = waiverKey({ signal: flags.signal, identifier: flags.identifier, other_feature: flags['other-feature'] });
|
|
1076
|
+
|
|
1077
|
+
const updated = withLockSync(root, 'state', () => {
|
|
1078
|
+
const resolution = loadCrossFeatureResolution(root, flags.feature);
|
|
1079
|
+
const match = resolution.waivers.find((w) => waiverKey(w) === key);
|
|
1080
|
+
if (!match) {
|
|
1081
|
+
fail(EXIT_CODES.NOT_PASSED, 'MISSING_ARTIFACT', `no waiver recorded for --signal ${flags.signal} --identifier "${flags.identifier}" --other-feature ${flags['other-feature']} -- nothing to unwaive`);
|
|
1082
|
+
}
|
|
1083
|
+
const at = new Date().toISOString();
|
|
1084
|
+
const next = {
|
|
1085
|
+
schema: 'sbf.cross-feature-resolution/1',
|
|
1086
|
+
feature_id: flags.feature,
|
|
1087
|
+
waivers: resolution.waivers.filter((w) => waiverKey(w) !== key),
|
|
1088
|
+
};
|
|
1089
|
+
saveCrossFeatureResolution(root, flags.feature, next);
|
|
1090
|
+
appendDecisionEvent(root, flags.feature, {
|
|
1091
|
+
kind: 'cross_feature_waiver', action: 'withdraw', at, reason: flags.reason, feature_id: flags.feature,
|
|
1092
|
+
subject: { signal: match.signal, identifier: match.identifier, other_feature: match.other_feature },
|
|
1093
|
+
});
|
|
1094
|
+
return next;
|
|
1095
|
+
});
|
|
1096
|
+
|
|
1097
|
+
const evaluation = evaluateCrossFeatureFindings(report.findings, updated);
|
|
1098
|
+
const evidence = {
|
|
1099
|
+
finding_count: report.findings.length,
|
|
1100
|
+
high_confidence_count: report.findings.filter((f) => f.confidence === 'high').length,
|
|
1101
|
+
waived_count: evaluation.waived.length,
|
|
1102
|
+
stale_waivers: evaluation.staleWaivers.length,
|
|
1103
|
+
};
|
|
1104
|
+
const gateState = evaluation.blocking
|
|
1105
|
+
? awaitNamedGateDisposition(root, 'cross_feature', flags.feature, { ...evidence, unwaived: evaluation.unwaived })
|
|
1106
|
+
: passNamedGate(root, 'cross_feature', flags.feature, evidence);
|
|
1107
|
+
|
|
1108
|
+
if (flags.json) {
|
|
1109
|
+
console.log(JSON.stringify({ withdrawn: true, gate: gateState.gates.cross_feature }, null, 2));
|
|
1110
|
+
} else if (!flags.quiet) {
|
|
1111
|
+
console.log(`unwaived: ${flags.signal} "${flags.identifier}" (${flags['other-feature']})`);
|
|
1112
|
+
console.log(`gate: cross_feature -> ${gateState.gates.cross_feature.status}`);
|
|
1113
|
+
}
|
|
1114
|
+
process.exit(evaluation.blocking ? EXIT.AWAITING_DISPOSITION : EXIT.PASS);
|
|
1115
|
+
}
|
|
1116
|
+
|
|
1051
1117
|
// D6 (D-feature-lifecycle): the whole read-specs/->compute-NNN->write-feature.json->
|
|
1052
1118
|
// load-modify-save-feature-index.json sequence runs under one exclusive lock -- confirmed live
|
|
1053
1119
|
// during this item's own grounding, the same lost-update shape S5 already fixed for setGate():
|
|
@@ -1913,20 +1979,38 @@ function cmdContractWaive(args) {
|
|
|
1913
1979
|
// entries). Locking only the final write (inside saveResolution()) would NOT close this race --
|
|
1914
1980
|
// the window is between this function's own loadResolution() read and its save, not inside the
|
|
1915
1981
|
// write call itself.
|
|
1916
|
-
|
|
1982
|
+
// D-waiver-renewal: `existingKeys` -- the "already waived, do nothing" set -- must only contain
|
|
1983
|
+
// keys of waivers that are still LIVE. Before this fix it was built from every stored waiver
|
|
1984
|
+
// regardless of expires_at, so an expired waiver's key permanently "existed" and every renewal
|
|
1985
|
+
// attempt matched it and produced zero new entries (see contracts/completeness.mjs's
|
|
1986
|
+
// isWaiverExpired() for the shared predicate this now shares with evaluateResolution() -- the
|
|
1987
|
+
// two were previously two unsynchronized inline checks). A renewal replaces the expired entry
|
|
1988
|
+
// (same key never appears twice) rather than appending alongside it, which would otherwise leave
|
|
1989
|
+
// evaluateResolution()'s expiredKeys/waivedKeys sets computing over two same-key entries.
|
|
1990
|
+
const { resolution: updatedResolution, newEntries, renewedEntries } = withLockSync(root, 'state', () => {
|
|
1917
1991
|
const resolution = loadResolution(root, flags.feature);
|
|
1918
|
-
const
|
|
1992
|
+
const now = Date.now();
|
|
1993
|
+
const liveWaivers = (resolution.waivers ?? []).filter((w) => !isWaiverExpired(w, now));
|
|
1994
|
+
const expiredKeys = new Set((resolution.waivers ?? []).filter((w) => isWaiverExpired(w, now)).map(warningKey));
|
|
1995
|
+
const liveKeys = new Set(liveWaivers.map(warningKey));
|
|
1919
1996
|
const at = new Date().toISOString();
|
|
1920
|
-
const
|
|
1921
|
-
|
|
1922
|
-
|
|
1997
|
+
const toRecord = toWaive.filter((w) => !liveKeys.has(warningKey(w)));
|
|
1998
|
+
const entries = toRecord.map((w) => ({ code: w.code, subject: w.subject, reason: flags.reason, at, ...(expiresAt ? { expires_at: expiresAt } : {}) }));
|
|
1999
|
+
const newEntriesOut = entries.filter((e) => !expiredKeys.has(warningKey(e)));
|
|
2000
|
+
const renewedEntriesOut = entries.filter((e) => expiredKeys.has(warningKey(e)));
|
|
1923
2001
|
const next = {
|
|
1924
2002
|
schema: 'sbf.contract-resolution/1',
|
|
1925
2003
|
feature_id: flags.feature,
|
|
1926
|
-
waivers: [...
|
|
2004
|
+
waivers: [...liveWaivers, ...entries],
|
|
1927
2005
|
};
|
|
1928
2006
|
saveResolution(root, flags.feature, next);
|
|
1929
|
-
|
|
2007
|
+
for (const e of newEntriesOut) {
|
|
2008
|
+
appendDecisionEvent(root, flags.feature, { kind: 'contract_waiver', action: 'record', at: e.at, reason: e.reason, feature_id: flags.feature, subject: { code: e.code, subject: e.subject ?? null }, expires_at: e.expires_at ?? null });
|
|
2009
|
+
}
|
|
2010
|
+
for (const e of renewedEntriesOut) {
|
|
2011
|
+
appendDecisionEvent(root, flags.feature, { kind: 'contract_waiver', action: 'renew', at: e.at, reason: e.reason, feature_id: flags.feature, subject: { code: e.code, subject: e.subject ?? null }, expires_at: e.expires_at ?? null });
|
|
2012
|
+
}
|
|
2013
|
+
return { resolution: next, newEntries: newEntriesOut, renewedEntries: renewedEntriesOut };
|
|
1930
2014
|
});
|
|
1931
2015
|
|
|
1932
2016
|
const evaluation = evaluateResolution(contract, updatedResolution);
|
|
@@ -1944,10 +2028,14 @@ function cmdContractWaive(args) {
|
|
|
1944
2028
|
: passNamedGate(root, 'contract', flags.feature, evidence);
|
|
1945
2029
|
|
|
1946
2030
|
if (flags.json) {
|
|
1947
|
-
console.log(JSON.stringify({ waived: newEntries, gate: gateState.gates.contract }, null, 2));
|
|
2031
|
+
console.log(JSON.stringify({ waived: newEntries, renewed: renewedEntries, gate: gateState.gates.contract }, null, 2));
|
|
1948
2032
|
} else {
|
|
1949
2033
|
if (!flags.quiet) {
|
|
1950
|
-
|
|
2034
|
+
const alreadyWaived = toWaive.length - newEntries.length - renewedEntries.length;
|
|
2035
|
+
console.log(`waived ${newEntries.length} new warning(s)${alreadyWaived > 0 ? ` (${alreadyWaived} already waived)` : ''}${expiresAt ? `, expiring ${expiresAt}` : ''}`);
|
|
2036
|
+
if (renewedEntries.length > 0) {
|
|
2037
|
+
console.log(`renewed ${renewedEntries.length} expired waiver(s)${expiresAt ? `, expiring ${expiresAt}` : ''}`);
|
|
2038
|
+
}
|
|
1951
2039
|
console.log(`gate: contract -> ${gateState.gates.contract.status}`);
|
|
1952
2040
|
}
|
|
1953
2041
|
if (evaluation.expiredWaivers.length > 0) {
|
|
@@ -1962,6 +2050,65 @@ function cmdContractWaive(args) {
|
|
|
1962
2050
|
process.exit(evaluation.blocking ? EXIT.AWAITING_DISPOSITION : EXIT.PASS);
|
|
1963
2051
|
}
|
|
1964
2052
|
|
|
2053
|
+
// D-decision-event-log (D6): forward-only retraction of exactly one {code, subject} waiver --
|
|
2054
|
+
// mirrors `gate revoke`'s own precedent (the entry's absence IS the state, not a snapshot restore
|
|
2055
|
+
// of its prior reason/expiry, which stays recoverable only from the decision log). Re-evaluates
|
|
2056
|
+
// and re-sets the gate the same way `cmdContractWaive` does, since removing a waiver can turn a
|
|
2057
|
+
// passing gate back into an awaiting_disposition one.
|
|
2058
|
+
function cmdContractUnwaive(args) {
|
|
2059
|
+
const flags = parseCommand('contract unwaive', args);
|
|
2060
|
+
if (flags.help) { console.log(renderCommandHelp('contract unwaive')); process.exit(0); }
|
|
2061
|
+
setContext('contract unwaive', flags);
|
|
2062
|
+
const root = requireRepoRoot();
|
|
2063
|
+
if (!flags.reason || !flags.reason.trim()) {
|
|
2064
|
+
fail(EXIT_CODES.BAD_ARGS, 'BAD_ARGS', 'bskel contract unwaive requires --reason "..." -- every withdrawal must be auditable');
|
|
2065
|
+
}
|
|
2066
|
+
const contract = loadContract(root, flags.feature);
|
|
2067
|
+
const key = warningKey({ code: flags.code, subject: flags.subject });
|
|
2068
|
+
|
|
2069
|
+
const updatedResolution = withLockSync(root, 'state', () => {
|
|
2070
|
+
const resolution = loadResolution(root, flags.feature);
|
|
2071
|
+
const match = (resolution.waivers ?? []).find((w) => warningKey(w) === key);
|
|
2072
|
+
if (!match) {
|
|
2073
|
+
fail(EXIT_CODES.NOT_PASSED, 'MISSING_ARTIFACT', `no waiver recorded for "${flags.code}" (${flags.subject}) on "${flags.feature}" -- nothing to unwaive`);
|
|
2074
|
+
}
|
|
2075
|
+
const at = new Date().toISOString();
|
|
2076
|
+
const next = {
|
|
2077
|
+
schema: 'sbf.contract-resolution/1',
|
|
2078
|
+
feature_id: flags.feature,
|
|
2079
|
+
waivers: (resolution.waivers ?? []).filter((w) => warningKey(w) !== key),
|
|
2080
|
+
};
|
|
2081
|
+
saveResolution(root, flags.feature, next);
|
|
2082
|
+
appendDecisionEvent(root, flags.feature, {
|
|
2083
|
+
kind: 'contract_waiver', action: 'withdraw', at, reason: flags.reason, feature_id: flags.feature,
|
|
2084
|
+
subject: { code: match.code, subject: match.subject ?? null }, expires_at: match.expires_at ?? null,
|
|
2085
|
+
});
|
|
2086
|
+
return next;
|
|
2087
|
+
});
|
|
2088
|
+
|
|
2089
|
+
const evaluation = evaluateResolution(contract, updatedResolution);
|
|
2090
|
+
const evidence = {
|
|
2091
|
+
operation_count: contract.completeness.operation_count,
|
|
2092
|
+
endpoint_count: contract.completeness.endpoint_count,
|
|
2093
|
+
completeness: evaluation.status,
|
|
2094
|
+
warning_codes: countByCode(contract.warnings),
|
|
2095
|
+
waived_count: evaluation.waived.length,
|
|
2096
|
+
stale_waivers: evaluation.staleWaivers.length,
|
|
2097
|
+
expired_waivers: evaluation.expiredWaivers.length,
|
|
2098
|
+
};
|
|
2099
|
+
const gateState = evaluation.blocking
|
|
2100
|
+
? awaitNamedGateDisposition(root, 'contract', flags.feature, { ...evidence, unwaived: evaluation.unwaived.map(({ code, subject }) => ({ code, subject })) })
|
|
2101
|
+
: passNamedGate(root, 'contract', flags.feature, evidence);
|
|
2102
|
+
|
|
2103
|
+
if (flags.json) {
|
|
2104
|
+
console.log(JSON.stringify({ withdrawn: { code: flags.code, subject: flags.subject }, gate: gateState.gates.contract }, null, 2));
|
|
2105
|
+
} else {
|
|
2106
|
+
console.log(`unwaived: ${flags.code} (${flags.subject})`);
|
|
2107
|
+
console.log(`gate: contract -> ${gateState.gates.contract.status}`);
|
|
2108
|
+
}
|
|
2109
|
+
process.exit(evaluation.blocking ? EXIT.AWAITING_DISPOSITION : EXIT.PASS);
|
|
2110
|
+
}
|
|
2111
|
+
|
|
1965
2112
|
// D-dependency-propagation-notice: called from cmdContractEmit/cmdHandlesEmit to warn a SOURCE
|
|
1966
2113
|
// feature, at the moment its own generated artifacts are refreshed, that other features declared a
|
|
1967
2114
|
// dependency on one of its fields. Only surfaces a note when the dependent's OWN `dependencies` gate
|
|
@@ -2142,11 +2289,30 @@ function cmdImpactAccept(args) {
|
|
|
2142
2289
|
process.exit(EXIT.PASS);
|
|
2143
2290
|
}
|
|
2144
2291
|
|
|
2292
|
+
// D-decision-event-log (D6): --withdraw is a flag variant of this same command, not a separate
|
|
2293
|
+
// subcommand -- withdrawal needs no --mode (there is nothing left to mode-classify once the
|
|
2294
|
+
// disposition is gone), so branching inside cmdImpactDisposition keeps the option set honest
|
|
2295
|
+
// rather than requiring --mode on a withdrawal that doesn't use it.
|
|
2145
2296
|
function cmdImpactDisposition(args) {
|
|
2146
2297
|
const flags = parseCommand('impact disposition', args);
|
|
2147
2298
|
if (flags.help) { console.log(renderCommandHelp('impact disposition')); process.exit(0); }
|
|
2148
2299
|
setContext('impact disposition', flags);
|
|
2149
2300
|
const root = requireRepoRoot();
|
|
2301
|
+
if (flags.withdraw) {
|
|
2302
|
+
let result;
|
|
2303
|
+
try {
|
|
2304
|
+
result = withdrawDisposition(root, { feature: flags.feature, changeKey: flags.change, downstreamFeature: flags.downstream, reason: flags.reason });
|
|
2305
|
+
} catch (err) {
|
|
2306
|
+
if (err instanceof ImpactOperationError) fail(err.exitCode, err.reasonCode, err.message);
|
|
2307
|
+
throw err;
|
|
2308
|
+
}
|
|
2309
|
+
if (flags.json) {
|
|
2310
|
+
console.log(JSON.stringify(result, null, 2));
|
|
2311
|
+
} else {
|
|
2312
|
+
console.log(`disposition withdrawn: ${flags.change} -> ${flags.downstream}`);
|
|
2313
|
+
}
|
|
2314
|
+
process.exit(EXIT.PASS);
|
|
2315
|
+
}
|
|
2150
2316
|
let result;
|
|
2151
2317
|
try {
|
|
2152
2318
|
result = recordDisposition(root, {
|
|
@@ -3346,6 +3512,10 @@ function cmdHandlesPatchApprove(args) {
|
|
|
3346
3512
|
const withoutExisting = (current.approvals ?? []).filter((a) => approvalKey(a.resource, a.field) !== key);
|
|
3347
3513
|
const next = { schema: 'sbf.patch-approvals/1', feature_id: flags.feature, approvals: [...withoutExisting, entry] };
|
|
3348
3514
|
savePatchApprovals(root, flags.feature, next);
|
|
3515
|
+
appendDecisionEvent(root, flags.feature, {
|
|
3516
|
+
kind: 'patch_approval', action: 'record', at, reason: flags.reason, feature_id: flags.feature,
|
|
3517
|
+
subject: { resource: flags.resource, field: flags.field }, strategy: flags.strategy,
|
|
3518
|
+
});
|
|
3349
3519
|
return next;
|
|
3350
3520
|
});
|
|
3351
3521
|
|
|
@@ -3353,6 +3523,41 @@ function cmdHandlesPatchApprove(args) {
|
|
|
3353
3523
|
process.exit(0);
|
|
3354
3524
|
}
|
|
3355
3525
|
|
|
3526
|
+
// D-decision-event-log (D6): forward-only retraction, mirroring `gate revoke` -- removes the
|
|
3527
|
+
// approval entry and appends a `withdraw` decision event. patch-approvals.json is not itself a
|
|
3528
|
+
// gate input (F3's fix is binding it into signed attestations, not making it a gate -- see
|
|
3529
|
+
// D-decision-event-log's own EXIT), so unlike the other three withdraw commands there is no gate
|
|
3530
|
+
// to re-evaluate here.
|
|
3531
|
+
function cmdHandlesPatchUnapprove(args) {
|
|
3532
|
+
const flags = parseCommand('handles patch unapprove', args);
|
|
3533
|
+
if (flags.help) { console.log(renderCommandHelp('handles patch unapprove')); process.exit(0); }
|
|
3534
|
+
setContext('handles patch unapprove', flags);
|
|
3535
|
+
const root = requireRepoRoot();
|
|
3536
|
+
if (!flags.reason || !flags.reason.trim()) {
|
|
3537
|
+
fail(EXIT_CODES.BAD_ARGS, 'BAD_ARGS', 'bskel handles patch unapprove requires --reason "..." -- every withdrawal must be auditable');
|
|
3538
|
+
}
|
|
3539
|
+
const key = approvalKey(flags.resource, flags.field);
|
|
3540
|
+
|
|
3541
|
+
const updated = withLockSync(root, 'state', () => {
|
|
3542
|
+
const current = loadPatchApprovals(root, flags.feature);
|
|
3543
|
+
const match = (current.approvals ?? []).find((a) => approvalKey(a.resource, a.field) === key);
|
|
3544
|
+
if (!match) {
|
|
3545
|
+
fail(EXIT_CODES.NOT_PASSED, 'MISSING_ARTIFACT', `no approval recorded for "${flags.resource}.${flags.field}" on "${flags.feature}" -- nothing to unapprove`);
|
|
3546
|
+
}
|
|
3547
|
+
const at = new Date().toISOString();
|
|
3548
|
+
const next = { schema: 'sbf.patch-approvals/1', feature_id: flags.feature, approvals: (current.approvals ?? []).filter((a) => approvalKey(a.resource, a.field) !== key) };
|
|
3549
|
+
savePatchApprovals(root, flags.feature, next);
|
|
3550
|
+
appendDecisionEvent(root, flags.feature, {
|
|
3551
|
+
kind: 'patch_approval', action: 'withdraw', at, reason: flags.reason, feature_id: flags.feature,
|
|
3552
|
+
subject: { resource: flags.resource, field: flags.field }, strategy: match.strategy,
|
|
3553
|
+
});
|
|
3554
|
+
return next;
|
|
3555
|
+
});
|
|
3556
|
+
|
|
3557
|
+
console.log(flags.json ? JSON.stringify(updated, null, 2) : `unapproved: ${flags.resource}.${flags.field}`);
|
|
3558
|
+
process.exit(0);
|
|
3559
|
+
}
|
|
3560
|
+
|
|
3356
3561
|
// D-patch-transactions: Slice 1 (config_check -> config_apply). All four commands only touch
|
|
3357
3562
|
// specs/<featureId>/patch-transactions/ except `apply`/`rollback`, which write to the real target
|
|
3358
3563
|
// file too -- matching D4's own "propose/approve are specs/-only, apply/rollback touch the repo"
|
|
@@ -4681,6 +4886,7 @@ async function dispatchCommand(cmd, rest) {
|
|
|
4681
4886
|
if (rest[0] === 'repair') return cmdScanRepair(rest.slice(1));
|
|
4682
4887
|
if (rest[0] === 'cross-feature-check') return cmdScanCrossFeatureCheck(rest.slice(1));
|
|
4683
4888
|
if (rest[0] === 'cross-feature-waive') return cmdScanCrossFeatureWaive(rest.slice(1));
|
|
4889
|
+
if (rest[0] === 'cross-feature-unwaive') return cmdScanCrossFeatureUnwaive(rest.slice(1));
|
|
4684
4890
|
await cmdScan(rest);
|
|
4685
4891
|
break;
|
|
4686
4892
|
}
|
|
@@ -4705,6 +4911,7 @@ async function dispatchCommand(cmd, rest) {
|
|
|
4705
4911
|
if (sub === 'validate') return cmdContractValidate(subArgs);
|
|
4706
4912
|
if (sub === 'tool-schema') return cmdContractToolSchema(subArgs);
|
|
4707
4913
|
if (sub === 'waive') return cmdContractWaive(subArgs);
|
|
4914
|
+
if (sub === 'unwaive') return cmdContractUnwaive(subArgs);
|
|
4708
4915
|
usage();
|
|
4709
4916
|
process.exit(14);
|
|
4710
4917
|
break;
|
|
@@ -4768,6 +4975,7 @@ async function dispatchCommand(cmd, rest) {
|
|
|
4768
4975
|
if (rest[0] === 'plan') return cmdHandlesPlan(rest.slice(1));
|
|
4769
4976
|
if (rest[0] === 'emit') return cmdHandlesEmit(rest.slice(1));
|
|
4770
4977
|
if (rest[0] === 'patch' && rest[1] === 'approve') return cmdHandlesPatchApprove(rest.slice(2));
|
|
4978
|
+
if (rest[0] === 'patch' && rest[1] === 'unapprove') return cmdHandlesPatchUnapprove(rest.slice(2));
|
|
4771
4979
|
if (rest[0] === 'audit') return await cmdHandlesAudit(rest.slice(1));
|
|
4772
4980
|
usage();
|
|
4773
4981
|
process.exit(14);
|
|
@@ -225,11 +225,22 @@ export function saveResolution(root, featureId, resolution) {
|
|
|
225
225
|
// its warning, so `unwaived`/`blocking` treat it exactly as if it had never been recorded. A
|
|
226
226
|
// waiver can be BOTH stale and expired at once (nothing prevents that combination); the two
|
|
227
227
|
// lists are independent, not mutually exclusive.
|
|
228
|
+
// D-waiver-renewal: the single shared expiry predicate. Previously inlined once here and NOT
|
|
229
|
+
// consulted by `cmdContractWaive`'s own "is this already waived" check (bin/bskel.mjs) -- that
|
|
230
|
+
// second, unsynchronized check meant an expired waiver was `waived` for evaluateResolution's
|
|
231
|
+
// purposes (correctly stops blocking) but simultaneously "already exists" for the CLI's own
|
|
232
|
+
// existingKeys set, so re-waiving it silently matched zero new entries and never updated the
|
|
233
|
+
// stored expires_at. Exporting one function closes that gap at the source instead of requiring
|
|
234
|
+
// two call sites to independently reimplement `Date.parse(w.expires_at) <= now` in sync forever.
|
|
235
|
+
export function isWaiverExpired(waiver, now = Date.now()) {
|
|
236
|
+
return typeof waiver.expires_at === 'string' && Date.parse(waiver.expires_at) <= now;
|
|
237
|
+
}
|
|
238
|
+
|
|
228
239
|
export function evaluateResolution(contract, resolution) {
|
|
229
240
|
const status = classifyContract(contract);
|
|
230
241
|
const waivers = resolution.waivers ?? [];
|
|
231
242
|
const now = Date.now();
|
|
232
|
-
const expiredWaivers = waivers.filter((w) =>
|
|
243
|
+
const expiredWaivers = waivers.filter((w) => isWaiverExpired(w, now));
|
|
233
244
|
const expiredKeys = new Set(expiredWaivers.map(warningKey));
|
|
234
245
|
const waivedKeys = new Set(waivers.filter((w) => !expiredKeys.has(warningKey(w))).map(warningKey));
|
|
235
246
|
|
package/lib/cli.mjs
CHANGED
|
@@ -187,6 +187,20 @@ export const COMMANDS = {
|
|
|
187
187
|
json: { type: 'boolean', default: false },
|
|
188
188
|
},
|
|
189
189
|
},
|
|
190
|
+
// D-decision-event-log (D6): forward-only retraction, mirroring `gate revoke` -- removes the
|
|
191
|
+
// waiver entry (its absence is the state) and appends a `withdraw` decision event. Never a
|
|
192
|
+
// snapshot restore.
|
|
193
|
+
'scan cross-feature-unwaive': {
|
|
194
|
+
usage: 'bskel scan cross-feature-unwaive --feature <id> --signal resource_type|table|operation_id|db_foreign_key --identifier <name> --other-feature <id> --reason "..." [--json]',
|
|
195
|
+
options: {
|
|
196
|
+
feature: { type: 'string', default: null, required: true },
|
|
197
|
+
signal: { type: 'string', default: null, required: true },
|
|
198
|
+
identifier: { type: 'string', default: null, required: true },
|
|
199
|
+
'other-feature': { type: 'string', default: null, required: true },
|
|
200
|
+
reason: { type: 'string', default: '' },
|
|
201
|
+
json: { type: 'boolean', default: false },
|
|
202
|
+
},
|
|
203
|
+
},
|
|
190
204
|
'feature init': {
|
|
191
205
|
usage: 'bskel feature init --slug <name>',
|
|
192
206
|
options: { slug: { type: 'string', default: null, required: true } },
|
|
@@ -299,6 +313,20 @@ export const COMMANDS = {
|
|
|
299
313
|
json: { type: 'boolean', default: false },
|
|
300
314
|
},
|
|
301
315
|
},
|
|
316
|
+
// D-decision-event-log (D6): forward-only retraction, mirroring `gate revoke` -- removes the
|
|
317
|
+
// waiver entry (its absence is the state) and appends a `withdraw` decision event. Never a
|
|
318
|
+
// snapshot restore (the entry's prior reason/expiry is preserved only in the decision log, not
|
|
319
|
+
// restorable through this command).
|
|
320
|
+
'contract unwaive': {
|
|
321
|
+
usage: 'bskel contract unwaive --feature <id> --code <CODE> --subject "VERB /path" --reason "..." [--json]',
|
|
322
|
+
options: {
|
|
323
|
+
feature: { type: 'string', default: null, required: true },
|
|
324
|
+
code: { type: 'string', default: null, required: true },
|
|
325
|
+
subject: { type: 'string', default: null, required: true },
|
|
326
|
+
reason: { type: 'string', default: '' },
|
|
327
|
+
json: { type: 'boolean', default: false },
|
|
328
|
+
},
|
|
329
|
+
},
|
|
302
330
|
'contract validate': {
|
|
303
331
|
usage: 'bskel contract validate --feature <id> --file <envelope.json>',
|
|
304
332
|
options: {
|
|
@@ -370,16 +398,21 @@ export const COMMANDS = {
|
|
|
370
398
|
},
|
|
371
399
|
// D-cross-feature-impact-graph (D6): three modes, three mechanically different consequences --
|
|
372
400
|
// `migrate` requires --tracked-by, `waive` requires --expires-days. `compatible` needs neither.
|
|
401
|
+
// D-decision-event-log (D6): --withdraw is a flag variant of this same command, not a separate
|
|
402
|
+
// subcommand -- a withdrawal needs no --mode, so `mode` is NOT `required: true` at the parse
|
|
403
|
+
// layer (recordDisposition() itself still refuses a missing/invalid mode on the record path --
|
|
404
|
+
// see cmdImpactDisposition's own comment for why this split is deliberate).
|
|
373
405
|
'impact disposition': {
|
|
374
|
-
usage: 'bskel impact disposition --feature <id> --change <change_key> --downstream <id> --mode compatible|migrate|waive
|
|
406
|
+
usage: 'bskel impact disposition --feature <id> --change <change_key> --downstream <id> (--mode compatible|migrate|waive [--tracked-by "<ref>"] [--expires-days <N>] | --withdraw) --reason "..." [--json]',
|
|
375
407
|
options: {
|
|
376
408
|
feature: { type: 'string', default: null, required: true },
|
|
377
409
|
change: { type: 'string', default: null, required: true },
|
|
378
410
|
downstream: { type: 'string', default: null, required: true },
|
|
379
|
-
mode: { type: 'string', default: null
|
|
411
|
+
mode: { type: 'string', default: null },
|
|
380
412
|
reason: { type: 'string', default: '' },
|
|
381
413
|
'tracked-by': { type: 'string', default: null },
|
|
382
414
|
'expires-days': { type: 'string', default: null, numeric: { min: 1 } },
|
|
415
|
+
withdraw: { type: 'boolean', default: false },
|
|
383
416
|
json: { type: 'boolean', default: false },
|
|
384
417
|
},
|
|
385
418
|
},
|
|
@@ -645,6 +678,19 @@ export const COMMANDS = {
|
|
|
645
678
|
json: { type: 'boolean', default: false },
|
|
646
679
|
},
|
|
647
680
|
},
|
|
681
|
+
// D-decision-event-log (D6): forward-only retraction, mirroring `gate revoke` -- removes the
|
|
682
|
+
// approval entry (its absence is the state) and appends a `withdraw` decision event. Never a
|
|
683
|
+
// snapshot restore.
|
|
684
|
+
'handles patch unapprove': {
|
|
685
|
+
usage: 'bskel handles patch unapprove --feature <id> --resource <Type> --field <name> --reason "..." [--json]',
|
|
686
|
+
options: {
|
|
687
|
+
feature: { type: 'string', default: null, required: true },
|
|
688
|
+
resource: { type: 'string', default: null, required: true },
|
|
689
|
+
field: { type: 'string', default: null, required: true },
|
|
690
|
+
reason: { type: 'string', default: '' },
|
|
691
|
+
json: { type: 'boolean', default: false },
|
|
692
|
+
},
|
|
693
|
+
},
|
|
648
694
|
// D-patch-transactions: content-addressed patch transactions, Slice 1 (config_check ->
|
|
649
695
|
// config_apply). `propose`/`approve` only touch specs/, so no --force escape exists on either --
|
|
650
696
|
// re-propose is the only remediation for a stale target. `rollback` alone gets --force (reverting
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
// D-decision-event-log: an append-only audit trail for the four spec-side decision files
|
|
2
|
+
// (contract waivers, cross-feature waivers, impact dispositions, patch approvals) -- mirrors
|
|
3
|
+
// lib/state.mjs's appendGateEvent()/readGateHistory() shape exactly (same file family, same
|
|
4
|
+
// schema-validated-JSONL contract, same corrupt-line-is-skipped-not-fatal resilience). A sibling
|
|
5
|
+
// to .sbf/<featureId>.history.jsonl, not a replacement for it -- gates keep their own log.
|
|
6
|
+
// WHY: F2 (found during this item's own grounding) -- all four decision files silently
|
|
7
|
+
// OVERWRITE the prior decision (reason/actor/timestamp) on re-decision, and none of the four had
|
|
8
|
+
// any retraction command. `gate revoke` is the only retraction primitive in the whole tool.
|
|
9
|
+
// COST: one more .sbf/ file per feature. Machine-local, gitignored-by-convention -- this log is
|
|
10
|
+
// NOT tamper-evident on its own (see schemas/decision-event.schema.json's own description);
|
|
11
|
+
// its evidentiary value comes from being rolled into a SIGNED gate-export attestation.
|
|
12
|
+
// EXIT: no cross-feature aggregate view; per-feature only, matching gate history's own scoping.
|
|
13
|
+
import fs from 'node:fs';
|
|
14
|
+
import { sbfDir } from './state.mjs';
|
|
15
|
+
import { validateAgainstSchema, formatSchemaErrors } from './schema-validate.mjs';
|
|
16
|
+
|
|
17
|
+
export function decisionLogPath(repoRoot, featureId) {
|
|
18
|
+
return `${sbfDir(repoRoot)}/${featureId}.decisions.jsonl`;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
// Called from inside the SAME withLockSync(root, 'state', ...) each of the four write paths
|
|
22
|
+
// already holds -- never opens its own lock, so the decision write and the log append can never
|
|
23
|
+
// observe each other out of order (same discipline setGate() already applies to gate writes).
|
|
24
|
+
export function appendDecisionEvent(repoRoot, featureId, event) {
|
|
25
|
+
const line = { schema: 'sbf.decision-event/1', ...event };
|
|
26
|
+
const { ok, errors } = validateAgainstSchema('decision-event.schema.json', line);
|
|
27
|
+
if (!ok) {
|
|
28
|
+
throw new Error(`refusing to append an invalid decision event for "${featureId}":\n${formatSchemaErrors(errors).join('\n')}`);
|
|
29
|
+
}
|
|
30
|
+
fs.mkdirSync(sbfDir(repoRoot), { recursive: true });
|
|
31
|
+
fs.appendFileSync(decisionLogPath(repoRoot, featureId), `${JSON.stringify(line)}\n`);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// Mirrors lib/gate-export.mjs's readGateHistory() resilience contract exactly: a corrupt/invalid
|
|
35
|
+
// line is skipped with a warning, never a hard failure.
|
|
36
|
+
export function readDecisionLog(repoRoot, featureId, { kind = null, onWarning } = {}) {
|
|
37
|
+
const file = decisionLogPath(repoRoot, featureId);
|
|
38
|
+
if (!fs.existsSync(file)) return [];
|
|
39
|
+
const lines = fs.readFileSync(file, 'utf8').split('\n').filter(Boolean);
|
|
40
|
+
const events = [];
|
|
41
|
+
for (const [i, line] of lines.entries()) {
|
|
42
|
+
let parsed;
|
|
43
|
+
try {
|
|
44
|
+
parsed = JSON.parse(line);
|
|
45
|
+
} catch {
|
|
46
|
+
onWarning?.(`${file}:${i + 1}: not valid JSON, skipped`);
|
|
47
|
+
continue;
|
|
48
|
+
}
|
|
49
|
+
const { ok, errors } = validateAgainstSchema('decision-event.schema.json', parsed);
|
|
50
|
+
if (!ok) {
|
|
51
|
+
onWarning?.(`${file}:${i + 1}: does not match schemas/decision-event.schema.json, skipped`, errors);
|
|
52
|
+
continue;
|
|
53
|
+
}
|
|
54
|
+
if (kind && parsed.kind !== kind) continue;
|
|
55
|
+
events.push(parsed);
|
|
56
|
+
}
|
|
57
|
+
return events;
|
|
58
|
+
}
|
package/lib/gate-export.mjs
CHANGED
|
@@ -13,13 +13,17 @@ import { specPath, sbfPath } from './paths.mjs';
|
|
|
13
13
|
import { crossFeatureReportPath, crossFeatureResolutionPath } from './cross-feature-collisions.mjs';
|
|
14
14
|
import { dependenciesPath } from './field-dependencies.mjs';
|
|
15
15
|
import { impactBaselinePath } from './impact-surface.mjs';
|
|
16
|
-
import { impactReportPath, impactResolutionPath } from './impact.mjs';
|
|
16
|
+
import { impactReportPath, impactResolutionPath, loadImpactResolution } from './impact.mjs';
|
|
17
17
|
import { manifestPath } from './handles-manifest.mjs';
|
|
18
|
+
import { patchApprovalsPath, loadPatchApprovals } from './patch-approvals.mjs';
|
|
19
|
+
import { loadResolution } from '../contracts/completeness.mjs';
|
|
20
|
+
import { loadCrossFeatureResolution } from './cross-feature-collisions.mjs';
|
|
21
|
+
import { decisionLogPath, readDecisionLog } from './decision-log.mjs';
|
|
18
22
|
import { currentBranch, headSha, headTreeSha, worktreeStatus } from './repo.mjs';
|
|
19
23
|
import { validateAgainstSchema } from './schema-validate.mjs';
|
|
20
24
|
import { CANONICALIZATION_ID } from './attest.mjs';
|
|
21
25
|
|
|
22
|
-
export const EXPORT_SCHEMA_VERSION = 'sbf.gate-export/
|
|
26
|
+
export const EXPORT_SCHEMA_VERSION = 'sbf.gate-export/4';
|
|
23
27
|
|
|
24
28
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
25
29
|
const SKILL_ROOT = path.resolve(__dirname, '..');
|
|
@@ -88,6 +92,11 @@ export const ARTIFACT_SOURCES = Object.freeze({
|
|
|
88
92
|
impact_baseline_hash: (root, featureId) => impactBaselinePath(root, featureId),
|
|
89
93
|
impact_report_hash: (root, featureId) => impactReportPath(root, featureId),
|
|
90
94
|
impact_resolution_hash: (root, featureId) => impactResolutionPath(root, featureId),
|
|
95
|
+
// D-decision-event-log (F3): patch-approvals.json was bound by NOTHING before this -- not a
|
|
96
|
+
// gate input, not an attestation artifact -- the exact same class of gap
|
|
97
|
+
// D-attestation-payload-completeness's finding #3 closed for .sbf/handles-manifest.json.
|
|
98
|
+
// sbf.gate-export/3 -> /4, additive (a /3 attestation still verifies, K7's own promise).
|
|
99
|
+
patch_approvals_hash: (root, featureId) => patchApprovalsPath(root, featureId),
|
|
91
100
|
});
|
|
92
101
|
|
|
93
102
|
export function collectArtifactHashes(root, featureId) {
|
|
@@ -103,7 +112,15 @@ export function collectArtifactHashes(root, featureId) {
|
|
|
103
112
|
// cross_feature_resolution) are represented by presence+hash only, never embedded content: the
|
|
104
113
|
// content is already bound via `artifacts` above, and embedding it again would duplicate that
|
|
105
114
|
// binding while ballooning the payload for no new guarantee.
|
|
106
|
-
|
|
115
|
+
// D-decision-event-log (D7): extended to cover all four spec-side decision files -- previously
|
|
116
|
+
// only 2 of 4 appeared in waiver_files, and NONE of the four's live entries were represented at
|
|
117
|
+
// all (only a hash). `records` mirrors gates.*.live's own "current vs live" distinction (K2):
|
|
118
|
+
// each kind's entries here are LIVE-evaluated (expired contract/impact entries excluded), never
|
|
119
|
+
// the raw stored array, so an expired-but-not-yet-renewed waiver never reads as still covering
|
|
120
|
+
// its warning inside a signed attestation. `event_counts` rolls up the decision log -- present
|
|
121
|
+
// even when a decision file itself is absent (an all-zero count is still informative: "no
|
|
122
|
+
// decisions of this kind were ever made here", distinct from "the file doesn't exist").
|
|
123
|
+
export function collectDecisions(root, featureId, gatesById, artifacts) {
|
|
107
124
|
const forced = [];
|
|
108
125
|
const revoked = [];
|
|
109
126
|
for (const [gate, entry] of Object.entries(gatesById)) {
|
|
@@ -112,13 +129,47 @@ export function collectDecisions(gatesById, artifacts) {
|
|
|
112
129
|
if (record.forced) forced.push({ gate, scope: entry.scope, reason: record.reason ?? null, at: record.at ?? null });
|
|
113
130
|
if (record.status === 'revoked') revoked.push({ gate, scope: entry.scope, reason: record.reason ?? null, at: record.at ?? null });
|
|
114
131
|
}
|
|
132
|
+
|
|
133
|
+
const now = Date.now();
|
|
134
|
+
const contractResolution = loadResolution(root, featureId);
|
|
135
|
+
const liveContractWaivers = (contractResolution.waivers ?? [])
|
|
136
|
+
.filter((w) => !(typeof w.expires_at === 'string' && Date.parse(w.expires_at) <= now))
|
|
137
|
+
.map((w) => ({ code: w.code, subject: w.subject ?? null, reason: w.reason, at: w.at, expires_at: w.expires_at ?? null }));
|
|
138
|
+
|
|
139
|
+
const crossFeatureResolution = loadCrossFeatureResolution(root, featureId);
|
|
140
|
+
const liveCrossFeatureWaivers = (crossFeatureResolution.waivers ?? [])
|
|
141
|
+
.map((w) => ({ signal: w.signal, identifier: w.identifier, other_feature: w.other_feature, reason: w.reason, at: w.at }));
|
|
142
|
+
|
|
143
|
+
const impactResolution = loadImpactResolution(root, featureId);
|
|
144
|
+
const liveImpactDispositions = (impactResolution.dispositions ?? [])
|
|
145
|
+
.filter((d) => !(typeof d.expires_at === 'string' && Date.parse(d.expires_at) <= now))
|
|
146
|
+
.map((d) => ({ change_key: d.change_key, downstream_feature: d.downstream_feature, mode: d.mode, reason: d.reason, tracked_by: d.tracked_by ?? null, expires_at: d.expires_at ?? null, acknowledged: d.acknowledged ?? false }));
|
|
147
|
+
|
|
148
|
+
const patchApprovals = loadPatchApprovals(root, featureId);
|
|
149
|
+
const livePatchApprovals = (patchApprovals.approvals ?? [])
|
|
150
|
+
.map((a) => ({ resource: a.resource, field: a.field, strategy: a.strategy, reason: a.reason, at: a.at }));
|
|
151
|
+
|
|
152
|
+
const eventCounts = { contract_waiver: 0, cross_feature_waiver: 0, impact_disposition: 0, patch_approval: 0 };
|
|
153
|
+
for (const event of readDecisionLog(root, featureId)) {
|
|
154
|
+
if (Object.hasOwn(eventCounts, event.kind)) eventCounts[event.kind] += 1;
|
|
155
|
+
}
|
|
156
|
+
|
|
115
157
|
return {
|
|
116
158
|
forced,
|
|
117
159
|
revoked,
|
|
118
160
|
waiver_files: {
|
|
119
161
|
contract_resolution: { present: artifacts.contract_resolution_hash !== null, hash: artifacts.contract_resolution_hash },
|
|
120
162
|
cross_feature_resolution: { present: artifacts.cross_feature_resolution_hash !== null, hash: artifacts.cross_feature_resolution_hash },
|
|
163
|
+
impact_resolution: { present: artifacts.impact_resolution_hash !== null, hash: artifacts.impact_resolution_hash },
|
|
164
|
+
patch_approvals: { present: artifacts.patch_approvals_hash !== null, hash: artifacts.patch_approvals_hash },
|
|
165
|
+
},
|
|
166
|
+
records: {
|
|
167
|
+
contract_waivers: liveContractWaivers,
|
|
168
|
+
cross_feature_waivers: liveCrossFeatureWaivers,
|
|
169
|
+
impact_dispositions: liveImpactDispositions,
|
|
170
|
+
patch_approvals: livePatchApprovals,
|
|
121
171
|
},
|
|
172
|
+
event_counts: eventCounts,
|
|
122
173
|
};
|
|
123
174
|
}
|
|
124
175
|
|
|
@@ -165,7 +216,7 @@ export function buildGateExportReport(root, featureId, { now = new Date(), dirty
|
|
|
165
216
|
}
|
|
166
217
|
|
|
167
218
|
const artifacts = collectArtifactHashes(root, featureId);
|
|
168
|
-
const decisions = collectDecisions(gates, artifacts);
|
|
219
|
+
const decisions = collectDecisions(root, featureId, gates, artifacts);
|
|
169
220
|
const verdict = buildVerdict(Object.entries(gates).map(([gate, g]) => ({ gate, blocking: g.live.blocking, status: g.live.status })));
|
|
170
221
|
|
|
171
222
|
const status = worktreeStatus(root, { cap: dirtyCap });
|
package/lib/impact.mjs
CHANGED
|
@@ -13,6 +13,7 @@ import { requireValidFeatureId } from './featureid.mjs';
|
|
|
13
13
|
import { EXIT_CODES } from './exit-codes.mjs';
|
|
14
14
|
import { buildImpactGraph } from './impact-graph.mjs';
|
|
15
15
|
import { computeSurface, loadBaseline, saveBaseline, diffSurface } from './impact-surface.mjs';
|
|
16
|
+
import { appendDecisionEvent } from './decision-log.mjs';
|
|
16
17
|
|
|
17
18
|
const REPORT_SCHEMA = 'sbf.impact-report/1';
|
|
18
19
|
const RESOLUTION_SCHEMA = 'sbf.impact-resolution/1';
|
|
@@ -240,6 +241,7 @@ export function recordDisposition(root, { feature, changeKey, downstreamFeature,
|
|
|
240
241
|
|
|
241
242
|
return withLockSync(root, 'state', () => {
|
|
242
243
|
const current = loadImpactResolution(root, feature);
|
|
244
|
+
const at = now.toISOString();
|
|
243
245
|
const next = {
|
|
244
246
|
schema: RESOLUTION_SCHEMA,
|
|
245
247
|
feature_id: feature,
|
|
@@ -255,11 +257,17 @@ export function recordDisposition(root, { feature, changeKey, downstreamFeature,
|
|
|
255
257
|
acknowledged: false,
|
|
256
258
|
acknowledged_at: null,
|
|
257
259
|
acknowledged_reason: null,
|
|
258
|
-
at
|
|
260
|
+
at,
|
|
259
261
|
},
|
|
260
262
|
],
|
|
261
263
|
};
|
|
262
|
-
|
|
264
|
+
const saved = saveImpactResolution(root, feature, next);
|
|
265
|
+
appendDecisionEvent(root, feature, {
|
|
266
|
+
kind: 'impact_disposition', action: 'record', at, reason, feature_id: feature,
|
|
267
|
+
subject: { change_key: changeKey, downstream_feature: downstreamFeature },
|
|
268
|
+
mode, expires_at: expiresAt, tracked_by: mode === 'migrate' ? trackedBy : null,
|
|
269
|
+
});
|
|
270
|
+
return saved;
|
|
263
271
|
});
|
|
264
272
|
}
|
|
265
273
|
|
|
@@ -281,6 +289,46 @@ export function acknowledgeInbound(root, { feature, from, changeKey, reason, now
|
|
|
281
289
|
match.acknowledged = true;
|
|
282
290
|
match.acknowledged_at = now.toISOString();
|
|
283
291
|
match.acknowledged_reason = reason;
|
|
284
|
-
|
|
292
|
+
const saved = saveImpactResolution(root, from, upstreamResolution);
|
|
293
|
+
appendDecisionEvent(root, from, {
|
|
294
|
+
kind: 'impact_disposition', action: 'acknowledge', at: match.acknowledged_at, reason, feature_id: from,
|
|
295
|
+
subject: { change_key: changeKey, downstream_feature: feature },
|
|
296
|
+
mode: match.mode, expires_at: match.expires_at ?? null, tracked_by: match.tracked_by ?? null,
|
|
297
|
+
});
|
|
298
|
+
return saved;
|
|
299
|
+
});
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
// D-decision-event-log (D6): forward-only retraction, mirroring `gate revoke`'s own precedent --
|
|
303
|
+
// removes the entry (its absence IS the state, matching every other withdraw in this item) and
|
|
304
|
+
// appends a `withdraw` event carrying the reason. Never a snapshot restore: a disposition has no
|
|
305
|
+
// "previous value" worth restoring once the change it covered has moved on -- see
|
|
306
|
+
// D-decision-event-log's own Q4 answer in DECISIONS.md for why this is the correct
|
|
307
|
+
// generalization of `gate revoke`, not of `lib/patch-transactions.mjs`'s preimage-blob rollback.
|
|
308
|
+
export function withdrawDisposition(root, { feature, changeKey, downstreamFeature, reason, now = new Date() }) {
|
|
309
|
+
requireValidOr400(feature);
|
|
310
|
+
requireValidOr400(downstreamFeature);
|
|
311
|
+
if (!reason || !reason.trim()) {
|
|
312
|
+
throw new ImpactOperationError('bskel impact disposition --withdraw requires --reason "..." -- every withdrawal must be auditable', { httpStatus: 400, exitCode: EXIT_CODES.BAD_ARGS, reasonCode: 'BAD_ARGS' });
|
|
313
|
+
}
|
|
314
|
+
return withLockSync(root, 'state', () => {
|
|
315
|
+
const current = loadImpactResolution(root, feature);
|
|
316
|
+
const match = current.dispositions.find((d) => d.change_key === changeKey && d.downstream_feature === downstreamFeature);
|
|
317
|
+
if (!match) {
|
|
318
|
+
throw new ImpactOperationError(`no disposition recorded for change_key "${changeKey}" -> "${downstreamFeature}" on feature "${feature}" -- nothing to withdraw`, { httpStatus: 404, exitCode: EXIT_CODES.NOT_PASSED, reasonCode: 'MISSING_ARTIFACT' });
|
|
319
|
+
}
|
|
320
|
+
const at = now.toISOString();
|
|
321
|
+
const next = {
|
|
322
|
+
schema: RESOLUTION_SCHEMA,
|
|
323
|
+
feature_id: feature,
|
|
324
|
+
dispositions: current.dispositions.filter((d) => !(d.change_key === changeKey && d.downstream_feature === downstreamFeature)),
|
|
325
|
+
};
|
|
326
|
+
const saved = saveImpactResolution(root, feature, next);
|
|
327
|
+
appendDecisionEvent(root, feature, {
|
|
328
|
+
kind: 'impact_disposition', action: 'withdraw', at, reason, feature_id: feature,
|
|
329
|
+
subject: { change_key: changeKey, downstream_feature: downstreamFeature },
|
|
330
|
+
mode: match.mode, expires_at: match.expires_at ?? null, tracked_by: match.tracked_by ?? null,
|
|
331
|
+
});
|
|
332
|
+
return saved;
|
|
285
333
|
});
|
|
286
334
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "backend-skeleton",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.7.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Deterministic gate layer for AI-assisted backend changes -- blocks brownfield collisions and contract/handle drift via disk-hash checks before code ships. Scaffolding codegen included (Java/Spring, Python/FastAPI, TypeScript/Express).",
|
|
6
6
|
"license": "AGPL-3.0-or-later",
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
|
3
|
+
"$id": "urn:sbf:decision-event:1",
|
|
4
|
+
"title": "backend-skeleton decision event",
|
|
5
|
+
"description": "D-decision-event-log: one line of a .sbf/<feature_id>.decisions.jsonl file, appended by lib/decision-log.mjs's appendDecisionEvent() every time one of the four spec-side decision files (contract-resolution, cross-feature-resolution, impact-resolution, patch-approvals) is written -- mirrors gate-event.schema.json's own shape and resilience contract exactly (append-only JSONL, a corrupt/invalid line is skipped with a warning at read time, never a hard failure). Deliberately NOT a unified record type across the four decision files themselves -- their on-disk shapes stay exactly as they were (different upsert semantics: contract waivers are append-if-absent, impact dispositions are replace-by-key, and forcing either into the other would be the over-unification this design explicitly rejected). `subject` is a discriminated union keyed on `kind`, carrying that decision kind's REAL key object verbatim -- never flattened to a string -- because a generic string key is exactly how the anti-wildcard property every one of the four already enforces (a decision covers ONE exact subject, never a class of future subjects) would eventually erode. This log is machine-local (.sbf/ is gitignored-by-convention) and is NOT tamper-evident on its own; its evidentiary value comes from being rolled up into a SIGNED gate-export attestation (see D-attestation-payload-completeness), exactly as gates.*.history already is.",
|
|
6
|
+
"type": "object",
|
|
7
|
+
"additionalProperties": false,
|
|
8
|
+
"required": ["schema", "kind", "action", "at", "reason", "feature_id", "subject"],
|
|
9
|
+
"properties": {
|
|
10
|
+
"schema": { "const": "sbf.decision-event/1" },
|
|
11
|
+
"kind": { "enum": ["contract_waiver", "cross_feature_waiver", "impact_disposition", "patch_approval"] },
|
|
12
|
+
"action": { "enum": ["record", "renew", "withdraw", "acknowledge"] },
|
|
13
|
+
"at": { "type": "string", "format": "date-time" },
|
|
14
|
+
"reason": { "type": "string", "minLength": 1 },
|
|
15
|
+
"feature_id": { "type": "string" },
|
|
16
|
+
"subject": {
|
|
17
|
+
"description": "The exact key object for this decision, shaped per `kind` -- never a flattened string.",
|
|
18
|
+
"oneOf": [
|
|
19
|
+
{
|
|
20
|
+
"type": "object", "additionalProperties": false,
|
|
21
|
+
"required": ["code", "subject"],
|
|
22
|
+
"properties": { "code": { "type": "string" }, "subject": { "type": ["string", "null"] } }
|
|
23
|
+
},
|
|
24
|
+
{
|
|
25
|
+
"type": "object", "additionalProperties": false,
|
|
26
|
+
"required": ["signal", "identifier", "other_feature"],
|
|
27
|
+
"properties": { "signal": { "type": "string" }, "identifier": { "type": "string" }, "other_feature": { "type": "string" } }
|
|
28
|
+
},
|
|
29
|
+
{
|
|
30
|
+
"type": "object", "additionalProperties": false,
|
|
31
|
+
"required": ["change_key", "downstream_feature"],
|
|
32
|
+
"properties": { "change_key": { "type": "string" }, "downstream_feature": { "type": "string" } }
|
|
33
|
+
},
|
|
34
|
+
{
|
|
35
|
+
"type": "object", "additionalProperties": false,
|
|
36
|
+
"required": ["resource", "field"],
|
|
37
|
+
"properties": { "resource": { "type": "string" }, "field": { "type": "string" } }
|
|
38
|
+
}
|
|
39
|
+
]
|
|
40
|
+
},
|
|
41
|
+
"mode": { "type": ["string", "null"] },
|
|
42
|
+
"expires_at": { "type": ["string", "null"] },
|
|
43
|
+
"tracked_by": { "type": ["string", "null"] },
|
|
44
|
+
"strategy": { "type": ["string", "null"] }
|
|
45
|
+
}
|
|
46
|
+
}
|
|
@@ -18,7 +18,7 @@
|
|
|
18
18
|
],
|
|
19
19
|
"properties": {
|
|
20
20
|
"schema": {
|
|
21
|
-
"const": "sbf.gate-export/
|
|
21
|
+
"const": "sbf.gate-export/4"
|
|
22
22
|
},
|
|
23
23
|
"feature_id": {
|
|
24
24
|
"type": "string",
|
|
@@ -151,7 +151,8 @@
|
|
|
151
151
|
"stack_record_hash",
|
|
152
152
|
"impact_baseline_hash",
|
|
153
153
|
"impact_report_hash",
|
|
154
|
-
"impact_resolution_hash"
|
|
154
|
+
"impact_resolution_hash",
|
|
155
|
+
"patch_approvals_hash"
|
|
155
156
|
],
|
|
156
157
|
"properties": {
|
|
157
158
|
"scan_report_hash": {
|
|
@@ -265,6 +266,13 @@
|
|
|
265
266
|
"null"
|
|
266
267
|
],
|
|
267
268
|
"pattern": "^[0-9a-f]{64}$"
|
|
269
|
+
},
|
|
270
|
+
"patch_approvals_hash": {
|
|
271
|
+
"type": [
|
|
272
|
+
"string",
|
|
273
|
+
"null"
|
|
274
|
+
],
|
|
275
|
+
"pattern": "^[0-9a-f]{64}$"
|
|
268
276
|
}
|
|
269
277
|
}
|
|
270
278
|
},
|
|
@@ -411,9 +419,11 @@
|
|
|
411
419
|
"required": [
|
|
412
420
|
"forced",
|
|
413
421
|
"revoked",
|
|
414
|
-
"waiver_files"
|
|
422
|
+
"waiver_files",
|
|
423
|
+
"records",
|
|
424
|
+
"event_counts"
|
|
415
425
|
],
|
|
416
|
-
"description": "D-attestation-payload-completeness (K2): rolled up purely from the gate records already in `gates` above -- zero new file reads.",
|
|
426
|
+
"description": "D-attestation-payload-completeness (K2): forced/revoked rolled up purely from the gate records already in `gates` above -- zero new file reads. D-decision-event-log (D7) additive, sbf.gate-export/3 -> /4: `records`/`event_counts` cover the four spec-side decision files (contract waivers, cross-feature waivers, impact dispositions, patch approvals) -- a /3 attestation still verifies (K7's own promise), it just won't have these two keys.",
|
|
417
427
|
"properties": {
|
|
418
428
|
"forced": {
|
|
419
429
|
"type": "array",
|
|
@@ -486,49 +496,104 @@
|
|
|
486
496
|
"additionalProperties": false,
|
|
487
497
|
"required": [
|
|
488
498
|
"contract_resolution",
|
|
489
|
-
"cross_feature_resolution"
|
|
499
|
+
"cross_feature_resolution",
|
|
500
|
+
"impact_resolution",
|
|
501
|
+
"patch_approvals"
|
|
490
502
|
],
|
|
491
|
-
"description": "presence + hash only -- the CONTENT is already bound via `artifacts` above (
|
|
503
|
+
"description": "presence + hash only -- the CONTENT is already bound via `artifacts` above, and again (live-evaluated) via `records` below, never embedded here a second time.",
|
|
492
504
|
"properties": {
|
|
493
|
-
"contract_resolution": {
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
505
|
+
"contract_resolution": { "$ref": "#/$defs/waiverFileRef" },
|
|
506
|
+
"cross_feature_resolution": { "$ref": "#/$defs/waiverFileRef" },
|
|
507
|
+
"impact_resolution": { "$ref": "#/$defs/waiverFileRef" },
|
|
508
|
+
"patch_approvals": { "$ref": "#/$defs/waiverFileRef" }
|
|
509
|
+
}
|
|
510
|
+
},
|
|
511
|
+
"records": {
|
|
512
|
+
"type": "object",
|
|
513
|
+
"additionalProperties": false,
|
|
514
|
+
"required": [
|
|
515
|
+
"contract_waivers",
|
|
516
|
+
"cross_feature_waivers",
|
|
517
|
+
"impact_dispositions",
|
|
518
|
+
"patch_approvals"
|
|
519
|
+
],
|
|
520
|
+
"description": "D-decision-event-log (D7): LIVE-evaluated entries only (expired contract/impact entries excluded, mirroring gates.*.live's current-vs-live split) -- never the raw stored array, so an expired-but-not-yet-renewed waiver never reads as still covering its warning inside a signed attestation.",
|
|
521
|
+
"properties": {
|
|
522
|
+
"contract_waivers": {
|
|
523
|
+
"type": "array",
|
|
524
|
+
"items": {
|
|
525
|
+
"type": "object",
|
|
526
|
+
"additionalProperties": false,
|
|
527
|
+
"required": ["code", "subject", "reason", "at", "expires_at"],
|
|
528
|
+
"properties": {
|
|
529
|
+
"code": { "type": "string" },
|
|
530
|
+
"subject": { "type": ["string", "null"] },
|
|
531
|
+
"reason": { "type": "string" },
|
|
532
|
+
"at": { "type": "string" },
|
|
533
|
+
"expires_at": { "type": ["string", "null"] }
|
|
509
534
|
}
|
|
510
535
|
}
|
|
511
536
|
},
|
|
512
|
-
"
|
|
513
|
-
"type": "
|
|
514
|
-
"
|
|
515
|
-
|
|
516
|
-
"
|
|
517
|
-
"
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
"type": "
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
537
|
+
"cross_feature_waivers": {
|
|
538
|
+
"type": "array",
|
|
539
|
+
"items": {
|
|
540
|
+
"type": "object",
|
|
541
|
+
"additionalProperties": false,
|
|
542
|
+
"required": ["signal", "identifier", "other_feature", "reason", "at"],
|
|
543
|
+
"properties": {
|
|
544
|
+
"signal": { "type": "string" },
|
|
545
|
+
"identifier": { "type": "string" },
|
|
546
|
+
"other_feature": { "type": "string" },
|
|
547
|
+
"reason": { "type": "string" },
|
|
548
|
+
"at": { "type": "string" }
|
|
549
|
+
}
|
|
550
|
+
}
|
|
551
|
+
},
|
|
552
|
+
"impact_dispositions": {
|
|
553
|
+
"type": "array",
|
|
554
|
+
"items": {
|
|
555
|
+
"type": "object",
|
|
556
|
+
"additionalProperties": false,
|
|
557
|
+
"required": ["change_key", "downstream_feature", "mode", "reason", "tracked_by", "expires_at", "acknowledged"],
|
|
558
|
+
"properties": {
|
|
559
|
+
"change_key": { "type": "string" },
|
|
560
|
+
"downstream_feature": { "type": "string" },
|
|
561
|
+
"mode": { "type": "string" },
|
|
562
|
+
"reason": { "type": "string" },
|
|
563
|
+
"tracked_by": { "type": ["string", "null"] },
|
|
564
|
+
"expires_at": { "type": ["string", "null"] },
|
|
565
|
+
"acknowledged": { "type": "boolean" }
|
|
566
|
+
}
|
|
567
|
+
}
|
|
568
|
+
},
|
|
569
|
+
"patch_approvals": {
|
|
570
|
+
"type": "array",
|
|
571
|
+
"items": {
|
|
572
|
+
"type": "object",
|
|
573
|
+
"additionalProperties": false,
|
|
574
|
+
"required": ["resource", "field", "strategy", "reason", "at"],
|
|
575
|
+
"properties": {
|
|
576
|
+
"resource": { "type": "string" },
|
|
577
|
+
"field": { "type": "string" },
|
|
578
|
+
"strategy": { "type": "string" },
|
|
579
|
+
"reason": { "type": "string" },
|
|
580
|
+
"at": { "type": "string" }
|
|
528
581
|
}
|
|
529
582
|
}
|
|
530
583
|
}
|
|
531
584
|
}
|
|
585
|
+
},
|
|
586
|
+
"event_counts": {
|
|
587
|
+
"type": "object",
|
|
588
|
+
"additionalProperties": false,
|
|
589
|
+
"required": ["contract_waiver", "cross_feature_waiver", "impact_disposition", "patch_approval"],
|
|
590
|
+
"description": "total decision-log events per kind (all actions combined) -- an all-zero count is informative (no log file, or no events of this kind), distinct from the file not existing at all.",
|
|
591
|
+
"properties": {
|
|
592
|
+
"contract_waiver": { "type": "integer", "minimum": 0 },
|
|
593
|
+
"cross_feature_waiver": { "type": "integer", "minimum": 0 },
|
|
594
|
+
"impact_disposition": { "type": "integer", "minimum": 0 },
|
|
595
|
+
"patch_approval": { "type": "integer", "minimum": 0 }
|
|
596
|
+
}
|
|
532
597
|
}
|
|
533
598
|
}
|
|
534
599
|
},
|
|
@@ -562,5 +627,16 @@
|
|
|
562
627
|
}
|
|
563
628
|
}
|
|
564
629
|
}
|
|
630
|
+
},
|
|
631
|
+
"$defs": {
|
|
632
|
+
"waiverFileRef": {
|
|
633
|
+
"type": "object",
|
|
634
|
+
"additionalProperties": false,
|
|
635
|
+
"required": ["present", "hash"],
|
|
636
|
+
"properties": {
|
|
637
|
+
"present": { "type": "boolean" },
|
|
638
|
+
"hash": { "type": ["string", "null"] }
|
|
639
|
+
}
|
|
640
|
+
}
|
|
565
641
|
}
|
|
566
642
|
}
|