From 645a449e20042a366baa6458167c9fdafa7fc6ee Mon Sep 17 00:00:00 2001 From: Rajeh Taher Date: Sun, 11 Oct 2026 04:30:56 +0300 Subject: [PATCH 1/4] Pin the feature availability contract from API PR #561 --- contracts/feature-evaluation.json | 1068 ++++++++++++++++++ scripts/sync-feature-evaluation-contract.mjs | 128 +++ 2 files changed, 1196 insertions(+) create mode 100644 contracts/feature-evaluation.json create mode 100644 scripts/sync-feature-evaluation-contract.mjs diff --git a/contracts/feature-evaluation.json b/contracts/feature-evaluation.json new file mode 100644 index 00000000..81377b7a --- /dev/null +++ b/contracts/feature-evaluation.json @@ -0,0 +1,1068 @@ +{ + "repository": "polymorfa/polymorfa", + "commit": "d36e12ff05f9f3537f3f87d8ad27258abc9b02df", + "branch": "dev", + "note": "Feature availability operations from API dev. The full snapshots and coverage.json stay pinned to contracts/source.json until the next repin.", + "extraction": "Feature availability operations and their transitive component references; operation objects unchanged", + "sources": { + "messaging": { + "sourcePath": "apps/api/docs/openapi.json", + "sourceSha256": "03f48a3ac98ff88e504337aaa95d60b1ae81ef3381deaa2d5ac9780896a48608", + "paths": { + "/messaging/features/evaluate": { + "post": { + "operationId": "evaluateFeatures", + "tags": ["Features"], + "summary": "Evaluate feature availability", + "description": "Returns feature decisions for the authenticated organization or project. Client credentials cannot access this operation. Machine requests do not record experiment exposure.", + "security": [{ "BearerAuth": [] }], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "surface": { + "type": "string", + "maxLength": 100, + "pattern": "^[a-z][a-z0-9]*(?:[.-][a-z0-9]+)*$" + }, + "keys": { + "type": "array", + "items": { + "type": "string", + "maxLength": 100, + "pattern": "^[a-z][a-z0-9]*(?:[.-][a-z0-9]+)*$" + }, + "maxItems": 50 + }, + "measurementAllowed": { + "type": "boolean", + "default": false + }, + "locale": { + "type": "string", + "enum": ["en", "pt-BR"], + "default": "en" + } + }, + "required": ["surface", "keys"], + "additionalProperties": false + } + } + } + }, + "responses": { + "200": { + "description": "Feature decisions", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { "type": "boolean", "enum": [true] }, + "data": { + "type": "array", + "items": { + "type": "object", + "properties": { + "decision": { + "type": "object", + "properties": { + "key": { + "type": "string", + "maxLength": 100, + "pattern": "^[a-z][a-z0-9]*(?:[.-][a-z0-9]+)*$" + }, + "version": { "type": "integer" }, + "available": { "type": "boolean" }, + "enrolled": { "type": "boolean" }, + "variant": { + "type": "string", + "nullable": true + }, + "experimentRevision": { + "type": "integer", + "nullable": true, + "minimum": 1, + "description": "Published experiment policy revision for an assigned native experiment; null otherwise." + }, + "experimentAssigned": { "type": "boolean" } + }, + "required": [ + "key", + "version", + "available", + "enrolled", + "variant", + "experimentRevision", + "experimentAssigned" + ] + }, + "title": { "type": "string" }, + "description": { "type": "string" }, + "state": { + "type": "string", + "enum": ["off", "opt_in", "on"], + "description": "Published access state. `opt_in` requires the team to accept `terms` before the feature is available." + }, + "termsRevision": { + "type": "string", + "nullable": true, + "description": "Current terms revision while the feature is opt-in; null otherwise." + }, + "terms": { + "type": "string", + "nullable": true, + "description": "Opt-in terms in the requested locale; null when the feature is not opt-in." + }, + "withdrawal": { + "type": "string", + "nullable": true + }, + "enrollmentSource": { + "type": "string", + "nullable": true, + "enum": ["customer", "staff", null], + "description": "Who enabled an opt-in feature for the team: a team owner or admin, or Polymorfa. Null when not enrolled." + }, + "exposureToken": { + "nullable": true, + "description": "Deprecated. Always null." + } + }, + "required": [ + "decision", + "title", + "description", + "state", + "termsRevision", + "terms", + "withdrawal", + "enrollmentSource", + "exposureToken" + ] + } + } + }, + "required": ["success", "data"] + } + } + }, + "headers": { + "X-RateLimit-Limit": { + "$ref": "#/components/headers/X-RateLimit-Limit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/X-RateLimit-Remaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/X-RateLimit-Reset" + } + } + }, + "400": { + "description": "Invalid feature request", + "headers": { + "X-RateLimit-Limit": { + "$ref": "#/components/headers/X-RateLimit-Limit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/X-RateLimit-Remaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/X-RateLimit-Reset" + } + }, + "content": { + "application/json": { + "schema": { "$ref": "#/components/schemas/PublicError" }, + "example": { + "error": { + "type": "invalid_request_error", + "code": "invalid_parameter", + "message": "Correct the invalid request field.", + "param": null, + "request_id": "5f0c2a8e-3b1d-4c6f-9e2a-7d4b1c8f6a30" + }, + "data": null, + "docs": "https://docs.polymorfa.com/api/errors#invalid-parameter" + } + } + } + }, + "401": { + "description": "Authentication required", + "headers": { + "X-RateLimit-Limit": { + "$ref": "#/components/headers/X-RateLimit-Limit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/X-RateLimit-Remaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/X-RateLimit-Reset" + } + }, + "content": { + "application/json": { + "schema": { "$ref": "#/components/schemas/PublicError" }, + "example": { + "error": { + "type": "authentication_error", + "code": "invalid_credential", + "message": "Supply a valid credential.", + "param": null, + "request_id": "5f0c2a8e-3b1d-4c6f-9e2a-7d4b1c8f6a30" + }, + "data": null, + "docs": "https://docs.polymorfa.com/api/errors#invalid-credential" + } + } + } + }, + "403": { + "description": "Credential cannot access feature evaluation", + "headers": { + "X-RateLimit-Limit": { + "$ref": "#/components/headers/X-RateLimit-Limit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/X-RateLimit-Remaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/X-RateLimit-Reset" + } + }, + "content": { + "application/json": { + "schema": { "$ref": "#/components/schemas/PublicError" }, + "example": { + "error": { + "type": "permission_error", + "code": "permission_denied", + "message": "The credential does not have access to this resource.", + "param": null, + "request_id": "5f0c2a8e-3b1d-4c6f-9e2a-7d4b1c8f6a30" + }, + "data": null, + "docs": "https://docs.polymorfa.com/api/errors#permission-denied" + } + } + } + }, + "413": { + "description": "Request exceeds 16 KiB", + "headers": { + "X-RateLimit-Limit": { + "$ref": "#/components/headers/X-RateLimit-Limit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/X-RateLimit-Remaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/X-RateLimit-Reset" + } + }, + "content": { + "application/json": { + "schema": { "$ref": "#/components/schemas/PublicError" }, + "example": { + "error": { + "type": "invalid_request_error", + "code": "invalid_parameter", + "message": "Correct the invalid request field.", + "param": null, + "request_id": "5f0c2a8e-3b1d-4c6f-9e2a-7d4b1c8f6a30" + }, + "data": null, + "docs": "https://docs.polymorfa.com/api/errors#invalid-parameter" + } + } + } + } + } + } + } + }, + "components": { + "schemas": { + "PublicError": { + "type": "object", + "properties": { + "error": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "invalid_request_error", + "authentication_error", + "permission_error", + "conflict_error", + "rate_limit_error", + "api_error", + "upstream_error" + ] + }, + "code": { + "type": "string", + "enum": [ + "invalid_parameter", + "invalid_credential", + "entitlement_required", + "cloud_template_authority_restricted", + "gate_limit_reached", + "method_not_allowed", + "permission_denied", + "missing_scope", + "resource_not_found", + "hms_not_enabled", + "media_fetch_failed", + "resource_gone", + "feature_unavailable", + "stream_cursor_invalid", + "stream_cursor_expired", + "stream_connection_limit_reached", + "state_conflict", + "hybrid_routing_paused", + "hybrid_choice_required", + "hybrid_transition_ineligible", + "hybrid_automation_unavailable", + "hybrid_transport_unavailable", + "hybrid_target_reference_unavailable", + "send_outcome_unknown", + "idempotency_conflict", + "idempotency_in_progress", + "idempotency_completed", + "idempotency_outcome_unknown", + "call_claimed", + "call_not_ringing", + "connection_limit", + "unsupported_for_connection", + "call_permission_required", + "invalid_sip_trunk", + "calls_disabled", + "call_recipient_opted_out", + "call_destination_blocked", + "call_permission_request_limited", + "call_permission_granted", + "order_status_transition_invalid", + "order_cancellation_failed", + "call_opt_out_limit", + "number_restricted", + "sip_trunk_in_use", + "sip_trunk_revision_conflict", + "sip_trunk_limit", + "sip_unavailable", + "voice_not_enabled", + "provider_credential_invalid", + "provider_unavailable", + "asset_not_ready", + "voice_asset_in_use", + "voice_asset_revision_conflict", + "voice_unavailable", + "rate_limit_exceeded", + "internal_error", + "operation_not_supported", + "upstream_failure", + "service_unavailable", + "credential_verification_unavailable", + "session_not_ready", + "command_dispatch_failed", + "result_unknown", + "recipient_not_on_whatsapp", + "conversation_window_closed", + "template_not_approved", + "media_too_large", + "whatsapp_rate_limited", + "new_chat_limit_reached", + "whatsapp_account_restricted", + "whatsapp_groups_ineligible", + "whatsapp_group_creation_paused", + "whatsapp_group_limit_reached", + "whatsapp_group_full", + "whatsapp_group_suspended", + "whatsapp_group_has_no_participants", + "bansafe_suspended", + "bansafe_org_suspended", + "bansafe_cold_blocked", + "bansafe_cold_held", + "bansafe_throttled", + "bansafe_daily_allowance_reached", + "bansafe_accounting_unavailable", + "bansafe_send_outcome_unknown", + "campaigns_not_entitled", + "campaign_throughput_capped", + "campaign_approval_required", + "campaign_approval_stale", + "campaign_variables_missing" + ] + }, + "message": { "type": "string" }, + "param": { "type": "string", "nullable": true }, + "request_id": { + "type": "string", + "description": "Matches the X-Request-Id response header. Include it when you contact support." + }, + "request_log_url": { + "type": "string", + "format": "uri", + "description": "Console page that shows this request. Returned only to organization and project credentials whose request was logged." + } + }, + "required": ["type", "code", "message", "param", "request_id"] + }, + "data": { + "type": "string", + "nullable": true, + "enum": [null], + "description": "Always null for a failed request." + }, + "docs": { "type": "string", "format": "uri" } + }, + "required": ["error", "data", "docs"] + } + }, + "headers": { + "X-RateLimit-Reset": { + "description": "Unix timestamp in seconds when the oldest counted request leaves the rolling window, when the per-team request limiter applies. Follow Retry-After on a rejected request.", + "schema": { "type": "integer", "minimum": 0 } + }, + "X-RateLimit-Remaining": { + "description": "Requests left in the per-team quota; zero when this limiter rejects the request.", + "schema": { "type": "integer", "minimum": 0 } + }, + "X-RateLimit-Limit": { + "description": "Team request quota per rolling minute, when the per-team request limiter applies.", + "schema": { "type": "integer", "minimum": 1 } + } + } + } + }, + "platform": { + "sourcePath": "apps/api/docs/openapi.management.json", + "sourceSha256": "baafc9d1836bf1aacdbb550e3e43c6a71e4160d4b4874deb80309b2f43339a10", + "paths": { + "/api/account/features/evaluate": { + "post": { + "operationId": "evaluateAccountFeatures", + "tags": ["Feature availability"], + "summary": "Evaluate feature availability", + "description": "Evaluate feature decisions for the live dashboard organization from each feature's published setting. No permission is granted by a decision. Features not offered to the team are omitted unless the team is enrolled; opt-in features include their terms before enrollment, and enrolled features stay visible for withdrawal.\n\nRequires a verified dashboard bearer and current organization authorization. Organization keys and project tokens are not accepted.", + "x-audience": "console", + "x-polymorfa-resource-boundary": "human_only", + "x-excluded-from-public": true, + "x-exclusion-reason": "Private dashboard feature discovery; requires live organization membership.", + "security": [{ "ConsoleSession": [] }], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": false, + "required": ["surface", "keys"], + "properties": { + "surface": { + "type": "string", + "pattern": "^[a-z][a-z0-9]*(?:[.-][a-z0-9]+)*$", + "maxLength": 100 + }, + "keys": { + "type": "array", + "maxItems": 50, + "items": { + "type": "string", + "pattern": "^[a-z][a-z0-9]*(?:[.-][a-z0-9]+)*$", + "maxLength": 100 + } + }, + "measurementAllowed": { + "type": "boolean", + "default": false, + "description": "Deprecated. Has no effect." + }, + "projectId": { + "type": "string", + "format": "uuid", + "description": "Optional active project in the signed-in team. Required when checking a project-targeted release." + }, + "locale": { + "type": "string", + "enum": ["en", "pt-BR"], + "default": "en", + "description": "Language for titles, descriptions and terms. Missing translations fall back to English." + } + } + } + } + } + }, + "responses": { + "200": { + "description": "Success", + "headers": { + "X-Request-Id": { + "schema": { "type": "string" }, + "description": "Stable request correlation identifier." + }, + "X-Trace-Id": { + "schema": { "type": "string", "pattern": "^[0-9a-f]{32}$" }, + "description": "W3C trace ID of this request. Your `traceparent` trace ID when you sent a valid one." + }, + "Polymorfa-Version": { + "schema": { "type": "string" }, + "description": "API version applied to this response." + }, + "Cache-Control": { + "schema": { "type": "string", "example": "no-store" } + } + }, + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": false, + "properties": { + "success": { "type": "boolean", "enum": [true] }, + "data": { + "type": "array", + "items": { + "type": "object", + "required": [ + "decision", + "title", + "description", + "state", + "termsRevision", + "terms", + "withdrawal", + "enrollmentSource", + "exposureToken" + ], + "properties": { + "decision": { + "type": "object", + "required": [ + "key", + "version", + "available", + "enrolled", + "variant", + "experimentRevision", + "experimentAssigned" + ], + "properties": { + "key": { + "type": "string", + "pattern": "^[a-z][a-z0-9]*(?:[.-][a-z0-9]+)*$", + "maxLength": 100 + }, + "version": { + "type": "integer", + "minimum": 1 + }, + "available": { "type": "boolean" }, + "enrolled": { "type": "boolean" }, + "variant": { + "type": "string", + "nullable": true + }, + "experimentRevision": { + "type": "integer", + "minimum": 1, + "nullable": true, + "description": "Published experiment policy revision for an assigned native experiment; null otherwise." + }, + "experimentAssigned": { + "type": "boolean", + "description": "True only when an experiment assigned a registered variant with measurement enabled." + } + } + }, + "title": { "type": "string" }, + "description": { "type": "string" }, + "state": { + "type": "string", + "enum": ["off", "opt_in", "on"], + "description": "Published access state. `opt_in` requires the team to accept `terms` before the feature is available." + }, + "termsRevision": { + "type": "string", + "nullable": true, + "description": "Current terms revision while the feature is opt-in; null otherwise." + }, + "terms": { + "type": "string", + "nullable": true, + "description": "Opt-in terms in the requested locale; null when the feature is not opt-in." + }, + "withdrawal": { + "type": "string", + "nullable": true + }, + "enrollmentSource": { + "type": "string", + "nullable": true, + "enum": ["customer", "staff", null], + "description": "Who enabled an opt-in feature for the team: a team owner or admin, or Polymorfa. Null when not enrolled." + }, + "exposureToken": { + "nullable": true, + "description": "Deprecated. Always null." + } + } + } + } + }, + "required": ["success", "data"] + } + } + } + }, + "400": { "$ref": "#/components/responses/BadRequest" }, + "401": { "$ref": "#/components/responses/Unauthorized" }, + "403": { "$ref": "#/components/responses/Forbidden" }, + "413": { "$ref": "#/components/responses/PayloadTooLarge" }, + "500": { "$ref": "#/components/responses/InternalError" } + } + } + }, + "/api/account/features/enrollment": { + "post": { + "operationId": "setFeatureEnrollment", + "tags": ["Feature availability"], + "summary": "setFeatureEnrollment", + "description": "Live organization owner/admin only. Enabling requires the feature's published opt-in setting, its current terms revision, entitlement, release eligibility and no operational disable; a terms change during the request returns 409. Withdrawal does not delete resources.\n\nRequires a verified dashboard bearer and current organization authorization. The signed-in member must be an organization owner or admin. Organization keys and project tokens are not accepted.", + "x-audience": "console", + "x-polymorfa-resource-boundary": "human_only", + "x-excluded-from-public": true, + "x-exclusion-reason": "Private beta enrollment; requires live organization owner or admin authority.", + "x-required-organization-role": "owner_or_admin", + "security": [{ "ConsoleSession": [] }], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": false, + "properties": { + "key": { + "type": "string", + "pattern": "^[a-z][a-z0-9]*(?:[.-][a-z0-9]+)*$", + "maxLength": 100 + }, + "enabled": { "type": "boolean" }, + "termsRevision": { + "type": "string", + "nullable": true, + "maxLength": 100 + }, + "projectId": { + "type": "string", + "format": "uuid", + "description": "Optional active project in the signed-in team for a project-targeted release." + } + }, + "required": ["key", "enabled", "termsRevision"] + } + } + } + }, + "responses": { + "200": { + "description": "Success", + "headers": { + "X-Request-Id": { + "schema": { "type": "string" }, + "description": "Stable request correlation identifier." + }, + "X-Trace-Id": { + "schema": { "type": "string", "pattern": "^[0-9a-f]{32}$" }, + "description": "W3C trace ID of this request. Your `traceparent` trace ID when you sent a valid one." + }, + "Polymorfa-Version": { + "schema": { "type": "string" }, + "description": "API version applied to this response." + } + }, + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": false, + "properties": { + "success": { "type": "boolean", "enum": [true] }, + "data": { + "type": "object", + "required": ["enabled", "termsRevision"], + "properties": { + "enabled": { "type": "boolean" }, + "termsRevision": { "type": "string" }, + "capture": { + "type": "object", + "description": "Present for features that control data capture: whether capture already follows the change for every affected Number (`applied`) or is still being brought in line (`pending`).", + "additionalProperties": false, + "required": ["state"], + "properties": { + "state": { + "type": "string", + "enum": ["applied", "pending"] + } + } + } + } + } + }, + "required": ["success", "data"] + } + } + } + }, + "400": { "$ref": "#/components/responses/BadRequest" }, + "401": { "$ref": "#/components/responses/Unauthorized" }, + "403": { "$ref": "#/components/responses/Forbidden" }, + "404": { "$ref": "#/components/responses/NotFound" }, + "409": { "$ref": "#/components/responses/Conflict" }, + "413": { "$ref": "#/components/responses/PayloadTooLarge" }, + "500": { "$ref": "#/components/responses/InternalError" } + } + } + }, + "/api/account/features/exposure": { + "post": { + "operationId": "recordFeatureExposure", + "tags": ["Feature availability"], + "summary": "recordFeatureExposure", + "description": "Deprecated. Exposure measurement is not recorded through this route; it always returns 403.\n\nRequires a verified dashboard bearer and current organization authorization. Organization keys and project tokens are not accepted.", + "x-audience": "console", + "x-polymorfa-resource-boundary": "human_only", + "x-excluded-from-public": true, + "x-exclusion-reason": "Private measurement receipt; requires the issuing dashboard identity and consent.", + "security": [{ "ConsoleSession": [] }], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": false, + "properties": { + "token": { "type": "string", "maxLength": 4096 }, + "measurementAllowed": { + "type": "boolean", + "enum": [true] + } + }, + "required": ["token", "measurementAllowed"] + } + } + } + }, + "responses": { + "200": { + "description": "Success", + "headers": { + "X-Request-Id": { + "schema": { "type": "string" }, + "description": "Stable request correlation identifier." + }, + "X-Trace-Id": { + "schema": { "type": "string", "pattern": "^[0-9a-f]{32}$" }, + "description": "W3C trace ID of this request. Your `traceparent` trace ID when you sent a valid one." + }, + "Polymorfa-Version": { + "schema": { "type": "string" }, + "description": "API version applied to this response." + } + }, + "content": { + "application/json": { + "schema": { + "type": "object", + "required": ["success"], + "properties": { + "success": { "type": "boolean", "enum": [true] } + } + } + } + } + }, + "400": { "$ref": "#/components/responses/BadRequest" }, + "401": { "$ref": "#/components/responses/Unauthorized" }, + "403": { "$ref": "#/components/responses/Forbidden" }, + "413": { "$ref": "#/components/responses/PayloadTooLarge" }, + "500": { "$ref": "#/components/responses/InternalError" } + } + } + } + }, + "components": { + "responses": { + "InternalError": { + "description": "The session-control publisher or testing-number persistence path is temporarily unavailable.", + "headers": { + "X-Request-Id": { + "schema": { "type": "string" }, + "description": "Stable request correlation identifier." + }, + "X-Trace-Id": { + "schema": { "type": "string", "pattern": "^[0-9a-f]{32}$" }, + "description": "W3C trace ID of this request. Your `traceparent` trace ID when you sent a valid one." + }, + "Polymorfa-Version": { + "schema": { "type": "string" }, + "description": "API version applied to this response." + } + }, + "content": { + "application/json": { + "schema": { "$ref": "#/components/schemas/ErrorEnvelope" } + } + } + }, + "PayloadTooLarge": { + "description": "Request body exceeds the endpoint limit.", + "headers": { + "X-Request-Id": { + "schema": { "type": "string" }, + "description": "Stable request correlation identifier." + }, + "X-Trace-Id": { + "schema": { "type": "string", "pattern": "^[0-9a-f]{32}$" }, + "description": "W3C trace ID of this request. Your `traceparent` trace ID when you sent a valid one." + }, + "Polymorfa-Version": { + "schema": { "type": "string" }, + "description": "API version applied to this response." + } + }, + "content": { + "application/json": { + "schema": { "$ref": "#/components/schemas/ErrorEnvelope" } + } + } + }, + "Forbidden": { + "description": "Authenticated but lacks the required scope/role/audience.", + "headers": { + "X-Request-Id": { + "schema": { "type": "string" }, + "description": "Stable request correlation identifier." + }, + "X-Trace-Id": { + "schema": { "type": "string", "pattern": "^[0-9a-f]{32}$" }, + "description": "W3C trace ID of this request. Your `traceparent` trace ID when you sent a valid one." + }, + "Polymorfa-Version": { + "schema": { "type": "string" }, + "description": "API version applied to this response." + } + }, + "content": { + "application/json": { + "schema": { "$ref": "#/components/schemas/ErrorEnvelope" } + } + } + }, + "Unauthorized": { + "description": "Missing/invalid credential for this audience.", + "headers": { + "X-Request-Id": { + "schema": { "type": "string" }, + "description": "Stable request correlation identifier." + }, + "X-Trace-Id": { + "schema": { "type": "string", "pattern": "^[0-9a-f]{32}$" }, + "description": "W3C trace ID of this request. Your `traceparent` trace ID when you sent a valid one." + }, + "Polymorfa-Version": { + "schema": { "type": "string" }, + "description": "API version applied to this response." + } + }, + "content": { + "application/json": { + "schema": { "$ref": "#/components/schemas/ErrorEnvelope" } + } + } + }, + "BadRequest": { + "description": "A path, query, or request-body value did not pass validation.", + "headers": { + "X-Request-Id": { + "schema": { "type": "string" }, + "description": "Stable request correlation identifier." + }, + "X-Trace-Id": { + "schema": { "type": "string", "pattern": "^[0-9a-f]{32}$" }, + "description": "W3C trace ID of this request. Your `traceparent` trace ID when you sent a valid one." + }, + "Polymorfa-Version": { + "schema": { "type": "string" }, + "description": "API version applied to this response." + } + }, + "content": { + "application/json": { + "schema": { "$ref": "#/components/schemas/ErrorEnvelope" } + } + } + }, + "Conflict": { + "description": "The request conflicts with the resource's current state.", + "headers": { + "X-Request-Id": { + "schema": { "type": "string" }, + "description": "Stable request correlation identifier." + }, + "X-Trace-Id": { + "schema": { "type": "string", "pattern": "^[0-9a-f]{32}$" }, + "description": "W3C trace ID of this request. Your `traceparent` trace ID when you sent a valid one." + }, + "Polymorfa-Version": { + "schema": { "type": "string" }, + "description": "API version applied to this response." + } + }, + "content": { + "application/json": { + "schema": { "$ref": "#/components/schemas/ErrorEnvelope" } + } + } + }, + "NotFound": { + "description": "The requested resource was not found inside the authenticated team or project scope.", + "headers": { + "X-Request-Id": { + "schema": { "type": "string" }, + "description": "Stable request correlation identifier." + }, + "X-Trace-Id": { + "schema": { "type": "string", "pattern": "^[0-9a-f]{32}$" }, + "description": "W3C trace ID of this request. Your `traceparent` trace ID when you sent a valid one." + }, + "Polymorfa-Version": { + "schema": { "type": "string" }, + "description": "API version applied to this response." + } + }, + "content": { + "application/json": { + "schema": { "$ref": "#/components/schemas/ErrorEnvelope" } + } + } + } + }, + "schemas": { + "ErrorEnvelope": { + "type": "object", + "required": ["error", "data", "docs"], + "properties": { + "error": { + "type": "object", + "required": ["type", "code", "message", "param", "request_id"], + "properties": { + "type": { + "type": "string", + "enum": [ + "invalid_request_error", + "authentication_error", + "permission_error", + "conflict_error", + "rate_limit_error", + "api_error", + "upstream_error" + ] + }, + "code": { + "type": "string", + "description": "Stable failure code. Use this value for programmatic handling." + }, + "message": { + "type": "string", + "description": "Safe explanation of the failure." + }, + "param": { + "type": "string", + "nullable": true, + "description": "Invalid request field, or null when no field applies." + }, + "request_id": { + "type": "string", + "description": "Matches the X-Request-Id response header. Include it when you contact support." + }, + "request_log_url": { + "type": "string", + "format": "uri", + "description": "Console page that shows this request. Returned only to organization and project credentials whose request was logged." + } + } + }, + "data": { + "type": "string", + "nullable": true, + "enum": [null], + "description": "Always null for a failed request." + }, + "docs": { + "type": "string", + "format": "uri", + "description": "Documentation link to this failure code." + } + } + } + } + } + } + }, + "operations": [ + { + "family": "messaging", + "method": "POST", + "path": "/messaging/features/evaluate", + "operationId": "evaluateFeatures", + "fingerprint": "06bfd46fac5a92bd17a3e4a554fbb41b19d0d2f119bcf7b04dcd46635ad0f12a", + "typescript": { + "status": "excluded", + "reason": "Host-runtime feature evaluation is not a customer messaging operation in this SDK." + } + }, + { + "family": "platform", + "method": "POST", + "path": "/api/account/features/enrollment", + "operationId": "setFeatureEnrollment", + "fingerprint": "133a1b23185bf582331a499556a4a7d9b5b83aa6c8770d020b144c646594c1c4", + "typescript": { + "status": "excluded", + "reason": "Dashboard identity and account controls require a live browser account session; not part of the machine SDK." + } + }, + { + "family": "platform", + "method": "POST", + "path": "/api/account/features/evaluate", + "operationId": "evaluateAccountFeatures", + "fingerprint": "63da15e03f7c0f9f9277b82cc4db8ab7b5204f305cc2995f15cb377cc5f8cf39", + "typescript": { + "status": "excluded", + "reason": "Dashboard identity and account controls require a live browser account session; not part of the machine SDK." + } + }, + { + "family": "platform", + "method": "POST", + "path": "/api/account/features/exposure", + "operationId": "recordFeatureExposure", + "fingerprint": "484abe6e496a9f2eb8776b749cc1c0b2b9b5f0aa54fb1d8d87f5fc9d7caa262f", + "typescript": { + "status": "excluded", + "reason": "Dashboard identity and account controls require a live browser account session; not part of the machine SDK." + } + } + ] +} diff --git a/scripts/sync-feature-evaluation-contract.mjs b/scripts/sync-feature-evaluation-contract.mjs new file mode 100644 index 00000000..53dee2a1 --- /dev/null +++ b/scripts/sync-feature-evaluation-contract.mjs @@ -0,0 +1,128 @@ +import { execFileSync } from "node:child_process"; +import { createHash } from "node:crypto"; +import { readFileSync, writeFileSync } from "node:fs"; +import { format } from "prettier"; +import { extractOperations } from "./check-coverage.mjs"; + +const [repository, revision] = process.argv.slice(2); +if (!repository || !/^[0-9a-f]{40}$/.test(revision ?? "")) + throw new Error( + "Usage: sync-feature-evaluation-contract ", + ); + +const families = { + messaging: { + sourcePath: "apps/api/docs/openapi.json", + operationIds: ["evaluateFeatures"], + }, + platform: { + sourcePath: "apps/api/docs/openapi.management.json", + operationIds: [ + "evaluateAccountFeatures", + "setFeatureEnrollment", + "recordFeatureExposure", + ], + }, +}; +const methods = new Set(["get", "post", "put", "patch", "delete"]); +const ledger = JSON.parse( + readFileSync(new URL("../contracts/coverage.json", import.meta.url), "utf8"), +); + +const sources = {}; +const operations = []; +for (const [family, { sourcePath, operationIds }] of Object.entries(families)) { + const bytes = execFileSync( + "git", + ["-C", repository, "show", `${revision}:${sourcePath}`], + { maxBuffer: 32 * 1024 * 1024 }, + ); + const source = JSON.parse(bytes); + const wanted = new Set(operationIds); + const paths = {}; + for (const [path, item] of Object.entries(source.paths)) { + for (const [method, operation] of Object.entries(item)) { + if (!methods.has(method) || !wanted.has(operation?.operationId)) continue; + paths[path] ??= Object.fromEntries( + Object.entries(item).filter(([key]) => !methods.has(key)), + ); + paths[path][method] = operation; + wanted.delete(operation.operationId); + } + } + if (wanted.size) + throw new Error(`Missing ${family} operations: ${[...wanted].join(", ")}`); + + const components = {}; + const pending = [paths]; + while (pending.length) { + const value = pending.pop(); + if (Array.isArray(value)) pending.push(...value); + else if (value && typeof value === "object") { + for (const [key, child] of Object.entries(value)) { + if ( + key === "$ref" && + typeof child === "string" && + child.startsWith("#/components/") + ) { + const [, , category, name] = child.split("/"); + components[category] ??= {}; + if (!components[category][name]) { + const resolved = source.components?.[category]?.[name]; + if (!resolved) + throw new Error(`Unresolved source reference ${child}`); + components[category][name] = resolved; + pending.push(resolved); + } + } else pending.push(child); + } + } + } + + // Fingerprints come from the full source file; the test proves the stored + // subset reproduces them. + const subset = { paths, components }; + const extracted = new Map( + extractOperations(family, subset).map((operation) => [ + operation.operationId, + operation, + ]), + ); + for (const operation of extractOperations(family, source)) { + if (!operationIds.includes(operation.operationId)) continue; + if ( + extracted.get(operation.operationId)?.fingerprint !== + operation.fingerprint + ) + throw new Error(`Incomplete extraction for ${operation.operationId}`); + const row = ledger.operations.find( + (entry) => + entry.family === family && + entry.method === operation.method && + entry.path === operation.path, + ); + if (!row) throw new Error(`No ledger row for ${operation.operationId}`); + operations.push({ ...operation, typescript: row.typescript }); + } + sources[family] = { + sourcePath, + sourceSha256: createHash("sha256").update(bytes).digest("hex"), + paths, + components, + }; +} + +const contract = { + repository: "polymorfa/polymorfa", + commit: revision, + branch: "dev", + note: "Feature availability operations from API dev. The full snapshots and coverage.json stay pinned to contracts/source.json until the next repin.", + extraction: + "Feature availability operations and their transitive component references; operation objects unchanged", + sources, + operations, +}; +writeFileSync( + new URL("../contracts/feature-evaluation.json", import.meta.url), + await format(JSON.stringify(contract), { parser: "json" }), +); From 762469f1a6b63b247206022873b65e8eaf10eaec Mon Sep 17 00:00:00 2001 From: Rajeh Taher Date: Sun, 11 Oct 2026 04:31:50 +0300 Subject: [PATCH 2/4] Check the feature availability supplement and keep its routes excluded --- contracts/README.md | 30 +++ .../test/feature-evaluation-contract.test.ts | 198 ++++++++++++++++++ 2 files changed, 228 insertions(+) create mode 100644 packages/typescript/test/feature-evaluation-contract.test.ts diff --git a/contracts/README.md b/contracts/README.md index 0201ff85..2f6f559a 100644 --- a/contracts/README.md +++ b/contracts/README.md @@ -609,3 +609,33 @@ schemas, and the related Messaging and Platform campaign and analytics fields, from API `dev` merge commit `8d248b578a7c76034b30a5a97349adcd15ce5c55` (polymorfa/polymorfa#406), with both source paths and file hashes. `campaign-ab-tests-contract.test.ts` checks the SDK types against it. + +## Feature evaluation supplement + +`feature-evaluation.json` records the four feature availability operations and +their referenced components from API `dev` merge commit +`d36e12ff05f9f3537f3f87d8ad27258abc9b02df` (polymorfa/polymorfa#561), with both +source paths and file hashes. `scripts/sync-feature-evaluation-contract.mjs` +extracts it without changing the operation objects. Run it with a monorepo +checkout and an exact commit to refresh the supplement. + +PR #561 changes two fingerprints. `evaluateFeatures` and +`evaluateAccountFeatures` accept an optional `locale` (`en` or `pt-BR`), and +each returned feature adds `state` (`off`, `opt_in` or `on`), `terms` and +`enrollmentSource` (`customer`, `staff` or null). `exposureToken` is always +null. `measurementAllowed` is still accepted and has no effect. The enrollment +and exposure routes change only in their descriptions; the exposure route now +always returns 403. + +All four operations stay excluded, with the reasons in `coverage.json`, and no +SDK package calls them. `evaluateFeatures` accepts team and project +credentials; callers use an authenticated request. The three account routes +require dashboard identity. `feature-evaluation-contract.test.ts` checks that +the stored operations reproduce the recorded fingerprints with +`check-coverage.mjs`, and that the ledger and SDK sources keep the exclusions. + +The full snapshots and `coverage.json` stay pinned to `contracts/source.json`. +At the next repin, these four rows change as recorded here, together with +unrelated shared changes such as the public error enum. No SDK method or type +depends on this supplement, so `scripts/release-gate.mjs` does not hold +publication for it. diff --git a/packages/typescript/test/feature-evaluation-contract.test.ts b/packages/typescript/test/feature-evaluation-contract.test.ts new file mode 100644 index 00000000..65210eef --- /dev/null +++ b/packages/typescript/test/feature-evaluation-contract.test.ts @@ -0,0 +1,198 @@ +import { spawnSync } from "node:child_process"; +import { mkdtempSync, readFileSync, readdirSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { fileURLToPath } from "node:url"; + +import { describe, expect, it } from "vitest"; + +type Coverage = { status: string; method?: string; reason?: string }; +type Row = { + family: "messaging" | "platform"; + method: string; + path: string; + operationId: string; + fingerprint: string; + typescript: Coverage; +}; +type Schema = { + type?: string; + nullable?: boolean; + enum?: unknown[]; + default?: unknown; + required?: string[]; + properties?: Record; + items?: Schema; +}; +type Operation = { + security: unknown; + requestBody: { content: { "application/json": { schema: Schema } } }; + responses: Record< + string, + { content?: { "application/json": { schema: Schema } } } + >; +}; +type Source = { + sourcePath: string; + sourceSha256: string; + paths: Record>; + components: Record>; +}; + +const repositoryFile = (path: string) => + fileURLToPath(new URL(`../../../${path}`, import.meta.url)); +const contract = JSON.parse( + readFileSync(repositoryFile("contracts/feature-evaluation.json"), "utf8"), +) as { + commit: string; + branch: string; + sources: Record<"messaging" | "platform", Source>; + operations: Row[]; +}; +const ledger = JSON.parse( + readFileSync(repositoryFile("contracts/coverage.json"), "utf8"), +) as { operations: Row[] }; +const { messaging, platform } = contract.sources; +const operation = (source: Source, path: string) => source.paths[path]!.post!; + +function itemSchema(source: Source, path: string) { + const data = operation(source, path).responses["200"]!.content![ + "application/json" + ].schema.properties!.data!; + return data.items!; +} + +describe("feature evaluation contract supplement", () => { + it("pins the merged API source for both audiences", () => { + expect(contract.commit).toMatch(/^[0-9a-f]{40}$/); + expect(contract.branch).toBe("dev"); + expect(messaging.sourcePath).toBe("apps/api/docs/openapi.json"); + expect(platform.sourcePath).toBe("apps/api/docs/openapi.management.json"); + for (const source of [messaging, platform]) + expect(source.sourceSha256).toMatch(/^[0-9a-f]{64}$/); + }); + + it("reproduces the recorded fingerprints from the stored operations", () => { + const directory = mkdtempSync(join(tmpdir(), "polymorfa-features-")); + const file = (name: string, value: unknown) => { + const path = join(directory, name); + writeFileSync(path, JSON.stringify(value)); + return path; + }; + const report = join(directory, "report.json"); + const result = spawnSync( + process.execPath, + [ + repositoryFile("scripts/check-coverage.mjs"), + "--messaging", + file("messaging.json", messaging), + "--platform", + file("platform.json", platform), + "--ledger", + file("ledger.json", { + schemaVersion: 1, + sourceCommit: contract.commit, + operations: contract.operations, + }), + "--report", + report, + "--strict", + ], + { encoding: "utf8" }, + ); + expect(result.stderr).toBe(""); + expect(result.status).toBe(0); + expect(JSON.parse(readFileSync(report, "utf8"))).toMatchObject({ + total: 4, + excluded: 4, + changed: 0, + missing: 0, + gaps: [], + resolutions: [], + }); + }); + + it("records the reviewed request and response fields", () => { + for (const [source, path] of [ + [messaging, "/messaging/features/evaluate"], + [platform, "/api/account/features/evaluate"], + ] as const) { + const request = operation(source, path).requestBody.content[ + "application/json" + ].schema; + expect(request.properties!.locale).toMatchObject({ + type: "string", + enum: ["en", "pt-BR"], + default: "en", + }); + // Still accepted for compatibility; the API ignores it. + expect(request.properties!.measurementAllowed).toMatchObject({ + type: "boolean", + }); + + const item = itemSchema(source, path); + expect(item.required).toEqual( + expect.arrayContaining([ + "state", + "terms", + "enrollmentSource", + "exposureToken", + ]), + ); + expect(item.properties!.state!.enum).toEqual(["off", "opt_in", "on"]); + expect(item.properties!.terms).toMatchObject({ + type: "string", + nullable: true, + }); + expect(item.properties!.enrollmentSource).toMatchObject({ + nullable: true, + enum: ["customer", "staff", null], + }); + // Deprecated and always null. + expect(item.properties!.exposureToken!.nullable).toBe(true); + } + }); + + it("keeps every feature availability operation excluded", () => { + expect(contract.operations.map((row) => row.operationId).sort()).toEqual([ + "evaluateAccountFeatures", + "evaluateFeatures", + "recordFeatureExposure", + "setFeatureEnrollment", + ]); + for (const row of contract.operations) { + const matches = ledger.operations.filter( + (entry) => + entry.family === row.family && + entry.method === row.method && + entry.path === row.path && + entry.operationId === row.operationId, + ); + expect(matches, row.operationId).toHaveLength(1); + expect(row.typescript.status, row.operationId).toBe("excluded"); + expect(row.typescript.method, row.operationId).toBeUndefined(); + expect(matches[0]!.typescript, row.operationId).toEqual(row.typescript); + } + // Team and project credentials can call evaluation; the account routes + // need dashboard identity. + expect( + operation(messaging, "/messaging/features/evaluate").security, + ).toEqual([{ BearerAuth: [] }]); + for (const path of Object.keys(platform.paths)) + expect(operation(platform, path).security, path).toEqual([ + { ConsoleSession: [] }, + ]); + }); + + it("has no typed SDK method for these routes", () => { + const packages = repositoryFile("packages"); + const files = readdirSync(packages, { recursive: true, encoding: "utf8" }) + .filter((path) => /^[^/]+\/src\/.*\.tsx?$/.test(path)) + .map((path) => join(packages, path)); + expect(files.length).toBeGreaterThan(0); + for (const file of files) + expect(readFileSync(file, "utf8"), file).not.toMatch( + /features\/(?:evaluate|enrollment|exposure)/, + ); + }); +}); From 08023c74e2b9cf3ac1922c64ee49d8216033686c Mon Sep 17 00:00:00 2001 From: Rajeh Taher Date: Fri, 16 Oct 2026 09:43:35 +0300 Subject: [PATCH 3/4] Document scoped feature evaluation contract pin after main refresh --- contracts/README.md | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/contracts/README.md b/contracts/README.md index d674b284..d65d27db 100644 --- a/contracts/README.md +++ b/contracts/README.md @@ -619,6 +619,13 @@ source paths and file hashes. `scripts/sync-feature-evaluation-contract.mjs` extracts it without changing the operation objects. Run it with a monorepo checkout and an exact commit to refresh the supplement. +The October 16 reconciliation retains this pin. All four stored operation +objects are unchanged at API dev `1c26b4a888`, while both full spec hashes have +changed. A full repin would also require reviewing unrelated contract changes. +SDK PR #349 already owns the campaign follow-up supplement, and the open Calls, +interactive-message, campaign pause and native SDK PRs own their respective +changes. This supplement adds no runtime consumer and leaves those pins intact. + PR #561 changes two fingerprints. `evaluateFeatures` and `evaluateAccountFeatures` accept an optional `locale` (`en` or `pt-BR`), and each returned feature adds `state` (`off`, `opt_in` or `on`), `terms` and From e795c07cd654b095119dda54b387110621f4ca77 Mon Sep 17 00:00:00 2001 From: Rajeh Taher Date: Thu, 22 Oct 2026 01:48:06 +0300 Subject: [PATCH 4/4] Record the October 22 feature evaluation pin reconciliation against API dev e95d02da67 --- contracts/README.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/contracts/README.md b/contracts/README.md index 38d550dd..4c577b66 100644 --- a/contracts/README.md +++ b/contracts/README.md @@ -635,6 +635,12 @@ SDK PR #349 already owns the campaign follow-up supplement, and the open Calls, interactive-message, campaign pause and native SDK PRs own their respective changes. This supplement adds no runtime consumer and leaves those pins intact. +The October 22 reconciliation against API dev `e95d02da674da8242453c2a41efd48883fa04734` +also retains it. The stored `evaluateFeatures` operation is identical there. +The three account routes change only description wording. The resolved `evaluateFeatures` fingerprint changes only because the shared +`PublicError` code enum gained unrelated codes. Those belong to the next full +repin, not this supplement. + PR #561 changes two fingerprints. `evaluateFeatures` and `evaluateAccountFeatures` accept an optional `locale` (`en` or `pt-BR`), and each returned feature adds `state` (`off`, `opt_in` or `on`), `terms` and