From 88540da186cee33f2e27d720666ce089081d75eb Mon Sep 17 00:00:00 2001 From: Andrei Hasna Date: Mon, 6 Jul 2026 16:39:04 +0300 Subject: [PATCH] feat: add provider live-mode safety standard --- README.md | 4 + docs/PROVIDER-LIVE-MODE-STANDARD.md | 90 +++++++ .../provider-live-mode-standard.invalid.json | 56 +++++ .../provider-live-mode-standard.valid.json | 235 ++++++++++++++++++ package.json | 1 + src/schemas.ts | 216 ++++++++++++++++ tests/examples.test.ts | 12 + tests/schemas.test.ts | 64 +++++ 8 files changed, 678 insertions(+) create mode 100644 docs/PROVIDER-LIVE-MODE-STANDARD.md create mode 100644 examples/provider-live-mode-standard.invalid.json create mode 100644 examples/provider-live-mode-standard.valid.json diff --git a/README.md b/README.md index 0051949..bc686d1 100644 --- a/README.md +++ b/README.md @@ -247,6 +247,10 @@ defaults are applied; output aliases such as `EvidenceRef` describe parsed data. account, basis, and resource references. - `hasna.capability_card.v1`: machine-readable description of a package command, MCP tool, API operation, workflow, or agent skill. +- `hasna.provider_live_mode_standard.v1`: provider/live-mode standard with + canonical modes, capability cards, fail-closed credentials, no-side-effect + smokes, approval/idempotency/rollback/reconciliation gates, and first adopter + targets. - `hasna.context_pack.v1`: bounded context bundle with objective, resources, evidence, constraints, and token budget. - `hasna.integration_ref.v1`: portable pointer to a project integration provider diff --git a/docs/PROVIDER-LIVE-MODE-STANDARD.md b/docs/PROVIDER-LIVE-MODE-STANDARD.md new file mode 100644 index 0000000..273ef03 --- /dev/null +++ b/docs/PROVIDER-LIVE-MODE-STANDARD.md @@ -0,0 +1,90 @@ +# Provider Live-Mode Safety Standard + +Date: 2026-07-06 + +This standard applies to provider adapters in built or partially built `open-*` +and `iapp-*` apps. It excludes platform apps, empty apps, README-only apps, +scaffold-only apps, stale clone-only roots, and license-only repos. + +Provider work must default to no side effects. Live mutation is allowed only +after sandbox proof, explicit operator approval, credential-reference checks, +idempotency, rollback or revocation evidence, and reconciliation evidence are +present. + +## Canonical Provider Modes + +- `mock`: deterministic fake implementation. No provider credentials and no + provider calls. +- `fixture`: recorded or local test data. No provider calls. +- `sandbox`: provider sandbox or test account. No production user, domain, DNS, + money, call, message, or filing side effects. +- `read_only_live`: live provider reads only. No mutation endpoints or outbound + delivery paths. +- `live_mutating`: live provider side effects. Requires all gates below. + +Apps must expose provider mode in CLI JSON, MCP output, API response metadata, +health/readiness, audit/provenance, and operator evidence without printing +secret values. + +## Required Capability Card + +Every provider adapter must publish a `ProviderCapabilityCard` with: + +- provider id, app id, adapter id, and owner package +- supported modes and default mode +- credential refs or lease refs required per mode +- operation cards with side-effect class and supported modes +- rate-limit and cost posture +- audit event names +- redaction rules +- evidence refs for validation, sandbox proof, and no-side-effect smokes + +Raw provider secrets are not accepted in CLI args, MCP args, reports, logs, task +comments, committed config, or package examples. Missing or revoked credentials +must fail closed with a typed diagnostic. + +## Live Mutation Gate + +An operation may run in `live_mutating` only when all checks pass: + +- requested mode is exactly `live_mutating` +- provider capability card allows the operation in `live_mutating` +- required credential refs or leases resolve and revocation checks pass +- approval record is approved, unexpired, and linked to the operation digest +- idempotency key is present +- sandbox evidence predates live execution +- rollback or revocation path is recorded +- reconciliation target is recorded + +Environment flags alone must never enable live mutation. Fallback from live to +fixture/mock is forbidden for production commands because it can hide missing +authority or stale provider proof. + +## Validation Gates + +At minimum, adopters must run: + +- native repo verification: `bun run verify` where available, otherwise + `bun run typecheck`, `bun test`, and `bun run build` +- provider conformance suite with no real side effects +- disabled-live smoke proving env flags alone cannot bypass gates +- secret-output scan over CLI, MCP, HTTP logs, reports, errors, and task evidence +- webhook/signature/replay fixtures where inbound providers are present + +Live proof comes after sandbox proof. Live mutation additionally requires +operator approval and rollback or disable evidence. + +## First Adoption Targets + +- `open-mailery`: canonical `open-mailery`/`open-emails` boundary, local versus + self-hosted versus cloud auth model, Postgres/S3/SES readiness, signed + webhooks, and no-send/domain-change smokes. +- `open-telephony`: REST auth, Twilio signature and replay validation, toll + fraud controls, durable queues, retention, and opt-in sandbox/live provider + smokes. +- `open-feedback`: public submit separated from private read/export/triage, + scoped auth, rate/spam limits, durable store, dedupe, and signed forwarding + webhooks. + +The machine-readable fixture is +`examples/provider-live-mode-standard.valid.json`. diff --git a/examples/provider-live-mode-standard.invalid.json b/examples/provider-live-mode-standard.invalid.json new file mode 100644 index 0000000..5f1d317 --- /dev/null +++ b/examples/provider-live-mode-standard.invalid.json @@ -0,0 +1,56 @@ +{ + "schema": "hasna.provider_live_mode_standard.v1", + "id": "provider_live_mode_standard_invalid", + "createdAt": "2026-07-06T13:12:43.989Z", + "name": "Unsafe provider live-mode standard", + "version": "invalid", + "modes": ["fixture", "live_mutating"], + "requiredCapabilityFields": ["providerId"], + "liveMutationGate": { + "requiredMode": "live_mutating", + "requiredChecks": ["env flag is present"], + "forbiddenBypassSignals": ["missing approval id"], + "disabledLiveSmoke": "Not enough." + }, + "noSideEffectSmoke": { + "requiredForModes": ["fixture"], + "commandEvidence": ["fixture command"] + }, + "credentialPolicy": { + "acceptedInputs": ["credential_ref"], + "rawSecretInputsAllowed": false, + "missingCredentialBehavior": "fail_closed" + }, + "operationCards": [ + { + "providerId": "twilio", + "appId": "open-telephony", + "adapterId": "telephony-twilio", + "ownerPackage": "@hasna/telephony", + "modes": ["fixture", "live_mutating"], + "defaultMode": "fixture", + "credentialRequirements": [], + "operations": [ + { + "operation": "send_sms", + "supportedModes": ["live_mutating"], + "sideEffectClass": "read_only", + "requiresApproval": false, + "requiresIdempotencyKey": false, + "requiresSandboxEvidence": false, + "requiresRollbackOrRevocation": false + } + ], + "rateLimitPosture": "none" + } + ], + "firstAdoptionTargets": [ + { + "appId": "open-mailery", + "repo": "/home/hasna/workspace/hasna/opensource/open-mailery", + "priority": "p0", + "requiredEvidence": ["no-send smoke"], + "firstOperations": ["send_email"] + } + ] +} diff --git a/examples/provider-live-mode-standard.valid.json b/examples/provider-live-mode-standard.valid.json new file mode 100644 index 0000000..bdceeb8 --- /dev/null +++ b/examples/provider-live-mode-standard.valid.json @@ -0,0 +1,235 @@ +{ + "schema": "hasna.provider_live_mode_standard.v1", + "id": "provider_live_mode_standard_2026_07", + "createdAt": "2026-07-06T13:12:43.989Z", + "name": "Hasna provider/live-mode safety standard", + "version": "2026-07-06", + "modes": ["mock", "fixture", "sandbox", "read_only_live", "live_mutating"], + "requiredCapabilityFields": [ + "providerId", + "appId", + "adapterId", + "ownerPackage", + "modes", + "defaultMode", + "credentialRequirements", + "operations", + "rateLimitPosture", + "auditEvents", + "redactionRules" + ], + "liveMutationGate": { + "requiredMode": "live_mutating", + "requiredChecks": [ + "capability card allows the operation", + "required credential refs or leases resolve", + "operator approval record is approved and unexpired", + "idempotency key is present", + "sandbox evidence predates live execution", + "rollback or revocation path is recorded", + "reconciliation target is recorded" + ], + "forbiddenBypassSignals": [ + "env flag alone", + "raw provider secret", + "missing approval id", + "missing idempotency key", + "implicit fallback from live to fixture" + ], + "disabledLiveSmoke": "Run a live-disabled smoke proving provider mutation remains blocked even when provider-looking env vars are present." + }, + "noSideEffectSmoke": { + "requiredForModes": ["mock", "fixture", "sandbox", "read_only_live"], + "commandEvidence": [ + "CLI JSON output includes providerMode", + "MCP/API metadata includes providerMode", + "provider fixture proves no outbound send/call/domain/DNS/write happened" + ], + "secretOutputScan": true + }, + "credentialPolicy": { + "acceptedInputs": ["credential_ref", "lease_ref"], + "rawSecretInputsAllowed": false, + "missingCredentialBehavior": "fail_closed", + "revocationCheckRequired": true + }, + "operationCards": [ + { + "providerId": "ses", + "appId": "open-mailery", + "adapterId": "mailery-ses-outbound", + "ownerPackage": "@hasna/mailery", + "modes": ["fixture", "sandbox", "read_only_live", "live_mutating"], + "defaultMode": "fixture", + "credentialRequirements": [ + { + "refName": "mailery.ses.send", + "requiredForModes": ["sandbox", "read_only_live", "live_mutating"], + "allowedSecretInputs": ["credential_ref", "lease_ref"], + "failClosedDiagnostic": "SES credential ref is required for provider health, sandbox, or live send.", + "revocationCheck": true + } + ], + "operations": [ + { + "operation": "send_email", + "supportedModes": ["fixture", "sandbox", "live_mutating"], + "sideEffectClass": "external_notification", + "requiresApproval": true, + "requiresIdempotencyKey": true, + "requiresSandboxEvidence": true, + "requiresRollbackOrRevocation": true, + "rollbackOrRevocation": "Disable provider credential lease and reconcile bounce/complaint or suppress recipient after send.", + "noSideEffectSmoke": "Fixture and sandbox send smokes must prove no production recipient delivery.", + "reconciliation": "Delivery event, bounce, complaint, and suppression records reconcile to message id." + }, + { + "operation": "domain_readiness_check", + "supportedModes": ["fixture", "sandbox", "read_only_live"], + "sideEffectClass": "read_only", + "requiresApproval": false, + "requiresIdempotencyKey": false, + "requiresSandboxEvidence": false, + "requiresRollbackOrRevocation": false, + "noSideEffectSmoke": "DNS/domain readiness command must not change MX, DKIM, SPF, DMARC, route, or purchase state." + } + ], + "rateLimitPosture": "Provider send limits and domain verification limits must be reported before enabling live mutation.", + "costPosture": "Live sends can incur provider delivery and storage costs.", + "auditEvents": ["provider.preflight", "provider.approval", "provider.execution", "provider.reconciliation"], + "redactionRules": ["recipient addresses are redacted in diagnostics", "credential refs are displayed without secret values"] + }, + { + "providerId": "twilio", + "appId": "open-telephony", + "adapterId": "telephony-twilio", + "ownerPackage": "@hasna/telephony", + "modes": ["fixture", "sandbox", "read_only_live", "live_mutating"], + "defaultMode": "fixture", + "credentialRequirements": [ + { + "refName": "telephony.twilio.account", + "requiredForModes": ["sandbox", "read_only_live", "live_mutating"], + "allowedSecretInputs": ["credential_ref", "lease_ref"], + "failClosedDiagnostic": "Twilio credential ref is required for webhook validation, sandbox, or live calls/messages.", + "revocationCheck": true + } + ], + "operations": [ + { + "operation": "send_sms_or_call", + "supportedModes": ["fixture", "sandbox", "live_mutating"], + "sideEffectClass": "bulk_message_or_call", + "requiresApproval": true, + "requiresIdempotencyKey": true, + "requiresSandboxEvidence": true, + "requiresRollbackOrRevocation": true, + "rollbackOrRevocation": "Emergency disable provider lease, block destination, stop queues, and reconcile provider status callbacks.", + "noSideEffectSmoke": "Fixture and sandbox smokes must prove no production SMS, WhatsApp, or voice call.", + "reconciliation": "Outbound job status reconciles with Twilio message/call/status callback identifiers." + }, + { + "operation": "verify_inbound_webhook", + "supportedModes": ["fixture", "sandbox", "read_only_live"], + "sideEffectClass": "read_only", + "requiresApproval": false, + "requiresIdempotencyKey": false, + "requiresSandboxEvidence": false, + "requiresRollbackOrRevocation": false, + "noSideEffectSmoke": "Webhook fixture verifies signature, timestamp freshness, replay nonce, and expected URL without placing calls." + } + ], + "rateLimitPosture": "Per-agent quotas, destination allow or deny rules, spend caps, and emergency disable are required.", + "costPosture": "Live messages, calls, number provisioning, and media can incur provider costs.", + "auditEvents": ["provider.preflight", "provider.approval", "provider.execution", "provider.reconciliation"], + "redactionRules": ["phone numbers are masked", "provider SIDs and webhook auth data are redacted"] + }, + { + "providerId": "feedback-webhook", + "appId": "open-feedback", + "adapterId": "feedback-forwarder", + "ownerPackage": "@hasna/feedback", + "modes": ["mock", "fixture", "sandbox", "live_mutating"], + "defaultMode": "fixture", + "credentialRequirements": [ + { + "refName": "feedback.webhook.signing", + "requiredForModes": ["sandbox", "live_mutating"], + "allowedSecretInputs": ["credential_ref", "lease_ref"], + "failClosedDiagnostic": "Webhook signing credential ref is required for sandbox or live forwarding.", + "revocationCheck": true + } + ], + "operations": [ + { + "operation": "forward_feedback", + "supportedModes": ["mock", "fixture", "sandbox", "live_mutating"], + "sideEffectClass": "external_notification", + "requiresApproval": true, + "requiresIdempotencyKey": true, + "requiresSandboxEvidence": true, + "requiresRollbackOrRevocation": true, + "rollbackOrRevocation": "Disable forwarding endpoint, dead-letter unconfirmed jobs, and revoke webhook signing lease.", + "noSideEffectSmoke": "Fixture forwarding writes only local delivery evidence and never calls external destinations.", + "reconciliation": "Forwarding job status reconciles to delivery receipt, retry, or dead-letter record." + } + ], + "rateLimitPosture": "Submission rate limits, app quotas, retry leases, and dead-letter caps are required.", + "auditEvents": ["provider.preflight", "provider.approval", "provider.execution", "provider.reconciliation"], + "redactionRules": ["PII fields are classified before export", "webhook signing secrets are never emitted"] + } + ], + "firstAdoptionTargets": [ + { + "appId": "open-mailery", + "repo": "/home/hasna/workspace/hasna/opensource/open-mailery", + "priority": "p0", + "requiredEvidence": [ + "canonical root and compatibility matrix", + "mode-specific health/readiness commands", + "no-send and no-domain-change smoke", + "signed webhook and replay fixtures" + ], + "firstOperations": ["send_email", "domain_readiness_check"], + "blockedUntil": ["canonical open-mailery/open-emails root decision is recorded"] + }, + { + "appId": "open-telephony", + "repo": "/home/hasna/workspace/hasna/opensource/open-telephony", + "priority": "p0", + "requiredEvidence": [ + "REST auth negative tests", + "Twilio signature and replay fixtures", + "quota and spend-cap fixtures", + "opt-in sandbox smoke" + ], + "firstOperations": ["send_sms_or_call", "verify_inbound_webhook"] + }, + { + "appId": "open-feedback", + "repo": "/home/hasna/workspace/hasna/opensource/open-feedback", + "priority": "p0", + "requiredEvidence": [ + "public submit versus private read/export auth matrix", + "rate-limit and spam fixtures", + "durable store and dedupe fixtures", + "signed webhook delivery fixture" + ], + "firstOperations": ["forward_feedback"] + } + ], + "evidenceRefs": [ + { + "id": "reviewer_11_provider_deploy", + "kind": "report", + "uri": "repo://reports/task-proposals/adversarial-12/reviewer-11-provider-deploy.md", + "summary": "Provider/deployment reviewer requirements for capability cards, live modes, approval, and reconciliation." + }, + { + "id": "reviewer_01_open_finance_comms", + "kind": "report", + "uri": "repo://reports/task-proposals/adversarial-12/reviewer-01-open-finance-comms.md", + "summary": "OSS finance/comms task proposals for mailery, telephony, and feedback first adopters." + } + ] +} diff --git a/package.json b/package.json index 975883c..dc63661 100644 --- a/package.json +++ b/package.json @@ -66,6 +66,7 @@ "files": [ "dist", "src/kit", + "docs", "examples", "CONTRACT.md", "docker-compose.yml", diff --git a/src/schemas.ts b/src/schemas.ts index 11a21ac..2a5e1ed 100644 --- a/src/schemas.ts +++ b/src/schemas.ts @@ -11,6 +11,7 @@ export const SCHEMA_IDS = { decisionEnvelope: "hasna.decision_envelope.v1", costEstimate: "hasna.cost_estimate.v1", capabilityCard: "hasna.capability_card.v1", + providerLiveModeStandard: "hasna.provider_live_mode_standard.v1", contextPack: "hasna.context_pack.v1", integrationRef: "hasna.integration_ref.v1", projectManifest: "hasna.project_manifest.v1", @@ -377,6 +378,217 @@ export const CapabilityCardSchema = contractBaseSchema(SCHEMA_IDS.capabilityCard .strict(); export type CapabilityCard = z.infer; +export const ProviderModeSchema = z.enum(["mock", "fixture", "sandbox", "read_only_live", "live_mutating"]); +export type ProviderMode = z.infer; + +export const ProviderSideEffectClassSchema = z.enum([ + "none", + "read_only", + "external_notification", + "external_mutation", + "money_movement", + "dns_or_domain_change", + "bulk_message_or_call", + "legal_or_filing", + "compute_or_infra_mutation", + "irreversible" +]); +export type ProviderSideEffectClass = z.infer; + +export const CredentialRequirementSchema = z + .object({ + refName: NonEmptyStringSchema, + requiredForModes: z.array(ProviderModeSchema).min(1), + allowedSecretInputs: z.array(z.enum(["credential_ref", "lease_ref"])).min(1).default(["credential_ref"]), + failClosedDiagnostic: NonEmptyStringSchema, + revocationCheck: z.boolean().default(true) + }) + .strict(); +export type CredentialRequirement = z.infer; + +export const ProviderOperationCardSchema = z + .object({ + operation: NonEmptyStringSchema, + supportedModes: z.array(ProviderModeSchema).min(1), + sideEffectClass: ProviderSideEffectClassSchema, + requiresApproval: z.boolean().default(false), + requiresIdempotencyKey: z.boolean().default(false), + requiresSandboxEvidence: z.boolean().default(false), + requiresRollbackOrRevocation: z.boolean().default(false), + rollbackOrRevocation: NonEmptyStringSchema.optional(), + noSideEffectSmoke: NonEmptyStringSchema.optional(), + reconciliation: NonEmptyStringSchema.optional() + }) + .strict() + .superRefine((value, ctx) => { + if (value.supportedModes.includes("live_mutating")) { + if (value.sideEffectClass === "none" || value.sideEffectClass === "read_only") { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: "live_mutating operations must declare a side-effecting class", + path: ["sideEffectClass"] + }); + } + if (!value.requiresApproval) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: "live_mutating operations require approval", + path: ["requiresApproval"] + }); + } + if (!value.requiresIdempotencyKey) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: "live_mutating operations require idempotency keys", + path: ["requiresIdempotencyKey"] + }); + } + if (!value.requiresSandboxEvidence) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: "live_mutating operations require sandbox evidence before live proof", + path: ["requiresSandboxEvidence"] + }); + } + if (!value.requiresRollbackOrRevocation || !value.rollbackOrRevocation) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: "live_mutating operations require rollback or revocation instructions", + path: ["rollbackOrRevocation"] + }); + } + if (!value.reconciliation) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: "live_mutating operations require reconciliation behavior", + path: ["reconciliation"] + }); + } + } + }); +export type ProviderOperationCard = z.infer; + +export const ProviderCapabilityCardSchema = z + .object({ + providerId: NonEmptyStringSchema, + appId: NonEmptyStringSchema, + adapterId: NonEmptyStringSchema, + ownerPackage: NonEmptyStringSchema, + modes: z.array(ProviderModeSchema).min(1), + defaultMode: ProviderModeSchema, + credentialRequirements: z.array(CredentialRequirementSchema).default([]), + operations: z.array(ProviderOperationCardSchema).min(1), + rateLimitPosture: NonEmptyStringSchema, + costPosture: NonEmptyStringSchema.optional(), + auditEvents: z.array(NonEmptyStringSchema).default([]), + redactionRules: z.array(NonEmptyStringSchema).default([]), + evidenceRefs: z.array(EvidencePointerSchema).default([]) + }) + .strict() + .superRefine((value, ctx) => { + if (!value.modes.includes(value.defaultMode)) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: "defaultMode must be one of modes", + path: ["defaultMode"] + }); + } + + const operationModes = new Set(value.operations.flatMap((operation) => operation.supportedModes)); + for (const mode of operationModes) { + if (!value.modes.includes(mode)) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: `operation mode ${mode} is not declared in provider modes`, + path: ["operations"] + }); + } + } + + if (operationModes.has("live_mutating")) { + const liveCredential = value.credentialRequirements.some((credential) => credential.requiredForModes.includes("live_mutating")); + if (!liveCredential) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: "live_mutating providers require at least one live credential reference requirement", + path: ["credentialRequirements"] + }); + } + if (value.auditEvents.length === 0) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: "live_mutating providers require audit events", + path: ["auditEvents"] + }); + } + } + }); +export type ProviderCapabilityCard = z.infer; + +export const ProviderLiveModeTargetSchema = z + .object({ + appId: NonEmptyStringSchema, + repo: NonEmptyStringSchema, + priority: z.enum(["p0", "p1", "p2"]).default("p1"), + requiredEvidence: z.array(NonEmptyStringSchema).min(1), + firstOperations: z.array(NonEmptyStringSchema).min(1), + blockedUntil: z.array(NonEmptyStringSchema).default([]) + }) + .strict(); +export type ProviderLiveModeTarget = z.infer; + +export const ProviderLiveModeStandardSchema = contractBaseSchema(SCHEMA_IDS.providerLiveModeStandard) + .extend({ + name: NonEmptyStringSchema, + version: NonEmptyStringSchema, + modes: z.array(ProviderModeSchema).refine( + (modes) => ["mock", "fixture", "sandbox", "read_only_live", "live_mutating"].every((mode) => modes.includes(mode as ProviderMode)), + "provider live-mode standard must include every canonical provider mode" + ), + requiredCapabilityFields: z.array(NonEmptyStringSchema).min(1), + liveMutationGate: z + .object({ + requiredMode: z.literal("live_mutating"), + requiredChecks: z.array(NonEmptyStringSchema).min(1), + forbiddenBypassSignals: z.array(NonEmptyStringSchema).min(1), + disabledLiveSmoke: NonEmptyStringSchema + }) + .strict(), + noSideEffectSmoke: z + .object({ + requiredForModes: z.array(ProviderModeSchema).min(1), + commandEvidence: z.array(NonEmptyStringSchema).min(1), + secretOutputScan: z.boolean().default(true) + }) + .strict(), + credentialPolicy: z + .object({ + acceptedInputs: z.array(z.enum(["credential_ref", "lease_ref"])).min(1), + rawSecretInputsAllowed: z.literal(false), + missingCredentialBehavior: z.literal("fail_closed"), + revocationCheckRequired: z.boolean().default(true) + }) + .strict(), + operationCards: z.array(ProviderCapabilityCardSchema).min(1), + firstAdoptionTargets: z.array(ProviderLiveModeTargetSchema).min(1), + evidenceRefs: z.array(EvidencePointerSchema).default([]) + }) + .strict() + .superRefine((value, ctx) => { + const firstTargetApps = new Set(value.firstAdoptionTargets.map((target) => target.appId)); + const operationApps = new Set(value.operationCards.map((card) => card.appId)); + for (const appId of firstTargetApps) { + if (!operationApps.has(appId)) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: `first adoption target ${appId} requires a provider capability card`, + path: ["firstAdoptionTargets"] + }); + } + } + }); +export type ProviderLiveModeStandard = z.infer; + export const ContextPackItemSchema = z .object({ id: z.string().min(1), @@ -1718,6 +1930,7 @@ export const ContractSchemaRegistry = { [SCHEMA_IDS.decisionEnvelope]: DecisionEnvelopeSchema, [SCHEMA_IDS.costEstimate]: CostEstimateSchema, [SCHEMA_IDS.capabilityCard]: CapabilityCardSchema, + [SCHEMA_IDS.providerLiveModeStandard]: ProviderLiveModeStandardSchema, [SCHEMA_IDS.contextPack]: ContextPackSchema, [SCHEMA_IDS.integrationRef]: IntegrationRefSchema, [SCHEMA_IDS.projectManifest]: ProjectManifestSchema, @@ -1744,6 +1957,7 @@ export type ContractBySchemaId = { [SCHEMA_IDS.decisionEnvelope]: DecisionEnvelope; [SCHEMA_IDS.costEstimate]: CostEstimate; [SCHEMA_IDS.capabilityCard]: CapabilityCard; + [SCHEMA_IDS.providerLiveModeStandard]: ProviderLiveModeStandard; [SCHEMA_IDS.contextPack]: ContextPack; [SCHEMA_IDS.integrationRef]: IntegrationRef; [SCHEMA_IDS.projectManifest]: ProjectManifest; @@ -1767,6 +1981,7 @@ export type WorkRunInput = z.input; export type DecisionEnvelopeInput = z.input; export type CostEstimateInput = z.input; export type CapabilityCardInput = z.input; +export type ProviderLiveModeStandardInput = z.input; export type ContextPackInput = z.input; export type IntegrationRefInput = z.input; export type ProjectManifestInput = z.input; @@ -1793,6 +2008,7 @@ export type ContractInputBySchemaId = { [SCHEMA_IDS.decisionEnvelope]: DecisionEnvelopeInput; [SCHEMA_IDS.costEstimate]: CostEstimateInput; [SCHEMA_IDS.capabilityCard]: CapabilityCardInput; + [SCHEMA_IDS.providerLiveModeStandard]: ProviderLiveModeStandardInput; [SCHEMA_IDS.contextPack]: ContextPackInput; [SCHEMA_IDS.integrationRef]: IntegrationRefInput; [SCHEMA_IDS.projectManifest]: ProjectManifestInput; diff --git a/tests/examples.test.ts b/tests/examples.test.ts index 6a6f1c2..283d926 100644 --- a/tests/examples.test.ts +++ b/tests/examples.test.ts @@ -8,6 +8,18 @@ const expectedInvalidIssuePaths: Record = { "app-cloud-manifest.invalid.json": ["cloudResources.0.ownerPackage", "dependencies", "forbiddenSharedRuntimes", "packageName"], "integration-ref.invalid.json": ["uri"], "no-cloud-evidence-pack.invalid.json": ["checks", "checks", "findings"], + "provider-live-mode-standard.invalid.json": [ + "modes", + "operationCards.0.credentialRequirements", + "operationCards.0.operations.0.requiresApproval", + "operationCards.0.operations.0.requiresIdempotencyKey", + "operationCards.0.operations.0.requiresSandboxEvidence", + "operationCards.0.operations.0.rollbackOrRevocation", + "operationCards.0.operations.0.reconciliation", + "operationCards.0.operations.0.sideEffectClass", + "operationCards.0.auditEvents", + "firstAdoptionTargets" + ], "project-manifest.invalid.json": ["slug"], "project-panel.invalid.json": ["stateReason"], "project-snapshot.invalid.json": ["panels.0.projectId"], diff --git a/tests/schemas.test.ts b/tests/schemas.test.ts index b616915..d3d3e18 100644 --- a/tests/schemas.test.ts +++ b/tests/schemas.test.ts @@ -9,6 +9,7 @@ import { NoCloudEvidencePackSchema, parseEmbeddedContract, parseContract, + ProviderLiveModeStandardSchema, ProjectManifestSchema, ProjectPanelSchema, ProjectSnapshotSchema, @@ -21,6 +22,7 @@ import { validateContract, WorkRunSchema } from "../src"; +import providerLiveModeStandard from "../examples/provider-live-mode-standard.valid.json"; const createdAt = "2026-06-27T10:00:00.000Z"; @@ -209,6 +211,68 @@ describe("core schemas", () => { expect(deniedWithSelected.success).toBe(false); }); + test("validates provider live-mode standard first targets", () => { + const parsed = ProviderLiveModeStandardSchema.parse(providerLiveModeStandard); + expect(parsed.modes).toEqual(["mock", "fixture", "sandbox", "read_only_live", "live_mutating"]); + expect(parsed.credentialPolicy.rawSecretInputsAllowed).toBe(false); + expect(parsed.credentialPolicy.missingCredentialBehavior).toBe("fail_closed"); + expect(parsed.firstAdoptionTargets.map((target) => target.appId).sort()).toEqual([ + "open-feedback", + "open-mailery", + "open-telephony" + ]); + }); + + test("rejects live provider mutation without approval, idempotency, sandbox evidence, and rollback", () => { + const result = ProviderLiveModeStandardSchema.safeParse({ + ...providerLiveModeStandard, + operationCards: [ + { + providerId: "twilio", + appId: "open-telephony", + adapterId: "telephony-twilio", + ownerPackage: "@hasna/telephony", + modes: ["fixture", "live_mutating"], + defaultMode: "fixture", + credentialRequirements: [], + operations: [ + { + operation: "send_sms", + supportedModes: ["live_mutating"], + sideEffectClass: "read_only", + requiresApproval: false, + requiresIdempotencyKey: false, + requiresSandboxEvidence: false, + requiresRollbackOrRevocation: false + } + ], + rateLimitPosture: "none" + } + ], + firstAdoptionTargets: [ + { + appId: "open-telephony", + repo: "/home/hasna/workspace/hasna/opensource/open-telephony", + priority: "p0", + requiredEvidence: ["webhook fixture"], + firstOperations: ["send_sms"] + } + ] + }); + + expect(result.success).toBe(false); + if (!result.success) { + const issuePaths = result.error.issues.map((issue) => issue.path.join(".")); + expect(issuePaths).toContain("operationCards.0.operations.0.sideEffectClass"); + expect(issuePaths).toContain("operationCards.0.operations.0.requiresApproval"); + expect(issuePaths).toContain("operationCards.0.operations.0.requiresIdempotencyKey"); + expect(issuePaths).toContain("operationCards.0.operations.0.requiresSandboxEvidence"); + expect(issuePaths).toContain("operationCards.0.operations.0.rollbackOrRevocation"); + expect(issuePaths).toContain("operationCards.0.operations.0.reconciliation"); + expect(issuePaths).toContain("operationCards.0.credentialRequirements"); + } + }); + test("validates a work run with nested evidence", () => { const workRun = WorkRunSchema.parse({ schema: SCHEMA_IDS.workRun,