From 9a87543403c90a5aac7f5b532cd1747c8a9e119b Mon Sep 17 00:00:00 2001 From: Aron Price Date: Sun, 29 Mar 2026 20:46:38 -0400 Subject: [PATCH 1/7] inventory deploy assembly contexts --- .../release-driven-deploy-contract.md | 42 ++++++++++++++++++- 1 file changed, 41 insertions(+), 1 deletion(-) diff --git a/docs/contracts/release-driven-deploy-contract.md b/docs/contracts/release-driven-deploy-contract.md index a1f2a700..f18b795d 100644 --- a/docs/contracts/release-driven-deploy-contract.md +++ b/docs/contracts/release-driven-deploy-contract.md @@ -1,6 +1,7 @@ # Lesser Release-Driven Deploy Contract -This document freezes the milestone-0 contract for release-driven Lesser deploys. +This document freezes the current release-driven deploy contract and the next-step target contract for later immutable +deploy milestones. Its job is to make the current deploy path explicit before later milestones move work from deploy time into release time. When this document says a category or rule is "canonical", later implementation milestones should preserve that @@ -251,3 +252,42 @@ This keeps the managed-runner contract explicit: - repo-local CDK/auth-ui source still remains an execution prerequisite - the checkout must stay version-aligned with the release bundle because bundle validation uses the checkout's canonical Lambda inventory - receipt/output behavior stays in the same Lesser-owned state dir the operator path already uses + +## M4.1 Per-Instance Context Inventory That Still Blocks A Reusable Deploy Assembly + +Moving beyond prebuilt Lambdas requires naming which contexts still keep deploy execution instance-bound today. + +| Context family | Examples | Current source of truth | Release-generic or instance-bound? | Compile/release-time or deploy-time? | Why it still blocks a reusable deploy assembly today | +| --- | --- | --- | --- | --- | --- | +| App identity | `--app`, stack names, Lesser state-dir prefix | CLI flags and local receipt paths | Instance-bound | Deploy-time | The same release can be installed under different app slugs, so synthesized stack names and local state paths cannot be baked once per release | +| Domain routing | `--base-domain`, per-stage domains, CloudFront aliases | CLI flags, `stageURLs`, Route53 resolution | Instance-bound | Deploy-time | DNS ownership and stage-domain mappings vary per deployment and depend on live hosted-zone/account state | +| Hosted zone selection | hosted zone ID/name, public DNS authority | Route53 lookup in the target AWS account | Instance-bound | Deploy-time | The deploy executor must bind the release to the actual hosted zone for the target account instead of assuming one zone per release | +| AWS target environment | account ID, region, bootstrap state, CDK environment | Ambient AWS auth and CDK bootstrap state | Instance-bound | Deploy-time | CloudFormation updates, bootstrap buckets, and trust boundaries are specific to the target account and region | +| Stage state | stage stack history, prior outputs, stage-specific URLs | Existing CloudFormation stacks plus Lesser receipts | Instance-bound | Deploy-time | Stage deploys are updates against live stack history rather than one-shot release materialization | +| Feature/config toggles | `bodyEnabled`, `lesserHostUrl`, translation flags, AI toggles, tips, managed provisioning inputs | CLI flags, env vars, provisioning JSON, stack context | Instance-bound | Deploy-time | These change behavior for one installation without changing the underlying release payload, so they must stay outside generic release artifacts | +| Bootstrap/admin lifecycle | bootstrap receipts, setup completion state, admin writes | `~/.lesser/...`, DynamoDB, setup endpoints | Instance-bound | Deploy-time | Lifecycle state depends on what has already happened in the target installation | +| Auth/UI publish destination | auth bucket name, CloudFront invalidation target | Stack outputs and live AWS state | Instance-bound output over release-generic source | Deploy-time for upload, release-time candidate for build | The UI source is generic, but the destination bucket/distribution is chosen from live stack outputs at deploy time | + +### Generic release state versus instance-bound deploy state + +The reusable pieces that belong to a release contract are: + +- the Lesser revision (`version`, `git_sha`) and its published checksums +- the operator CLI binary that executes deploy workflows +- the Lambda bundle and manifest that reproduce the canonical `bin/*.zip` set +- the repo revision that defines the canonical CDK app, auth UI source, and Lambda inventory +- any future published deploy assembly payload that is valid for every installation of that release + +The still-instance-bound pieces are: + +- app slug, base domain, hosted zone, AWS account, and region +- stage stack history, bootstrap state, and prior CloudFormation outputs +- per-instance feature/config toggles and provisioning input JSON +- post-deploy side effects such as DNS records, invalidations, receipt writes, and bootstrap/admin state changes + +### Compile-time versus deploy-time contexts + +Milestone 4 keeps one boundary explicit: + +- Release-time / compile-time candidates: Lambda compilation, auth UI bundling, CDK or equivalent deploy assembly synthesis, release manifests, and checksums +- Deploy-time only: AWS credential selection, hosted-zone lookup, stack update planning against live history, feature-flag injection, DNS writes, invalidations, and instance receipt/bootstrap updates From a22c0e20a4c0a41956d901fc710147a30bff121b Mon Sep 17 00:00:00 2001 From: Aron Price Date: Sun, 29 Mar 2026 20:48:44 -0400 Subject: [PATCH 2/7] define deploy assembly contract --- cmd/lesser/release_contract_examples_test.go | 100 +++++++++++ docs/contracts/README.md | 2 + .../deploy-assembly-descriptor.schema.json | 166 ++++++++++++++++++ .../lesser-deploy-assembly.example.json | 49 ++++++ .../release-driven-deploy-contract.md | 59 ++++++- 5 files changed, 375 insertions(+), 1 deletion(-) create mode 100644 docs/contracts/deploy-assembly-descriptor.schema.json create mode 100644 docs/contracts/examples/lesser-deploy-assembly.example.json diff --git a/cmd/lesser/release_contract_examples_test.go b/cmd/lesser/release_contract_examples_test.go index 4c54eaaa..336a7e6f 100644 --- a/cmd/lesser/release_contract_examples_test.go +++ b/cmd/lesser/release_contract_examples_test.go @@ -37,6 +37,39 @@ type lambdaBundleManifestExample struct { } `json:"files"` } +type deployAssemblyDescriptorExample struct { + Kind string `json:"kind"` + SchemaVersion int `json:"schema_version"` + Release struct { + Name string `json:"name"` + Version string `json:"version"` + GitSHA string `json:"git_sha"` + } `json:"release"` + Assembly struct { + Path string `json:"path"` + Format string `json:"format"` + SHA256 string `json:"sha256"` + } `json:"assembly"` + Payload struct { + Kind string `json:"kind"` + ContractVersion int `json:"contract_version"` + Entrypoint string `json:"entrypoint"` + } `json:"payload"` + Compatibility struct { + ReleaseManifestPath string `json:"release_manifest_path"` + DeployArtifactsKey string `json:"deploy_artifacts_key"` + ExecutorContractValue int `json:"executor_contract_version"` + } `json:"compatibility"` + InstanceInputs struct { + Required []string `json:"required"` + Optional []string `json:"optional"` + } `json:"instance_inputs"` + Verification struct { + IntegrityRequired []string `json:"integrity_required"` + PreflightRequired []string `json:"preflight_required"` + } `json:"verification"` +} + func TestLambdaBundleManifestExampleContract(t *testing.T) { repoRoot, err := findRepoRoot() require.NoError(t, err) @@ -112,6 +145,73 @@ func TestLambdaBundleManifestSchemaContract(t *testing.T) { require.Equal(t, "lesser.lambda_inventory", anyMap(t, inventoryProps["kind"])["const"]) } +func TestDeployAssemblyDescriptorExampleContract(t *testing.T) { + repoRoot, err := findRepoRoot() + require.NoError(t, err) + + data, err := os.ReadFile(filepath.Join(repoRoot, "docs", "contracts", "examples", "lesser-deploy-assembly.example.json")) + require.NoError(t, err) + + var descriptor deployAssemblyDescriptorExample + require.NoError(t, json.Unmarshal(data, &descriptor)) + + require.Equal(t, "lesser.deploy_assembly_descriptor", descriptor.Kind) + require.Equal(t, 1, descriptor.SchemaVersion) + require.Equal(t, "lesser", descriptor.Release.Name) + require.Regexp(t, regexp.MustCompile(`^v[0-9].*$`), descriptor.Release.Version) + require.Regexp(t, regexp.MustCompile(`^[a-f0-9]{40}$`), descriptor.Release.GitSHA) + require.Equal(t, "lesser-deploy-assembly.tar.gz", descriptor.Assembly.Path) + require.Equal(t, "tar.gz", descriptor.Assembly.Format) + require.Regexp(t, regexp.MustCompile(`^[a-f0-9]{64}$`), descriptor.Assembly.SHA256) + require.Regexp(t, regexp.MustCompile(`^[a-z][a-z0-9_.-]*$`), descriptor.Payload.Kind) + require.GreaterOrEqual(t, descriptor.Payload.ContractVersion, 1) + require.NotEmpty(t, descriptor.Payload.Entrypoint) + require.NotContains(t, descriptor.Payload.Entrypoint, "..") + require.NotContains(t, descriptor.Payload.Entrypoint, `\`) + require.Equal(t, "lesser-release.json", descriptor.Compatibility.ReleaseManifestPath) + require.Equal(t, "deploy_assembly", descriptor.Compatibility.DeployArtifactsKey) + require.GreaterOrEqual(t, descriptor.Compatibility.ExecutorContractValue, 1) + require.NotEmpty(t, descriptor.InstanceInputs.Required) + require.NotEmpty(t, descriptor.Verification.IntegrityRequired) + require.NotEmpty(t, descriptor.Verification.PreflightRequired) +} + +func TestDeployAssemblyDescriptorSchemaContract(t *testing.T) { + repoRoot, err := findRepoRoot() + require.NoError(t, err) + + data, err := os.ReadFile(filepath.Join(repoRoot, "docs", "contracts", "deploy-assembly-descriptor.schema.json")) + require.NoError(t, err) + + var schema map[string]any + require.NoError(t, json.Unmarshal(data, &schema)) + + require.Equal(t, "https://json-schema.org/draft/2020-12/schema", schema["$schema"]) + require.Equal(t, "Lesser Deploy Assembly Descriptor", schema["title"]) + require.ElementsMatch(t, []string{ + "kind", + "schema_version", + "release", + "assembly", + "payload", + "compatibility", + "instance_inputs", + "verification", + }, anySliceToStrings(t, schema["required"])) + + properties := anyMap(t, schema["properties"]) + require.Equal(t, "lesser.deploy_assembly_descriptor", anyMap(t, properties["kind"])["const"]) + require.Equal(t, float64(1), anyMap(t, properties["schema_version"])["const"]) + + assemblyProps := anyMap(t, anyMap(t, properties["assembly"])["properties"]) + require.Equal(t, "lesser-deploy-assembly.tar.gz", anyMap(t, assemblyProps["path"])["const"]) + require.Equal(t, "tar.gz", anyMap(t, assemblyProps["format"])["const"]) + + compatibilityProps := anyMap(t, anyMap(t, properties["compatibility"])["properties"]) + require.Equal(t, "lesser-release.json", anyMap(t, compatibilityProps["release_manifest_path"])["const"]) + require.Equal(t, "deploy_assembly", anyMap(t, compatibilityProps["deploy_artifacts_key"])["const"]) +} + func anyMap(t *testing.T, value any) map[string]any { t.Helper() diff --git a/docs/contracts/README.md b/docs/contracts/README.md index 859811f4..8979b7b3 100644 --- a/docs/contracts/README.md +++ b/docs/contracts/README.md @@ -30,6 +30,8 @@ running environment. - Release-driven deploy contract: `docs/contracts/release-driven-deploy-contract.md` - Lambda bundle manifest schema: `docs/contracts/lambda-bundle-manifest.schema.json` - Lambda bundle manifest example: `docs/contracts/examples/lesser-lambda-bundle.example.json` +- Deploy assembly descriptor schema: `docs/contracts/deploy-assembly-descriptor.schema.json` +- Deploy assembly descriptor example: `docs/contracts/examples/lesser-deploy-assembly.example.json` - Published release metadata now includes `lesser-release.json` entries that point at the Lambda bundle and manifest for release-driven deploy consumers. diff --git a/docs/contracts/deploy-assembly-descriptor.schema.json b/docs/contracts/deploy-assembly-descriptor.schema.json new file mode 100644 index 00000000..bbf8527f --- /dev/null +++ b/docs/contracts/deploy-assembly-descriptor.schema.json @@ -0,0 +1,166 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://equalto.ai/contracts/deploy-assembly-descriptor.schema.json", + "title": "Lesser Deploy Assembly Descriptor", + "type": "object", + "additionalProperties": false, + "required": [ + "kind", + "schema_version", + "release", + "assembly", + "payload", + "compatibility", + "instance_inputs", + "verification" + ], + "properties": { + "kind": { + "const": "lesser.deploy_assembly_descriptor" + }, + "schema_version": { + "const": 1 + }, + "release": { + "type": "object", + "additionalProperties": false, + "required": [ + "name", + "version", + "git_sha" + ], + "properties": { + "name": { + "const": "lesser" + }, + "version": { + "type": "string", + "pattern": "^v[0-9].*$" + }, + "git_sha": { + "type": "string", + "pattern": "^[a-f0-9]{40}$" + } + } + }, + "assembly": { + "type": "object", + "additionalProperties": false, + "required": [ + "path", + "format", + "sha256" + ], + "properties": { + "path": { + "const": "lesser-deploy-assembly.tar.gz" + }, + "format": { + "const": "tar.gz" + }, + "sha256": { + "type": "string", + "pattern": "^[a-f0-9]{64}$" + } + } + }, + "payload": { + "type": "object", + "additionalProperties": false, + "required": [ + "kind", + "contract_version", + "entrypoint" + ], + "properties": { + "kind": { + "type": "string", + "pattern": "^[a-z][a-z0-9_.-]*$" + }, + "contract_version": { + "type": "integer", + "minimum": 1 + }, + "entrypoint": { + "type": "string", + "pattern": "^(?!/)(?!.*\\.\\.)(?!.*\\\\).+$" + } + } + }, + "compatibility": { + "type": "object", + "additionalProperties": false, + "required": [ + "release_manifest_path", + "deploy_artifacts_key", + "executor_contract_version" + ], + "properties": { + "release_manifest_path": { + "const": "lesser-release.json" + }, + "deploy_artifacts_key": { + "const": "deploy_assembly" + }, + "executor_contract_version": { + "type": "integer", + "minimum": 1 + } + } + }, + "instance_inputs": { + "type": "object", + "additionalProperties": false, + "required": [ + "required", + "optional" + ], + "properties": { + "required": { + "type": "array", + "items": { + "type": "string", + "pattern": "^[a-z][a-z0-9_]*$" + }, + "uniqueItems": true + }, + "optional": { + "type": "array", + "items": { + "type": "string", + "pattern": "^[a-z][a-z0-9_]*$" + }, + "uniqueItems": true + } + } + }, + "verification": { + "type": "object", + "additionalProperties": false, + "required": [ + "integrity_required", + "preflight_required" + ], + "properties": { + "integrity_required": { + "type": "array", + "items": { + "type": "string", + "pattern": "^[a-z][a-z0-9_.-]*$" + }, + "minItems": 1, + "uniqueItems": true + }, + "preflight_required": { + "type": "array", + "items": { + "type": "string", + "pattern": "^[a-z][a-z0-9_.-]*$" + }, + "minItems": 1, + "uniqueItems": true + } + } + } + } +} diff --git a/docs/contracts/examples/lesser-deploy-assembly.example.json b/docs/contracts/examples/lesser-deploy-assembly.example.json new file mode 100644 index 00000000..3db77f9e --- /dev/null +++ b/docs/contracts/examples/lesser-deploy-assembly.example.json @@ -0,0 +1,49 @@ +{ + "kind": "lesser.deploy_assembly_descriptor", + "schema_version": 1, + "release": { + "name": "lesser", + "version": "v1.2.3", + "git_sha": "0123456789abcdef0123456789abcdef01234567" + }, + "assembly": { + "path": "lesser-deploy-assembly.tar.gz", + "format": "tar.gz", + "sha256": "eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee" + }, + "payload": { + "kind": "aws.cloud_assembly", + "contract_version": 1, + "entrypoint": "cdk.out/manifest.json" + }, + "compatibility": { + "release_manifest_path": "lesser-release.json", + "deploy_artifacts_key": "deploy_assembly", + "executor_contract_version": 1 + }, + "instance_inputs": { + "required": [ + "app", + "aws_region", + "base_domain", + "feature_flags", + "hosted_zone", + "stage_plan" + ], + "optional": [ + "bootstrap_output_path", + "lesser_host_urls", + "provisioning_input" + ] + }, + "verification": { + "integrity_required": [ + "assembly.sha256", + "checksums.txt" + ], + "preflight_required": [ + "instance_input_validation", + "release_manifest_compatibility" + ] + } +} diff --git a/docs/contracts/release-driven-deploy-contract.md b/docs/contracts/release-driven-deploy-contract.md index f18b795d..958b944f 100644 --- a/docs/contracts/release-driven-deploy-contract.md +++ b/docs/contracts/release-driven-deploy-contract.md @@ -70,7 +70,7 @@ Milestone-0 freezes four artifact categories. These names are part of the contra | `operator_cli` | `lesser--` | Human- or runner-invoked CLI executable | Operators, CI, `lesser-host` runner | Already published | | `release_metadata` | `checksums.txt`, `lesser-release.json` | Release-level discovery and integrity metadata | Operators, CI, artifact mode in `lesser up` | Already published | | `lambda_bundle` | `lesser-lambda-bundle.tar.gz`, `lesser-lambda-bundle.json` | First-phase immutable deploy asset containing the canonical `bin/*.zip` set plus its manifest | Artifact mode in `lesser up`, managed runners | Published | -| `deploy_assembly` | Reserved future category | Later deploy package that may include more than prebuilt Lambdas | Future thin deploy executor / managed runners | Explicitly out of scope for the first phase | +| `deploy_assembly` | `lesser-deploy-assembly.tar.gz`, `lesser-deploy-assembly.json` | Later deploy package that may include more than prebuilt Lambdas | Future thin deploy executor / managed runners | Contract target defined, but not yet published in live releases | ### Taxonomy rules @@ -291,3 +291,60 @@ Milestone 4 keeps one boundary explicit: - Release-time / compile-time candidates: Lambda compilation, auth UI bundling, CDK or equivalent deploy assembly synthesis, release manifests, and checksums - Deploy-time only: AWS credential selection, hosted-zone lookup, stack update planning against live history, feature-flag injection, DNS writes, invalidations, and instance receipt/bootstrap updates + +## M4.2 Future Release Contract For A Publishable Deploy Assembly + +Milestone 4 does not publish a deploy assembly yet, but it does freeze the outer contract shape for when that happens. + +The future release directory layout for the `deploy_assembly` category is: + +- `lesser-deploy-assembly.tar.gz`: archive containing the release-published deploy assembly payload +- `lesser-deploy-assembly.json`: descriptor that tells a thin deploy executor how to verify and interpret the archive + +The normative descriptor schema is: + +- `docs/contracts/deploy-assembly-descriptor.schema.json` + +The illustrative example is: + +- `docs/contracts/examples/lesser-deploy-assembly.example.json` + +### Why the contract uses a descriptor instead of freezing one payload format + +The outer Lesser contract stays stable even if the inner payload changes over time. That is why the descriptor freezes: + +- the published archive path and checksum +- the release identity (`name`, `version`, `git_sha`) +- the payload kind and entrypoint +- the executor compatibility contract +- the required instance-input categories and verification expectations + +The descriptor deliberately does not freeze one implementation such as AWS Cloud Assembly forever. Instead: + +- `payload.kind` names the inner assembly type, such as `aws.cloud_assembly` +- `payload.entrypoint` identifies the file inside the archive that the future executor should start from +- `payload.contract_version` version-controls the inner payload independently of the outer Lesser descriptor + +This leaves room for Cloud Assembly or an equivalent plan format without forcing the release contract to rename assets or +change trust boundaries later. + +### Compatibility with the current release manifest model + +When Lesser eventually publishes a deploy assembly, the top-level `lesser-release.json` contract should extend the +existing `artifacts.deploy_artifacts` section instead of inventing a second discovery document. + +The future shape is: + +- `artifacts.deploy_artifacts.deploy_assembly.path` +- `artifacts.deploy_artifacts.deploy_assembly.manifest_path` +- `artifacts.deploy_artifacts.deploy_assembly.manifest_kind` +- `artifacts.deploy_artifacts.deploy_assembly.manifest_schema_version` + +That mirrors the existing `lambda_bundle` reference model: + +- release metadata points at one published archive plus one machine-readable descriptor +- the descriptor carries the detailed contract for the archive contents and compatibility rules +- `checksums.txt` remains the top-level integrity root for the published assets + +Milestone 4 does not change the live release manifest schema yet. It freezes the next-step target so the future +`deploy_assembly` category can be added as a sibling of `lambda_bundle` rather than as a separate trust model. From e30f53d9fdbd9239e8176e276794024143de6593 Mon Sep 17 00:00:00 2001 From: Aron Price Date: Sun, 29 Mar 2026 20:49:53 -0400 Subject: [PATCH 3/7] separate deploy inputs from release artifacts --- cmd/lesser/release_contract_examples_test.go | 14 +++++- .../lesser-deploy-assembly.example.json | 10 ++-- .../release-driven-deploy-contract.md | 48 +++++++++++++++++++ 3 files changed, 66 insertions(+), 6 deletions(-) diff --git a/cmd/lesser/release_contract_examples_test.go b/cmd/lesser/release_contract_examples_test.go index 336a7e6f..9ddadf1a 100644 --- a/cmd/lesser/release_contract_examples_test.go +++ b/cmd/lesser/release_contract_examples_test.go @@ -171,7 +171,19 @@ func TestDeployAssemblyDescriptorExampleContract(t *testing.T) { require.Equal(t, "lesser-release.json", descriptor.Compatibility.ReleaseManifestPath) require.Equal(t, "deploy_assembly", descriptor.Compatibility.DeployArtifactsKey) require.GreaterOrEqual(t, descriptor.Compatibility.ExecutorContractValue, 1) - require.NotEmpty(t, descriptor.InstanceInputs.Required) + require.ElementsMatch(t, []string{ + "app_identity", + "aws_target", + "base_domain", + "feature_config", + "hosted_zone", + "stage_plan", + }, descriptor.InstanceInputs.Required) + require.ElementsMatch(t, []string{ + "bootstrap_io", + "managed_service_urls", + "provisioning_input", + }, descriptor.InstanceInputs.Optional) require.NotEmpty(t, descriptor.Verification.IntegrityRequired) require.NotEmpty(t, descriptor.Verification.PreflightRequired) } diff --git a/docs/contracts/examples/lesser-deploy-assembly.example.json b/docs/contracts/examples/lesser-deploy-assembly.example.json index 3db77f9e..6836a418 100644 --- a/docs/contracts/examples/lesser-deploy-assembly.example.json +++ b/docs/contracts/examples/lesser-deploy-assembly.example.json @@ -23,16 +23,16 @@ }, "instance_inputs": { "required": [ - "app", - "aws_region", + "app_identity", + "aws_target", "base_domain", - "feature_flags", + "feature_config", "hosted_zone", "stage_plan" ], "optional": [ - "bootstrap_output_path", - "lesser_host_urls", + "bootstrap_io", + "managed_service_urls", "provisioning_input" ] }, diff --git a/docs/contracts/release-driven-deploy-contract.md b/docs/contracts/release-driven-deploy-contract.md index 958b944f..5a780dd2 100644 --- a/docs/contracts/release-driven-deploy-contract.md +++ b/docs/contracts/release-driven-deploy-contract.md @@ -348,3 +348,51 @@ That mirrors the existing `lambda_bundle` reference model: Milestone 4 does not change the live release manifest schema yet. It freezes the next-step target so the future `deploy_assembly` category can be added as a sibling of `lambda_bundle` rather than as a separate trust model. + +## M4.3 Separate Generic Release Artifacts From Instance-Specific Deploy Inputs + +The future deploy contract uses a two-part model: + +- generic release artifacts published once per Lesser release +- instance-specific deploy inputs supplied separately for each installation or update + +### Generic release artifacts + +These artifacts must stay identical for every consumer of the same Lesser release: + +| Release artifact | Scope | Why it stays generic | +| --- | --- | --- | +| `lesser--` CLI binaries | Release | They execute workflows, but they are not parameterized by one target installation | +| `checksums.txt` and `lesser-release.json` | Release | They define discovery and integrity for the published assets | +| `lesser-lambda-bundle.tar.gz` and `lesser-lambda-bundle.json` | Release | They reproduce the canonical Lambda asset set for that release | +| Future `lesser-deploy-assembly.tar.gz` and `lesser-deploy-assembly.json` | Release | They package the reusable deploy assembly payload and its descriptor once per release | + +### Canonical instance-specific input set + +The future thin deploy executor should consume these input categories separately from the release artifacts: + +| Input category | What it covers | Why it stays instance-specific | Current operator / runner surface | +| --- | --- | --- | --- | +| `app_identity` | app slug, stack prefix, state-dir namespace | One release can be installed under many app names | `--app` and local receipt paths | +| `aws_target` | AWS credentials, account, region, bootstrap environment | Trust and billing boundaries vary per deploy | AWS profile/env credentials plus CLI region resolution | +| `base_domain` | root domain for the installation | Domains vary per customer/instance | `--base-domain` | +| `hosted_zone` | actual Route53 hosted zone binding | The same domain may resolve in different AWS accounts or zones | Route53 lookup and optional operator choice | +| `stage_plan` | which stages are being updated plus their live stack history | Deploys are updates against existing stage stacks | CloudFormation state and Lesser receipts | +| `feature_config` | `bodyEnabled`, translation flags, AI toggles, tips, and similar behavior switches | These are installation-level decisions, not release identity | CLI flags, env vars, and provisioning input JSON | +| `managed_service_urls` | `lesserHostUrl`, attestations URL, similar managed endpoints | Managed control-plane wiring differs by installation | provisioning input JSON or env vars | +| `provisioning_input` | operator/runner-supplied bootstrap and managed config blob | It is provided per deploy request | `--provisioning-input` | +| `bootstrap_io` | output/bootstrap file locations and receipt handling | File paths are runner-local execution concerns | `--out`, local Lesser state dir, managed receipts | + +### Separation rules + +The contract boundary is: + +- release artifacts may describe required input categories, but they must not embed per-instance values +- app names, domains, hosted-zone IDs, account IDs, feature flags, and provisioning JSON stay outside published artifacts +- the same release artifact set must be reusable across different customers and domains without rewriting the artifact bytes +- operators and managed runners should map their local invocation surfaces into the same canonical input categories above + +This keeps the future deploy assembly usable by both modes: + +- operators can continue supplying flags and files that map onto the canonical categories +- managed runners can build the same category map from their higher-level job model without mutating the published release artifacts From 14e8d3f87b88eaedbe62ae945d2a2a789e028767 Mon Sep 17 00:00:00 2001 From: Aron Price Date: Sun, 29 Mar 2026 20:50:28 -0400 Subject: [PATCH 4/7] define thin deploy executor migration --- .../release-driven-deploy-contract.md | 48 +++++++++++++++++++ 1 file changed, 48 insertions(+) diff --git a/docs/contracts/release-driven-deploy-contract.md b/docs/contracts/release-driven-deploy-contract.md index 5a780dd2..51836538 100644 --- a/docs/contracts/release-driven-deploy-contract.md +++ b/docs/contracts/release-driven-deploy-contract.md @@ -396,3 +396,51 @@ This keeps the future deploy assembly usable by both modes: - operators can continue supplying flags and files that map onto the canonical categories - managed runners can build the same category map from their higher-level job model without mutating the published release artifacts + +## M4.4 Migration Path From Source-Shelling `cdk deploy` To A Thin Deploy Executor + +Today, `./lesser up` still treats a source checkout as the deploy substrate: + +- it shells into the repo-local CDK app +- it validates or builds local deploy artifacts +- it builds and uploads `auth-ui/` from source +- it writes local receipts and bootstrap outputs in the same execution flow + +Milestone 4 freezes the migration path so later work can remove those source dependencies deliberately instead of by +accident. + +### Target responsibilities for a thin deploy executor + +The future executor should still own only the work that must remain deploy-time: + +- verify the selected release assets and their checksums +- accept the canonical instance-input set for one installation +- bind release artifacts to the target AWS account, region, domain, and hosted zone +- execute the infrastructure update plan against live CloudFormation state +- publish the resulting outputs, receipts, and post-deploy side effects + +The future executor should stop owning release-time work such as: + +- compiling Lambdas from source +- synthesizing the deploy assembly from repo-local CDK/app source +- rebuilding any release-generic frontend payloads +- discovering artifact shape from ad-hoc repo structure + +### Migration sequence + +| Phase | Deploy substrate | What still comes from source? | What must move into release assets next? | +| --- | --- | --- | --- | +| Current M0-M3 path | source checkout plus optional `--release-dir` Lambda bundle | CDK app, auth UI source, Lambda inventory, helper scripts | reusable deploy assembly, auth UI artifact, executor-facing input contract | +| M4 contract target | source checkout for execution, but with a frozen future deploy-assembly contract | CDK shelling and auth UI source still remain live dependencies | publishable deploy assembly descriptor/archive and explicit instance-input contract | +| Future phase-2 executor | release directory plus instance-input set | only tooling needed to execute the deploy contract, not the full repo source tree | release-published infrastructure assembly, frontend payloads, and executor compatibility metadata | + +### Dependencies that must become explicit future work + +The migration cannot complete until Lesser publishes or freezes contracts for: + +- a deploy assembly payload that replaces repo-local CDK synthesis as the generic release artifact +- release-published frontend assets, or an explicit reason the frontend remains outside the immutable path +- executor compatibility rules for CloudFormation/bootstrap behavior against live AWS state +- receipt/output contracts that preserve the current Lesser state-dir behavior without depending on a mutable checkout + +Milestone 4 keeps those dependencies named. It does not imply they are already solved by the Lambda bundle milestone. From 61278ed94dafb309b7b07cc04d594b033be8834a Mon Sep 17 00:00:00 2001 From: Aron Price Date: Sun, 29 Mar 2026 20:51:29 -0400 Subject: [PATCH 5/7] capture phase-two deploy outcomes --- .../release-driven-deploy-contract.md | 32 +++++++++++++++++++ docs/release-checklist.md | 4 +-- 2 files changed, 34 insertions(+), 2 deletions(-) diff --git a/docs/contracts/release-driven-deploy-contract.md b/docs/contracts/release-driven-deploy-contract.md index 51836538..b5f05ed2 100644 --- a/docs/contracts/release-driven-deploy-contract.md +++ b/docs/contracts/release-driven-deploy-contract.md @@ -444,3 +444,35 @@ The migration cannot complete until Lesser publishes or freezes contracts for: - receipt/output contracts that preserve the current Lesser state-dir behavior without depending on a mutable checkout Milestone 4 keeps those dependencies named. It does not imply they are already solved by the Lambda bundle milestone. + +## M4.5 Phase-2 Cost, Latency, Reliability, And Verification Goals + +The next immutable deploy phase exists to solve an operational problem, not just to publish more files. The target +state is a thinner deploy path that avoids repeated release-generic work during each installation or client update. + +### Outcome goals + +| Goal area | Current managed-deploy pain | Phase-2 target state | How success should be verified | +| --- | --- | --- | --- | +| Cost | Each deploy still pays for repo-local synthesis/build tooling beyond the Lambda bundle | Release-generic assembly work happens once per release publish, not once per deploy | Managed deploy logs show artifact verification plus executor work, but no repo-local Lambda compile, deploy-assembly synthesis, or frontend rebuild steps | +| Latency | Mutable source preparation still delays deploy start and compounds runner time | A managed runner should reach the first live AWS deploy operation after artifact verification and input validation only, without any release-generic build stage in the path | Future runner smoke tests and timing instrumentation show deploy preparation is bounded by asset download/verification rather than by source builds | +| Reliability | Repo drift and local toolchain state can change deploy behavior for the same release | The same release artifacts plus the same instance-input set should resolve to the same deploy assembly contract and verification results | Descriptor/checksum validation, executor compatibility checks, and source-independence smoke tests all pass for the same release/input pair | +| Verification | Today the contract is partly implicit in repo layout and CLI behavior | The future executor must fail fast on missing assets, incompatible descriptor versions, or incomplete instance inputs before mutating AWS state | CI contract tests, release checks, and executor preflight tests catch drift before a live deploy begins | + +### Target-state verification guardrails + +Later implementation work should prove the phase-2 target with explicit checks: + +- contract tests for the published deploy assembly descriptor schema and examples +- release checks that the future deploy assembly assets and `lesser-release.json` references are present and checksummed +- executor integration tests that consume release assets plus instance inputs without needing repo-local synthesis or rebuilds +- managed-runner smoke tests that confirm the deploy path stays on the artifact/executor route rather than silently falling back to source-shelling + +### What milestone 4 deliberately leaves unchanged + +Milestone 4 defines the target and the migration path, but it does not claim: + +- CDK source is already gone from deploy execution +- auth UI is already a release-published immutable asset +- a no-checkout deploy path is shipping today +- managed deploy pain is solved by the Lambda bundle milestone alone diff --git a/docs/release-checklist.md b/docs/release-checklist.md index dd1d6c87..302b3e3d 100644 --- a/docs/release-checklist.md +++ b/docs/release-checklist.md @@ -18,8 +18,8 @@ This checklist is for maintainers preparing a release/deployable build. - [ ] Confirm the release contract still matches reality in `docs/contracts/release-driven-deploy-contract.md` - [ ] Confirm `dist/release/` contains the published deploy assets: `lesser-lambda-bundle.tar.gz`, `lesser-lambda-bundle.json`, `lesser-release.json`, and `checksums.txt` -- [ ] Remember that release-driven Lambda assets are published now, but `lesser up` still validates deploys through the - source-based path until artifact-consumption support lands +- [ ] Remember that `--release-dir` artifact consumption exists now, but only the Lambda asset set is release-published; + CDK execution and auth UI deployment still depend on repo-local source until later immutable deploy milestones land ## Deploy From 1c72bb0f768309f49c77b65907b8b568b3cbdd39 Mon Sep 17 00:00:00 2001 From: Aron Price Date: Sun, 29 Mar 2026 21:03:40 -0400 Subject: [PATCH 6/7] pin deploy input taxonomy in schema --- cmd/lesser/release_contract_examples_test.go | 18 +++++++++++++++ .../deploy-assembly-descriptor.schema.json | 22 +++++++++++++++---- .../release-driven-deploy-contract.md | 1 + 3 files changed, 37 insertions(+), 4 deletions(-) diff --git a/cmd/lesser/release_contract_examples_test.go b/cmd/lesser/release_contract_examples_test.go index 9ddadf1a..b6c7e793 100644 --- a/cmd/lesser/release_contract_examples_test.go +++ b/cmd/lesser/release_contract_examples_test.go @@ -222,6 +222,24 @@ func TestDeployAssemblyDescriptorSchemaContract(t *testing.T) { compatibilityProps := anyMap(t, anyMap(t, properties["compatibility"])["properties"]) require.Equal(t, "lesser-release.json", anyMap(t, compatibilityProps["release_manifest_path"])["const"]) require.Equal(t, "deploy_assembly", anyMap(t, compatibilityProps["deploy_artifacts_key"])["const"]) + + defs := anyMap(t, schema["$defs"]) + instanceInputCategory := anyMap(t, defs["instance_input_category"]) + require.ElementsMatch(t, []string{ + "app_identity", + "aws_target", + "base_domain", + "hosted_zone", + "stage_plan", + "feature_config", + "managed_service_urls", + "provisioning_input", + "bootstrap_io", + }, anySliceToStrings(t, instanceInputCategory["enum"])) + + instanceInputProps := anyMap(t, anyMap(t, properties["instance_inputs"])["properties"]) + require.Equal(t, "#/$defs/instance_input_category", anyMap(t, anyMap(t, instanceInputProps["required"])["items"])["$ref"]) + require.Equal(t, "#/$defs/instance_input_category", anyMap(t, anyMap(t, instanceInputProps["optional"])["items"])["$ref"]) } func anyMap(t *testing.T, value any) map[string]any { diff --git a/docs/contracts/deploy-assembly-descriptor.schema.json b/docs/contracts/deploy-assembly-descriptor.schema.json index bbf8527f..8ea8002a 100644 --- a/docs/contracts/deploy-assembly-descriptor.schema.json +++ b/docs/contracts/deploy-assembly-descriptor.schema.json @@ -4,6 +4,22 @@ "title": "Lesser Deploy Assembly Descriptor", "type": "object", "additionalProperties": false, + "$defs": { + "instance_input_category": { + "type": "string", + "enum": [ + "app_identity", + "aws_target", + "base_domain", + "hosted_zone", + "stage_plan", + "feature_config", + "managed_service_urls", + "provisioning_input", + "bootstrap_io" + ] + } + }, "required": [ "kind", "schema_version", @@ -119,16 +135,14 @@ "required": { "type": "array", "items": { - "type": "string", - "pattern": "^[a-z][a-z0-9_]*$" + "$ref": "#/$defs/instance_input_category" }, "uniqueItems": true }, "optional": { "type": "array", "items": { - "type": "string", - "pattern": "^[a-z][a-z0-9_]*$" + "$ref": "#/$defs/instance_input_category" }, "uniqueItems": true } diff --git a/docs/contracts/release-driven-deploy-contract.md b/docs/contracts/release-driven-deploy-contract.md index b5f05ed2..7eec4e66 100644 --- a/docs/contracts/release-driven-deploy-contract.md +++ b/docs/contracts/release-driven-deploy-contract.md @@ -391,6 +391,7 @@ The contract boundary is: - app names, domains, hosted-zone IDs, account IDs, feature flags, and provisioning JSON stay outside published artifacts - the same release artifact set must be reusable across different customers and domains without rewriting the artifact bytes - operators and managed runners should map their local invocation surfaces into the same canonical input categories above +- `docs/contracts/deploy-assembly-descriptor.schema.json` freezes those exact category names so future descriptors cannot silently invent a different taxonomy This keeps the future deploy assembly usable by both modes: From 11f76a6e652b73ac62c9f11ceb510cf069ef9752 Mon Sep 17 00:00:00 2001 From: Aron Price Date: Sun, 29 Mar 2026 22:49:40 -0400 Subject: [PATCH 7/7] fix: accept canonical lambda bundles regardless of path order --- cmd/lesser/release_deploy_assets.go | 1 + cmd/lesser/release_deploy_assets_test.go | 15 +++++++++++++++ 2 files changed, 16 insertions(+) diff --git a/cmd/lesser/release_deploy_assets.go b/cmd/lesser/release_deploy_assets.go index 5cd82738..800a9832 100644 --- a/cmd/lesser/release_deploy_assets.go +++ b/cmd/lesser/release_deploy_assets.go @@ -286,6 +286,7 @@ func validateBundleAgainstInventory(repoRoot string, bundle releaseassets.Lambda for _, file := range bundle.Files { manifestNames = append(manifestNames, file.Lambda) } + slices.Sort(manifestNames) if !slices.Equal(lambdaNames, manifestNames) { return fmt.Errorf("lambda bundle manifest does not match canonical inventory") diff --git a/cmd/lesser/release_deploy_assets_test.go b/cmd/lesser/release_deploy_assets_test.go index 33569ed4..28c8bdd2 100644 --- a/cmd/lesser/release_deploy_assets_test.go +++ b/cmd/lesser/release_deploy_assets_test.go @@ -123,6 +123,21 @@ func TestInstallReleaseLambdaAssets_ErrorsWhenInventoryDoesNotMatch(t *testing.T require.Contains(t, err.Error(), "does not match canonical inventory") } +func TestInstallReleaseLambdaAssets_AcceptsPathSortedBundleManifest(t *testing.T) { + sourceRepo := testRepoWithCanonicalInventory(t, []string{"graphql", "graphql-ws"}) + require.NoError(t, os.MkdirAll(filepath.Join(sourceRepo, "bin"), 0o755)) + require.NoError(t, os.WriteFile(filepath.Join(sourceRepo, "bin", "graphql.zip"), []byte("graphql zip"), 0o644)) + require.NoError(t, os.WriteFile(filepath.Join(sourceRepo, "bin", "graphql-ws.zip"), []byte("graphql-ws zip"), 0o644)) + + releaseDir := testReleaseDirFromRepo(t, sourceRepo) + targetRepo := testRepoWithCanonicalInventory(t, []string{"graphql", "graphql-ws"}) + + result, err := installReleaseLambdaAssets(targetRepo, releaseDir, filepath.Join(t.TempDir(), "lambda-assets")) + require.NoError(t, err) + require.Equal(t, "v1.2.3", result.Version) + require.Equal(t, "0123456789abcdef0123456789abcdef01234567", result.GitSHA) +} + func TestVerifyReleaseChecksums_MissingEntry(t *testing.T) { releaseDir := t.TempDir() files := releaseFileSet{