Skip to content

Commit dbace6b

Browse files
committed
feat(spec,platform-objects): add degraded to the job status vocabulary (#7072)
Executes the 2026-08-08 maintainer ruling on #5548 — one additional outcome meaning "completed without accomplishing the work", and no enum family. This is the consumer-side half of the `JobRunOutcome` producer shape #6617 shipped on `contracts/job-service.ts`. Three declaration sites move together because the two platform-object selects are enforced by ObjectQL's record validator (`invalid_option`) while `DbJobAdapter.finishRun` swallows the rejection in a best-effort try/catch — a value legal in the spec enum but absent from the selects is a silently dropped write that strands the run row at `running`, not a type error. - packages/spec/src/system/job.zod.ts — JobExecutionStatus + TSDoc - packages/platform-objects/.../sys-job-run.object.ts — status select - packages/platform-objects/.../sys-job.object.ts — last_status select `degraded` is not a failure and never retries; its `reason` rides the existing `error` / `last_error` columns with `failure_count` flat, and the TSDoc records the cost of that (an "Error" column may carry a non-error note). Also regenerated: i18n bundles (en/zh-CN/ja-JP/es-ES, translated rather than left at the raw seed) and content/docs/references/system/job.mdx. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PiRUoQkTSBBmpyXBY3cVn2
1 parent f3f855a commit dbace6b

10 files changed

Lines changed: 148 additions & 12 deletions

File tree

.changeset/tame-jars-shake.md

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
---
2+
'@objectstack/spec': minor
3+
'@objectstack/platform-objects': minor
4+
---
5+
6+
feat(spec,platform-objects): add `degraded` to the job status vocabulary (#7072)
7+
8+
`JobExecutionStatus` and the two `sys_job*` selects now carry a fifth value,
9+
`degraded` — "the run finished, but its work did not happen". This is the
10+
consumer-side half of the `JobRunOutcome` producer shape #6617 shipped on
11+
`contracts/job-service.ts`, and it executes the 2026-08-08 maintainer ruling on
12+
#5548 verbatim:
13+
14+
> **Vocabulary stays minimal** — one additional outcome meaning "completed
15+
> without accomplishing the work". ⛔ Do not open an enum family; a second key
16+
> would need its own pull.
17+
18+
Three declaration sites had to move together, because the two platform-object
19+
selects are *enforced* — ObjectQL's record validator refuses an
20+
out-of-vocabulary `select` value with `invalid_option`, and `DbJobAdapter`
21+
swallows that rejection in a best-effort `try/catch`. A value legal in the spec
22+
enum but absent from the selects would therefore be a silently dropped write
23+
that leaves the run row `running` forever, not a type error:
24+
25+
- `packages/spec/src/system/job.zod.ts``JobExecutionStatus`
26+
- `packages/platform-objects/src/audit/sys-job-run.object.ts``status`
27+
- `packages/platform-objects/src/audit/sys-job.object.ts``last_status`
28+
29+
**`degraded` is not a failure and never retries.** Retry and failure are driven
30+
exclusively by a rejected handler promise, so a resolved
31+
`{ outcome: 'degraded' }` never re-runs the job.
32+
33+
A degraded run's `reason` rides the existing `error` / `last_error` columns and
34+
leaves `failure_count` flat — the ruling's minimal-vocabulary spirit applied to
35+
columns as to enum members. The cost is recorded in the TSDoc at the enum: a
36+
column labelled "Error" may carry a non-error operator note whenever
37+
`status === 'degraded'`, so readers must gate on the status first.
38+
39+
Additive only: no existing value changed meaning, and nothing yet produces
40+
`degraded` — wiring `DbJobAdapter` to map the outcome is #5548, which this
41+
unblocks. Locale bundles (en / zh-CN / ja-JP / es-ES) carry the new option.

content/docs/references/system/job.mdx

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -83,7 +83,7 @@ const result = CronScheduleSchema.parse(data);
8383
| **jobId** | `string` || Job identifier |
8484
| **startedAt** | `string` || ISO 8601 datetime when execution started |
8585
| **completedAt** | `string` | optional | ISO 8601 datetime when execution completed |
86-
| **status** | `Enum<'running' \| 'success' \| 'failed' \| 'timeout'>` || Execution status |
86+
| **status** | `Enum<'running' \| 'success' \| 'failed' \| 'timeout' \| 'degraded'>` || Execution status |
8787
| **error** | `string` | optional | Error message if failed |
8888
| **durationMs** | `integer` | optional | Execution duration in milliseconds |
8989

@@ -98,6 +98,7 @@ const result = CronScheduleSchema.parse(data);
9898
* `success`
9999
* `failed`
100100
* `timeout`
101+
* `degraded`
101102

102103

103104
---

packages/platform-objects/src/apps/translations/en.objects.generated.ts

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2343,7 +2343,8 @@ export const enObjects: NonNullable<TranslationData['objects']> = {
23432343
success: "success",
23442344
failed: "failed",
23452345
timeout: "timeout",
2346-
running: "running"
2346+
running: "running",
2347+
degraded: "degraded"
23472348
}
23482349
},
23492350
last_error: {
@@ -2380,7 +2381,8 @@ export const enObjects: NonNullable<TranslationData['objects']> = {
23802381
running: "running",
23812382
success: "success",
23822383
failed: "failed",
2383-
timeout: "timeout"
2384+
timeout: "timeout",
2385+
degraded: "degraded"
23842386
}
23852387
},
23862388
started_at: {

packages/platform-objects/src/apps/translations/es-ES.objects.generated.ts

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2343,7 +2343,8 @@ export const esESObjects: NonNullable<TranslationData['objects']> = {
23432343
success: "Correcto",
23442344
failed: "Fallido",
23452345
timeout: "Tiempo de espera agotado",
2346-
running: "En ejecución"
2346+
running: "En ejecución",
2347+
degraded: "Degradado"
23472348
}
23482349
},
23492350
last_error: {
@@ -2380,7 +2381,8 @@ export const esESObjects: NonNullable<TranslationData['objects']> = {
23802381
running: "En ejecución",
23812382
success: "Correcto",
23822383
failed: "Fallido",
2383-
timeout: "Tiempo de espera agotado"
2384+
timeout: "Tiempo de espera agotado",
2385+
degraded: "Degradado"
23842386
}
23852387
},
23862388
started_at: {

packages/platform-objects/src/apps/translations/ja-JP.objects.generated.ts

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2343,7 +2343,8 @@ export const jaJPObjects: NonNullable<TranslationData['objects']> = {
23432343
success: "成功",
23442344
failed: "失敗",
23452345
timeout: "タイムアウト",
2346-
running: "実行中"
2346+
running: "実行中",
2347+
degraded: "縮退"
23472348
}
23482349
},
23492350
last_error: {
@@ -2380,7 +2381,8 @@ export const jaJPObjects: NonNullable<TranslationData['objects']> = {
23802381
running: "実行中",
23812382
success: "成功",
23822383
failed: "失敗",
2383-
timeout: "タイムアウト"
2384+
timeout: "タイムアウト",
2385+
degraded: "縮退"
23842386
}
23852387
},
23862388
started_at: {

packages/platform-objects/src/apps/translations/zh-CN.objects.generated.ts

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2343,7 +2343,8 @@ export const zhCNObjects: NonNullable<TranslationData['objects']> = {
23432343
success: "成功",
23442344
failed: "失败",
23452345
timeout: "超时",
2346-
running: "运行中"
2346+
running: "运行中",
2347+
degraded: "降级"
23472348
}
23482349
},
23492350
last_error: {
@@ -2380,7 +2381,8 @@ export const zhCNObjects: NonNullable<TranslationData['objects']> = {
23802381
running: "运行中",
23812382
success: "成功",
23822383
failed: "失败",
2383-
timeout: "超时"
2384+
timeout: "超时",
2385+
degraded: "降级"
23842386
}
23852387
},
23862388
started_at: {

packages/platform-objects/src/audit/sys-job-run.object.ts

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -46,8 +46,15 @@ export const SysJobRun = ObjectSchema.create({
4646
group: 'Identity',
4747
}),
4848

49+
// [#7072] `degraded` = ran to completion, work did not happen (#5548's
50+
// ruling: one additional outcome, no enum family). This list is *enforced*
51+
// — ObjectQL's record validator refuses an unlisted value with
52+
// `invalid_option` — and must stay identical to `JobExecutionStatus` in
53+
// `@objectstack/spec` (`system/job.zod.ts`) and to `sys_job.last_status`.
54+
// A degraded run puts its reason in `error` below and does not bump the
55+
// job's `failure_count`.
4956
status: Field.select(
50-
['running', 'success', 'failed', 'timeout'],
57+
['running', 'success', 'failed', 'timeout', 'degraded'],
5158
{ label: 'Status', required: true, defaultValue: 'running', group: 'State' },
5259
),
5360

packages/platform-objects/src/audit/sys-job.object.ts

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -70,8 +70,14 @@ export const SysJob = ObjectSchema.create({
7070
}),
7171

7272
last_run_at: Field.datetime({ label: 'Last Run At', required: false, group: 'State' }),
73+
// [#7072] `degraded` mirrors `sys_job_run.status` (#5548's ruling: one
74+
// additional outcome meaning "completed without accomplishing the work").
75+
// Enforced by ObjectQL's record validator, so it must stay in step with
76+
// `JobExecutionStatus` in `@objectstack/spec` and with `sys_job_run.status`.
77+
// A degraded run leaves `failure_count` below untouched and puts its reason
78+
// in `last_error` — that column may therefore carry a non-error note.
7379
last_status: Field.select(
74-
['success', 'failed', 'timeout', 'running'],
80+
['success', 'failed', 'timeout', 'running', 'degraded'],
7581
{ label: 'Last Status', required: false, group: 'State' },
7682
),
7783
last_error: Field.textarea({ label: 'Last Error', required: false, group: 'State' }),

packages/spec/src/system/job.test.ts

Lines changed: 44 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -400,12 +400,36 @@ describe('JobExecutionStatus', () => {
400400
expect(() => JobExecutionStatus.parse('success')).not.toThrow();
401401
expect(() => JobExecutionStatus.parse('failed')).not.toThrow();
402402
expect(() => JobExecutionStatus.parse('timeout')).not.toThrow();
403+
// [#7072] The fifth value, per #5548's ruling: "completed without
404+
// accomplishing the work". Not a failure, never retried.
405+
expect(() => JobExecutionStatus.parse('degraded')).not.toThrow();
403406
});
404407

405408
it('should reject invalid execution statuses', () => {
406409
expect(() => JobExecutionStatus.parse('pending')).toThrow();
407410
expect(() => JobExecutionStatus.parse('cancelled')).toThrow();
408411
expect(() => JobExecutionStatus.parse('')).toThrow();
412+
// [#7072] The ruling closes the vocabulary at five: "⛔ Do not open an enum
413+
// family; a second key would need its own pull." These are the near-misses a
414+
// future adapter is most likely to reach for; they stay refused.
415+
expect(() => JobExecutionStatus.parse('partial')).toThrow();
416+
expect(() => JobExecutionStatus.parse('skipped')).toThrow();
417+
expect(() => JobExecutionStatus.parse('Degraded')).toThrow();
418+
});
419+
420+
it('should carry exactly the five ruled values, in declaration order', () => {
421+
// [#7072] Pins the vocabulary itself, not just membership: the two
422+
// `platform-objects` selects (`sys_job_run.status`, `sys_job.last_status`)
423+
// are *enforced* by ObjectQL's record validator, so this enum growing a
424+
// value they do not carry is a silently swallowed write rather than a type
425+
// error. Any change here needs the same change there.
426+
expect(JobExecutionStatus.options).toEqual([
427+
'running',
428+
'success',
429+
'failed',
430+
'timeout',
431+
'degraded',
432+
]);
409433
});
410434
});
411435

