Skip to content

Commit fdca3c0

Browse files
authored
fix: verify portable vendor capability contracts (#169)
## Outcome Make the already implemented portable-vendor deployments diagnosable and publish the missing Azure runtime+transport truth required by #132. ## Root cause `PostgresJobQueue` correctly reports `provider: postgresql`, but the CLI recovery schema accepted only `memory` and `aws-sqs`. As a result, `hayasend doctor` rejected valid diagnostics from Cloud Run, Render, Railway, Fly.io, Azure Container Apps, and Vercel. The doctor also compared capability digests only for AWS SES, leaving the bundled Azure ACS Email and SendGrid transports unverifiable. Finally, the Azure Container Apps + ACS implementation had separate runtime and provider documents but no combined deployment capability document or readiness-matrix entry. ## Changes - accept the portable PostgreSQL queue diagnostics contract in `hayasend doctor`; - add one registry for every bundled provider capability document and verify AWS SES, Azure ACS Email, Cloudflare Email, and SendGrid digests; - publish `azure-container-apps-acs.v1.json` with conservative effective limits and all #152 evidence gates still pending; - include the Azure combination in the generated readiness matrix and npm artifact gates; - add regression tests and update operator documentation. ## Safety and product boundary - no vendor resource, credential, email, or billable service was created; - no maturity was promoted and `production_ready` remains false; - unknown third-party transport extensions remain explicitly unverifiable rather than being assigned invented capability truth; - this advances #132 and #152 but does not close their hosted evidence gates. ## Validation - `npm run check` - `npm test` — 85 files passed, 573 tests passed; 4 files / 5 tests intentionally skipped - `npm run build` - `npm run check:conformance` - `npm run check:workers` — 38 Workers tests plus Wrangler dry-run - `npm run lint:openapi` - `npm run site:build && npm run site:verify` - `actionlint` - `shellcheck` across deployment scripts - Terraform 1.15.8 recursive format check - Render, Railway, Fly.io, and Vercel validators - `npm audit --audit-level=high` — 0 vulnerabilities - `npm outdated --depth=0 --json` — no direct updates - package smoke: 587 entries, 723,070 bytes compressed, 3,715,119 bytes unpacked; Azure deployment document and provider registry present Relates to #132 Relates to #152 Signed-off-by: Yusuke Hayashi <yusuke8h@gmail.com>
1 parent 4e6e949 commit fdca3c0

14 files changed

Lines changed: 407 additions & 15 deletions

.github/workflows/ci.yml

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -715,7 +715,7 @@ jobs:
715715
"$RUNNER_TEMP/npm-pack-metadata.json"
716716
)"
717717
jq --exit-status '
718-
.entryCount <= 580 and
718+
.entryCount <= 590 and
719719
.size < 740000 and
720720
.unpackedSize < 3800000 and
721721
([.files[].path] | index("dist/cli.js") != null) and
@@ -744,12 +744,16 @@ jobs:
744744
index("schemas/deployment-capabilities.v1.schema.json") != null) and
745745
([.files[].path] |
746746
index("conformance/deployments/aws-ses.v1.json") != null) and
747+
([.files[].path] |
748+
index("conformance/deployments/azure-container-apps-acs.v1.json") != null) and
747749
([.files[].path] |
748750
index("conformance/runtimes/portable-postgres.v1.json") != null) and
749751
([.files[].path] |
750752
index("conformance/providers/azure-communication-services.v1.json") != null) and
751753
([.files[].path] |
752754
index("conformance/providers/sendgrid.v1.json") != null) and
755+
([.files[].path] |
756+
index("dist/provider-capability-registry.js") != null) and
753757
([.files[].path] |
754758
index("dist/adapters/portable-attachment-storage.js") != null) and
755759
([.files[].path] |
@@ -889,10 +893,14 @@ jobs:
889893
"$package_root/schemas/deployment-capabilities.v1.schema.json"
890894
test -f \
891895
"$package_root/conformance/deployments/aws-ses.v1.json"
896+
test -f \
897+
"$package_root/conformance/deployments/azure-container-apps-acs.v1.json"
892898
test -f \
893899
"$package_root/conformance/runtimes/portable-postgres.v1.json"
894900
test -f \
895901
"$package_root/conformance/providers/azure-communication-services.v1.json"
902+
test -f \
903+
"$package_root/dist/provider-capability-registry.js"
896904
test -f \
897905
"$package_root/dist/adapters/portable-attachment-storage.js"
898906
test -f \
Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
{
2+
"schema_version": "1.0.0",
3+
"deployment": "azure-container-apps-acs",
4+
"adapter_version": "0.3.1",
5+
"checked_at": "2026-07-29",
6+
"runtime": {
7+
"profile": "portable-postgres",
8+
"adapter_version": "0.3.1",
9+
"capability_document": "conformance/runtimes/portable-postgres.v1.json"
10+
},
11+
"transport": {
12+
"provider": "azure-communication-services",
13+
"adapter_version": "0.3.1",
14+
"capability_document": "conformance/providers/azure-communication-services.v1.json"
15+
},
16+
"maturity": {
17+
"runtime": "experimental",
18+
"transport": "experimental",
19+
"combination": "experimental"
20+
},
21+
"production_ready": false,
22+
"effective_limits": {
23+
"max_serialized_request_bytes": 10000000,
24+
"max_mime_message_bytes": 10000000,
25+
"max_combined_recipients": 50,
26+
"max_attachments": 20,
27+
"max_decoded_attachment_bytes": 7500000,
28+
"max_batch_messages": 100,
29+
"max_schedule_delay_seconds": 2592000
30+
},
31+
"evidence": {
32+
"conformance": {
33+
"status": "pending",
34+
"url": "https://github.com/haya-inc/hayasend/issues/152",
35+
"notes": "The shared PostgreSQL and ACS adapter contracts pass locally; exact hosted recipient convergence remains pending."
36+
},
37+
"lifecycle": {
38+
"status": "pending",
39+
"url": "https://github.com/haya-inc/hayasend/issues/152",
40+
"notes": "An isolated Azure deploy, migration, failure drill, upgrade, and rollback has not yet passed."
41+
},
42+
"terminal_delivery": {
43+
"status": "pending",
44+
"url": "https://github.com/haya-inc/hayasend/issues/152",
45+
"notes": "ACS acceptance and exact-recipient Event Grid terminal delivery have not yet converged on an isolated hosted deployment."
46+
},
47+
"controlled_receipt": {
48+
"status": "pending",
49+
"url": "https://github.com/haya-inc/hayasend/issues/152",
50+
"notes": "A uniquely identified ACS message has not yet been confirmed in a controlled mailbox for this exact deployment."
51+
},
52+
"cleanup": {
53+
"status": "pending",
54+
"url": "https://github.com/haya-inc/hayasend/issues/152",
55+
"notes": "The isolated Azure resource graph has not yet passed zero-residue cleanup and billing verification."
56+
}
57+
},
58+
"privacy": {
59+
"customer_owned_data_plane": true,
60+
"management_plane_content_exported_by_default": false,
61+
"addresses_exported_by_default": false,
62+
"raw_provider_errors_retained": false
63+
},
64+
"limitations": [
65+
"Production readiness remains false while issue #152 is incomplete.",
66+
"A customer-owned custom ACS Email domain, approved quota, and authenticated Event Grid ingress are required.",
67+
"Provider acceptance is not terminal delivery, and ACS does not provide provider-enforced send idempotency.",
68+
"Multi-recipient engagement events are retained without guessing a recipient because ACS can omit the recipient."
69+
]
70+
}

conformance/readiness.v1.json

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,28 @@
2121
"controlled_receipt"
2222
]
2323
},
24+
{
25+
"deployment": "azure-container-apps-acs",
26+
"checked_at": "2026-07-29",
27+
"runtime": "portable-postgres",
28+
"transport": "azure-communication-services",
29+
"maturity": "experimental",
30+
"production_ready": false,
31+
"evidence": {
32+
"conformance": "pending",
33+
"lifecycle": "pending",
34+
"terminal_delivery": "pending",
35+
"controlled_receipt": "pending",
36+
"cleanup": "pending"
37+
},
38+
"blockers": [
39+
"conformance",
40+
"lifecycle",
41+
"terminal_delivery",
42+
"controlled_receipt",
43+
"cleanup"
44+
]
45+
},
2446
{
2547
"deployment": "cloud-run-sendgrid",
2648
"checked_at": "2026-07-29",

docs/cli.md

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -110,8 +110,10 @@ npx --yes "@haya-inc/hayasend@${HAYASEND_VERSION}" doctor
110110
1. the health endpoint returns JSON identifying the service as HayaSend;
111111
2. the key can read the email endpoint;
112112
3. privacy-safe outbox age, stuck-lease, queue/DLQ, provider-event lag, and
113-
capability evidence when the key has `diagnostics:read`;
114-
4. whether the running AWS capability digest matches this CLI package;
113+
capability evidence for the in-memory, AWS SQS, or portable PostgreSQL
114+
runtime when the key has `diagnostics:read`;
115+
4. whether the running AWS SES, Azure ACS Email, Cloudflare Email, or SendGrid
116+
capability digest matches this CLI package;
115117
5. whether the local preview is available.
116118

117119
Without `diagnostics:read`, the recovery check reports `not_authorized`

docs/operations.md

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -277,7 +277,10 @@ intent.
277277
Use `GET /diagnostics/recovery` or `hayasend doctor` with a
278278
`diagnostics:read` key to inspect the same outbox measures together with SQS
279279
queue and DLQ depth, latest provider-event lag, and the running capability
280-
document digest. Use `GET /emails/{id}/recipients` or
280+
document digest. The CLI verifies the digest for every bundled transport:
281+
AWS SES, Azure ACS Email, Cloudflare Email, and SendGrid. An unknown extension
282+
is reported without inventing capability truth. Use
283+
`GET /emails/{id}/recipients` or
281284
`hayasend emails recipients ID` with `emails:read` for canonical
282285
mixed-recipient truth. Both outputs are allowlisted: they exclude addresses,
283286
subject and body content, credentials, signed URLs, provider message IDs, raw

docs/provider-capabilities.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,8 @@ The generated artifacts are:
2121
— experimental ACS Email transport and Event Grid capabilities;
2222
- [`conformance/providers/sendgrid.v1.json`](../conformance/providers/sendgrid.v1.json)
2323
— experimental shared SendGrid Mail Send and Signed Event Webhook capabilities;
24-
- [`conformance/deployments/aws-ses.v1.json`](../conformance/deployments/aws-ses.v1.json)
24+
- [`conformance/deployments/aws-ses.v1.json`](../conformance/deployments/aws-ses.v1.json),
25+
[`conformance/deployments/azure-container-apps-acs.v1.json`](../conformance/deployments/azure-container-apps-acs.v1.json),
2526
and
2627
[`conformance/deployments/cloudflare-email.v1.json`](../conformance/deployments/cloudflare-email.v1.json)
2728
— exact runtime+transport maturity, effective limits, and evidence gates;

docs/runtime-portability.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -245,6 +245,7 @@ Provider documents:
245245
Combined deployment documents:
246246

247247
- [`conformance/deployments/aws-ses.v1.json`](../conformance/deployments/aws-ses.v1.json)
248+
- [`conformance/deployments/azure-container-apps-acs.v1.json`](../conformance/deployments/azure-container-apps-acs.v1.json)
248249
- [`conformance/deployments/cloudflare-email.v1.json`](../conformance/deployments/cloudflare-email.v1.json)
249250
- [`conformance/deployments/cloud-run-sendgrid.v1.json`](../conformance/deployments/cloud-run-sendgrid.v1.json)
250251
- [`conformance/deployments/render-sendgrid.v1.json`](../conformance/deployments/render-sendgrid.v1.json)

scripts/conformance-artifacts.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,9 @@ import {
2222
runtimeCapabilityDocumentSchema,
2323
} from "../src/core/runtime-capabilities.js";
2424
import { AWS_SES_DEPLOYMENT_CAPABILITIES } from "../src/deployments/aws-ses-capabilities.js";
25+
import {
26+
AZURE_CONTAINER_APPS_ACS_DEPLOYMENT_CAPABILITIES,
27+
} from "../src/deployments/azure-container-apps-acs-capabilities.js";
2528
import { CLOUDFLARE_EMAIL_DEPLOYMENT_CAPABILITIES } from "../src/deployments/cloudflare-email-capabilities.js";
2629
import {
2730
CLOUD_RUN_SENDGRID_DEPLOYMENT_CAPABILITIES,
@@ -48,6 +51,7 @@ function jsonSchema(schema: z.ZodType, id: string) {
4851

4952
const READINESS_MATRIX = buildReadinessMatrix([
5053
AWS_SES_DEPLOYMENT_CAPABILITIES,
54+
AZURE_CONTAINER_APPS_ACS_DEPLOYMENT_CAPABILITIES,
5155
CLOUDFLARE_EMAIL_DEPLOYMENT_CAPABILITIES,
5256
...SENDGRID_DEPLOYMENT_CAPABILITIES,
5357
]);
@@ -73,6 +77,8 @@ export const CONFORMANCE_ARTIFACTS: Readonly<Record<string, unknown>> = {
7377
VERCEL_RUNTIME_CAPABILITIES,
7478
"conformance/deployments/aws-ses.v1.json":
7579
AWS_SES_DEPLOYMENT_CAPABILITIES,
80+
"conformance/deployments/azure-container-apps-acs.v1.json":
81+
AZURE_CONTAINER_APPS_ACS_DEPLOYMENT_CAPABILITIES,
7682
"conformance/deployments/cloudflare-email.v1.json":
7783
CLOUDFLARE_EMAIL_DEPLOYMENT_CAPABILITIES,
7884
"conformance/deployments/cloud-run-sendgrid.v1.json":

src/cli.ts

Lines changed: 11 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
#!/usr/bin/env node
22

3-
import { createHash, randomUUID } from "node:crypto";
3+
import { randomUUID } from "node:crypto";
44
import { realpathSync } from "node:fs";
55
import {
66
access,
@@ -41,8 +41,10 @@ import {
4141
} from "./cli-templates.js";
4242
import { suppressionCommand } from "./cli-suppressions.js";
4343
import { webhookCommand } from "./cli-webhooks.js";
44+
import {
45+
providerCapabilityDocumentDigest,
46+
} from "./provider-capability-registry.js";
4447
import { apiKeySchema, publicApiKeySchema } from "./schemas.js";
45-
import { AWS_SES_CAPABILITIES } from "./adapters/aws-ses-capabilities.js";
4648
import { runServerProcess } from "./server.js";
4749
import { HAYASEND_VERSION } from "./version.js";
4850

@@ -915,7 +917,7 @@ const recoveryDiagnosticsSchema = z.object({
915917
truncated: z.boolean(),
916918
}),
917919
queues: z.object({
918-
provider: z.enum(["memory", "aws-sqs"]),
920+
provider: z.enum(["memory", "aws-sqs", "postgresql"]),
919921
primary: queueDepthSchema,
920922
dead_letters: z.object({
921923
delivery: queueDepthSchema.nullable(),
@@ -990,18 +992,17 @@ async function doctor(args: string[], dependencies: CliDependencies) {
990992
if (!parsed.success) {
991993
throw new Error("HayaSend returned invalid recovery diagnostics.");
992994
}
993-
const expectedAwsDigest = createHash("sha256")
994-
.update(JSON.stringify(AWS_SES_CAPABILITIES), "utf8")
995-
.digest("hex");
995+
const expectedDigest = providerCapabilityDocumentDigest(
996+
parsed.data.capability.provider,
997+
);
996998
recoveryCheck = "pass";
997999
recovery = {
9981000
...parsed.data,
9991001
capability: {
10001002
...parsed.data.capability,
1001-
drift:
1002-
parsed.data.capability.provider === AWS_SES_CAPABILITIES.provider
1003-
? parsed.data.capability.document_sha256 !== expectedAwsDigest
1004-
: null,
1003+
drift: expectedDigest
1004+
? parsed.data.capability.document_sha256 !== expectedDigest
1005+
: null,
10051006
},
10061007
};
10071008
} else {
Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,98 @@
1+
import { ACS_EMAIL_CAPABILITIES } from "../adapters/azure/acs-email-capabilities.js";
2+
import { PORTABLE_RUNTIME_CAPABILITIES } from "../adapters/portable-runtime-capabilities.js";
3+
import {
4+
validateDeploymentCapabilityDocument,
5+
type DeploymentCapabilityDocument,
6+
} from "../core/runtime-capabilities.js";
7+
import { HAYASEND_VERSION } from "../version.js";
8+
9+
const issueUrl = "https://github.com/haya-inc/hayasend/issues/152";
10+
11+
export const AZURE_CONTAINER_APPS_ACS_DEPLOYMENT_CAPABILITIES =
12+
validateDeploymentCapabilityDocument(
13+
{
14+
schema_version: "1.0.0",
15+
deployment: "azure-container-apps-acs",
16+
adapter_version: HAYASEND_VERSION,
17+
checked_at: "2026-07-29",
18+
runtime: {
19+
profile: PORTABLE_RUNTIME_CAPABILITIES.runtime,
20+
adapter_version: PORTABLE_RUNTIME_CAPABILITIES.adapter_version,
21+
capability_document:
22+
"conformance/runtimes/portable-postgres.v1.json",
23+
},
24+
transport: {
25+
provider: ACS_EMAIL_CAPABILITIES.provider,
26+
adapter_version: ACS_EMAIL_CAPABILITIES.adapter_version,
27+
capability_document:
28+
"conformance/providers/azure-communication-services.v1.json",
29+
},
30+
maturity: {
31+
runtime: PORTABLE_RUNTIME_CAPABILITIES.service_maturity,
32+
transport: ACS_EMAIL_CAPABILITIES.service_maturity,
33+
combination: "experimental",
34+
},
35+
production_ready: false,
36+
effective_limits: {
37+
...ACS_EMAIL_CAPABILITIES.limits,
38+
max_serialized_request_bytes: Math.min(
39+
PORTABLE_RUNTIME_CAPABILITIES.limits.max_payload_bytes,
40+
ACS_EMAIL_CAPABILITIES.limits.max_serialized_request_bytes,
41+
),
42+
max_mime_message_bytes: Math.min(
43+
PORTABLE_RUNTIME_CAPABILITIES.limits.max_payload_bytes,
44+
ACS_EMAIL_CAPABILITIES.limits.max_mime_message_bytes,
45+
),
46+
max_schedule_delay_seconds: Math.min(
47+
PORTABLE_RUNTIME_CAPABILITIES.limits.max_schedule_delay_seconds,
48+
ACS_EMAIL_CAPABILITIES.limits.max_schedule_delay_seconds,
49+
),
50+
},
51+
evidence: {
52+
conformance: {
53+
status: "pending",
54+
url: issueUrl,
55+
notes:
56+
"The shared PostgreSQL and ACS adapter contracts pass locally; exact hosted recipient convergence remains pending.",
57+
},
58+
lifecycle: {
59+
status: "pending",
60+
url: issueUrl,
61+
notes:
62+
"An isolated Azure deploy, migration, failure drill, upgrade, and rollback has not yet passed.",
63+
},
64+
terminal_delivery: {
65+
status: "pending",
66+
url: issueUrl,
67+
notes:
68+
"ACS acceptance and exact-recipient Event Grid terminal delivery have not yet converged on an isolated hosted deployment.",
69+
},
70+
controlled_receipt: {
71+
status: "pending",
72+
url: issueUrl,
73+
notes:
74+
"A uniquely identified ACS message has not yet been confirmed in a controlled mailbox for this exact deployment.",
75+
},
76+
cleanup: {
77+
status: "pending",
78+
url: issueUrl,
79+
notes:
80+
"The isolated Azure resource graph has not yet passed zero-residue cleanup and billing verification.",
81+
},
82+
},
83+
privacy: {
84+
customer_owned_data_plane: true,
85+
management_plane_content_exported_by_default: false,
86+
addresses_exported_by_default: false,
87+
raw_provider_errors_retained: false,
88+
},
89+
limitations: [
90+
"Production readiness remains false while issue #152 is incomplete.",
91+
"A customer-owned custom ACS Email domain, approved quota, and authenticated Event Grid ingress are required.",
92+
"Provider acceptance is not terminal delivery, and ACS does not provide provider-enforced send idempotency.",
93+
"Multi-recipient engagement events are retained without guessing a recipient because ACS can omit the recipient.",
94+
],
95+
},
96+
PORTABLE_RUNTIME_CAPABILITIES,
97+
ACS_EMAIL_CAPABILITIES,
98+
) satisfies DeploymentCapabilityDocument;

0 commit comments

Comments
 (0)