@openwop/openwop-conformance 1.26.0 → 1.28.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/CHANGELOG.md +20 -0
- package/README.md +2 -2
- package/api/openapi.yaml +215 -0
- package/coverage.md +11 -0
- package/package.json +1 -1
- package/schemas/README.md +4 -0
- package/schemas/capabilities.schema.json +49 -0
- package/schemas/localized-content-language-settings.schema.json +26 -0
- package/schemas/localized-content-page-response.schema.json +60 -0
- package/schemas/localized-content-page.schema.json +62 -0
- package/schemas/localized-content-section.schema.json +51 -0
- package/schemas/suspend-request.schema.json +20 -0
- package/src/scenarios/i18n-negotiation.test.ts +30 -3
- package/src/scenarios/interrupt-approver-routing.test.ts +166 -0
- package/src/scenarios/localized-content-delivery.test.ts +221 -0
- package/src/scenarios/spec-corpus-validity.test.ts +1 -0
|
@@ -2052,6 +2052,32 @@
|
|
|
2052
2052
|
},
|
|
2053
2053
|
"additionalProperties": false
|
|
2054
2054
|
},
|
|
2055
|
+
"content": {
|
|
2056
|
+
"type": "object",
|
|
2057
|
+
"description": "RFC 0103 (spec/v1/localized-content.md). Localized authored content (pages → sections) advertisement. Reuses the i18n annex's Accept-Language/Content-Language negotiation; it does NOT redeclare negotiation. Requires `i18n.supported: true`. `baseLocale` MUST equal `capabilities.i18n.defaultLocale`; `({baseLocale} ∪ supportedLocales)` MUST be a subset of `capabilities.i18n.supportedLocales`; `baseLocale` MUST NOT appear in `supportedLocales`. Hosts that omit this block serve no content surface; the conformance scenarios skip cleanly.",
|
|
2058
|
+
"additionalProperties": false,
|
|
2059
|
+
"required": ["supported", "baseLocale", "supportedLocales"],
|
|
2060
|
+
"properties": {
|
|
2061
|
+
"supported": {
|
|
2062
|
+
"type": "boolean",
|
|
2063
|
+
"description": "Host serves the localized-content surface (`GET /v1/content/pages/{slug}` + tenant-scoped admin CRUD). Requires `i18n.supported: true`."
|
|
2064
|
+
},
|
|
2065
|
+
"baseLocale": {
|
|
2066
|
+
"type": "string",
|
|
2067
|
+
"pattern": "^[a-zA-Z]{2,3}(-[a-zA-Z0-9]{2,8}){0,3}$",
|
|
2068
|
+
"description": "The locale section `data` is authored in. MUST equal `capabilities.i18n.defaultLocale`."
|
|
2069
|
+
},
|
|
2070
|
+
"supportedLocales": {
|
|
2071
|
+
"type": "array",
|
|
2072
|
+
"uniqueItems": true,
|
|
2073
|
+
"items": {
|
|
2074
|
+
"type": "string",
|
|
2075
|
+
"pattern": "^[a-zA-Z]{2,3}(-[a-zA-Z0-9]{2,8}){0,3}$"
|
|
2076
|
+
},
|
|
2077
|
+
"description": "BCP 47 tags the host has authored content translations for. MUST NOT contain `baseLocale`; `({baseLocale} ∪ supportedLocales)` MUST be a subset of `i18n.supportedLocales`."
|
|
2078
|
+
}
|
|
2079
|
+
}
|
|
2080
|
+
},
|
|
2055
2081
|
"portability": {
|
|
2056
2082
|
"type": "object",
|
|
2057
2083
|
"description": "RFC 0098 (`Active`). Export/import of a tenant's reusable estate (agents 0070, packs 0003/0013, prompt templates 0027, connection *refs* 0045/0095, schedules 0052, roster/org-chart 0086/0087). An export bundle carries NO credential values — only refs to be re-bound at the destination (RFC 0046/0079). Import maps the estate onto the destination's RFC 0048 identity, MUST offer a no-write dry-run plan, MUST be idempotent, and is gated by an RFC 0049 scope. A host advertising this serves `/v1/host/sample/{export,import}` (promotable to `/v1/{export,import}`) and emits the content-free `import.applied` event. Hosts that omit this block neither export nor import; the conformance scenarios skip cleanly.",
|
|
@@ -2081,6 +2107,29 @@
|
|
|
2081
2107
|
},
|
|
2082
2108
|
"if": { "properties": { "import": { "const": true } }, "required": ["import"] },
|
|
2083
2109
|
"then": { "properties": { "dryRun": { "const": true } }, "required": ["dryRun"] }
|
|
2110
|
+
},
|
|
2111
|
+
"interrupt": {
|
|
2112
|
+
"type": "object",
|
|
2113
|
+
"additionalProperties": false,
|
|
2114
|
+
"description": "RFC 0104 (`Active`). Interrupt-surface capabilities. Currently advertises portable HITL approver routing on the `kind: \"approval\"` InterruptPayload.",
|
|
2115
|
+
"properties": {
|
|
2116
|
+
"approverRouting": {
|
|
2117
|
+
"type": "object",
|
|
2118
|
+
"additionalProperties": false,
|
|
2119
|
+
"required": ["supported"],
|
|
2120
|
+
"description": "RFC 0104. When `supported: true`, the host surfaces the OPTIONAL, ADVISORY `approverGroupRefs` / `approverRoleRefs` / `audience` fields on the approval InterruptPayload unchanged, resolves the ref kinds it advertises against its own identity/RBAC, ENFORCES eligibility at resolve time (the refs stay advisory metadata for clients; enforcement is host-side), and SHOULD route notifications to the resolved union. Refs are opaque to the engine and snapshotted at decision time for deterministic replay. Hosts that omit this block (or set `supported: false`) ignore the fields and remain conformant. Advertise only what the host actually resolves (`refKinds`) and honors (`audience`).",
|
|
2121
|
+
"properties": {
|
|
2122
|
+
"supported": { "type": "boolean", "description": "Host honors the RFC 0104 approver-routing fields." },
|
|
2123
|
+
"refKinds": {
|
|
2124
|
+
"type": "array",
|
|
2125
|
+
"items": { "type": "string", "enum": ["group", "role"] },
|
|
2126
|
+
"uniqueItems": true,
|
|
2127
|
+
"description": "Which approver ref kinds the host actually resolves. `group` ⇒ honors `approverGroupRefs`; `role` ⇒ honors `approverRoleRefs`. Absent ⇒ the host resolves neither (advisory-only passthrough). Capability honesty: advertise only the kinds the host's resolver supports."
|
|
2128
|
+
},
|
|
2129
|
+
"audience": { "type": "boolean", "default": false, "description": "Host honors the `audience` notification-targeting override. Absent/`false` ⇒ the host notifies the resolved eligible union and ignores `audience`." }
|
|
2130
|
+
}
|
|
2131
|
+
}
|
|
2132
|
+
}
|
|
2084
2133
|
}
|
|
2085
2134
|
},
|
|
2086
2135
|
"additionalProperties": true
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
|
3
|
+
"$id": "https://openwop.dev/spec/v1/localized-content-language-settings.schema.json",
|
|
4
|
+
"title": "LocalizedContentLanguageSettings",
|
|
5
|
+
"description": "RFC 0103 (spec/v1/localized-content.md §B). Per-tenant authoring configuration for the content surface. Invariant: `baseLocale` MUST NOT appear in `supportedLocales` (the base locale is carried by section `data`, supported locales by `localizations`). This object is the source of truth for the host's advertised `content` capability block; the advertisement MUST reflect it (`baseLocale` equal, advertised `supportedLocales` equal or a subset).",
|
|
6
|
+
"type": "object",
|
|
7
|
+
"additionalProperties": false,
|
|
8
|
+
"required": ["baseLocale", "supportedLocales", "autoTranslateOnPublish"],
|
|
9
|
+
"properties": {
|
|
10
|
+
"baseLocale": {
|
|
11
|
+
"type": "string",
|
|
12
|
+
"pattern": "^[a-z]{2}(-[A-Z]{2})?$",
|
|
13
|
+
"description": "The locale section `data` is authored in. MUST equal `capabilities.i18n.defaultLocale`."
|
|
14
|
+
},
|
|
15
|
+
"supportedLocales": {
|
|
16
|
+
"type": "array",
|
|
17
|
+
"description": "BCP-47 tags the tenant authors content translations for. MUST NOT contain `baseLocale`.",
|
|
18
|
+
"items": { "type": "string", "pattern": "^[a-z]{2}(-[A-Z]{2})?$" },
|
|
19
|
+
"uniqueItems": true
|
|
20
|
+
},
|
|
21
|
+
"autoTranslateOnPublish": {
|
|
22
|
+
"type": "boolean",
|
|
23
|
+
"description": "Whether the host auto-translates missing locale fields on publish (host-extension behavior; informational at the protocol layer)."
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
|
3
|
+
"$id": "https://openwop.dev/spec/v1/localized-content-page-response.schema.json",
|
|
4
|
+
"title": "LocalizedContentPageResponse",
|
|
5
|
+
"description": "RFC 0103 (spec/v1/localized-content.md §D). The resolved delivery response for `GET /v1/content/pages/{slug}`. `locale` is the negotiated locale (equals the `Content-Language` response header). `sections[]` carry the ALREADY-MERGED section bodies — resolution (the per-section field merge §C) happened server-side, so no `localizations` map appears in the response. Only `published`, `enabled` sections are present, in render order.",
|
|
6
|
+
"type": "object",
|
|
7
|
+
"additionalProperties": false,
|
|
8
|
+
"required": ["version", "generatedAt", "locale", "slug", "page", "sections"],
|
|
9
|
+
"properties": {
|
|
10
|
+
"version": {
|
|
11
|
+
"type": "string",
|
|
12
|
+
"description": "Response schema version marker (e.g. `\"1\"`)."
|
|
13
|
+
},
|
|
14
|
+
"generatedAt": {
|
|
15
|
+
"type": "string",
|
|
16
|
+
"format": "date-time",
|
|
17
|
+
"description": "When the resolved response was generated."
|
|
18
|
+
},
|
|
19
|
+
"locale": {
|
|
20
|
+
"type": "string",
|
|
21
|
+
"pattern": "^[a-zA-Z]{2,3}(-[a-zA-Z0-9]{2,8}){0,3}$",
|
|
22
|
+
"description": "The negotiated locale actually used (BCP-47). Equals the `Content-Language` response header."
|
|
23
|
+
},
|
|
24
|
+
"slug": {
|
|
25
|
+
"type": "string",
|
|
26
|
+
"pattern": "^[a-z][a-z0-9-]*$",
|
|
27
|
+
"description": "The requested page slug."
|
|
28
|
+
},
|
|
29
|
+
"page": {
|
|
30
|
+
"type": "object",
|
|
31
|
+
"additionalProperties": false,
|
|
32
|
+
"required": ["pageId", "slug", "name"],
|
|
33
|
+
"description": "Resolved page metadata (status omitted — only published pages are delivered).",
|
|
34
|
+
"properties": {
|
|
35
|
+
"pageId": { "type": "string", "minLength": 1 },
|
|
36
|
+
"slug": { "type": "string", "pattern": "^[a-z][a-z0-9-]*$" },
|
|
37
|
+
"name": { "type": "string", "minLength": 1 },
|
|
38
|
+
"seo": { "type": "object", "additionalProperties": true }
|
|
39
|
+
}
|
|
40
|
+
},
|
|
41
|
+
"sections": {
|
|
42
|
+
"type": "array",
|
|
43
|
+
"description": "Resolved sections in render order; each body is the merged result, not the raw record.",
|
|
44
|
+
"items": {
|
|
45
|
+
"type": "object",
|
|
46
|
+
"additionalProperties": false,
|
|
47
|
+
"required": ["sectionId", "sectionType", "data"],
|
|
48
|
+
"properties": {
|
|
49
|
+
"sectionId": { "type": "string", "minLength": 1 },
|
|
50
|
+
"sectionType": { "type": "string", "minLength": 1 },
|
|
51
|
+
"data": {
|
|
52
|
+
"type": "object",
|
|
53
|
+
"additionalProperties": true,
|
|
54
|
+
"description": "The merged section body for the negotiated locale (exact → family → base shallow overlay)."
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
|
3
|
+
"$id": "https://openwop.dev/spec/v1/localized-content-page.schema.json",
|
|
4
|
+
"title": "LocalizedContentPage",
|
|
5
|
+
"description": "RFC 0103 (spec/v1/localized-content.md §B). An authored content page: an ordered composition of sections addressed by a URL `slug`. Publication status is atomic across locales for v1. SEO carries hreflang + og:locale alternates so a renderer can emit per-locale link relations.",
|
|
6
|
+
"type": "object",
|
|
7
|
+
"additionalProperties": false,
|
|
8
|
+
"required": ["pageId", "slug", "name", "status", "sectionOrder"],
|
|
9
|
+
"properties": {
|
|
10
|
+
"pageId": {
|
|
11
|
+
"type": "string",
|
|
12
|
+
"minLength": 1,
|
|
13
|
+
"description": "Stable identifier of the page within its tenant."
|
|
14
|
+
},
|
|
15
|
+
"slug": {
|
|
16
|
+
"type": "string",
|
|
17
|
+
"pattern": "^[a-z][a-z0-9-]*$",
|
|
18
|
+
"description": "URL slug for public delivery (`GET /v1/content/pages/{slug}`). Lowercase, hyphen-separated."
|
|
19
|
+
},
|
|
20
|
+
"name": {
|
|
21
|
+
"type": "string",
|
|
22
|
+
"minLength": 1,
|
|
23
|
+
"description": "Human-facing page name (admin/authoring label)."
|
|
24
|
+
},
|
|
25
|
+
"status": {
|
|
26
|
+
"type": "string",
|
|
27
|
+
"enum": ["draft", "published"],
|
|
28
|
+
"description": "Publication status, atomic across locales for v1. A `draft` page MUST NOT be served on the public delivery path."
|
|
29
|
+
},
|
|
30
|
+
"sectionOrder": {
|
|
31
|
+
"type": "array",
|
|
32
|
+
"items": { "type": "string", "minLength": 1 },
|
|
33
|
+
"description": "Ordered list of `sectionId`s defining the page's render order."
|
|
34
|
+
},
|
|
35
|
+
"seo": {
|
|
36
|
+
"type": "object",
|
|
37
|
+
"additionalProperties": false,
|
|
38
|
+
"description": "Optional SEO metadata for per-locale link relations.",
|
|
39
|
+
"properties": {
|
|
40
|
+
"hreflang": {
|
|
41
|
+
"type": "array",
|
|
42
|
+
"description": "hreflang alternates: per-locale canonical URLs.",
|
|
43
|
+
"items": {
|
|
44
|
+
"type": "object",
|
|
45
|
+
"additionalProperties": false,
|
|
46
|
+
"required": ["locale", "href"],
|
|
47
|
+
"properties": {
|
|
48
|
+
"locale": { "type": "string", "pattern": "^[a-z]{2}(-[A-Z]{2})?$" },
|
|
49
|
+
"href": { "type": "string", "format": "uri" }
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
},
|
|
53
|
+
"ogLocaleAlternates": {
|
|
54
|
+
"type": "array",
|
|
55
|
+
"description": "og:locale:alternate values.",
|
|
56
|
+
"items": { "type": "string", "pattern": "^[a-z]{2}(-[A-Z]{2})?$" },
|
|
57
|
+
"uniqueItems": true
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
|
3
|
+
"$id": "https://openwop.dev/spec/v1/localized-content-section.schema.json",
|
|
4
|
+
"title": "LocalizedContentSection",
|
|
5
|
+
"description": "RFC 0103 (spec/v1/localized-content.md §B). One section record of an authored content page. A section is ONE record carrying a base `data` payload (authored in the host's `content.baseLocale`) plus a sparse `localizations` map of partial per-locale field overrides — never one record per locale. The negotiated locale's resolved body is computed by the normative per-section field merge (exact-locale → language-family → base `data`, shallow overlay; §C). Body field shapes inside `data`/`localizations` are open and host/section-type-defined; this schema closes the envelope, not the body.",
|
|
6
|
+
"type": "object",
|
|
7
|
+
"additionalProperties": false,
|
|
8
|
+
"required": ["sectionId", "sectionType", "data", "localizations", "status", "enabled", "order"],
|
|
9
|
+
"properties": {
|
|
10
|
+
"sectionId": {
|
|
11
|
+
"type": "string",
|
|
12
|
+
"minLength": 1,
|
|
13
|
+
"description": "Stable identifier of the section within its page."
|
|
14
|
+
},
|
|
15
|
+
"sectionType": {
|
|
16
|
+
"type": "string",
|
|
17
|
+
"minLength": 1,
|
|
18
|
+
"description": "The section-type discriminator (e.g. `hero`, `features`). The host/section-type defines the body field shape; the protocol does not."
|
|
19
|
+
},
|
|
20
|
+
"data": {
|
|
21
|
+
"type": "object",
|
|
22
|
+
"additionalProperties": true,
|
|
23
|
+
"description": "The base/default-locale fields, authored in `content.baseLocale`. Open object — body shapes are the host/section-type's concern."
|
|
24
|
+
},
|
|
25
|
+
"localizations": {
|
|
26
|
+
"type": "object",
|
|
27
|
+
"description": "Sparse map of per-locale partial field overrides. MAY be empty (`{}`). Each key is a BCP-47 tag matching `^[a-z]{2}(-[A-Z]{2})?$` and MUST NOT equal `content.baseLocale` (the base locale is carried by `data`). Each value is a partial overlay of `data` fields; missing fields fall through to `data` (per-section merge §C).",
|
|
28
|
+
"propertyNames": {
|
|
29
|
+
"pattern": "^[a-z]{2}(-[A-Z]{2})?$"
|
|
30
|
+
},
|
|
31
|
+
"additionalProperties": {
|
|
32
|
+
"type": "object",
|
|
33
|
+
"additionalProperties": true,
|
|
34
|
+
"description": "Partial field overrides for one locale; shallow-overlaid onto `data`."
|
|
35
|
+
}
|
|
36
|
+
},
|
|
37
|
+
"status": {
|
|
38
|
+
"type": "string",
|
|
39
|
+
"enum": ["draft", "published"],
|
|
40
|
+
"description": "Publication status. Atomic across all locales for v1 (no per-locale publish). The public delivery path serves `published` only."
|
|
41
|
+
},
|
|
42
|
+
"enabled": {
|
|
43
|
+
"type": "boolean",
|
|
44
|
+
"description": "Whether the section participates in delivery when its page is rendered."
|
|
45
|
+
},
|
|
46
|
+
"order": {
|
|
47
|
+
"type": "integer",
|
|
48
|
+
"description": "Render order of the section within its page (lower first). `sectionOrder` on the page is authoritative; `order` is the per-section tiebreak/sort hint."
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
}
|
|
@@ -81,6 +81,26 @@
|
|
|
81
81
|
"type": "boolean",
|
|
82
82
|
"default": false,
|
|
83
83
|
"description": "RFC 0093 §D2 (pins RFC 0051 UQ 2). When `true`, a configured override principal (per `interrupt-profiles.md` §approval-gate `override.requiredRole`) MAY bypass the quorum (`requiredApprovals`) and release the gate alone. Default `false`: an override principal's approval counts as ONE quorum vote; quorum still applies. Optional, additive."
|
|
84
|
+
},
|
|
85
|
+
"approverGroupRefs": {
|
|
86
|
+
"type": "array",
|
|
87
|
+
"items": { "type": "string" },
|
|
88
|
+
"description": "RFC 0104 — OPTIONAL, ADVISORY. Opaque group refs whose resolved members are eligible to approve. Like `approversList`, advisory: the host resolves refs against its own identity/RBAC and ENFORCES eligibility at resolve time; the engine treats refs as opaque (no membership resolution in the engine). Capability-gated via `interrupt.approverRouting` (`refKinds` MUST include `group`). Hosts that don't advertise it ignore this field. Membership is resolved at decision time and snapshotted for deterministic replay."
|
|
89
|
+
},
|
|
90
|
+
"approverRoleRefs": {
|
|
91
|
+
"type": "array",
|
|
92
|
+
"items": { "type": "string" },
|
|
93
|
+
"description": "RFC 0104 — OPTIONAL, ADVISORY. Opaque role refs whose holders are eligible to approve. Same advisory + host-resolved + opaque-to-engine + decision-time-snapshot semantics as `approverGroupRefs`. Capability-gated via `interrupt.approverRouting` (`refKinds` MUST include `role`)."
|
|
94
|
+
},
|
|
95
|
+
"audience": {
|
|
96
|
+
"type": "object",
|
|
97
|
+
"additionalProperties": false,
|
|
98
|
+
"description": "RFC 0104 — OPTIONAL, ADVISORY notification-targeting hint: who should be TOLD about this gate, distinct from who MAY approve (the eligibility refs). When OMITTED, the host SHOULD notify the resolved union of the eligibility refs (`approversList` ∪ `approverGroupRefs` ∪ `approverRoleRefs`). When PRESENT, it OVERRIDES that default. Advisory; the host remains the routing authority. Capability-gated via `interrupt.approverRouting.audience`.",
|
|
99
|
+
"properties": {
|
|
100
|
+
"subjects": { "type": "array", "items": { "type": "string" }, "description": "Opaque subject refs to notify." },
|
|
101
|
+
"groups": { "type": "array", "items": { "type": "string" }, "description": "Opaque group refs to notify." },
|
|
102
|
+
"roles": { "type": "array", "items": { "type": "string" }, "description": "Opaque role refs to notify." }
|
|
103
|
+
}
|
|
84
104
|
}
|
|
85
105
|
}
|
|
86
106
|
},
|
|
@@ -166,16 +166,43 @@ describe.skipIf(HTTP_SKIP)('i18n-negotiation: behavioral (i18n.md §Accept-Langu
|
|
|
166
166
|
// Prefer a non-default supported locale so localization actually engages.
|
|
167
167
|
const negotiated = supported.find((t) => t !== defaultLocale) ?? defaultLocale;
|
|
168
168
|
|
|
169
|
+
// Baseline: the same probe under the host's DEFAULT locale. The i18n MUST is
|
|
170
|
+
// locale-INVARIANCE of the machine-readable code, not a specific vocabulary
|
|
171
|
+
// value. i18n.md §"`locale` field on `ErrorEnvelope.details`" requires the
|
|
172
|
+
// `error` code to "remain English / lowercase / underscore-cased regardless
|
|
173
|
+
// of locale" — it is an identifier, not human text. The spec does NOT pin a
|
|
174
|
+
// missing run to the literal `not_found`: error-envelope.schema.json says
|
|
175
|
+
// codes are SHOULD-snake_case, rest-endpoints.md §"Common error codes" is
|
|
176
|
+
// non-exhaustive, and `run_forbidden` (RFC 0048) sets precedent that hosts
|
|
177
|
+
// MAY use run-specific codes. Asserting a literal here would also pressure a
|
|
178
|
+
// conformant host into a COMPATIBILITY.md §2.2 breaking error-code change.
|
|
179
|
+
const baseRes = await driver.get(PROBE_PATH, {
|
|
180
|
+
headers: { 'Accept-Language': defaultLocale },
|
|
181
|
+
});
|
|
182
|
+
const baseCode = errCode(baseRes.json);
|
|
183
|
+
|
|
169
184
|
const res = await driver.get(PROBE_PATH, {
|
|
170
185
|
headers: { 'Accept-Language': negotiated },
|
|
171
186
|
});
|
|
172
187
|
expect(res.status).toBe(404);
|
|
188
|
+
const negotiatedCode = errCode(res.json);
|
|
189
|
+
|
|
190
|
+
// (a) the code is an English snake_case identifier (not localized prose)
|
|
191
|
+
expect(
|
|
192
|
+
typeof negotiatedCode === 'string' && /^[a-z][a-z0-9_]*$/.test(negotiatedCode),
|
|
193
|
+
driver.describe(
|
|
194
|
+
'i18n.md §"`locale` field on `ErrorEnvelope.details`"',
|
|
195
|
+
`the machine-readable error code MUST be an English lowercase snake_case identifier, not localized text (got ${String(negotiatedCode)} under ${negotiated})`,
|
|
196
|
+
),
|
|
197
|
+
).toBe(true);
|
|
198
|
+
|
|
199
|
+
// (b) it is byte-identical to the default-locale code — the actual invariance MUST
|
|
173
200
|
expect(
|
|
174
|
-
|
|
201
|
+
negotiatedCode,
|
|
175
202
|
driver.describe(
|
|
176
203
|
'i18n.md §"`locale` field on `ErrorEnvelope.details`"',
|
|
177
|
-
`the machine-readable error code MUST remain
|
|
204
|
+
`the machine-readable error code MUST remain identical regardless of the negotiated locale (default ${defaultLocale} → ${String(baseCode)}; negotiated ${negotiated})`,
|
|
178
205
|
),
|
|
179
|
-
).toBe(
|
|
206
|
+
).toBe(baseCode);
|
|
180
207
|
});
|
|
181
208
|
});
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Portable HITL approver routing (RFC 0104; `spec/v1/interrupt.md` §`kind: "approval"`).
|
|
3
|
+
*
|
|
4
|
+
* Adds three OPTIONAL, ADVISORY fields to the approval `InterruptPayload` so
|
|
5
|
+
* group/role approver routing is portable + capability-gated across hosts:
|
|
6
|
+
* `approverGroupRefs`, `approverRoleRefs`, and an `audience` notification hint.
|
|
7
|
+
* `approversList` advisory semantics are unchanged; enforcement stays host-side;
|
|
8
|
+
* refs are opaque to the engine and decision-time-snapshotted for replay.
|
|
9
|
+
*
|
|
10
|
+
* Two layers:
|
|
11
|
+
*
|
|
12
|
+
* A. Always-on, server-free legs — the `interrupt.approverRouting` capability
|
|
13
|
+
* block shape, the additive optionality of the three fields on the
|
|
14
|
+
* `ApprovalData` schema, and the §"Portable approver routing" reference
|
|
15
|
+
* rule that `audience` DEFAULTS to the resolved eligibility union when
|
|
16
|
+
* omitted and OVERRIDES it when present (`notifyTargets`).
|
|
17
|
+
*
|
|
18
|
+
* B. Capability-gated advertisement-coherence leg — on a host advertising
|
|
19
|
+
* `capabilities.interrupt.approverRouting.supported`, the advertised shape
|
|
20
|
+
* MUST be honest: `refKinds` ⊆ {group, role}; `audience` boolean. Hosts
|
|
21
|
+
* that do not advertise the capability soft-skip via the gate (the fields
|
|
22
|
+
* are ignored and the host stays conformant).
|
|
23
|
+
*
|
|
24
|
+
* @see spec/v1/interrupt.md §"Portable approver routing (RFC 0104)"
|
|
25
|
+
* @see spec/v1/capabilities.md — interrupt.approverRouting.*
|
|
26
|
+
* @see schemas/suspend-request.schema.json — ApprovalData
|
|
27
|
+
* @see RFCS/0104-hitl-approver-routing.md
|
|
28
|
+
*/
|
|
29
|
+
|
|
30
|
+
import { describe, it, expect } from 'vitest';
|
|
31
|
+
import { readFileSync } from 'node:fs';
|
|
32
|
+
import { join } from 'node:path';
|
|
33
|
+
import Ajv2020 from 'ajv/dist/2020.js';
|
|
34
|
+
import addFormats from 'ajv-formats';
|
|
35
|
+
import { SCHEMAS_DIR } from '../lib/paths.js';
|
|
36
|
+
import { readCapabilityFamily } from '../lib/discovery-capabilities.js';
|
|
37
|
+
import { behaviorGate } from '../lib/behavior-gate.js';
|
|
38
|
+
|
|
39
|
+
const why = (specRef: string, requirement: string): string => `${specRef} — ${requirement}`;
|
|
40
|
+
function loadSchema(name: string): Record<string, unknown> {
|
|
41
|
+
return JSON.parse(readFileSync(join(SCHEMAS_DIR, name), 'utf8')) as Record<string, unknown>;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
interface ApproverRoutingCap {
|
|
45
|
+
supported?: boolean;
|
|
46
|
+
refKinds?: string[];
|
|
47
|
+
audience?: boolean;
|
|
48
|
+
}
|
|
49
|
+
interface InterruptCap {
|
|
50
|
+
approverRouting?: ApproverRoutingCap;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// ── §"Portable approver routing" reference rule — audience default/override ──
|
|
54
|
+
type ApprovalPayload = {
|
|
55
|
+
approversList?: string[];
|
|
56
|
+
approverGroupRefs?: string[];
|
|
57
|
+
approverRoleRefs?: string[];
|
|
58
|
+
audience?: { subjects?: string[]; groups?: string[]; roles?: string[] };
|
|
59
|
+
};
|
|
60
|
+
/**
|
|
61
|
+
* The advisory routing union a notifying host SHOULD target. Mirrors the
|
|
62
|
+
* normative rule: omitted `audience` ⇒ the eligibility union
|
|
63
|
+
* (`approversList` ∪ `approverGroupRefs` ∪ `approverRoleRefs`); present
|
|
64
|
+
* `audience` ⇒ its own union, overriding the default. Refs stay opaque —
|
|
65
|
+
* this composes refs, it does NOT resolve membership.
|
|
66
|
+
*/
|
|
67
|
+
function notifyTargets(p: ApprovalPayload): string[] {
|
|
68
|
+
const uniq = (xs: string[]): string[] => [...new Set(xs)];
|
|
69
|
+
if (p.audience) {
|
|
70
|
+
return uniq([...(p.audience.subjects ?? []), ...(p.audience.groups ?? []), ...(p.audience.roles ?? [])]);
|
|
71
|
+
}
|
|
72
|
+
return uniq([...(p.approversList ?? []), ...(p.approverGroupRefs ?? []), ...(p.approverRoleRefs ?? [])]);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// ════════════════════════════════════════════════════════════════════════════
|
|
76
|
+
// A. Server-free legs
|
|
77
|
+
// ════════════════════════════════════════════════════════════════════════════
|
|
78
|
+
|
|
79
|
+
describe('interrupt-approver-routing: capability advertisement shape (capabilities.md, server-free)', () => {
|
|
80
|
+
const caps = loadSchema('capabilities.schema.json');
|
|
81
|
+
const interrupt = (caps.properties as Record<string, { properties?: Record<string, { required?: string[]; properties?: Record<string, unknown> }> }>).interrupt;
|
|
82
|
+
|
|
83
|
+
it('capabilities schema declares interrupt.approverRouting', () => {
|
|
84
|
+
expect(interrupt, why('capabilities.schema.json §interrupt', 'the interrupt block MUST be declared')).toBeDefined();
|
|
85
|
+
const ar = interrupt?.properties?.approverRouting;
|
|
86
|
+
expect(ar, why('RFC 0104', 'interrupt.approverRouting MUST be declared')).toBeDefined();
|
|
87
|
+
expect(ar?.required, why('RFC 0104', 'approverRouting.supported MUST be required')).toEqual(expect.arrayContaining(['supported']));
|
|
88
|
+
for (const f of ['supported', 'refKinds', 'audience']) {
|
|
89
|
+
expect(ar?.properties?.[f], why('RFC 0104', `approverRouting.${f} MUST be declared`)).toBeDefined();
|
|
90
|
+
}
|
|
91
|
+
});
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
describe('interrupt-approver-routing: ApprovalData additive optionality (interrupt.md §approval, server-free)', () => {
|
|
95
|
+
const ajv = new Ajv2020({ strict: false, allErrors: true });
|
|
96
|
+
addFormats(ajv);
|
|
97
|
+
const suspend = loadSchema('suspend-request.schema.json');
|
|
98
|
+
const validate = ajv.compile(suspend);
|
|
99
|
+
|
|
100
|
+
const baseApproval = {
|
|
101
|
+
kind: 'approval',
|
|
102
|
+
key: 'run:node:0',
|
|
103
|
+
data: { artifactId: 'a1', artifactType: 'prd', title: 'Approve budget', actions: ['accept', 'reject'] },
|
|
104
|
+
};
|
|
105
|
+
|
|
106
|
+
it('an approval payload WITHOUT the routing fields still validates (additive, optional)', () => {
|
|
107
|
+
expect(validate(baseApproval), why('RFC 0104 Compatibility', `the fields are optional — Errors: ${JSON.stringify(validate.errors)}`)).toBe(true);
|
|
108
|
+
});
|
|
109
|
+
it('an approval payload WITH group/role refs + audience validates', () => {
|
|
110
|
+
const withRouting = {
|
|
111
|
+
...baseApproval,
|
|
112
|
+
data: {
|
|
113
|
+
...baseApproval.data,
|
|
114
|
+
approverGroupRefs: ['grp:finance-approvers'],
|
|
115
|
+
approverRoleRefs: ['role:controller'],
|
|
116
|
+
audience: { groups: ['grp:finance-approvers'], roles: ['role:controller'], subjects: ['user:cfo'] },
|
|
117
|
+
},
|
|
118
|
+
};
|
|
119
|
+
expect(validate(withRouting), why('RFC 0104 §Proposal', `routing fields MUST validate — Errors: ${JSON.stringify(validate.errors)}`)).toBe(true);
|
|
120
|
+
});
|
|
121
|
+
it('an audience with an unknown key is rejected (audience object is closed)', () => {
|
|
122
|
+
const badAudience = { ...baseApproval, data: { ...baseApproval.data, audience: { teams: ['grp:x'] } } };
|
|
123
|
+
expect(validate(badAudience), why('RFC 0104', 'audience MUST be additionalProperties:false')).toBe(false);
|
|
124
|
+
});
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
describe('interrupt-approver-routing: audience default/override rule (interrupt.md §"Portable approver routing", server-free)', () => {
|
|
128
|
+
it('omitted audience ⇒ notify the resolved eligibility union', () => {
|
|
129
|
+
expect(
|
|
130
|
+
notifyTargets({ approversList: ['user:a'], approverGroupRefs: ['grp:fin'], approverRoleRefs: ['role:ctrl'] }),
|
|
131
|
+
why('RFC 0104', 'omitted audience MUST default to the eligibility union'),
|
|
132
|
+
).toEqual(['user:a', 'grp:fin', 'role:ctrl']);
|
|
133
|
+
});
|
|
134
|
+
it('present audience ⇒ overrides the eligibility union', () => {
|
|
135
|
+
expect(
|
|
136
|
+
notifyTargets({ approverGroupRefs: ['grp:fin'], audience: { subjects: ['user:cfo'], groups: ['grp:audit'] } }),
|
|
137
|
+
why('RFC 0104', 'present audience MUST override the default'),
|
|
138
|
+
).toEqual(['user:cfo', 'grp:audit']);
|
|
139
|
+
});
|
|
140
|
+
it('no eligibility refs and no audience ⇒ empty target set', () => {
|
|
141
|
+
expect(notifyTargets({}), why('RFC 0104', 'nothing to route when no refs')).toEqual([]);
|
|
142
|
+
});
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
// ════════════════════════════════════════════════════════════════════════════
|
|
146
|
+
// B. Capability-gated advertisement-coherence leg
|
|
147
|
+
// ════════════════════════════════════════════════════════════════════════════
|
|
148
|
+
|
|
149
|
+
describe('interrupt-approver-routing: advertised shape is honest (capability-gated)', () => {
|
|
150
|
+
it('an advertising host advertises a coherent approverRouting block', async () => {
|
|
151
|
+
if (!process.env.OPENWOP_BASE_URL) return; // no live host → nothing to read
|
|
152
|
+
const interrupt = await readCapabilityFamily<InterruptCap>('interrupt');
|
|
153
|
+
const ar = interrupt?.approverRouting;
|
|
154
|
+
// Soft-skip: host does not advertise the capability (fields ignored; still conformant).
|
|
155
|
+
if (!behaviorGate('interrupt.approverRouting', ar?.supported === true)) return;
|
|
156
|
+
|
|
157
|
+
// Opt-in established (supported === true): the advertised shape MUST be honest.
|
|
158
|
+
const allowed = new Set(['group', 'role']);
|
|
159
|
+
for (const k of ar?.refKinds ?? []) {
|
|
160
|
+
expect(allowed.has(k), why('RFC 0104 capabilities.md', `refKinds MUST be a subset of {group, role} — saw ${k}`)).toBe(true);
|
|
161
|
+
}
|
|
162
|
+
if (ar?.audience !== undefined) {
|
|
163
|
+
expect(typeof ar.audience, why('RFC 0104 capabilities.md', 'approverRouting.audience MUST be boolean')).toBe('boolean');
|
|
164
|
+
}
|
|
165
|
+
});
|
|
166
|
+
});
|