diff --git a/docs/packages/aws-lambda.mdx b/docs/packages/aws-lambda.mdx
index 2d8d40d1f..199be356c 100644
--- a/docs/packages/aws-lambda.mdx
+++ b/docs/packages/aws-lambda.mdx
@@ -70,7 +70,6 @@ const site = await deploySite({
const handle = await renderToLambda({
siteHandle: site,
- planProtocol: "v2",
bucketName: site.bucketName,
stateMachineArn: "arn:aws:states:us-east-1:123456789012:stateMachine:hyperframes-render",
config: {
@@ -88,10 +87,17 @@ const progress = await getRenderProgress({ executionArn: handle.executionArn });
console.log(progress.status, progress.overallProgress, progress.costs.displayCost);
```
-Plan v2 is recommended for new integrations because workers fetch
-manifest-selected content-addressed artifacts. For backwards compatibility,
-omitting `planProtocol` still selects v1; existing callers do not change
-behavior until they opt in.
+Plan v2 is the default because workers fetch manifest-selected
+content-addressed artifacts. The SDK sends explicit v2 when `planProtocol` is
+omitted. Deprecated v1 compatibility remains available by passing
+`planProtocol: "v1"`.
+
+
+ For an existing installation, pause new renders and drain active Step
+ Functions executions. Redeploy the Lambda handler and SAM/CDK state machine
+ from the same package version before upgrading the application SDK. Older
+ infrastructure may default omission to v1 or lack v2 support.
+
`renderToLambda()` validates the distributed render config before starting the Step Functions execution, so invalid dimensions, formats, chunk sizes, or payload sizes fail synchronously.
diff --git a/docs/packages/gcp-cloud-run.mdx b/docs/packages/gcp-cloud-run.mdx
index f7beacfe3..edf5317db 100644
--- a/docs/packages/gcp-cloud-run.mdx
+++ b/docs/packages/gcp-cloud-run.mdx
@@ -79,7 +79,6 @@ import { getRenderProgress, renderToCloudRun } from "@hyperframes/gcp-cloud-run/
const handle = await renderToCloudRun({
projectDir: "./my-composition",
- planProtocol: "v2",
config: { fps: 30, width: 1920, height: 1080, format: "mp4" },
bucketName: "hyperframes-render-my-project",
projectId: "my-project",
@@ -97,10 +96,17 @@ while (progress.status === "running") {
console.log(progress.status, progress.outputFile, progress.costs.displayCost);
```
-Plan v2 is recommended for new integrations because workers fetch
-manifest-selected content-addressed artifacts. For backwards compatibility,
-omitting `planProtocol` still selects v1; existing callers do not change
-behavior until they opt in.
+Plan v2 is the default because workers fetch manifest-selected
+content-addressed artifacts. The SDK sends explicit v2 when `planProtocol` is
+omitted. Deprecated v1 compatibility remains available by passing
+`planProtocol: "v1"`.
+
+
+ For an existing installation, pause new renders and drain active workflow
+ executions. Redeploy the Cloud Run image and Cloud Workflows definition from
+ the same package version before upgrading the application SDK. Older
+ workflows may default omission to v1 or lack v2 support.
+
Pass `projectDir` for one-shot uploads, or call `deploySite()` separately and reuse the returned site handle across many renders.
diff --git a/examples/aws-lambda/README.md b/examples/aws-lambda/README.md
index 3a04edb25..6c429820d 100644
--- a/examples/aws-lambda/README.md
+++ b/examples/aws-lambda/README.md
@@ -75,7 +75,6 @@ aws stepfunctions start-execution \
"ProjectS3Uri": "s3://${RENDER_BUCKET}/projects/my-project.tar.gz",
"PlanOutputS3Prefix": "s3://${RENDER_BUCKET}/renders/$(date +%s)/",
"OutputS3Uri": "s3://${RENDER_BUCKET}/output.mp4",
- "PlanProtocol": "v2",
"Config": {
"fps": 30,
"width": 1920,
@@ -92,10 +91,20 @@ EOF
The Step Functions execution kicks off Plan, fans out RenderChunk via
the Map state, and finally Assemble. Final mp4 lands at `OutputS3Uri`.
-Plan v2 is recommended for new integrations. `PlanProtocol` may be `"v1"` or
-`"v2"`; absent still defaults to v1 for backwards compatibility. V2 uses
-separate manifest and content-addressed artifact locators throughout the
-workflow and never places a v2 object in `PlanS3Uri`.
+Plan v2 is the default when `PlanProtocol` is absent. V2 uses separate
+manifest and content-addressed artifact locators throughout the workflow and
+never places a v2 object in `PlanS3Uri`. The deprecated v1 transport remains
+available by sending `"PlanProtocol": "v1"` explicitly.
+
+### Upgrading an existing stack
+
+Pause new renders and let active Step Functions executions drain before the
+upgrade. Redeploy the Lambda handler and this state machine (or the matching
+CDK construct) from the same package version before upgrading the application
+that calls `renderToLambda`. The new SDK sends explicit v2 by default, while
+older infrastructure may default omission to v1 or lack v2 support. Keep
+passing `planProtocol: "v1"` until the infrastructure redeploy completes if
+you need a staged migration.
## Local invocation
@@ -112,10 +121,13 @@ sam validate
sam local invoke RenderFunction --event sample-events/plan.json
```
-The `sample-events/` directory ships small JSON payloads for each of the
-three actions. They reference fake S3 URIs — useful for sanity-checking
-the handler's dispatch logic; not for full end-to-end testing (real S3
-calls require credentials and a project zip to actually exist).
+The `sample-events/` directory ships three tiers for each action:
+`*.json` demonstrates default v2 with `PlanProtocol` omitted, `*-v1.json`
+demonstrates deprecated explicit-v1 compatibility, and `*-v2.json`
+demonstrates callers that stamp v2 explicitly. They reference fake S3 URIs —
+useful for sanity-checking the handler's dispatch logic; not for full
+end-to-end testing (real S3 calls require credentials and a project zip to
+actually exist).
## End-to-end smoke + benchmark
@@ -124,7 +136,7 @@ the architecture works on a deployed Lambda — use the local smoke
script:
```bash
-# Defaults use the fixture's meta.json minPsnr (30 dB for mp4-h264-sdr).
+# Defaults use Plan v2 and the fixture's meta.json minPsnr (30 dB for mp4-h264-sdr).
./scripts/smoke.sh
# Customised:
diff --git a/examples/aws-lambda/sample-events/assemble-v1.json b/examples/aws-lambda/sample-events/assemble-v1.json
new file mode 100644
index 000000000..6dcc2aa8c
--- /dev/null
+++ b/examples/aws-lambda/sample-events/assemble-v1.json
@@ -0,0 +1,12 @@
+{
+ "Action": "assemble",
+ "PlanProtocol": "v1",
+ "PlanS3Uri": "s3://example-bucket/renders/sample/plan.tar.gz",
+ "ChunkS3Uris": [
+ "s3://example-bucket/renders/sample/chunks/0000.mp4",
+ "s3://example-bucket/renders/sample/chunks/0001.mp4"
+ ],
+ "AudioS3Uri": null,
+ "OutputS3Uri": "s3://example-bucket/renders/sample/output.mp4",
+ "Format": "mp4"
+}
diff --git a/examples/aws-lambda/sample-events/assemble.json b/examples/aws-lambda/sample-events/assemble.json
index 7e3ab4b9f..22d43c884 100644
--- a/examples/aws-lambda/sample-events/assemble.json
+++ b/examples/aws-lambda/sample-events/assemble.json
@@ -1,6 +1,8 @@
{
"Action": "assemble",
- "PlanS3Uri": "s3://example-bucket/renders/sample/plan.tar.gz",
+ "PlanV2ManifestS3Uri": "s3://example-bucket/renders/sample/v2/manifest.json",
+ "PlanV2ArtifactS3Prefix": "s3://example-bucket/renders/sample/v2/artifacts/sha256",
+ "PlanHash": "0000000000000000000000000000000000000000000000000000000000000000",
"ChunkS3Uris": [
"s3://example-bucket/renders/sample/chunks/0000.mp4",
"s3://example-bucket/renders/sample/chunks/0001.mp4",
diff --git a/examples/aws-lambda/sample-events/plan-v1.json b/examples/aws-lambda/sample-events/plan-v1.json
new file mode 100644
index 000000000..28e174110
--- /dev/null
+++ b/examples/aws-lambda/sample-events/plan-v1.json
@@ -0,0 +1,15 @@
+{
+ "Action": "plan",
+ "PlanProtocol": "v1",
+ "ProjectS3Uri": "s3://example-bucket/projects/sample.tar.gz",
+ "PlanOutputS3Prefix": "s3://example-bucket/renders/sample/",
+ "Config": {
+ "fps": 30,
+ "width": 1920,
+ "height": 1080,
+ "format": "mp4",
+ "chunkSize": 240,
+ "maxParallelChunks": 8,
+ "runtimeCap": "lambda"
+ }
+}
diff --git a/examples/aws-lambda/sample-events/plan.json b/examples/aws-lambda/sample-events/plan.json
index de6f7c12f..51bcf48ea 100644
--- a/examples/aws-lambda/sample-events/plan.json
+++ b/examples/aws-lambda/sample-events/plan.json
@@ -1,6 +1,6 @@
{
"Action": "plan",
- "ProjectS3Uri": "s3://example-bucket/projects/sample-composition.tar.gz",
+ "ProjectS3Uri": "s3://example-bucket/projects/sample.tar.gz",
"PlanOutputS3Prefix": "s3://example-bucket/renders/sample/",
"Config": {
"fps": 30,
diff --git a/examples/aws-lambda/sample-events/render-chunk-v1.json b/examples/aws-lambda/sample-events/render-chunk-v1.json
new file mode 100644
index 000000000..bc59ed151
--- /dev/null
+++ b/examples/aws-lambda/sample-events/render-chunk-v1.json
@@ -0,0 +1,9 @@
+{
+ "Action": "renderChunk",
+ "PlanProtocol": "v1",
+ "PlanS3Uri": "s3://example-bucket/renders/sample/plan.tar.gz",
+ "PlanHash": "0000000000000000000000000000000000000000000000000000000000000000",
+ "ChunkIndex": 0,
+ "ChunkOutputS3Prefix": "s3://example-bucket/renders/sample/",
+ "Format": "mp4"
+}
diff --git a/examples/aws-lambda/sample-events/render-chunk.json b/examples/aws-lambda/sample-events/render-chunk.json
index 46d191ae2..51959bd8e 100644
--- a/examples/aws-lambda/sample-events/render-chunk.json
+++ b/examples/aws-lambda/sample-events/render-chunk.json
@@ -1,6 +1,7 @@
{
"Action": "renderChunk",
- "PlanS3Uri": "s3://example-bucket/renders/sample/plan.tar.gz",
+ "PlanV2ManifestS3Uri": "s3://example-bucket/renders/sample/v2/manifest.json",
+ "PlanV2ArtifactS3Prefix": "s3://example-bucket/renders/sample/v2/artifacts/sha256",
"PlanHash": "0000000000000000000000000000000000000000000000000000000000000000",
"ChunkIndex": 0,
"ChunkOutputS3Prefix": "s3://example-bucket/renders/sample/",
diff --git a/examples/aws-lambda/scripts/smoke.sh b/examples/aws-lambda/scripts/smoke.sh
index d90d46f71..f2f2ff6c9 100755
--- a/examples/aws-lambda/scripts/smoke.sh
+++ b/examples/aws-lambda/scripts/smoke.sh
@@ -31,7 +31,7 @@
# --region (default: $AWS_REGION or us-east-1)
# --profile (default: $AWS_PROFILE, otherwise the AWS
# default profile resolution chain)
-# --plan-protocol (default: v1)
+# --plan-protocol (default: v2)
# --keep-stack (skip `sam delete` at the end)
# --skip-build (skip the ZIP rebuild; use the existing one)
#
@@ -82,7 +82,7 @@ SMOKE_RUN_ID="${HYPERFRAMES_SMOKE_RUN_ID:-$(hf_new_smoke_run_id)}"
STACK_NAME="${STACK_NAME:-hyperframes-lambda-smoke-${SMOKE_RUN_ID}}"
AWS_REGION="${AWS_REGION:-us-east-1}"
AWS_PROFILE="${AWS_PROFILE:-}"
-PLAN_PROTOCOL="${PLAN_PROTOCOL:-v1}"
+PLAN_PROTOCOL="${PLAN_PROTOCOL:-v2}"
KEEP_STACK="false"
SKIP_BUILD="false"
REQUIRE_ENCODED_SHA_EQUAL="${REQUIRE_ENCODED_SHA_EQUAL:-false}"
@@ -110,7 +110,7 @@ Flags:
--stack-name SAM stack name (default: hyperframes-lambda-smoke-)
--region AWS region (default: $AWS_REGION or us-east-1)
--profile AWS profile (default: $AWS_PROFILE)
- --plan-protocol plan transport(s) to compare (default: v1)
+ --plan-protocol plan transport(s) to compare (default: v2)
--reserved-concurrency Lambda Map MaxConcurrency cap (default: 16)
--keep-stack skip `sam delete` at the end (manual teardown later)
--require-encoded-sha-equal also gate byte-identical encoded MP4 output
diff --git a/examples/aws-lambda/template.yaml b/examples/aws-lambda/template.yaml
index 7ae0b7f6f..8e47da3eb 100644
--- a/examples/aws-lambda/template.yaml
+++ b/examples/aws-lambda/template.yaml
@@ -222,12 +222,12 @@ Resources:
- Variable: $.PlanProtocol
IsPresent: true
Next: UnsupportedPlanProtocol
- Default: Plan
+ Default: PlanV2
UnsupportedPlanProtocol:
Type: Fail
Error: PLAN_PROTOCOL_UNSUPPORTED
- Cause: PlanProtocol must be "v1", "v2", or absent (defaults to v1).
+ Cause: PlanProtocol must be "v1", "v2", or absent (defaults to v2).
Plan:
Type: Task
@@ -236,6 +236,7 @@ Resources:
FunctionName: !GetAtt RenderFunction.Arn
Payload:
Action: plan
+ PlanProtocol: v1
ProjectS3Uri.$: "$.ProjectS3Uri"
PlanOutputS3Prefix.$: "$.PlanOutputS3Prefix"
Config.$: "$.Config"
@@ -391,6 +392,7 @@ Resources:
FunctionName: !GetAtt RenderFunction.Arn
Payload:
Action: renderChunk
+ PlanProtocol: v1
ChunkIndex.$: "$.ChunkIndex"
PlanS3Uri.$: "$.PlanS3Uri"
PlanHash.$: "$.PlanHash"
@@ -427,6 +429,7 @@ Resources:
FunctionName: !GetAtt RenderFunction.Arn
Payload:
Action: assemble
+ PlanProtocol: v1
PlanS3Uri.$: "$.Plan.PlanS3Uri"
ChunkS3Uris.$: "$.Chunks[*].ChunkS3Uri"
AudioS3Uri.$: "$.Plan.AudioS3Uri"
diff --git a/examples/gcp-cloud-run/README.md b/examples/gcp-cloud-run/README.md
index 62732f992..0f92ca826 100644
--- a/examples/gcp-cloud-run/README.md
+++ b/examples/gcp-cloud-run/README.md
@@ -8,7 +8,7 @@ Cloud Workflows adapter for HyperFrames distributed rendering.
```text
scripts/smoke.sh Owner-isolated real-GCP deploy, render, parity, cleanup
-sample-events/ v1 and v2 handler request examples
+sample-events/ Default-v2, explicit-v1, and explicit-v2 request examples
```
The Terraform module and Cloud Workflows definition live in
@@ -16,8 +16,9 @@ The Terraform module and Cloud Workflows definition live in
## Protocol rollout
-The workflow defaults to plan protocol v1 when `PlanProtocol` is absent. V2 is
-accepted only when the caller explicitly sends `PlanProtocol: "v2"`.
+The workflow defaults to Plan v2 when `PlanProtocol` is absent. Deprecated v1
+compatibility remains available only when the caller explicitly sends
+`PlanProtocol: "v1"`.
V1 and v2 use disjoint plan locators:
@@ -26,9 +27,15 @@ V1 and v2 use disjoint plan locators:
The workflow validates that the plan response matches the selected protocol
before starting chunk fan-out. It never silently falls back from v2 to v1.
-Deploy the v2 workflow only with a Cloud Run image whose handler implements
-the matching v2 request/response contract. An older v1-only handler will keep
-serving default v1 requests, but explicit v2 smoke executions will fail closed.
+Deploy the workflow only with a Cloud Run image whose handler implements the
+matching v2 request/response contract.
+
+For an existing installation, pause new renders and drain active workflow
+executions. Redeploy the Cloud Run image and workflow from the same package
+version before upgrading the application SDK. The new SDK sends explicit v2;
+older workflows may still default omission to v1 or lack v2 support. Keep
+passing `planProtocol: "v1"` until the infrastructure redeploy completes if
+you need a staged migration.
## Prerequisites
@@ -40,7 +47,7 @@ serving default v1 requests, but explicit v2 smoke executions will fail closed.
## Run the smoke
-V1 remains the safe default:
+Plan v2 is the normal smoke path:
```bash
./scripts/smoke.sh \
@@ -120,12 +127,17 @@ old invocation's state or image implicitly.
The sample events mirror the request bodies sent by Cloud Workflows:
```bash
-# V1
+# Default v2 (PlanProtocol omitted)
curl -sX POST localhost:8080/ \
-H 'content-type: application/json' \
--data @sample-events/plan.json | jq .
-# Explicit v2
+# Deprecated explicit v1 compatibility
+curl -sX POST localhost:8080/ \
+ -H 'content-type: application/json' \
+ --data @sample-events/plan-v1.json | jq .
+
+# Explicit v2 for callers that always stamp the protocol
curl -sX POST localhost:8080/ \
-H 'content-type: application/json' \
--data @sample-events/plan-v2.json | jq .
diff --git a/examples/gcp-cloud-run/sample-events/assemble-v1.json b/examples/gcp-cloud-run/sample-events/assemble-v1.json
new file mode 100644
index 000000000..4be98aa21
--- /dev/null
+++ b/examples/gcp-cloud-run/sample-events/assemble-v1.json
@@ -0,0 +1,12 @@
+{
+ "Action": "assemble",
+ "PlanProtocol": "v1",
+ "PlanGcsUri": "gs://hyperframes-render-PROJECT/renders/hf-render-demo/plan.tar.gz",
+ "ChunkGcsUris": [
+ "gs://hyperframes-render-PROJECT/renders/hf-render-demo/chunks/0000.mp4",
+ "gs://hyperframes-render-PROJECT/renders/hf-render-demo/chunks/0001.mp4"
+ ],
+ "AudioGcsUri": null,
+ "OutputGcsUri": "gs://hyperframes-render-PROJECT/renders/hf-render-demo/output.mp4",
+ "Format": "mp4"
+}
diff --git a/examples/gcp-cloud-run/sample-events/assemble.json b/examples/gcp-cloud-run/sample-events/assemble.json
index 4be98aa21..1b4ebf282 100644
--- a/examples/gcp-cloud-run/sample-events/assemble.json
+++ b/examples/gcp-cloud-run/sample-events/assemble.json
@@ -1,7 +1,8 @@
{
"Action": "assemble",
- "PlanProtocol": "v1",
- "PlanGcsUri": "gs://hyperframes-render-PROJECT/renders/hf-render-demo/plan.tar.gz",
+ "PlanV2ManifestGcsUri": "gs://hyperframes-render-PROJECT/renders/hf-render-demo/v2/manifest.json",
+ "PlanV2ArtifactGcsPrefix": "gs://hyperframes-render-PROJECT/renders/hf-render-demo/v2/artifacts/sha256",
+ "PlanHash": "REPLACE_WITH_PLAN_HASH",
"ChunkGcsUris": [
"gs://hyperframes-render-PROJECT/renders/hf-render-demo/chunks/0000.mp4",
"gs://hyperframes-render-PROJECT/renders/hf-render-demo/chunks/0001.mp4"
diff --git a/examples/gcp-cloud-run/sample-events/plan-v1.json b/examples/gcp-cloud-run/sample-events/plan-v1.json
new file mode 100644
index 000000000..6f0856668
--- /dev/null
+++ b/examples/gcp-cloud-run/sample-events/plan-v1.json
@@ -0,0 +1,7 @@
+{
+ "Action": "plan",
+ "PlanProtocol": "v1",
+ "ProjectGcsUri": "gs://hyperframes-render-PROJECT/sites/abc123/project.tar.gz",
+ "PlanOutputGcsPrefix": "gs://hyperframes-render-PROJECT/renders/hf-render-demo/",
+ "Config": { "fps": 30, "width": 1920, "height": 1080, "format": "mp4" }
+}
diff --git a/examples/gcp-cloud-run/sample-events/plan.json b/examples/gcp-cloud-run/sample-events/plan.json
index 6f0856668..667988b41 100644
--- a/examples/gcp-cloud-run/sample-events/plan.json
+++ b/examples/gcp-cloud-run/sample-events/plan.json
@@ -1,6 +1,5 @@
{
"Action": "plan",
- "PlanProtocol": "v1",
"ProjectGcsUri": "gs://hyperframes-render-PROJECT/sites/abc123/project.tar.gz",
"PlanOutputGcsPrefix": "gs://hyperframes-render-PROJECT/renders/hf-render-demo/",
"Config": { "fps": 30, "width": 1920, "height": 1080, "format": "mp4" }
diff --git a/examples/gcp-cloud-run/sample-events/render-chunk-v1.json b/examples/gcp-cloud-run/sample-events/render-chunk-v1.json
new file mode 100644
index 000000000..c11bd441a
--- /dev/null
+++ b/examples/gcp-cloud-run/sample-events/render-chunk-v1.json
@@ -0,0 +1,9 @@
+{
+ "Action": "renderChunk",
+ "PlanProtocol": "v1",
+ "PlanGcsUri": "gs://hyperframes-render-PROJECT/renders/hf-render-demo/plan.tar.gz",
+ "PlanHash": "REPLACE_WITH_PLAN_HASH",
+ "ChunkIndex": 0,
+ "ChunkOutputGcsPrefix": "gs://hyperframes-render-PROJECT/renders/hf-render-demo/",
+ "Format": "mp4"
+}
diff --git a/examples/gcp-cloud-run/sample-events/render-chunk.json b/examples/gcp-cloud-run/sample-events/render-chunk.json
index c11bd441a..cbc3c8be0 100644
--- a/examples/gcp-cloud-run/sample-events/render-chunk.json
+++ b/examples/gcp-cloud-run/sample-events/render-chunk.json
@@ -1,7 +1,7 @@
{
"Action": "renderChunk",
- "PlanProtocol": "v1",
- "PlanGcsUri": "gs://hyperframes-render-PROJECT/renders/hf-render-demo/plan.tar.gz",
+ "PlanV2ManifestGcsUri": "gs://hyperframes-render-PROJECT/renders/hf-render-demo/v2/manifest.json",
+ "PlanV2ArtifactGcsPrefix": "gs://hyperframes-render-PROJECT/renders/hf-render-demo/v2/artifacts/sha256",
"PlanHash": "REPLACE_WITH_PLAN_HASH",
"ChunkIndex": 0,
"ChunkOutputGcsPrefix": "gs://hyperframes-render-PROJECT/renders/hf-render-demo/",
diff --git a/examples/gcp-cloud-run/scripts/smoke.sh b/examples/gcp-cloud-run/scripts/smoke.sh
index 91a4d4d58..669b82621 100755
--- a/examples/gcp-cloud-run/scripts/smoke.sh
+++ b/examples/gcp-cloud-run/scripts/smoke.sh
@@ -2,11 +2,11 @@
# Owner-isolated real-GCP smoke + v1/v2 parity test for the HyperFrames
# Cloud Run adapter.
#
-# The default is intentionally v1-only. Plan protocol v2 must be opted into
-# explicitly with --protocols v1,v2. Every invocation derives a unique,
-# length-safe resource prefix and uses an isolated Terraform working directory
-# and state file. Cleanup verifies every owned resource is absent and fails
-# closed on API/authentication errors.
+# The default exercises Plan v2. Deprecated v1 compatibility can be selected
+# explicitly with --protocols v1 or compared with --protocols v1,v2. Every
+# invocation derives a unique, length-safe resource prefix and uses an isolated
+# Terraform working directory and state file. Cleanup verifies every owned
+# resource is absent and fails closed on API/authentication errors.
#
# Usage:
# ./smoke.sh --project
@@ -33,7 +33,7 @@ REGION="${GCP_REGION:-us-central1}"
FIXTURE="${FIXTURE:-mp4-h264-sdr}"
CHUNK_SIZES="${CHUNK_SIZES:-}"
PSNR_THRESHOLD="${PSNR_THRESHOLD:-35}"
-PROTOCOLS="${PROTOCOLS:-v1}"
+PROTOCOLS="${PROTOCOLS:-v2}"
OWNER="${HYPERFRAMES_SMOKE_OWNER:-}"
AR_REPO="${AR_REPO:-}"
AR_REPO_WAS_EXPLICIT=0
diff --git a/packages/aws-lambda/README.md b/packages/aws-lambda/README.md
index 14423f08e..0ef9a34cb 100644
--- a/packages/aws-lambda/README.md
+++ b/packages/aws-lambda/README.md
@@ -47,14 +47,13 @@ inside Step Functions' history budget (under 200 bytes per chunk).
### Plan transport selection
-Plan v2 is recommended for new integrations. `renderToLambda` still defaults
-an omitted `planProtocol` to the existing monolithic v1 transport for
-backwards compatibility, so select v2 explicitly:
+Plan v2 is the default for new renders. When `planProtocol` is omitted,
+`renderToLambda` sends an explicit `PlanProtocol: "v2"` so the SDK and the
+deployed state machine agree:
```ts
await renderToLambda({
// ...bucket, state machine, project, and config...
- planProtocol: "v2",
});
```
@@ -64,7 +63,24 @@ only manifest-selected chunk artifacts, while the assembler fetches its
own metadata and audio subset. Blobs are immutable SHA-256-addressed
objects, verified on upload and download, and the manifest is published
last. Unknown protocols and digest mismatches are terminal Step Functions
-errors. Omit the selector—or use `"v1"`—to retain the prior wire contract.
+errors. The monolithic v1 transport remains available as deprecated
+compatibility by passing `planProtocol: "v1"` explicitly.
+
+#### Upgrade order
+
+This default changes application behavior and requires a coordinated
+infrastructure upgrade. Before upgrading an application that calls
+`renderToLambda`:
+
+1. Pause new renders and let existing Step Functions executions drain.
+2. Redeploy the Lambda handler and SAM template or CDK construct from the
+ same new package version.
+3. Resume renders, then upgrade the application/SDK dependency.
+
+Older state machines can default missing protocol fields to v1 or lack v2
+branches, while the new SDK sends explicit v2. If infrastructure cannot be
+redeployed first, keep the application on its previous package version or
+pass `planProtocol: "v1"` explicitly until the redeploy is complete.
## Chrome runtime
diff --git a/packages/aws-lambda/src/cdk/HyperframesRenderStack.snapshot.test.ts b/packages/aws-lambda/src/cdk/HyperframesRenderStack.snapshot.test.ts
index c7f27f503..010a0d2c3 100644
--- a/packages/aws-lambda/src/cdk/HyperframesRenderStack.snapshot.test.ts
+++ b/packages/aws-lambda/src/cdk/HyperframesRenderStack.snapshot.test.ts
@@ -151,6 +151,18 @@ describe("HyperframesRenderStack — snapshot", () => {
expect(actualStates.sort()).toEqual([...EXPECTED_STATE_NAMES].sort());
});
+ it("defaults omitted plan protocol to v2 and preserves the explicit v1 branch", () => {
+ for (const definition of [SYNTHED.definition, readSamDefinition()]) {
+ const selection = requireRecord(
+ definition.States.SelectPlanProtocol,
+ "SelectPlanProtocol state",
+ );
+ expect(selection.Default).toBe("PlanV2");
+ expect(JSON.stringify(selection)).toContain('"StringEquals":"v1"');
+ expect(JSON.stringify(definition.States.Plan)).toContain('"PlanProtocol":"v1"');
+ }
+ });
+
it("preserves every typed non-retryable error name across the three Lambda tasks", () => {
const { definition } = SYNTHED;
const collected = new Set();
diff --git a/packages/aws-lambda/src/cdk/HyperframesRenderStack.ts b/packages/aws-lambda/src/cdk/HyperframesRenderStack.ts
index baa7bd6b1..8b0c4a7a1 100644
--- a/packages/aws-lambda/src/cdk/HyperframesRenderStack.ts
+++ b/packages/aws-lambda/src/cdk/HyperframesRenderStack.ts
@@ -247,6 +247,7 @@ export class HyperframesRenderStack extends Construct {
lambdaFunction: this.renderFunction,
payload: sfn.TaskInput.fromObject({
Action: "plan",
+ PlanProtocol: "v1",
"ProjectS3Uri.$": "$.ProjectS3Uri",
"PlanOutputS3Prefix.$": "$.PlanOutputS3Prefix",
"Config.$": "$.Config",
@@ -319,6 +320,7 @@ export class HyperframesRenderStack extends Construct {
lambdaFunction: this.renderFunction,
payload: sfn.TaskInput.fromObject({
Action: "renderChunk",
+ PlanProtocol: "v1",
"ChunkIndex.$": "$.ChunkIndex",
"PlanS3Uri.$": "$.PlanS3Uri",
"PlanHash.$": "$.PlanHash",
@@ -361,6 +363,7 @@ export class HyperframesRenderStack extends Construct {
lambdaFunction: this.renderFunction,
payload: sfn.TaskInput.fromObject({
Action: "assemble",
+ PlanProtocol: "v1",
"PlanS3Uri.$": "$.Plan.PlanS3Uri",
"ChunkS3Uris.$": "$.Chunks[*].ChunkS3Uri",
"AudioS3Uri.$": "$.Plan.AudioS3Uri",
@@ -473,13 +476,13 @@ export class HyperframesRenderStack extends Construct {
const unsupportedPlanProtocol = new sfn.Fail(this, "UnsupportedPlanProtocol", {
error: "PLAN_PROTOCOL_UNSUPPORTED",
- cause: 'PlanProtocol must be "v1", "v2", or absent (defaults to v1).',
+ cause: 'PlanProtocol must be "v1", "v2", or absent (defaults to v2).',
});
return new sfn.Choice(this, "SelectPlanProtocol")
.when(sfn.Condition.stringEquals("$.PlanProtocol", "v2"), planV2)
.when(sfn.Condition.stringEquals("$.PlanProtocol", "v1"), plan)
.when(sfn.Condition.isPresent("$.PlanProtocol"), unsupportedPlanProtocol)
- .otherwise(plan);
+ .otherwise(planV2);
}
}
diff --git a/packages/aws-lambda/src/events.ts b/packages/aws-lambda/src/events.ts
index 44b5e6066..8b25122dc 100644
--- a/packages/aws-lambda/src/events.ts
+++ b/packages/aws-lambda/src/events.ts
@@ -55,17 +55,17 @@ interface PlanEventBase {
}
/**
- * Legacy/default plan transport. Absence is deliberately interpreted as v1.
+ * Legacy plan transport. Callers must select it explicitly.
*
* @deprecated Use {@link PlanV2Event} for new integrations.
*/
export interface PlanV1Event extends PlanEventBase {
- PlanProtocol?: "v1";
+ PlanProtocol: "v1";
}
-/** Explicit opt-in to the content-addressed v2 plan transport. */
+/** Default content-addressed v2 plan transport. */
export interface PlanV2Event extends PlanEventBase {
- PlanProtocol: "v2";
+ PlanProtocol?: "v2";
}
export type PlanEvent = PlanV1Event | PlanV2Event;
@@ -90,12 +90,12 @@ interface RenderChunkEventBase {
}
/**
- * Legacy/default chunk event.
+ * Legacy chunk event. Callers must select it explicitly.
*
* @deprecated Use {@link RenderChunkV2Event} for new integrations.
*/
export interface RenderChunkV1Event extends RenderChunkEventBase {
- PlanProtocol?: "v1";
+ PlanProtocol: "v1";
/** S3 URI of the v1 plan tar produced by a PlanEvent invocation. */
PlanS3Uri: string;
}
@@ -105,7 +105,7 @@ export interface RenderChunkV1Event extends RenderChunkEventBase {
* describes the exact content-addressed artifacts needed by this chunk.
*/
export interface RenderChunkV2Event extends RenderChunkEventBase {
- PlanProtocol: "v2";
+ PlanProtocol?: "v2";
PlanV2ManifestS3Uri: string;
PlanV2ArtifactS3Prefix: string;
}
@@ -136,19 +136,19 @@ interface AssembleEventBase {
}
/**
- * Legacy/default assemble event.
+ * Legacy assemble event. Callers must select it explicitly.
*
* @deprecated Use {@link AssembleV2Event} for new integrations.
*/
export interface AssembleV1Event extends AssembleEventBase {
- PlanProtocol?: "v1";
+ PlanProtocol: "v1";
/** S3 URI of the v1 plan tar produced by a PlanEvent invocation. */
PlanS3Uri: string;
}
/** V2 assemble event, scoped to manifest-declared assembler artifacts. */
export interface AssembleV2Event extends AssembleEventBase {
- PlanProtocol: "v2";
+ PlanProtocol?: "v2";
PlanV2ManifestS3Uri: string;
PlanV2ArtifactS3Prefix: string;
PlanHash: string;
diff --git a/packages/aws-lambda/src/handler.test.ts b/packages/aws-lambda/src/handler.test.ts
index d1af1a96d..ec8a3d540 100644
--- a/packages/aws-lambda/src/handler.test.ts
+++ b/packages/aws-lambda/src/handler.test.ts
@@ -145,6 +145,7 @@ describe("unwrapEvent", () => {
it("unwraps a Step Functions { Payload } envelope", () => {
const inner: RenderChunkEvent = {
Action: "renderChunk",
+ PlanProtocol: "v1",
PlanS3Uri: "s3://bucket/plan.tar.gz",
PlanHash: "deadbeef",
ChunkIndex: 3,
@@ -158,6 +159,7 @@ describe("unwrapEvent", () => {
it("unwraps multiple levels of envelopes", () => {
const inner: AssembleEvent = {
Action: "assemble",
+ PlanProtocol: "v1",
PlanS3Uri: "s3://bucket/plan.tar.gz",
ChunkS3Uris: ["s3://bucket/chunks/0001.mp4"],
AudioS3Uri: null,
@@ -176,7 +178,7 @@ describe("unwrapEvent", () => {
});
describe("handler dispatch", () => {
- it("routes Action='plan' to the plan primitive", async () => {
+ it("preserves explicit v1 plan compatibility", async () => {
const tmpRoot = makeTmpRoot();
const s3 = new FakeS3Client();
// Seed a fake project tarball so the untar step has something to chew on.
@@ -213,6 +215,7 @@ describe("handler dispatch", () => {
const event: PlanEvent = {
Action: "plan",
+ PlanProtocol: "v1",
ProjectS3Uri: "s3://bucket/project.tar.gz",
PlanOutputS3Prefix: "s3://bucket/renders/abc/",
Config: { fps: 30, width: 1920, height: 1080, format: "mp4" },
@@ -270,6 +273,7 @@ describe("handler dispatch", () => {
handler(
{
Action: "plan",
+ PlanProtocol: "v1",
ProjectS3Uri: "s3://bucket/project.tar.gz",
PlanOutputS3Prefix: "s3://bucket/renders/terminal/",
Config: { fps: 30, width: 640, height: 360, format: "mp4" },
@@ -332,6 +336,7 @@ describe("handler dispatch", () => {
const event: PlanEvent = {
Action: "plan",
+ PlanProtocol: "v1",
ProjectS3Uri: "s3://bucket/project.tar.gz",
PlanOutputS3Prefix: "s3://bucket/renders/abc/",
Config: { fps: 30, width: 1920, height: 1080, format: "mp4" },
@@ -406,6 +411,7 @@ describe("handler dispatch", () => {
const event: RenderChunkEvent = {
Action: "renderChunk",
+ PlanProtocol: "v1",
PlanS3Uri: "s3://bucket/plan.tar.gz",
PlanHash: "fakehash",
ChunkIndex: 2,
@@ -455,6 +461,7 @@ describe("handler dispatch", () => {
const event: RenderChunkEvent = {
Action: "renderChunk",
+ PlanProtocol: "v1",
PlanS3Uri: "s3://bucket/plan.tar.gz",
PlanHash: "not-the-real-hash",
ChunkIndex: 0,
@@ -511,6 +518,7 @@ describe("handler dispatch", () => {
const event: AssembleEvent = {
Action: "assemble",
+ PlanProtocol: "v1",
PlanS3Uri: "s3://bucket/plan.tar.gz",
ChunkS3Uris: ["s3://bucket/chunks/0001.mp4", "s3://bucket/chunks/0002.mp4"],
AudioS3Uri: null,
@@ -541,7 +549,7 @@ describe("handler dispatch", () => {
expect(assembleMock).toHaveBeenCalledTimes(1);
});
- it("runs v2 plan → target-scoped chunk → assemble without a PlanS3Uri", async () => {
+ it("defaults omitted plan protocol to v2 across plan → chunk → assemble", async () => {
const tmpRoot = makeTmpRoot();
const s3 = new FakeS3Client();
s3.objects.set("s3://bucket/project.tar.gz", await makeMinimalProjectTar());
@@ -614,7 +622,6 @@ describe("handler dispatch", () => {
const planned = await handler(
{
Action: "plan",
- PlanProtocol: "v2",
ProjectS3Uri: "s3://bucket/project.tar.gz",
PlanOutputS3Prefix: "s3://bucket/renders/v2/",
Config: { fps: 30, width: 640, height: 360, format: "mp4" },
@@ -642,7 +649,6 @@ describe("handler dispatch", () => {
const chunk = await handler(
{
Action: "renderChunk",
- PlanProtocol: "v2",
PlanV2ManifestS3Uri: planned.PlanV2ManifestS3Uri,
PlanV2ArtifactS3Prefix: planned.PlanV2ArtifactS3Prefix,
PlanHash: planned.PlanHash,
@@ -661,7 +667,6 @@ describe("handler dispatch", () => {
await handler(
{
Action: "assemble",
- PlanProtocol: "v2",
PlanV2ManifestS3Uri: planned.PlanV2ManifestS3Uri,
PlanV2ArtifactS3Prefix: planned.PlanV2ArtifactS3Prefix,
PlanHash: planned.PlanHash,
@@ -677,6 +682,56 @@ describe("handler dispatch", () => {
).toBe(true);
});
+ it("rejects unknown plan protocol values", async () => {
+ const tmpRoot = makeTmpRoot();
+ const s3 = new FakeS3Client();
+
+ await expect(
+ handler(
+ {
+ Action: "plan",
+ PlanProtocol: "v3",
+ ProjectS3Uri: "s3://bucket/project.tar.gz",
+ PlanOutputS3Prefix: "s3://bucket/renders/invalid/",
+ Config: { fps: 30, width: 640, height: 360, format: "mp4" },
+ } as unknown as LambdaEvent,
+ {
+ s3: s3 as unknown as import("@aws-sdk/client-s3").S3Client,
+ tmpRoot,
+ skipChromeResolution: true,
+ },
+ ),
+ ).rejects.toMatchObject({ name: "PLAN_PROTOCOL_UNSUPPORTED" });
+ expect(s3.ops).toHaveLength(0);
+ });
+
+ it("rejects mixed v1/v2 plan locators at runtime", async () => {
+ const tmpRoot = makeTmpRoot();
+ const s3 = new FakeS3Client();
+
+ await expect(
+ handler(
+ {
+ Action: "renderChunk",
+ PlanS3Uri: "s3://bucket/plan.tar.gz",
+ PlanHash: "fakehash",
+ ChunkIndex: 0,
+ ChunkOutputS3Prefix: "s3://bucket/renders/mixed/",
+ Format: "mp4",
+ } as unknown as LambdaEvent,
+ {
+ s3: s3 as unknown as import("@aws-sdk/client-s3").S3Client,
+ tmpRoot,
+ skipChromeResolution: true,
+ },
+ ),
+ ).rejects.toMatchObject({
+ name: "PLAN_PROTOCOL_UNSUPPORTED",
+ message: expect.stringContaining("mixed or missing plan locators"),
+ });
+ expect(s3.ops).toHaveLength(0);
+ });
+
it("rejects unknown actions", async () => {
const tmpRoot = makeTmpRoot();
await expect(
@@ -735,6 +790,7 @@ describe("handler — S3 URI allowlist (security: F-004)", () => {
const event: AssembleEvent = {
Action: "assemble",
+ PlanProtocol: "v1",
PlanS3Uri: "s3://good-bucket/plan.tar.gz",
ChunkS3Uris: ["s3://good-bucket/chunks/0001.mp4", "s3://evil-bucket/chunks/0002.mp4"],
AudioS3Uri: null,
diff --git a/packages/aws-lambda/src/handler.ts b/packages/aws-lambda/src/handler.ts
index e16b68b78..08fc2caf3 100644
--- a/packages/aws-lambda/src/handler.ts
+++ b/packages/aws-lambda/src/handler.ts
@@ -44,9 +44,12 @@ import type {
LambdaEvent,
LambdaResult,
PlanEvent,
+ PlanV2Event,
PlanLambdaResult,
RenderChunkEvent,
+ RenderChunkV2Event,
RenderChunkLambdaResult,
+ AssembleV2Event,
} from "./events.js";
import {
downloadS3ObjectToFile,
@@ -97,6 +100,7 @@ export interface HandlerDeps {
*/
export async function handler(event: LambdaEvent, deps?: HandlerDeps): Promise {
const unwrapped = unwrapEvent(event);
+ validatePlanProtocolShape(unwrapped);
validateEventS3Uris(unwrapped);
primeRuntimeEnv();
// Single structured boot log line — CloudWatch Logs Insights queries
@@ -201,6 +205,43 @@ function isLambdaAction(value: string): value is LambdaAction {
return value === "plan" || value === "renderChunk" || value === "assemble";
}
+// This is the single fail-closed boundary for the wire union. Keeping all
+// forbidden locator combinations together makes mixed-protocol input auditable.
+// fallow-ignore-next-line complexity
+function validatePlanProtocolShape(event: PlanEvent | RenderChunkEvent | AssembleEvent): void {
+ const raw = event as unknown as Record;
+ const protocol = raw.PlanProtocol;
+ if (protocol !== undefined && protocol !== "v1" && protocol !== "v2") {
+ const error = new Error(
+ `[handler] unsupported PlanProtocol ${JSON.stringify(protocol)}; expected "v1", "v2", or absent`,
+ );
+ error.name = "PLAN_PROTOCOL_UNSUPPORTED";
+ throw error;
+ }
+ if (event.Action === "plan") return;
+
+ const effectiveProtocol = protocol ?? "v2";
+ const hasV1Locator = typeof raw.PlanS3Uri === "string";
+ const hasV2Manifest = typeof raw.PlanV2ManifestS3Uri === "string";
+ const hasV2Prefix = typeof raw.PlanV2ArtifactS3Prefix === "string";
+ const valid =
+ effectiveProtocol === "v2"
+ ? !hasV1Locator && hasV2Manifest && hasV2Prefix
+ : hasV1Locator && !hasV2Manifest && !hasV2Prefix;
+ if (!valid) {
+ const error = new Error(
+ `[handler] ${effectiveProtocol} ${event.Action} event has mixed or missing plan locators`,
+ );
+ error.name = "PLAN_PROTOCOL_UNSUPPORTED";
+ throw error;
+ }
+ if (effectiveProtocol === "v2" && event.Action === "assemble" && event.AudioS3Uri !== null) {
+ const error = new Error("[handler] v2 assemble audio must be materialized from the manifest");
+ error.name = "PLAN_PROTOCOL_UNSUPPORTED";
+ throw error;
+ }
+}
+
/**
* Emit a single JSON line to stdout. CloudWatch ingests each line as a
* structured event; Logs Insights queries can `filter event="..."` and
@@ -229,14 +270,14 @@ function summarizeEvent(
return {
projectS3Uri: event.ProjectS3Uri,
planOutputS3Prefix: event.PlanOutputS3Prefix,
- planProtocol: event.PlanProtocol ?? "v1",
+ planProtocol: event.PlanProtocol ?? "v2",
format: event.Config.format,
fps: event.Config.fps,
};
case "renderChunk":
return {
- planProtocol: event.PlanProtocol ?? "v1",
- ...(event.PlanProtocol === "v2"
+ planProtocol: event.PlanProtocol ?? "v2",
+ ...(event.PlanProtocol !== "v1"
? { planV2ManifestS3Uri: event.PlanV2ManifestS3Uri }
: { planS3Uri: event.PlanS3Uri }),
chunkIndex: event.ChunkIndex,
@@ -244,8 +285,8 @@ function summarizeEvent(
};
case "assemble":
return {
- planProtocol: event.PlanProtocol ?? "v1",
- ...(event.PlanProtocol === "v2"
+ planProtocol: event.PlanProtocol ?? "v2",
+ ...(event.PlanProtocol !== "v1"
? { planV2ManifestS3Uri: event.PlanV2ManifestS3Uri }
: { planS3Uri: event.PlanS3Uri }),
chunkCount: event.ChunkS3Uris.length,
@@ -278,7 +319,7 @@ function primeRuntimeEnv(): void {
// The v1 handler owns one transactional download, plan, archive, upload, and cleanup lifecycle.
// fallow-ignore-next-line complexity
async function handlePlan(event: PlanEvent, deps?: HandlerDeps): Promise {
- if (event.PlanProtocol === "v2") {
+ if (event.PlanProtocol !== "v1") {
return handlePlanV2(event, deps);
}
const started = Date.now();
@@ -358,7 +399,7 @@ async function handlePlan(event: PlanEvent, deps?: HandlerDeps): Promise,
+ event: PlanV2Event,
deps?: HandlerDeps,
): Promise> {
const started = Date.now();
@@ -412,7 +453,7 @@ async function handleRenderChunk(
event: RenderChunkEvent,
deps?: HandlerDeps,
): Promise {
- if (event.PlanProtocol === "v2") {
+ if (event.PlanProtocol !== "v1") {
return handleRenderChunkV2(event, deps);
}
const started = Date.now();
@@ -481,7 +522,7 @@ async function handleRenderChunk(
// render, and upload in one lifecycle so cleanup and errors remain atomic.
// fallow-ignore-next-line complexity
async function handleRenderChunkV2(
- event: Extract,
+ event: RenderChunkV2Event,
deps?: HandlerDeps,
): Promise {
const started = Date.now();
@@ -554,7 +595,7 @@ async function handleAssemble(
event: AssembleEvent,
deps?: HandlerDeps,
): Promise {
- if (event.PlanProtocol === "v2") {
+ if (event.PlanProtocol !== "v1") {
return handleAssembleV2(event, deps);
}
const started = Date.now();
@@ -610,7 +651,7 @@ async function handleAssemble(
// keeping the steps local makes its temporary-storage ownership explicit.
// fallow-ignore-next-line complexity
async function handleAssembleV2(
- event: Extract,
+ event: AssembleV2Event,
deps?: HandlerDeps,
): Promise {
const started = Date.now();
@@ -776,12 +817,12 @@ function getEventS3Uris(event: PlanEvent | RenderChunkEvent | AssembleEvent): st
case "plan":
return [event.ProjectS3Uri, event.PlanOutputS3Prefix];
case "renderChunk":
- return event.PlanProtocol === "v2"
+ return event.PlanProtocol !== "v1"
? [event.PlanV2ManifestS3Uri, event.PlanV2ArtifactS3Prefix, event.ChunkOutputS3Prefix]
: [event.PlanS3Uri, event.ChunkOutputS3Prefix];
case "assemble":
return [
- ...(event.PlanProtocol === "v2"
+ ...(event.PlanProtocol !== "v1"
? [event.PlanV2ManifestS3Uri, event.PlanV2ArtifactS3Prefix]
: [event.PlanS3Uri]),
...event.ChunkS3Uris,
diff --git a/packages/aws-lambda/src/sdk/renderToLambda.test.ts b/packages/aws-lambda/src/sdk/renderToLambda.test.ts
index 61402c2ad..bb9007bb0 100644
--- a/packages/aws-lambda/src/sdk/renderToLambda.test.ts
+++ b/packages/aws-lambda/src/sdk/renderToLambda.test.ts
@@ -98,11 +98,11 @@ describe("renderToLambda", () => {
PlanOutputS3Prefix: "s3://test-bucket/renders/smoke-1/",
OutputS3Uri: "s3://test-bucket/renders/smoke-1/output.mp4",
Config: baseConfig,
- PlanProtocol: "v1",
+ PlanProtocol: "v2",
});
});
- it("opts the complete execution into plan protocol v2 explicitly", async () => {
+ it("preserves explicit plan protocol v1 compatibility", async () => {
const sfn = new FakeSFN();
const s3 = new FakeS3();
const handle = await renderToLambda({
@@ -110,18 +110,18 @@ describe("renderToLambda", () => {
bucketName: "test-bucket",
stateMachineArn: "arn:aws:states:us-east-1:1234:stateMachine:hf",
config: baseConfig,
- executionName: "smoke-v2",
- planProtocol: "v2",
+ executionName: "smoke-v1",
+ planProtocol: "v1",
sfn: asSFNClient(sfn),
s3: asS3Client(s3),
});
expect(sfn.starts[0]?.input).toEqual({
ProjectS3Uri: handle.projectS3Uri,
- PlanOutputS3Prefix: "s3://test-bucket/renders/smoke-v2/",
- OutputS3Uri: "s3://test-bucket/renders/smoke-v2/output.mp4",
+ PlanOutputS3Prefix: "s3://test-bucket/renders/smoke-v1/",
+ OutputS3Uri: "s3://test-bucket/renders/smoke-v1/output.mp4",
Config: baseConfig,
- PlanProtocol: "v2",
+ PlanProtocol: "v1",
});
});
diff --git a/packages/aws-lambda/src/sdk/renderToLambda.ts b/packages/aws-lambda/src/sdk/renderToLambda.ts
index 8df5e20c1..1232e6876 100644
--- a/packages/aws-lambda/src/sdk/renderToLambda.ts
+++ b/packages/aws-lambda/src/sdk/renderToLambda.ts
@@ -38,8 +38,8 @@ export interface RenderToLambdaOptions {
/** Validated `SerializableDistributedRenderConfig` (no logger / abortSignal). */
config: SerializableDistributedRenderConfig;
/**
- * Distributed plan transport. Defaults to `"v1"` for backwards
- * compatibility. New integrations should explicitly select `"v2"`.
+ * Distributed plan transport. Defaults to `"v2"`. Select `"v1"`
+ * explicitly only for deprecated compatibility with the monolithic plan.
*/
planProtocol?: LambdaPlanProtocol;
/** S3 bucket from the SAM stack output (`RenderBucketName`). */
@@ -115,7 +115,7 @@ export async function renderToLambda(opts: RenderToLambdaOptions): Promise {
OutputGcsUri: "gs://b/renders/hf-render-fixed/output.mp4",
ServiceUrl: "https://render-abc.run.app",
Config: config,
- PlanProtocol: "v1",
+ PlanProtocol: "v2",
});
expect(fake.lastParent).toBe(
"projects/proj/locations/us-central1/workflows/hyperframes-render",
);
});
- it("forwards an explicit v2 whole-render opt-in", async () => {
+ it("preserves explicit plan protocol v1 compatibility", async () => {
const fake = new FakeExecutions();
- await renderToCloudRun({ ...opts(fake), planProtocol: "v2" });
+ await renderToCloudRun({ ...opts(fake), planProtocol: "v1" });
const arg = JSON.parse(fake.lastArgument ?? "{}");
expect(arg).toEqual({
RenderId: "hf-render-fixed",
@@ -106,7 +106,7 @@ describe("renderToCloudRun", () => {
OutputGcsUri: "gs://b/renders/hf-render-fixed/output.mp4",
ServiceUrl: "https://render-abc.run.app",
Config: config,
- PlanProtocol: "v2",
+ PlanProtocol: "v1",
});
});
diff --git a/packages/gcp-cloud-run/src/sdk/renderToCloudRun.ts b/packages/gcp-cloud-run/src/sdk/renderToCloudRun.ts
index ae07d2e91..55cb20a3c 100644
--- a/packages/gcp-cloud-run/src/sdk/renderToCloudRun.ts
+++ b/packages/gcp-cloud-run/src/sdk/renderToCloudRun.ts
@@ -53,8 +53,8 @@ export interface RenderToCloudRunOptions {
/** Validated `SerializableDistributedRenderConfig` (no logger / abortSignal). */
config: SerializableDistributedRenderConfig;
/**
- * Distributed plan transport. Defaults to `"v1"` for backwards
- * compatibility. New integrations should explicitly select `"v2"`.
+ * Distributed plan transport. Defaults to `"v2"`. Select `"v1"`
+ * explicitly only for deprecated compatibility with the monolithic plan.
*/
planProtocol?: CloudRunPlanProtocol;
/** GCS bucket from the Terraform output (`render_bucket_name`). */
@@ -149,7 +149,7 @@ export async function renderToCloudRun(opts: RenderToCloudRunOptions): Promise {
});
describe("dispatch", () => {
- it("routes plan, uploads the plan tarball", async () => {
+ it("preserves explicit v1 plan compatibility", async () => {
const gcs = new FakeGcs();
await seedProjectTar(gcs, "gs://b/sites/x/project.tar.gz");
const event: PlanEvent = {
Action: "plan",
+ PlanProtocol: "v1",
ProjectGcsUri: "gs://b/sites/x/project.tar.gz",
PlanOutputGcsPrefix: "gs://b/renders/r1/",
Config: { fps: 30, width: 1920, height: 1080, format: "mp4" } as PlanEvent["Config"],
@@ -201,6 +202,7 @@ describe("dispatch", () => {
await seedPlanTar(gcs, "gs://b/renders/r1/plan.tar.gz", PLAN_HASH);
const event: RenderChunkEvent = {
Action: "renderChunk",
+ PlanProtocol: "v1",
PlanGcsUri: "gs://b/renders/r1/plan.tar.gz",
PlanHash: PLAN_HASH,
ChunkIndex: 2,
@@ -220,6 +222,7 @@ describe("dispatch", () => {
await seedPlanTar(gcs, "gs://b/renders/r1/plan.tar.gz", PLAN_HASH);
const event: RenderChunkEvent = {
Action: "renderChunk",
+ PlanProtocol: "v1",
PlanGcsUri: "gs://b/renders/r1/plan.tar.gz",
PlanHash: "WRONG_HASH",
ChunkIndex: 0,
@@ -236,6 +239,7 @@ describe("dispatch", () => {
gcs.seed("gs://b/renders/r1/chunks/0001.mp4", Buffer.from("c1"));
const event: AssembleEvent = {
Action: "assemble",
+ PlanProtocol: "v1",
PlanGcsUri: "gs://b/renders/r1/plan.tar.gz",
ChunkGcsUris: ["gs://b/renders/r1/chunks/0000.mp4", "gs://b/renders/r1/chunks/0001.mp4"],
AudioGcsUri: null,
@@ -251,7 +255,7 @@ describe("dispatch", () => {
// This end-to-end adapter contract is intentionally one narrative test: it
// verifies ordering and target isolation across all three handler roles.
// fallow-ignore-next-line complexity
- it("runs v2 plan → target-scoped chunk → assemble with manifest-last CAS", async () => {
+ it("defaults omitted plan protocol to v2 across plan → chunk → assemble", async () => {
const gcs = new FakeGcs();
await seedProjectTar(gcs, "gs://b/sites/v2/project.tar.gz");
const root = mkTmp("hf-v2-e2e-");
@@ -306,7 +310,6 @@ describe("dispatch", () => {
const planned = await dispatch(
{
Action: "plan",
- PlanProtocol: "v2",
ProjectGcsUri: "gs://b/sites/v2/project.tar.gz",
PlanOutputGcsPrefix: "gs://b/renders/v2/",
Config: { fps: 30, width: 640, height: 360, format: "mp4" },
@@ -326,7 +329,6 @@ describe("dispatch", () => {
await dispatch(
{
Action: "plan",
- PlanProtocol: "v2",
ProjectGcsUri: "gs://b/sites/v2/project.tar.gz",
PlanOutputGcsPrefix: "gs://b/renders/v2/",
Config: { fps: 30, width: 640, height: 360, format: "mp4" },
@@ -342,7 +344,6 @@ describe("dispatch", () => {
const chunk = await dispatch(
{
Action: "renderChunk",
- PlanProtocol: "v2",
PlanV2ManifestGcsUri: planned.PlanV2ManifestGcsUri,
PlanV2ArtifactGcsPrefix: planned.PlanV2ArtifactGcsPrefix,
PlanHash: planned.PlanHash,
@@ -360,7 +361,6 @@ describe("dispatch", () => {
await dispatch(
{
Action: "assemble",
- PlanProtocol: "v2",
PlanV2ManifestGcsUri: planned.PlanV2ManifestGcsUri,
PlanV2ArtifactGcsPrefix: planned.PlanV2ArtifactGcsPrefix,
PlanHash: planned.PlanHash,
@@ -409,6 +409,7 @@ describe("bucket allowlist guard", () => {
try {
const event: RenderChunkEvent = {
Action: "renderChunk",
+ PlanProtocol: "v1",
PlanGcsUri: "gs://evil-bucket/plan.tar.gz",
PlanHash: PLAN_HASH,
ChunkIndex: 0,
@@ -451,6 +452,7 @@ describe("bucket allowlist guard", () => {
try {
const event: RenderChunkEvent = {
Action: "renderChunk",
+ PlanProtocol: "v1",
PlanGcsUri: "gs://any-bucket/renders/r1/plan.tar.gz",
PlanHash: PLAN_HASH,
ChunkIndex: 0,
@@ -476,6 +478,7 @@ describe("createApp HTTP mapping", () => {
headers: { "content-type": "application/json" },
body: JSON.stringify({
Action: "renderChunk",
+ PlanProtocol: "v1",
PlanGcsUri: "gs://b/renders/r1/plan.tar.gz",
PlanHash: PLAN_HASH,
ChunkIndex: 0,
@@ -497,6 +500,7 @@ describe("createApp HTTP mapping", () => {
headers: { "content-type": "application/json" },
body: JSON.stringify({
Action: "renderChunk",
+ PlanProtocol: "v1",
PlanGcsUri: "gs://b/renders/r1/plan.tar.gz",
PlanHash: "WRONG",
ChunkIndex: 0,
@@ -524,6 +528,7 @@ describe("createApp HTTP mapping", () => {
headers: { "content-type": "application/json" },
body: JSON.stringify({
Action: "renderChunk",
+ PlanProtocol: "v1",
PlanGcsUri: "gs://b/renders/r1/plan.tar.gz",
PlanHash: PLAN_HASH,
ChunkIndex: 0,
@@ -561,6 +566,7 @@ describe("createApp HTTP mapping", () => {
headers: { "content-type": "application/json" },
body: JSON.stringify({
Action: "renderChunk",
+ PlanProtocol: "v1",
PlanGcsUri: "gs://b/renders/r1/plan.tar.gz",
PlanHash: PLAN_HASH,
ChunkIndex: 0,
@@ -598,6 +604,7 @@ describe("createApp HTTP mapping", () => {
headers: { "content-type": "application/json" },
body: JSON.stringify({
Action: "renderChunk",
+ PlanProtocol: "v1",
PlanGcsUri: "gs://b/renders/r1/plan.tar.gz",
PlanHash: PLAN_HASH,
ChunkIndex: 0,
@@ -626,6 +633,7 @@ describe("createApp HTTP mapping", () => {
headers: { "content-type": "application/json" },
body: JSON.stringify({
Action: "plan",
+ PlanProtocol: "v1",
ProjectGcsUri: "gs://b/sites/invalid-video-metadata/project.tar.gz",
PlanOutputGcsPrefix: "gs://b/renders/invalid-video-metadata/",
Config: { fps: 30, width: 640, height: 360, format: "mp4" },
@@ -645,6 +653,7 @@ describe("createApp HTTP mapping", () => {
headers: { "content-type": "application/json" },
body: JSON.stringify({
Action: "renderChunk",
+ PlanProtocol: "v1",
PlanGcsUri: "gs://b/renders/r1/missing.tar.gz",
PlanHash: PLAN_HASH,
ChunkIndex: 0,
diff --git a/packages/gcp-cloud-run/src/server.ts b/packages/gcp-cloud-run/src/server.ts
index 81aae4c2b..8d467ec7a 100644
--- a/packages/gcp-cloud-run/src/server.ts
+++ b/packages/gcp-cloud-run/src/server.ts
@@ -46,13 +46,16 @@ import {
import { resolveChromeExecutablePath } from "./chromium.js";
import type {
AssembleEvent,
+ AssembleV2Event,
AssembleResultBody,
CloudRunAction,
CloudRunEvent,
CloudRunResult,
PlanEvent,
+ PlanV2Event,
PlanResultBody,
RenderChunkEvent,
+ RenderChunkV2Event,
RenderChunkResultBody,
} from "./events.js";
import { type DistributedFormat, formatExtension } from "./formatExtension.js";
@@ -155,21 +158,22 @@ function validatePlanProtocolShape(event: PlanEvent | RenderChunkEvent | Assembl
}
if (event.Action === "plan") return;
+ const effectiveProtocol = protocol ?? "v2";
const hasV1Locator = typeof raw.PlanGcsUri === "string";
const hasV2Manifest = typeof raw.PlanV2ManifestGcsUri === "string";
const hasV2Prefix = typeof raw.PlanV2ArtifactGcsPrefix === "string";
const valid =
- protocol === "v2"
+ effectiveProtocol === "v2"
? !hasV1Locator && hasV2Manifest && hasV2Prefix
: hasV1Locator && !hasV2Manifest && !hasV2Prefix;
if (!valid) {
const error = new Error(
- `[handler] ${protocol === "v2" ? "v2" : "v1"} ${event.Action} event has mixed or missing plan locators`,
+ `[handler] ${effectiveProtocol} ${event.Action} event has mixed or missing plan locators`,
);
error.name = "PLAN_PROTOCOL_UNSUPPORTED";
throw error;
}
- if (protocol === "v2" && event.Action === "assemble" && event.AudioGcsUri !== null) {
+ if (effectiveProtocol === "v2" && event.Action === "assemble" && event.AudioGcsUri !== null) {
const error = new Error("[handler] v2 assemble audio must be materialized from the manifest");
error.name = "PLAN_PROTOCOL_UNSUPPORTED";
throw error;
@@ -254,14 +258,14 @@ function summarizeEvent(
return {
projectGcsUri: event.ProjectGcsUri,
planOutputGcsPrefix: event.PlanOutputGcsPrefix,
- planProtocol: event.PlanProtocol ?? "v1",
+ planProtocol: event.PlanProtocol ?? "v2",
format: event.Config.format,
fps: event.Config.fps,
};
case "renderChunk":
return {
- planProtocol: event.PlanProtocol ?? "v1",
- ...(event.PlanProtocol === "v2"
+ planProtocol: event.PlanProtocol ?? "v2",
+ ...(event.PlanProtocol !== "v1"
? { planV2ManifestGcsUri: event.PlanV2ManifestGcsUri }
: { planGcsUri: event.PlanGcsUri }),
chunkIndex: event.ChunkIndex,
@@ -269,8 +273,8 @@ function summarizeEvent(
};
case "assemble":
return {
- planProtocol: event.PlanProtocol ?? "v1",
- ...(event.PlanProtocol === "v2"
+ planProtocol: event.PlanProtocol ?? "v2",
+ ...(event.PlanProtocol !== "v1"
? { planV2ManifestGcsUri: event.PlanV2ManifestGcsUri }
: { planGcsUri: event.PlanGcsUri }),
chunkCount: event.ChunkGcsUris.length,
@@ -297,7 +301,7 @@ function primeChrome(deps?: HandlerDeps): void {
// fallow-ignore-next-line complexity
async function handlePlan(event: PlanEvent, deps?: HandlerDeps): Promise {
- if (event.PlanProtocol === "v2") {
+ if (event.PlanProtocol !== "v1") {
return handlePlanV2(event, deps);
}
const started = Date.now();
@@ -365,7 +369,7 @@ async function handlePlan(event: PlanEvent, deps?: HandlerDeps): Promise,
+ event: PlanV2Event,
deps?: HandlerDeps,
): Promise> {
const started = Date.now();
@@ -418,7 +422,7 @@ async function handleRenderChunk(
event: RenderChunkEvent,
deps?: HandlerDeps,
): Promise {
- if (event.PlanProtocol === "v2") {
+ if (event.PlanProtocol !== "v1") {
return handleRenderChunkV2(event, deps);
}
const started = Date.now();
@@ -475,7 +479,7 @@ async function handleRenderChunk(
/** Materialize only this chunk's verified v2 dependencies before rendering. */
// fallow-ignore-next-line complexity
async function handleRenderChunkV2(
- event: Extract,
+ event: RenderChunkV2Event,
deps?: HandlerDeps,
): Promise {
const started = Date.now();
@@ -548,7 +552,7 @@ async function handleAssemble(
event: AssembleEvent,
deps?: HandlerDeps,
): Promise {
- if (event.PlanProtocol === "v2") {
+ if (event.PlanProtocol !== "v1") {
return handleAssembleV2(event, deps);
}
const started = Date.now();
@@ -613,7 +617,7 @@ async function handleAssemble(
*/
// fallow-ignore-next-line complexity
async function handleAssembleV2(
- event: Extract,
+ event: AssembleV2Event,
deps?: HandlerDeps,
): Promise {
const started = Date.now();
@@ -781,12 +785,12 @@ function getEventGcsUris(event: PlanEvent | RenderChunkEvent | AssembleEvent): s
case "plan":
return [event.ProjectGcsUri, event.PlanOutputGcsPrefix];
case "renderChunk":
- return event.PlanProtocol === "v2"
+ return event.PlanProtocol !== "v1"
? [event.PlanV2ManifestGcsUri, event.PlanV2ArtifactGcsPrefix, event.ChunkOutputGcsPrefix]
: [event.PlanGcsUri, event.ChunkOutputGcsPrefix];
case "assemble":
return [
- ...(event.PlanProtocol === "v2"
+ ...(event.PlanProtocol !== "v1"
? [event.PlanV2ManifestGcsUri, event.PlanV2ArtifactGcsPrefix]
: [event.PlanGcsUri]),
...event.ChunkGcsUris,
diff --git a/packages/gcp-cloud-run/terraform/smoke-safety.test.ts b/packages/gcp-cloud-run/terraform/smoke-safety.test.ts
index 4ba0df321..faaa03b1d 100644
--- a/packages/gcp-cloud-run/terraform/smoke-safety.test.ts
+++ b/packages/gcp-cloud-run/terraform/smoke-safety.test.ts
@@ -7,8 +7,8 @@ const smoke = readFileSync(smokePath, "utf-8");
const dockerfile = readFileSync(join(import.meta.dir, "../Dockerfile"), "utf-8");
describe("GCP smoke ownership and protocol safety", () => {
- it("defaults to v1 and requires an explicit v2 protocol argument", () => {
- expect(smoke).toContain('PROTOCOLS="${PROTOCOLS:-v1}"');
+ it("defaults to v2 and retains explicit v1/v2 protocol arguments", () => {
+ expect(smoke).toContain('PROTOCOLS="${PROTOCOLS:-v2}"');
expect(smoke).toContain("--protocols)");
expect(smoke).toContain("PlanProtocol: $protocol");
expect(smoke).toContain("decodedFramesEqual");
diff --git a/packages/gcp-cloud-run/terraform/workflow.test.ts b/packages/gcp-cloud-run/terraform/workflow.test.ts
index b17e00772..e4c9f5d92 100644
--- a/packages/gcp-cloud-run/terraform/workflow.test.ts
+++ b/packages/gcp-cloud-run/terraform/workflow.test.ts
@@ -77,8 +77,8 @@ describe("Cloud Workflows plan protocol routing", () => {
expect(source.match(/max_retries: 4/g)).toHaveLength(4);
});
- it("keeps v1 as the default and rejects unknown protocols before plan", () => {
- expect(source).toContain('default(map.get(args, "PlanProtocol"), "v1")');
+ it("defaults omitted protocol to v2 and rejects unknown protocols before plan", () => {
+ expect(source).toContain('default(map.get(args, "PlanProtocol"), "v2")');
expect(namedStep("selectPlanProtocol")).toMatchObject({
next: "unsupportedPlanProtocol",
});
diff --git a/packages/gcp-cloud-run/terraform/workflow.yaml b/packages/gcp-cloud-run/terraform/workflow.yaml
index 7901a5a2b..2e563a489 100644
--- a/packages/gcp-cloud-run/terraform/workflow.yaml
+++ b/packages/gcp-cloud-run/terraform/workflow.yaml
@@ -26,9 +26,9 @@ main:
- planOutputGcsPrefix: ${args.PlanOutputGcsPrefix}
- outputGcsUri: ${args.OutputGcsUri}
- config: ${args.Config}
- # Backward-compatible default. v2 is accepted only through an
- # explicit top-level PlanProtocol opt-in.
- - planProtocol: ${default(map.get(args, "PlanProtocol"), "v1")}
+ # Plan v2 is the default. Explicit v1 remains available during the
+ # deprecated monolithic-plan compatibility window.
+ - planProtocol: ${default(map.get(args, "PlanProtocol"), "v2")}
# ── Plan (Activity A) ────────────────────────────────────────────────────
- selectPlanProtocol: