@company-semantics/contracts 98.0.0 → 99.1.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.
@@ -0,0 +1,146 @@
1
+ /**
2
+ * Scheduled-execution lifecycle, recovery and incident vocabulary.
3
+ *
4
+ * The backend ledger stores these states; the reconciler moves rows only along
5
+ * SCHEDULE_EXECUTION_TRANSITIONS. Wire values are lowercase snake_case.
6
+ *
7
+ * Lifecycle: due → dispatch_requested → started → completed → output_verified.
8
+ * - `dispatch_requested` is entered by a compare-and-swap on the attempt number
9
+ * BEFORE the dispatch call (claim-before-call), so two clocks ticking at once
10
+ * dispatch an attempt at most once. A crash before the call, or an ambiguous
11
+ * acceptance, leaves `dispatch_outcome` null/`ambiguous`: after the admission
12
+ * timeout the run is looked up by display_title; if none appeared, a NEW
13
+ * attempt is dispatched (`dispatch_requested` → `dispatch_requested`). Creating
14
+ * a run has no effect of its own; the workflow's claim is the gate.
15
+ * - `started` is entered by the workflow's OIDC-bound claim, which carries a
16
+ * monotonically increasing `claim_epoch` (the fencing token). A lease is not
17
+ * exactly-once: a stale holder's writes are rejected by epoch.
18
+ * - `completed` carries a conclusion. GitHub's run conclusion is authoritative;
19
+ * the workflow's own finish call is an optimization.
20
+ * - `timed_out`: the backend canceled a run past the schedule's timeoutMinutes.
21
+ * - Re-execution (`completed`/`timed_out` → `started` with a new epoch) happens
22
+ * automatically ONLY when the schedule's effects allow it
23
+ * (mayReexecuteAutomatically); `unsafe` effects wait for a human.
24
+ * - `superseded`: under catchUp `latest`, an older missed slot whose successor ran.
25
+ * - `missed`: under catchUp `skip`, a slot recorded as not run.
26
+ * - `acknowledged_loss`: a person applied SCHEDULE_LOSS_ACK_LABEL to its incident.
27
+ */
28
+
29
+ export const SCHEDULE_EXECUTION_STATES = [
30
+ "due",
31
+ "dispatch_requested",
32
+ "started",
33
+ "completed",
34
+ "output_verified",
35
+ "timed_out",
36
+ "superseded",
37
+ "missed",
38
+ "acknowledged_loss",
39
+ ] as const;
40
+ export type ScheduleExecutionState = (typeof SCHEDULE_EXECUTION_STATES)[number];
41
+
42
+ export const SCHEDULE_RUN_CONCLUSIONS = ["success", "failure"] as const;
43
+ export type ScheduleRunConclusion = (typeof SCHEDULE_RUN_CONCLUSIONS)[number];
44
+
45
+ export const SCHEDULE_DISPATCH_OUTCOMES = [
46
+ "accepted",
47
+ "rejected",
48
+ "ambiguous",
49
+ ] as const;
50
+ export type ScheduleDispatchOutcome =
51
+ (typeof SCHEDULE_DISPATCH_OUTCOMES)[number];
52
+
53
+ /** Allowed moves. A move not listed here is a reconciler bug, never a data repair. */
54
+ export const SCHEDULE_EXECUTION_TRANSITIONS: Readonly<
55
+ Record<ScheduleExecutionState, readonly ScheduleExecutionState[]>
56
+ > = {
57
+ due: ["dispatch_requested", "superseded", "missed", "acknowledged_loss"],
58
+ dispatch_requested: ["dispatch_requested", "started", "acknowledged_loss"],
59
+ started: ["started", "completed", "timed_out"],
60
+ completed: ["output_verified", "started", "acknowledged_loss"],
61
+ timed_out: ["started", "acknowledged_loss"],
62
+ output_verified: [],
63
+ superseded: [],
64
+ missed: [],
65
+ acknowledged_loss: [],
66
+ };
67
+
68
+ export function canTransitionScheduleExecution(
69
+ from: ScheduleExecutionState,
70
+ to: ScheduleExecutionState,
71
+ ): boolean {
72
+ return SCHEDULE_EXECUTION_TRANSITIONS[from].includes(to);
73
+ }
74
+
75
+ export interface ScheduleExecutionSettlementInput {
76
+ readonly state: ScheduleExecutionState;
77
+ readonly conclusion: ScheduleRunConclusion | null;
78
+ /** True when the schedule declares an output verifier. */
79
+ readonly requiresVerification: boolean;
80
+ }
81
+
82
+ /**
83
+ * Settled = nothing further will happen to this slot without a human.
84
+ * A successful completion settles only when no verifier is declared.
85
+ */
86
+ export function isScheduleExecutionSettled(
87
+ row: ScheduleExecutionSettlementInput,
88
+ ): boolean {
89
+ switch (row.state) {
90
+ case "output_verified":
91
+ case "superseded":
92
+ case "missed":
93
+ case "acknowledged_loss":
94
+ return true;
95
+ case "completed":
96
+ return row.conclusion === "success" && !row.requiresVerification;
97
+ default:
98
+ return false;
99
+ }
100
+ }
101
+
102
+ /** What the workflow's side effects tolerate. Declared per schedule; required. */
103
+ export const SCHEDULE_EFFECTS = [
104
+ "none",
105
+ "idempotent",
106
+ "fenced",
107
+ "unsafe",
108
+ ] as const;
109
+ export type ScheduleEffects = (typeof SCHEDULE_EFFECTS)[number];
110
+
111
+ /**
112
+ * none: read-only. idempotent: downstream writes keyed by execution_id.
113
+ * fenced: downstream writes carry claim_epoch and stale epochs are rejected.
114
+ * unsafe: none of the above: never re-executed without a human.
115
+ */
116
+ export function mayReexecuteAutomatically(effects: ScheduleEffects): boolean {
117
+ return effects !== "unsafe";
118
+ }
119
+
120
+ export const SCHEDULE_INCIDENT_CLASSES = [
121
+ "clock",
122
+ "admission",
123
+ "delivery",
124
+ "execution",
125
+ "output",
126
+ ] as const;
127
+ export type ScheduleIncidentClass = (typeof SCHEDULE_INCIDENT_CLASSES)[number];
128
+
129
+ /**
130
+ * Dedupe key for an incident. Subject: the clock source (clock), the
131
+ * schedule_id (delivery), or the execution_id (admission, execution, output).
132
+ * Each class resolves on its own subject, never on an unrelated newer run.
133
+ */
134
+ export function scheduleIncidentKey(
135
+ incidentClass: ScheduleIncidentClass,
136
+ subject: string,
137
+ ): string {
138
+ return `schedule:${incidentClass}:${subject}`;
139
+ }
140
+
141
+ export const SCHEDULE_INFRA_LABEL = "schedule-infra";
142
+ export const SCHEDULE_LOSS_ACK_LABEL = "schedule-loss-acknowledged";
143
+
144
+ /** Independent tick sources. Each one's health is tracked and alarmed separately. */
145
+ export const SCHEDULE_CLOCK_SOURCES = ["edge", "jobqueue"] as const;
146
+ export type ScheduleClockSource = (typeof SCHEDULE_CLOCK_SOURCES)[number];
@@ -0,0 +1,99 @@
1
+ /**
2
+ * Scheduled-execution identity.
3
+ *
4
+ * An execution is named by the slot it was INTENDED for, never by when a run
5
+ * happened to start: `execution_id = <schedule_id>/<slot ISO>`, with the slot a
6
+ * whole UTC minute written `YYYY-MM-DDTHH:MM:00Z`. A dispatch attempt is named
7
+ * `<execution_id>#<attempt>`; that string is the workflow's `run-name`, so the
8
+ * GitHub run's `display_title` is how a dispatched run is found again after an
9
+ * ambiguous dispatch.
10
+ *
11
+ * A run that carries no execution_id (a manual run, an unrelated dispatch, or a
12
+ * legacy `on.schedule` run) never satisfies a slot. Legacy runs are admitted
13
+ * only while the schedule's mode is not `live`, and still never get an id
14
+ * (migration admission; contracts ADR slug schedule-execution-wire-types).
15
+ */
16
+
17
+ /** Kebab-case, no `/` or `#`, so an execution_id and a display title parse unambiguously. */
18
+ export const SCHEDULE_ID_PATTERN = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
19
+ export const SCHEDULE_ID_MAX_LENGTH = 64;
20
+ export const SCHEDULE_SLOT_ISO_PATTERN = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:00Z$/;
21
+
22
+ /** The workflow_dispatch inputs every registered workflow declares once it leaves mode `off`. */
23
+ export const SCHEDULE_DISPATCH_INPUTS = [
24
+ "schedule_id",
25
+ "scheduled_at",
26
+ "execution_id",
27
+ "attempt",
28
+ ] as const;
29
+ export type ScheduleDispatchInput = (typeof SCHEDULE_DISPATCH_INPUTS)[number];
30
+
31
+ /** The exact `run-name:` a registered workflow declares, so display_title = execution_id#attempt. */
32
+ export const SCHEDULE_RUN_NAME =
33
+ "${{ inputs.execution_id && format('{0}#{1}', inputs.execution_id, inputs.attempt) || github.workflow }}";
34
+
35
+ export function isScheduleId(value: string): boolean {
36
+ return (
37
+ value.length <= SCHEDULE_ID_MAX_LENGTH && SCHEDULE_ID_PATTERN.test(value)
38
+ );
39
+ }
40
+
41
+ /** `YYYY-MM-DDTHH:MM:00Z`. Throws RangeError when `slot` is not a whole UTC minute. */
42
+ export function slotIso(slot: Date): string {
43
+ if (Number.isNaN(slot.getTime()) || slot.getTime() % 60_000 !== 0) {
44
+ throw new RangeError("a schedule slot must be a whole UTC minute");
45
+ }
46
+ return slot.toISOString().replace(".000Z", "Z");
47
+ }
48
+
49
+ export function executionIdFor(scheduleId: string, slot: Date): string {
50
+ if (!isScheduleId(scheduleId))
51
+ throw new RangeError(`invalid schedule id "${scheduleId}"`);
52
+ return `${scheduleId}/${slotIso(slot)}`;
53
+ }
54
+
55
+ export interface ParsedExecutionId {
56
+ readonly scheduleId: string;
57
+ readonly slot: Date;
58
+ }
59
+
60
+ export function parseExecutionId(
61
+ executionId: string,
62
+ ): ParsedExecutionId | null {
63
+ const slash = executionId.indexOf("/");
64
+ if (slash < 0) return null;
65
+ const scheduleId = executionId.slice(0, slash);
66
+ const iso = executionId.slice(slash + 1);
67
+ if (!isScheduleId(scheduleId) || !SCHEDULE_SLOT_ISO_PATTERN.test(iso))
68
+ return null;
69
+ const slot = new Date(iso);
70
+ if (Number.isNaN(slot.getTime()) || slotIso(slot) !== iso) return null;
71
+ return { scheduleId, slot };
72
+ }
73
+
74
+ export function displayTitleFor(executionId: string, attempt: number): string {
75
+ if (parseExecutionId(executionId) === null) {
76
+ throw new RangeError(`invalid execution id "${executionId}"`);
77
+ }
78
+ if (!Number.isInteger(attempt) || attempt < 1) {
79
+ throw new RangeError("a dispatch attempt is a positive integer");
80
+ }
81
+ return `${executionId}#${attempt}`;
82
+ }
83
+
84
+ export interface ParsedDisplayTitle extends ParsedExecutionId {
85
+ readonly executionId: string;
86
+ readonly attempt: number;
87
+ }
88
+
89
+ /** Inverse of displayTitleFor; null for any run title that is not a scheduled dispatch. */
90
+ export function parseDisplayTitle(title: string): ParsedDisplayTitle | null {
91
+ const hash = title.lastIndexOf("#");
92
+ if (hash < 0) return null;
93
+ const executionId = title.slice(0, hash);
94
+ const attemptText = title.slice(hash + 1);
95
+ if (!/^[1-9]\d*$/.test(attemptText)) return null;
96
+ const parsed = parseExecutionId(executionId);
97
+ if (parsed === null) return null;
98
+ return { ...parsed, executionId, attempt: Number(attemptText) };
99
+ }
@@ -0,0 +1,124 @@
1
+ /**
2
+ * Schedules Domain Barrel
3
+ *
4
+ * Re-exports the scheduled-execution vocabulary: schedule definitions and the
5
+ * registry snapshot, execution identity, the execution lifecycle with its
6
+ * recovery and incident vocabulary, the pure UTC cron evaluator, and the
7
+ * responses the backend's schedule routes publish.
8
+ * Import from the package root, '@company-semantics/contracts'. This barrel is
9
+ * reached only through that root re-export: there is no './schedules' subpath
10
+ * in package.json, by design (same as product-intake).
11
+ *
12
+ * Participating repos: control authors the registry and validates it with
13
+ * parseCron; the backend stores the ledger and runs the reconciler on the same
14
+ * evaluator; edge reads SCHEDULE_TICK_PATH and the ScheduleTickRequest type. The
15
+ * ci action speaks HTTP only and imports nothing here.
16
+ *
17
+ * @see ./README.md
18
+ */
19
+
20
+ export {
21
+ parseCron,
22
+ cronMatches,
23
+ previousSlot,
24
+ nextSlot,
25
+ slotsBetween,
26
+ maxIntervalMinutes,
27
+ } from "./cron";
28
+ export type {
29
+ CronField,
30
+ ParsedCron,
31
+ CronParseResult,
32
+ SlotWindow,
33
+ } from "./cron";
34
+
35
+ export {
36
+ SCHEDULE_ID_PATTERN,
37
+ SCHEDULE_ID_MAX_LENGTH,
38
+ SCHEDULE_SLOT_ISO_PATTERN,
39
+ SCHEDULE_DISPATCH_INPUTS,
40
+ SCHEDULE_RUN_NAME,
41
+ isScheduleId,
42
+ slotIso,
43
+ executionIdFor,
44
+ parseExecutionId,
45
+ displayTitleFor,
46
+ parseDisplayTitle,
47
+ } from "./identity";
48
+ export type {
49
+ ScheduleDispatchInput,
50
+ ParsedExecutionId,
51
+ ParsedDisplayTitle,
52
+ } from "./identity";
53
+
54
+ export {
55
+ SCHEDULE_EXECUTION_STATES,
56
+ SCHEDULE_RUN_CONCLUSIONS,
57
+ SCHEDULE_DISPATCH_OUTCOMES,
58
+ SCHEDULE_EXECUTION_TRANSITIONS,
59
+ canTransitionScheduleExecution,
60
+ isScheduleExecutionSettled,
61
+ SCHEDULE_EFFECTS,
62
+ mayReexecuteAutomatically,
63
+ SCHEDULE_INCIDENT_CLASSES,
64
+ scheduleIncidentKey,
65
+ SCHEDULE_INFRA_LABEL,
66
+ SCHEDULE_LOSS_ACK_LABEL,
67
+ SCHEDULE_CLOCK_SOURCES,
68
+ } from "./execution";
69
+ export type {
70
+ ScheduleExecutionState,
71
+ ScheduleRunConclusion,
72
+ ScheduleDispatchOutcome,
73
+ ScheduleExecutionSettlementInput,
74
+ ScheduleEffects,
75
+ ScheduleIncidentClass,
76
+ ScheduleClockSource,
77
+ } from "./execution";
78
+
79
+ export {
80
+ SCHEDULE_CATCH_UP_POLICIES,
81
+ SCHEDULE_IDEMPOTENCY_LEVELS,
82
+ SCHEDULE_OUTPUT_VERIFIERS,
83
+ SCHEDULE_MODES,
84
+ SCHEDULE_SEVERITIES,
85
+ SCHEDULE_TIMEZONE,
86
+ SCHEDULE_MAX_CATCH_UP_SLOTS,
87
+ SCHEDULE_MAX_TIMEOUT_MINUTES,
88
+ ScheduleDefinitionSchema,
89
+ SCHEDULE_DEFINITION_KEYS,
90
+ canonicalScheduleRegistryJson,
91
+ SCHEDULE_REGISTRY_SCHEMA_VERSION,
92
+ ScheduleRegistrySnapshotSchema,
93
+ } from "./definition";
94
+ export type {
95
+ ScheduleCatchUpPolicy,
96
+ ScheduleIdempotency,
97
+ ScheduleOutputVerifier,
98
+ ScheduleMode,
99
+ ScheduleSeverity,
100
+ ScheduleDefinition,
101
+ ScheduleRegistrySnapshot,
102
+ } from "./definition";
103
+
104
+ export {
105
+ SCHEDULE_CLAIM_REFUSALS,
106
+ ScheduleClaimResponseSchema,
107
+ SCHEDULE_FINISH_REJECTIONS,
108
+ ScheduleFinishResponseSchema,
109
+ ScheduleTickResponseSchema,
110
+ SCHEDULE_TICK_PATH,
111
+ SCHEDULE_METRICS_NAMESPACE,
112
+ SCHEDULE_METRIC_TICK,
113
+ SCHEDULE_METRIC_TICK_SOURCE_DIMENSION,
114
+ SCHEDULE_METRIC_RECONCILE_SUCCESS,
115
+ SCHEDULE_METRIC_ENVIRONMENT_DIMENSION,
116
+ } from "./api";
117
+ export type {
118
+ ScheduleClaimRefusal,
119
+ ScheduleClaimResponse,
120
+ ScheduleFinishRejection,
121
+ ScheduleFinishResponse,
122
+ ScheduleTickResponse,
123
+ ScheduleTickRequest,
124
+ } from "./api";