model-eol 0.5.1 → 0.6.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/lib/reports.mjs CHANGED
@@ -8,9 +8,16 @@ export const marker = status =>
8
8
  status === 'watch' ? '?' :
9
9
  '·'
10
10
 
11
- const shutdownText = finding => finding.date_precision === 'earliest'
12
- ? `no earlier than ${finding.shutdown}`
13
- : finding.shutdown
11
+ const shutdownText = finding =>
12
+ finding.date_precision === 'tentative' ? `not sooner than ${finding.shutdown}` :
13
+ finding.date_precision === 'earliest' ? `no earlier than ${finding.shutdown}` :
14
+ finding.shutdown
15
+
16
+ const tentativeFloor = finding => finding.date_precision === 'tentative'
17
+
18
+ const policyTail = finding => finding.safe_until && finding.shutdown && finding.safe_until > finding.shutdown
19
+ ? `, guaranteed until ${finding.safe_until} by ${finding.publisher} policy`
20
+ : ''
14
21
 
15
22
  const replacementTail = finding => finding.replacement
16
23
  ? ` -> ${finding.replacement}`
@@ -18,22 +25,32 @@ const replacementTail = finding => finding.replacement
18
25
  ? ` options: ${finding.replacement_options.join(' | ')}`
19
26
  : ''
20
27
 
21
- export const findingTail = (f, days) =>
28
+ const lifecycleTail = (f, days) =>
22
29
  f.status === 'retired' && !f.shutdown ? `RETIRED (shutdown date unavailable)${replacementTail(f)}` :
23
30
  f.status === 'retired' ? `RETIRED ${shutdownText(f)} (${-f.days} days ago)${replacementTail(f)}` :
24
31
  f.status === 'retiring' ? `RETIRES ${shutdownText(f)} (${f.days} days)${replacementTail(f)}` :
32
+ tentativeFloor(f) ? `scheduled - tentative, not announced: ${shutdownText(f)}${policyTail(f)}` :
25
33
  f.status === 'scheduled' ? `scheduled ${shutdownText(f)} (${f.days} days, outside --days ${f.threshold_days ?? days})` :
26
34
  f.status === 'watch' ? 'deprecation announced, no shutdown date yet' :
27
35
  'no retirement scheduled'
28
36
 
29
- const statusStyle = status =>
30
- status === 'retired' ? 'red' :
31
- status === 'retiring' ? 'yellow' :
32
- status === 'ok' ? 'dim' :
37
+ const waiverTail = finding => !finding.waiver
38
+ ? ''
39
+ : finding.waiver.active
40
+ ? ` [waived until ${finding.waiver.expires} by ${finding.waiver.owner}: ${finding.waiver.reason}]`
41
+ : ` [waiver expired on ${finding.waiver.expires}; owner ${finding.waiver.owner}: ${finding.waiver.reason}]`
42
+
43
+ export const findingTail = (finding, days) => `${lifecycleTail(finding, days)}${waiverTail(finding)}`
44
+
45
+ const statusStyle = finding =>
46
+ finding.waiver?.active === true ? 'yellow' :
47
+ finding.status === 'retired' ? 'red' :
48
+ finding.status === 'retiring' ? 'yellow' :
49
+ finding.status === 'ok' ? 'dim' :
33
50
  null
34
51
 
