@capsiynau/intelligence-contracts 0.1.1 → 0.1.2

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/README.md CHANGED
@@ -22,9 +22,21 @@ in the platform.
22
22
 
23
23
  ## The additive rule
24
24
 
25
- Within a major version, a status, code, scope or capability may be **added**,
26
- never removed or repurposed. `scripts/check-contract-additive.mjs` fails the
27
- build on a removal, checked against `contract-baseline.json`.
25
+ Within a major version, a status, code, scope, capability or record field may
26
+ be **added**, never removed or repurposed. `scripts/check-contract-additive.mjs`
27
+ fails the build on a removal, checked against `contract-baseline.json`.
28
+
29
+ ## Records
30
+
31
+ The execution record (AIOG's audit account of one execution) and the external
32
+ execution report (a product-side fallback reported to AIOG) are defined in
33
+ `src/execution.js` and documented field by field in [SCHEMA.md](./SCHEMA.md).
34
+ Both are contract only as of 18.09.2026: nothing writes them yet. Every
35
+ jurisdiction in them carries its source, so "unknown" is recorded rather than
36
+ left out. An execution is identified at four separate layers - route
37
+ (`provider`), execution platform (`platform`), model (`model_id`,
38
+ `model_version`) and runtime/worker (`runtime_version`, `placement`) - and a
39
+ platform is neither a language nor a jurisdiction; see SCHEMA.md.
28
40
 
29
41
  ## Usage
30
42
 
package/SCHEMA.md ADDED
@@ -0,0 +1,214 @@
1
+ # @capsiynau/intelligence-contracts: record schemas
2
+
3
+ Shapes that are records rather than messages. Today that is the execution
4
+ record and the external execution report, both added on 18.09.2026 (remediation
5
+ plan stage 0.5). Source: `src/execution.js`. Types: `types/index.d.ts`.
6
+
7
+ ## The rules both shapes follow
8
+
9
+ - **Contract only, for now.** Nothing in the platform writes either shape yet.
10
+ Populating the execution record is stage 0.2; the endpoint that accepts an
11
+ external report comes after that. The existing table,
12
+ `aiog.intelligence_api_execution_records`, is unchanged.
13
+ - **Every key is present.** A value that is not known is `null`, never an
14
+ omitted key. A record that omits a field looks complete; one that says `null`
15
+ is honestly incomplete.
16
+ - **Unknown is recorded as unknown.** Every jurisdiction and every serving
17
+ region is paired with its source: `unknown`, `declared-config` (an operator
18
+ set it, on the provider's written attestation) or `provider-attested` (the
19
+ provider's own response said so). `null` goes with `unknown` and only with
20
+ `unknown`. Nothing is inferred from a provider's name, endpoint host, model
21
+ name or publishing country, and a `null` jurisdiction never satisfies
22
+ `uk_only`.
23
+ - **Additive.** `record_version` and `report_version` are integers, currently
24
+ `1`. A later version may add fields; it may not remove or repurpose one. The
25
+ validators accept a later version's extra fields. Every field path, nested
26
+ ones included (`attempts[].provider`), is in `scripts/contract-baseline.json`,
27
+ and `scripts/check-contract-additive.mjs` fails the build if one disappears.
28
+ - **Audit documents, not job results.** Provider names appear here because an
29
+ operator, a regulator or a product's compliance team reads these. They are
30
+ opaque strings: the contract enumerates no provider, no model and no fallback
31
+ order. A job result still never names its provider (ADR 0006).
32
+
33
+ ## Four identities, kept apart
34
+
35
+ Corrected 18.09.2026, before the contract shipped. One execution is identified
36
+ at four layers, and no field stands in for another:
37
+
38
+ capability + requirements -> AIOG route -> execution platform -> model -> worker
39
+
40
+ | Layer | Field | What it is | What it is NOT |
41
+ |---|---|---|---|
42
+ | Route | `provider` | AIOG's adapter: the route AIOG chose (`openai-whisper`, `welsh-selfhosted`). An AIOG concept. | The company that ran the work. Several routes can reach one platform. |
43
+ | Platform | `platform` | The execution platform the route called (`pendra`, `openai`). | A language, a jurisdiction, or a model. |
44
+ | Model | `model_id`, `model_version` | The model the platform ran, as it reported executing, and the model's own identity or version: a weights digest, or a version label the platform publishes for that model. | The runtime build. A worker's software version never goes in `model_version`, even when it is the only version on offer. |
45
+ | Runtime / worker | `runtime_version`, `placement.node_id` | The serving runtime or worker software build, and the worker instance that served. | The model's version. The same weights under a new decoder build can score differently, which is why the two are separate. |
46
+
47
+ **`model_version` and `runtime_version` answer different questions.**
48
+ `model_version` says *which weights ran*: two runs with the same value ran the
49
+ same model. `runtime_version` says *which serving software ran them*. For
50
+ Pendra today that maps as:
51
+
52
+ | Pendra source | Field |
53
+ |---|---|
54
+ | response `model` | `model_id` |
55
+ | catalogue `gguf_sha256` for that variant | `model_version` |
56
+ | header `x-worker-version` / `x-runtime-version` | `runtime_version` |
57
+ | header `x-worker-id`, `x-worker-name` | `placement.node_id`, `placement.node_name` |
58
+ | header `x-request-id` | `provider_execution_id` |
59
+
60
+ A value is never moved between rows to fill a gap: a missing `model_version`
61
+ stays `null` rather than borrowing the worker's build number.
62
+
63
+ **`provider` means the route, and only the route.** It is used that way
64
+ consistently: in the runners (`attempts.push({ provider: provider.name })`), the
65
+ policy observation (`{ id: routeProvider }`), and the `route_provider` column.
66
+ Nobody should read `provider === 'pendra'`. It is not, and will not be: the
67
+ platform has its own field for exactly that reason.
68
+
69
+ An execution on Pendra therefore reads the same whichever model it ran:
70
+
71
+ | | Welsh ASR on Pendra | Gemma on Pendra |
72
+ |---|---|---|
73
+ | `provider` | the ASR route | the analysis route |
74
+ | `platform` | `pendra` | `pendra` |
75
+ | `model_id` | the Welsh ASR model id | the Gemma model id |
76
+ | `model_version` | its weights hash, or `null` | its weights hash, or `null` |
77
+ | `runtime_version` | Pendra's worker build | Pendra's worker build |
78
+ | `placement.node_id` | the worker that served | the worker that served |
79
+ | `provider_execution_id` | Pendra's `x-request-id` | Pendra's `x-request-id` |
80
+ | `jurisdiction`, `jurisdiction_source` | as evidenced, else `null` / `unknown` | as evidenced, else `null` / `unknown` |
81
+
82
+ Gemma needs no special case. It is an ordinary model id on a platform.
83
+
84
+ ### Two invariants
85
+
86
+ - **A platform is not a language.** Pendra is not "Welsh". Language and
87
+ specialism belong to the model and the capability.
88
+ - **A platform is not a jurisdiction.** Pendra is not "UK-only". Where work ran
89
+ is `jurisdiction` and `placement.region`, each with its evidence source. A
90
+ platform's name, like a route's name, is never that evidence, and a `null`
91
+ never satisfies `uk_only`.
92
+
93
+ ### Retry and fallback
94
+
95
+ Defined across all three identities, not by "the provider changed", because one
96
+ platform hosts several models:
97
+
98
+ | Event | Means | `fallback_scope` |
99
+ |---|---|---|
100
+ | `retry` | Another attempt at the same route, platform and model, after a transient failure. `to_*` repeats `from_*`. | `null` |
101
+ | `fallback` | Execution moved. `fallback_scope` is the widest identity that changed, and must agree with `from_*` / `to_*` (`fallbackScopeOf`). | `model` (same route and platform, another model), `route` (another route onto the same platform), `platform` (another platform) |
102
+
103
+ A fallback that changes none of the three is a retry recorded under the wrong
104
+ type, and is refused.
105
+
106
+ ## The execution record
107
+
108
+ AIOG's authoritative account of one intelligence-layer execution. It must be
109
+ sufficient to audit a request without consulting the calling product's
110
+ database. Validate with `validateExecutionRecord(record)`.
111
+
112
+ "Today" says where each value can come from at `origin/main` on 18.09.2026:
113
+ **column** means the existing row already stores it, **runner** means the
114
+ runner or job holds it but does not store it, **not yet** means nothing
115
+ produces it and stage 0.2 has to.
116
+
117
+ | Field | Meaning | Today |
118
+ |---|---|---|
119
+ | `record_version` | Schema version, `1` | new |
120
+ | `execution_record_id` | `exr_…` | column |
121
+ | `request_id`, `job_id` | Platform request and job | column |
122
+ | `client_id` | The calling product's credential client | column |
123
+ | `tenant_reference` | The product's organisation, opaque to AIOG | column |
124
+ | `user_reference` | The product's user, when sent | column (writer passes none) |
125
+ | `capability`, `task_class` | What was asked for | column (`task_class` never set) |
126
+ | `requirements.language` | `source_language` as submitted | runner (job request) |
127
+ | `requirements.residency` | A per-request residency requirement | not yet: no request field exists |
128
+ | `requirements.quality_profile` | As submitted | column `quality_profile` |
129
+ | `requirements.diarisation` | `off`, `auto`, `speakers:<n>` | runner (job request) |
130
+ | `requirements.data_class` | As submitted | column `data_class` |
131
+ | `policy_id`, `policy_version` | Citation of the tenant's policy | column (null without a policy) |
132
+ | `effective_policy` | Resolved hard constraints plus `enforced`; `null` when the tenant has no policy | runner can resolve it (`resolveHard`); stored only as `execution_profile.policy_observation` |
133
+ | `eligible_candidates[]` | `provider`, `platform`, `model_id`, `reason`, `jurisdiction`, `jurisdiction_source`. A candidate is a route, a platform AND a model: one platform can offer several models, and policy may admit one and block another | runner can have the route (`candidatesFor`); no adapter declares its platform, and no reason is produced today |
134
+ | `blocked_candidates[]` | `provider`, `platform`, `model_id`, `rule` (the `rejectionReason` vocabulary), `jurisdiction`, `jurisdiction_source` | not yet: policy is not evaluated per candidate before dispatch, and `supports()` returns a bare false with no reason |
135
+ | `selected_provider`, `selected_platform`, `selected_model_id` | The routing decision, before any attempt | runner can have the route (first candidate); not stored |
136
+ | `route_reason` | Why that route (`static-chain` today) | column |
137
+ | `attempts[]` | `provider` (route), `platform` (required), `model_id`, `model_version`, `runtime_version` (worker build), `started_at`, `latency_ms`, `outcome`, `error_code`, `provider_execution_id`, `jurisdiction`, `jurisdiction_source`, `placement` | partial: the runner sees each attempt start (`onAttempt`) but keeps only the last; a failed attempt's error is swallowed inside the registry. Pendra adapters return the model as `execution.runtimeId` (an internal name that pre-dates this contract; it maps to `model_id`) and the worker build as `execution.runtimeVersion` |
138
+ | `attempts[].placement` | `node_id`, `node_name`, `region`, `region_source` | Pendra adapters return node id and name; no provider reports a region |
139
+ | `events[]` | `type` (`retry`, `fallback`), `fallback_scope`, `at`, `from_provider`, `from_platform`, `from_model_id`, `to_provider`, `to_platform`, `to_model_id`, `reason` | not yet: fallbacks happen inside the registry unreported; AIOG has no in-place retry, so `retry` stays empty |
140
+ | `final_provider` | The route whose answer was kept | column `route_provider` on success (on failure that column holds the last attempted) |
141
+ | `final_platform`, `final_model_id`, `final_model_version`, `final_runtime_version` | The platform, model and worker build that served. Required to match a succeeded attempt | not yet: no column; the Pendra adapters return model and worker build, the transcription runner keeps neither |
142
+ | `final_jurisdiction`, `final_jurisdiction_source` | Where the served attempt ran, and how that is known | not yet: no adapter declares one |
143
+ | `provider_execution_id` | The provider's own request id (Pendra's `x-request-id`) | column; the transcription runner fills it when the adapter reports one (stage 0.1) |
144
+ | `prompt_version` | Prompt identity for prompted capabilities | column (writer passes none) |
145
+ | `usage` | `{ unit, amount }` in platform units | column |
146
+ | `provider_cost_internal` | Platform margin analysis, never shown to a caller | column (never populated) |
147
+ | `queue_ms`, `provider_ms`, `total_ms` | Timings | column |
148
+ | `outcome` | A terminal job status | column |
149
+ | `error_code` | Contract error code; required when `outcome` is `failed` | column |
150
+ | `created_at` | ISO 8601 | column |
151
+
152
+ The validator also refuses contradictions: a `completed` or
153
+ `partially_completed` record with no succeeded attempt or no `final_platform`, a
154
+ `final_provider`, `final_platform` or `final_model_id` that no succeeded attempt
155
+ matches, an attempt with no platform, a failed attempt without an `error_code`,
156
+ a succeeded one with one, an event that does not name both ends in full, a
157
+ retry that changed anything, and a fallback whose `fallback_scope` disagrees
158
+ with what changed.
159
+
160
+ ## The external execution report
161
+
162
+ While traffic migrates, a product still makes some provider calls itself and
163
+ may fall back on its own side (Capsiynau's direct Pendra path falls back to
164
+ other engines, for example). **A product-side fallback that is not reported to
165
+ AIOG cannot count as canary evidence**, because AIOG's own records would then
166
+ describe a cleaner route than the one the customer got. This is the shape the
167
+ report takes. There is no endpoint for it yet.
168
+
169
+ Validate with `validateExternalExecutionReport(report)`.
170
+
171
+ | Field | Meaning |
172
+ |---|---|
173
+ | `report_version` | Schema version, `1` |
174
+ | `report_id` | Chosen by the product; the idempotency key for the report |
175
+ | `tenant_reference` | The product's organisation |
176
+ | `capability` | As in the record |
177
+ | `product_reference` | The product's own job or project id, opaque to AIOG |
178
+ | `job_id`, `request_id` | The AIOG job and request this relates to, or `null` when the work never touched AIOG |
179
+ | `occurred_at` | ISO 8601 |
180
+ | `requirements` | Same shape as the record's |
181
+ | `attempts[]` | Same shape as the record's, product-side providers included |
182
+ | `events[]` | Same shape as the record's; at least one `fallback` is required |
183
+ | `final_provider`, `final_platform`, `final_model_id`, `final_model_version` | What served in the end: the route, the platform and the model |
184
+ | `final_jurisdiction`, `final_jurisdiction_source` | As in the record |
185
+ | `provider_execution_id` | The final provider's request id, when known |
186
+ | `usage`, `outcome`, `error_code` | As in the record |
187
+
188
+ There is no `client_id`. The reporting product is whoever holds the credential
189
+ the report arrives with; a body that could name its own client could name
190
+ another's.
191
+
192
+ ## What stage 0.2 has to populate
193
+
194
+ This contract is the target; filling it is stage 0.2. Found while correcting
195
+ the identities on 18.09.2026 and deliberately NOT fixed here:
196
+
197
+ - **Every adapter declares its platform.** No adapter carries one today, so
198
+ nothing can fill `platform` yet.
199
+ - **Pendra analysis drops all provenance.** `pendraAnalysis.js` returns text and
200
+ usage only, discarding the `execution` its runtime call returned: model,
201
+ worker build, worker and request id never reach a record.
202
+ - **The transcription runner keeps only `provider_execution_id`.**
203
+ `normaliseResult` carries the model, worker build and placement up; the runner
204
+ stores none of them.
205
+ - **`model_version` has no producer.** Pendra publishes a weights hash in its
206
+ catalogue (`gguf_sha256`, read as `contentHash`) but not on an execution
207
+ response, so `identifiesExecution()` can never be satisfied by a Pendra run.
208
+ - **Policy lists match route labels.** Observation builds candidates as
209
+ `{ id: routeProvider }`, so a `provider_denylist` naming a platform matches
210
+ nothing. Platform- and model-level policy needs the candidate identity above.
211
+ - **Usage and cost carry no route, platform or model.** The usage ledger joins
212
+ to the record by job only, and `provider_cost_internal` is never populated.
213
+ - **Internal names pre-date the contract.** Adapters say `runtimeId` for what the
214
+ contract calls `model_id`; align them when 0.2 maps one onto the other.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@capsiynau/intelligence-contracts",
3
- "version": "0.1.1",
3
+ "version": "0.1.2",
4
4
  "description": "Wire contracts between products and the Intelligence Layer: job statuses, error codes, capability names, the segment model, and event schemas. Types and constants only - no implementation, no prompts, no provider names.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -16,12 +16,14 @@
16
16
  "./errors": "./src/errors.js",
17
17
  "./capabilities": "./src/capabilities.js",
18
18
  "./segments": "./src/segments.js",
19
- "./events": "./src/events.js"
19
+ "./events": "./src/events.js",
20
+ "./execution": "./src/execution.js"
20
21
  },