@@ -463,8 +487,27 @@ describe('JobExecutionSchema', () => {
463487
expect(parsed.status).toBe('timeout');
464488
});
465489

490+
it('should accept degraded execution', () => {
491+
// [#7072] A degraded run's `reason` rides the existing `error` field — the
492+
// ruling's minimal vocabulary applies to columns too, so no `reason` member
493+
// was added. Note the cost this pins: `error` carries a non-error operator
494+
// note whenever the status is `degraded`.
495+
const execution = {
496+
jobId: 'job-321',
497+
startedAt: '2024-01-15T13:00:00Z',
498+
completedAt: '2024-01-15T13:00:02Z',
499+
status: 'degraded',
500+
error: 'STORE_UNAVAILABLE',
501+
durationMs: 2000,
502+
};
503+
504+
const parsed = JobExecutionSchema.parse(execution);
505+
expect(parsed.status).toBe('degraded');
506+
expect(parsed.error).toBe('STORE_UNAVAILABLE');
507+
});
508+
466509
it('should accept all execution statuses', () => {
467-
const statuses: Array<JobExecution['status']> = ['running', 'success', 'failed', 'timeout'];
510+
const statuses: Array<JobExecution['status']> = ['running', 'success', 'failed', 'timeout', 'degraded'];
468511

469512
statuses.forEach(status => {
470513
const execution = {

packages/spec/src/system/job.zod.ts

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -174,12 +174,42 @@ export function defineJob(config: z.input<typeof JobSchema>): JobParsed {
174174
/**
175175
* Job Execution Status Enum
176176
* Status of job execution
177+
*
178+
* [#7072] `degraded` executes the 2026-08-08 maintainer ruling on #5548, quoted
179+
* verbatim: 「**Vocabulary stays minimal** — one additional outcome meaning
180+
* "completed without accomplishing the work". ⛔ Do not open an enum family; a
181+
* second key would need its own pull.」 It is the consumer-side half of the
182+
* `JobRunOutcome` producer shape #6617 shipped on `contracts/job-service.ts`,
183+
* and it is declared in exactly three places that must agree: this enum,
184+
* `sys_job_run.status` and `sys_job.last_status` (both in
185+
* `@objectstack/platform-objects`). The platform-object selects are *enforced*
186+
* — ObjectQL's record validator refuses an out-of-vocabulary `select` value
187+
* with `invalid_option` — so a value legal here and absent there is a silently
188+
* swallowed write, not a type error.
189+
*
190+
* ⚠️ **`degraded` is NOT a failure and never retries.** It means the run ran to
191+
* completion and its work did not happen (a store was unavailable, zero rows
192+
* matched a precondition). Retry and failure are driven exclusively by a
193+
* *rejected* handler promise; a resolved `{ outcome: 'degraded' }` never
194+
* re-runs the job. See {@link JobHandler} in `contracts/job-service.ts` for the
195+
* three-outcome table this mirrors.
196+
*
197+
* **Where the reason goes, and what that costs.** A degraded run's `reason`
198+
* rides the existing `error` column (`sys_job.last_error` for the job-level
199+
* mirror) and leaves `failure_count` flat — the ruling's minimal-vocabulary
200+
* spirit applied to columns as to enum members, decided at the `domain:services`
201+
* seat on #7072. The cost is stated here rather than left for the next reader: a
202+
* column labelled **"Error"** may carry a non-error operator note whenever
203+
* `status === 'degraded'`, so a reader must gate on the status before reading
204+
* that column as a failure. Adding a distinct `reason` column would be the
205+
* "second key" the ruling reserves for its own pull.
177206
*/
178207
export const JobExecutionStatus = z.enum([
179208
'running',
180209
'success',
181210
'failed',
182211
'timeout',
212+
'degraded',
183213
]);
184214

185215
export type JobExecutionStatus = z.input<typeof JobExecutionStatus>;

0 commit comments

Comments
 (0)