35
- const humanFinding = (status, line) => {
36
- const style = statusStyle(status)
52
+ const humanFinding = (finding, line) => {
53
+ const style = statusStyle(finding)
37
54
  return style ? color(style, line) : line
38
55
  }
39
56
 
@@ -67,6 +84,7 @@ export const buildInventory = ({ scan, findings, days, via, scope, targets }) =>
67
84
  replacement: lifecycle.replacement,
68
85
  ...(lifecycle.replacement_options?.length ? { replacement_options: lifecycle.replacement_options } : {}),
69
86
  ...(lifecycle.replacement_note ? { replacement_note: lifecycle.replacement_note } : {}),
87
+ ...(lifecycle.waiver ? { waiver: lifecycle.waiver } : {}),
70
88
  source: ref.source,
71
89
  policy: ref.policy ?? null,
72
90
  feed_generated: ref.generated ?? null,
@@ -149,9 +167,11 @@ export const buildSchedule = inventory => {
149
167
  }
150
168
 
151
169
  export const buildAlert = schedule => {
152
- const errors = schedule.items.filter(item => item.status === 'retired' || item.status === 'retiring')
170
+ const errors = schedule.items.filter(isBad)
153
171
  const warnings = [
154
- ...schedule.items.filter(item => item.status === 'scheduled' || item.status === 'watch'),
172
+ ...schedule.items.filter(item =>
173
+ item.status === 'scheduled' || item.status === 'watch' ||
174
+ ((item.status === 'retired' || item.status === 'retiring') && item.waiver?.active === true)),
155
175
  ...schedule.candidate_model_references.map(item => ({ ...item, status: 'unknown' })),
156
176
  ...schedule.unresolved_integrations.map(item => ({ ...item, status: 'unresolved' })),
157
177
  ]
@@ -174,7 +194,7 @@ export const formatCheck = ({ findings, bad, scannedFiles, days, scope }) => {
174
194
  }
175
195
  for (const f of findings) {
176
196
  const where = `${f.file}:${f.line}`
177
- out.push(humanFinding(f.status, `${marker(f.status)} ${where} ${f.matched} ${findingTail(f, days)}${f.via && f.via !== 'publisher' ? ` [via ${f.via}]` : ''}`))
197
+ out.push(humanFinding(f, `${marker(f.status)} ${where} ${f.matched} ${findingTail(f, days)}${f.via && f.via !== 'publisher' ? ` [via ${f.via}]` : ''}`))
178
198
  }
179
199
  if (bad.length) out.push(`\n${color('bold', `model-eol: ${bad.length} finding(s) at or past the ${days}-day threshold`)}`)
180
200
  return out.join('\n')
@@ -187,7 +207,7 @@ export const formatInventory = (inventory, days) => {
187
207
  if (inventory.model_references.length) {
188
208
  out.push('\nModel references')
189
209
  for (const item of inventory.model_references) {
190
- out.push(humanFinding(item.status, `${marker(item.status)} ${item.file}:${item.line} ${item.matched} ${item.publisher}/${item.id} ${item.usage} ${findingTail(item, days)}`))
210
+ out.push(humanFinding(item, `${marker(item.status)} ${item.file}:${item.line} ${item.matched} ${item.publisher}/${item.id} ${item.usage} ${findingTail(item, days)}`))
191
211
  }
192
212
  }
193
213
  if (inventory.candidate_model_references.length) {
@@ -212,7 +232,7 @@ export const formatSchedule = (schedule, days) => {
212
232
  if (incompleteNotes.length) out.push(`WARNING: scan incomplete; ${incompleteNotes.length} coverage-loss note(s) recorded`)
213
233
  for (const item of schedule.items) {
214
234
  const safeUntil = item.safe_until ? ` [safe until ${item.safe_until}]` : ''
215
- out.push(humanFinding(item.status, `${marker(item.status)} ${item.shutdown ? shutdownText(item) : 'TBD'} ${item.file}:${item.line} ${item.matched} ${findingTail(item, days)} [${item.usage}]${safeUntil}`))
235
+ out.push(humanFinding(item, `${marker(item.status)} ${item.shutdown ? shutdownText(item) : 'TBD'} ${item.file}:${item.line} ${item.matched} ${findingTail(item, days)} [${item.usage}]${safeUntil}`))
216
236
  }
217
237
  if (schedule.unresolved_integrations.length) {
218
238
  out.push('\nUnresolved cloud/gateway references')
@@ -279,6 +299,7 @@ export const formatInventoryCycloneDX = inventory => {
279
299
  identity,
280
300
  item,
281
301
  status: item.status,
302
+ occurrenceWaivers: new Map(),
282
303
  usages: new Set(),
283
304
  occurrences: new Set(),
284
305
  }
@@ -287,12 +308,54 @@ export const formatInventoryCycloneDX = inventory => {
287
308
  if ((statusSeverity.get(item.status) ?? 9) < (statusSeverity.get(group.status) ?? 9)) {
288
309
  group.status = item.status
289
310
  }
311
+ const location = `${item.file}#${item.line}`
312
+ const occurrenceWaiver = group.occurrenceWaivers.get(location) ?? { active: true, activeWaivers: [], expiredWaivers: [] }
313
+ if (item.waiver?.active === true) occurrenceWaiver.activeWaivers.push(item.waiver)
314
+ else {
315
+ occurrenceWaiver.active = false
316
+ if (item.waiver?.active === false) occurrenceWaiver.expiredWaivers.push(item.waiver)
317
+ }
318
+ group.occurrenceWaivers.set(location, occurrenceWaiver)
290
319
  group.usages.add(item.usage)
291
- group.occurrences.add(`${item.file}#${item.line}`)
320
+ group.occurrences.add(location)
292
321
  }
293
322
 
294
323
  const components = [...groups.values()].map(group => {
295
324
  const { item, identity } = group
325
+ const occurrenceWaivers = [...group.occurrenceWaivers.values()]
326
+ const activeWaivers = occurrenceWaivers.flatMap(occurrence => occurrence.activeWaivers)
327
+ const expiredWaivers = occurrenceWaivers.flatMap(occurrence => occurrence.expiredWaivers)
328
+ const everyOccurrenceWaived = occurrenceWaivers.every(occurrence => occurrence.active && occurrence.activeWaivers.length > 0)
329
+ let waiverProperties = []
330
+ if (activeWaivers.length > 0 && !everyOccurrenceWaived) {
331
+ waiverProperties = [{ name: 'model-eol:waiver_active', value: 'partial' }]
332
+ } else if (everyOccurrenceWaived) {
333
+ const earliestExpiry = activeWaivers.map(waiver => waiver.expires).sort()[0]
334
+ const distinctWaivers = new Set(activeWaivers.map(waiver => JSON.stringify([waiver.owner, waiver.expires, waiver.reason])))
335
+ waiverProperties = [
336
+ { name: 'model-eol:waiver_active', value: 'true' },
337
+ ...(distinctWaivers.size === 1
338
+ ? [
339
+ { name: 'model-eol:waiver_owner', value: activeWaivers[0].owner },
340
+ { name: 'model-eol:waiver_expires', value: earliestExpiry },
341
+ { name: 'model-eol:waiver_reason', value: activeWaivers[0].reason },
342
+ ]
343
+ : [{ name: 'model-eol:waiver_expires', value: earliestExpiry }]),
344
+ ]
345
+ } else if (expiredWaivers.length > 0) {
346
+ const latestExpiry = expiredWaivers.map(waiver => waiver.expires).sort().at(-1)
347
+ const distinctWaivers = new Set(expiredWaivers.map(waiver => JSON.stringify([waiver.owner, waiver.expires, waiver.reason])))
348
+ waiverProperties = [
349
+ { name: 'model-eol:waiver_active', value: 'false' },
350
+ ...(distinctWaivers.size === 1
351
+ ? [
352
+ { name: 'model-eol:waiver_owner', value: expiredWaivers[0].owner },
353
+ { name: 'model-eol:waiver_expires', value: latestExpiry },
354
+ { name: 'model-eol:waiver_reason', value: expiredWaivers[0].reason },
355
+ ]
356
+ : [{ name: 'model-eol:waiver_expires', value: latestExpiry }]),
357
+ ]
358
+ }
296
359
  return {
297
360
  type: 'machine-learning-model',
298
361
  'bom-ref': identity.bomRef,
@@ -319,6 +382,7 @@ export const formatInventoryCycloneDX = inventory => {
319
382
  ...(item.replacement_note
320
383
  ? [{ name: 'model-eol:replacement_note', value: item.replacement_note }]
321
384
  : []),
385
+ ...waiverProperties,
322
386
  { name: 'model-eol:usage', value: [...group.usages].sort().join(',') },
323
387
  ],
324
388
  evidence: {
package/lib/scanner.mjs CHANGED
@@ -638,6 +638,7 @@ export const scanTargets = ({
638
638
 
639
639
  const pushModelRef = ({ file, line, lineText, matchIndex = null, matched, record, classification, route = null, mappedFrom = null }) => {
640
640
  if (ignoresModel(file, matched, record.entry.id) || (mappedFrom && ignoresModel(file, mappedFrom, record.entry.id))) return
641
+ const waivers = policyForFile?.(path.resolve(file))?.waivers ?? []
641
642
  const occurrence = [...lineText.matchAll(new RegExp(escapeRegExp(matched), 'g'))]
642
643
  .findIndex(match => match.index === (matchIndex ?? lineText.indexOf(matched)))
643
644
  modelRefs.push({
@@ -659,6 +660,7 @@ export const scanTargets = ({
659
660
  occurrence: occurrence < 0 ? 0 : occurrence,
660
661
  route_index: route?.index ?? null,
661
662
  mapped_from: mappedFrom,
663
+ waivers,
662
664
  })
663
665
  }
664
666
 
@@ -64,7 +64,7 @@ export const normalizeDocumentType = value => {
64
64
  const isObject = value => value !== null && typeof value === 'object' && !Array.isArray(value)
65
65
 
66
66
  const configKeys = new Set([
67
- 'days', 'scope', 'via', 'feeds', 'ignore', 'overrides', 'routes', 'issues', 'eval',
67
+ 'days', 'scope', 'via', 'feeds', 'ignore', 'waivers', 'overrides', 'routes', 'issues', 'eval',
68
68
  ])
69
69
 
70
70
  export const detectDocumentType = (document, { file = null } = {}) => {
@@ -9,8 +9,9 @@ export const MAX_TIMEOUT_MS = 3600000
9
9
  const CONTROL_CHARACTER_PATTERN = /[\u0000-\u001f\u007f-\u009f]/
10
10
  const DATE_PATTERN = /^\d{4}-\d{2}-\d{2}$/
11
11
  const DATE_TIME_PATTERN = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:Z|[+-]\d{2}:\d{2})$/
12
- const DATE_PRECISIONS = new Set(['exact', 'earliest'])
12
+ const DATE_PRECISIONS = new Set(['exact', 'earliest', 'tentative'])
13
13
  const DISTRIBUTION_STATUSES = new Set(['active', 'legacy', 'extended-access', 'retired'])
14
+ const RESERVED_DISTRIBUTION_VIAS = new Set(['publisher', 'publisher-fallback'])
14
15
 
15
16
  export const assertIsoDate = (value, label = 'date') => {
16
17
  if (typeof value !== 'string' || !DATE_PATTERN.test(value)) {
@@ -181,7 +182,13 @@ export const validateFeed = feed => {
181
182
  const shutdownValid = checkDate(model.shutdown, `${at}.shutdown`)
182
183
  if (announcedValid && shutdownValid) validDates.push({ at, announced: model.announced, shutdown: model.shutdown })
183
184
  if (model.date_precision !== undefined) {
184
- if (typeof model.date_precision !== 'string' || !DATE_PRECISIONS.has(model.date_precision)) fail(`${at}.date_precision`, 'must be exact or earliest')
185
+ if (typeof model.date_precision !== 'string' || !DATE_PRECISIONS.has(model.date_precision)) fail(`${at}.date_precision`, 'must be exact, earliest, or tentative')
186
+ }
187
+ if (model.date_precision === 'tentative' && model.announced !== undefined) {
188
+ fail(`${at}.announced`, 'must be absent when date_precision is tentative')
189
+ }
190
+ if (model.date_precision === 'tentative' && model.shutdown === undefined) {
191
+ fail(`${at}.shutdown`, 'is required when date_precision is tentative')
185
192
  }
186
193
  if (model.replacement !== undefined) checkIdentifier(model.replacement, `${at}.replacement`)
187
194
  if (model.replacement_options !== undefined) {
@@ -210,6 +217,9 @@ export const validateFeed = feed => {
210
217
  if (!checkObjectKeys(distribution, dAt, new Set(['via', 'announced', 'shutdown', 'date_precision', 'status', 'source']))) continue
211
218
  if (!checkString(distribution.via, `${dAt}.via`, MAX_IDENTIFIER_LENGTH) || !distribution.via) fail(`${dAt}.via`, 'must be a non-empty string')
212
219
  if (typeof distribution.via === 'string' && distribution.via.length > 0) {
220
+ if (RESERVED_DISTRIBUTION_VIAS.has(distribution.via.toLowerCase())) {
221
+ fail(`${dAt}.via`, 'must not use a reserved publisher clock name')
222
+ }
213
223
  const previous = seenDistributionVias.get(distribution.via)
214
224
  if (previous) fail(`${dAt}.via`, `duplicate distributor via "${distribution.via}" also used at ${previous}`)
215
225
  else seenDistributionVias.set(distribution.via, `${dAt}.via`)
@@ -217,8 +227,17 @@ export const validateFeed = feed => {
217
227
  const distributionAnnounced = checkDate(distribution.announced, `${dAt}.announced`)
218
228
  const distributionShutdown = checkDate(distribution.shutdown, `${dAt}.shutdown`)
219
229
  if (distributionAnnounced && distributionShutdown && distribution.shutdown < distribution.announced) fail(dAt, 'shutdown precedes announced')
220
- if (distribution.date_precision !== undefined && (typeof distribution.date_precision !== 'string' || !DATE_PRECISIONS.has(distribution.date_precision))) fail(`${dAt}.date_precision`, 'must be exact or earliest')
230
+ if (distribution.date_precision !== undefined && (typeof distribution.date_precision !== 'string' || !DATE_PRECISIONS.has(distribution.date_precision))) fail(`${dAt}.date_precision`, 'must be exact, earliest, or tentative')
221
231
  if (distribution.status !== undefined && (typeof distribution.status !== 'string' || !DISTRIBUTION_STATUSES.has(distribution.status))) fail(`${dAt}.status`, 'has an unsupported status')
232
+ if (distribution.date_precision === 'tentative' && distribution.announced !== undefined) {
233
+ fail(`${dAt}.announced`, 'must be absent when date_precision is tentative')
234
+ }
235
+ if (distribution.date_precision === 'tentative' && distribution.shutdown === undefined) {
236
+ fail(`${dAt}.shutdown`, 'is required when date_precision is tentative')
237
+ }
238
+ if (distribution.date_precision === 'tentative' && distribution.status === 'retired') {
239
+ fail(`${dAt}.status`, 'must not be retired when date_precision is tentative')
240
+ }
222
241
  checkUrl(distribution.source, `${dAt}.source`)
223
242
  if ((distribution.announced || distribution.shutdown) && !(distribution.source || model.source || feed.source)) fail(dAt, 'dated distribution needs a source')
224
243
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "model-eol",
3
- "version": "0.5.1",
3
+ "version": "0.6.0",
4
4
  "description": "AI model deprecation feeds with a zero-dependency checker, inventory CLI, and migration bot.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -54,6 +54,13 @@
54
54
  },
55
55
  "default": { "models": [], "paths": [] }
56
56
  },
57
+ "waivers": {
58
+ "type": "array",
59
+ "maxItems": 500,
60
+ "items": { "$ref": "#/definitions/waiver" },
61
+ "default": [],
62
+ "description": "Owned, expiring exceptions that remain visible in reports while active."
63
+ },
57
64
  "overrides": {
58
65
  "type": "array",
59
66
  "items": { "$ref": "#/definitions/pathOverride" },
@@ -120,6 +127,7 @@
120
127
  "via": null,
121
128
  "feeds": { "allow_vendored_fallback": false },
122
129
  "ignore": { "models": [], "paths": [] },
130
+ "waivers": [],
123
131
  "overrides": [],
124
132
  "routes": [],
125
133
  "issues": { "enabled": true },
@@ -144,6 +152,24 @@
144
152
  "paths": { "$ref": "#/definitions/stringArray" }
145
153
  }
146
154
  },
155
+ "waiver": {
156
+ "type": "object",
157
+ "additionalProperties": false,
158
+ "required": ["model", "reason", "owner", "expires"],
159
+ "properties": {
160
+ "model": { "type": "string", "minLength": 1 },
161
+ "paths": { "$ref": "#/definitions/stringArray" },
162
+ "via": { "type": "string", "minLength": 1 },
163
+ "reason": { "type": "string", "minLength": 1, "maxLength": 500 },
164
+ "owner": { "type": "string", "minLength": 1, "maxLength": 200 },
165
+ "expires": { "type": "string", "format": "date" }
166
+ }
167
+ },
168
+ "waivers": {
169
+ "type": "array",
170
+ "maxItems": 500,
171
+ "items": { "$ref": "#/definitions/waiver" }
172
+ },
147
173
  "pathOverride": {
148
174
  "type": "object",
149
175
  "additionalProperties": false,
@@ -153,7 +179,8 @@
153
179
  "days": { "type": "integer", "minimum": 0 },
154
180
  "scope": { "enum": ["direct", "all"] },
155
181
  "via": { "type": ["string", "null"], "minLength": 1 },
156
- "ignore": { "$ref": "#/definitions/ignore" }
182
+ "ignore": { "$ref": "#/definitions/ignore" },
183
+ "waivers": { "$ref": "#/definitions/waivers" }
157
184
  }
158
185
  },
159
186
  "referenceRoute": {
@@ -32,7 +32,7 @@
32
32
  "confidence": { "$ref": "https://thossullivan.github.io/model-eol/schema/0.1/model-eol.inventory.schema.json#/definitions/confidence" },
33
33
  "status": { "$ref": "https://thossullivan.github.io/model-eol/schema/0.1/model-eol.inventory.schema.json#/definitions/status" },
34
34
  "shutdown": { "type": ["string", "null"], "format": "date" },
35
- "date_precision": { "enum": ["exact", "earliest", null] },
35
+ "date_precision": { "enum": ["exact", "earliest", "tentative", null] },
36
36
  "distribution_status": { "enum": ["active", "legacy", "extended-access", "retired", null] },
37
37
  "via": { "type": ["string", "null"], "minLength": 1 },
38
38
  "days": { "type": ["integer", "null"] },
@@ -47,6 +47,7 @@
47
47
  "threshold_days": { "type": "integer", "minimum": 0 },
48
48
  "effective_scope": { "enum": ["all", "direct"] },
49
49
  "requested_via": { "type": ["string", "null"], "minLength": 1 },
50
+ "waiver": { "$ref": "https://thossullivan.github.io/model-eol/schema/0.1/model-eol.inventory.schema.json#/definitions/waiver" },
50
51
  "policy_provenance": { "$ref": "https://thossullivan.github.io/model-eol/schema/0.1/model-eol.inventory.schema.json#/definitions/policyProvenance" },
51
52
  "mapped_from": { "type": "string", "minLength": 1 }
52
53
  },
@@ -60,7 +60,7 @@
60
60
  "evidence": { "type": "string", "minLength": 1 },
61
61
  "status": { "$ref": "#/definitions/status" },
62
62
  "shutdown": { "type": ["string", "null"], "format": "date" },
63
- "date_precision": { "enum": ["exact", "earliest", null] },
63
+ "date_precision": { "enum": ["exact", "earliest", "tentative", null] },
64
64
  "distribution_status": { "enum": ["active", "legacy", "extended-access", "retired", null] },
65
65
  "via": { "type": ["string", "null"], "minLength": 1 },
66
66
  "requested_via": { "type": ["string", "null"], "minLength": 1 },
@@ -77,6 +77,7 @@
77
77
  "items": { "type": "string", "pattern": "^[A-Za-z0-9][A-Za-z0-9._-]*$" }
78
78
  },
79
79
  "replacement_note": { "type": "string", "minLength": 1 },
80
+ "waiver": { "$ref": "#/definitions/waiver" },
80
81
  "source": { "type": ["string", "null"], "format": "uri" },
81
82
  "policy": {
82
83
  "type": ["object", "null"],
@@ -147,6 +148,17 @@
147
148
  "route_index": { "type": ["integer", "null"], "minimum": 0 }
148
149
  },
149
150
  "additionalProperties": false
151
+ },
152
+ "waiver": {
153
+ "type": ["object", "null"],
154
+ "required": ["reason", "owner", "expires", "active"],
155
+ "properties": {
156
+ "reason": { "type": "string", "minLength": 1, "maxLength": 500 },
157
+ "owner": { "type": "string", "minLength": 1, "maxLength": 200 },
158
+ "expires": { "type": "string", "format": "date" },
159
+ "active": { "type": "boolean" }
160
+ },
161
+ "additionalProperties": false
150
162
  }
151
163
  }
152
164
  }
@@ -36,6 +36,7 @@
36
36
  "effective_scope": { "enum": ["all", "direct"] },
37
37
  "policy_provenance": { "$ref": "#/definitions/policyProvenance" },
38
38
  "mapped_from": { "type": "string", "minLength": 1 },
39
+ "waiver": { "$ref": "#/definitions/waiver" },
39
40
  "sources": { "type": "array", "items": { "type": "string" } },
40
41
  "notes": { "type": ["string", "null"] }
41
42
  },
@@ -69,6 +70,7 @@
69
70
  },
70
71
  "replacement_note": { "type": "string" },
71
72
  "provider": { "type": "string" },
73
+ "waiver": { "$ref": "#/definitions/waiver" },
72
74
  "reason": { "type": "string", "minLength": 1 },
73
75
  "sources": { "type": "array", "items": { "type": "string" } },
74
76
  "notes": { "type": ["string", "null"] }
@@ -99,6 +101,17 @@
99
101
  "route_index": { "type": ["integer", "null"], "minimum": 0 }
100
102
  },
101
103
  "additionalProperties": false
104
+ },
105
+ "waiver": {
106
+ "type": ["object", "null"],
107
+ "required": ["reason", "owner", "expires", "active"],
108
+ "properties": {
109
+ "reason": { "type": "string", "minLength": 1, "maxLength": 500 },
110
+ "owner": { "type": "string", "minLength": 1, "maxLength": 200 },
111
+ "expires": { "type": "string", "format": "date" },
112
+ "active": { "type": "boolean" }
113
+ },
114
+ "additionalProperties": false
102
115
  }
103
116
  }
104
117
  }
@@ -40,7 +40,7 @@
40
40
  },
41
41
  "announced": { "$ref": "#/definitions/isoDate" },
42
42
  "shutdown": { "$ref": "#/definitions/isoDate" },
43
- "date_precision": { "enum": ["exact", "earliest"] },
43
+ "date_precision": { "enum": ["exact", "earliest", "tentative"] },
44
44
  "replacement": { "type": "string", "maxLength": 256, "pattern": "^[A-Za-z0-9][A-Za-z0-9._-]*$" },
45
45
  "replacement_options": {
46
46
  "type": "array",
@@ -59,7 +59,7 @@
59
59
  "via": { "type": "string", "minLength": 1 },
60
60
  "announced": { "$ref": "#/definitions/isoDate" },
61
61
  "shutdown": { "$ref": "#/definitions/isoDate" },
62
- "date_precision": { "enum": ["exact", "earliest"] },
62
+ "date_precision": { "enum": ["exact", "earliest", "tentative"] },
63
63
  "status": { "enum": ["active", "legacy", "extended-access", "retired"] },
64
64
  "source": { "type": "string", "format": "uri" }
65
65
  },