21
22
  "files": [
22
23
  "src",
23
24
  "types",
24
25
  "README.md",
26
+ "SCHEMA.md",
25
27
  "LICENSE"
26
28
  ],
27
29
  "engines": {
@@ -36,7 +38,7 @@
36
38
  },
37
39
  "repository": {
38
40
  "type": "git",
39
- "url": "git+https://github.com/aledprysparry/capsiynau-intelligence.git",
41
+ "url": "git+https://github.com/aledprysparry/aiog.git",
40
42
  "directory": "packages/intelligence-contracts"
41
43
  }
42
44
  }
package/src/errors.js CHANGED
@@ -49,6 +49,13 @@ export const ERROR_CODE = Object.freeze({
49
49
  RATE_LIMIT_EXCEEDED: { status: 429, retryable: true, message: 'Too many requests.' },
50
50
  PROVIDER_UNAVAILABLE: { status: 503, retryable: true, message: 'The upstream service is temporarily unavailable.' },
51
51
  PROVIDER_REJECTED: { status: 502, retryable: false, message: 'The upstream service rejected the request.' },
52
+ // Added 18.09.2026. The upstream ACCEPTED the request and answered success,
53
+ // but the answer cannot be used (a transcript with no timed segments), or it
54
+ // failed in some other way a retry will not fix. PROVIDER_REJECTED says the
55
+ // upstream refused, which is untrue here; PROCESSING_FAILED and
56
+ // PROVIDER_UNAVAILABLE both tell a client to retry, which would resubmit
57
+ // work that fails the same way and is paid for each time.
58
+ PROVIDER_FAILED: { status: 502, retryable: false, message: 'The upstream service failed in a way that retrying will not fix.' },
52
59
  PROCESSING_FAILED: { status: 500, retryable: true, message: 'Processing failed.' },
53
60
  TIMEOUT_EXCEEDED: { status: 504, retryable: true, message: 'The operation timed out.' },
54
61
  CONFLICT_JOB_TERMINAL: { status: 409, retryable: false, message: 'The job has already reached a terminal state.' },
@@ -0,0 +1,546 @@
1
+ /**
2
+ * @capsiynau/intelligence-contracts/execution
3
+ *
4
+ * The execution record: AIOG's authoritative account of one intelligence-layer
5
+ * execution (remediation plan stage 0.5, 18.09.2026). It must be sufficient to
6
+ * audit a request without consulting the calling product's database, so it
7
+ * carries who asked, what they required, what policy was in force, which
8
+ * candidates were eligible or blocked and why, every attempt, every retry and
9
+ * fallback, what finally served, what it cost and how it ended.
10
+ *
11
+ * And the external execution report: the shape a PRODUCT uses to report a
12
+ * fallback that happened on its own side, outside AIOG, while traffic is
13
+ * migrating. The rule it exists for: a product-side fallback that is not
14
+ * reported to AIOG cannot count as canary evidence, because AIOG's records
15
+ * would then describe a cleaner route than the one customers actually got.
16
+ *
17
+ * CONTRACT ONLY. Nothing in the platform writes this shape yet: populating it
18
+ * is stage 0.2, and the endpoint that accepts a report comes later. The
19
+ * existing row (`aiog.intelligence_api_execution_records`) keeps its columns;
20
+ * every field below reuses a column's name where one exists, so filling the
21
+ * record is a mapping rather than a renaming. See SCHEMA.md for the field
22
+ * table and where each value comes from today.
23
+ *
24
+ * WHY PROVIDER NAMES MAY APPEAR HERE, when ADR 0006 keeps them from callers:
25
+ * this is an audit document read by an operator, a regulator or the product's
26
+ * own compliance team, not a job result. Providers are opaque strings; the
27
+ * contract enumerates none of them, and no model id, prompt or fallback ORDER
28
+ * is published by it. The record says what happened, never how routing works.
29
+ *
30
+ * FOUR IDENTITIES, KEPT APART (18.09.2026). One execution is identified at four
31
+ * layers, and no field stands in for another:
32
+ *
33
+ * provider AIOG's route: the adapter that made the call
34
+ * (`openai-whisper`, `welsh-selfhosted`). An AIOG concept.
35
+ * It is NOT the company that ran the work, and it never
36
+ * will be - several routes can reach one platform.
37
+ * platform The execution platform the route called (`pendra`,
38
+ * `openai`). Where the work was sent.
39
+ * model_id The model the platform ran (a Gemma variant, a Welsh ASR
40
+ * model), as the platform reported it executing.
41
+ * model_version The identity or version of the MODEL ITSELF: a digest of
42
+ * its weights (e.g. Pendra's catalogue `gguf_sha256`), or a
43
+ * version label the platform publishes for that model. Two
44
+ * runs with the same model_version ran the same weights.
45
+ * NEVER the serving software: a worker or runtime build
46
+ * (`x-worker-version`, `x-runtime-version`) is
47
+ * runtime_version, and must not be copied here even when it
48
+ * is the only version on offer. Null when the platform gives
49
+ * no model identity - an honest gap, not a reason to borrow
50
+ * another layer's number.
51
+ * runtime_version The serving runtime or worker BUILD. Not the model's
52
+ * version: the same weights under a new decoder build can
53
+ * score differently, which is why it is kept separately.
54
+ * placement The worker INSTANCE that served (`node_id`), and where it
55
+ * ran as far as is evidenced.
56
+ *
57
+ * So an execution on Pendra reads `provider: <route>, platform: 'pendra',
58
+ * model_id: <model>`, whether the model is Gemma or Welsh ASR. Two invariants
59
+ * follow, and nothing in this contract may encode either assumption:
60
+ *
61
+ * A PLATFORM IS NOT A LANGUAGE. Pendra is not "Welsh": language and
62
+ * specialism belong to the model and the capability, not to the platform.
63
+ *
64
+ * A PLATFORM IS NOT A JURISDICTION. Pendra is not "UK-only": where work ran
65
+ * is `jurisdiction` / `placement.region` with their evidence sources, and a
66
+ * platform's name, like a route's name, is never that evidence.
67
+ *
68
+ * UNKNOWN IS RECORDED AS UNKNOWN. Wherever the record carries a jurisdiction
69
+ * or a serving region it carries the SOURCE beside it: `unknown`,
70
+ * `declared-config` (an operator set it, on a provider's written attestation)
71
+ * or `provider-attested` (the provider's own response said so). A null with
72
+ * source `unknown` is a finding; a missing field would be a gap nobody can
73
+ * tell from one. Nothing here may be inferred from a provider's name, host,
74
+ * model or publishing country, and a null never satisfies `uk_only`.
75
+ *
76
+ * ADDITIVE, like everything in this package. `record_version` and
77
+ * `report_version` are integers; a later version may add fields and must not
78
+ * remove or repurpose one, and a validator for version N accepts a record of a
79
+ * later version whose extra fields it does not know. The field lists are
80
+ * guarded by scripts/check-contract-additive.mjs.
81
+ */
82
+ import { ALL_CAPABILITIES } from './capabilities.js'
83
+ import { TERMINAL_STATUSES, JOB_STATUS } from './jobs.js'
84
+ import { isValidDiarisation } from './segments.js'
85
+
86
+ export const EXECUTION_RECORD_VERSION = 1
87
+ export const EXTERNAL_EXECUTION_REPORT_VERSION = 1
88
+
89
+ /** How one attempt ended. A skipped candidate is not an attempt. */
90
+ export const ATTEMPT_OUTCOME = Object.freeze({
91
+ SUCCEEDED: 'succeeded',
92
+ FAILED: 'failed',
93
+ })
94
+
95
+ /**
96
+ * Something that happened BETWEEN attempts, with the identity it moved from and
97
+ * to. Both carry the reason, which is normally the error code of the attempt
98
+ * that prompted them.
99
+ *
100
+ * retry another attempt at the SAME route, platform and model, after a
101
+ * transient failure. `to_*` repeats `from_*`.
102
+ * fallback execution moved to a different identity. Which kind of move is
103
+ * `fallback_scope`, below - "the provider changed" is not the
104
+ * definition, because a platform hosts several models and a model
105
+ * change on one platform is a fallback too.
106
+ */
107
+ export const EXECUTION_EVENT_TYPE = Object.freeze({
108
+ RETRY: 'retry',
109
+ FALLBACK: 'fallback',
110
+ })
111
+
112
+ /**
113
+ * The widest identity a fallback changed. Derived from `from_*` / `to_*` and
114
+ * checked against them, so the label cannot contradict the evidence:
115
+ *
116
+ * platform a different execution platform (and so, normally, a different route)
117
+ * route a different AIOG route onto the same platform
118
+ * model the same route and platform, a different model
119
+ *
120
+ * A retry carries `fallback_scope: null`. A fallback that changes none of the
121
+ * three is a retry recorded under the wrong type, and is refused.
122
+ */
123
+ export const FALLBACK_SCOPE = Object.freeze({
124
+ MODEL: 'model',
125
+ ROUTE: 'route',
126
+ PLATFORM: 'platform',
127
+ })
128
+
129
+ /**
130
+ * Where a jurisdiction or region value came from. `unknown` pairs with null
131
+ * and only with null: a value with no source is a guess, and a source with no
132
+ * value is a contradiction.
133
+ */
134
+ export const JURISDICTION_SOURCE = Object.freeze({
135
+ UNKNOWN: 'unknown',
136
+ DECLARED_CONFIG: 'declared-config',
137
+ PROVIDER_ATTESTED: 'provider-attested',
138
+ })
139
+
140
+ /** What the caller required. Every key present; null where it was not stated. */
141
+ export const EXECUTION_REQUIREMENT_KEYS = Object.freeze([
142
+ 'language', 'residency', 'quality_profile', 'diarisation', 'data_class',
143
+ ])
144
+
145
+ /**
146
+ * The policy in force, resolved for this execution's data class. Named as the
147
+ * policy resolver names its hard constraints, plus `enforced`, which records
148
+ * whether anything actually gated on it (observe-only records false).
149
+ */
150
+ export const EXECUTION_POLICY_KEYS = Object.freeze([
151
+ 'enforced', 'residency', 'external_providers', 'training_use', 'data_class',
152
+ 'on_unsatisfiable', 'provider_allowlist', 'provider_denylist',
153
+ ])
154
+
155
+ /**
156
+ * A candidate is a route, a platform AND a model, because one platform can
157
+ * offer several models for the same requirement and policy may admit one and
158
+ * block another. `model_id` is null only where a route has not resolved a
159
+ * model at candidate time.
160
+ */
161
+ const CANDIDATE_IDENTITY_KEYS = Object.freeze(['provider', 'platform', 'model_id'])
162
+
163
+ /** An eligible candidate, why it was eligible, and where it runs as far as is known. */
164
+ export const ELIGIBLE_CANDIDATE_KEYS = Object.freeze([
165
+ ...CANDIDATE_IDENTITY_KEYS, 'reason', 'jurisdiction', 'jurisdiction_source',
166
+ ])
167
+
168
+ /** A blocked candidate and the rule that blocked it (`residency_undeclared`...). */
169
+ export const BLOCKED_CANDIDATE_KEYS = Object.freeze([
170
+ ...CANDIDATE_IDENTITY_KEYS, 'rule', 'jurisdiction', 'jurisdiction_source',
171
+ ])
172
+
173
+ /**
174
+ * Where one attempt was served, as evidenced. A worker id with no serving
175
+ * region is recorded exactly so: `region: null`, `region_source: 'unknown'`.
176
+ */
177
+ export const EXECUTION_PLACEMENT_KEYS = Object.freeze(['node_id', 'node_name', 'region', 'region_source'])
178
+
179
+ /** One call, through one route, to one model on one platform. */
180
+ export const EXECUTION_ATTEMPT_KEYS = Object.freeze([
181
+ 'provider', 'platform', 'model_id', 'model_version', 'runtime_version',
182
+ 'started_at', 'latency_ms', 'outcome', 'error_code', 'provider_execution_id',
183
+ 'jurisdiction', 'jurisdiction_source', 'placement',
184
+ ])
185
+
186
+ export const EXECUTION_EVENT_KEYS = Object.freeze([
187
+ 'type', 'fallback_scope', 'at',
188
+ 'from_provider', 'from_platform', 'from_model_id',
189
+ 'to_provider', 'to_platform', 'to_model_id',
190
+ 'reason',
191
+ ])
192
+
193
+ /** Every top-level key of the execution record, version 1. */
194
+ export const EXECUTION_RECORD_KEYS = Object.freeze([
195
+ 'record_version', 'execution_record_id', 'request_id', 'job_id',
196
+ 'client_id', 'tenant_reference', 'user_reference',
197
+ 'capability', 'task_class', 'requirements',
198
+ 'policy_id', 'policy_version', 'effective_policy',
199
+ 'eligible_candidates', 'blocked_candidates',
200
+ 'selected_provider', 'selected_platform', 'selected_model_id', 'route_reason',
201
+ 'attempts', 'events',
202
+ 'final_provider', 'final_platform', 'final_model_id', 'final_model_version', 'final_runtime_version',
203
+ 'final_jurisdiction', 'final_jurisdiction_source', 'provider_execution_id',
204
+ 'prompt_version', 'usage', 'provider_cost_internal',
205
+ 'queue_ms', 'provider_ms', 'total_ms',
206
+ 'outcome', 'error_code', 'created_at',
207
+ ])
208
+
209
+ /** Keys that must be present AND non-null. The rest may be an honest null. */
210
+ const RECORD_NON_NULL = Object.freeze([
211
+ 'record_version', 'execution_record_id', 'client_id', 'tenant_reference', 'capability',
212
+ 'requirements', 'eligible_candidates', 'blocked_candidates', 'route_reason',
213
+ 'attempts', 'events', 'final_jurisdiction_source', 'outcome', 'created_at',
214
+ ])
215
+
216
+ /**
217
+ * Every top-level key of an external execution report, version 1. There is no
218
+ * `client_id`: the reporting product is whoever holds the credential it
219
+ * reports with, and a body that named its own client could name another's.
220
+ */
221
+ export const EXTERNAL_EXECUTION_REPORT_KEYS = Object.freeze([
222
+ 'report_version', 'report_id', 'tenant_reference', 'capability',
223
+ 'product_reference', 'job_id', 'request_id', 'occurred_at',
224
+ 'requirements', 'attempts', 'events',
225
+ 'final_provider', 'final_platform', 'final_model_id', 'final_model_version',
226
+ 'final_jurisdiction', 'final_jurisdiction_source',
227
+ 'provider_execution_id', 'usage', 'outcome', 'error_code',
228
+ ])
229
+
230
+ const REPORT_NON_NULL = Object.freeze([
231
+ 'report_version', 'report_id', 'tenant_reference', 'capability', 'product_reference',
232
+ 'occurred_at', 'requirements', 'attempts', 'events', 'final_jurisdiction_source', 'outcome',
233
+ ])
234
+
235
+ /**
236
+ * Dotted paths, `[]` marking an array of objects (`attempts[].provider`): the
237
+ * unit the additive guard compares, so removing a nested field fails the build
238
+ * exactly as removing a top-level one does.
239
+ */
240
+ function fieldPaths(topKeys, objects = {}, arrays = {}) {
241
+ const paths = []
242
+ for (const key of topKeys) {
243
+ paths.push(key)
244
+ for (const child of objects[key] || []) paths.push(`${key}.${child}`)
245
+ for (const child of arrays[key] || []) {
246
+ paths.push(`${key}[].${child}`)
247
+ if (key === 'attempts' && child === 'placement') {
248
+ for (const leaf of EXECUTION_PLACEMENT_KEYS) paths.push(`${key}[].${child}.${leaf}`)
249
+ }
250
+ }
251
+ }
252
+ return Object.freeze(paths.sort())
253
+ }
254
+
255
+ /** Every field of the execution record, as a sorted list of paths. */
256
+ export const EXECUTION_RECORD_FIELDS = fieldPaths(EXECUTION_RECORD_KEYS, {
257
+ requirements: EXECUTION_REQUIREMENT_KEYS,
258
+ effective_policy: EXECUTION_POLICY_KEYS,
259
+ }, {
260
+ eligible_candidates: ELIGIBLE_CANDIDATE_KEYS,
261
+ blocked_candidates: BLOCKED_CANDIDATE_KEYS,
262
+ attempts: EXECUTION_ATTEMPT_KEYS,
263
+ events: EXECUTION_EVENT_KEYS,
264
+ })
265
+
266
+ /** Every field of the external execution report, as a sorted list of paths. */
267
+ export const EXTERNAL_EXECUTION_REPORT_FIELDS = fieldPaths(EXTERNAL_EXECUTION_REPORT_KEYS, {
268
+ requirements: EXECUTION_REQUIREMENT_KEYS,
269
+ }, {
270
+ attempts: EXECUTION_ATTEMPT_KEYS,
271
+ events: EXECUTION_EVENT_KEYS,
272
+ })
273
+
274
+ // ── Shared checks ───────────────────────────────────────────────────────────
275
+
276
+ const isObject = (v) => v !== null && typeof v === 'object' && !Array.isArray(v)
277
+ const isText = (v) => typeof v === 'string' && v.trim().length > 0
278
+ const isInstant = (v) => typeof v === 'string' && Number.isFinite(Date.parse(v))
279
+ const isQuantity = (v) => typeof v === 'number' && Number.isFinite(v) && v >= 0
280
+
281
+ const SOURCES = Object.values(JURISDICTION_SOURCE)
282
+
283
+ /**
284
+ * A value and its source, checked together. Null pairs with `unknown`; a
285
+ * value pairs with a source that is not `unknown`.
286
+ */
287
+ function checkSourced(obj, valueKey, sourceKey, where, problems) {
288
+ const value = obj[valueKey]
289
+ const source = obj[sourceKey]
290
+ if (source === undefined) return
291
+ if (!SOURCES.includes(source)) return problems.push(`${where}${sourceKey} must be one of ${SOURCES.join(', ')}`)
292
+ if (value != null && !isText(value)) return problems.push(`${where}${valueKey} must be a string or null`)
293
+ if (value == null && source !== JURISDICTION_SOURCE.UNKNOWN) {
294
+ problems.push(`${where}${valueKey} is null, so ${sourceKey} must be unknown`)
295
+ }
296
+ if (value != null && source === JURISDICTION_SOURCE.UNKNOWN) {
297
+ problems.push(`${where}${valueKey} has a value, so ${sourceKey} cannot be unknown`)
298
+ }
299
+ }
300
+
301
+ function requireKeys(obj, keys, nonNull, where, problems) {
302
+ for (const key of keys) {
303
+ if (obj[key] === undefined) problems.push(`${where}missing ${key}`)
304
+ else if (obj[key] === null && nonNull.includes(key)) problems.push(`${where}${key} must not be null`)
305
+ }
306
+ }
307
+
308
+ function checkRequirements(req, problems) {
309
+ if (!isObject(req)) return problems.push('requirements must be an object')
310
+ requireKeys(req, EXECUTION_REQUIREMENT_KEYS, [], 'requirements: ', problems)
311
+ for (const key of EXECUTION_REQUIREMENT_KEYS) {
312
+ if (req[key] != null && typeof req[key] !== 'string') problems.push(`requirements: ${key} must be a string or null`)
313
+ }
314
+ if (req.diarisation != null && !isValidDiarisation(req.diarisation)) {
315
+ problems.push('requirements: diarisation is not off, auto or speakers:<n>')
316
+ }
317
+ }
318
+
319
+ function checkAttempts(attempts, problems) {
320
+ if (!Array.isArray(attempts)) return problems.push('attempts must be an array')
321
+ attempts.forEach((a, i) => {
322
+ const at = `attempts[${i}]: `
323
+ if (!isObject(a)) return problems.push(`${at}not an object`)
324
+ requireKeys(a, EXECUTION_ATTEMPT_KEYS, ['provider', 'platform', 'started_at', 'outcome', 'jurisdiction_source', 'placement'], at, problems)
325
+ checkSourced(a, 'jurisdiction', 'jurisdiction_source', at, problems)
326
+ // The route and the platform are always known: AIOG chose the one and the
327
+ // one it chose names the other. The model and its versions may honestly
328
+ // not be.
329
+ for (const key of ['provider', 'platform']) {
330
+ if (a[key] != null && !isText(a[key])) problems.push(`${at}${key} must be a non-empty string`)
331
+ }
332
+ for (const key of ['model_id', 'model_version', 'runtime_version']) {
333
+ if (a[key] != null && !isText(a[key])) problems.push(`${at}${key} must be a non-empty string or null`)
334
+ }
335
+ if (a.started_at != null && !isInstant(a.started_at)) problems.push(`${at}started_at must be an ISO 8601 instant`)
336
+ if (a.latency_ms != null && !isQuantity(a.latency_ms)) problems.push(`${at}latency_ms must be a non-negative number or null`)
337
+ if (a.outcome != null && !Object.values(ATTEMPT_OUTCOME).includes(a.outcome)) problems.push(`${at}unknown outcome ${a.outcome}`)
338
+ // A failure with no code cannot be audited; a success with one is a contradiction.
339
+ if (a.outcome === ATTEMPT_OUTCOME.FAILED && !isText(a.error_code)) problems.push(`${at}a failed attempt needs an error_code`)
340
+ if (a.outcome === ATTEMPT_OUTCOME.SUCCEEDED && a.error_code != null) problems.push(`${at}a succeeded attempt carries no error_code`)
341
+ if (a.placement != null) {
342
+ if (!isObject(a.placement)) problems.push(`${at}placement must be an object`)
343
+ else {
344
+ requireKeys(a.placement, EXECUTION_PLACEMENT_KEYS, ['region_source'], `${at}placement: `, problems)
345
+ checkSourced(a.placement, 'region', 'region_source', `${at}placement: `, problems)
346
+ }
347
+ }
348
+ })
349
+ }
350
+
351
+ /**
352
+ * The widest identity that differs between the two ends of an event, or null
353
+ * when none does. Platform outranks route outranks model.
354
+ */
355
+ export function fallbackScopeOf(e) {
356
+ if (e?.from_platform !== e?.to_platform) return FALLBACK_SCOPE.PLATFORM
357
+ if (e?.from_provider !== e?.to_provider) return FALLBACK_SCOPE.ROUTE
358
+ if ((e?.from_model_id ?? null) !== (e?.to_model_id ?? null)) return FALLBACK_SCOPE.MODEL
359
+ return null
360
+ }
361
+
362
+ function checkEvents(events, problems) {
363
+ if (!Array.isArray(events)) return problems.push('events must be an array')
364
+ events.forEach((e, i) => {
365
+ const at = `events[${i}]: `
366
+ if (!isObject(e)) return problems.push(`${at}not an object`)
367
+ requireKeys(e, EXECUTION_EVENT_KEYS, ['type', 'at', 'from_provider', 'from_platform', 'reason'], at, problems)
368
+ if (e.type != null && !Object.values(EXECUTION_EVENT_TYPE).includes(e.type)) problems.push(`${at}unknown type ${e.type}`)
369
+ if (e.at != null && !isInstant(e.at)) problems.push(`${at}at must be an ISO 8601 instant`)
370
+
371
+ // BOTH ENDS NAMED IN FULL. An event that says where it came from and not
372
+ // where it went is half a route; the audit needs both halves to explain it.
373
+ for (const end of ['from', 'to']) {
374
+ for (const key of ['provider', 'platform']) {
375
+ if (!isText(e[`${end}_${key}`])) problems.push(`${at}${end}_${key} must be a non-empty string`)
376
+ }
377
+ const model = e[`${end}_model_id`]
378
+ if (model != null && !isText(model)) problems.push(`${at}${end}_model_id must be a non-empty string or null`)
379
+ }
380
+
381
+ const scope = fallbackScopeOf(e)
382
+ if (e.type === EXECUTION_EVENT_TYPE.RETRY) {
383
+ if (scope !== null) problems.push(`${at}a retry repeats the same route, platform and model; this one changed ${scope}`)
384
+ if (e.fallback_scope != null) problems.push(`${at}a retry carries no fallback_scope`)
385
+ }
386
+ if (e.type === EXECUTION_EVENT_TYPE.FALLBACK) {
387
+ if (scope === null) problems.push(`${at}a fallback that changes no route, platform or model is a retry`)
388
+ else if (e.fallback_scope !== scope) {
389
+ problems.push(`${at}fallback_scope must be ${scope}, the widest identity that changed`)
390
+ }
391
+ }
392
+ })
393
+ }
394
+
395
+ function checkCandidates(list, keys, name, problems) {
396
+ if (!Array.isArray(list)) return problems.push(`${name} must be an array`)
397
+ // `reason` for an eligible candidate, `rule` for a blocked one.
398
+ const because = keys.find((k) => k === 'reason' || k === 'rule')
399
+ list.forEach((c, i) => {
400
+ const at = `${name}[${i}]: `
401
+ if (!isObject(c)) return problems.push(`${at}not an object`)
402
+ requireKeys(c, keys, ['jurisdiction_source'], at, problems)
403
+ for (const key of ['provider', 'platform', because]) {
404
+ if (!isText(c[key])) problems.push(`${at}${key} must be a non-empty string`)
405
+ }
406
+ if (c.model_id != null && !isText(c.model_id)) problems.push(`${at}model_id must be a non-empty string or null`)
407
+ checkSourced(c, 'jurisdiction', 'jurisdiction_source', at, problems)
408
+ })
409
+ }
410
+
411
+ /** Checks both shapes share: capability, outcome and its code, usage, the served provider. */
412
+ function checkEnding(doc, problems) {
413
+ checkSourced(doc, 'final_jurisdiction', 'final_jurisdiction_source', '', problems)
414
+ if (doc.capability != null && !ALL_CAPABILITIES.includes(doc.capability)) problems.push(`unknown capability ${doc.capability}`)
415
+ if (doc.outcome != null && !TERMINAL_STATUSES.includes(doc.outcome)) {
416
+ problems.push(`outcome must be one of ${TERMINAL_STATUSES.join(', ')}`)
417
+ }
418
+ if (doc.outcome === JOB_STATUS.FAILED && !isText(doc.error_code)) problems.push('a failed execution needs an error_code')
419
+ if (doc.usage != null && (!isObject(doc.usage) || !isText(doc.usage.unit) || !isQuantity(doc.usage.amount))) {
420
+ problems.push('usage must be null or { unit, amount } with a non-negative amount')
421
+ }
422
+ // WHAT SERVED MUST BE AN ATTEMPT THAT SUCCEEDED, at every identity layer. A
423
+ // record naming a final provider, platform or model that nothing in its own
424
+ // attempt list succeeded with is describing a route that did not happen - and
425
+ // "succeeded on Pendra" is not "succeeded with THIS model on Pendra".
426
+ const served = Array.isArray(doc.attempts)
427
+ ? doc.attempts.filter((a) => a?.outcome === ATTEMPT_OUTCOME.SUCCEEDED)
428
+ : []
429
+ const resultBearing = doc.outcome === JOB_STATUS.COMPLETED || doc.outcome === JOB_STATUS.PARTIALLY_COMPLETED
430
+ if (resultBearing && served.length === 0) problems.push(`a ${doc.outcome} execution needs a succeeded attempt`)
431
+ if (doc.final_provider != null) {
432
+ const byProvider = served.filter((a) => a.provider === doc.final_provider)
433
+ if (byProvider.length === 0) problems.push('final_provider is not the provider of any succeeded attempt')
434
+ else {
435
+ const byPlatform = byProvider.filter((a) => a.platform === doc.final_platform)
436
+ if (byPlatform.length === 0) problems.push('final_platform is not the platform of the succeeded attempt it names')
437
+ else if (doc.final_model_id != null && !byPlatform.some((a) => a.model_id === doc.final_model_id)) {
438
+ problems.push('final_model_id is not the model of the succeeded attempt it names')
439
+ }
440
+ }
441
+ }
442
+ if (resultBearing && doc.final_provider == null) problems.push(`a ${doc.outcome} execution names its final_provider`)
443
+ if (resultBearing && doc.final_platform == null) problems.push(`a ${doc.outcome} execution names its final_platform`)
444
+ for (const key of ['final_platform', 'final_model_id', 'final_model_version']) {
445
+ if (doc[key] != null && !isText(doc[key])) problems.push(`${key} must be a non-empty string or null`)
446
+ }
447
+ }
448
+
449
+ function checkVersion(value, name, problems) {
450
+ if (value != null && !(Number.isInteger(value) && value >= 1)) problems.push(`${name} must be a positive integer`)
451
+ }
452
+
453
+ // ── Validators ──────────────────────────────────────────────────────────────
454
+
455
+ /**
456
+ * Structural check on an execution record.
457
+ *
458
+ * Every key is PRESENT, with null where the value is honestly unknown: a
459
+ * record that omits a field looks complete, and one that says null is
460
+ * honestly incomplete. Unknown extra keys are accepted, so a record of a later
461
+ * version still validates here.
462
+ *
463
+ * @param {unknown} record
464
+ * @returns {{ valid: boolean, problems: string[] }}
465
+ */
466
+ export function validateExecutionRecord(record) {
467
+ if (!isObject(record)) return { valid: false, problems: ['execution record is not an object'] }
468
+ const problems = []
469
+ requireKeys(record, EXECUTION_RECORD_KEYS, RECORD_NON_NULL, '', problems)
470
+ checkVersion(record.record_version, 'record_version', problems)
471
+ for (const key of ['execution_record_id', 'client_id', 'tenant_reference', 'route_reason']) {
472
+ if (record[key] != null && !isText(record[key])) problems.push(`${key} must be a non-empty string`)
473
+ }
474
+ if (record.created_at != null && !isInstant(record.created_at)) problems.push('created_at must be an ISO 8601 instant')
475
+ if (record.requirements != null) checkRequirements(record.requirements, problems)
476
+ if (record.effective_policy != null) {
477
+ if (!isObject(record.effective_policy)) problems.push('effective_policy must be an object or null')
478
+ else {
479
+ requireKeys(record.effective_policy, EXECUTION_POLICY_KEYS, ['enforced'], 'effective_policy: ', problems)
480
+ if (typeof record.effective_policy.enforced !== 'boolean') problems.push('effective_policy: enforced must be a boolean')
481
+ }
482
+ }
483
+ if (record.eligible_candidates != null) checkCandidates(record.eligible_candidates, ELIGIBLE_CANDIDATE_KEYS, 'eligible_candidates', problems)
484
+ if (record.blocked_candidates != null) checkCandidates(record.blocked_candidates, BLOCKED_CANDIDATE_KEYS, 'blocked_candidates', problems)
485
+ if (record.attempts != null) checkAttempts(record.attempts, problems)
486
+ if (record.events != null) checkEvents(record.events, problems)
487
+ for (const key of ['provider_cost_internal', 'queue_ms', 'provider_ms', 'total_ms']) {
488
+ if (record[key] != null && !isQuantity(record[key])) problems.push(`${key} must be a non-negative number or null`)
489
+ }
490
+ checkEnding(record, problems)
491
+ return { valid: problems.length === 0, problems }
492
+ }
493
+
494
+ /**
495
+ * Structural check on an external execution report.
496
+ *
497
+ * A report exists to describe a product-side FALLBACK, so one without a
498
+ * fallback event is refused: it would add a record to the canary evidence
499
+ * that says nothing the evidence needs.
500
+ *
501
+ * @param {unknown} report
502
+ * @returns {{ valid: boolean, problems: string[] }}
503
+ */
504
+ export function validateExternalExecutionReport(report) {
505
+ if (!isObject(report)) return { valid: false, problems: ['external execution report is not an object'] }
506
+ const problems = []
507
+ requireKeys(report, EXTERNAL_EXECUTION_REPORT_KEYS, REPORT_NON_NULL, '', problems)
508
+ checkVersion(report.report_version, 'report_version', problems)
509
+ for (const key of ['report_id', 'tenant_reference', 'product_reference']) {
510
+ if (report[key] != null && !isText(report[key])) problems.push(`${key} must be a non-empty string`)
511
+ }
512
+ if (report.occurred_at != null && !isInstant(report.occurred_at)) problems.push('occurred_at must be an ISO 8601 instant')
513
+ if (report.requirements != null) checkRequirements(report.requirements, problems)
514
+ if (report.attempts != null) checkAttempts(report.attempts, problems)
515
+ if (report.events != null) {
516
+ checkEvents(report.events, problems)
517
+ if (Array.isArray(report.events) && !report.events.some((e) => e?.type === EXECUTION_EVENT_TYPE.FALLBACK)) {
518
+ problems.push('a report describes at least one fallback event')
519
+ }
520
+ }
521
+ checkEnding(report, problems)
522
+ return { valid: problems.length === 0, problems }
523
+ }
524
+
525
+ export default {
526
+ EXECUTION_RECORD_VERSION,
527
+ EXTERNAL_EXECUTION_REPORT_VERSION,
528
+ ATTEMPT_OUTCOME,
529
+ EXECUTION_EVENT_TYPE,
530
+ FALLBACK_SCOPE,
531
+ JURISDICTION_SOURCE,
532
+ EXECUTION_REQUIREMENT_KEYS,
533
+ EXECUTION_POLICY_KEYS,
534
+ ELIGIBLE_CANDIDATE_KEYS,
535
+ BLOCKED_CANDIDATE_KEYS,
536
+ EXECUTION_PLACEMENT_KEYS,
537
+ EXECUTION_ATTEMPT_KEYS,
538
+ EXECUTION_EVENT_KEYS,
539
+ EXECUTION_RECORD_KEYS,
540
+ EXTERNAL_EXECUTION_REPORT_KEYS,
541
+ EXECUTION_RECORD_FIELDS,
542
+ EXTERNAL_EXECUTION_REPORT_FIELDS,
543
+ fallbackScopeOf,
544
+ validateExecutionRecord,
545
+ validateExternalExecutionReport,
546
+ }
package/src/index.js CHANGED
@@ -18,3 +18,4 @@ export * from './errors.js'
18
18
  export * from './capabilities.js'
19
19
  export * from './segments.js'
20
20
  export * from './events.js'
21
+ export * from './execution.js'
package/types/index.d.ts CHANGED
@@ -93,3 +93,196 @@ export declare const REQUIRED_REVIEW_EVENT_KEYS: readonly string[]
93
93
  /** The family an event type belongs to ("job", "review"), from its own prefix. */
94
94
  export declare function eventFamily(type: unknown): string | null
95
95
  export declare function validateEvent(event: unknown): ValidationResult
96
+
97
+ // ── Execution record and external execution report (stage 0.5, 18.09.2026) ──
98
+ // Audit shapes, not job results. Every key is present; null means "not known",
99
+ // never "not recorded". See SCHEMA.md.
100
+
101
+ export type AttemptOutcome = 'succeeded' | 'failed'
102
+ export type ExecutionEventType = 'retry' | 'fallback'
103
+ /** The widest identity a fallback changed: platform > route > model. */
104
+ export type FallbackScope = 'model' | 'route' | 'platform'
105
+ /** Where a jurisdiction or region came from. 'unknown' pairs with null, only. */
106
+ export type JurisdictionSource = 'unknown' | 'declared-config' | 'provider-attested'
107
+
108
+ export interface ExecutionPlacement {
109
+ node_id: string | null
110
+ node_name: string | null
111
+ region: string | null
112
+ region_source: JurisdictionSource
113
+ }
114
+
115
+ export interface ExecutionRequirements {
116
+ language: string | null
117
+ residency: string | null
118
+ quality_profile: string | null
119
+ diarisation: string | null
120
+ data_class: string | null
121
+ }
122
+
123
+ export interface EffectivePolicy {
124
+ /** Whether anything gated on the policy. Observe-only records false. */
125
+ enforced: boolean
126
+ residency: string | null
127
+ external_providers: string | null
128
+ training_use: string | null
129
+ data_class: string | null
130
+ on_unsatisfiable: string | null
131
+ provider_allowlist: string[] | null
132
+ provider_denylist: string[] | null
133
+ }
134
+
135
+ /**
136
+ * Four identities, kept apart (SCHEMA.md):
137
+ * provider AIOG's route/adapter - never the company that ran the work
138
+ * platform the execution platform, e.g. 'pendra'
139
+ * model_id the model the platform ran
140
+ * runtime_version the serving runtime/worker BUILD, not the model's version
141
+ * A platform is neither a language nor a jurisdiction.
142
+ */
143
+ export interface ExecutionAttempt {
144
+ /** AIOG's route: the adapter that made the call. */
145
+ provider: string
146
+ /** The execution platform the route called. */
147
+ platform: string
148
+ /** The model the platform ran, as it reported executing. */
149
+ model_id: string | null
150
+ /**
151
+ * The model's own identity or version: a weights digest or a model version
152
+ * label the platform publishes. Never the worker/runtime build, which is
153
+ * `runtime_version`. Null when the platform gives no model identity.
154
+ */
155
+ model_version: string | null
156
+ /** The serving runtime or worker build. Not the model's version. */
157
+ runtime_version: string | null
158
+ started_at: string
159
+ latency_ms: number | null
160
+ outcome: AttemptOutcome
161
+ /** Required when the attempt failed, null when it succeeded. */
162
+ error_code: string | null
163
+ /** The provider's own id for the request, e.g. Pendra's x-request-id. */
164
+ provider_execution_id: string | null
165
+ jurisdiction: string | null
166
+ jurisdiction_source: JurisdictionSource
167
+ placement: ExecutionPlacement
168
+ }
169
+
170
+ /**
171
+ * A retry repeats the same route, platform and model (`to_*` equals `from_*`,
172
+ * `fallback_scope` null). A fallback changes at least one, and
173
+ * `fallback_scope` names the widest that changed.
174
+ */
175
+ export interface ExecutionEvent {
176
+ type: ExecutionEventType
177
+ fallback_scope: FallbackScope | null
178
+ at: string
179
+ from_provider: string
180
+ from_platform: string
181
+ from_model_id: string | null
182
+ to_provider: string
183
+ to_platform: string
184
+ to_model_id: string | null
185
+ reason: string
186
+ }
187
+
188
+ /** A route, a platform and a model: one platform can offer several models. */
189
+ export interface ExecutionCandidateIdentity {
190
+ provider: string
191
+ platform: string
192
+ model_id: string | null
193
+ }
194
+
195
+ export interface ExecutionUsage { unit: string; amount: number; [extra: string]: unknown }
196
+
197
+ export interface ExecutionRecord {
198
+ record_version: number
199
+ execution_record_id: string
200
+ request_id: string | null
201
+ job_id: string | null
202
+ client_id: string
203
+ tenant_reference: string
204
+ user_reference: string | null
205
+ capability: Capability
206
+ task_class: string | null
207
+ requirements: ExecutionRequirements
208
+ policy_id: string | null
209
+ policy_version: string | null
210
+ effective_policy: EffectivePolicy | null
211
+ eligible_candidates: Array<ExecutionCandidateIdentity & { reason: string; jurisdiction: string | null; jurisdiction_source: JurisdictionSource }>
212
+ blocked_candidates: Array<ExecutionCandidateIdentity & { rule: string; jurisdiction: string | null; jurisdiction_source: JurisdictionSource }>
213
+ selected_provider: string | null
214
+ selected_platform: string | null
215
+ selected_model_id: string | null
216
+ route_reason: string
217
+ attempts: ExecutionAttempt[]
218
+ events: ExecutionEvent[]
219
+ final_provider: string | null
220
+ final_platform: string | null
221
+ final_model_id: string | null
222
+ final_model_version: string | null
223
+ final_runtime_version: string | null
224
+ final_jurisdiction: string | null
225
+ final_jurisdiction_source: JurisdictionSource
226
+ provider_execution_id: string | null
227
+ prompt_version: string | null
228
+ usage: ExecutionUsage | null
229
+ provider_cost_internal: number | null
230
+ queue_ms: number | null
231
+ provider_ms: number | null
232
+ total_ms: number | null
233
+ outcome: JobStatus
234
+ error_code: string | null
235
+ created_at: string
236
+ }
237
+
238
+ /** A product-side fallback, reported to AIOG. The client is the credential's. */
239
+ export interface ExternalExecutionReport {
240
+ report_version: number
241
+ report_id: string
242
+ tenant_reference: string
243
+ capability: Capability
244
+ product_reference: string
245
+ job_id: string | null
246
+ request_id: string | null
247
+ occurred_at: string
248
+ requirements: ExecutionRequirements
249
+ attempts: ExecutionAttempt[]
250
+ /** At least one event of type 'fallback'. */
251
+ events: ExecutionEvent[]
252
+ final_provider: string | null
253
+ final_platform: string | null
254
+ final_model_id: string | null
255
+ final_model_version: string | null
256
+ final_jurisdiction: string | null
257
+ final_jurisdiction_source: JurisdictionSource
258
+ provider_execution_id: string | null
259
+ usage: ExecutionUsage | null
260
+ outcome: JobStatus
261
+ error_code: string | null
262
+ }
263
+
264
+ export declare const EXECUTION_RECORD_VERSION: number
265
+ export declare const EXTERNAL_EXECUTION_REPORT_VERSION: number
266
+ export declare const ATTEMPT_OUTCOME: Readonly<{ SUCCEEDED: 'succeeded'; FAILED: 'failed' }>
267
+ export declare const EXECUTION_EVENT_TYPE: Readonly<{ RETRY: 'retry'; FALLBACK: 'fallback' }>
268
+ export declare const FALLBACK_SCOPE: Readonly<{ MODEL: 'model'; ROUTE: 'route'; PLATFORM: 'platform' }>
269
+ export declare const JURISDICTION_SOURCE: Readonly<{
270
+ UNKNOWN: 'unknown'; DECLARED_CONFIG: 'declared-config'; PROVIDER_ATTESTED: 'provider-attested'
271
+ }>
272
+ export declare const EXECUTION_PLACEMENT_KEYS: readonly string[]
273
+ export declare const EXECUTION_REQUIREMENT_KEYS: readonly string[]
274
+ export declare const EXECUTION_POLICY_KEYS: readonly string[]
275
+ export declare const ELIGIBLE_CANDIDATE_KEYS: readonly string[]
276
+ export declare const BLOCKED_CANDIDATE_KEYS: readonly string[]
277
+ export declare const EXECUTION_ATTEMPT_KEYS: readonly string[]
278
+ export declare const EXECUTION_EVENT_KEYS: readonly string[]
279
+ export declare const EXECUTION_RECORD_KEYS: readonly string[]
280
+ export declare const EXTERNAL_EXECUTION_REPORT_KEYS: readonly string[]
281
+ /** Sorted field paths, nested ones as `attempts[].provider`. Guarded additive. */
282
+ export declare const EXECUTION_RECORD_FIELDS: readonly string[]
283
+ export declare const EXTERNAL_EXECUTION_REPORT_FIELDS: readonly string[]
284
+ /** The widest identity that differs between an event's two ends, or null. */
285
+ export declare function fallbackScopeOf(event: Pick<ExecutionEvent,
286
+ 'from_provider' | 'from_platform' | 'from_model_id' | 'to_provider' | 'to_platform' | 'to_model_id'>): FallbackScope | null
287
+ export declare function validateExecutionRecord(record: unknown): ValidationResult
288
+ export declare function validateExternalExecutionReport(report: unknown): ValidationResult