@capsiynau/intelligence-contracts 0.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.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Aled Parry / Capsiynau
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,36 @@
1
+ # @capsiynau/intelligence-contracts
2
+
3
+ The wire contract between products and the Intelligence Layer: job statuses,
4
+ error codes, capability and scope names, the segment model, event schemas.
5
+
6
+ **Private.** Not published, and not publishable - `"private": true`. The
7
+ published packages are a permanent public baseline (ADR 0028); this one is not
8
+ part of it.
9
+
10
+ ## What belongs here
11
+
12
+ Anything a client needs to speak the protocol: constants it branches on, shapes
13
+ it exchanges, invariants it can assert.
14
+
15
+ ## What must never land here
16
+
17
+ Provider implementations, prompts, model names, routing or fallback logic,
18
+ quality-profile internals. The reading test (ADR 0006): a developer holding only
19
+ this package and the client should be unable to rebuild the platform. If a type
20
+ encodes *how* something is computed rather than *what* is exchanged, it belongs
21
+ in the platform.
22
+
23
+ ## The additive rule
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`.
28
+
29
+ ## Usage
30
+
31
+ No workspace wiring - this repo consumes in-repo packages by relative path:
32
+
33
+ ```js
34
+ import { JOB_STATUS, errorBody, validateTranslationRoundTrip }
35
+ from '../../packages/intelligence-contracts/src/index.js'
36
+ ```
package/package.json ADDED
@@ -0,0 +1,42 @@
1
+ {
2
+ "name": "@capsiynau/intelligence-contracts",
3
+ "version": "0.1.0",
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
+ "type": "module",
6
+ "license": "MIT",
7
+ "author": "Aled Parry <aled@aledparry.com>",
8
+ "main": "./src/index.js",
9
+ "types": "./types/index.d.ts",
10
+ "exports": {
11
+ ".": {
12
+ "types": "./types/index.d.ts",
13
+ "default": "./src/index.js"
14
+ },
15
+ "./jobs": "./src/jobs.js",
16
+ "./errors": "./src/errors.js",
17
+ "./capabilities": "./src/capabilities.js",
18
+ "./segments": "./src/segments.js",
19
+ "./events": "./src/events.js"
20
+ },
21
+ "files": [
22
+ "src",
23
+ "types",
24
+ "README.md",
25
+ "LICENSE"
26
+ ],
27
+ "engines": {
28
+ "node": ">=18"
29
+ },
30
+ "scripts": {
31
+ "test": "vitest run __tests__"
32
+ },
33
+ "sideEffects": false,
34
+ "publishConfig": {
35
+ "access": "public"
36
+ },
37
+ "repository": {
38
+ "type": "git",
39
+ "url": "git+https://github.com/aledprysparry/capsiynau-intelligence.git",
40
+ "directory": "packages/intelligence-contracts"
41
+ }
42
+ }
@@ -0,0 +1,118 @@
1
+ /**
2
+ * @capsiynau/intelligence-contracts/capabilities
3
+ *
4
+ * Capability and quality-profile names (ADR 0003). A client asks for a
5
+ * capability at a quality profile and never learns which provider ran, which
6
+ * model, what the prompt said, or what the fallback order was - that is what
7
+ * lets the platform re-route, re-price or fall back without a client release.
8
+ *
9
+ * A profile is a public commitment about outcome and cost band. Adding one is
10
+ * cheap; changing what an existing one means is a breaking change.
11
+ */
12
+
13
+ export const CAPABILITY = Object.freeze({
14
+ TRANSCRIPTION: 'transcription',
15
+ TRANSLATION: 'translation',
16
+ LANGUAGE: 'language',
17
+ CAPTIONS: 'captions',
18
+ GLOSSARY: 'glossary',
19
+ ANALYSIS: 'analysis',
20
+ SEARCH: 'search',
21
+ MEDIA: 'media',
22
+ LIVE: 'live',
23
+ // A human looking at a draft before it goes out. Not a provider capability -
24
+ // nothing is routed to a model - but a platform capability a client asks for
25
+ // by name, so it belongs in the same vocabulary (ADR 0003).
26
+ REVIEW: 'review',
27
+ })
28
+
29
+ export const ALL_CAPABILITIES = Object.freeze(Object.values(CAPABILITY))
30
+
31
+ /** Capabilities that run as jobs. The rest answer synchronously. */
32
+ export const ASYNC_CAPABILITIES = Object.freeze([
33
+ CAPABILITY.TRANSCRIPTION,
34
+ CAPABILITY.TRANSLATION,
35
+ CAPABILITY.ANALYSIS,
36
+ CAPABILITY.MEDIA,
37
+ ])
38
+
39
+ export const QUALITY_PROFILE = Object.freeze({
40
+ FAST: 'fast',
41
+ ACCURATE: 'accurate',
42
+ WELSH_BROADCAST: 'welsh-broadcast',
43
+ })
44
+
45
+ export const ALL_QUALITY_PROFILES = Object.freeze(Object.values(QUALITY_PROFILE))
46
+
47
+ /** Scopes a client credential may hold (ADR 0007). */
48
+ export const SCOPE = Object.freeze({
49
+ TRANSCRIPTION_WRITE: 'transcription:write',
50
+ TRANSCRIPTION_READ: 'transcription:read',
51
+ TRANSLATION_WRITE: 'translation:write',
52
+ // Added 5.1 alongside the translation endpoint. Reads carry their own scope
53
+ // for the same reason transcription's do: a credential holding only
54
+ // `translation:write` could otherwise read every job status and result, and
55
+ // one whose write access had been revoked could still poll (Codex, #1505).
56
+ TRANSLATION_READ: 'translation:read',
57
+ CAPTIONS_ANALYSE: 'captions:analyse',
58
+ GLOSSARIES_WRITE: 'glossaries:write',
59
+ GLOSSARIES_READ: 'glossaries:read',
60
+ // Added 6.2 alongside the analysis endpoint. Split read from write for the
61
+ // same reason translation's are: a credential that may SUBMIT a transcript
62
+ // for summarising should not thereby gain the ability to read every analysis
63
+ // in the organisation - and a summary of a private meeting is a more
64
+ // sensitive artefact than the job that produced it.
65
+ ANALYSIS_WRITE: 'analysis:write',
66
+ ANALYSIS_READ: 'analysis:read',
67
+ SEARCH_QUERY: 'search:query',
68
+ // Added 6.3 alongside the search endpoint. Indexing writes documents into a
69
+ // tenant's corpus and spends embedding tokens; querying only reads it. A
70
+ // credential that may ASK should not thereby be able to WRITE what everyone
71
+ // else's answers are retrieved from - same split as every other capability.
72
+ SEARCH_INDEX: 'search:index',
73
+ // Added 6.5a alongside the media endpoint. Write submits provider-billed
74
+ // work; read polls it - same split as every job capability, for the same
75
+ // reason translation split them (#1505).
76
+ MEDIA_WRITE: 'media:write',
77
+ MEDIA_READ: 'media:read',
78
+ // Added 7.4a. The credential-management surface itself. OPERATOR-ISSUED
79
+ // ONLY: the admin endpoints refuse to grant this scope, so an admin
80
+ // credential cannot mint further admin credentials - the chain of custody
81
+ // for it always runs through issue-credential.mjs on an operator machine.
82
+ CREDENTIALS_ADMIN: 'credentials:admin',
83
+ ASSETS_WRITE: 'assets:write',
84
+ USAGE_READ: 'usage:read',
85
+ // Reviews split read from write for the same reason translation did: a
86
+ // credential that may CREATE a review for its own drafts should not thereby
87
+ // gain the ability to read every review in the organisation, including the
88
+ // drafts and the reviewer comments on them.
89
+ REVIEWS_WRITE: 'reviews:write',
90
+ REVIEWS_READ: 'reviews:read',
91
+ })
92
+
93
+ export const ALL_SCOPES = Object.freeze(Object.values(SCOPE))
94
+
95
+ /**
96
+ * Neutral usage units (ADR 0022). The platform reports quantities; products
97
+ * apply their own pricing. No plan name, tier or price belongs in this file -
98
+ * that is the whole point of the boundary.
99
+ */
100
+ export const USAGE_UNIT = Object.freeze({
101
+ AUDIO_SECONDS: 'audio_seconds',
102
+ VIDEO_SECONDS: 'video_seconds',
103
+ CHARACTERS: 'characters',
104
+ TOKENS_INPUT: 'tokens_input',
105
+ TOKENS_OUTPUT: 'tokens_output',
106
+ TOKENS_CACHED: 'tokens_cached',
107
+ STORAGE_BYTES: 'storage_bytes',
108
+ INDEX_BYTES: 'index_bytes',
109
+ JOB_COUNT: 'job_count',
110
+ })
111
+
112
+ export const ALL_USAGE_UNITS = Object.freeze(Object.values(USAGE_UNIT))
113
+
114
+ export function isAsync(capability) {
115
+ return ASYNC_CAPABILITIES.includes(capability)
116
+ }
117
+
118
+ export default { CAPABILITY, ALL_CAPABILITIES, ASYNC_CAPABILITIES, QUALITY_PROFILE, ALL_QUALITY_PROFILES, SCOPE, ALL_SCOPES, USAGE_UNIT, ALL_USAGE_UNITS, isAsync }
package/src/errors.js ADDED
@@ -0,0 +1,154 @@
1
+ /**
2
+ * @capsiynau/intelligence-contracts/errors
3
+ *
4
+ * The error envelope (ADR 0017). `code` is the only thing a client should
5
+ * branch on - codes are added, never repurposed.
6
+ *
7
+ * `message` is a neutral English DEVELOPER string. It is never end-user copy
8
+ * and never Welsh: products map codes to their own bilingual messages, because
9
+ * a platform that owns user-facing wording owns the wrong thing. `details`
10
+ * carries safe structured context only - never a provider name, a prompt, a
11
+ * stack trace or a credential.
12
+ */
13
+
14
+ export const ERROR_CATEGORY = Object.freeze({
15
+ AUTH: { prefix: 'AUTH', status: 401 },
16
+ FORBIDDEN: { prefix: 'FORBIDDEN', status: 403 },
17
+ VALIDATION: { prefix: 'VALIDATION', status: 400 },
18
+ UPLOAD: { prefix: 'UPLOAD', status: 400 },
19
+ MEDIA_UNSUPPORTED: { prefix: 'MEDIA_UNSUPPORTED', status: 415 },
20
+ QUOTA: { prefix: 'QUOTA', status: 402 },
21
+ RATE_LIMIT: { prefix: 'RATE_LIMIT', status: 429 },
22
+ PROVIDER: { prefix: 'PROVIDER', status: 503 },
23
+ PROCESSING: { prefix: 'PROCESSING', status: 500 },
24
+ TIMEOUT: { prefix: 'TIMEOUT', status: 504 },
25
+ CONFLICT: { prefix: 'CONFLICT', status: 409 },
26
+ DUPLICATE: { prefix: 'DUPLICATE', status: 409 },
27
+ NOT_FOUND: { prefix: 'NOT_FOUND', status: 404 },
28
+ INTERNAL: { prefix: 'INTERNAL', status: 500 },
29
+ })
30
+
31
+ /**
32
+ * The published catalogue. `retryable` states whether the SAME request may be
33
+ * sent again - combined with an idempotency key (ADR 0013) that makes retry
34
+ * safe rather than merely permitted.
35
+ */
36
+ export const ERROR_CODE = Object.freeze({
37
+ AUTH_MISSING: { status: 401, retryable: false, message: 'Authentication required.' },
38
+ AUTH_INVALID: { status: 401, retryable: false, message: 'The credential is not valid.' },
39
+ AUTH_REVOKED: { status: 401, retryable: false, message: 'The credential has been revoked.' },
40
+ FORBIDDEN_SCOPE: { status: 403, retryable: false, message: 'The credential does not carry the required scope.' },
41
+ FORBIDDEN_TENANT: { status: 403, retryable: false, message: 'The credential may not act for this tenant.' },
42
+ VALIDATION_FAILED: { status: 400, retryable: false, message: 'The request failed validation.' },
43
+ VALIDATION_IDEMPOTENCY_KEY_REQUIRED: { status: 400, retryable: false, message: 'An Idempotency-Key header is required for this endpoint.' },
44
+ UPLOAD_URL_EXPIRED: { status: 400, retryable: true, message: 'The signed upload URL has expired.' },
45
+ UPLOAD_INCOMPLETE: { status: 400, retryable: true, message: 'The asset was not fully uploaded.' },
46
+ MEDIA_UNSUPPORTED_TYPE: { status: 415, retryable: false, message: 'The media type is not supported.' },
47
+ MEDIA_UNSUPPORTED_DURATION: { status: 415, retryable: false, message: 'The media exceeds the supported duration.' },
48
+ QUOTA_EXCEEDED: { status: 402, retryable: false, message: 'The tenant has exhausted its quota for this period.' },
49
+ RATE_LIMIT_EXCEEDED: { status: 429, retryable: true, message: 'Too many requests.' },
50
+ PROVIDER_UNAVAILABLE: { status: 503, retryable: true, message: 'The upstream service is temporarily unavailable.' },
51
+ PROVIDER_REJECTED: { status: 502, retryable: false, message: 'The upstream service rejected the request.' },
52
+ PROCESSING_FAILED: { status: 500, retryable: true, message: 'Processing failed.' },
53
+ TIMEOUT_EXCEEDED: { status: 504, retryable: true, message: 'The operation timed out.' },
54
+ CONFLICT_JOB_TERMINAL: { status: 409, retryable: false, message: 'The job has already reached a terminal state.' },
55
+ CONFLICT_IDEMPOTENCY_KEY_REUSE: { status: 409, retryable: false, message: 'The idempotency key was reused with a different payload.' },
56
+ // Added with /v1/assets. Completing an asset that is expired, failed or
57
+ // deleted is a conflict, not a validation error: the request is well formed
58
+ // and the id is real, the asset has simply moved on.
59
+ CONFLICT_ASSET_STATE: { status: 409, retryable: false, message: 'The asset is not in a state that allows this.' },
60
+ DUPLICATE_REQUEST: { status: 409, retryable: false, message: 'A request with this idempotency key is already in flight.' },
61
+ NOT_FOUND_JOB: { status: 404, retryable: false, message: 'No such job.' },
62
+ NOT_FOUND_ASSET: { status: 404, retryable: false, message: 'No such asset.' },
63
+ NOT_FOUND_GLOSSARY: { status: 404, retryable: false, message: 'No such glossary.' },
64
+ // Added with /v1/vocabulary. A row that belongs to ANOTHER tenant answers
65
+ // this too, rather than FORBIDDEN: "forbidden" would confirm the row exists,
66
+ // and a uuid is guessable enough that the confirmation is the leak.
67
+ NOT_FOUND_TERM: { status: 404, retryable: false, message: 'No such term.' },
68
+ // 7.4a: the admin credential surface addresses clients by cli_ id.
69
+ NOT_FOUND_CLIENT: { status: 404, retryable: false, message: 'No such client.' },
70
+ // A review id and a share token both answer NOT_FOUND rather than FORBIDDEN,
71
+ // for the same reason a job id does: "forbidden" confirms the thing exists.
72
+ NOT_FOUND_REVIEW: { status: 404, retryable: false, message: 'No such review.' },
73
+ // The author has finished with it. Not retryable, and not a validation
74
+ // problem - the request was well formed and arrived too late.
75
+ CONFLICT_REVIEW_FINALISED: { status: 409, retryable: false, message: 'This review has been finalised.' },
76
+ // The reviewer's page is showing a version other than the one their link
77
+ // shared. Additive rather than reusing CONFLICT_REVIEW_FINALISED: a client
78
+ // should reload here, and must not offer to retry with a different version.
79
+ CONFLICT_REVIEW_VERSION: { status: 409, retryable: false, message: 'This page is showing an older draft. Reload it before answering.' },
80
+ // Only the author closes a review. The credential proves which product is
81
+ // calling, not which person, so a product that serves many people sends a
82
+ // user reference and this is what refuses a mismatch.
83
+ FORBIDDEN_REVIEW_AUTHOR: { status: 403, retryable: false, message: 'Only the author of this review can finalise it.' },
84
+ // Expired or revoked. One code for both: telling a holder WHICH would let
85
+ // them distinguish a link that never existed from one since withdrawn.
86
+ FORBIDDEN_REVIEW_LINK: { status: 403, retryable: false, message: 'This review link is no longer valid.' },
87
+ // ── Workspace links and per-workspace credentials (Track B, B0.5) ──
88
+ //
89
+ // THESE EXISTED IN THE SERVICES BEFORE THEY EXISTED HERE, and that gap was a
90
+ // defect waiting for its first HTTP caller. `errorBody` resolves an unknown
91
+ // code to INTERNAL_ERROR, so an operator asking about a workspace nobody had
92
+ // linked would have received a 500 saying "an internal error occurred" -
93
+ // a platform fault, for a question with a correct and useful answer.
94
+ // src/workspaceLinks.js and src/workspaceCredentials.js are the only
95
+ // throwers; the list below was derived from them rather than recalled.
96
+ NOT_FOUND_WORKSPACE_LINK: { status: 404, retryable: false, message: 'No link exists for that workspace.' },
97
+ // SEPARATE FROM FORBIDDEN_TENANT, which is about a CREDENTIAL naming a
98
+ // tenant it may not act for. This is about a WORKSPACE not being entitled to
99
+ // the tenant it claimed. Different subject, different fix, so folding them
100
+ // together would send an operator to the wrong half of the system.
101
+ FORBIDDEN_TENANT_MISMATCH: { status: 403, retryable: false, message: 'That workspace is not entitled to act for this tenant.' },
102
+ // "Never consented" and "consent withdrawn" are deliberately distinct, unlike
103
+ // FORBIDDEN_REVIEW_LINK which merges its two cases on purpose. The reasoning
104
+ // inverts because the audience does: a review link is held by an outsider who
105
+ // must not learn which, and these are read by the operator who has to fix it.
106
+ FORBIDDEN_CONSENT_MISSING: { status: 403, retryable: false, message: 'That workspace has no recorded consent.' },
107
+ FORBIDDEN_CONSENT_REVOKED: { status: 403, retryable: false, message: 'Consent has been withdrawn for that workspace.' },
108
+ CONFLICT_WORKSPACE_LINKED: { status: 409, retryable: false, message: 'That workspace is already linked to a different tenant.' },
109
+ CONFLICT_LINK_REVOKED: { status: 409, retryable: false, message: 'Consent was withdrawn for that workspace; re-granting it is a deliberate act.' },
110
+ CONFLICT_CREDENTIAL_EXISTS: { status: 409, retryable: false, message: 'That workspace already holds a credential; rotate it rather than issuing a second.' },
111
+ CONFLICT_CREDENTIAL_ATTACHED: { status: 409, retryable: false, message: 'That workspace still holds a credential; withdraw it so the credential is revoked with the consent.' },
112
+ // NOT retryable, and that is the whole point of it having its own code. The
113
+ // credential was minted and could not be attached to its link, so a retry
114
+ // would mint a second. The message carries what to do instead.
115
+ CONFLICT_LINK_NOT_UPDATED: { status: 409, retryable: false, message: 'A credential was issued but could not be attached to its workspace link.' },
116
+ METHOD_NOT_ALLOWED: { status: 405, retryable: false, message: 'That method is not allowed on this route.' },
117
+ SERVICE_UNAVAILABLE: { status: 503, retryable: true, message: 'The platform is not accepting requests.' },
118
+ INTERNAL_ERROR: { status: 500, retryable: true, message: 'An internal error occurred.' },
119
+ })
120
+
121
+ export const ALL_ERROR_CODES = Object.freeze(Object.keys(ERROR_CODE))
122
+
123
+ /**
124
+ * Build the wire envelope.
125
+ *
126
+ * @param {string} code a key of ERROR_CODE
127
+ * @param {object} [opts]
128
+ * @param {string} [opts.requestId]
129
+ * @param {object} [opts.details] safe structured context only
130
+ * @param {string} [opts.message] override the developer string
131
+ */
132
+ export function errorBody(code, { requestId = null, details = {}, message } = {}) {
133
+ const spec = ERROR_CODE[code] || ERROR_CODE.INTERNAL_ERROR
134
+ const resolved = ERROR_CODE[code] ? code : 'INTERNAL_ERROR'
135
+ return {
136
+ error: {
137
+ code: resolved,
138
+ message: message || spec.message,
139
+ requestId,
140
+ retryable: spec.retryable,
141
+ details,
142
+ },
143
+ }
144
+ }
145
+
146
+ export function statusFor(code) {
147
+ return (ERROR_CODE[code] || ERROR_CODE.INTERNAL_ERROR).status
148
+ }
149
+
150
+ export function isRetryable(code) {
151
+ return (ERROR_CODE[code] || ERROR_CODE.INTERNAL_ERROR).retryable
152
+ }
153
+
154
+ export default { ERROR_CATEGORY, ERROR_CODE, ALL_ERROR_CODES, errorBody, statusFor, isRetryable }
package/src/events.js ADDED
@@ -0,0 +1,114 @@
1
+ /**
2
+ * @capsiynau/intelligence-contracts/events
3
+ *
4
+ * Events, delivered by webhook or observed by polling (ADR 0018).
5
+ *
6
+ * TWO FAMILIES, one envelope. A job event names a job, a capability and a
7
+ * status; a review event names a review and nothing else - a review is not a
8
+ * job, nothing runs, and there is no status to report. `validateEvent`
9
+ * discriminates on the type's own prefix rather than demanding job fields of
10
+ * everything.
11
+ *
12
+ * Delivery is AT-LEAST-ONCE. Consumers must be idempotent on `event_id` -
13
+ * a redelivery is normal operation, not an error, and a consumer that acts
14
+ * twice on one event has a bug the platform cannot fix for it.
15
+ */
16
+
17
+ export const EVENT_TYPE = Object.freeze({
18
+ JOB_QUEUED: 'job.queued',
19
+ JOB_PROCESSING: 'job.processing',
20
+ JOB_PROGRESS: 'job.progress',
21
+ JOB_COMPLETED: 'job.completed',
22
+ JOB_PARTIALLY_COMPLETED: 'job.partially_completed',
23
+ JOB_FAILED: 'job.failed',
24
+ JOB_CANCELLED: 'job.cancelled',
25
+
26
+ // Review history. Append-only and human-scale: these are the lines an author
27
+ // reads as a timeline, and the same rows a future knowledge layer may read
28
+ // (never write) to learn who reviews what.
29
+ REVIEW_CREATED: 'review.created',
30
+ REVIEW_VERSION_ADDED: 'review.version_added',
31
+ REVIEW_SHARED: 'review.shared',
32
+ REVIEW_OPENED: 'review.opened',
33
+ REVIEW_COMMENTED: 'review.commented',
34
+ REVIEW_SUGGESTED: 'review.suggested',
35
+ REVIEW_DECIDED: 'review.decided',
36
+ REVIEW_FINALISED: 'review.finalised',
37
+ REVIEW_LINK_REVOKED: 'review.link_revoked',
38
+ })
39
+
40
+ export const ALL_EVENT_TYPES = Object.freeze(Object.values(EVENT_TYPE))
41
+
42
+ /** Signature header, and the window inside which a signed body is accepted. */
43
+ export const SIGNATURE_HEADER = 'X-Signature'
44
+ export const REQUEST_ID_HEADER = 'X-Request-Id'
45
+ export const IDEMPOTENT_REPLAY_HEADER = 'X-Idempotent-Replay'
46
+ export const IDEMPOTENCY_KEY_HEADER = 'Idempotency-Key'
47
+ export const TENANT_HEADER = 'X-Tenant-Reference'
48
+ export const USER_HEADER = 'X-User-Reference'
49
+
50
+ /** Replay window in seconds. A timestamp outside it must be rejected. */
51
+ export const SIGNATURE_TOLERANCE_SECONDS = 300
52
+
53
+ /**
54
+ * Keys present on every event envelope, whatever it is about.
55
+ *
56
+ * `job_id`, `capability` and `status` are NOT here (Codex, #1637). They belong
57
+ * to a job, and a review is not a job: it has no capability, nothing runs, and
58
+ * there is no status to report. Requiring them of every event meant a review
59
+ * event either failed validation or carried invented job fields - and the
60
+ * review types were already in EVENT_TYPE, so this validator rejected events
61
+ * the contract itself publishes.
62
+ */
63
+ export const REQUIRED_EVENT_KEYS = Object.freeze(['event_id', 'type', 'occurred_at'])
64
+
65
+ /** What a JOB event carries beyond the common keys. */
66
+ export const REQUIRED_JOB_EVENT_KEYS = Object.freeze(['job_id', 'capability', 'status'])
67
+
68
+ /** What a REVIEW event carries beyond the common keys. */
69
+ export const REQUIRED_REVIEW_EVENT_KEYS = Object.freeze(['review_id'])
70
+
71
+ /**
72
+ * The family an event type belongs to, from its own name.
73
+ *
74
+ * Additive by construction: a new family is a new prefix and a new key list,
75
+ * and an unknown prefix validates against the common keys alone rather than
76
+ * being rejected for missing fields its family never had.
77
+ */
78
+ export function eventFamily(type) {
79
+ if (typeof type !== 'string') return null
80
+ const [family] = type.split('.')
81
+ return family || null
82
+ }
83
+
84
+ const FAMILY_KEYS = Object.freeze({
85
+ job: REQUIRED_JOB_EVENT_KEYS,
86
+ review: REQUIRED_REVIEW_EVENT_KEYS,
87
+ })
88
+
89
+ export function validateEvent(event) {
90
+ const problems = []
91
+ if (!event || typeof event !== 'object') return { valid: false, problems: ['event is not an object'] }
92
+
93
+ const required = [...REQUIRED_EVENT_KEYS, ...(FAMILY_KEYS[eventFamily(event.type)] || [])]
94
+ for (const key of required) {
95
+ if (event[key] === undefined || event[key] === null) problems.push(`missing ${key}`)
96
+ }
97
+ if (event.type && !ALL_EVENT_TYPES.includes(event.type)) problems.push(`unknown type ${event.type}`)
98
+ return { valid: problems.length === 0, problems }
99
+ }
100
+
101
+ export default {
102
+ EVENT_TYPE,
103
+ ALL_EVENT_TYPES,
104
+ eventFamily,
105
+ SIGNATURE_HEADER,
106
+ REQUEST_ID_HEADER,
107
+ IDEMPOTENT_REPLAY_HEADER,
108
+ IDEMPOTENCY_KEY_HEADER,
109
+ TENANT_HEADER,
110
+ USER_HEADER,
111
+ SIGNATURE_TOLERANCE_SECONDS,
112
+ REQUIRED_EVENT_KEYS,
113
+ validateEvent,
114
+ }
package/src/index.js ADDED
@@ -0,0 +1,20 @@
1
+ /**
2
+ * @capsiynau/intelligence-contracts
3
+ *
4
+ * The wire contract between products and the Intelligence Layer: job statuses,
5
+ * error codes, capability and scope names, the segment model, event schemas.
6
+ *
7
+ * WHAT MUST NEVER LAND HERE (ADR 0006, ADR 0010): provider implementations,
8
+ * prompts, model names, routing or fallback logic, quality-profile internals,
9
+ * or anything else from which the platform could be reconstructed. The reading
10
+ * test is simple - a developer holding only this package and the client should
11
+ * be unable to rebuild the implementation. If a type encodes HOW something is
12
+ * computed rather than WHAT is exchanged, it belongs in the platform.
13
+ *
14
+ * Private by design: `"private": true` in package.json. This is not published.
15
+ */
16
+ export * from './jobs.js'
17
+ export * from './errors.js'
18
+ export * from './capabilities.js'
19
+ export * from './segments.js'
20
+ export * from './events.js'
package/src/jobs.js ADDED
@@ -0,0 +1,83 @@
1
+ /**
2
+ * @capsiynau/intelligence-contracts/jobs
3
+ *
4
+ * One job lifecycle for every asynchronous capability (ADR 0018). Products
5
+ * poll or subscribe against these statuses; the platform is free to change
6
+ * how work is done, never what a status means.
7
+ */
8
+
9
+ /** Every status a job may hold. */
10
+ export const JOB_STATUS = Object.freeze({
11
+ QUEUED: 'queued',
12
+ VALIDATING: 'validating',
13
+ PROCESSING: 'processing',
14
+ COMPLETED: 'completed',
15
+ PARTIALLY_COMPLETED: 'partially_completed',
16
+ FAILED: 'failed',
17
+ CANCEL_REQUESTED: 'cancel_requested',
18
+ CANCELLED: 'cancelled',
19
+ EXPIRED: 'expired',
20
+ })
21
+
22
+ export const ALL_STATUSES = Object.freeze(Object.values(JOB_STATUS))
23
+
24
+ /**
25
+ * Statuses from which a job will never move again.
26
+ *
27
+ * `partially_completed` is terminal on purpose: a 90-minute programme whose
28
+ * last two minutes failed is worth returning, and forcing it to `failed`
29
+ * throws away work the client has already paid for.
30
+ */
31
+ export const TERMINAL_STATUSES = Object.freeze([
32
+ JOB_STATUS.COMPLETED,
33
+ JOB_STATUS.PARTIALLY_COMPLETED,
34
+ JOB_STATUS.FAILED,
35
+ JOB_STATUS.CANCELLED,
36
+ JOB_STATUS.EXPIRED,
37
+ ])
38
+
39
+ /** Statuses whose result is readable. */
40
+ export const RESULT_BEARING_STATUSES = Object.freeze([
41
+ JOB_STATUS.COMPLETED,
42
+ JOB_STATUS.PARTIALLY_COMPLETED,
43
+ ])
44
+
45
+ /**
46
+ * Legal transitions. The platform enforces these; the contract publishes them
47
+ * so a client can reason about what it will see next without guessing.
48
+ */
49
+ export const TRANSITIONS = Object.freeze({
50
+ [JOB_STATUS.QUEUED]: [JOB_STATUS.VALIDATING, JOB_STATUS.PROCESSING, JOB_STATUS.CANCEL_REQUESTED, JOB_STATUS.CANCELLED, JOB_STATUS.FAILED],
51
+ [JOB_STATUS.VALIDATING]: [JOB_STATUS.PROCESSING, JOB_STATUS.FAILED, JOB_STATUS.CANCEL_REQUESTED],
52
+ [JOB_STATUS.PROCESSING]: [JOB_STATUS.COMPLETED, JOB_STATUS.PARTIALLY_COMPLETED, JOB_STATUS.FAILED, JOB_STATUS.CANCEL_REQUESTED],
53
+ [JOB_STATUS.CANCEL_REQUESTED]: [JOB_STATUS.CANCELLED, JOB_STATUS.COMPLETED, JOB_STATUS.PARTIALLY_COMPLETED, JOB_STATUS.FAILED],
54
+ [JOB_STATUS.COMPLETED]: [JOB_STATUS.EXPIRED],
55
+ [JOB_STATUS.PARTIALLY_COMPLETED]: [JOB_STATUS.EXPIRED],
56
+ [JOB_STATUS.FAILED]: [JOB_STATUS.EXPIRED],
57
+ [JOB_STATUS.CANCELLED]: [],
58
+ [JOB_STATUS.EXPIRED]: [],
59
+ })
60
+
61
+ export function isTerminal(status) {
62
+ return TERMINAL_STATUSES.includes(status)
63
+ }
64
+
65
+ export function hasResult(status) {
66
+ return RESULT_BEARING_STATUSES.includes(status)
67
+ }
68
+
69
+ /**
70
+ * `cancel_requested` may still end in `completed`: cancellation is cooperative
71
+ * and best-effort (ADR 0016), so a job already inside a provider call can
72
+ * finish before the worker reaches its next checkpoint.
73
+ */
74
+ export function canTransition(from, to) {
75
+ return (TRANSITIONS[from] || []).includes(to)
76
+ }
77
+
78
+ /** Statuses a client should keep polling. */
79
+ export function isPending(status) {
80
+ return !isTerminal(status)
81
+ }
82
+
83
+ export default { JOB_STATUS, ALL_STATUSES, TERMINAL_STATUSES, RESULT_BEARING_STATUSES, TRANSITIONS, isTerminal, hasResult, canTransition, isPending }
@@ -0,0 +1,141 @@
1
+ /**
2
+ * @capsiynau/intelligence-contracts/segments
3
+ *
4
+ * The caption segment as it crosses the wire, and the invariants that must
5
+ * survive a round trip through translation (ADR 0017, target architecture §3.3).
6
+ *
7
+ * This file describes the SHAPE only. Segmentation, line breaking and timing
8
+ * correction are platform implementation and live nowhere near here - a
9
+ * developer holding the contracts package must not be able to reconstruct how
10
+ * a segment is produced, only what one looks like (ADR 0006).
11
+ */
12
+
13
+ /** Required keys on every segment crossing the boundary. */
14
+ export const REQUIRED_SEGMENT_KEYS = Object.freeze(['segment_id', 'start', 'end', 'text'])
15
+
16
+ /** Optional keys the platform may return and a client may send back. */
17
+ export const OPTIONAL_SEGMENT_KEYS = Object.freeze([
18
+ 'speaker', 'language', 'confidence', 'line_breaks', 'source_text', 'formatting',
19
+ ])
20
+
21
+ /**
22
+ * Keys translation must preserve byte-for-byte. Only `text` may change.
23
+ *
24
+ * A translation that renumbers, drops or re-times a segment is a CONTRACT
25
+ * VIOLATION, not a quality problem - the caller's editor state, styling and
26
+ * corrections are all keyed on `segment_id`, so losing it silently destroys
27
+ * work that looks intact until someone opens the file.
28
+ */
29
+ export const TRANSLATION_INVARIANT_KEYS = Object.freeze([
30
+ 'segment_id', 'start', 'end', 'speaker', 'line_breaks', 'formatting',
31
+ ])
32
+
33
+ /**
34
+ * Has the text in these segments already been through a corrector?
35
+ *
36
+ * A property of the SEGMENTS a client sends, not of how the client made them.
37
+ * The platform needs it because some outputs QUOTE the caller's text back -
38
+ * `moments[].quote` is "the exact text of the moment" - and a quote may only be
39
+ * corrected when the transcript it was taken from was corrected. Correct a
40
+ * quote taken from raw text and it stops appearing in the transcript it claims
41
+ * to come from, while reading as better language than the transcript does.
42
+ *
43
+ * ABSENT MEANS TRUE. Corrected transcripts are the normal case and were the
44
+ * only case before this field existed, so an older client keeps its behaviour.
45
+ * A client sending raw provider text sends false explicitly.
46
+ *
47
+ * WHY NOT THE CLIENT'S OWN NAME FOR IT (ADR 0003, ADR 0006). Capsiynau reaches
48
+ * this state through a `verbatim` caption style, and passing that style through
49
+ * would put a product's caption vocabulary into the wire contract and let a
50
+ * reader of this package infer how the platform cuts captions. It does not cut
51
+ * them. One boolean about the input says everything the platform is entitled
52
+ * to know.
53
+ */
54
+ export const TRANSCRIPT_NORMALISED_KEY = 'transcript_normalised'
55
+
56
+ /** Absent means the transcript was corrected. Only an explicit false is raw. */
57
+ export function isTranscriptNormalised(request) {
58
+ return request?.[TRANSCRIPT_NORMALISED_KEY] !== false
59
+ }
60
+
61
+ export const TIMESTAMP_GRANULARITY = Object.freeze({ SEGMENT: 'segment', WORD: 'word' })
62
+
63
+ export const DIARISATION = Object.freeze({ OFF: 'off', AUTO: 'auto' })
64
+
65
+ /** `speakers:<n>` where n is a positive integer, or one of DIARISATION. */
66
+ export function isValidDiarisation(value) {
67
+ if (Object.values(DIARISATION).includes(value)) return true
68
+ const m = /^speakers:(\d+)$/.exec(String(value ?? ''))
69
+ return Boolean(m) && Number(m[1]) > 0
70
+ }
71
+
72
+ /**
73
+ * Structural check on one segment. Deliberately shallow: this validates the
74
+ * contract, not the content. It answers "could a client consume this", never
75
+ * "is this a good caption".
76
+ *
77
+ * @returns {{ valid: boolean, problems: string[] }}
78
+ */
79
+ export function validateSegment(segment) {
80
+ const problems = []
81
+ if (!segment || typeof segment !== 'object') return { valid: false, problems: ['segment is not an object'] }
82
+
83
+ for (const key of REQUIRED_SEGMENT_KEYS) {
84
+ if (segment[key] === undefined || segment[key] === null) problems.push(`missing ${key}`)
85
+ }
86
+ if (segment.start !== undefined && typeof segment.start !== 'number') problems.push('start must be a number')
87
+ if (segment.end !== undefined && typeof segment.end !== 'number') problems.push('end must be a number')
88
+ if (typeof segment.start === 'number' && typeof segment.end === 'number' && segment.end < segment.start) {
89
+ problems.push('end precedes start')
90
+ }
91
+ if (segment.text !== undefined && typeof segment.text !== 'string') problems.push('text must be a string')
92
+
93
+ return { valid: problems.length === 0, problems }
94
+ }
95
+
96
+ /**
97
+ * Prove a translation preserved everything except `text`.
98
+ *
99
+ * Used by contract tests on both sides of the boundary: the platform asserts
100
+ * it before returning, a product may assert it on receipt.
101
+ *
102
+ * @returns {{ valid: boolean, problems: string[] }}
103
+ */
104
+ export function validateTranslationRoundTrip(source, translated) {
105
+ const problems = []
106
+ if (!Array.isArray(source) || !Array.isArray(translated)) {
107
+ return { valid: false, problems: ['both sides must be arrays'] }
108
+ }
109
+ if (source.length !== translated.length) {
110
+ problems.push(`segment count changed: ${source.length} -> ${translated.length}`)
111
+ }
112
+
113
+ const byId = new Map(translated.map((s) => [s?.segment_id, s]))
114
+ for (const src of source) {
115
+ const out = byId.get(src?.segment_id)
116
+ if (!out) {
117
+ problems.push(`segment ${src?.segment_id} missing from the translation`)
118
+ continue
119
+ }
120
+ for (const key of TRANSLATION_INVARIANT_KEYS) {
121
+ if (src[key] === undefined && out[key] === undefined) continue
122
+ if (JSON.stringify(src[key]) !== JSON.stringify(out[key])) {
123
+ problems.push(`segment ${src.segment_id}: ${key} changed`)
124
+ }
125
+ }
126
+ }
127
+ return { valid: problems.length === 0, problems }
128
+ }
129
+
130
+ export default {
131
+ TRANSCRIPT_NORMALISED_KEY,
132
+ isTranscriptNormalised,
133
+ REQUIRED_SEGMENT_KEYS,
134
+ OPTIONAL_SEGMENT_KEYS,
135
+ TRANSLATION_INVARIANT_KEYS,
136
+ TIMESTAMP_GRANULARITY,
137
+ DIARISATION,
138
+ isValidDiarisation,
139
+ validateSegment,
140
+ validateTranslationRoundTrip,
141
+ }
@@ -0,0 +1,95 @@
1
+ /**
2
+ * Typed surface of the wire contract. Shapes only - nothing here reveals how
3
+ * the platform produces a result (ADR 0006).
4
+ */
5
+
6
+ export type JobStatus =
7
+ | 'queued' | 'validating' | 'processing'
8
+ | 'completed' | 'partially_completed' | 'failed'
9
+ | 'cancel_requested' | 'cancelled' | 'expired'
10
+
11
+ export type Capability =
12
+ | 'transcription' | 'translation' | 'language' | 'captions'
13
+ | 'glossary' | 'analysis' | 'search' | 'media' | 'live'
14
+
15
+ export type QualityProfile = 'fast' | 'accurate' | 'welsh-broadcast'
16
+
17
+ export type UsageUnit =
18
+ | 'audio_seconds' | 'video_seconds' | 'characters'
19
+ | 'tokens_input' | 'tokens_output' | 'tokens_cached'
20
+ | 'storage_bytes' | 'index_bytes' | 'job_count'
21
+
22
+ export interface Segment {
23
+ segment_id: string
24
+ start: number
25
+ end: number
26
+ text: string
27
+ speaker?: string
28
+ language?: string
29
+ confidence?: number
30
+ line_breaks?: number[]
31
+ source_text?: string
32
+ formatting?: Record<string, unknown>
33
+ }
34
+
35
+ export interface JobProgress {
36
+ stage: string
37
+ /** Present only where the number is real - never fabricated. */
38
+ percent?: number
39
+ }
40
+
41
+ export interface Job {
42
+ job_id: string
43
+ status: JobStatus
44
+ capability: Capability
45
+ progress?: JobProgress
46
+ created_at: string
47
+ updated_at: string
48
+ client_reference?: string | null
49
+ result: { available: boolean; url?: string }
50
+ error: ErrorEnvelope['error'] | null
51
+ usage?: Partial<Record<UsageUnit, number>>
52
+ processing?: { attempts: number; queue_seconds: number; quality_profile: QualityProfile }
53
+ }
54
+
55
+ export interface ErrorEnvelope {
56
+ error: {
57
+ code: string
58
+ /** Neutral English developer string. Never end-user copy, never Welsh. */
59
+ message: string
60
+ requestId: string | null
61
+ retryable: boolean
62
+ details: Record<string, unknown>
63
+ }
64
+ }
65
+
66
+ export interface JobEvent {
67
+ event_id: string
68
+ type: 'job.queued' | 'job.processing' | 'job.progress' | 'job.completed'
69
+ | 'job.partially_completed' | 'job.failed' | 'job.cancelled'
70
+ job_id: string
71
+ capability: Capability
72
+ status: JobStatus
73
+ occurred_at: string
74
+ client_reference?: string | null
75
+ }
76
+
77
+ export interface ValidationResult { valid: boolean; problems: string[] }
78
+
79
+ export declare function isTerminal(status: JobStatus): boolean
80
+ export declare function hasResult(status: JobStatus): boolean
81
+ export declare function isPending(status: JobStatus): boolean
82
+ export declare function canTransition(from: JobStatus, to: JobStatus): boolean
83
+ export declare function errorBody(code: string, opts?: { requestId?: string | null; details?: Record<string, unknown>; message?: string }): ErrorEnvelope
84
+ export declare function statusFor(code: string): number
85
+ export declare function isRetryable(code: string): boolean
86
+ export declare function isAsync(capability: Capability): boolean
87
+ export declare function isValidDiarisation(value: string): boolean
88
+ export declare function validateSegment(segment: unknown): ValidationResult
89
+ export declare function validateTranslationRoundTrip(source: Segment[], translated: Segment[]): ValidationResult
90
+ export declare const REQUIRED_EVENT_KEYS: readonly string[]
91
+ export declare const REQUIRED_JOB_EVENT_KEYS: readonly string[]
92
+ export declare const REQUIRED_REVIEW_EVENT_KEYS: readonly string[]
93
+ /** The family an event type belongs to ("job", "review"), from its own prefix. */
94
+ export declare function eventFamily(type: unknown): string | null
95
+ export declare function validateEvent(event: unknown): ValidationResult