Service Reference
Coverage matrix
Substrate ships 67 built-in service plugins. This section is generated from the plugin registry (make docs-reference), so the count and plugin list cannot drift from the implementation. The live count is also available from the /ready endpoint (curl http://localhost:4566/ready). Per-service operation, CloudFormation, and cost detail follows below the matrix.
| # | Service | Plugin name | Protocol |
|---|---|---|---|
| 1 | Account Management | account | REST/JSON |
| 2 | ACM | acm | JSON |
| 3 | API Gateway (REST) | apigateway | REST/JSON |
| 4 | API Gateway (HTTP) | apigatewayv2 | REST/JSON |
| 5 | AppSync | appsync | REST/JSON |
| 6 | Athena | athena | JSON |
| 7 | Backup | backup | REST/JSON |
| 8 | Batch | batch | REST/JSON |
| 9 | Bedrock Runtime | bedrock-runtime | REST/JSON |
| 10 | Budgets | budgets | JSON |
| 11 | Cost Explorer | ce | JSON |
| 12 | CloudFormation | cloudformation | Query |
| 13 | CloudFront | cloudfront | REST/XML |
| 14 | CloudTrail | cloudtrail | JSON |
| 15 | CodeBuild | codebuild | JSON |
| 16 | CodeDeploy | codedeploy | JSON |
| 17 | CodePipeline | codepipeline | JSON |
| 18 | Cognito Identity | cognito-identity | JSON |
| 19 | Cognito Identity Provider | cognito-idp | JSON |
| 20 | Config | config | JSON |
| 21 | DynamoDB | dynamodb | JSON |
| 22 | EC2 / VPC | ec2 | Query |
| 23 | ECR | ecr | JSON |
| 24 | ECS | ecs | JSON |
| 25 | EFS | efs | REST/JSON |
| 26 | ElastiCache | elasticache | Query |
| 27 | ELBv2 | elasticloadbalancing | Query |
| 28 | EMR Serverless | emrserverless | REST/JSON |
| 29 | EventBridge | eventbridge | JSON |
| 30 | API Gateway (execute-api) | execute-api | REST/JSON |
| 31 | Kinesis Data Firehose | firehose | JSON |
| 32 | FSx | fsx | JSON |
| 33 | Glue | glue | JSON |
| 34 | Health | health | JSON |
| 35 | IAM | iam | Query |
| 36 | Kinesis Data Streams | kinesis | JSON |
| 37 | KMS | kms | JSON |
| 38 | Lambda | lambda | REST/JSON |
| 39 | CloudWatch Logs | logs | JSON |
| 40 | CloudWatch | monitoring | CBOR / JSON / Query |
| 41 | MSK | msk | REST/JSON |
| 42 | HealthOmics | omics | REST/JSON |
| 43 | OpenSearch | opensearch | REST/JSON |
| 44 | Organizations | organizations | JSON |
| 45 | Price List Query API | pricing | JSON |
| 46 | QuickSight | quicksight | REST/JSON |
| 47 | RAM | ram | REST/JSON |
| 48 | RDS | rds | Query |
| 49 | Redshift | redshift | Query |
| 50 | Redshift Data API | redshift-data | JSON |
| 51 | Route 53 | route53 | REST/XML |
| 52 | S3 | s3 | REST/XML |
| 53 | SageMaker | sagemaker | JSON |
| 54 | EventBridge Scheduler | scheduler | REST/JSON |
| 55 | Secrets Manager | secretsmanager | JSON |
| 56 | Service Quotas | servicequotas | JSON |
| 57 | SES v2 | sesv2 | REST/JSON |
| 58 | SNS | sns | Query |
| 59 | SQS | sqs | JSON |
| 60 | SSM | ssm | JSON |
| 61 | SSO / Identity Store | sso | JSON |
| 62 | Step Functions | states | JSON |
| 63 | STS | sts | Query |
| 64 | Resource Groups Tagging | tagging | JSON |
| 65 | Timestream | timestream | JSON |
| 66 | Transfer Family | transfer | JSON |
| 67 | WAFv2 | wafv2 | JSON |
Every plugin in the matrix above now has a section below. Each is written and maintained by hand, and carries the operations substrate routes, the divergences from the published API with the issue that tracks each one, what a refusal reports, the CloudFormation resource types the service deploys, and its cost notes. A section describes what substrate does today rather than what AWS does: where the two differ, the difference is named and linked rather than smoothed over.
How a request reaches a plugin
A plugin that is registered is not necessarily reachable. Substrate resolves which plugin serves a request by reducing what the client sent to one lowercase service name, using four signals in priority order:
| # | Signal | Example | Notes |
|---|---|---|---|
| 1 | X-Amz-Target namespace | DynamoDB_20120810.GetItem | The only signal a --endpoint-url caller supplies beyond the credential scope |
| 2 | Host | dynamodb.us-east-1.amazonaws.com | Useless against substrate, where the host is localhost |
| 3 | URL path | /service/GraniteServiceVersion20100801/operation/GetMetricData | Smithy RPC v2 CBOR transport |
| 4 | SigV4 credential scope | …/us-east-1/dynamodb/aws4_request | The signing name, which is not always the endpoint prefix |
The order is what makes a routing bug hard to see. The target is checked first, so an unrecognized namespace short-circuits signals 2 and 4, both of which would often have answered correctly. Substrate is reached by pointing a client at --endpoint-url http://localhost:4566, where signal 2 is localhost and signal 3 is absent — so for a JSON service the target is effectively the only signal, and a namespace substrate does not recognize answers ServiceNotAvailable for every operation of that service while the plugin's own tests stay green.
That has happened repeatedly: sso (#561), organizations and config (#580), eventbridge (#734), and then monitoring, health, cloudtrail and timestream (#739). Each was found by pointing a real client at a running server, not by the test suite.
The routing table
emulator/routing.go records, for every registered plugin, the identifiers a real AWS client sends: the target namespace, one example endpoint host per distinct shape, and the SigV4 signing names. Every row cites the source it was read from, and the citation names which source — see CloudTrail below for why that matters.
The table has three consumers, so it cannot rot quietly:
- the generated coverage matrix above reads its display names and protocols;
make docs-reference-checkfails if a registered plugin has no row, or a row names no registered plugin;- a sweep test drives every identifier in every row through the parser and asserts the result is a registered plugin — not merely the expected string.
Adding a plugin therefore means recording how a client addresses it. That is the check whose absence let four plugins ship unreachable.
The four that were unreachable
| Plugin | What the client sends | What it reduced to | Who saw the failure |
|---|---|---|---|
monitoring | X-Amz-Target: GraniteServiceVersion20100801.{Op} | nothing — the name was in the Smithy path table and absent from the target table | every AWS CLI and boto3 caller |
health | X-Amz-Target: AWSHealth_20160804.{Op} | nothing — the table held an invented healthservice instead | every SDK |
health | Host: global.health.amazonaws.com | global | a caller using the real endpoint |
cloudtrail | X-Amz-Target: com.amazonaws.cloudtrail.v20131101.CloudTrail_20131101.{Op} | com | the AWS CLI and boto3 only |
timestream | Host: ingest. / query.timestream.{region}.amazonaws.com | ingest / query | a caller using endpoint discovery |
Two of these are worth understanding rather than just recording.
CloudTrail is why a source citation must name the SDK. botocore sends the model's fully-qualified namespace; aws-sdk-go-v2 sends the terse CloudTrail_20131101. The Go SDK's form always worked, so substrate's suite and its real-SDK end-to-end tier — both of which drive aws-sdk-go-v2 — were green while every CLI and boto3 CloudTrail call answered ServiceNotAvailable. An alias could not fix the long form, because its first label is com, which prefixes every fully-qualified namespace and would hijack any other service that adopted one. Substrate now reduces a dot-qualified namespace to its last segment, mirroring what operation extraction already did. Of the 430 botocore models only three carry a dotted prefix — cloudtrail, codeconnections and codestar-connections, the latter two not substrate plugins — and their last segments do not collide.
CloudWatch is now drivable three ways, and that closes the gap this section used to carve out. Its service shape declares awsQuery, awsJson1_0, rpcv2Cbor andawsQueryCompatible at once, and its clients disagree about which to use: aws-sdk-go-v2 posts Smithy RPC v2 CBOR, the AWS CLI and boto3 post awsJson1_0 under an X-Amz-Target, and a hand-rolled client posts a query form. Substrate serves all three (#785) — a request is normalized into query parameters before dispatch and the reply rendered from one ordered document — so the ten operations answer bytes each client can deserialize, and since #757 a refusal is shaped the same way: a CBOR caller gets a CBOR error naming the modeled shape in __type, a JSON-RPC caller gets a JSON one, a Query caller gets <ErrorResponse>, and a caller that sent X-Amzn-Query-Mode: true also gets the Query code back in an x-amzn-query-error header. The plugin previously answered XML on every protocol, so aws-sdk-go-v2 reported deserialization failed, expected map for struct, got major type 1 — 0x3C, the leading <, read as a CBOR major type — and the AWS CLI printed nothing at all. Substrate's own suite was green over it, because every CloudWatch test posted a form body and read the XML back as a string; see Protocols under CloudWatch for what each client now gets.
Plugins that are deliberately not addressable three ways
Three rows cannot assert all three signals, and say so in prose rather than being skipped:
apigatewayv2sharesapigateway's endpoint host and signing name, and neither client sends a target. The two are separated by the/v2/path segment instead (#529).execute-apiis addressed by a host whose first label is the API's ID — data, not a service name — so the match is on theexecute-apilabel. There is no service model; this is the runtime endpoint, which no SDK-generated client calls.opensearchis data-plane only. A managed domain's host and a Serverless collection's host both carry the resource name ahead of thees/aosslabel, and the OpenSearch Serverless control-plane target prefix names operations substrate does not implement, so it is absent on purpose.
Coverage limits
All 67 rows were read from the botocore models bundled in a locally installed AWS CLI v2. The JSON ones were cross-checked against aws-sdk-go-v2 serializers. The Java, JavaScript and .NET SDKs were not checked, so a namespace only they send would not have been caught here. Where a per-service section below states a target prefix, that prefix is what the model declares; substrate also accepts the older spellings it used to document, so correcting one of them cannot break a caller.
An operation substrate does not implement
No plugin covers its whole service, so every one of the 67 has an answer for a call it does not serve. That answer is decided by the Protocol column above and by nothing else — not by which plugin the call reached, and not by who wrote it.
| Protocol | Code | HTTP | Message |
|---|---|---|---|
| JSON, REST/JSON | UnknownOperationException | 404 | The action <name> is not recognized. |
Query, ec2, REST/XML | InvalidAction | 400 | The action <name> is not valid for this endpoint. |
<name> is the operation name for a service that dispatches on one, and the HTTP verb and path — POST /substrate-no-such-path — for a REST service whose router matched nothing. A REST operation is identified by the pair, so both halves are reported: DELETE and POST on one path are different operations, and the same verb on a mistyped path is the more common mistake.
The name is always present. It is what lets a consumer tell substrate has not implemented this call from this call was rejected, which is the difference that decides whether to change the test or the code under test.
Where the two rows come from
UnknownOperationException at 404 is AWS's, quoted from its Common Errors pages: "The action or operation isn't recognized. Verify that the action name is spelled correctly and that it's supported by the API version you're using." Both JSON families publish it and both publish the 404 — checked on DynamoDB's for JSON and Lambda's for REST/JSON.
InvalidAction at 400 is a citation, which corrects the reading this row carried when #716 wrote it. The pages AWS has regenerated publish no unknown-action code for the Query, ec2 and REST/XML families — the eighteen-entry Query list is byte-identical on EC2, RDS, IAM, ELB and CloudFormation, and the InvalidAction entry it once carried is gone from all five. But #1064 found it is not gone from the reference: SQS's Common Errors page is the one Query-family list AWS has not regenerated, and it still publishes InvalidAction at exactly this 400.
The citation is one step removed, and the row says so rather than overclaiming: substrate routes sqs itself as a JSON-RPC service, so no substrate endpoint reaches this arm for SQS. What SQS's page establishes is that the Query family's code for this condition is still published, not that any one service answers it. See the three generations.
Two things this is not
It is not a Go type name. Until #716 every plugin wrote this refusal itself — 59 sites in eight wordings, each leading with the plugin's Go type ("SSMPlugin: unknown operation "Foo""). No AWS endpoint emits that, so a consumer's error branch was matching on a substrate internal. A tripwire test now sweeps all 67 plugins and fails if any answer contains the word Plugin.
It is not InvalidAction on a JSON service. Forty-four of those 59 sites answered the Query protocol's code at 400 on a service whose protocol is JSON, which is what the table above corrects. A test asserting InvalidAction or a 400 for an unimplemented call on a JSON or REST/JSON service needs both the code and the status from the first row — see the release that landed #716 in CHANGELOG.md for the upgrade note. The change is visible for every service in the matrix except the nine Query, ec2 and REST/XML ones.
One shape difference survives on purpose: IAM answers a refusal as a 4xx response document rather than as an error object. Both conventions are in use across the tree (#516 is about that), so #716 changed where IAM's code, message and status come from and left the shape alone.
A request body that will not parse
An unimplemented operation is refused before a plugin sees it. A body that arrives at a real operation and then fails to decode is the plugin's own refusal, and it was the largest single source of unpublished error codes in the tree. 110 sites in fourteen services answered a code their own operation does not publish — in most cases a code that appears nowhere in the service's documentation at all — and all 110 are corrected (#923, #1003, #950). Three further services — MSK, CloudWatch and Bedrock Runtime — had the right code and the wrong message, and kept the code.
Every one of the 110 already answered HTTP 400, and 400 is what every published counterpart carries, so this whole class was invisible to #923's status audit. It is a code-string and message defect throughout.
The rule: a per-operation code where one covers every guarded site, otherwise the common page
There is no single answer, because AWS does not give one. The rule the audit settled on, applied uniformly:
- If every operation carrying a guard publishes the same input-validation code in its own Errors section, use that code.
- Otherwise fall back to the service's common-errors page, which is where an error belonging to no single operation belongs.
Step 1 is preferred because a per-operation code is a stronger citation: the page that receives the request names it. Step 2 exists because a body that will not parse belongs to no operation — substrate cannot know which operation the caller meant beyond the X-Amz-Target header or the path, and it certainly cannot know which parameter was wrong, since nothing was decoded.
The rule is why the table below is not one column of one code, and why two services under one reference can legitimately differ: Budgets and Cost Explorer share aws-cost-management/latest/APIReference/CommonErrors.html, but Budgets publishes InvalidParameterException on all five of its guarded operations and Cost Explorer publishes no validation code on any of its three, so Budgets takes step 1 and Cost Explorer step 2.
It is also why Firehose loses InvalidArgumentException even at the one site that publishes it. A missing DeliveryStreamName is one condition, and answering it with two different codes depending on which operation received it is the failure mode a uniform choice exists to avoid.
The inventory
| Service | Sites | Answered | Answers now | Provenance |
|---|---|---|---|---|
| Step Functions | 15 | InvalidRequest | ValidationError / 400 | Common page. ValidationException is published on only 5 of the 15 |
| Kinesis | 16 parse + 3 member | InvalidParameterException | InvalidArgumentException / 400 | All 16 operation pages |
| EventBridge | 7 parse + 6 member | InvalidParameterException | ValidationError / 400 | Common page. EventBridge publishes no validation code on any guarded operation |
| Systems Manager | 12 | InvalidRequest ×10, SerializationException ×2 | ValidationError / 400 | Common page. None of the 12 publishes one |
| SageMaker | 6 parse + 4 conflated | InvalidParameterValue | ValidationError / 400 | Common page. 0 of 6 publish any validation error |
| ACM | 6 | InvalidParameterException | ValidationError / 400 | Common page. InvalidParameterException is on 3 of 6, ValidationException on 5 of 6 |
| Lambda | 4 parse + 3 member | ValidationException | InvalidParameterValueException / 400 | All 4 operation pages |
| Firehose | 3 parse + 3 member | MalformedData, InvalidArgumentException | ValidationError / 400 | Common page. InvalidArgumentException is on 1 of 3 |
| EFS | 4 | MalformedData | BadRequest / 400 | All 4 operation pages, which is why the answer is unaffected by #1064 finding that EFS's API-reference common-errors page now exists |
| Service Quotas | 3 parse + 1 member | SerializationException, ValidationException | IllegalArgumentException / 400 | All 4 operation pages |
| Budgets | 5 | MalformedData | InvalidParameterException / 400 | All 5 operation pages |
| Cost Explorer | 3 | MalformedData | ValidationError / 400 | Common page. None of the 3 publishes a validation code |
| SES v2 | 1 parse + 3 member | MalformedData, BadRequest | BadRequestException / 400 | All 5 operation pages |
| Batch | 2 of 8 | InvalidParameterValue | ClientException / 400 | All operation pages. No common-errors page exists |
| Bedrock Runtime | 2 | ValidationException (code correct) | ValidationException / 400, unchanged | Both operation pages |
| MSK | 2 parse + 9 member | BadRequest, message leaking | BadRequest / 400, unchanged | Nothing published — see below |
| CloudWatch | 1 | SerializationException, message leaking | unchanged | Substrate's reading — see the CloudWatch section |
The Sites column counts places in the source, not operations, and the two numbers differ wherever handlers share a decoder. Service Quotas' three parse sites are reached from five operations, because sqUnmarshal serves three of them; MSK's eleven from eleven. The wire tests are written per operation rather than per site, since an operation is what a caller can reach, which is why invalid_body_inventory_test.go names five Service Quotas cases against the three counted here.
Fourteen services were audited and found already correct, and are listed here so they are not re-derived: Cognito Identity Provider (28 sites), Secrets Manager (11), ECR (15), ECS (15), CloudWatch Logs (10), Athena (7), API Gateway (6), API Gateway v2 (5), Cognito Identity (5), Resource Groups Tagging (3), Bedrock (2), EventBridge Scheduler (2), EMR Serverless (1), HealthOmics (1).
That the common page is AWS boilerplate is what makes step 2 transfer
Step Functions', Systems Manager's and KMS's common-errors pages are byte-identical: the same fifteen entries in the same order with the same statuses. The code is spelled ValidationError, with no Exception suffix — "The input doesn't meet the required format or constraints. Check that all required parameters are included and that values are valid." The same entry, with the same status and the same gloss, is what EventBridge's, SageMaker's, ACM's, Firehose's and Cost Management's pages publish, so a finding at one service transfers rather than being a coincidence to re-derive per plugin.
One page is not the boilerplate and is worth naming, because it is why a service was verified operation by operation rather than by transfer: Secrets Manager's common-errors page has 24 entries, not fifteen. Its eleven sites were each checked, and each was already correct.
The reference has three common-errors generations, not two
Re-verified across twenty-two pages on 2026-09-18 for #1064, because a code sourced from one of these pages before AWS regenerated it may now cite a page that no longer says it. There are three live generations, and which one a service serves decides what step 2 can be cited for:
| Generation | Entries | Verified identical on | ValidationError gloss |
|---|---|---|---|
| JSON / REST-JSON | 15 | KMS, Systems Manager, Step Functions, Lambda, SageMaker, Firehose, Cost Management, Organizations, EventBridge, ACM, RAM, CloudTrail, Glue, FSx, WAFv2, DynamoDB — sixteen services, byte-identical | "The input doesn't meet the required format or constraints. Check that all required parameters are included and that values are valid." |
Query / ec2 | 18 | EC2, RDS, IAM, ELB, CloudFormation — five services, byte-identical | identical to the JSON generation's, word for word |
| Legacy Query | 18, a different set | SQS alone | "The input fails to satisfy the constraints specified by an AWS service." |
Three consequences worth stating once rather than re-deriving per plugin:
- The two current generations agree on
ValidationError's status and gloss, so step 2 transfers across the protocol boundary as well as within it. They differ on exactly one status:IncompleteSignatureis 403 on the JSON list and 400 on the Query list. - SQS is a live counterexample to consolidation, and this is load-bearing twice over. Its page is the older generation, so
sqsInvalidBody's gloss ("fails to satisfy the constraints") is correct for SQS and would be wrong anywhere else — the two spellings in the tree are two page generations, not one stale citation. And SQS's list still carriesInvalidActionat 400, which is why the unknown-action row is a citation rather than substrate's invention. SQS also inverts three statuses against the current Query list:AccessDeniedException400 (against 403),ThrottlingException403 (against 400),NotAuthorized400 (against 401). - Every other citation in the tree survived the sweep. All fifty-five non-test common-errors citations in
emulator/resolve to a page that still says what they quote, so #1064 produced no code corrections in the #950 class — the two corrections it did produce are both provenance, recorded below.
ValidationError and ValidationException are different codes, and ACM publishes both
This distinction has been re-derived more than once, so it is recorded here. They are not spellings of one code:
ValidationError/400 is a common error. It appears only on a common-errors page, in all three generations, and never in an operation's own Errors section.ValidationException/400 is a service-specific error, published by many services in the Errors section of individual operations, and it appears on no common-errors page in any generation.
So a doc comment citing "the common-errors page" for ValidationException is citing something no such page says, and one citing an operation page for ValidationError is doing the reverse. ACM is the worked example, because it publishes both with different glosses. Its common-errors page carries ValidationError/400 glossed "The input doesn't meet the required format or constraints…", while ListTagsForCertificate and DescribeCertificate each publish a three-code Errors section — InvalidArnException, ResourceNotFoundException, ValidationException, all 400 — in which ValidationException is glossed "The supplied input failed to satisfy constraints of an AWS service." Substrate answers each from its own source: acmValidationError cites the operation pages for ValidationException, and acmInvalidBody cites the common page for ValidationError.
Two services publish no common-errors page, and EFS is no longer one of them
Batch and API Gateway v2 have no fifteen-entry page to fall back to: both CommonErrors links resolve to a meta refresh onto the service's welcome page. MSK's does the same. For each of them step 1 was the only route available, and in each it was open — Batch publishes ClientException, API Gateway v2's five sites were already correct.
#950 recorded EFS as a third such service and #1064 found that stale. The page that redirects is the user guide's api-errors.html; the API reference's CommonErrors.html now publishes the full fifteen-entry list. This is the one direction the sweep was not looking in: consolidation did not only change what a page says, it created pages that did not exist.
EFS's answer is unchanged regardless, and by the rule rather than by luck — step 1 outranks step 2, so a common page appearing underneath a satisfied step 1 changes nothing. BadRequest/400 stays, still cited to the Errors section of all four guarded operations.
This remains the strongest argument for preferring step 1. A rule that depends on a page some services do not have is a rule with holes in it — and, as EFS shows, a rule whose holes move.
One service publishes nothing to check against
MSK is the single row above whose code could not be verified against anything, and it kept what it had. Three sources would normally settle it and each is absent:
- There is no common-errors page.
- The operation pages carry no Errors section —
clusters.htmldocuments response codes only, and theErrorschema it names has members{message, invalidParameter}, with noCodemember, so nothing on the page states a code string a caller could match on. CreateClusterV2, one of the two operations carrying a guard, has no documentation page.
So BadRequest stays, recorded as substrate's reading rather than presented as modelled, the same treatment CloudWatch's SerializationException gets. Inventing a code on no evidence would be worse than keeping one that has at least been shipped. The status is not in doubt: 400 is what every site already answered and what the response codes on clusters.html give a client error. If MSK ever publishes an Errors section, this is the one row in the table that should be revisited.
Two near misses at Step Functions, and why neither is the answer
| Candidate | Published | Declined because |
|---|---|---|
ValidationException | Step Functions publishes it at 400 on five of the fifteen operations that carry a parse guard | Answering it everywhere leaves ten sites reporting a code their own operation does not publish — the same defect relocated. Answering it at only five makes one failure produce two codes inside one plugin. |
MalformedHttpRequestException | On the common page at 400 | Its published scope is the transport layer: "the request body can't be processed. This typically happens when the request body can't be decompressed using the specified content encoding algorithm." A body that arrived intact and then failed to parse is not that. |
ACM is the same shape one operation smaller, and it is why that plugin carries two codes side by side on purpose. ValidationException is published on five of ACM's six guarded operations and not on RequestCertificate; InvalidParameterException is on only three. So a CertificateArn breaking a published constraint answers ValidationException, because every operation taking a certificate publishes it, while a body that would not parse belongs to no operation and takes the code from the page that belongs to no operation.
The trap worth naming: an error mentioned in prose is not an error a shape publishes. Five Systems Manager pages name ValidationException in prose — "if the specified name for a parameter contains spaces between characters, the request fails with a ValidationException error" — and none of the five lists it in an Errors section. Four Step Functions pages do the same for the Distributed Map note. A reader who greps for the word finds a pattern the reference does not actually publish, which is how the wrong code survives a careful reading.
And the trap that vindicated checking every guarded operation rather than a representative one: Firehose. InvalidArgumentException at 400 is published for CreateDeliveryStream — the operation anyone would check first — and for neither of the other two. DescribeDeliveryStream publishes exactly one error, ResourceNotFoundException, and DeleteDeliveryStream two, ResourceInUseException and ResourceNotFoundException. Applying the obvious replacement from the representative page would have been wrong at two of three sites, which is this whole defect relocated.
The message describes the request, not the emulator
Twenty-two sites, across eight services, passed encoding/json's own error text through as the message, so a caller was told which Go struct field failed to unmarshal by an endpoint that is meant to look like AWS. Service Quotas passed it bare, with no prefix at all; EFS, Firehose, SES v2, Budgets, Cost Explorer, MSK and CloudWatch prefixed it with "invalid JSON body: " or "invalid JSON: ". The message is now substrate's: "the request body is not valid JSON". Nothing a caller can act on was lost, because the only actionable fact is that the body was not JSON.
CloudWatch is the one place where a decoder's text is still appended, and the distinction is deliberate. Its CBOR arm reports cborDecode's message, which is substrate's own and describes the wire — "cbor: 3 trailing byte(s) after the top-level item" — so it is useful to the caller who wrote those bytes. Its JSON arm no longer appends anything, because encoding/json's text describes Go.
A parse failure is not a missing member
Five sites conflated the two, testing if err != nil || body.X == "" and reporting the missing member for both — four in SageMaker and one in Bedrock Runtime's CreateModelInvocationJob. They are split, because the two conditions call for different fixes by the caller: one sends different bytes, the other adds a member, and telling a caller their jobName is missing when their JSON is truncated sends them to look at the wrong thing.
The remaining conflated sites, in Athena and Secrets Manager, are recorded here rather than changed. There the required member is the only member the handler reads, so the two answers coincide, and splitting them would add a branch that cannot change what a caller does.
Why a green suite held all of them
Every test that builds a request from a Go value is structurally incapable of reaching these guards: json.Marshal produces valid JSON by construction. The only way in is to hand the server bytes. Nothing in the suite did, at any of the seventeen services, which is exactly why the whole class survived — and why, when 110 codes were corrected, exactly one existing test failed: the Service Quotas case that had asserted SerializationException deliberately.
The suite now sends bytes: one case per guarded operation, asserting the status and the code together, since a decoded error struct carries only the code and a consumer's retry logic branches on the status. Each table names every operation that carries a guard rather than a representative sample, because the defect was per-site duplication of one literal and the assertion that matters is that no site was missed. emulator/invalid_body_inventory_test.go carries 218 guarded operations in 42 services — 66 in fourteen when that sentence was first written, grown by #1007's third slice and by #1066's thirty-four — plus 35 member-complaint sites in nine (below), and emulator/invalid_body_code_test.go the Step Functions and Systems Manager sites #1003 fixed.
Three different 35s appear in this section and they count three different things, which is worth saying once rather than leaving a reader to infer it: the 35 member-complaint sites just named (#1062), the 35 files that already contained a checked guard when #1007 began (next section), and the 35 guards that were checked and still leaked encoding/json's own error text (#1066, below). No two of the three share a site.
Two of the 66 needed a resource to exist first, which was worth recording because it is the one way a guard can be present, correct and still untested. Lambda's AddPermission and TagResource looked up the function before they parsed the body, so on an empty emulator both answered ResourceNotFoundException/404 and never reached the guard at all. The two sites were therefore only reachable with a function in place, and a test that did not create one would have reported success while asserting nothing.
A guard that was never there: the discarded unmarshal error
The 110 sites above answered the wrong code. A second, larger class answered no code: 95 sites across 38 files wrote _ = json.Unmarshal(req.Body, &input) and carried on with a zero-valued input, so a body that would not parse was not refused at all. That is worse than a wrong code, because a wrong code is at least an error: a discarded one produces a plausible success, or a refusal about something else entirely. It is filed as #1007 and was corrected in three slices, since each service needed its own published code under the two-step rule above. All 95 are now corrected, and one site is deliberately retained with its reachability recorded — see the third slice below for the check that keeps it the only one.
SQS and AWS Health are the first slice, and they are first because they were the only files in the inventory with no checked guard anywhere to copy a code from — the other 35 files already contained one, which is why 80 of the 95 sites need no fresh archaeology. (A different 35 from #1062's sites above and from #1066's leaks below; this one counts files.) Both resolve to step 2:
| Service | Code | Status | Provenance |
|---|---|---|---|
| SQS | ValidationError | 400 | Common Errors: "The input fails to satisfy the constraints specified by an AWS service". No SQS operation page names an undecodable body. |
| AWS Health | ValidationError | 400 | Common Error Types: "The input doesn't meet the required format or constraints". DescribeEventDetails publishes only UnsupportedLocale. |
Nearer-looking codes on the same pages are deliberately unused, and the reason is the same each time — a code that misdescribes the fault sends the reader to the wrong place. SQS's InvalidParameterValue names "the input parameter", and a body that will not parse has no parameter to name; MalformedQueryString is published at 404 and describes a query string; MissingParameter asserts which parameter is absent, which is unknowable when nothing decoded. Health's MalformedHttpRequestException/400 is specifically about a body that cannot be decompressed under the declared content encoding, so it would send a caller to check its Content-Encoding header over a syntax error in its own JSON.
Four SQS operations were answering an actively misleading code. GetQueueAttributes, DeleteQueue, ListQueueTags and PurgeQueue read the queue URL through one shared helper and passed it straight to the queue lookup, so a discarded decode yielded an empty URL and the lookup answered QueueDoesNotExist: the emulator reported a missing queue when the queue was fine and the body was not. That is the most expensive kind of wrong answer, because it sends the reader to look at infrastructure rather than at the request. The helper now returns the refusal, and a test creates the queue first so that a passing assertion is known to come from the guard rather than from a lookup that happened to fail for another reason — while a well-formed request for an absent queue still answers QueueDoesNotExist, so the lookup moved rather than went away.
Nine SQS handlers decode the body twice, and the second decode catches a different fault. They read the queue URL through the shared helper and then decode the same bytes into their own struct, so a body that will not parse has been refused one call earlier and their own guard can never see one. It is still reachable, by a body that parses cleanly but contradicts a member's type — {"QueueUrl": "…", "Attributes": "not-a-map"} — because the first decode skips a field its target does not declare without type-checking it. That shape answers the same ValidationError/400, and the offending member is deliberately not named: naming it would mean answering InvalidParameterValue for a type mismatch and ValidationError for a syntax error on the same operation, which is exactly the one-plugin-two-codes split #950 removed. The common-errors gloss covers both.
One discard in this slice is retained, with its reachability recorded rather than a second refusal invented: sqsRequestedAttributeNames decodes the same body a second time for ReceiveMessage's attribute selectors, and ReceiveMessage has already refused an unparseable body through its own guard before that helper runs. sqs_plugin.go's FIFO deduplication decode is also left alone, because it reads stored state rather than a request — a corrupted state blob is not a caller error and answering a caller-error code for one would be a new defect.
The second slice is the twelve services that already had the answer. Twenty sites in files whose own *InvalidBody() constructor was sitting a few hundred lines away, carrying the provenance #950 and #1003 established for it — Lambda (4), Step Functions (3), KMS (2), SES v2 (2), Firehose (2), and one each in EventBridge, EFS, Systems Manager, Service Quotas, SageMaker, Kinesis and ACM. No new AWS reading was needed for any of them, which is the whole reason this slice is separable from the first.
Fourteen of the twenty were hiding behind a true statement. They sat inside if len(req.Body) > 0 { _ = json.Unmarshal(…) }, and six carried the comment //nolint:errcheck // optional body. The body is optional on all fourteen — they are list operations whose answer to an empty request is "everything" — but that is what the length check is for. Discarding the error from a body that is present is a second, separate decision, and the stated reason for the first was covering it. Both now hold at once: an absent body still lists everything, and a present body that will not parse is refused. TestInvalidBodyLeavesAnAbsentBodyAlone asserts the first, because a guard that refused an empty body would satisfy every refusal assertion in the tables and break every consumer that lists without filters.
Three of the twenty needed more than the error checked:
ListAppshad no length check at all, so a plain guard would have refused the empty body AWS accepts. It gained the check the other thirteen already had. Both of its members are filters.- Service Quotas' site was bypassing
sqUnmarshal, a helper in its own file that already handles the absent body and returns the refusal. It was the only site in that file decoding by hand, which is why it was the only one still discarding. It now goes through the helper. - Lambda's four update operations parsed below their resource lookup, so adding the guard there would have answered
404for an unparseable body naming an absent function whileAddPermissionandTagResource— moved above the lookup by [#1006] — answer400for the same request. One plugin, two codes, for one class of caller error is what [#950] removed, so all six now parse first. This does not settle the tree-wide question recorded under "Whether a body is parsed before the resource is looked up" below; it makes one plugin answer one code.
Six of the twenty are not reachable on POST — four Lambda updates and EFS's UpdateFileSystem are routed on PUT, and ListEmailIdentities on GET with its filters in the body — so they carry their own table rather than being quietly absent from the POST one.
Two adjacent gaps are recorded rather than folded in: DescribeEventDetails publishes eventArns as Required: Yes with Array Members 1–10 and substrate answers an empty successfulSet for an absent list, and SQS's own required-member checks are not part of this class. Both are separate divergences from "a malformed body is not refused", and are filed rather than mixed into a sweep whose whole value is being mechanical.
The third slice is the inline-literal tail: the remaining sixty sites, across twenty-three files that spelled a body-parse refusal as an &AWSError{…} literal at every site rather than through a constructor. Those literals are why this slice is separate and why it is the one that found wrong codes: a code repeated inline twenty-six times is a code no reviewer ever sees twice in one screen, so a borrowed one survives indefinitely. The sixty refusals now go through twenty-one constructors collected in one file, emulator/invalid_body_refusals.go, on the reasoning that the thing under review is a single decision repeated twenty-one times — which code does this service publish for a body that will not parse? — and a reviewer reading them side by side can see a borrowed code that a reviewer reading one plugin cannot.
Four of the twenty-one were sourced from a service that does not publish them. Each was verified against the service's own Common Errors page, its operation pages, and its exception-class list before being replaced:
| Service | Was | Is | Why the old code was wrong |
|---|---|---|---|
| RAM | MalformedQueryString/400 | ValidationError/400 | Absent from RAM entirely. It is published at 404 on the Query-protocol page and describes the URL query string, not a body. |
| CloudTrail | InvalidParameterCombinationException/400 | ValidationError/400 | Means two parameters that cannot be used together; not published on LookupEvents at all. A body that will not parse yields no parameters to combine. |
| Glue | InvalidParameterValueException/400 | InvalidInputException/400 | Absent from Glue's Common Errors page, from CreateDatabase/GetTables/StartJobRun, and from all thirty-six AWSGlueException subclasses. Glue publishes InvalidInputException, "The input provided was not valid.", on every operation page. |
| FSx | InvalidRequest/400 | BadRequest/400 | Absent from FSx's Common Errors page, from DescribeFileSystems and DeleteFileSystem, and from all thirty-five AmazonFSxException subclasses. InvalidRequest is an Amazon S3 code — the likely provenance of the mistake. FSx's Java class is BadRequestException, but the wire code carries no suffix, so the file's no-suffix instinct was right and only the stem was wrong. BadRequest is the first of DeleteFileSystem's five published errors, which is where #1063 landed the required-member refusal below. |
Only these four constructors changed a code outright; WAFv2's is a re-reading recorded below, and the other sixteen carry forward what their file already answered. Twenty-one constructors cover twenty-one of the twenty-three files; the other two needed none — OpenSearch's refusal is not an AWSError at all (see below), and Batch already had batchClientError.
But a code correction cannot stop at the sites a sweep happens to touch. Leaving the other body-parse guards in those five services on the old code would have made RAM, CloudTrail, Glue, FSx and WAFv2 each answer two different codes for the identical caller error — the one-plugin-two-codes split #950 removed, and a direct violation of the invariant invalid_body_inventory_test.go states, which is one code per service for this condition. So the forty-six pre-existing body-parse guards were folded in as well (RAM 1, CloudTrail 6, Glue 26, FSx 2, WAFv2 11), located by their "invalid JSON" message text rather than by hand, which also removed forty-six err.Error() message leaks.
What #950 left answering the old code in those five services was the required-member class, not the body-parse one: Glue's DatabaseInput.Name check and its three resolveGlueARN failures, FSx's FileSystemId check, and six WAFv2 WAFInvalidParameterException required-member literals — the last inconsistent with WAFv2's own #755 reading below, under which an omitted member is ValidationError. Each of those is a per-operation code decision rather than a mechanical edit, so they were filed separately as #1063 and corrected there: Glue's four now answer InvalidInputException/400, FSx's one BadRequest/400, and WAFv2's six ValidationError/400, matching each service's body-parse constructor. Three findings came out of doing it, each recorded where it applies:
- FSx's site is in
DeleteFileSystem, notDescribeFileSystems, whereFileSystemIdisRequired: Yes.DescribeFileSystemsmarksFileSystemIdsRequired: Noand an absent list means describe them all, which substrate already does — so the guard was in the right place and only the code was wrong. - Glue's
resolveGlueARNreturns the refusal itself rather than a bare error its three callers wrap. All four of its failures are complaints about the shape of the string and none reads state, which is whyEntityNotFoundException— published at 400 on all three tagging pages — applies to none of them: nothing has been looked up. A well-formed ARN that addresses nothing is that code's condition and is recorded as an open divergence, sinceGetTagson an untagged-and-absent resource answers an empty tag set. - WAFv2's
Idcheck could not move wholesale.GetWebACLmarksARN,Id,NameandScopeallRequired: No, whileGetWebACL's three siblings and all threeIPSetreaders mark theirsRequired: Yes. So the shared helper's check moved out to the callers that need it, andGetWebACLreports that the request addressed nothing rather than naming a member its own page says is optional.
WAFv2 is a judgement recorded rather than a correction. Its other guards answer WAFInvalidParameterException, which is published at 400 — so unlike the four above it is not sourced from nowhere. The body-parse sites answer ValidationError/400 instead, because substrate has already drawn this line and tested it: wafv2_createipset_validation_test.go records, from #755, that an omitted required member is ValidationError while a present-but-invalid value is WAFInvalidParameterException. A body that will not parse yields no members at all, so it falls on the first side of a distinction already made. AWS glosses WAFInvalidParameterException as "AWS WAF didn't recognize a parameter in the request", all four of its published examples concern a value that was read, and it carries Field, Parameter and Reason members substrate cannot truthfully fill for a request that never deserialized. WAFInvalidRequestException does not exist.
DynamoDB's SerializationException is kept although no AWS reference publishes it. It is absent from the Common Errors page, from the developer guide's error list, from Programming.LowLevelAPI.html and from DynamoDB's model; the only AWS-published uses of the name are a Lambda client-side helper and a Smithy Kotlin serde class. On the wire it arrives under the Coral namespace (com.amazon.coral.service#SerializationException) rather than DynamoDB's own com.amazonaws.dynamodb.v20120810#, because the protocol layer rejects the body before the request reaches the service — which is exactly why the service never documents it. It is retained for wire fidelity, since an SDK's retry classifier reads the code it receives and not the one the page omits. Its provenance is observed behaviour rather than the API model, which is the one code in this slice that is not a citation.
OpenSearch's three sites are not an AWS control-plane API at all. They are the domain's own REST search API, so no AWS reference publishes a code for them and the refusal is not an AWSError: openSearchInvalidBody returns json_parse_exception/400 through openSearchError, following the file's existing convention of the engine's own lowercased exception name (resource_already_exists_exception, illegal_argument_exception). The provenance is engine behaviour, recorded as such.
A structural finding that outlives this issue: AWS has consolidated the JSON-protocol Common Errors boilerplate. The RAM, CloudTrail, DynamoDB, Glue, FSx and WAFv2 Common Errors pages are now byte-identical fifteen-code lists, distinct from EC2's longer Query-protocol list. The shared list publishes ValidationError/400 — note ValidationError, not ValidationException — and its only body-scoped code, MalformedHttpRequestException/400, is published as being about decompression and content-encoding rather than JSON syntax. Any code in the tree that was sourced from a per-service Common Errors page before the consolidation may therefore cite a page that no longer says it.
That risk was swept in #1064 and the result is recorded under the three generations: the consolidation is not sitewide, SQS being a live counterexample, and all fifty-five citations in emulator/ survived. The two corrections were both provenance — EFS's page now exists, and InvalidAction turned out to be citable.
The "no bare _ =" criterion needed a script, because errcheck cannot state it. To errcheck, a bare _ = json.Unmarshal(req.Body, …) and a //nolint:errcheck carrying a written reason are the same construct: the assignment is explicit, so the error is "handled". Telling those two apart is the whole point of the criterion, so it is enforced by scripts/check-discarded-unmarshal.sh and make discarded-unmarshal-check, in the same shape as scripts/check-doc-versions.sh — a rule a linter cannot express is still a rule. The script's allowlist is keyed by file and each entry must carry its reason, because the reason is the thing being reviewed; an allowlist without one is a suppression. It holds exactly one entry, sqs_messageattributes.go, the retained discard described in the first slice.
Every one of the hundred and six changed sites is asserted over the wire. The inventory test's service table carries 189 operations, the forty-six folded-in guards among them — a changed code that no test reads is a code that can drift back. Three sites a bare server cannot reach have their own test, which creates the prerequisite first: API Gateway v2 UpdateApi, AppSync CreateApiKey and Backup UpdateBackupPlan each sit below a lookup of the resource their path names, so the lookup answers before the guard runs. That is the lookup-first convention recorded under Whether a body is parsed before the resource is looked up rather than a defect in this slice — but an unreached guard is an unchecked code, and appsyncInvalidBody would otherwise have had no caller any test exercises at all.
Which tail operations must still answer 200 for an absent body was measured, not reasoned. Every one was called with no body at all and the ones answering 200 were listed; fourteen of those are what their page publishes and are now pinned. Nine more answered 200 where their page marks a member Required: Yes, and those were deliberately left unpinned — SSO's three account-assignment operations, ListIdentityPools and ListUserPools (MaxResults), Glue GetTables (DatabaseName), WAFv2 ListWebACLs and ListIPSets (Scope), and DynamoDB Streams GetRecords (ShardIterator). Asserting the tree's current answer there would have turned a missing required-member check into a pinned requirement, so they were recorded as the missing-required-member class and filed separately.
#1062 is that class, and all nine now refuse. Each answers the code its own page publishes, which is four different codes rather than one: ValidationException/400 for SSO's three (published on all three pages, and already the plugin's answer at its two checked guards), InvalidParameterException/400 for the two Cognito services, InvalidInputException/400 for Glue — the code #1063 centralised one release earlier — and ValidationError/400 for WAFv2, which is #755's reading of the common list, cited rather than re-derived.
Two of the nine needed a reading recorded rather than a citation. Cognito user pools' InvalidParameterException is glossed "…encounters an invalid parameter" and does not say missing, where Cognito Identity's says "Thrown for missing or bad input parameter(s)" — so ListUserPools' refusal is substrate's reading of an absent required parameter as an invalid one, and ListIdentityPools' rests on the page. DynamoDB Streams GetRecords publishes no validation error of any kind — its five published errors are about iterators, limits, resources and trimmed data, and even an out-of-range Limit is assigned to LimitExceededException — so its refusal comes from the JSON common list. ValidationException was declined there even though eighteen sites in the same plugin answer it, because those are DynamoDB's control-plane pages and borrowing across API surfaces is what #671 settles against.
The measurement missed sites in one direction only, and #1062 corrected for it. A site that already refuses an absent body never appears as a 200, so two required-member defects were invisible to it: Cognito ListUserPoolClients, which unmarshals unconditionally and so refuses an absent body while ignoring its required UserPoolId, and six further WAFv2 operations that default Scope. Scope is Required: Yes on eight of the nine operations that read it and Required: No only on GetWebACL, so eight now refuse an absent Scope and GetWebACL keeps the REGIONAL fallback — a recorded divergence, because no WAFv2 page publishes any default for the member and an optional member's lookup still needs a value. A present-but-unrecognised Scope answers WAFInvalidParameterException/400 at all nine, which is #755's omitted-versus-invalid split applied to a second member.
Also corrected while measuring: MaxResults is Required: Yes over 1–60 on both Cognito list pages with no published default, so the silent rewrite of an absent or non-positive value to 60 is gone at those two operations — absent, zero, negative and above-60 are each outside the published range and each refuse. ListUserPoolClients marks the same member Required: No, so its rewrite is a page-size defect rather than a required-member one and stays for that issue.
A guard that was there all along: the decoder's own error text on the wire
The two classes above are a guard answering the wrong code and a guard that was never written. A third is narrower and had survived both sweeps: a guard that is present, runs, and answers with Message: "invalid JSON: " + err.Error(). That hands the caller encoding/json's text — a Go struct field name and offset, from an endpoint whose whole purpose is to be indistinguishable from AWS. #950 removed twenty-two such leaks and #1007's third slice forty-six more; #1066 is the remainder, and it is 35 sites in eight services:
| Service | Sites | Old code | New code |
|---|---|---|---|
| Transfer Family | 9 | InvalidRequestException/400 | unchanged |
| CodeDeploy | 8 | InvalidInputException/400 | ValidationError/400 |
| CodePipeline | 7 | InvalidStructureException/400 | ValidationException/400 |
| CodeBuild | 6 | InvalidInputException/400 | unchanged |
| Backup | 2 | InvalidRequestException/400 | unchanged |
| IAM Identity Center | 1 | ValidationException/400 | unchanged |
| Redshift Data | 1 | ValidationException/400 | unchanged |
| AppSync | 1 | BadRequestException/400 | unchanged |
Why these thirty survived two sweeps is the finding, not the leak. #1007 went looking for a discarded decode error, and Transfer, CodeDeploy, CodePipeline and CodeBuild discard none: every handler in all four decodes exactly once and checks the error every time — nine, eight, seven and six handlers, thirty guards, thirty leaks. So the sweep passed the four services over entirely, and none of them had a single row in invalid_body_inventory_test.go. assertNoDecoderText has been the enforcing helper since #950, and a site it never reaches is a site it never enforced. The other five leaks are single handlers in services that did have rows — each one sitting beside a sibling whose guard #1007 had routed through a constructor.
Two codes changed, and neither was visible from inside its own plugin. In all four Code* services the same &AWSError{…} literal appears at every site, which reads as deliberate consistency; the error only shows up once the four codes sit next to each other in emulator/invalid_body_refusals.go, which is what that file is for.
- CodePipeline's
InvalidStructureExceptionis wrong twice over. Where it is published —CreatePipelineandUpdatePipeline— it is glossed "The structure was specified in an invalid format.", meaning the pipeline structure, a value read out of the body after the body has parsed. And it is published on only those two of the seven operations substrate routes:GetPipeline,DeletePipeline,StartPipelineExecution,GetPipelineStateandGetPipelineExecutiondo not carry it, so five of the seven sites were borrowing a code from a sibling operation, which #671 settles against.ValidationException/400, "The validation was specified in an invalid format.", is published on all seven, and because it is on the operation pages it outranks the common list'sValidationErrorunder #950's ordering. - CodeDeploy's
InvalidInputExceptionis real, well-glossed, and unpublished where six of the eight handlers live. "The input was specified in an invalid format." fits this condition better than anything else in the service, but CodeDeploy publishes it on onlyCreateDeploymentandCreateDeploymentGroup. The other six publish nothing generic at all — only per-field codes (ApplicationNameRequiredException,InvalidApplicationNameException,InvalidDeploymentGroupNameException,DeploymentIdRequiredException,InvalidDeploymentIdException). Using the generic code service-wide would borrow it at six sites; using it at two and something else at six would spell one condition two ways in one file, the split #950 removed. A body that will not parse names no field, so the landing place is CodeDeploy's own common-errorsValidationError/400 — the same argument as RAM's and CloudTrail's above, reached from the opposite direction, and the third code this class has changed.
Transfer's and CodeBuild's codes were checked as closely and kept. Transfer publishes InvalidRequestException/400 on every page checked, glossed "This exception is thrown when the client submits a malformed request." — the strongest published fit of the eight services, because the sentence names this condition outright. Its common page also publishes MalformedHttpRequestException/400, which reads like the better name and is not: the gloss is about a body whose content-encoding could not be decompressed, a failure that happens before any JSON is seen. CodeBuild publishes InvalidInputException/400, "The input value that was provided is not valid.", everywhere including BatchGetBuilds, where it is the only error listed — the same shape as ECS, a service whose generic code is the only choice on offer.
One site needed the prerequisite created first. CreateBackupSelection loads the backup plan before it decodes, so against a bare server the loader answers and the guard never runs — the lookup-first convention recorded below. It joins the three sites in TestInvalidBodyBelowAResourceLookup, which is also why its leak outlived createBackupPlan's in the same file.
The arithmetic is a second assertion rather than an extension of #1007's.TestInvalidBodyTailIsFullyCovered pins tailSites = 60, the sites #1007's third slice changed — a closed historical figure about guards with a discarded error. #1066's thirty-five had no error to discard; every one was already checked. The populations are disjoint in both directions, so TestInvalidBodyDecoderTextLeaksAreFullyCovered counts the thirty-five separately (30 in four new service entries, 4 in existing ones, 1 below a lookup) and 60 stays 60.
Also corrected: four constructor doc comments and the file header overstated their own reach. Each said its service's checked guards "now call this" when what it had established was only that the guard it was written beside did. Counting what was left is what found the five leaks in services #1007 had already touched, so the comments now say which sweep routed which site, and the file header says that assertNoDecoderText rather than a claim in a comment is what keeps the rule true.
Whether a body is parsed before the resource is looked up
#1006 settled this for Lambda's two sites and, deliberately, for nothing else. Lambda now parses first: AddPermission and TagResource refuse an unparseable body with InvalidParameterValueException/400 whether or not the function exists, and a well-formed request for an absent function is still a 404, so the lookup moved rather than went away. AddPermission's required-member check moved with the parse guard, because a body that will not parse and a body with no StatementId are both mistakes visible in the request alone, and answering 404 for one and 400 for the other on the same request is exactly the one-plugin-two-codes inconsistency #950 corrected.
Which answer AWS gives is unverified, and this is substrate's reading rather than a published fact. No Lambda page states the precedence; both Errors sections list the two codes without ordering them; substrate vendors no Smithy model or SDK, so the wire order is not citable from this repository — which is why #1006's first acceptance criterion could not be met as written. The reading is that a request whose shape is wrong is wrong whatever state exists, so it is refused without consulting state. The case for the other order is real and is why this is recorded rather than asserted: a caller naming a function that does not exist arguably wants to hear that first.
The tree does not follow one convention, and this release does not impose one. Lookup-first handlers remain elsewhere — AppSync's updates and creates, Backup's UpdateBackupPlan and CreateBackupSelection, and Lambda's own UpdateFunctionCode, UpdateFunctionConfiguration, PutFunctionEventInvokeConfig and UpdateEventSourceMapping among them — and the opposite order is documented with its own reasoning where the question arises in its cursor form: ListObjectsV2 states that "the bucket-existence 404 above keeps precedence over this refusal … the bucket is the resource the request addresses" (see A cursor substrate did not issue). A tree-wide flip would therefore overrule stated reasoning rather than fill a gap, so it is out of scope here and is deliberately not filed: the honest position is that substrate holds two orders, each argued where it applies, and that AWS's own documentation is what leaves the question open. A single shared hook is also the wrong shape for it — Server.handleAWSRequest is one of three entry points (StackDeployer.dispatch and Server.stateAtSequence bypass the gates), and it would need a per-service code table that does not exist. The two sites #1006 moved are the ones where the old order made a guard unreachable, which is a coverage fact rather than a fidelity opinion, and that is the whole of what it settles.
The member-complaint half of the inventory is covered the same way, in TestMemberComplaintAnswersThePublishedCode — every site in the "+ N member" column above, which is to say every site whose code #950 corrected that is not a parse guard. These are the easier half to leave unverified: a parse guard is one literal per handler, while these are scattered complaints about a missing member or a malformed identifier, and the whole point of correcting them was that one plugin must not answer two codes for one class of caller error. They send {} rather than a truncated body, deliberately — {} parses, so it travels past the parse guard and reaches the member check underneath, where an unparseable body would have stopped one line earlier — and they assert the message alongside the code, because once every site in a service answers one code the message is the only thing distinguishing them.
Those guards used to be unreachable, and since #1009 every one of them is reached.parseKafkaOperation and parseSESv2Operation opened by trimming a trailing slash, and parseSchedulerOperation normalised the same way by testing path == "/schedules/" explicitly, so a request naming an empty path parameter collapsed onto the collection route one case earlier in the same switch: GET /v1/clusters/ dispatched ListClusters, not DescribeCluster with an empty ARN, and GET /schedules/ answered every schedule in the group to a caller that had asked for one. The inventory is every router in the tree that normalised a trailing slash, with the reachability of each empty-parameter guard beneath it:
| Router | Normalisation | Guards below it | Now |
|---|---|---|---|
parseKafkaOperation | strings.TrimRight(path, "/") | describeCluster, deleteCluster, describeClusterV2 | reachable; trim removed |
parseSESv2Operation | strings.TrimRight(path, "/") | getEmailIdentity, deleteEmailIdentity | reachable; trim removed |
parseSchedulerOperation | explicit path == "/schedules/" arm | getSchedule, createSchedule, updateSchedule, deleteSchedule | reachable; the arm and the name != "" route condition removed |
parseCloudFrontOperation | strings.TrimSuffix(path, "/") | none — the inverse hole, below | filed |
parseAccountOperation | strings.TrimSuffix(path, "/") | none; the service has no path parameters | harmless, left alone |
Nine guards in three routers were dead, not the five in two originally recorded; Scheduler's four were invisible to a TrimRight search. MSK's getBootstrapBrokers and listNodes escaped only because a literal segment follows the ARN, so the empty parameter was interior rather than trailing and /v1/clusters//nodes reached them. parseEFSOperation never trimmed, which is why all nine of EFS's equivalent guards were always reachable, and it is the precedent the three fixed routers now follow: an empty path parameter reaches the operation the caller named and is refused there. AWS publishes nothing about a trailing slash for any of these services — whether it answers a validation error, a 404 or the collection operation is unverified, and MSK is the weakest service in the tree to settle from documentation for the reasons given below. Substrate's reading is that a refusal is recoverable where a wrong operation is not: a caller that built a path from an empty variable is told which parameter was empty rather than served a listing it did not ask for.
CloudFront is the same defect inverted and is not fixed here: /distribution/ reaches GetDistribution with an empty ID and there is no guard below it to reach, so the fix is a guard to add rather than a fold to remove. It is filed separately.
One service is outside this rule by design. CloudWatch speaks Smithy RPC v2 CBOR, and its refusal names the modelled shape rather than a code from a common-errors page; neither the protocol nor the CloudWatch model names a shape for an undecodable body, so it answers SerializationException at 400 as substrate's own choice. See the CloudWatch section.
Which account a request is attributed to
Most plugins scope a resource to the account of the request that created it, and every ARN substrate mints names one, so "which account is this?" is a question with a single answer. Substrate resolves it in one place and in this order:
| Source | When it applies |
|---|---|
123456789012 | Always, as the starting point. AWS's documented example account. |
account.default in substrate.yaml | Whenever it is set and 12 digits. Set it to whatever your fixtures assert on. |
A CredentialRegistry entry | When the request is signed with an access key the registry holds. Wire one with credentials: in substrate.yaml or ServerOptions.Credentials. |
| An STS session record | When the request is signed with credentials AssumeRole minted. |
Later rows win. The order is what it is because each row knows strictly more than the one above it: a config file knows the deployment, a registry knows which key belongs to which account, and only the session record knows the account a cross-account AssumeRole landed in — a temporary credential's account is recorded when the session is minted and appears nowhere on the wire.
An unsigned request is attributed to the resolved default. That is deliberate: VerifySigV4 passes a request carrying no Authorization header, so an unsigned caller reaches its plugin as the default account even against a server with a credential registry wired.
There is no second account for free
Until #734, the account came from the shape of the access key: a key beginning with AKIA was attributed to 123456789012, and everything else — substrate's own documented test/test, an unsigned request, an ASIA session key — to 000000000000. One server served two accounts, chosen by which of two documented credentials the client happened to pick, and nothing on the wire told a caller which one they had.
000000000000 no longer exists anywhere in substrate. A test asserts it: every non-test Go file is parsed and any string literal carrying an account-shaped run of twelve digits fails the build, with one exemption for the declaration of the default itself. A run bounded by anything other than :, / or the end of the string is not an account — which is what keeps shardId-000000000000 and the like out of it.
If a fixture asserts on 000000000000, it now sees 123456789012. Two things follow. Every ARN returned to an unsigned or non-AKIA caller changes account. And because several plugins prefix their state keys with the account — table:{account}/{region}/{name}, instance:{account}/{id} — persisted SQLite state written under the old account is unreachable after upgrading. Re-seed it, or set account.default: "000000000000" to read it back.
IAM entities belong to an account
Every IAM state key carries the account it belongs to — user:{account}/{name}, role_policies:{account}/{name}, group_inline:{account}/{group}:{policy} — the same {kind}:{account}/{rest} shape DynamoDB and the other account-aware plugins already used. Before #737 they did not, and an IAM entity belonged to the emulator rather than to an account. Three things follow, all of them now observable:
- Two accounts can hold the same name.
CreateRole deployin a second account answeredEntityAlreadyExists; it now succeeds, and the two roles are distinct records with distinct ARNs. - A listing reports one account.
ListUsers,ListRoles,ListGroups,ListInstanceProfilesandListPolicieswithScope=Localscan the caller's account only.ListPoliciesin particular used to scan every account's policies. - A principal resolves against its own account.
resolveIAMEntitytakes the account from the principal ARN, not from the request, so a cross-account principal is evaluated against its own account's policies rather than a same-named entity in the account the request resolved to.AssumeRolelikewise reads the role named byRoleArnfrom that ARN's account, which is the case cross-account role assumption exists for.
One IAM key has no account and cannot have one: accesskey:{id}. An access key ID is what determines an account, so a signed request looks the record up before any account is known. The owning account is a field on the record instead. A record written before this release has that field empty and falls back to the account the request resolved to — which is what it always used, so nothing that worked stops working.
Because the keys changed shape, IAM state written before this release is unreachable: a snapshot or exported fixture holding user:alice is not read by a handler now looking for user:123456789012/alice. Re-seed it through the API. This is the same class of break as the account-default change above, for the same reason.
Configuring the registry from substrate.yaml
account:
default: "123456789012"
credentials:
enabled: true # build the registry; without it there is none
verify_signatures: true # the default — see below
entries:
- access_key_id: "AKIAEXAMPLE00000001"
secret_access_key: "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY"
account_id: "111122223333"enabled: false — the shipped default — wires no registry at all, so every caller resolves to account.default and no signature is checked. Nothing about an existing deployment changes.
verify_signatures defaults to true, because this section has documented enabled as "enable SigV4 signature verification" since it was written and enabled: true alone has to keep meaning that. Set it to false for the combination #630 opened up: resolve an account per access key without authenticating anyone. That is the setting a multi-account test usually wants, since the registry is there to answer which account and no call needs a real signature.
account_id is optional and falls back to account.default, so an entry can exist only to make a key signable. secret_access_key is required while verification is on and unused otherwise. A malformed entry — an empty access_key_id, an account_id that is not twelve digits, a duplicated key — is a startup error rather than a silently dropped row, and it is checked whether or not the section is enabled, so a typo does not stay hidden until someone flips the flag.
Every registry — configured or built in-process — is seeded with the three credentials substrate's own documentation tells a caller to use, all in account.default: AKIATEST12345678901, test/test (README, endpoint configuration) and AKIAIOSFODNN7EXAMPLE (testing guide, test/e2e). Without that, turning verification on would make the quickstart wrong — a consumer would have followed the documentation exactly and been answered InvalidClientTokenId 403. Reusing one of those access key IDs in entries: replaces it, which is how a test moves the built-in key into another account.
SIGHUP re-reads the file and adds any new entries: to the live registry. enabled and verify_signatures are read once at startup; changing either logs a warning saying so and takes effect on restart.
Cross-service IAM authorization has no flag, and there is nothing to enable.substrate server always builds an AuthController. Enforcement is opt-in by existence instead: a request is checked against attached policies, inline policies and a permission boundary only once its access key resolves to an IAM entity substrate actually holds, and a caller that never created a user or role is authorized against nothing. An auth.enabled flag would overpromise in both directions — it could not turn enforcement off for a principal that exists, because IAM's own authorization resolves against state directly rather than through the controller.
Attributing accounts and enforcing signatures are separate
ServerOptions.Credentials answers which account does this access key belong to; ServerOptions.VerifySignatures answers is this signature valid. One field used to mean both (#630), so wiring a registry in order to reach a second account also refused every credential substrate documents — test/test and AKIAIOSFODNN7EXAMPLE were in no registry, and an unregistered key is InvalidClientTokenId 403. All four combinations are expressible now, and the one that did not exist before — a registry with verification off — is what every test server uses. Both of those credentials are seeded into every registry as of #736, so signing with one is no longer refused either way.
One consequence worth knowing, because it decides what GetCallerIdentity reports. A key the registry holds but IAM does not know names a principal only when its signature was verified. A verified key has proven the caller holds the secret, so naming it is a statement about someone substrate authenticated; a key merely present in a table has proven nothing, and naming it would flip GetCallerIdentity's ARN off :root and turn GetUser from a validation error into a NoSuchEntity lookup for a server that wired a registry only to attribute accounts. Either way the synthesized ARN names the key, not a user, so it resolves to no policies and authorizes nothing.
VerifySignatures with no registry has no key material to check against. The server logs a warning and runs with verification off rather than refusing every signed request. Refusing at construction would be better, but NewServer returns no error and a panic in an emulator library is worse than either.
In tests, StartTestServer wires a registry with verification off, so TestServer.RegisterAccount works on any server it returns; ask for enforcement with StartTestServer(t, WithSignatureVerification()).
The order a listing returns its members in
A listing is sorted, and the order is part of what substrate promises rather than an incidental tidiness. StateManager.List returns the keys of a namespace sorted lexicographically, and its doc comment states that as a contract an implementation must meet. Every listing built from it therefore answers the same way twice.
That guarantee is stated here because for most of substrate's history it did not hold. State is held in Go maps, whose iteration order is randomised per process, and List returned that order directly — so a listing rendered straight into a response body reported its members in a different order on each run, and sometimes on two calls within one run. In an emulator whose whole claim is that a recorded run replays byte-for-byte, that is a correctness defect and not a cosmetic one: a response body that reorders itself between two reads of unchanged state cannot be compared against a recording, and a cursor paged over an unstable order can omit or repeat a resource between pages (#865).
The fix is in the one non-test List implementation rather than at each call site, because the same defect had already been fixed five separate times at five individual sites — CloudFormation stack tags, aws:TagKeys, CreateSnapshots' snapshotSet, DeleteSnapshot's image-ID tie-break, and the four tag merge helpers — each with its own written rationale, and each leaving every other caller exposed.
Where the order comes from differs by operation, and the three cases are not equally strong.
- AWS documents it.
ListMultipartUploadspublishes a "Sorting of multipart uploads in response" section: ascending object key, then ascending initiation time among uploads sharing a key. Substrate's previous order was a citable violation, not merely a nondeterminism. Where two uploads of one key share an initiation instant — which a controlled clock allows and a real one effectively does not — the upload ID breaks the tie, which AWS does not document but itskey-marker/upload-id-markercursor implies. - The operation's own cursor requires it, though the prose states none.
ListObjectVersionsis the case in point:NextKeyMarkeris "the first key not returned that satisfies the search criteria" and aCommonPrefixesentry "is filtered out from results if it is not lexicographically greater than the key-marker", both of which presuppose a key order. RDS's and ElastiCache's describes are the other: their pages state no order either, but they do state what theMarkermeans — "the response includes only records beyond the marker" — and a record cannot be beyond another without an order to be beyond it in. AWS also publishes aMarkerandPageSizeon the four ELBv2 describes, though substrate implements neither parameter there yet (#1244), so ELBv2's order rests on the replay promise alone for now. A cursor over an unstable order is the worst form of this defect, because it loses and duplicates resources rather than merely reordering them. - AWS documents no order at all, and lexicographic is substrate's reading.
ListBucketssays nothing about the order buckets come back in; neither doesDescribeRules, nor EC2'sreservationSet. The guarantee there rests on the replay promise and on the five precedents above, not on a published statement, and a consumer should not read it as AWS's behaviour.
Operations whose response-body order this actually changed, verified one by one rather than inferred from the call sites: S3 ListBuckets, ListMultipartUploads and ListObjectVersions; the four ELBv2 describes that return a list — DescribeLoadBalancers, DescribeTargetGroups, DescribeListeners and DescribeRules; RDS's five describes; and fifteen EC2 describes, among them DescribeInstances, DescribeVolumes, DescribeSnapshots, DescribeImages, DescribeVpcs, DescribeSubnets and DescribeSecurityGroups. The remaining List callers are single-key lookups or mutations that stop at the first match, and their order was never observable.
Two listings were never affected, and are recorded here so the claim is not overstated. The Resource Groups Tagging API's GetResources sorts its scan results by ARN before paginating, so its PaginationToken was always a cursor over a stable order. EC2's DescribeTags sorts by resource ID, then type, then key. Both sorts remain load-bearing after the change, because neither wants the order of the state keys its records were read from: DescribeTags spans every resource type at once, and a tag's state key does not sort by resource ID.
DescribeInstances needed a second fix beyond the shared one. It buckets instances into reservations through a map and then ranges that map, so sorting List made the instances within a reservation deterministic while leaving reservationSet's own member order in Go's map order. It is now ordered by reservation ID.
Every listing audited against those three tiers
#865 fixed the ordering defect at its source and named the three tiers above, but it established them from the listings it happened to touch. #887 audited the rest, so that the tier a given operation sits in is a recorded finding rather than an assumption. All 128 non-test List call sites, across 46 files, were classified by whether their order can be observed:
| Class | Sites | What it means |
|---|---|---|
| A | 50 | The keys reach a response body, so the order is observable by a caller. |
| B | 21 | The keys reach one of substrate's own control endpoints, not an AWS-shaped response. |
| C | 57 | The keys are scanned internally — a single-key lookup, a mutation that stops at the first match, or a set that is re-sorted before it is rendered. |
The tagging_plugin.go scanners are the largest block of class C: twenty of them feed GetResources, which sorts by ARN before paginating, so their own order is discarded.
The audit found no new tier-1 operation. Eleven further AWS pages were read — RDS DescribeDBInstances and DescribeDBClusters; ElastiCache DescribeCacheClusters, DescribeReplicationGroups and DescribeCacheSubnetGroups; API Gateway GetRestApis; CloudWatch DescribeAlarms and ListMetrics; SSM DescribeParameters and GetParametersByPath; and EC2's Query request page — and none publishes an ordering statement. ListMultipartUploads remains the only operation in the tree with a documented order. EC2 is settled by a blanket disclaimer on its Query request page rather than per operation: "The order of the elements in the response, including those within nested structures, might vary. Applications should not assume that the elements appear in a particular order." All thirty EC2-family sites are therefore tier 3, and substrate's lexicographic order is a stronger guarantee than AWS gives.
It found one tier-2 defect, at exactly three sites. RDS's DescribeDBInstances and DescribeDBClusters and ElastiCache's DescribeCacheClusters implemented Marker as a decimal offset into the sorted listing. Their pages document the parameter positionally — "the response includes only records beyond the marker, up to the value specified by MaxRecords" — and an offset diverges from that in two ways a caller can observe:
- A record removed behind the cursor loses a record the caller never sees. Paging five instances two at a time and deleting the first after page one, the offset cursor answered page two as
items[2:]of a now-four-record listing, so the third record was never reported. The caller's loop terminated normally with four of five records and nothing to indicate the fifth had been skipped. A record added behind the cursor repeated one instead. - A
Markersubstrate never issued was answered with page one. The offset was parsed withstrconv.Atoiand the error discarded, so any unparseable marker became0. A consumer that persisted a marker across a restart, or truncated one, silently restarted the walk.
Both are fixed by making the Marker name the last record of the previous page rather than count the records before it, so the next page is the records sorting strictly after it — the pattern ListBuckets' continuation-token already uses. The marker is base64, so one substrate did not issue is detectable and is refused with InvalidParameterValue / 400. That code is published for ElastiCache, on DescribeCacheClusters and DescribeReplicationGroups; the two RDS pages publish only their NotFound faults, so for RDS it is substrate's reading. Truncation is decided on the next matching record rather than on the page filling up, so a full last page carries no Marker and costs the caller no round trip to an empty page.
MaxRecords was a separate matter and deliberately untouched by that fix — folding a page-size change into a page-contents change would have made the two indistinguishable in one diff — and it is now corrected in its own right, below.
A page size outside the documented range is refused, not honored or rewritten
#913. Both families publish the same three facts on MaxRecords, and substrate honored none of them:
| Fact | Published as | Substrate before |
|---|---|---|
| Default | Default: 100 | 100, but also applied to an unusable value |
| Minimum | Minimum 20 (RDS) / minimum 20 (ElastiCache) | any positive integer honored |
| Maximum | maximum 100 | any positive integer honored |
The two halves fail in different directions, and both matter:
- An honored out-of-range value diverges towards the caller's disadvantage.
MaxRecords=5produced a five-record page here and is refused by real RDS, so a consumer written against substrate broke on AWS. That is the direction an emulator must not permit. - A rewritten value cannot be noticed.
MaxRecords=0,-1andabcbecame 100. A caller asking for a small page and receiving a hundred records sees the same well-formed shape as a caller whose listing is short — the same argument the token refusal above rests on.
An absent MaxRecords still defaults to 100, which is the case AWS publishes a default for. Anything else must be an integer within 20–100 inclusive; the ends are accepted, because narrowing a published range would be substrate inventing a contract. A refusal answers InvalidParameterValue / 400 with a message naming the range, following parseSimulateRequest and parseS3ListBucketsParams. That code is published for ElastiCache — API_DescribeCacheClusters and API_DescribeReplicationGroups both list InvalidParameterValue at 400, "The value for a parameter is invalid." — and is substrate's reading for RDS, whose pages publish only their NotFound faults (API_DescribeDBInstances lists DBInstanceNotFound / 404 and nothing else). One code serves both families, as it does for the Marker, so the two parameters of one cursor cannot be refused under different codes. Both parameters are validated before any state is read.
Elastic Load Balancing joined this rule later, and from the other side. Its two paginated operations published one range — PageSize 1–400, on both generations' pages — and answered it two ways: the classic DescribeLoadBalancers refused an out-of-range value while ELBv2 DescribeAccountLimits substituted its default and answered 200. The fallback rested on the operation publishing no error of its own, which #1064 answered, so #1150 gave the plugin one rule and it is this one: absent is the published default, anything else must be within the range, and a value outside it is refused. The code is ValidationError/400 rather than InvalidParameterValue, because that is what ELB's own Common Errors page publishes — the rule is shared, the vocabulary is each service's.
A finding recorded rather than quietly fixed. Eleven request sites across eight tests paged at MaxRecords=2 — a page size both real services refuse — and passed only because substrate was permissive. That is evidence of the divergence, not noise, so it is recorded here: those tests now page at the documented minimum of twenty and create twenty-odd records to reach a second page.
A pagination token substrate never issued is refused, not answered with page one
#915 is the second half of the tier-2 defect above, at the four operations the RDS and ElastiCache fix did not reach; the tagging API's GetResources joined them with #1010, which is why the table below has five rows for a four-operation issue. Each decoded its token and discarded the error:
if decoded, decErr := base64.StdEncoding.DecodeString(nextToken); decErr == nil {
if n, parseErr := strconv.Atoi(string(decoded)); parseErr == nil && n >= 0 {
offset = n
}
}So a token substrate could not have issued — one from another operation, a truncated copy, a hand-written string, an offset left over from an older recording — left the offset at zero and the operation answered a well-formed page one. That is the one wrong answer a paginating caller cannot detect: a loop that runs until the token comes back empty is handed the first page again, so it either spins or processes the same records twice, and the response says nothing.
The code and the message are per operation, and their provenance differs:
| Operation | Code | Message | Provenance |
|---|---|---|---|
CloudWatch DescribeAlarms | InvalidNextToken / 400 | The next token specified is invalid. | Published. It is the only error API_DescribeAlarms lists, and the same code appears in CloudWatch's Smithy model, which is where the JSON and CBOR protocols get the shape name. |
SSM DescribeParameters | InvalidNextToken / 400 | The specified token isn't valid. | Published, on the operation's own page. |
SSM GetParametersByPath | InvalidNextToken / 400 | The specified token isn't valid. | Published, identically. |
S3 ListObjectsV2 | InvalidArgument / 400 | The continuation token provided is incorrect. | Substrate's reading. API_ListObjectsV2 publishes exactly one error, NoSuchBucket at 404, and says nothing about an unusable continuation-token; the S3 ErrorResponses page returns an empty body to automated fetches. It follows the choice already recorded for ListBuckets, and the two operations taking this cursor now decode it through one helper, so they cannot refuse it differently. |
Resource Groups Tagging GetResources | InvalidParameterException / 400 | PaginationToken is not a token this API issued | Substrate's reading, added by #1010 as the fifth operation of this class. API_GetResources publishes PaginationTokenExpiredException as well, and it is deliberately not the code used here: an unissued token is malformed, not expired, and the two imply different consumer actions. InvalidParameterException's own gloss covers "a provided string parameter is malformed". |
Two rules decide what counts as issuable, and both are stated once, in emulator/offset_pagination_token.go, rather than agreed on at each site:
- A token is issuable if the encoder could have produced it. For the three offset cursors that means base64 whose decoded text is exactly what
strconv.Itoarenders for the offset it names, so+5,05and a trailing space are refused as well as a negative offset and a non-integer — formsstrconv.Atoiaccepts but nothing ever emitted. ForListObjectsV2the cursor is a key, so any base64 is issuable and only an undecodable token is refused: a token naming an object that has since been deleted resumes after it, which is the same reading the RDS and ElastiCache marker records. - An offset past the end of the listing is not refused. That token was issued, over a listing that has since shrunk, and it clamps to a final empty page. Refusing it would break a legitimate walk whose records were deleted mid-loop.
The token is validated before any state is read. Three of the four read first — CloudWatch loaded its alarm index and Systems Manager its parameter paths — so a refusal could depend on how much state happened to exist. This is the ordering rule ec2_describetags.go records for DescribeTags, and it is asserted by sealing the state store against reads and requiring the refusal to arrive anyway. ListObjectsV2 is the one exception, deliberately: its bucket-existence 404 keeps its precedence over the token refusal, because the bucket is the resource the request addresses and AWS publishes nothing about which of the two wins. Only the object listing is guaranteed unread. GetResources follows the same rule: its whole eight-member validation runs ahead of the account-wide scan, so a refusal does not depend on how many resources happen to exist.
What this does not fix. CloudWatch DescribeAlarms and both Systems Manager listings still page by offset, so a record added or removed behind the cursor still shifts every later page — the other half of the tier-2 defect, and the reason the RDS and ElastiCache describes were converted to a value-based cursor. Refusing an unissued token and choosing a stable cursor basis are independent defects, and only the first is #915.
The same idiom at fifteen more sites, and why four services went first
#1086 found the pre-#915 form at fifteen further sites across nine services. The decode is mechanical — the helpers above already exist — but the refusal is not, because the services do not share a code, and that is what decides the order the sites move in rather than the size of each edit.
Four services, five sites, went first — three of them because they publish a code that names this condition, and CloudFormation because the tree carried a recorded decision at its site that had to be resolved one way or the other before the class could move at all:
| Operation | Code | Provenance |
|---|---|---|
KMS ListKeys | InvalidMarkerException / 400 | Published, in the operation's own Errors section, glossed "the request was rejected because the marker that specifies where pagination should next begin is not valid". KMS is also the only one of the four that constrains the token itself — Marker is Length 1–1024, Pattern [\u0020-\u00FF]* — so a marker outside that range is refusable on the page's own terms as well. Substrate's issuability rule is the stricter test, and every string it refuses that is inside the Pattern is still a string KMS could not have minted. |
KMS ListAliases | InvalidMarkerException / 400 | Published, identically. Asserted separately from ListKeys because the two decoded their markers with two copies of one block, which is how they came to carry the defect twice. |
Secrets Manager ListSecrets | InvalidNextTokenException / 400 | Published, and published separately from InvalidParameterException, which is the whole point: the page's Errors list is four entries and carries a code for the token and a code for a member, so answering InvalidParameterException here would tell a caller that the value of some member was wrong when AWS has a code for exactly this condition. NextToken is Length 1–4096, which substrate's token is always inside. |
EventBridge ListRules | InvalidToken / 400 | Published in prose only. The Errors section is InternalException/500 and ResourceNotFoundException/400, and InvalidToken is absent from EventBridge's common-errors page too — but the NextToken member's own description says "Using an expired pagination token results in an HTTP 400 InvalidToken error." The code, the status and the condition are all the page's, in the description of the very member being refused. This corrects #950's sweep of EventBridge, which read Errors sections and so settled for the common-errors fallback for this condition. AWS names an expired token and substrate's tokens do not expire; what substrate refuses is the other way a token fails to be honourable, and both are one observation for a caller — a token this service will not resume from. |
CloudFormation DescribeStackEvents | ValidationError / 400 | Substrate's reading. The Errors section is literally empty, so the code comes from CloudFormation's Common Errors page, which carries ValidationError and InvalidParameterValue both at 400. ValidationError is what this plugin already answers for every other malformed parameter, so a caller sees one code per class of mistake rather than a second code invented for this member. |
This reverses a decision recorded in the tree, rather than deleting it. cfn_events.go argued the silent page-one answer from that empty Errors section, and a test asserted it with the rationale attached. Two things overturn the argument. The empty section is not the whole of what CloudFormation publishes — the common page is also published, and this plugin has a house rule for choosing between its two 400s. And a well-formed page one is the one wrong answer a paginating caller cannot detect, which is the finding the whole class rests on. The reversal also fixes a second defect at that site for free: its guard required the offset to be < len(events), so a token substrate itself had issued, over a listing that has since shrunk, reset to page one instead of clamping to a final empty page — the exact behaviour the old comment claimed to be preserving.
All five validate the token before they read any state. Four of the five loaded their index first, so a refusal could depend on how much state happened to exist and a store failure would be reported as a 500 for a request that was already refusable. Asserted the way #915's sites assert it: by sealing the state store against reads and requiring the refusal to arrive anyway.
The other ten sites, and what their pages do not say. Thirteen operation references were read for the remaining five services, and not one publishes a code that AWS attributes to an invalid, unusable or expired pagination token — in an Errors section or in the token member's prose. So each of those ten refuses under a code that is substrate's reading of a generic code published on the operation's own page, the way S3 ListObjectsV2 and GetResources above already do, and none by borrowing a sibling operation's code, which #671 forbids. All ten have landed — SNS's two sites, Athena's two, CloudWatch Logs' four, EventBridge Scheduler's one and Batch's one, which alone carries three operations — and each has its own section below. The class is closed: no listing in the tree now answers page one to a token it could not have issued.
What the ten have in common, and what the five sections record separately, is that the condition is substrate's reading in every case while the code is never borrowed. Four of the five services publish the where-a-token-comes-from sentence in the token parameter's own description — Athena, CloudWatch Logs, EventBridge Scheduler and Batch — and that sentence, not an Errors entry, is the footing each refusal stands on. SNS is the one that publishes no such sentence, so its section carries the weaker argument, which is why it says so.
SNS's three listings refuse a token under the code their own pages publish
ListTopics, ListSubscriptions and ListSubscriptionsByTopic are the first three of those ten sites to convert. All three answer InvalidParameter / 400, which every one of the three pages publishes in its own Errors section, glossed "Indicates that a request parameter does not comply with the associated constraints." The code is therefore the operation's own vocabulary, not a sibling's. What is substrate's reading is the condition: each page describes NextToken in a single sentence — "Token returned by the previous ListTopics request." — with no constraint, no length, and no error attributed to it, and SNS's common-errors page says nothing about a token either. So "a token no previous call returned" is substrate's reading of that gloss, and the message says which parameter and which operation rather than restating it, because a caller handed "does not comply with the associated constraints" cannot tell which of its parameters AWS means:
NextToken is not a token returned by a previous ListTopics requestTwo things this does not claim. It is not an expiry refusal: SNS publishes no lifetime for a NextToken and substrate's tokens do not expire, so what is refused is the other way a token fails to be honourable — one this service could not have minted. And it does not make a token portable between the three: all three encode an offset the same way, so ListTopics' token decodes cleanly under ListSubscriptions and would index into the wrong listing. That is now refused, which is why the message names the operation.
The page size is a constant 100, not a default. All three pages say "Each call returns a limited list of topics, up to 100" and none publishes a request parameter that can change it, so the listing in the tests is 101 records — the smallest one that makes the operation issue a token at all.
Two of the three validate the token before they read any state; the third does so deliberately later. ListSubscriptionsByTopic resolves the topic first, so the NotFound/404 that #926 added for an absent topic keeps its precedence over a token refusal — the topic is the resource the request addresses, which is the same reading S3 ListObjectsV2 records for its bucket, and AWS publishes nothing about which of the two wins. The token is still decoded before the subscription index is read, and that boundary is asserted by sealing that one state key rather than every read: sealing every read would fail the topic lookup instead and prove nothing about the token.
Athena's two listings refuse a token, and the page says where a token comes from
ListQueryExecutions and ListWorkGroups are the next two of those ten sites to convert. Both answer InvalidRequestException / 400, which both pages publish in their own Errors sections, glossed "Indicates that something is wrong with the input to the request. For example, a required parameter may be missing or out of range." — and which this plugin already answers at five other doors, so it is Athena's own vocabulary rather than a sibling's.
Athena has the best footing of the five services in this class, because its page says where a token comes from. Both operations describe NextToken — for the request parameter and again for the response element — as "A token generated by the Athena service that specifies where to continue pagination if a previous request was truncated. To obtain the next set of pages, pass in the NextToken from the response object of the previous page call." A token no previous response carried is therefore not the thing the parameter is documented to accept. What is still substrate's reading is only that this input problem is the one the InvalidRequestException gloss covers: neither page publishes a code AWS attributes to a pagination token, and Athena's common-errors page carries nothing about one either.
NextToken is not a token returned by a previous ListWorkGroups requestThe published Length constraint needs no check of its own. NextToken is Length 1–1024 on both the request and the response, and the encoder emits an offset in decimal base64 — a handful of characters — so every token longer than 1024 already fails the issuability round trip. The minimum of 1 is why an empty NextToken is an absent token, the start of the listing, rather than an invalid one.
A token from the other Athena listing is issuable and is not refused. Both encode an offset the same way, so ListQueryExecutions' token decodes cleanly under ListWorkGroups. Nothing in the token can distinguish them; what the refusal contributes is the operation name in the message, so a caller that crossed the two listings can see which one a token belongs to. The same shape is still live at Batch, whose three describes share one paginator.
The page size is the caller's, and its published range is not enforced. Both operations publish a MaxResults — Valid Range 0–50 for ListQueryExecutions, 1–50 for ListWorkGroups — and substrate honours a value above the maximum and silently rewrites one at or below zero to 50. That is the unenforced page-size class, a different defect from the token, and this change leaves it exactly as it was; it is named here so the conversion is not read as having fixed it.
Both validate the token before they read any state, and that is counted rather than sealed. Neither operation resolves a resource first, so neither has a NotFound whose precedence has to be preserved — ListQueryExecutions' WorkGroup is a filter, not a lookup. But the sealed-store assertion the other sites use cannot tell Athena's two orderings apart: its index loader reports a store failure as an empty index, so a sealed read answers 200 with an empty page, which is indistinguishable from an empty listing. The test counts the reads made in Athena's namespace instead and requires a refused request to have made none, with a paired control — a token the operation could have issued — that must read the index, so a count of zero cannot pass by never reaching the handler.
CloudWatch Logs carried one block four times, and the page says where a token comes from
DescribeLogGroups, DescribeLogStreams, GetLogEvents and FilterLogEvents are the next four of those ten sites to convert, and they are the largest concentration in the class: four copies of one decode block in one file, which is why the conversion is worth more here than the count of operations suggests — a fix applied to one of them would have left the other three answering page one. All four answer InvalidParameterException / 400, glossed "A parameter is specified incorrectly." and published in all four operations' own Errors sections, so it is each operation's own vocabulary rather than a sibling's; it is also already this plugin's code at every other door it has.
The footing is the parenthesis in the token's own description. All four pages describe the request parameter identically — "The token for the next set of items to return. (You received this token from a previous call.)", with FilterLogEvents saying "events" where the others say "items". That aside states where a token comes from, so a token no previous call returned is not what the parameter is documented to accept. What is still substrate's reading is only that this input problem is the one the InvalidParameterException gloss covers: no page publishes a code AWS attributes to a pagination token.
nextToken is not a token returned by a previous DescribeLogGroups requestNothing in the published constraints bounds the token. nextToken is Length minimum 1 with no maximum and no Pattern, so unlike Athena's and KMS's 1–1024 there is no ceiling for the refusal to subsume, and the issuability round trip is the entire rule. The minimum of 1 is again why an empty nextToken is an absent token, the start of the listing, rather than an invalid one.
Here the page sizes are published, and substrate's match. The two describes publish "If you don't specify a value, the default is up to 50 items" with a Valid Range of 1–50; FilterLogEvents publishes "The default is 10,000 events." and GetLogEvents "the default is as many log events as can fit in a response size of 1 MB (up to 10,000 log events)", both with a Valid Range of 1–10000. Substrate applies 50 and 10,000, which is the published figure in each case — unlike Athena, where the default was substrate's own choice. It models no response-size ceiling, so GetLogEvents applies the count alone.
One thing this does not fix, named so the conversion is not read as having fixed it. All four response members publish "The token expires after 24 hours." and no page publishes a code for presenting an expired one, so a token substrate issues stays valid for the life of the store — refusing an expired one would need the token to carry its issue time and the simulated clock to judge it, and would have to attribute the refusal to InvalidParameterException/400, the only 400-class code these pages publish for a bad parameter. That attribution is a reading rather than a read, so the expiry is declined rather than deferred: substrate's pagination tokens do not expire, and a test that needs an expired-token path cannot get one here.
Two more were recorded here and have since been fixed. ResourceNotFoundException had no site at three of these four doors, so a listing over a log group that does not exist was empty rather than refused — see a log group that does not exist is not an empty log group for the refusal and for how it orders against the token. And GetLogEvents' pair of directional tokens is now reported in full — see GetLogEvents pages by a pair of tokens, which is also where the one way the four now differ is set out: GetLogEvents prefixes its tokens with a direction, so its tokens no longer decode under the other three and theirs no longer decode under it.
All four validate the token before they read any listing, asserted by sealing the store the way #915's sites are; unlike Athena, Logs' index loader propagates a store failure, so the seal can tell the two orderings apart. DescribeLogGroups has nothing ahead of its decode and seals every read; the other three seal the listing's key alone — the stream-name index at two of them, the events key at GetLogEvents — each with a control call proving the sealed key is reached, because sealing every read would fail the group lookup that now precedes the token and assert nothing about it. Three of the four require a member first — logGroupName, and logStreamName as well at GetLogEvents — and that refusal keeps its precedence, because an absent required member is the more basic failure and neither ordering is published. Both carry the same published code, so the message is the only thing that says which one answered, which is why the precedence is asserted rather than left to the reader. Those same three now also resolve the log group (and the log stream, at GetLogEvents) ahead of the token, for the reason SNS ListSubscriptionsByTopic resolves its topic first; the token is still decoded before the listing is read, which is what #887's criterion asks.
EventBridge Scheduler's one listing refuses a token the parameter is not documented to accept
ListSchedules is the ninth of those ten sites, and the only one whose token travels in a query string rather than a request body. It answers ValidationException / 400, which the operation's own Errors section publishes, glossed "The input fails to satisfy the constraints specified by an AWS service." It is also the only refusal this service publishes for an input that fails a constraint, so the message carries which input failed, in the shape every other Scheduler refusal renders:
1 validation error detected: Value at 'nextToken' failed to satisfy constraint: Member must be a token
returned by a previous ListSchedules requestThe footing is the request parameter's own sentence, not the Errors section. API_ListSchedules describes NextToken as "The token returned by a previous call to retrieve the next set of results." — the page states where a token comes from, so a token no previous call returned is not the thing the parameter is documented to accept. What remains substrate's reading is only that this input problem is the one the ValidationException gloss covers: the page publishes no code AWS attributes to a pagination token. The published Length constraint of 1–2048 is subsumed by the issuability round trip, since a base64 encoding of a decimal offset is far shorter than 2048 bytes and a length refusal would be unreachable behind it; the minimum of 1 is again why an empty NextToken is an absent token, the start of the listing, rather than an invalid one.
The token is decoded before the name index is read. There is no required member on this operation and no schedule to resolve, so unlike the three Scheduler operations that check Name first, nothing competes with the refusal for precedence. Asserted by sealing the state store the way #915's sites are; Scheduler's index loader propagates a store failure, so the seal can tell the two orderings apart.
Two things this does not claim, and two it does not fix. It refuses a token this service could not have minted; it does not make a token portable between two listings of different shape, because the token carries an offset and nothing else, so a token issued for one ScheduleGroup, NamePrefix or State decodes cleanly against another and indexes into a listing the caller never asked for — a property every offset paginator in the tree has, recorded here because the refusal's name invites the stronger reading. And a past-the-end offset still clamps to a final empty page rather than being refused, because a token substrate issued over a listing that has since shrunk is still a token it issued. The State filter was also left as it was — applied after the page is cut, so a state-filtered request could be answered a page shorter than MaxResults while still carrying a NextToken — and has since been fixed under #1229; see the next section. Still left as it was: a MaxResults above the published maximum of 100 is clamped rather than refused, with a value of zero or below silently ignored in favour of substrate's own default of 20. That is a separate class from the token, and changing it changes which schedules a page contains rather than which tokens are accepted.
A published filter selects what the page is cut from
ListSchedules read its three filters in three different places relative to the cut. ScheduleGroup chose which name index to load and NamePrefix filtered that index — both ahead of the cut — but State was applied inside the render loop, on the records the page had already selected. So a state-filtered request was answered MaxResults schedules minus however many of that page failed the filter, while the NextToken alongside had been computed from the unfiltered listing (#1229).
API_ListSchedules publishes State in the same URI-parameter list as the other two and in the same words — "If specified, only lists the schedules whose current state matches the given filter." against NamePrefix's "Schedule name prefix to return the filtered list of resources." — so it is a filter on the listing, and the listing is what a page is cut from. The response member is the ordinary cursor: "Indicates whether there are additional results to retrieve. If the value is null, there are no more results." Nothing on the page says a page may be shorter than the MaxResults it was asked for.
Two observations changed, the second worse than the first. ?State=ENABLED&MaxResults=20 over a group of 40 where half are DISABLED answered fewer than 20 enabled schedules with a NextToken, where AWS answers 20. And when every schedule on a page failed the filter, the answer was {"Schedules":[],"NextToken":"…"} with matches still to come — a shape a caller that stops at an empty list reads as "the filter matched nothing". AWS can produce that shape too, but from server-side scan limits, never from applying a filter it publishes.
The cost is a state.Get per schedule in the group, per call, because State lives in the record and not in the name index, so a state-filtered listing cannot be assembled from names alone. That is the deliberate reading: the alternative is to copy State into the name index so the filter stays index-only, which would duplicate a field of the record into an index and leave UpdateSchedule with two places to write it — the class #756 exists for.
What is unchanged. The offset now counts matching schedules, so a token issued with a State filter and replayed without one indexes into a different listing — already true of NamePrefix, and already recorded above as a property of every offset paginator in the tree. And a name the index holds with no record behind it, or a record that will not decode, is still skipped: those are store inconsistencies rather than published filters, there is no request a caller could send to produce either, and the page publishes no code for substrate's own index and records disagreeing, so a refusal would be inventing one. What changed is that skipping one can no longer shorten a page — it shortens the listing being cut from, exactly as a filter does, and a page is short only when the listing has run out.
A log group that does not exist is not an empty log group
DescribeLogStreams, GetLogEvents and FilterLogEvents each answered HTTP 200 with an empty listing for a log group with no record, so "this group is empty" and "this group was never created" were the same response — and a consumer waiting for a Lambda's first log line could not tell a still-warming stream from a name it had spelled wrong. All three now answer ResourceNotFoundException / 400:
The specified log group does not exist: /aws/lambda/my-functionThe code and the status are published on each page, and the status is not a typo. Each of API_DescribeLogStreams, API_GetLogEvents and API_FilterLogEvents lists ResourceNotFoundException, glossed "The specified resource does not exist.", at HTTP Status Code 400, not the 404 the code's name suggests. API_DescribeLogGroups is the exception and is left alone: its Errors section publishes InvalidParameterException / 400 and ServiceUnavailableException / 500 and no not-found at all, so a logGroupNamePrefix matching nothing is a legitimately empty listing there, and refusing it would be inventing a code the page does not carry.
So the fix also moved four refusals that already existed. DeleteLogGroup, CreateLogStream, DeleteLogStream and PutLogEvents answered the same ResourceNotFoundException at 404, and each of their pages publishes 400. One code cannot keep two statuses in one service without a consumer matching on status seeing a difference AWS does not have, so all seven sites now go through two helpers and cannot drift. GetLogEvents also resolves the stream, since its logStreamName is Required: Yes and a stream is what it reads; the group is checked first, because a caller told the stream is missing would create it and be refused again.
The one status left as it was is CreateLogStream's and CreateLogGroup's ResourceAlreadyExistsException, which substrate answers at 409 where both pages publish 400. That is the same class of defect, but it is a different code with its own consumers, so it is filed as #1251 rather than swept in here under a not-found heading.
Against the token refusal above, the resource wins. No page publishes the order, so the reading is the one SNS ListSubscriptionsByTopic already records: a token is a continuation of a listing over the resource the request addresses, and there is no listing to continue when the resource does not exist. The token is still decoded before the listing is read, which is all #887's criterion asks — a refusal must not depend on how much state happens to exist, and existence of the addressed resource is not "how much". The absent-required-member refusal keeps the front of the sequence, since a request naming no group names no resource to resolve. All three orderings are asserted, in both directions, because every refusal involved answers 400 and the code is the only thing that says which one won. logStreamNames on FilterLogEvents is a filter and not the addressed resource, so a name in it with no stream behind it is still not refused.
Batch's three describes shared one paginator, so the decode had to leave it
DescribeComputeEnvironments, DescribeJobQueues and DescribeJobDefinitions are the last of those ten sites, and the only one where three operations paginate through one shared helper — which is why one conversion covers three operations, and why the fix had to move the decode out of the helper rather than correcting it in place.
All three answer ClientException / 400, glossed "These errors are usually caused by a client action. … Another cause is specifying an identifier that's not valid." and published in all three operations' own Errors sections. Each Batch page publishes exactly two errors — ClientException/400 and ServerException/500 — and Batch publishes no common-errors page, so those two are the whole published vocabulary and there is nothing a refusal here could borrow from a sibling:
nextToken is not a token returned by a previous DescribeComputeEnvironments requestThe footing is each page's own description of the parameter, and it names the operation. All three say "The nextToken value returned from a previous paginated Describe… request where maxResults was used and the results exceeded the value of that parameter.", each naming its own operation as the source. The same paragraph adds "Treat this token as an opaque identifier that's only used to retrieve the next items in a list and not for other programmatic purposes.", which is addressed to the caller rather than to the service and so is not itself a refusal rule — it is the page's own statement that the token's contents are not a caller-constructible value. What remains substrate's reading is only that a token is one of the identifiers the ClientException gloss covers: the page never joins the two sentences. No page publishes a Length or Pattern constraint on nextToken, so unlike KMS's 1–1024 there is no ceiling for the refusal to subsume and the issuability round trip is the entire rule.
Why the decode left the shared helper. DescribeJobDefinitions loads the job-definition index before it reaches the helper, to expand a jobDefinitionName into its revisions, so a decode inside the helper would have sat below a state read on exactly one of the three and only for one shape of request. Moving it to each handler makes the refusal unconditional, which is #887's criterion, and the state-store seal asserts it for all three rather than for two-and-an-argument. The body decode keeps its precedence: a request whose JSON does not parse cannot have a token read out of it, and since both refusals carry the same published code, the message is the only thing that distinguishes them.
Tokens are not portable between the three, and that is asserted rather than implied. The token carries an offset and nothing else, so a DescribeJobQueues token is well-formed at DescribeComputeEnvironments and indexes into a listing the caller never asked for. The message names the operation for that reason, and a test pins the non-portability so the naming is not mistaken for enforcement. A past-the-end offset still clamps to a final empty page, because a token substrate issued over a listing that has since shrunk is still a token it issued.
Two things this does not fix. maxResults outside the published range of 1–100 is clamped rather than refused — all three pages publish "If this parameter isn't used, then Describe… returns up to 100 results", so 100 is the published default for an absent value and applying it to a zero or negative one as well is substrate's reading. And DescribeJobDefinitions applies its status filter after the page is cut, which is recorded in the tree as substrate's reading of the page's ordering and is unchanged here.
ListJobs was recorded here as a third, because it published maxResults and nextToken and implemented neither. It is the fourth caller of the same decode as of #1236 — see ListJobs reads its request, which is also where the rest of that operation's members are argued.
Six describes published a cursor and implemented none of it
#916. The RDS and ElastiCache work above fixed the three describes that had a cursor. Six more publish Marker and MaxRecords and implemented neither: RDS DescribeDBSnapshots, DescribeDBSubnetGroups and DescribeDBParameterGroups, and ElastiCache DescribeReplicationGroups, DescribeCacheSubnetGroups and DescribeCacheParameterGroups. Each answered its entire listing, emitted no Marker, and discarded a MaxRecords the caller sent.
Of the three states a published parameter can be in — implemented, absent and refused, or accepted and ignored — the third is the worst, and it is the one all six were in:
- A paging loop is dead code until it runs against AWS. A consumer that walks until the
Markercomes back empty completes on the first response here, so the loop is never exercised; the first time it executes is against real AWS, over a listing long enough to page, with no test covering it. - A
Markerfrom anywhere else restarted the listing. With nothing decoding the parameter, a token persisted across a restart, copied from another operation, or left over from a recording was answered with the whole listing again rather than refused.
All six now go through the same three helpers as the first three — parseQueryMarker, queryMaxRecords and queryMarkerPage — so nine operations share one cursor, and the semantics cannot diverge between them by construction rather than by nine sites agreeing.
The refusal code is published for three of the nine, and the split is not the family boundary. This is a correction to #916's own acceptance criteria, which claimed the code was published for ElastiCache and unpublished for RDS:
| Page | InvalidParameterValue / 400 | Published errors |
|---|---|---|
API_DescribeCacheClusters | published | ElastiCache's cluster faults, plus InvalidParameterValue and InvalidParameterCombination |
API_DescribeReplicationGroups | published | ReplicationGroupNotFoundFault / 404, InvalidParameterValue / 400, InvalidParameterCombination / 400 |
API_DescribeCacheParameterGroups | published | CacheParameterGroupNotFound / 404, InvalidParameterValue / 400, InvalidParameterCombination / 400 |
API_DescribeCacheSubnetGroups | absent | CacheSubnetGroupNotFoundFault / 400 only |
API_DescribeDBInstances | absent | DBInstanceNotFound / 404 only |
API_DescribeDBClusters | absent | its own NotFound fault only |
API_DescribeDBSnapshots | absent | DBSnapshotNotFound / 404 only |
API_DescribeDBSubnetGroups | absent | DBSubnetGroupNotFoundFault / 404 only |
API_DescribeDBParameterGroups | absent | DBParameterGroupNotFound / 404 only |
API_DescribeCacheSubnetGroups is the case that matters: it is an ElastiCache page that publishes no InvalidParameterValue, so "ElastiCache publishes it, RDS does not" is not a rule the provenance can rest on. Three of nine are published and six are substrate's reading, and which is which has to be stated per page.
The MaxRecords range is published identically on all nine — Default: 100 with a minimum of 20 and a maximum of 100 — though not in identical words: the RDS pages write Constraints: Minimum 20, maximum 100. and the ElastiCache pages Constraints: minimum 20; maximum 100. The numbers are what one shared range depends on, and they agree. Both parameters are validated before any state is read, so a request substrate cannot serve is refused rather than answered with page one.
A single-resource filter that lands past page one still answers its record. Every one of the six publishes one — DBSnapshotIdentifier, DBSubnetGroupName, DBParameterGroupName, ReplicationGroupId, CacheSubnetGroupName, CacheParameterGroupName — and filtering plus paging is the ordering trap the issue names: a filter applied after a page is cut would answer an empty page, or a NotFound fault, for a record that exists and merely sorts late. It cannot happen here, because the filter runs inside queryMarkerPage's record callback and a non-matching record consumes no page slot: a filtered listing is one record long however deep into the unfiltered listing the record sits. DescribeReplicationGroups was the one of the six answering a NotFound fault when this landed — all six do since #1020 — and its fault is therefore decided on a page that cannot be empty for a group that exists. Both properties are asserted rather than left to the reasoning.
DescribeDBSnapshots is the one of the six with more than one filter to order against — it also publishes DBInstanceIdentifier, DbiResourceId, SnapshotType, IncludePublic, IncludeShared and Filters.Filter.N — and both of its sibling RDS group describes publish Filters.Filter.N as "Not currently supported", a parameter AWS declares and refuses to honour, so substrate ignoring it matches the page rather than diverging from it.
What this did not fix. Five of the six published a NotFound fault their handler did not answer: a filtered request naming a resource that does not exist got an empty 200 instead of DBSnapshotNotFound / 404, DBSubnetGroupNotFoundFault / 404, DBParameterGroupNotFound / 404, CacheSubnetGroupNotFoundFault / 400 or CacheParameterGroupNotFound / 404. That is a defect about a request's result, not about how a listing is cut into pages, so it was #1020 rather than part of that change — the same reason MaxRecords was kept out of the cursor fix above. It is fixed below.
A single-resource filter that names nothing answers the published fault
#1020. Each of the six describes above publishes exactly one single-resource filter and a NotFound fault to go with it, and five of the six answered an empty 200 for a filter that matched nothing. A consumer's error path for a resource that has been deleted — the branch every retry and every "create if absent" depends on — was therefore dead code that first executed against real AWS.
The fault belongs to the filter, not to the listing. Every gloss says so, naming the parameter rather than the collection: "DBSnapshotIdentifier doesn't refer to an existing DB snapshot", "DBSubnetGroupName doesn't refer to an existing DB subnet group", "The requested cache subnet group name does not refer to an existing cache subnet group". So an unfiltered listing with no records is still an empty 200, which is asserted separately — a check written as "the page came back empty" would refuse the first call a fresh emulator serves.
| Page | Code | Status |
|---|---|---|
API_DescribeDBSnapshots | DBSnapshotNotFound | 404 |
API_DescribeDBSubnetGroups | DBSubnetGroupNotFoundFault | 404 |
API_DescribeDBParameterGroups | DBParameterGroupNotFound | 404 |
API_DescribeReplicationGroups | ReplicationGroupNotFoundFault | 404 |
API_DescribeCacheSubnetGroups | CacheSubnetGroupNotFoundFault | 400 |
API_DescribeCacheParameterGroups | CacheParameterGroupNotFound | 404 |
Nothing in that table is substrate's reading; each row is its own page's Errors section. Two disagreements in it are AWS's and are reproduced rather than tidied: three codes carry a Fault suffix and three do not, and the ElastiCache subnet-group fault is 400 where the other five are 404. A sweep that made either uniform would break a consumer matching on the code, which is why both are asserted per operation rather than derived from a shared constant.
All six now go through one helper, queryMarkerFilterNotFound, including DescribeReplicationGroups, which already answered its fault inline. One condition in one place is the same argument the shared cursor rests on, and it is what keeps the six from drifting into six readings of one sentence.
Pagination cannot make the fault fire for a record that exists, and that is closed by construction rather than by ordering two checks. The filter runs inside the record callback, so a non-matching record consumes no page slot; at most one record can match a single-resource filter; and the published minimum MaxRecords is 20. A filtered page therefore never truncates and never carries a Marker.
One case is substrate's reading: a caller that sends both a Marker and a filter naming a record at or before that marker gets the fault, because the cursor skipped the record. No page says anything about combining a cursor with a single-resource filter, and substrate reads a Marker as naming a position in the listing — so such a request asked about a stretch of records that does not include its own, and the fault is the honest answer to the request as asked.
DescribeDBSnapshots is the one operation whose fault is narrower than "the filtered page is empty", because it publishes two single-resource filters and a fault for only one of them. DBInstanceIdentifier carries the constraint "if supplied, must match the identifier of an existing DBInstance" and no Errors entry, so an instance that does not exist is an empty 200. The case that decides the implementation is a snapshot that exists under a different instance: the caller named its snapshot correctly, so DBSnapshotNotFound would be a false statement, and substrate tracks whether the snapshot identifier matched independently of the instance filter.
One AWS slip is recorded rather than followed: API_DescribeDBParameterGroups constrains its DBParameterGroupName to "the name of an existing DBClusterParameterGroup", which is a different resource described by a different operation. The Errors gloss — "DBParameterGroupName doesn't refer to an existing DB parameter group" — is the statement substrate implements.
Two more cursors published and unread, outside EC2
#917 is mostly an EC2 change (see One offset paginator, shared), but two operations in other services were in the same state — both halves of a cursor published on the URI, and neither read:
- Lambda
ListEventSourceMappings, whose URI publishes?EventSourceArn={EventSourceArn}&FunctionName={FunctionName}&Marker={Marker}&MaxItems={MaxItems}. - API Gateway
GetBasePathMappings, whose URI publishes?domainNameId={domainNameId}&limit={limit}&position={position}.
Each answered its whole listing with no token whatever the caller sent, so a paging loop terminated on its first response — the divergence a caller cannot see, described in full above.
Both now cut their page through the same helper as each other, over the base64 offset token the CloudWatch, Systems Manager and S3 listings use rather than EC2's decimal one. A token substrate could not have issued is refused rather than answered with page one, and refused before any state is read; an offset past the end clamps to an empty final page. Those are the rules already stated for a token substrate never issued, and this change adds no new ones.
The page-size rules are per operation, and neither resembles EC2's.
| Operation | Absent page size | Out of range | Refusal |
|---|---|---|---|
Lambda ListEventSourceMappings | 100 | MaxItems outside 1–10000 is refused, never clamped | InvalidParameterValueException / 400, which the page publishes |
API Gateway GetBasePathMappings | 25, the published default | limit outside 1–500 is refused | BadRequestException / 400, which the page publishes |
MaxItems publishes two bounds that are two different rules, and substrate keeps them apart: Valid Range: Minimum value of 1. Maximum value of 10000. is what the parameter accepts, and "Note that ListEventSourceMappings returns a maximum of 100 items in each response, even if you set the number higher" is what a response may carry. So MaxItems=5000 is a valid request that answers at most 100 mappings with a NextMarker, while MaxItems=10001 is refused. An absent MaxItems is that same 100, which is substrate's reading: the page publishes no default and states the cap against every response.
API Gateway is the opposite case, and the only paginated operations in the tree with a published default: "The maximum number of returned results per page. The default value is 25 and the maximum value is 500." A request naming no limit therefore still pages, at 25, where it used to answer the whole collection. No minimum is published — the floor of one is substrate's reading, for the reason the EC2 section gives: a page of zero elements describes a walk that answers nothing and hands back a position forever. The same two sentences appear on all seven paginated v1 collections, so that reading is applied once and shared; see Six more v1 collections read the pair they publish.
Neither operation gained an InvalidParameterCombination. EC2's service-wide rule that an ID list and MaxResults may not appear together has no counterpart on either page: Lambda's FunctionName and EventSourceArn narrow a page rather than forbidding one, and importing the EC2 rule would be substrate inventing a refusal.
The order each cursor counts positions in is substrate's reading, since neither page publishes one. GetBasePathMappings walks the state keys' lexicographic order, which is by base path within the domain. ListEventSourceMappings has two code paths — a scan of the mapping keys when no function is named, and a per-function index when one is — and the index holds mappings in creation order, so it is sorted by UUID before the page is cut. Without that the same Marker would name two different positions depending on whether the caller passed FunctionName.
What the same audit found still unconverted is counted, not estimated. Nine routed EC2 describes published MaxResults and NextToken and read neither; #1024 converted them in three parts, grouped by the range each page publishes, and each is named with its range in One offset paginator, shared. Six routed API Gateway v1 collections were in that state too; all six were converted under #1025, two at a time — see the next section. Lambda's ListFunctions is a third case of the narrower defect: it pages, but accepts a Marker it never issued and range-checks no MaxItems.
Six more v1 collections read the pair they publish
#1025 is the section above applied to the rest of API Gateway v1. Six collections besides GetBasePathMappings publish limit and position on their URI and read neither, answering the whole collection with no cursor: GetRestApis, GetResources, GetDeployments, GetAuthorizers, GetApiKeys and GetUsagePlans. All six carry AWS's two sentences byte-identically, so this is one rule six times rather than six rules, and the bounds, the default, the absent minimum and the BadRequestException / 400 to refuse with are read in one place for all seven.
All six now page, converted two at a time. Each gained the request parameter its signature could not previously reach — six of the eight v1 collection handlers took no request at all — which is why this was three changes rather than one sweep. With GetBasePathMappings, every v1 collection whose URI publishes the pair now reads it.
The order each collection is paged in is substrate's reading, because no page publishes one, and it is the order the collection already had. These six are walked in ascending element ID: each is built from a string index of IDs which is kept sorted as it is written, so the order is persisted state rather than a map walk and is therefore stable between two reads and across a replay — the obligation an offset cursor imposes. Two consequences are worth stating plainly, because both are things a reader may expect not to hold:
- An ID is generated, not chosen by the caller, so the order is not one a caller can predict from its own inputs. It is only one it can rely on not to change under it.
- A REST API's root resource is not first in
GetResources. Its ID is generated like any other resource's, so/falls wherever that ID sorts, and a first page of a large API need not contain it. Sorting bypathinstead would make a nicer collection to read and a worse emulator: it is not the order this operation answered in before it paged, and nothing published asks for it. GetDeploymentsis not newest-first, or oldest-first. A deployment ID is generated too, so the order carries no relation to thecreatedDatethe element publishes. The same argument applies: sorting bycreatedDatewould read better and emulate worse.
Five further request parameters across three of the six are still unread, and they are a different divergence. GetResources publishes embed, whose only accepted value is methods, and substrate answers every resource with its resourceMethods populated regardless. GetApiKeys publishes customerId, includeValues, and the parameter documented as nameQuery but spelled name on the query string — worth recording, since a later reader will grep for the documented name and find nothing. GetUsagePlans publishes keyId. Every one of the five narrows what is reported, so honoring one would over-report before and under-report after — the opposite direction from the cursor defect these conversions fix, and not the rest of it. Narrowing a response a caller may already be reading is a compatibility break that wants its own issue and its own citation.
GetApiKeys publishes a third response member, warnings, and it is deliberately absent rather than reported empty. The page says it holds "a list of warning messages logged during the import of API keys when the failOnWarnings option is set to true", and failOnWarnings belongs to ImportApiKeys, which substrate does not route. So no call that can reach this handler could produce a warning and no state could hold one; under #1013's rule an unmodelled member is omitted rather than sent empty, because [] would be a claim that the import ran and warned about nothing.
A tag set read back out of a map
#946 is the same defect one layer down, at five operations the audit above could not reach: they build their response from a resource's own map[string]string of tags rather than from StateManager.List. They are ACM's ListTagsForCertificate, CloudFront's ListTagsForResource, Kinesis's ListTagsForStream, and S3's GetBucketTagging and GetObjectTagging. Each ranged that map and appended, so the order on the wire came from the map's hash seed and two identical reads of unchanged state could disagree. All five now report their tags sorted by key.
Sorting List could not have fixed these, and neither could the four tag merge helpers #862 sorted: the map is loaded whole from a single state key, so no listing is involved. A rendering of a map as a JSON object was never affected, because encoding/json sorts map keys itself — the defect is specific to a map flattened into an ordered array or into a repeated XML element, where nothing sorted.
Four of the five sit in tier 3 and one in tier 2. ACM's page, CloudFront's, and both of S3's publish neither a cursor nor an ordering statement, so lexicographic there is substrate's reading, resting on the replay promise exactly as ListBuckets' order does. Kinesis's is tier 2: ListTagsForStream states no order in prose either, but it publishes ExclusiveStartTagKey — "the key to use as the starting point for the list of tags. If this parameter is set, ListTagsForStream gets all tags that occur after ExclusiveStartTagKey" — and a tag cannot occur after a key unless the tags are walked in some order over keys. Which order is still substrate's reading, and AWS's own sample response is not sorted.
That cursor is implemented as of #954, and this sort was its prerequisite for the reason the tier-2 defect above already demonstrated: a cursor paged over an unstable order skips and repeats. See Paging the tags on a stream for what a page contains and for the two AWS sentences about HasMoreTags that do not agree.
The two readers #946 could not see
#1011 is the same defect at the two sites that sweep missed, and it was found the way the others were not — by measuring rather than by reading. Twelve identical GetResources calls against one eight-tag resource in one process produced eight distinct bodies, all rotations of the sorted order, which is the signature of Go's randomised map-range start offset over a map whose insertion order was already sorted.
The first site is the Resource Groups Tagging API's own GetResources, which #946 could not have covered because it is not a per-service tag listing: it renders every service's tags, through four shared converters in tagging_plugin.go. mapToTaggingTags is the load-bearing one — it has most of the scanners as call sites and is the only one of the four that passes through a map at all, so the other three (iamTagsToTaggingTags, ec2TagsToTaggingTags, efsTagsToTaggingTags) were order-preserving by luck and it could not be. All four sort now, including the three that were already fed a sorted slice by #862's merge helpers, so the guarantee does not rest on every writer remembering to sort: a raw writer that bypasses a merge helper cannot make the response non-deterministic through them. The four scanners that build the list by hand — KMS, SNS, Secrets Manager and Systems Manager — already sorted, so the converters were the whole gap.
GetResources had exactly one sort before this, on ResourceARN, which made the resource order deterministic and left the tag order inside each resource to the map. That is why a reader checking the write path found nothing wrong: state on disk was ordered by #862, and the disorder was introduced on the way out.
The second site is ElastiCache's ListTagsForResource, which is exactly the shape #946 swept and was missed for a reason worth naming: all five of #946's operations answer JSON, and ElastiCache flattens its map[string]string into a repeated XML element instead, so it did not match that sweep's shape. The rest of the tree was swept for this pattern at the same time; every other site that ranges a tag map either already sorts or writes into another map, where no order is observable.
Both sit in tier 3. API_GetResources says nothing about the order of Tags within a ResourceTagMapping, and API_ResourceTagMapping describes Tags only as "the tags that have been applied to one or more AWS resources"; ElastiCache's API_ListTagsForResource describes TagList.Tag.N only as "A list of tags as key-value pairs" and publishes no cursor at all, so unlike Kinesis there is nothing here implying an order even indirectly. Lexicographic by key is substrate's reading at both, resting on the replay promise. No tie-break is needed, because a tag set cannot carry a duplicate key.
The assertion is made on the raw response bytes, and that is the finding underneath the finding: the existing tagging suite decoded ResourceTagMappingList and then collected ResourceARN only, discarding Tags, so it was structurally incapable of seeing this — the same way #950's suite could not see a parse guard. A decoded tag set compares equal whatever order it arrived in.
How a seed survives a replay
A seed is recorded as an event and re-applied where it was written, so a stream recorded under a seed replays under the same seed. This is stated once here rather than at each of the seeding sections below, because it is one mechanism serving all of them.
Every seedable outcome in substrate is written through a control-plane endpoint — POST/DELETE /v1/{service}/… — rather than through an AWS request, and until #1140 only AWS requests were recorded. A replay resets the whole state manager before re-executing, and a seed lives in the state manager, so a stream recorded under a seed replayed as the unseeded sequence: four pending snapshot observations followed by completed came back as five completeds. Nothing failed, because every recorded request was re-executed and every one succeeded — the divergence was in the answers, not in the count.
A successful control-plane write is now recorded as an event of its own, carrying the method, the request target including its query string, the body and the status. Replaying it re-issues that request against the emulator's own control plane. Three consequences worth knowing:
- Position is preserved. A seed written between two observations is re-applied between the same two, so a recording whose first poll answered
completedand whose second answeredpending— because the seed landed in between — replays in that order. Preserving the seed across the reset instead, the obvious smaller fix, could not do this. - A consumed count restarts. The reset still wipes the observation counters and the spend-down budgets, and the recorded write re-arms the seed as it was originally armed. That matters most for the seeds whose budget is spent in place — SQS's queue-miss count and S3's three conditional-conflict counters decrement the stored record — where by the end of a recording the stored value is zero and there is nothing left to preserve.
- A
DELETEreplays as the sameDELETE. Most clear endpoints name their target in the query (?snapshotId=,?bucket=&key=,?roleName=), which is why the event records the full request target and not the path alone.
A replay driven programmatically must be given the handler. substrate replay wires it itself; a test constructing an emulator.ReplayEngine directly passes emulator.WithControlPlaneHandler(ts.ControlPlaneHandler()). Without it a recorded seed is reported in SkippedEvents and the replay answers the unseeded sequence — the behaviour every replay had before #1140, now visible in the counters rather than silent. Withholding it is occasionally what a test wants: an unseeded replay is the sharpest check that a seed governs only what an observation reports and was never written back into the resource record.
A few control-plane endpoints are deliberately not recorded. /v1/state/reset, /v1/control/time and /v1/control/scale are the replay's own business — it resets the state manager and freezes the clock at each recorded timestamp itself, so re-applying a recorded reset or time set would fight it. /v1/fault/rules writes to the fault controller, which the replay rewinds to the configuration it was armed with, so re-arming would double every rule. The rest write nothing a replayed AWS observation can read: /v1/s3/presign mints a URL, /v1/pricing/refresh reloads a price table, and the pricing discount and credit endpoints write to the cost tracker rather than to the state manager the reset clears.
CloudFormation
Endpoint: cloudformation.{region}.amazonaws.comProtocol: AWS Query (form-encoded, Action= parameter)
Supported operations
| Operation | Notes |
|---|---|
| CreateStack | Deploys every resource in TemplateBody and returns the stack ARN; honours RoleARN |
| UpdateStack | Re-deploys the template; an omitted TemplateBody re-uses the stored one, and an omitted RoleARN the stored role |
| DeleteStack | Sweeps the resources the stack deployed, then removes the stack record (see below); deleting an absent stack succeeds; RoleARN applies to this operation only |
| DescribeStacks | One stack by StackName, or every stack when omitted; reports RoleARN when the stack has one |
| ListStacks | Summary shape; honours StackStatusFilter.member.N |
| DescribeStackResources | By StackName + optional LogicalResourceId, or by PhysicalResourceId |
| GetTemplate | Returns the stored TemplateBody byte-for-byte |
| CreateChangeSet | ChangeSetType=UPDATE only; records Tags; see below |
| DescribeChangeSet | Accepts a bare change-set name or its ARN |
| ExecuteChangeSet | Applies the change and its tags, and consumes the set |
| ListChangeSets | Pending change sets for a stack |
| DeleteChangeSet | Discards a pending set; deleting an absent set succeeds |
| DetectStackDrift | Returns a StackDriftDetectionId |
| DescribeStackDriftDetectionStatus | Resolves that ID to a completed detection |
| DescribeStackResourceDrifts | Per-resource drift; honours StackResourceDriftStatusFilters.member.N |
| ListExports | Every exported output value in the caller's account and Region, in one page |
| ListImports | Stack names importing an ExportName; an export nothing imports is an empty list |
TemplateURL is refused with ValidationError rather than ignored: fetching a template is a network read substrate does not perform, and silently accepting the parameter deployed a stack with no resources in it.
A request that names something absent — a stack, a change set, a drift detection — is a ValidationError at 400, and a template body that cannot be decoded is a ValidationError at 400 prefixed Template format error:. A resource that failed to deploy after the template parsed is an InternalFailure at 500, because the request was well-formed and the failure is substrate's. That distinction is drawn from the classification the stack model attaches to a failure, not from its message, so a message may be reworded without moving any consumer's error code.
A stack ARN is accepted wherever StackName is
CreateStack reports the stack's ARN as its StackId, and every stack-scoped operation takes that identifier in place of the name — "The name or the unique stack ID that's associated with the stack", as the reference puts it. That covers UpdateStack, DeleteStack, DescribeStacks, DescribeStackResources, GetTemplate, the four change-set operations that take a StackName, and both drift operations. CreateStack is the exception, and the API's: the ID does not exist until that call mints it.
ARN=$(aws cloudformation create-stack --stack-name probe \
--template-body file:///tmp/probe.json --query StackId --output text)
aws cloudformation describe-stacks --stack-name "$ARN" # the stack
aws cloudformation delete-stack --stack-name "$ARN" # sweeps its resourcesThe ARN is verified, not merely parsed for the name inside it. Substrate builds a stack ARN from the caller's partition, Region and account plus a digest over those and the stack name, so an ARN naming another account, another Region, another partition, or a digest that does not belong to the name attached to it is not an identifier substrate would have issued — and is refused with the same ValidationError an absent stack reports. Reporting the two cases identically is deliberate: a stack outside the caller's account and Region is one the caller cannot observe, so a distinct error would disclose whether some other scope holds a stack by that name.
Stacks share state with every other plugin
The plugin is a thin adapter over the same stack model substrate has always exposed to in-process Go callers, and it deploys through the same plugin registry the server routes with. A resource a template declares is therefore a real resource in the corresponding plugin:
aws cloudformation create-stack --stack-name probe \
--template-body '{"Resources":{"B":{"Type":"AWS::S3::Bucket",
"Properties":{"BucketName":"probe-data"}}}}'
aws s3api head-bucket --bucket probe-data # 200 — a real bucket
aws s3api put-object --bucket probe-data --key k --body fThe reverse also holds: a stack created in process through emulator.Client is visible to DescribeStacks over the wire, and a wire-created stack is visible to the in-process API. There is one set of stacks.
Deleting a stack's resource outside CloudFormation is the drift substrate models — DescribeStackResourceDrifts reports it as DELETED.
A stack deploys into the calling account and region
Most plugins scope a resource to the account, and some also to the region, of the request that created it. A stack's resources are created under the identity of the caller that created the stack, so they are visible to that caller's reads and no one else's:
# unsigned, so the caller is the default account — 123456789012
aws --endpoint-url http://localhost:4566 cloudformation create-stack \
--stack-name acct --template-body '{"Resources":{"I":{"Type":"AWS::EC2::Instance",
"Properties":{"ImageId":"ami-12345678","InstanceType":"t3.micro"}}}}'
aws --endpoint-url http://localhost:4566 ec2 describe-instances # 1 reservation
aws --endpoint-url http://localhost:4566 --region eu-west-1 \
ec2 describe-instances # 0 — a different partitionAWS::AccountId and AWS::Region resolve to the same caller, so a physical name built from either agrees with the stack ARN.
The in-process emulator.Client deploys into substrate's default partition (123456789012 / us-east-1). Its callers never sign a request, so there is no caller identity to take; an in-process caller that needs another partition can set one on the deployer with emulator.WithDeployerIdentity. An unsigned wire caller lands in the same partition — see Which account a request is attributed to — so a stack deployed in process and one deployed over the wire are visible to each other without either side configuring anything.
A stack's resource calls are authorized
CloudFormation does not create resources as itself. With a service role it "always uses this role for all future operations on the stack"; without one it uses "a temporary session that's generated from your user credentials". Substrate models both, so a template asking for a permission the deploying identity does not have fails the way it fails on AWS instead of deploying cleanly.
RoleARN is accepted on CreateStack, UpdateStack and DeleteStack and reported by DescribeStacks. Its lifetime differs by operation, following the reference rather than convenience:
UpdateStackwithoutRoleARNkeeps the role the stack already has, and one that supplies it replaces the role for that update and every operation after.DeleteStack'sRoleARNapplies to that delete only and is not persisted, so a delete refused by its override leaves the stack's own role intact and a retry runs as the identity the stack was created with.- A stack with no service role reports no
RoleARNat all rather than an empty string.
Absent a service role, the calls are attributed to the principal that created the stack — so CreateStack cannot be used to obtain a permission the caller does not have. The same resolution covers teardown: a resource created by a role is deleted by that role, not by whoever asks for the delete, and a rollback's sweep runs as the identity that created what it is tearing down.
A refused resource call surfaces as CREATE_FAILED with the denial — AccessDenied, naming the action and the resource ARN — as that resource's ResourceStatusReason in DescribeStackResources, and the stack rolls back:
aws cloudformation create-stack --stack-name s --template-body file://bucket.json \
--role-arn arn:aws:iam::123456789012:role/narrow
aws cloudformation describe-stacks --stack-name s \
--query 'Stacks[0].[StackStatus,RoleARN]'
# ROLLBACK_COMPLETE arn:aws:iam::123456789012:role/narrow
aws cloudformation describe-stack-resources --stack-name s \
--query 'StackResources[0].ResourceStatusReason'
# AccessDenied: User: arn:aws:iam::123456789012:role/narrow is not
# authorized to perform: s3:CreateBucket on resource: arn:aws:s3:::...The denial is also reported as a StackEvent, which is where a deployment wrapper conventionally reads a CloudFormation failure from:
aws cloudformation describe-stack-events --stack-name s \
--query 'StackEvents[?ResourceStatus==`CREATE_FAILED`].ResourceStatusReason'
# AccessDenied: User: arn:aws:iam::123456789012:role/narrow is not
# authorized to perform: s3:CreateBucket on resource: arn:aws:s3:::...Both views share one derivation, so they cannot disagree about whether a resource failed. See "Stack events are derived from the stack record" below for what the event model does and does not contain.
Enforcement is opt-in by creating the principal: a stack deployed with a credential that resolves to no IAM user or role in state is not authorized at all, which is every in-process emulator.Client caller and every credential that never touched IAM. See the testing guide's "Testing IAM permissions".
A resource with no name in the template gets a per-stack name
Omitting a resource's physical name is the recommended practice — it is what makes a template deployable more than once, and AWS documents that naming a resource explicitly costs you replacement updates. So substrate generates a name for the omitted case, in the shape CloudFormation documents for its own generated physical IDs (MyStack-MyBucket-abcdefghijk1):
{stack name}-{logical ID}-{12-character suffix}The omitted case previously used the logical ID verbatim, which is unique only within a stack. Every name below is unique across an account or a Region, so a second stack from the same template either collided outright or — for SQS::Queue and SNS::Topic, whose creates are idempotent — silently shared one resource with the first, and deleting either stack destroyed the other's resource with no error reported to anyone (#560).
- An explicit name still wins, verbatim. A template that sets
BucketNamegets exactly that bucket, un-repeatability included, because that is what it asked for. - The suffix is derived, not random — FNV-64a over the account, Region, stack name and logical ID, base36. This is substrate's own divergence: AWS randomizes.
UpdateStackhere re-deploys the whole template, so a name regenerated per deploy would mint a fresh resource on every update and leak the one it replaced. Deriving it keeps an unchanged update a no-op and every name reproducible from its inputs. The account and Region are in the hash because two same-named stacks in different Regions are different stacks. - The name fits the service. The stack and logical-ID segments are truncated proportionally to fit the service's limit and lowercased where the service demands it; the suffix is never truncated, since it is the only part that makes the name unique. A generated name always begins with a letter and holds only ASCII letters, digits and hyphens, with no trailing or doubled hyphen.
The types that get a generated name, with the limit each is fitted to:
| Resource type | Name property | Limit |
|---|---|---|
AWS::IAM::Role | RoleName | 64 |
AWS::IAM::InstanceProfile | InstanceProfileName | 128 |
AWS::IAM::Policy | PolicyName | 128 |
AWS::S3::Bucket | BucketName | 63, lowercase |
AWS::DynamoDB::Table | TableName | 255 |
AWS::SQS::Queue | QueueName | 80 |
AWS::SNS::Topic | TopicName | 256 |
AWS::Logs::LogGroup | LogGroupName | 512 |
AWS::Lambda::Function | FunctionName | 64 |
A type absent from that table still uses its logical ID. The list is names that are account- or Region-unique, not every property that falls back to the logical ID: an AWS::ApiGateway::Resource's PathPart is a URL segment unique only within its parent, and Domain, SecretId, ClusterId and ReplicationGroupId are identifiers a template legitimately controls. Generating those would change the URLs and identifiers a consumer wrote the template to get.
Ref and GetAtt resolve to the generated name and ARN, and the delete sweep deletes by it, so a template that wires its resources together needs no change — and two stacks from one template can now be torn down independently.
PhysicalResourceId is the stored identifier, and is not per type
Ref is resolved per resource type. PhysicalResourceId is not, and the asymmetry is deliberate (#837). The three operations that report it — DescribeStackResources, DescribeStackEvents and DescribeStackResourceDrifts — all report the identifier substrate stored when it deployed the resource: a bucket or role name, a VPC or instance ID, a generated name from the table above. Nothing is derived at render time.
The question this settles was raised by #827, which made Ref per-type and scoped PhysicalResourceId out. The natural inference — Ref's documented value differs by type, so PhysicalResourceId's probably does too — does not survive checking the API reference, and that is the whole of the reason:
API_StackResourcepublishes one description for every type: "The name or unique identifier that corresponds to a physical instance ID of a resource supported by CloudFormation."Type: String,Required: No. There is no per-typePhysicalResourceIddocumented anywhere in the API reference, the way each type's Template Reference page documents its ownRef.- The one type AWS names explicitly it names on
API_DescribeStackResources: "for an Amazon Elastic Compute Cloud (EC2) instance,PhysicalResourceIdcorresponds to theInstanceId." That is a name-or-ID, and it is what substrate already stores. - The only worked values AWS publishes are that page's own sample response —
MyStack_DB1andMyStack_ASG1. Generated names, not ARNs.
So a per-type PhysicalResourceId would be substrate inventing a divergence AWS does not publish, which is the opposite of what #827 did: Ref was made per-type because AWS documents it per type. A per-type audit is therefore not deferred — it has no citable source to be conducted against, and that is recorded here rather than left as an open question.
The round trip AWS describes does work: the value the describes report is accepted back by DescribeStackResources' own PhysicalResourceId selector, which is the use the page puts it to ("You can pass the EC2 InstanceId to DescribeStackResources to find which stack the instance belongs to"). Naming both it and StackName in one request is a ValidationError, as published. DescribeStackResource (singular) and ListStackResources are not implemented.
Two arguments for keeping the stored value were made when this was filed and are corrected here rather than repeated, because both were checkable and neither held (#819):
- A redeploy does not recognise a resource by its physical ID. It matches on logical ID, and recognition of an unchanged resource is
clearUnchangedRedeploys, whose reason the physical ID cannot be the key is that a refused create returns none at all. - Not every tag state key is composed from the physical ID. The four ELBv2 types find their record by ARN and bypass the stamp's target resolver entirely.
What does still hold is that most aws:cloudformation:* tag state keys are composed from it, which is why the stored value is the one thing a later change must leave alone: derive at render time if a type ever needs a different reported value, and the safety rule is that a stored identifier may change only if no tag state key is composed from it and it is stable across a redeploy.
DeleteStack deletes the stack's resources
DeleteStack sweeps the resources the stack deployed before removing the stack record: a bucket a stack created is gone once its stack is, and s3api head-bucket answers 404.
The sweep is the exact inverse of the deploy — resources are ordered by descending deploy priority, ties by descending logical ID — so a resource is deleted before whatever it was created after. Substrate deploys in priority order rather than from a dependency graph, and inverting that order is what makes the teardown observable: the recorded event sequence for a stack of an IAM role, a bucket, a queue and a topic reads CreateRole, CreateBucket, CreateQueue, CreateTopic and then DeleteTopic, DeleteQueue, DeleteBucket, DeleteRole.
DeletionPolicy and UpdateReplacePolicy are parsed from both the JSON and YAML template paths, and a value outside Delete/Retain/RetainExceptOnCreate/ Snapshot is a template error rather than a silent default. Retain keeps the resource and the stack still deletes; RetainExceptOnCreate retains for a DeleteStack but not for the rollback of the create that made the resource. The default is Delete, except Snapshot for AWS::RDS::DBCluster and for an AWS::RDS::DBInstance that declares no DBClusterIdentifier — a sweep that assumed Delete would destroy a database the template asked to be snapshotted. Snapshot does not retain: substrate deletes the resource and records in the per-resource reason that no snapshot was taken, since no snapshot resource is modelled for any of the eight Snapshot-capable types.
A resource already absent — deleted out of band between the deploy and the sweep — is a success, not a failure: a stack must not be wedged by a resource someone else removed. Any other refusal is a failure, and a stack with a failed deletion keeps its record, its resource list and its name index while reporting DELETE_FAILED in DescribeStacks, with the offending resource and the plugin's own error code in the reason. A stack that reported a failed delete and then vanished would leave a caller no way to retry and no way to learn what held it.
The delete-stack call still answers 200 in that case, as the API does: real DeleteStack returns success and the stack reaches DELETE_FAILED asynchronously, so poll DescribeStacks to learn the outcome rather than relying on the call raising. The in-process emulator.StackDeployer.DeleteStack returns an error directly, since a Go caller has no status to poll.
aws cloudformation delete-stack --stack-name probe
aws s3api head-bucket --bucket probe-data # 404 — deleted with its stack
aws cloudformation delete-stack --stack-name stuck # 200
aws cloudformation describe-stacks --stack-name stuck \
--query 'Stacks[0].StackStatus' # DELETE_FAILEDA resource whose delete needs a detach first gets one
Some deletes are refused while a subordinate entity still references the resource. AWS::IAM::InstanceProfile is the case substrate models: the sweep dispatches one RemoveRoleFromInstanceProfile for each role in the profile's declared Roles before DeleteInstanceProfile, resolving each !Ref through the same context the deploy used — so a role whose name was generated is detached by its generated name.
Without it the stack could not converge from either side (#581): DeleteRole succeeded while the profile still held the role, leaving the profile listing a role that no longer existed, and DeleteInstanceProfile then refused with DeleteConflict. The failure therefore landed on the resource that was still present, and a retry failed identically — with no RetainResources escape, since retaining the profile leaves it behind for good.
A pre-step failure fails that resource: a delete dispatched after a failed detach would be refused anyway, and reporting the detach's own error names what went wrong. A profile declaring no roles dispatches only its own delete.
Coverage is stated rather than implied. Of the 109 resource types the deployer dispatches, 89 have a delete request and 11 are state-only types whose stub record is removed. The remaining 9 sweep to a no-op and are reported as DELETE_SKIPPED naming the reason:
| Type | Why the sweep is a no-op |
|---|---|
AWS::CloudFront::CloudFrontOriginAccessIdentity | the ID is derived rather than registered with CloudFront, so the deploy records no state to remove |
AWS::ECS::CapacityProvider | the deploy records no state to remove |
AWS::SSM::Association | the deploy records no state to remove |
AWS::SecretsManager::SecretTargetAttachment | the deploy records no state to remove |
AWS::Route53::RecordSetGroup | its record sets are dispatches the stack does not record individually |
AWS::ECR::LifecyclePolicy | DeleteLifecyclePolicy is not routed; deleting the repository removes the policy |
AWS::ApiGateway::UsagePlanKey | DeleteUsagePlanKey is not routed; the key goes with its usage plan |
AWS::Cognito::IdentityPoolRoleAttachment | Cognito models no DeleteIdentityPoolRoles |
AWS::SecretsManager::RotationSchedule | CancelRotateSecret is not routed; the schedule goes with the secret |
A type the deployer does not recognize at all is also DELETE_SKIPPED: its stub state is removed, but substrate never created a resource for it, so reporting DELETE_COMPLETE would claim a deletion that did not happen. A skip is always reported — a claim of cleanliness that is not true is worse than a stated gap.
A refused resource reports CREATE_FAILED
A plugin that refuses a resource — an invalid bucket name, a malformed trust policy, a security group that does not exist — makes that resource CREATE_FAILED in DescribeStackResources, with the plugin's own error code and message as the reason:
aws cloudformation describe-stack-resources --stack-name badname
# ResourceStatus: CREATE_FAILED
# ResourceStatusReason: InvalidBucketName: The specified bucket is not valid.The stack rolls back. By default the resources the create had already made are swept — in the same reverse order a DeleteStack uses — and the stack reaches ROLLBACK_COMPLETE, naming the resource that failed and the plugin's error code in its reason:
aws cloudformation create-stack --stack-name partial --template-body file:///tmp/partial.json
aws cloudformation describe-stacks --stack-name partial \
--query 'Stacks[0].StackStatus' # ROLLBACK_COMPLETE
aws sqs get-queue-url --queue-name still-here # absent — swept with the stackCreateStack's two failure options both work, and are mutually exclusive as the API makes them: specifying OnFailure and DisableRollback together is a ValidationError, and the test is presence rather than value, so the CLI's --no-disable-rollback counts as specifying it.
| Option | Outcome |
|---|---|
OnFailure=ROLLBACK (default), DisableRollback=false | resources swept, stack reports ROLLBACK_COMPLETE |
OnFailure=DO_NOTHING, DisableRollback=true | nothing swept, stack reports CREATE_FAILED |
OnFailure=DELETE | resources swept and the stack record removed |
Whichever was given is reported back: DescribeStacks emits DisableRollback as true for a DO_NOTHING stack rather than always false. A sweep that cannot delete a resource gives ROLLBACK_FAILED, and the stack keeps its record so the undeleted resource is discoverable. All of these answer 200 on the wire — real CreateStack has returned its StackId before the rollback happens, so a rolled-back stack is not a failed call; poll DescribeStacks for the outcome.
RetainExceptOnCreate interacts here: it retains a resource for a DeleteStack sweep but deletes it for the rollback of the create that made it.
A failed stack publishes no outputs, and therefore exports none — an import against a value whose resource never deployed would resolve against nothing. A duplicate export name is still refused as an error rather than as a rolled-back stack, so that refusal reads the same whether or not a resource beside it failed.
Two divergences are substrate's own, both deliberate:
- Substrate deploys the resources declared after the failure, where real CloudFormation stops at the first one, so a single deploy reports every refusal a template contains rather than only the first. The stack status is the same either way, and the status is what a caller keys off. Under
DO_NOTHINGthose later resources are therefore left in place too. - A failed
UpdateStackreportsUPDATE_ROLLBACK_COMPLETEby re-deploying the stored previous template, since that is the only description of the previous state substrate holds. It converges on the previous template's declared state rather than restoring properties field by field, so a resource the failed update replaced may keep a new physical ID. A previous record that cannot be read leaves the stack atUPDATE_FAILEDwith the reason logged rather than a rollback attempted against nothing.
Because an update is a re-deploy, an unchanged resource's create is re-issued and the plugin refuses it as already existing. That refusal is not a failure of the update: substrate clears it when the stack's previous deployment created that logical ID successfully and the template still declares it identically. A rename into a name another stack owns, a resource that failed the previous time, and a record belonging to another account or region are all left standing as real failures — without that guard every UpdateStack would roll back the resources it was asked to keep.
A refused resource's follow-up configuration requests are not sent either. A bucket whose name S3 rejected has no PUT ?versioning issued against it, so the event log holds only the request a real client would have made.
Templates
113 resource types are supported; each service section below lists the types it backs under CloudFormation resource types. A template body may be JSON or YAML.
Every intrinsic function resolves. Ref, Fn::GetAtt, Fn::Sub, Fn::Join, Fn::Select, Fn::Split, Fn::Base64, Fn::If, Fn::Equals, Fn::And, Fn::Or, Fn::Not, Fn::FindInMap, Fn::GetAZs, Fn::Cidr and Fn::ImportValue, as do every pseudo-parameter: AWS::Region, AWS::AccountId, AWS::StackName, AWS::StackId, AWS::Partition, AWS::URLSuffix, AWS::NotificationARNs and AWS::NoValue.
AWS::Partition and AWS::URLSuffix follow the region — aws-cn and amazonaws.com.cn for a cn- region, aws-us-gov for a us-gov- one, aws and amazonaws.com otherwise. AWS::StackId is the same ARN CreateStack returned and DescribeStacks reports for the stack, so a template that writes its own stack ID into a property and a caller that captured StackId agree. AWS::NotificationARNs is an empty list, which is the accurate answer for a stack created without any: substrate has no notification model, so there is never an ARN to report, and !Select ['0', !Ref 'AWS::NotificationARNs'] yields the empty string rather than the reference string.
What Ref returns
Ref is resolved per resource type. CloudFormation does not return one kind of value: each type's Template Reference "Return values" section documents its own, which is an ARN for some types, a name for others, a service-assigned ID for others and a URL for one. Ref used to answer the resource's physical ID for every type — correct for the majority, and the wrong kind of thing for the rest (#827). Nothing refuses a wrong-kind value, so a template writing LoadBalancerArn: !Ref lb passed a bare load balancer name to CreateListener and AWS's own listener shape did not deploy at all; where the receiving operation was more permissive the wrong value was simply stored.
For most types the documented value is the physical ID — a bucket or role name, a VPC or instance ID, an SNS topic ARN — and those are unchanged. These are the types whose Ref is something else:
| Resource type | Ref returns |
|---|---|
AWS::ElasticLoadBalancingV2::LoadBalancer | the load balancer ARN |
AWS::ElasticLoadBalancingV2::TargetGroup | the target group ARN |
AWS::ECS::Service | the service ARN |
AWS::StepFunctions::StateMachine | the state machine ARN |
AWS::StepFunctions::Activity | the activity ARN |
AWS::AppSync::GraphQLApi | the API ARN |
AWS::AppSync::DataSource | the data source ARN |
AWS::AppSync::Resolver | the resolver ARN |
AWS::AppSync::FunctionConfiguration | the function ARN |
AWS::Transfer::Server | the server ARN |
AWS::KMS::Key, AWS::KMS::ReplicaKey | the key ID, not the key ARN |
AWS::EC2::EIP | the Elastic IP address, not the allocation ID |
AWS::CloudTrail::Trail | the trail name, not the trail ARN |
AWS::SQS::Queue | the queue URL |
AWS::WAFv2::WebACL | name|id|scope, as the page's own example spells it |
AWS::ApiGateway::UsagePlanKey | keyId:usagePlanId |
A queue's Ref is the URL substrate's own SQS operations answer with and accept — http://sqs.{region}.localhost/{account}/{name} rather than AWS's https://sqs.{region}.amazonaws.com/... — because a URL the emulator would then reject is useless to the caller who resolved it. Both come from one function, so the value a template resolves and the value CreateQueue returns cannot drift.
Every arn:aws:wafv2 ARN — a web ACL's or an IP set's, from the WAFv2 API or from CloudFormation — comes from one builder, for the same reason. The CloudFormation path derived the ARN's scope segment from the Scope property while the plugin hardcoded regional, so one logical web ACL reported two different ARNs depending on which path created it, and a CLOUDFRONT resource created through the API named a scope it did not have. An IP-set ARN is what an IPSetReferenceStatement takes and a web-ACL ARN is what AssociateWebACL takes, so that is an identifier another operation is expected to accept. The segment is the lowercase of Scope, which is substrate's reading: AWS publishes exactly one substituted web-ACL ARN anywhere, on the AWS::WAFv2::WebACL Template Reference page, and it is a REGIONAL ACL rendering regional. What one builder guarantees is that every path agrees; which answer they agree on is not cited (#858).
CreateIPSet refuses an incomplete request rather than inventing one. All four of Addresses, IPAddressVersion, Name and Scope are documented Required: Yes, and substrate defaulted three of them — so a request AWS rejects created an IP set whose scope and address family the caller never chose, and then reported them back as though it had asked. An omitted required member answers ValidationError/400 (WAFv2's CommonErrors: "Check that all required parameters are included and that values are valid"); a present-but-invalid value answers WAFInvalidParameterException/400, which the operation's own Errors section lists. "Addresses": [] still succeeds, because AWS lists it among the operation's valid specifications while marking "Addresses": [""] INVALID — so an empty list and an absent member stay distinct, and the nil-to-[] normalisation had to go rather than sit beside the refusal (#755).
A listener's DefaultActions and a listener rule's Actions are now forwarded to CreateListener and CreateRule. They were dropped, so the resolved target group never reached the listener and DescribeListeners reported a listener with no default action; forwarding them is what makes a resolved !Ref on a target group observable through an API call rather than only through a stack Output.
An AWS::ApiGateway::Method's physical ID is a generated method ID, {stack}-{logical}-{suffix}, which is the shape AWS's own Template Reference example for the type has (mysta-metho-01234b567890example). It was the HTTP verb, so two methods with the same verb on different resources of one API were indistinguishable — and because a PhysicalResourceId lookup scans every stack in the account, so was every GET method everywhere. The type has no name property, so unlike every other generated name the bound comes from that published example rather than from a service limit; API Gateway's REST API publishes no method identifier at all, which is why the identifier exists only in the CloudFormation layer and the plugin is unchanged — a method is still addressed by its verb in API Gateway's own state, and the deleter resolves the verb from the template's properties, the same channel it already used for the method's two parents (#843).
An AWS::CloudFront::CloudFrontOriginAccessIdentity's physical ID is a derived OAI ID — E followed by 13 uppercase alphanumerics — computed by SHA-256 over the account, the Region, the stack name and the logical ID. It was the resource's own logical ID, so !Ref Oai handed an S3 bucket policy or an origin-access configuration a value AWS would never mint. That was not a stub left in place: the deploy asked for CloudFrontOriginAccessIdentityConfig.Comment, a dotted key read from a flat map, so the lookup matched nothing and the fallback — the logical ID — won unconditionally (#877). The shape is AWS's, published twice by example (E15MNIMTCFKK4C for Ref, E74FTE3AJFJ256A for Fn::GetAtt Id); the derivation is substrate's reading, and it is derived rather than random because UpdateStack re-deploys the whole template, so a crypto/rand ID would change on every update and every policy naming the old one would silently stop matching. The type still dispatches nothing and still sweeps to a no-op on delete: the identity is derived, not registered with CloudFront (#859).
Four other property lookups read a dotted key from a flat map, and all four took their fallback unconditionally (#877). A Glue job's Command.Name and Command.ScriptLocation are nested object members, so every job created through CloudFormation shipped an empty ScriptLocation — the property that says what the job runs — and a pythonshell job was created as a Spark ETL one. An ECS task definition's RequiresCompatibilities.0 is a list element, so every task definition registered as EC2 and a Fargate one was never Fargate. A distribution's DistributionConfig.Comment stored the logical ID; that one is cosmetic, because the physical ID is recovered from CreateDistribution's response, but a comment reporting a logical ID is still a value from somewhere other than the template. Each site reads its own nested member or list element rather than the shared helper learning to split a dotted key, because a nested read and a list-indexed read are different operations and a shared walk would have turned all five constants into template-controlled values at once, as a side effect of a refactor rather than as a decision per site. An AST tripwire refuses a sixth.
When a type's documented value cannot be built, Ref resolves to empty rather than falling back to the physical ID. An empty value means the deploy did not yield the source the value is derived from, which happens only for a resource that failed and is about to be reported CREATE_FAILED; a plausible-looking wrong answer is worse than an obviously missing one, since a stack Output carrying it would be asserted against happily.
These divergences are recorded rather than fixed, because the value AWS documents does not exist in substrate to return:
AWS::SNS::Subscription— AWS documents "Refreturns the subscription's logical name". Substrate returns the subscription ARN, deliberately: the logical name is a value the template already has, and the ARN is the one anUnsubscribetakes.AWS::ElasticLoadBalancing::LoadBalancer(classic) — documented as the DNS name. The classic load balancer has no deploy helper at all and falls through to the generic stub, so there is no DNS name to return. #844 routed the classicCreateLoadBalancer, which does mint a DNS name, but a deploy helper calling it is deliberately not part of that work — so this divergence stands until one exists.AWS::EC2::SecurityGroupIngressand::SecurityGroupEgress— no per-rule identity exists, andRefon the ingress type is not documented.AWS::EC2::SecurityGroup— AWS returns the group name for a group created without aVpcIdand the group ID otherwise. Substrate always returns the ID.AWS::SecretsManager::RotationScheduleand::SecretTargetAttachment, andAWS::Backup::BackupPlan— conditionally or self-consistently correct as they stand.
What Fn::GetAtt returns
Fn::GetAtt keys on the resource type and the attribute name. It used to key on the attribute name alone, which is the mirror image of the Ref defect above: an attribute name two services share resolved with the wrong service's rule, and everything unrecognised fell through to the physical ID (#827). !GetAtt bucket.Arn answered the bucket name, because a bucket had no recorded ARN and the Arn arm fell back; !GetAtt bucket.DomainName answered the name too, because DomainName was CloudFront's arm; !GetAtt param.Value answered an SSM parameter's name where AWS documents "returns the value of the parameter"; and !GetAtt lb.DNSName had no arm at all.
An attribute the resolver cannot answer resolves to empty, never to the physical ID. This generalises the rule the AWS::Config::ConfigRule arm already stated to the whole resolver, for its reason: a bare name where an ARN, a URL or a stored value belongs is a plausible-looking wrong answer, which a stack Output carries and a test asserts against happily, while an empty string is distinguishable from a real value. So an attribute AWS documents but substrate models nothing for — an ELBv2 load balancer's CanonicalHostedZoneID, a DynamoDB table's StreamArn when the table has no stream — reads as empty rather than as something plausible.
Resolution answers only from a fact, in three steps:
- A per-type rule, for the attributes whose value is specific to the type.
- The attribute's own name in the resource's recorded metadata, which is the channel every deploy helper already records a resolvable attribute through —
RepositoryUri,Endpoint.Address,InvokeURL,ProviderName,AllocationId,ConfigRuleId. - The resource's own ARN, when the attribute's name says it returns one — a check on the attribute's spelling rather than a table of 113 resource types, which is why
LoadBalancerArn,ListenerArn,RuleArn,TopicArn,TaskDefinitionArn,ServiceArnand FSx'sResourceARNall resolve without a rule each. A plural…Arnsis a list, not a string, and is not matched; neither is a dotted name, because a nested path names a member's ARN rather than the resource's — an RDS instance'sMasterUserSecret.SecretArnis the secret's.
The per-type rules, each carrying the value the type's own "Return values" section documents:
| Resource type | Attributes |
|---|---|
AWS::S3::Bucket | DomainName, RegionalDomainName, DualStackDomainName, WebsiteURL — built from the bucket name and the region, in AWS's own forms |
AWS::Logs::LogGroup | Arn — with the trailing :* AWS's example carries, built by the same function the Logs API's own arn member uses |
AWS::ElasticLoadBalancingV2::LoadBalancer | LoadBalancerName, LoadBalancerFullName (app/name/id) |
AWS::ElasticLoadBalancingV2::TargetGroup | TargetGroupName, TargetGroupFullName (targetgroup/name/id) |
AWS::SNS::Topic | TopicName — off the end of the ARN, since a topic's physical ID is its ARN |
AWS::SQS::Queue | QueueName, QueueUrl — the same URL Ref resolves to, from the same builder |
AWS::SecretsManager::Secret | Id — which for this type is the ARN, not an opaque identifier |
AWS::CloudFront::CloudFrontOriginAccessIdentity | Id — the derived OAI ID, which is also what Ref returns; S3CanonicalUserId is empty, since AWS publishes no format for it (#859) |
AWS::Glue::Database | CatalogId — the deploying account |
AWS::ECS::Service, AWS::ApiGateway::Stage | Name |
AWS::Cognito::UserPool | UserPoolId |
AWS::ApiGatewayV2::Api | ApiId, ApiEndpoint (https://{id}.execute-api.{region}.amazonaws.com) |
AWS::AppSync::GraphQLApi | ApiId; GraphQLEndpointArn is empty — it is the endpoint's ARN, not the API's |
AWS::ElastiCache::CacheCluster | RedisEndpoint.Address, RedisEndpoint.Port — AWS's documented spelling, translated to the RedisEndPoint the ElastiCache API uses |
AWS::DynamoDB::Table | StreamArn — empty unless a stream was recorded, so the table's own ARN is not returned in its place |
AWS::SSM::Parameter | Type and Value, recorded at deploy time |
An S3 bucket's four domain names and an HTTP API's endpoint name AWS's hostnames rather than substrate's, unlike a queue's Ref: they are values a template hands to another resource — a CloudFront origin, a redirect target — not endpoints the emulator serves, so there is no request for a substrate-local form to satisfy. Where a value is an endpoint the caller then uses, the emulator's own is returned instead.
An attribute of a resource the template does not declare still resolves to {LogicalID}.{Attribute}, which names the mistake rather than hiding it as an empty string.
Mappings and Fn::FindInMap
A template's Mappings section is read and Fn::FindInMap resolves all three levels — map name, top-level key, second-level key — including the nested form the reference leads with, where the second-level key is itself a Ref or a Fn::FindInMap. A mapping's leaf value may be a string or a list, so SecurityGroupIds: !FindInMap [SGs, Prod, Ids] contributes several IDs; in a scalar context the members are rejoined on commas, as Fn::Split's are.
A lookup that misses fails the resource: it reports CREATE_FAILED with a ResourceStatusReason naming the intrinsic and the key, and the resource is not created. This is deliberate and it is the point of the model — the JSON-encoding fallback would have turned a missing AMI into a nonsense ImageId that launched an instance, reporting success for a template real CloudFormation rejects. The rest of the stack still deploys; one unresolvable property does not abort it, matching the per-resource failure reporting above.
The optional fourth argument supplies a fallback: !FindInMap [M, K1, K2, {DefaultValue: x}] resolves to x when either key is missing, and the fallback is not consulted when the lookup succeeds. It must be spelled exactly DefaultValue — a map with any other key is not a default and the lookup fails as it would without one, rather than substrate guessing at the intent.
Fn::GetAZs and Fn::Cidr
Fn::GetAZs resolves to the same zone names EC2's DescribeAvailabilityZones reports for that region, from the same list, so a subnet placed with !Select [0, !GetAZs ''] names a zone the caller can afterwards query. An empty string means the caller's region, as Ref 'AWS::Region' does.
Fn::Cidr splits ipBlock into count blocks whose mask is the address width less cidrBits, for IPv4 and IPv6 alike — !Cidr ['192.168.0.0/24', 6, 5] gives six /27s, and !Cidr ['2001:db8::/56', 1, 64] gives a /64. A request the block cannot satisfy — a count larger than the number of blocks that fit, a cidrBits that would widen the block, a count outside 1–256, an ipBlock that is not a CIDR block — fails the resource rather than returning a short list, for the same reason: !Select [3, !Cidr [...]] over a short list would read an empty string out of it and deploy.
Fn::Split resolves to a list, and a list-valued property receives every element. A CommaDelimitedList (or List<…>) parameter is list-valued too: a Ref to one yields one member per comma, each space-trimmed, so !Select ['2', !Split [':', arn]] picks the third field and SecurityGroupIds: !Ref SubnetIds reaches the API as several IDs rather than one string. Whether a Ref is list-valued comes from the parameter's declared type, not from whether its value happens to contain a comma.
Ref 'AWS::NoValue' in a list position contributes no element, and as a property's whole value it removes the property, which is what makes the conventional !If [HasCommand, !Split [',', !Ref Command], !Ref 'AWS::NoValue'] idiom work: the property is absent rather than present-and-empty, and an API that rejects an empty value sees what it would see from real CloudFormation.
Intrinsics resolve at any depth inside a structured property, not only where the property's whole value is one. A Ref inside KeySchema, an Fn::Sub inside a container definition's Environment, an Fn::Split nested in a list — all resolve, and a nested list-valued intrinsic contributes its elements to the list holding it rather than one rejoined string. Two rules bound the walk:
- Only a single-key map is an intrinsic. A map with several keys is user data even when one of them is named
Ref, so a property whose interior is a caller-supplied map — a log driver'sOptions, an IAM policyConditionblock — keeps its own shape. - Keys are never rewritten by resolution. Where a property's member names differ between CloudFormation and the service's API — ECS spells a container's members in camelCase where CloudFormation spells them PascalCase — that mapping is per-service and applied separately, and it stops at any member whose keys are user-supplied.
A resolved intrinsic is a string, or a list of strings where the intrinsic is list-valued. So "Cpu": {"Ref": "Cpu"} reaches the API as "256" where a literal 256 would have stayed a number; a literal is never retyped.
Where the property is scalar and has nowhere to put a list — an Outputs value, say — Fn::Split's elements are rejoined on the delimiter, reproducing the source string, and Fn::GetAZs, Fn::Cidr and a list-valued Fn::FindInMap leaf are rejoined on commas. Real CloudFormation rejects the template instead; substrate resolves rather than rejects, and rejoining is the spelling that loses nothing.
A parameter declared Default: '' is a parameter whose default is the empty string, not a parameter without one — which is what makes the conventional optional-parameter idiom work:
Parameters:
Command: {Type: String, Default: ''}
Conditions:
HasCommand: !Not [!Equals [!Ref Command, '']]A condition that references another condition by name resolves regardless of the order the template declares them in; a reference cycle resolves to false. Each condition is evaluated once, before any resource deploys, and keeps that value for the whole deployment — as in real CloudFormation, where conditions are evaluated when the stack is created or updated and cannot reference a resource or its attributes.
Cross-stack exports and Fn::ImportValue
An output that declares Export: {Name: …} publishes its value for another stack to import; an output without one is readable through DescribeStacks and nowhere else. Fn::ImportValue resolves against those exports, so the two-stack idiom — a network stack exporting a subnet ID, an app stack importing it — deploys and reads back as it does in AWS. The export name may itself be an intrinsic, which is what makes the conventional Export: {Name: !Sub '${AWS::StackName}-SubnetID'} work; it resolves before the first resource deploys, which the API permits because an export name may not depend on a resource.
Exports are scoped per account and Region, matching the documented restriction that cross-stack references are limited to the same account and Region. A caller in another account or another Region does not see them in ListExports and cannot import them — a template that would fail in AWS fails here rather than resolving against an export it is not entitled to.
Four rules are enforced, and they are the reason exports are modelled rather than faked:
- An import of an unpublished name fails the resource, with the export name in the
ResourceStatusReason. It does not resolve to the empty string or to the intrinsic's JSON — either would launch a resource named with nonsense and report success. - Export names are unique per account and Region. A second stack claiming a name another stack already exports is a
ValidationErrorat 400 naming the holder. - A stack whose export is imported cannot be deleted.
DeleteStackis aValidationErrorat 400 naming the export and every importing stack; the stack is untouched. Delete the importers first, as AWS requires. - An imported export's value cannot be changed — nor dropped, which is the same thing from the importer's side. An
UpdateStackthat would change or remove it is refused on the same terms. Re-deploying the same value is not a change, so an idempotent redeploy is unaffected.
What counts as an import is decided when the resolver walks the template, not from the template's text. An Fn::ImportValue in the branch of an Fn::If that was not taken never happened, and neither does one that failed to resolve, so neither pins an exporting stack. DescribeStacks reports each output's ExportName beside it, and ListImports answers the "who is holding this" question a refused delete raises.
The two refusals above use ValidationError at 400. The DeleteStack reference documents only TokenAlreadyExists, so that code is substrate's own choice for this case — the same code the service uses for every other unsatisfiable request on it.
YAML short forms
The YAML tag shorthands are expanded to their long forms before the template is read, so a template written with !Ref / !Sub / !If resolves identically to the same template written with Ref / Fn::Sub / Fn::If. All of !Ref, !Condition, !GetAtt, !Sub, !Join, !Select, !Split, !Base64, !If, !Equals, !Not, !And, !Or, !FindInMap, !ImportValue, !Cidr, !GetAZs and !Transform are expanded, at any nesting depth — !Not [!Equals [!Ref VpcId, '']] is three levels and works.
!GetAtt is the one irregular form: it takes a dotted string where Fn::GetAtt takes a two-element list, and the split is on the first period only, so !GetAtt Res.Outputs.Nested is ["Res", "Outputs.Nested"] — an attribute name may itself contain periods.
Expansion is not resolution, but nothing is now expanded that does not also resolve: every tag in the list above reaches a resolver, !Transform excepted — it expands to Fn::Transform, which substrate carries but does not apply, since a macro is code substrate does not run.
A tag substrate does not recognize is not dropped: the node's value is kept and a WARN naming the tag is logged, since a macro or transform may introduce a tag substrate has never heard of, and refusing the template would reject one real CloudFormation accepts.
A short form's value is read as a string, as the long forms' unquoted values are, so !Sub 12345 and !Sub 2026-08-02 reach the resolver as written rather than as a number and a timestamp.
One tag per node is a YAML rule, not a substrate limitation: !Base64 !Ref P is a parse error, which is why AWS's own examples spell the outer function long-form — Fn::Base64: !Ref P. That nesting works.
Fn::Sub honours the documented ${!Literal} escape: ${!Count.Index} renders as the literal ${Count.Index} with no substitution, which is how a template passes a ${…} through to something that interpolates it later, such as Terraform or cloud-init.
Parameters use the Query protocol's list encoding, which is what every SDK and the CLI send:
Parameters.member.1.ParameterKey=Env
Parameters.member.1.ParameterValue=staging
Parameters.member.2.ParameterKey=Size
Parameters.member.2.UsePreviousValue=trueUsePreviousValue=true resolves against the stack's stored parameters, so an UpdateStack need not repeat every value.
Template transforms are not applied — GetTemplate reports StagesAvailable: [Original] only, and a SAM or macro template reaches the deployer unexpanded.
A stack's own tags
CreateStack's and UpdateStack's Tags.member.N is recorded on the stack and reported by DescribeStacks (#764). Before that it was decoded by nothing at all: a caller sending --tags Key=team,Value=platform got a 200 and a stack that reported no tags, so a cost-allocation or policy assertion keyed on a stack tag had nothing to read.
Tags are reported sorted by key, for the same reason parameters and outputs are: a response whose member order followed Go's map iteration would differ run to run, and an assertion on it would flake in an emulator whose whole claim is that it does not. A stack with no tags reports an empty <Tags></Tags> — Parameters and Outputs render the same way, and every SDK decodes an empty list and an absent one alike.
The update semantics are AWS's, and they are the reason a stack's tags are modelled as a map that can be absent rather than as a list: "If you don't specify this parameter, CloudFormation doesn't modify the stack's tags. If you specify an empty value, CloudFormation removes all associated tags."
UpdateStack sends | Result |
|---|---|
no Tags parameter | the stack keeps the tags it had |
Tags= (an empty list) | every tag is removed |
Tags.member.N | the tag set is replaced wholesale, not merged |
The empty list is not the same wire request as no parameter, and the distinction is observable: the query protocol serialises an empty list as a bare Tags=, so --tags '[]' arrives with the parameter present and no members, while a request that omits it entirely arrives without it. Substrate turns on exactly that.
A tag set is validated against the Tag data type's own constraints — at most 50 tags ("A maximum number of 50 tags can be specified"), a key of 1 to 128 characters, a value of 1 to 256 — counted in characters rather than bytes, so a tag in a non-Latin script is not refused for being multi-byte. Two points where a consumer will otherwise guess wrong:
- A tag value may not be empty. The model's minimum is 1 and the member is
Required: Yes, where IAM's and ELBv2's tag values document a minimum of 0 and accept an empty string.Key=teamwith no value is therefore refused by CloudFormation and accepted by those two. That is AWS's own inconsistency; substrate asserts it rather than smoothing it over. - The console page disagrees with the model by one character. It documents keys "up to 127 characters" and values "up to 255"; the API model and the Template Reference both say 128 and 256. The model wins, so a 128-character key the console would reject is accepted here.
No character set is enforced, because the Tag model publishes no Pattern for either member — unlike IAM's Tag and AWS Config's TagsList, whose patterns substrate compiles verbatim. The Template Reference does list a character set, but for a template's resource-level Tags property rather than for the stack-level list an API call carries.
A key carrying the reserved aws: prefix is refused, matched case-insensitively — so AWS:owner is refused too. That is CloudFormation's own rule, stated outright: "The aws: prefix is reserved for AWS use. This prefix is case-insensitive." EC2, ELBv2 and IAM all match the prefix case-sensitively, because their pages state no such thing. The prefix is refused in a key only; a value beginning aws: is stored, since the only thing AWS says about one is that "you can't update or delete the tag" — a consequence, not a refusal.
Provenance for the refusal code. ValidationError at 400 is substrate's choice, not AWS's: neither CreateStack's nor UpdateStack's Errors list names a code for a bad tag, and substrate's two existing precedents disagree — EC2 invents InvalidParameterValue and ELBv2 accepts silently. ValidationError is the code the plugin already answers every other parameter refusal with, and the refusal is of the whole request: a create with one bad tag leaves no stack behind, and a refused update changes nothing.
Two tags naming the same key collapse, last one winning. AWS publishes no error code for a duplicate key, and the tag set is a mapping from key to value, so there is nothing a second entry could mean other than an overwrite.
An in-process Client deploy is held to the same limits as an HTTP request, because both funnel through the deployer's options, where the validation lives.
A stack tag reaches the resources the stack creates
AWS says so on the Tags member itself — "CloudFormation also propagates these tags to the resources created in the stack" — and it is the half a policy or a cost report reads: a stack tag only DescribeStacks reports cannot be the subject of an aws:ResourceTag condition on anything the stack built. A stack tag therefore lands on the same resources the three aws:cloudformation:* keys do, through the same per-service tag stores, and is readable through each service's own tag call — see what the stamp reaches for the table of services and the named list of what is skipped. A resource whose service models no tags is skipped silently here too, with no log line.
Whose tag a key is is the question propagation actually has to answer, because two of AWS's rules pull against each other: a tag the caller set directly on a resource must survive propagation, and a stack tag whose value the caller changes on the stack must reach the resource. Neither is decidable from the new tag set alone. What decides it is the stack's previous tag set, which substrate already stores:
| The resource's stored value for the key | What propagation does |
|---|---|
| the key is absent | write it |
| equal to what the stack carried before | overwrite it — the stack's own copy |
| anything else | leave it alone — the caller's |
| the key left the stack, value still matches | remove it |
| the key left the stack, value differs | leave it alone |
That needs no per-resource bookkeeping and can never delete a key the stack did not propagate. The one case it cannot distinguish is a caller who sets a resource tag to the same value the stack propagates: removing the stack tag then removes theirs too. Substrate records that rather than papering over it — the alternative is tracking, per resource, which keys the deployer wrote, which is real state in the event stream for an ambiguity AWS does not resolve either, since a propagated tag on a real resource carries no provenance a caller can read.
Three further consequences worth knowing before writing an assertion:
- Propagation runs after the whole stack is deployed, not per resource like the
aws:cloudformation:*stamp. It has to: an update re-creates every resource in the template, and a resource that already exists is refused and then recognised as an unchanged redeploy — so at stamp time it still looks failed. A tag added to an existing stack must reach exactly those resources. - A resource whose redeploy was refused may report no physical ID, and the previous stack record supplies it. That is sound only where it is used: the refusal was dismissed because the record shows this stack deployed that logical ID from an identical declaration.
- An update re-creates an unnamed resource rather than retagging it. A VPC the template does not name is minted afresh on every update and carries the new tags; the old one keeps the old ones until it is swept. That is the deployer's redeploy model rather than anything about tags.
Propagation can leave a resource over its service's tag quota
Four of the services a stack tag reaches publish a per-resource tag quota and enforce it on their own tagging operations — EC2, ELBv2, IAM and Kinesis, all four at 50 (above). Propagation does not check any of them, so a stack carrying enough tags can leave a resource holding more tags than its own service would accept. That is substrate's recorded reading of a case AWS does not publish (#1077), not an oversight.
What AWS publishes, and what it does not:
CreateStackpublishes exactly four errors —AlreadyExists,InsufficientCapabilities,LimitExceededandTokenAlreadyExists, all 400 — and none is about tags.LimitExceeded's own description scopes it to "the quota for the resource … see CloudFormation quotas", and that quotas page has no row for tags at all.- The resource-tagging reference publishes only that "[t]he propagation of stack-level tags to resources, including tags with the
aws:prefix, varies by resource type". Nothing there, or on the console page, addresses a target whose service caps tags below the stack's count.
What settles it rather than leaving it open is that a refusal would have no vocabulary. CloudFormation publishes no tag error, and each service's own TagLimitExceeded / TooManyTags / LimitExceeded / LimitExceededException is published for that service's own tagging operation, not for a CloudFormation propagation. Borrowing one is the analogy #671 forbids, and it would make substrate's deployer fail a template real CloudFormation deploys — a false failure in a consumer's test, which is the worst answer this emulator can give.
The case is narrower than it sounds, for two published reasons. A caller cannot supply a reserved key — CloudFormation's Tag Key publishes "can't be prefixed with aws:" — and the three aws:cloudformation:* stamp keys do not count toward a per-resource limit, which EC2's tag restrictions, ELBv2's, Classic ELB's and the general Tag Editor rule ("a maximum of 50 user created tags") all state. Since a stack is capped at 50 tags and each quota is 50, a resource carrying no tags of its own can always take a full stack's worth. Reaching the overflow needs a resource tagged independently, through its own service or the tagging API.
Two things worth knowing if you assert on this:
- Enforcing would be four decisions, not one flag. Propagation dispatches to four arms and only the record-keyed one goes through the shared merge that takes a quota mode; the EC2, ELBv2 and AWS Config arms each write through their own service's writer and would need their own check.
- The quota is untouched everywhere AWS publishes it. A stream the deployer took to fifty-eight tags still refuses a fifty-ninth through Kinesis's own
AddTagsToStream, with its publishedLimitExceededExceptionat 400. The divergence is about one door, not about the limit.
Change sets describe, they do not stage
A change set records the template that would be applied and reports the resource-level changes it implies. ExecuteChangeSet applies it and deletes the set, so DescribeChangeSet afterwards reports ChangeSetNotFound (404 — unlike the stack family, which reports ValidationError at 400).
ChangeSetType=CREATE is refused: it would have to produce a stack in REVIEW_IN_PROGRESS, a state the stack model has no representation for. Create the stack, then change-set the update.
What a change set does with tags
CreateChangeSet records its Tags, DescribeChangeSet reports them, and ExecuteChangeSet applies them to the stack and propagates them to the stack's resources (#824). The parameter was decoded by nothing before, so a change set answered 200 and then executed as though the caller had asked for no tags — the one hole left when CreateStack and UpdateStack gained theirs.
The three-way meaning is the family's, and it is UpdateStack's wording because that is the operation an execution routes through: "If you don't specify this parameter, CloudFormation doesn't modify the stack's tags. If you specify an empty value, CloudFormation removes all associated tags." So a change set created without Tags leaves the stack's own tags exactly as they are — which is how every change set behaved before this — one created with an empty Tags clears them, and one created with tags replaces them wholesale.
The warrant for applying them on execution is DescribeChangeSet's, not ExecuteChangeSet's. API_ExecuteChangeSet mentions tags nowhere at all: no request parameter, no response element, an empty result body. The only statement AWS makes about what executing a change set does with tags is the description of the member DescribeChangeSet reports — "if you execute the change set, the tags that will be associated with the stack" — and that is the sentence substrate implements.
Tags are validated at creation, against the same limits CreateStack is held to (50 tags, a key of 1–128 characters, a value of 1–256, no case-insensitive aws: key prefix), because CreateChangeSet publishes the same constraints. A change set that could never execute is refused rather than recorded, and the refusal leaves no change set behind.
DescribeChangeSet's Tags member sits after Parameters, where DescribeStacks puts its own; AWS's page lists response elements alphabetically, so it does not settle wire order. A change set with no tags reports an empty <Tags></Tags>, matching the Parameters and Changes members beside it.
Stack events are derived from the stack record
DescribeStackEvents answers from the stack's recorded state rather than from a separately maintained event stream, which is what keeps every value in it a real observation. Four consequences are worth knowing before writing an assertion.
Resource events are terminal only. A stack's own events bracket the operation — an opening CREATE_IN_PROGRESS / UPDATE_IN_PROGRESS / DELETE_IN_PROGRESS and the terminal status — because the record says the deploy started and how it finished. Its resources get one event each, the terminal one. Substrate deploys synchronously and never observed a resource mid-create, so emitting a per-resource CREATE_IN_PROGRESS would be inventing an observation. A consumer counting two events per resource will find one.
A deploy's events share one timestamp. They come from the record's creation and update times, and a synchronous deploy is one instant. Manufacturing an interval is not available: simulated time advances with wall time, so any spacing substrate invented would make a test's assertions wall-clock dependent. Order is carried by position instead — newest first, as the API documents, with the last-deployed resource above the one before it.
EventId is deterministic. A resource event's is {LogicalId}-{ResourceStatus}-{Timestamp}, the form real CloudFormation is observed to return; a stack event's is a UUID derived from the stack ARN, status and timestamp. So a replayed stack reports byte-identical events.
Only the most recent operation is reported. An update overwrites the record's status and time, so the create that preceded it left nothing to report. And a stack deleted successfully is removed from the record entirely, so DescribeStackEvents on it reports ValidationError — the same answer DescribeStacks gives. A stack whose delete failed remains, and its events name the resource holding it.
Responses paginate at 100 events with a NextToken. DescribeStackEvents has no MaxResults parameter (the CLI's --max-items is applied client-side), so a NextToken loop is the only way to page.
Stack status is terminal on return
CreateStack deploys synchronously and returns with the stack already CREATE_COMPLETE; UpdateStack returns UPDATE_COMPLETE. There is no *_IN_PROGRESS window, so a wait stack-create-complete succeeds immediately rather than polling. That is the deterministic-clock trade: a stack's observable state does not depend on how long a test waited.
Stack and change-set ARNs are deterministic
The UUID in a stack or change-set ARN is derived from the account, region and name, not from a clock or a PRNG, so the same call produces the same ARN on a replay. StackId is stable across CreateStack, DescribeStacks and ListStacks.
Cost
CloudFormation operations are free. The resources a template deploys are costed by their own plugins, so a stack's cost shows up under S3, EC2 and so on.
IAM
Endpoint: iam.amazonaws.comProtocol: AWS Query (form-encoded, Action= parameter)
Supported operations
| Operation | Notes |
|---|---|
| CreateUser | Returns User object |
| GetUser | |
| DeleteUser | |
| ListUsers | |
| CreateRole | Supports trust policy document |
| GetRole | Reports AssumeRolePolicyDocument, re-marshalled from the stored document rather than returned verbatim, so an equivalent form can come back (a {"AWS":"1234"} principal reads back as "1234") |
| UpdateAssumeRolePolicy | Replaces the trust policy in place; takes effect on the next AssumeRole and does not revoke existing sessions. PolicyDocument is required |
| DeleteRole | Refuses with DeleteConflict/409 while a policy is attached or an instance profile holds the role; the message names the profiles. Refuses a service-linked role with UnmodifiableEntity/400 — note 400, not the 409 the conflicts use |
| ListRoles | |
| CreateServiceLinkedRole | Mints the role under /aws-service-role/<principal>/ with a trust policy naming the linked service. A duplicate name is InvalidInput/400, not EntityAlreadyExists (see below) |
| DeleteServiceLinkedRole | Returns a DeletionTaskId; an incomplete task for the same role is returned again rather than duplicated. A role that exists but is not service-linked is NoSuchEntity/404 |
| GetServiceLinkedRoleDeletionStatus | Reports SUCCEEDED unless seeded; a FAILED task also reports the Reason and RoleUsageList |
| CreateGroup | |
| GetGroup | Reports the group's actual members |
| DeleteGroup | Refuses with DeleteConflict/409 while the group has members or policies |
| ListGroups | |
| AddUserToGroup | Writes both sides of the membership index; idempotent |
| RemoveUserFromGroup | Idempotent — removing a non-member is not an error, per the model |
| ListGroupsForUser | |
| AttachUserPolicy | Refuses a malformed PolicyArn with InvalidInput; a well-formed ARN that resolves nowhere succeeds and logs at WARN (see below) |
| DetachUserPolicy | Refuses a malformed PolicyArn with InvalidInput, as the attach does; a well-formed ARN that is not attached answers NoSuchEntity/404 (see below) |
| ListAttachedUserPolicies | |
| AttachRolePolicy | Same PolicyArn check as AttachUserPolicy |
| DetachRolePolicy | Same PolicyArn check as DetachUserPolicy |
| ListAttachedRolePolicies | |
| AttachGroupPolicy | Same PolicyArn check as AttachUserPolicy |
| DetachGroupPolicy | Same PolicyArn check as DetachUserPolicy |
| ListAttachedGroupPolicies | |
| PutGroupPolicy | Inline policy |
| GetGroupPolicy | |
| DeleteGroupPolicy | |
| ListGroupPolicies | |
| CreatePolicy | |
| GetPolicy | Resolves a bundled AWS managed policy or a CreatePolicy one; metadata only, as on AWS |
| DeletePolicy | DeleteConflict/409 while attached, naming the users, groups and roles; InvalidInput for a malformed ARN or a bundled AWS managed one — see below |
| ListPolicies | Applies Scope, PathPrefix and OnlyAttached, and includes the bundled catalog. PolicyUsageFilter is validated and narrows nothing (see below) |
| GetPolicyVersion | Returns the document, URL-encoded per RFC 3986. A VersionId other than the policy's default is NoSuchEntity |
| ListPolicyVersions | Returns exactly one version — substrate stores one document per policy |
| SimulatePrincipalPolicy | Evaluates a user's, group's or role's identity policies plus its permissions boundary. See "What the policy simulator evaluates" |
| SimulateCustomPolicy | Evaluates PolicyInputList only; resolves no entity, so it declares no NoSuchEntity |
| CreateAccessKey | |
| DeleteAccessKey | |
| ListAccessKeys | |
| PutUserPolicy | Inline policy |
| GetUserPolicy | |
| DeleteUserPolicy | |
| ListUserPolicies | |
| PutRolePolicy | Inline policy |
| GetRolePolicy | |
| DeleteRolePolicy | |
| ListRolePolicies | |
| PutUserPermissionsBoundary | ARN shape checked, existence not — see below |
| DeleteUserPermissionsBoundary | |
| PutRolePermissionsBoundary | Refuses a service-linked role with UnmodifiableEntity |
| DeleteRolePermissionsBoundary | |
| TagUser | |
| UntagUser | |
| ListUserTags | |
| TagRole | |
| UntagRole | |
| ListRoleTags | |
| TagPolicy | Customer-managed policies only |
| UntagPolicy | Customer-managed policies only |
| ListPolicyTags | Customer-managed policies only |
| CreateInstanceProfile | |
| GetInstanceProfile | |
| DeleteInstanceProfile | |
| AddRoleToInstanceProfile | |
| RemoveRoleFromInstanceProfile | |
| ListInstanceProfiles | Lists the account's profiles, narrowed by PathPrefix and paged by MaxItems/Marker; PathPrefix is applied before the page is cut — see below |
| TagInstanceProfile | |
| UntagInstanceProfile | |
| ListInstanceProfileTags | |
| GetAccountAuthorizationDetails | Reports the account's users, groups, roles and managed policies with their inline and attached policies in one response. Filter selects populations, MaxItems counts across all four — see below |
What the policy simulator evaluates
SimulatePrincipalPolicy and SimulateCustomPolicy answer "may this principal do X to Y?" without doing X to Y. Both run the same evaluator the request gate enforces with, which is the property worth relying on: a simulated decision that could disagree with an enforced one would be worse than no simulator, because a consumer would trust the preflight and then be refused.
The three decisions are reported separately and mean different things:
EvalDecision | Meaning |
|---|---|
allowed | A statement allows the action and nothing denies it |
explicitDeny | A statement denies the action by name |
implicitDeny | Nothing allows it — or a permissions boundary does not permit it |
The distinction matters to an assertion. "The policy explicitly forbids this" and "no policy grants this" are different findings, and a test asserting the first must not pass on the second.
What is in the evaluated set. For SimulatePrincipalPolicy: the entity's attached managed policies, its inline policies, the managed and inline policies of every group a user belongs to, plus any PolicyInputList documents supplied with the call. The boundary is PermissionsBoundaryPolicyInputList when given, otherwise the entity's stored one. For SimulateCustomPolicy: PolicyInputList and PermissionsBoundaryPolicyInputList only — it resolves no entity.
MatchedStatements names which policy decided, as SourcePolicyId (the policy name, or PolicyInputList.N for a string input) and SourcePolicyType. MissingContextValues reports every condition key a statement tested that the request supplied no value for, so a conditional grant does not read as a clean refusal. A key present with an empty value is set, not missing — the Null operator exists precisely to test for absence.
CallerArn defaults to PolicySourceArn and populates aws:PrincipalArn; ResourceOwner populates aws:ResourceAccount; an explicit ContextEntry wins over both. ResourceArns defaults to ["*"], and every action is evaluated against every resource. MaxItems defaults to 100, valid 1–1000 — there is no cap on the number of actions per call.
What the simulator does not evaluate
Each of these is absent rather than faked, because a fabricated field in a preflight answer is worse than a missing one:
| Not evaluated | Why |
|---|---|
| Service control policies | Organizations stores SCPs and CheckAccess never consults them either, so simulating them would report a bound substrate does not enforce. OrganizationsDecisionDetail is therefore absent from a result rather than present and false |
StartPosition / EndPosition | They are byte offsets into the policy document as submitted. Substrate stores a parsed document, so the original text is gone and any offset would be invented. The AWS sample response shows an empty <MatchedStatements/>, so an absent member is a shape callers already handle |
EvalDecisionDetails, ResourceSpecificResults | Cross-account constructs. The reference states EvalDecisionDetails "is returned, but the response is empty" for a same-account simulation with a resource ARN, which is what substrate models |
ResourceHandlingOption, PolicyExclusionList | Accepted and ignored; both select alternate evaluation modes substrate does not model |
A consumer whose preflight depends on an SCP boundary cannot get that answer here, and should not read an allowed as covering it.
Condition key names are matched case-insensitively
AWS: "Context key names are not case-sensitive. For example, including the aws:SourceIP context key is equivalent to testing for AWS:SourceIp. Case-sensitivity of context key values depends on the condition operator that you use."
Substrate follows both halves. A policy naming ec2:createaction, AWS:RequestTag/Env or aws:tagkeys is evaluated identically to its canonical spelling, in Allow and Deny position alike — and every operator still compares its value exactly as its own definition says, which is why StringEquals and StringEqualsIgnoreCase remain different operators.
The name was previously matched byte-for-byte, which failed in both directions: an Allow written with a differently-cased name was an implicit deny — a false refusal — and a Deny written that way was inert, allowing what AWS refuses.
| Folded | Not folded |
|---|---|
The whole key name, including the tag suffix of aws:RequestTag/<key> and aws:ResourceTag/<key> | The condition value, per the operator's own definition |
The aws: / ec2: / sts: service prefix | A tag key carried as a value, in aws:TagKeys — and substrate's own tag rules, which AWS documents as case-sensitive |
Names supplied through a simulation's ContextEntries | The operator and its set qualifier: stringequals and forallvalues:StringEquals are not operators substrate evaluates, and deny rather than being read as their canonical spellings |
The tag suffix folds too, which is AWS's rule stated in the same breath as the key–value form: "Key names are not case-sensitive. This means that if you specify "aws:ResourceTag/TagKey1": "Value1" in the condition element of your policy, then the condition matches a resource tag key named either TagKey1 or tagkey1, but not both." A tag key compared as a value is a different thing and stays case-sensitive.
When both spellings exist, substrate answers with the first in sorted order. AWS names this hazard without resolving it — "you might tag an Amazon EC2 instance with ec2=test1 and EC2=test2 … the key name matches both tags, but only one value matches. This can result in unexpected condition failures" — so substrate makes the choice explicit rather than leaving it to Go's randomized map iteration, which a decision that must replay identically from the event log cannot depend on. An exact hit always wins over a folded one, whatever the folded one sorts as.
Two notes for a caller reading a simulation:
- A key the evaluator resolved by folding case is not reported in
MissingContextValues. Reporting it would make the simulation contradict the enforcement it exists to predict. - A key that genuinely is absent is reported as the policy spelled it, not canonicalized — that string is compared against the document the caller submitted.
Only ContextEntries can introduce two spellings of one key on the way in; every producer inside substrate writes a canonical literal. Two entries whose names differ only by case are one key, and the later one wins, under the earlier one's spelling. A ContextEntry also overrides a derived aws:PrincipalArn or aws:ResourceAccount however it is cased.
Which condition operators substrate evaluates
Every operator AWS documents, in all five families, plus the ...IfExists suffix on each of them.
| Family | Operators | Comparison |
|---|---|---|
| String | StringEquals, StringNotEquals, StringEqualsIgnoreCase, StringNotEqualsIgnoreCase, StringLike, StringNotLike | Exact, case-folded, or globbed with * and ? |
| Numeric | NumericEquals, NumericNotEquals, NumericLessThan, NumericLessThanEquals, NumericGreaterThan, NumericGreaterThanEquals | Both sides read as decimals |
| Date | DateEquals, DateNotEquals, DateLessThan, DateLessThanEquals, DateGreaterThan, DateGreaterThanEquals | W3C ISO 8601 (2020-01-01T00:00:00Z down to 2020) or epoch seconds, on either side |
| Boolean | Bool | true / false |
| Binary | BinaryEquals | Base64 text, compared as AWS's own example table does |
| IP | IpAddress, NotIpAddress | CIDR containment, IPv4 and IPv6 |
| ARN | ArnEquals, ArnLike, ArnNotEquals, ArnNotLike | Six components, each globbed separately |
| Existence | Null | true matches an absent key, false a present one |
Three details of the comparisons are worth stating, because they are the ones a policy written against real IAM depends on:
- An ARN's six components are compared one at a time, per AWS: "Each of the six colon-delimited components of the ARN is checked separately and each can include multi-character match wildcards (
*) or single-character match wildcards (?)." Soarn:aws:sns:*:TOPIC-IDdoes not matcharn:aws:sns:us-east-1:123456789012:TOPIC-ID— a*cannot cross a colon. A pattern naming fewer than six components has the missing trailing ones filled with*.ArnEquals/ArnLikeare one operator andArnNotEquals/ArnNotLikeare its negation, which is AWS's rule, not an approximation: "TheArnEqualsandArnLikecondition operators behave identically." - A bare IP address is a host route —
/32for IPv4, as AWS says, and/128for IPv6, which AWS's IPv4-written sentence does not cover. - Unquoted numbers and Booleans are accepted, per the grammar: "Values are enclosed in quotation marks. Quotation marks are optional for numeric and Boolean values." So
{"Bool": {"aws:SecureTransport": false}}and{"NumericLessThanEquals": {"s3:max-keys": 10}}are read as written, and a number keeps the spelling the document used.
A key the request context does not carry follows the operator's polarity, which is AWS's rule: "If the key that you specify in a policy condition is not present in the request context, the values do not match and the condition is false. If the policy condition requires that the key is not matched, such as StringNotLike or ArnNotLike, and the right key is not present, the condition is true." A key present with an empty value counts as absent — AWS's own phrase is a value that "resolves to a null dataset, such as an empty string" — except to Null, which is the operator whose whole job is to see it.
...IfExists on any operator but Null. AWS excludes Null explicitly ("any condition operator name except the Null condition"), so NullIfExists does not match; Null already answers the question the suffix asks. On a Deny, a negated IfExists still fires on an absent key, which is AWS's documented behaviour and the reason the suffix is safe to write in a guardrail.
A value substrate cannot parse as the operator's type is substrate's own choice, since AWS documents neither side. In the policy, such a value is skipped, so one unparseable element of a list does not decide the answer. In the request context, it fails a positive operator and satisfies a negated one, on the reasoning that a comparison that cannot be made has not been satisfied. Two consequences: "2020" is read as a year rather than as epoch second 2020, the one place the two date notations collide; and Go's spellings of infinity, NaN and hexadecimal floats are not numbers, so "Inf" cannot satisfy NumericGreaterThan against every number in existence.
An operator substrate does not recognize is refused when the document is submitted, with MalformedPolicyDocument and HTTP 400 — AWS's published pair for every one of the four submitting operations, whose Errors sections read "The request was rejected because the policy document was malformed. The error message describes the specific error." That last sentence is what licenses substrate naming the offending operator in the message, which is the only part of the refusal a caller can act on. The four doors are CreateRole, UpdateAssumeRolePolicy, CreatePolicy and the three inline-policy operations (PutUserPolicy, PutRolePolicy, PutGroupPolicy), which share one handler.
Three names are refused for a documented reason rather than for being unknown: NullIfExists, because AWS allows the suffix on "any condition operator name except the Null condition"; ForAllValues:Null and ForAnyValue:Null, because AWS defines neither. A set qualifier over any other recognized operator is accepted, including the combinations AWS's condition-operator page does not tabulate — it lists the String, Bool and ARN forms, while its set-operator reference describes the qualifiers generally, so refusing ForAllValues:NumericLessThan would be substrate inventing a restriction. Operator names are matched case-sensitively: stringequals is refused, because AWS's case-insensitivity covers condition key names, not operator names.
Validation runs on the submission paths only, never when a stored document is read back. PolicyDocument is unmarshalled on every read from state, so validating there would make an already-stored document — one written by an older substrate, or by a seed — permanently unloadable. An operator that is already in state still denies, which is the evaluator's own reading and unchanged: it is the only answer that cannot turn a typo into a grant, including under a set qualifier, where an unrecognized operator over an absent key used to be vacuously true and granted the statement.
Out of scope, and named because the asymmetry is visible: S3's PutBucketPolicy performs a shallower check and answers MalformedPolicy, and the SNS and SQS Policy attribute is stored opaquely and parsed by neither.
Which keys have a producer
An operator is only as useful as the keys it can compare. Substrate populates these, and nothing else:
| Key | Where it comes from |
|---|---|
aws:RequestTag/<key>, aws:TagKeys | The tags the request asks to apply |
aws:ResourceTag/<key> | The tags on each resource the request names |
ec2:ResourceTag/<key> | The same tags, under EC2's service-specific duplicate of the global key — see both prefixes |
aws:CurrentTime, aws:EpochTime | The emulator's simulated clock |
ec2:CreateAction | A tagged create's second authorization pass |
ec2:Subnet, ec2:Vpc | A launch's networking resources |
sts:ExternalId | An AssumeRole that supplied one |
aws:PrincipalArn | The caller, at every gate — identity policy, permission boundary, tag-on-create, the IAM control plane and a role's trust policy |
aws:username | The caller's IAM user name, at the same gates — absent for every principal that has none; see policy variables |
aws:userid | The caller's unique ID, in AWS's per-kind form — an IAM user's AIDA…, and <role-id>:<session-name> for an assumed role; see the caller's unique ID and tags |
aws:PrincipalTag/<key> | One key per tag on the IAM user or role behind the request, read from its record at the same gates |
aws:ResourceAccount | A simulation only, from ResourceOwner |
| Anything at all | A simulation's ContextEntries |
The time pair comes from the emulator's TimeController, not the wall clock, so a recorded request replays to the same decision. An AuthController constructed without one leaves both keys absent rather than falling back to time.Now(): a date condition then reports a missing key and denies, which is a false refusal, but a replayable one.
aws:PrincipalArn is written at every gate rather than only where a condition on it is expected, because a request that passes two gates must get one answer from both. It is also why the key could not stay simulator-only once the negated operators arrived: AWS answers true for a negated operator over an absent key, so "ArnNotEquals": {"aws:PrincipalArn": "arn:aws:iam::*:user/carol"} — an exemption — was satisfied by every caller. As a Deny it fired on carol herself; as an Allow it granted everyone.
What has no producer, and therefore never matches on the enforcement path:
aws:SecureTransport,aws:MultiFactorAuthPresent,aws:MultiFactorAuthAge,aws:SourceIp,aws:SourceVpc,aws:PrincipalOrgIDand every other key not in the table above. The IP family is therefore correct but satisfiable only through a simulation'sContextEntries. (S3's public-access analysis reads anaws:SourceIpcondition out of a bucket policy to judge how broad it is, which is a different question from evaluating one against a request.)aws:PrincipalAccount, even thoughaws:PrincipalArn,aws:username,aws:useridandaws:PrincipalTag/are all populated beside it. It is the last of the four caller keys that needs a derivation substrate cannot make honestly, because a cross-account credential makes "the principal's account" ambiguous between the one in the ARN and the one the request resolved to — and a key guessed wrong is worse than one a policy can test for withNull.aws:usernameleft this list in #745 and the other two in #771, all three by the same route, which is the reason worth keeping: what made each honest was recording the value where the credential is minted or reading it where the credential is resolved, never parsing it back out of an ARN. See the caller's unique ID and tags.Four of the seven keys the bundled AWS managed policies condition on, each for its own reason rather than as one omission:
Key Blocks Why it has no producer iam:PassedToService8 No PassRoleoperation exists anywhere in substrate, and the value comes from the calling API's service principal, which substrate has no analogue forcodestar-notifications:NotificationsForResource6 No CodeStar Notifications plugin aws:CalledViaLast2 RequestContexthas no call-chain notion, and there is no service-to-service internal path to record one fromdevops-guru:ServiceNames2 No DevOps Guru plugin Three now have one:
ec2:ResourceTag/<key>(see both prefixes),iam:AWSServiceName(see service-linked roles) andelasticloadbalancing:CreateAction(see ELB v2). Between them they were the largest group in the table — 11 of the 32 condition blocks turned oniam:AWSServiceNamealone — and each needed the same two things rather than one: a producer for the key, and a request resource specific enough for the statement'sResourceto match. The ELB key needed a third: the tagging operations it conditions did not exist, so the statement inAmazonECS_FullAccess'sELBTaggingPolicyconditioned an action no request could carry.elasticloadbalancing:CreateActionwas the one whose provenance looked thinnest and turned out not to be. It is absent from the ELB user guide's own list of ELB-specific condition keys, and both Service Authorization Reference pages for ELB render their key tables in JavaScript and were unreachable — so the bundled managed policy read like the only citable source. It is not: the guide's "Tag your Elastic Load Balancing resources during creation" page documents the key, theAddTagssecond authorization it belongs to, and the bare operation name as its value.Each remaining absence is a false deny in the safe direction: all 32 condition blocks in the bundled catalog sit on an
Allow, so such a statement grants nothing rather than aDenygoing inert.
The caller's unique ID and tags
aws:userid and aws:PrincipalTag/<key> are populated (#771). Both are caller keys, and both arrive by the route #745 established for aws:username: substrate records or reads the value where it is known, and never parses it back out of the principal ARN.
aws:userid is recorded when the credential is minted, in AWS's own per-kind form:
| Caller | aws:userid |
|---|---|
| IAM user | the user's AIDA…, copied onto the access key by CreateAccessKey |
| Assumed role | <role-id>:<session-name>, the string AssumeRole already returns as AssumedRoleId |
GetSessionToken session | the calling user's AIDA…, unchanged — the principal is the same user |
| Anything else | absent |
Recording rather than deriving is what makes the assumed-role form possible at all: a session's ARN carries the role's name, so <role-id> cannot be recovered from the credential afterwards — and the role it named may since have been deleted and recreated with a new ID. The account root is the one kind AWS's table documents that substrate has no value for, because it models no root principal: an unauthenticated caller resolves to a nil principal, which the gate leaves unenforced and GetCallerIdentity reports as …:root.
Absent is not empty. A credential that resolves to no IAM entity — the documented AKIAIOSFODNN7EXAMPLE, or a record written before this release — publishes no aws:userid key at all, so a policy testing it with Null still answers, and nothing matches a guess.
aws:PrincipalTag/<key> is read from the entity's record when the credential is resolved, one key per tag on the IAM user or role behind the request. Reading it per request is the point: TagUser and UntagUser change tags after an access key exists, and a snapshot taken at CreateAccessKey time would authorize a long-lived key against tags its principal no longer has — so an UntagUser meant to revoke an exemption would have no effect until the key was rotated. The cost is one extra state read per signed request that resolves to an IAM entity.
Session tags are not modelled. Substrate's AssumeRole reads no Tags parameter, so an assumed role's aws:PrincipalTag/ is the role's own tags, where AWS would also publish whatever the session passed. A deliberate narrowing: what is published is a subset of AWS's, never a superset, so a statement that matches here matches there.
Both keys reach both authorization doors, because both call the same publisher — the one-answer-per-request requirement of #411. And ${aws:PrincipalTag/team} resolves as a policy variable with no further work, since substitution reads any single-valued key from the same context.
Upgrading inverts one thing, and it inverts the same way on AWS: a policy asserting the absence of either key with Null stops matching for a caller that now has one.
What an entity read reports about its tags
An entity's tags were stored and never reported (#796): TagRole wrote them, ListRoleTags read them back, and GetRole reported none — so a consumer comparing desired state against the entity read saw the same tag change on every plan, for ever.
AWS draws the line by shape, not by entity, and substrate now draws it the same way. Nine responses carry a Tags member when the entity has tags:
| Shape | Operations |
|---|---|
User | CreateUser, GetUser |
Role | CreateRole, GetRole, CreateServiceLinkedRole |
Policy | CreatePolicy, GetPolicy |
InstanceProfile | CreateInstanceProfile, GetInstanceProfile |
The list shapes deliberately report no tags. ListUsers, ListRoles, ListPolicies and ListInstanceProfiles each carry the same note in the API reference, verbatim: "IAM resource-listing operations return a subset of the available attributes for the resource. This operation does not return the following attributes, even though they are an attribute of the returned object: PermissionsBoundary, RoleLastUsed, Tags. To view all of the information for a role, see GetRole." The roles nested inside an instance-profile shape are a list too, and carry none for the same reason — a tagged role reports its tags through GetRole and not through the profile that holds it.
An untagged entity omits the member rather than reporting an empty list. Tags is Required: No on all four data types, and every untagged sample response leaves the element out; CreateInstanceProfile's sample settles the contrast by rendering its required empty list as <Roles/> while carrying no <Tags> at all. ListUserTags and ListRoleTags are the other case — there Tags is required, so it is always rendered, empty or not.
Groups cannot be tagged. There is no Tags member on the Group data type, no TagGroup/UntagGroup/ListGroupTags in the Actions index, no iam:*Group tagging action in the vendored service-authorization snapshot, and the User Guide says it directly: "You can tag most IAM resources, but not groups, assumed roles, access reports, or hardware-based MFA devices." A Tags.member.N sent to CreateGroup anyway is ignored rather than stored.
Tags.member.N at create time is accepted by all four — CreatePolicy and CreateInstanceProfile had nowhere to put one before this release, so a --tags on either was dropped silently. A record written by an earlier version reads back with no tags, which is the same thing an untagged entity is.
Which members each entity shape reports
Tags are one member of several that AWS reports per shape rather than per entity (#807). The full picture, with what substrate renders:
| Member | Single-entity shape | List shape | Notes |
|---|---|---|---|
UserId, UserName, Arn, Path, CreateDate | yes | yes | required on User |
RoleId, RoleName, Arn, Path, CreateDate | yes | yes | required on Role |
Description (role) | yes | yes | Required: No, omitted when unset |
MaxSessionDuration | yes | yes | omitted when unset |
AssumeRolePolicyDocument | yes | yes | omitted when the role has no trust policy |
PasswordLastUsed | yes | yes | omitted when unset, which is always — see below |
PermissionsBoundary | yes | no | excluded from the list shapes by AWS's own note |
Tags | yes | no | same note; omitted when the entity has none |
PolicyId, PolicyName, Arn, Path, AttachmentCount, CreateDate | yes | yes | |
DefaultVersionId, UpdateDate | yes | yes | omitted when unset |
IsAttachable | yes | yes | always rendered, false included |
Description (policy) | yes | no | Required: No, omitted when unset |
PermissionsBoundaryUsageCount | yes | yes | computed per read, never stored — see below |
RoleLastUsed | yes | no | same note; omitted until the role is assumed — see below |
PermissionsBoundary left the list shapes, which is a behaviour change: ListUsers and ListRoles reported one until this release. AWS's note on both operations excludes it by name, in the same sentence that excludes Tags — so substrate was more generous than the service, and a consumer could write an assertion against a list response that AWS never satisfies. Read the entity to see its boundary, which is what the note instructs. The users nested in a GetGroup response and the roles nested in an instance-profile shape are list shapes too, and carry no boundary for the same reason.
Description on a policy is single-entity-only, and here AWS says so about the member directly rather than through the listing note: "This element is included in the response to the GetPolicy operation. It is not included in the response to the ListPolicies operation." Both it and IsAttachable were stored from CreatePolicy and never rendered before this release, so a consumer setting a description could not read it back at all.
PasswordLastUsed is rendered but never populated. AWS documents it as "returned only in the GetUser and ListUsers operations", and a null value means the user never signed in with a password. Substrate models no password operation at all — ChangePassword, CreateLoginProfile and UpdateLoginProfile answer InvalidAction — so nothing assigns the field and the member is always omitted in an ordinary run. It is rendered from the record so a consumer that seeds one directly observes it.
PermissionsBoundaryUsageCount is computed on every policy read, not stored. A boundary lives as an ARN on the entity — IAMUser/IAMRole — and not as a back-reference on the policy, so CreatePolicy, GetPolicy and ListPolicies each count it by scanning the account's users and roles once and reading the resulting map per member. That makes a policy read O(policies + entities) rather than the O(policies × entities) a per-policy scan would cost, and it is why three things need no code of their own: the count reaches zero when the last boundary is removed, DeleteUser/DeleteRole decrement it (the boundary goes with the deleted record), and a replayed run reports the live run's count, because the count is a function of the state replay rebuilds rather than a counter accumulated alongside it. A bundled AWS managed policy is counted the same way, which matters because a fresh emulator's only available boundary ARNs are bundled ones (#815).
No member of these shapes is left unmodelled now. PermissionsBoundaryUsageCount and RoleLastUsed were the last two, each recorded as a design decision rather than a field to render, and both are answered as of #815 and #816.
A boundary is reported under AWS's own member names, which two of them were not until #852. The PermissionsBoundary member of User, Role, UserDetail and RoleDetail is an AttachedPermissionsBoundary, and its Contents section lists exactly PermissionsBoundaryArn and PermissionsBoundaryType. Substrate rendered PolicyArn and PolicyName instead — names that appear on no AWS shape — so an SDK decoded the element into an empty struct and a consumer reading role.PermissionsBoundary.PermissionsBoundaryArn got "" for a boundary substrate had stored and was reporting. PolicyName is still kept on the stored record, because ListAttachedRolePolicies renders one and AWS's AttachedPolicy shape has that member, but a boundary no longer carries it to the wire: there is no member on the shape to carry it.
PermissionsBoundaryType renders PermissionsBoundaryPolicy. AWS's page contradicts itself here — the prose says the type "can only have a value of Policy" while the same page's enumeration says "Valid Values: PermissionsBoundaryPolicy", and the CLI v2 reference, generated from the service model, lists only the latter. Substrate follows the model rather than the prose, and the contradiction is recorded rather than silently resolved so a consumer who read the prose knows which of the two substrate chose.
AttachmentCount is derived per read, exactly as PermissionsBoundaryUsageCount is. Nothing writes the field: CreatePolicy never set it, the bundled catalog carries no value for it, and an attach records only the ARN on the entity's own list. ListPolicies derived the count and GetPolicy did not, so a policy attached to three entities reported 3 in a listing and 0 when read on its own (#847). Both now count the same way, from the three <kind>_policies: prefixes, which is also why an attach and a detach are immediately visible in both. The count is written onto a copy of a bundled policy's record rather than onto the catalog entry, because the catalog hands back shared pointers: writing through one would leak a count into every later read of that policy, including reads from a different emulator in the same process.
What a role read reports about its last use
GetRole reports RoleLastUsed — AWS's structure carrying LastUsedDate and Region — once the role has been assumed (#816). It was neither stored nor rendered before, so a consumer could not tell an assumed role from an untouched one.
AssumeRole is what writes it, which makes that operation a writer of IAM state. AWS's RoleLastUsed advances when the role is used, and an assume is the only use an emulator that models no workload can observe, so the stamp is written by the STS operation onto the IAM record. AssumeRoleWithWebIdentity and AssumeRoleWithSAML are not implemented, so there is exactly one write site. The role record was already read to evaluate its trust policy, so the stamp costs one Put and no extra read, and it is written after the session credentials are stored — a caller the trust policy refuses records no use.
A projection over the recorded AssumeRole events was considered and rejected. The direct write replays identically for the reason a projection would: the replay engine re-executes each recorded request with the simulated clock frozen at that event's timestamp and the request's region taken from the event, so the re-executed AssumeRole derives the same date and the same region and writes the same value. ("Frozen at" rather than "set to" since #1217: setting it left the clock advancing with wall time, so a rendered date was the recorded one only to within the replay's own latency.) What a projection adds is a dependency no plugin has — the IAM plugin would have to hold the event store and scan it on every GetRole, making the answer to a read depend on the event log rather than on state.
The date comes from the simulated clock and the region from the request. AWS documents Region as "the name of the AWS Region in which the role was last used", so it is the assuming request's region and not the emulator's configured one: assuming the same role from eu-west-2 and then ap-southeast-1 reports each in turn.
Only GetRole and GetAccountAuthorizationDetails report it. The RoleLastUsed type says so directly: "This data type is returned as a response element in the GetRole and GetAccountAuthorizationDetails operations." Substrate answers both since #848, and the second renders the member in two places — on a RoleDetail, and on each role nested inside that detail's InstanceProfileList, which is where AWS's own sample response puts it. ListRoles excludes it by name, in the same sentence that excludes PermissionsBoundary and Tags, and the roles nested inside an instance-profile shape are a list too. CreateRole and CreateServiceLinkedRole share the single-role wrapper and report none, because a role created a moment ago has not been assumed.
Omitting the member for a never-assumed role is substrate's choice, not AWS's. AWS's page settles neither half of the question. LastUsedDate is documented only as "This field is null if the role has not been used within the IAM tracking period" — a statement about a role falling out of the trailing 400 days of tracked activity, not about a role never assumed at all — and it says nothing about Region in that case, nor about whether the structure itself is present or absent. Both members are Required: No, so omitting the wrapper is admissible; substrate omits it because that is the only way a consumer can distinguish "not yet assumed" from "assumed", where a rendered wrapper holding a zero date and an empty region would report values AWS never publishes. So do not assert on RoleLastUsed being present-but-null: assert on its absence.
A role record written by an earlier version reads back with no last use, which is the same thing a never-assumed role is.
The account-wide authorization snapshot
GetAccountAuthorizationDetails reports the account's users, groups, roles and managed policies in one response, each with its inline policies, its attached managed policies and — for a user — its group memberships (#848). It answered InvalidAction/400 before, while the action was already authorizable, so a consumer's permission to call it did not mean the call worked.
It is the only operation that reports all four entity shapes together, which is what makes it worth having beyond the convenience: AttachmentCount, PermissionsBoundaryUsageCount and RoleLastUsed are each derived per read, and nothing else puts a derivation side by side with the operation that reports the same value singly. All three were defects — #847, #815 and #816 — precisely because no response contained two of them at once.
The four detail shapes are not the shapes the single-entity reads use. UserDetail has no PasswordLastUsed and RoleDetail has neither Description nor MaxSessionDuration, though GetUser and GetRole report all three; GroupDetail's scalars match Group's exactly, and ManagedPolicyDetail carries no Tags. The roles nested inside a RoleDetail's InstanceProfileList are Role, not RoleDetail, so Description and MaxSessionDurationare admissible there. The wrapper names differ from the sibling listings too: AttachedManagedPolicies, not the AttachedPolicies that ListAttachedRolePolicies sends.
An embedded role is re-read from state, never taken from the instance-profile record.AddRoleToInstanceProfile stores a copy of the role inside the profile, and every later write to that role — STS's RoleLastUsed, UpdateRole's description and session duration, UpdateAssumeRolePolicy's trust policy — lands on the role's own record and never reaches the copy. AWS's sample response renders <RoleLastUsed> inside InstanceProfileList → Roles → member, so rendering the stored copy would report nothing there for essentially every role that has been assumed. A role state no longer holds keeps the embedded copy, since that is all there is to report.
MaxItems counts across the four lists combined, which AWS does not document. The page says only "the maximum number of items", and the response carries four lists plus one Marker. Substrate pages one combined, ordered key space — keys composed as user/<name>, group/<name>, role/<name> and policy/<arn> — so a Marker is unambiguous across the populations: a per-list cursor would skip or repeat an entity at every page boundary. Each of the four list elements is emitted whether or not the page holds a member of it, so a consumer decodes one shape rather than branching. With 52 bundled managed policies against a default MaxItems of 100, truncation is on the default path for any account of moderate size — a caller that ignores IsTruncated here sees a partial account, not an edge case.
Policy documents follow the per-shape pages, not the operation's blanket note, and AWS's own documentation contradicts itself about this. The operation page states that every policy document in the response is URL-encoded per RFC 3986, while its own sample response renders all of them as plain JSON; of the members, only PolicyVersion.Document's type page repeats the mandate, and PolicyDetail.PolicyDocument and RoleDetail.AssumeRolePolicyDocument carry no such sentence. Substrate follows the per-shape pages, so one response carries both conventions: PolicyVersionList[].Document is percent-encoded, byte-identical to GetPolicyVersion's, while the inline documents and the trust policy are plain JSON, byte-identical to GetUserPolicy's and GetRole's. That is the invariant this operation exists to protect — a shape must not diverge between the operations reporting it — and encoding everything instead would be a breaking wire change to five shipped operations. The contradiction is recorded rather than silently resolved; GetRole's page shows the same one.
SAMLProviderList is a Filter value AWS accepts and substrate refuses, because substrate models no SAML provider: accepting the filter would select nothing, and a caller reading an empty UserDetailList could not tell that from an account with no users. Errors come from CommonErrors — the page itself declares only ServiceFailure/500 — so an unknown Filter value is ValidationError/400.
An out-of-range MaxItems is refused, not silently rewritten
Every IAM operation that decodes MaxItems refuses a value outside 1–1000 (#868). AWS's maxItemsType has Min: 1 and Max: 1000, and the service refuses MaxItems=0 or MaxItems=1001 rather than choosing a value for the caller.
The code is ValidationError/400 at nineteen operations and InvalidInput/400 at SimulatePrincipalPolicy and SimulateCustomPolicy, and that split is AWS's rather than substrate's: the two simulate operations publish InvalidInput in their own Errors sections, while ListUsers, ListUserTags and the rest publish only NoSuchEntity and ServiceFailure — so for them the code can only come from CommonErrors, which does not list InvalidInput at all. Only the code differs; the bounds and the presence rule below are the same everywhere, so no IAM operation disagrees with another about which values are acceptable.
ListInstanceProfiles was the one exception, and it was a gap rather than a decision: it decoded no request parameters at all, so it ignored MaxItems, Marker and PathPrefix and always reported IsTruncated as false. There was no decoded value for the guard to range-check, so closing the gap meant implementing the pagination the operation had never had, which #873 did. Every IAM operation that publishes MaxItems now applies the same bounds.
Substrate coerced instead: the shared paginator rewrote anything outside the range to 100, so a caller who asked for 1001 items got 100 and a caller who asked for 0 got 100 — a page size no part of the request named. That is the release's theme applied to a request parameter rather than a response value. The coercion is still the defaulting path for an absent MaxItems, which is what AWS's documented default of 100 means; it is no longer reachable by a value the caller actually sent.
A parameter that is present but empty is accepted, and that is a decision rather than an oversight: a form body carrying MaxItems= expressed no limit, so it takes the default. The refusal is per operation, in the handler, rather than in the paginator, because the paginator cannot tell an absent parameter from a zero one.
ListInstanceProfiles filters before it pages
ListInstanceProfiles applies PathPrefix to the whole account and then cuts the page (#873). ListUsers and ListRoles do it the other way round — paginateIAMKeys slices first and the prefix is checked while the page is rendered — which under-fills a page whenever the entities outside the prefix outnumber it. Ask for one profile under /service-role/ with two profiles under / sorting ahead of it and the filter-after order answers an empty page while IsTruncated says there is more; a caller cannot tell that from an account with nothing under that path. The order here follows ListPolicies, which already gets it right. The ListUsers/ListRoles order is a separate defect and is not fixed by #873.
The cost is that every profile in the account is decoded on each call rather than one page's worth: PathPrefix matches on Path, which lives inside the record rather than in its state key, so nothing can be filtered until the record is read. A page that under-fills is wrong in a way a caller cannot detect; an extra state read is only slower.
PathPrefix is validated, against the pattern API_ListInstanceProfiles publishes — \u002F[\u0021-\u007F]*, a leading slash followed by any character from ! to DEL — and against its published length of 1–512. An out-of-range or malformed value answers ValidationError/400. That pattern is deliberately not the one ListPolicies enforces (policyPathType, which requires a trailing slash as well): the two operations publish different patterns, and sharing one would refuse /service-role here, a prefix IAM accepts. Enforcing the leading slash is worth doing because service-role/ matches no profile at all, and a silent empty result is indistinguishable from "nothing is under that path".
An empty PathPrefix is taken as absent rather than refused against the published minimum length of 1, matching the MaxItems rule above: a form body carrying PathPrefix= expressed no filter, so it takes the documented default of /, which selects every profile.
An attached policy cannot be deleted
DeletePolicy answers DeleteConflict/409 while the policy is attached to any user, group or role, and the message names them. Before #853 it went straight from the NoSuchEntity check to the delete, and what the silent success left behind is worse than the missing code: nothing writes a back-reference onto a policy, so every entity kept its ARN, ListAttachedUserPolicies still reported it, the authorization evaluator loaded no document for it — so the entity silently lost the permissions the policy granted, and a test asserting a deny passed for the wrong reason — and GetPolicy answered NoSuchEntity for the same ARN.
The refusal reads the same three <kind>_policies: prefixes, through the same loader, that the AttachmentCount GetPolicy and ListPolicies report is derived from. That is deliberate: the count and the refusal must not be able to disagree about what "attached" means, and reading the same keys is what guarantees it rather than promises it. It also closes a hazard the derived count made observable — a delete now succeeds only when nothing is attached, so a policy re-created under a deleted ARN cannot inherit the previous one's attachments.
Naming the entities matters more here than in the sibling refusals. API_DeletePolicy's description tells the caller to use ListEntitiesForPolicy to find what to detach, and substrate does not implement that operation, so the 409's message is the only way a caller can learn it. The three refusals that already name what to remove — DeleteUser's group memberships, DeleteRole's instance profiles, DeleteGroup's users — set the wording; the four that refuse over attached policies name nothing, which is why this message is written rather than copied.
A malformed PolicyArn answers InvalidInput/400 through the same shape check the three attach operations and both Put*PermissionsBoundary apply. Previously any non-empty string became a state key that could never match, so a bare policy name was reported as a policy that does not exist — the wrong answer to a request that was never well-formed enough to name one.
AWS's other stated precondition, deleting every non-default version first, needs no code here: it publishes no error code — DeleteConflict's own text is about attached subordinate entities — and substrate models exactly one version per policy, so there is never a non-default version to delete.
A bundled AWS managed ARN answers InvalidInput/400 rather than NoSuchEntity, and that is substrate's reading of a constrained choice rather than a documented code. The handler read state only, so arn:aws:iam::aws:policy/PowerUserAccess was reported as not existing while GetPolicy resolved the same ARN from the catalog and returned the policy — one ARN, two operations, opposite answers about whether the thing exists. API_DeletePolicy does not say what happens to an AWS managed ARN, and the strongest published statement anywhere is "You cannot change the permissions defined in AWS managed policies" (Managed policies and inline policies), which establishes that the customer does not administer them but names no code and does not mention deletion. So the code comes from DeletePolicy's own Errors section, and of the five it publishes InvalidInput is the only one describing a rejected input value: NoSuchEntity is false here because the policy is readable, DeleteConflict means attached subordinate entities, and LimitExceeded and ServiceFailure are unrelated. UnmodifiableEntity — which DeleteRole answers for a service-linked role — fits the meaning better but is not published for this operation, and answering a code AWS does not list would trade one wrong answer for another.
The tagging operations, and what a listing reports
Twelve tagging operations answer, in four families of three. The user and role families have existed for some time; the policy and instance-profile families answered InvalidAction/400 before this release (#796), which is the other half of the same drift — a consumer's tag aspect tags every entity it creates, and only two of the four could accept one.
| Family | Operations | Identifier |
|---|---|---|
| User | TagUser, UntagUser, ListUserTags | UserName |
| Role | TagRole, UntagRole, ListRoleTags | RoleName |
| Policy | TagPolicy, UntagPolicy, ListPolicyTags | PolicyArn |
| Instance profile | TagInstanceProfile, UntagInstanceProfile, ListInstanceProfileTags | InstanceProfileName |
A listing is sorted by tag key, on all six — AWS states it on each, and it is not cosmetic: the response Marker names a key rather than an offset, so an unsorted underlying order would make a second page arbitrary. All six render through one code path, so ListUserTags and ListRoleTags now sort too, where they previously reported tags in whatever order they were stored. MaxItems defaults to 100 (1–1000), and Marker appears in the response only when IsTruncated is true. Tags is a required response member here, so an untagged resource reports an empty list rather than omitting it — the opposite of the entity shapes above.
Tagging is a privilege-relevant operation, not bookkeeping: an aws:ResourceTag-conditioned statement is decided on the resource's tags, so a caller who can retag an instance profile can move it in or out of the reach of every such statement. All six are authorized at both doors against the resource they name, which is what the six new iamAuthzOperationResource rows are for.
An AWS managed policy cannot be tagged. AWS documents these operations for an "IAM customer managed policy", and a managed policy belongs to the aws account; substrate's bundled catalog is read-only for the same reason, so tagging one answers NoSuchEntity/404 even though GetPolicy resolves the same ARN.
What an IAM tag request is refused for
All twelve paths that accept a tag — the four Tag*, the four Untag* and the four Create* — validate against one rule set (#806). Before this release every one of them accepted anything at all: fifty-one tags, an empty key, a key beginning with the reserved aws: prefix, a character outside the set AWS publishes.
| Rule | Source | Answer |
|---|---|---|
| Key 1–128 characters | Tag — "Minimum length of 1. Maximum length of 128" | ValidationError/400 |
| Value 0–256 characters | Tag — "Minimum length of 0. Maximum length of 256" | ValidationError/400 |
Key matches [\p{L}\p{Z}\p{N}_.:/=+\-@]+ | Tag — Pattern | ValidationError/400 |
Value matches [\p{L}\p{Z}\p{N}_.:/=+\-@]* | Tag — Pattern | ValidationError/400 |
| At most 50 members in one request | Tags.member.N / TagKeys.member.N — "Array Members: Maximum number of 50 items" | ValidationError/400 |
Neither key nor value begins with aws: | Tagging IAM resources — "You cannot create a tag key or value that begins with the text aws:" | InvalidInput/400 |
| At most 50 tags on a resource, counted after the merge | the same array cap, applied to the resulting set | LimitExceeded/409 |
The character set is Unicode, not ASCII. The two rules above are AWS's own patterns compiled verbatim, so Abteilung=Zürich, 部門=エンジニアリング and a value of Arabic-Indic digits are all accepted — where the ASCII whitelist the User Guide's prose rendering suggests ("letters, numbers, spaces, and _ . : / = + - @") would refuse them. The lengths are counted in characters rather than bytes for the same reason: a 128-rune key of non-Latin letters is legal. The anchors on the patterns are substrate's — AWS publishes them unanchored, and an unanchored match would accept any string containing one legal character.
Which code answers which rule is substrate's mapping, and it is worth stating plainly because AWS publishes no per-rule code. A constraint stated on the shape answers ValidationError/400, the code the IAM plugin already returns for every other shape violation and the only 400 the four Untag* operations declare at all. A rule stated only in prose and inexpressible in the shape — the reserved prefix — answers InvalidInput/400, which every tag-writing and create operation documents. The over-limit total answers LimitExceeded/409, whose documented sentence is "The request was rejected because it attempted to create resources beyond the current AWS account limits." The messages are substrate's own wording throughout; no page publishes message text, and an SDK dispatches on the code.
The 50-tag cap is counted over the post-merge set, the way EC2's is: rewriting the value of a key an entity already carries adds no key, so it succeeds on an entity already holding fifty tags, while adding a fifty-first is refused. Unlike EC2, reserved keys are not exempt from the count — EC2's restrictions list states that exemption and no IAM page does, and a caller cannot create such a key here in any case.
aws: is matched case-sensitively, so AWS:billing is an ordinary caller tag. The prohibition names "the text aws:", the reserved keys the same page lists are all lowercase (aws:cloudformation:stack-name), and folding the comparison would refuse a key real IAM accepts.
An Untag* validates its keys too, including against the reserved prefix. TagKeys.member.N carries the same length, pattern and array constraints as a tag key, so the same checks apply; the reserved-prefix check there is a choice rather than a rule, because the prohibition is on creating such a tag — but no IAM entity in substrate can hold one, so a caller naming it is asking to remove a tag that cannot exist.
A bad tag on a create leaves no entity behind. CreateUser's Tags.member.N is explicit — "If any one of the tags is invalid or if you exceed the allowed maximum number of tags, then the entire request fails and the resource is not created" — so validation runs before the write on all four creates. A create also collapses a duplicate key under its entity type's case rule (below), so an entity cannot be born holding two keys no later Tag* could produce.
Authorization is decided first. A caller without the tagging permission on the named resource is told AccessDenied whether its payload is legal or not, rather than being told which of its tags was malformed on a resource it cannot touch.
Tag keys are case-sensitive for some entity types and not others, which is AWS's split rather than substrate's: "Tag key values for IAM users and roles are not case sensitive, but case is preserved. This means that you cannot have separate Department and department tag keys. […] For other IAM resource types, tag key values are case sensitive."
| Entity | Key comparison | Department=finance then department=hr yields |
|---|---|---|
| User | not case sensitive | Department=hr — one tag, stored spelling, new value |
| Role | not case sensitive | Department=hr |
| Customer managed policy | case sensitive | both keys |
| Instance profile | case sensitive | both keys |
Case being preserved matters on the insensitive side: the surviving key keeps the spelling already stored, because taking the incoming spelling would silently rename a key an aws:ResourceTag condition might be matching on. The same rule reaches the removal — untagging DEPARTMENT removes a user's Department and removes nothing from a policy — because otherwise a user could hold a tag no spelling a caller can send would delete.
Two rules are deliberately not enforced. The Tag type marks both Key and ValueRequired: Yes, but an omitted Tags.member.N.Value arrives on the query wire as the empty string, which is indistinguishable from an explicitly empty one — and the empty value is documented as legal ("You can create a tag with an empty value such as phoneNumber = "), so it is accepted rather than guessed at. An empty key is refused, by the minimum length of 1 and the pattern's + alike.
ConcurrentModification/409 and ServiceFailure/500 are unreachable by construction. Both are declared on all twelve operations and substrate emits neither. A tagging handler's read-modify-write runs synchronously inside one request against a state manager that serializes its own access, so no second request can interleave to produce the simultaneous-change condition ConcurrentModification reports. A state failure is returned as a Go error from the plugin and answered as an internal error, not as an IAM-shaped ServiceFailure body, so no code path constructs one. A consumer testing a retry loop around either code cannot drive it from here.
Service-linked roles and iam:AWSServiceName
The three service-linked-role operations exist principally as the producer for iam:AWSServiceName (#747). Eleven of the 32 condition blocks in the bundled managed-policy catalog turn on that key — the largest single group — and before this release every one of them was unevaluatable, because the operation carrying the parameter answered InvalidAction/400.
Making them evaluate needed two things, not one. The key is published at both authorization doors: the generic gate reads it off the request, and the handler passes it alongside the action, because a request that passes two gates must get one answer from both (#411). And the request resource had to become the role's own ARN — four of those statements scope Resource to arn:aws:iam::*:role/aws-service-role/… and two of the four to an exact ARN with no trailing *, none of which a flat arn:aws:iam::<account>:* can match. All three operations get a real ARN, including the status poll, whose DeletionTaskId embeds the service and the role so the resource resolves without reading state — which is what makes it still resolve after the role is gone, the normal case for the poll that finally reports SUCCEEDED.
The key is published for CreateServiceLinkedRole and DeleteServiceLinkedRole and not for GetServiceLinkedRoleDeletionStatus, matching where the Service Authorization Reference lists it. Publishing a key AWS does not is the permissive direction: it would let a StringEquals succeed here and fail on AWS.
The role name is substrate's convention, not AWS's rule. AWS publishes no derivation from a service principal to a role name, and the IAM User Guide warns against inferring even the principal — "Do not try to guess the service principal, because it is case sensitive and the format can vary across AWS services." So substrate carries a table of exactly the six principals a bundled statement names inside a Resource:
| Service principal | Role name |
|---|---|
lambda.amazonaws.com | AWSServiceRoleForLambda |
elasticache.amazonaws.com | AWSServiceRoleForElastiCache |
events.amazonaws.com | AWSServiceRoleForCloudWatchEvents |
ssm.amazonaws.com | AWSServiceRoleForAmazonSSM |
cognito-idp.amazonaws.com | AWSServiceRoleForAmazonCognitoIdp |
email.cognito-idp.amazonaws.com | AWSServiceRoleForAmazonCognitoIdpEmail |
Every other principal gets a derived name: the .amazonaws.com suffix stripped and each remaining ./-/_ segment title-cased, so someservice.amazonaws.com yields AWSServiceRoleForSomeservice. That will differ from AWS for a service whose real name is not mechanical — spot.amazonaws.com is AWSServiceRoleForEC2Spot on AWS, which no transformation of "spot" produces. The table is deliberately not padded out with the other well-known names: a guessed row would be indistinguishable from a cited one, whereas a derived name is documented as substrate's own. One bundled statement scopes a Resource around an untabled principal — AWSBatchFullAccess pairs batch.amazonaws.com with arn:aws:iam::*:role/*Batch* — and the derived AWSServiceRoleForBatch satisfies it, because that pattern is a contains-glob rather than a path.
CustomSuffix is joined to the name with _, which is observed behaviour: AWS says only that the suffix "is combined with the service-provided prefix to form the complete role name". A combined name over 64 characters is refused rather than stored, so GetRole and DeleteServiceLinkedRole can always name what the create made.
A duplicate name is InvalidInput/400. CreateServiceLinkedRole publishes exactly four errors — InvalidInput 400, LimitExceeded 409, NoSuchEntity 404, ServiceFailure 500 — and notably not EntityAlreadyExists, which is what CreateRole answers. So the refusal cannot be a copy of CreateRole's; InvalidInput is substrate's reading of AWS's "the request fails with a duplicate role name error" against the four codes the operation actually publishes.
DeleteRole refuses a service-linked role with UnmodifiableEntity at HTTP 400. Without that guard DeleteServiceLinkedRole would be decorative — a caller could delete the role through the ordinary path and never submit a task.
Seeding a service-linked-role deletion outcome
Deletion is asynchronous on AWS, and the failure it documents — "If you submit a deletion request for a service-linked role whose linked service is still accessing a resource, then the deletion task fails" — is unreachable in an emulator that runs no linked service. So substrate reports SUCCEEDED by default and the outcome is seedable, which is the only way a consumer's poll loop's FAILED branch is testable without wall-clock time:
curl -X POST http://localhost:4566/v1/iam/slr-deletion-status \
-d '{"roleName":"AWSServiceRoleForLambda","status":"FAILED",
"reason":"Cannot delete the role because it is still in use.",
"roleUsageList":[{"Region":"us-east-1",
"Resources":["arn:aws:lambda:us-east-1:123456789012:function:live"]}]}'
curl -X DELETE 'http://localhost:4566/v1/iam/slr-deletion-status?roleName=AWSServiceRoleForLambda'roleName defaults to "*", which applies to every role; an exact name wins over the wildcard. status must be one of AWS's four documented values, so a typo is refused where it is written rather than reported later as a status no SDK models. Omitting roleName on the DELETE clears every seed.
The seed is read at submission, not at each poll, because the deletion is conditional on it: only a SUCCEEDED task removes the role record. That is the observable difference the two outcomes turn on — a caller who polls to FAILED and then reads the role must still find it. A task held at IN_PROGRESS or NOT_STARTED also makes a resubmission return the same DeletionTaskId, per AWS's "if the deletion task is not yet complete, the DeletionTaskId of the existing task is returned". A FAILED task does not block a resubmission, or a caller who removed the blocking resources could never retry.
Policy variables resolve from the request context
A ${…} in a policy is substituted before the comparison (#745), so arn:aws:s3:::home/${aws:username}/* grants alice her own prefix and refuses bob's. Until that release it was compared as literal text, which matched no ARN at all — a policy of that shape, the shape AWS's own documentation gives for "a prefix per user", granted nothing.
Substitution runs in exactly the elements AWS names, and nowhere else:
| Element | Substituted? | Rule |
|---|---|---|
Resource, NotResource | Yes | AWS names both, in the resource portion of the ARN |
Condition values under String* and Arn* | Yes | "any condition that involves the string operators or the ARN operators" |
Condition values under Numeric, Date, Bool, Binary, IpAddress, Null | No | "You can't use a policy variable with other operators" — the text is compared as written, which for those types is a comparison that cannot be made |
Action, NotAction, Principal, Sid | No | Not on AWS's list |
Four rules that decide the edge cases, each taken from AWS's IAM policy elements: Variables and tags page:
- The
Versionelement gates the whole feature. "Variables were introduced in version2012-10-17… If you don't include theVersionelement and set it to an appropriate version date, variables like${aws:username}are treated as literal strings in the policy." A document with an olderVersion, or none, is read exactly as it was before this release. All 52 bundled AWS managed policies declare2012-10-17, so nothing bundled changed meaning by accident. - Key names are case-insensitive, so
${aws:userName}and${AWS:USERNAME}resolve the same key a producer wrote asaws:username. - Defaults are honored:
${aws:PrincipalTag/team, 'company-wide'}resolves tocompany-widewhen the tag has no value, so a variable with a default is never unresolved. ${*},${?}and${$}stay literal. They are AWS's escapes for those characters, so substrate tracks per byte whether a*was written by the policy author or arrived from a substitution. A wildcard inside a resolved value is text too — a tag value is data the request carried, not pattern the author wrote. AWS documents no rule for that case; reading it as text is substrate's choice, and it is the reading that cannot widen a statement on behalf of whoever set the tag.
A variable with no value voids what contains it, which is one AWS rule stated twice for the two element kinds:
| Where | AWS's rule | Effect |
|---|---|---|
Resource | "the resource that includes a policy variable with no value will not match any resource" | The entry grants nothing |
NotResource | the same sentence names both elements | The entry excludes nothing — so the statement matches every resource |
| Condition value, positive operator | "the value is effectively null. There is no equal or like value" | No match |
| Condition value, negated operator | "Inverted condition operators like StringNotEquals or StringNotLike do match against a null value" | Match |
This page published the NotResource half of that backwards before #744 corrected it. Substrate reached all four answers correctly even while comparing literally, because an ARN never contains a ${…} — but by coincidence rather than by the rule, which is why the rule is now applied deliberately: a resolved value can itself contain a wildcard, and coincidence does not survive that.
aws:username is the producer this needed, and it is recorded rather than derived:
| Caller | aws:username |
|---|---|
| A long-term access key | The IAM user that holds it |
GetSessionToken | The same user — its principal is unchanged, and AWS publishes the key |
AssumeRole | Absent. The session name is not a user name |
| A registered credential with no IAM entity behind it | Absent. That ARN's last segment is the access key ID |
| A CloudFormation stack's own resource calls | The stack's creator, carried in the stack record so a rollback's deletes are authorized as the create was |
SimulateCustomPolicy / SimulatePrincipalPolicy | Derived from CallerArn when it names a user, because there the ARN is the caller's own assertion of who to simulate as. It takes the last segment, which is the friendly name — the reading every other reader of an entity ARN adopted in #801 |
Substitution reads only the single-valued context, per AWS's "You can use any single-valued condition key as a variable. You can't use a multivalued condition key as a variable" — so ${aws:TagKeys} never resolves.
For one release, substitution alone did not make the bundled IAMUserChangePassword grant anything: its Resource resolved correctly to arn:aws:iam::*:user/alice, but every IAM request's resource was built as arn:aws:iam::<account>:*, so there was nothing user-shaped to match. That was a separate gap in how IAM's request resource is derived, closed by #770 — see what resource an IAM request is decided against, below. The bundled policy now grants the caller their own password and nothing else.
What resource an IAM request is decided against
An IAM request is authorized against the entity it names (#770), so a statement whose Resource is arn:aws:iam::123456789012:user/alice grants exactly alice. Until that release every IAM request was decided against arn:aws:iam::<account>:* — a literal * in the resource position, which no statement naming a user, a role or a path can match. The consequence was a false deny: every narrowly scoped IAM statement was inert, including AWS's own managed policies for letting a user manage their own credentials.
Resolution is one table of 59 operations, keyed by operation name, plus the three service-linked-role operations that are resolved separately because their resource comes from a service principal or a deletion-task ID rather than from a name on the wire:
| Resource type | Where the name comes from |
|---|---|
user | UserName, or the caller's own user when the parameter is absent — GetUser, CreateAccessKey, DeleteAccessKey, ListAccessKeys and ChangePassword, each because AWS documents that default for that operation |
role | RoleName |
group | GroupName — including AddUserToGroup and RemoveUserFromGroup, which publish group and only group |
policy | PolicyArn as it stands, or PolicyName for CreatePolicy |
instance-profile | InstanceProfileName |
| whichever of the three | PolicySourceArn for SimulatePrincipalPolicy, which is a finished ARN |
Two things about that table are checked rather than claimed. Every row's resource type is one AWS publishes for that action, and every IAM action AWS publishes no resource types for is absent from the table — both read from the vendored Service Reference Information snapshot (#797), so a row that drifts from AWS's own data fails the build. The minted ARNs are checked the same way, against AWS's published format strings.
An IAM ARN embeds the entity's path, and only the Create* operations carry Path on the wire. So a request naming an entity costs one state read to recover the stored path: a policy scoped to arn:aws:iam::123456789012:role/division/engineering/worker matches GetRole(RoleName=worker) when that is where the role lives, and a policy scoped to …:role/worker does not. On a state miss the request stays on the account path rather than being decided against an ARN that only looks specific.
An operation the table does not cover is decided against arn:aws:iam::<account>:*, which is also AWS's answer for the operations that name no resource: ListUsers, ListRoles, ListGroups, ListPolicies, ListInstanceProfiles and SimulateCustomPolicy. A statement scoped to one user grants nothing on those — the account wildcard is deliberately not a bare *, which would match every statement's Resource and quietly satisfy a Deny.
Both authorization doors call one resolver. The generic AuthController gate and the IAM plugin's own gate see the same request and derive the same ARN, which is what keeps them from answering one request two ways — the failure behind #411, #714 and #745. The plugin door passed a literal "*" at 48 of its gates before this release, and the six instance-profile operations did not call it at all: AddRoleToInstanceProfile — the classic privilege-escalation step, attaching a more privileged role to a profile an instance already carries — had no plugin-side gate. All six are gated now.
The same rule applies to the caller, whose ARN also embeds their path (#801). AWS writes an entity ARN as arn:aws:iam::<account>:user/<UserNameWithPath> — one component in which the friendly name is the last segment — so …:user/division/engineering/alice names the user alice at /division/engineering/. Substrate read the whole component as the name, and two things followed from that:
- A caller at a non-default path was not enforced at all. Their ARN resolved to an entity called
division/engineering/alice, which exists nowhere, and a principal that resolves to nothing is unenforced, because enforcement is opt-in by existence. Reachable two ways without any misconfiguration: a CloudFormation stack whoseRoleARNis a service role at/service-role/— where AWS's console creates one — had every resource call allowed, andsts:AssumeRoleon a role at any path answeredNoSuchEntityExceptionfor a valid ARN. - A signed IAM-user call reported an ARN that named no entity. The principal's ARN was built from the access key's record without the path, so
aws:PrincipalArnconditions andGetCallerIdentitypublished…:user/alicefor a user who is not there. That defect masked the first one for long-term keys, which is why both moved together.
The caller's ARN now carries the path, and every reader takes the friendly name from its last segment. One interaction is worth stating because it reads like a regression and is not: a pathful caller's own resource ARN carries the path too, so a statement scoped to arn:aws:iam::*:user/${aws:username} does not match them. That is AWS's behavior, and it is why AWS's own IAMUserChangePassword names a second resource, arn:aws:iam::*:user/*/${aws:username} — which does match, so the bundled policy grants a pathful user their own password exactly as it grants a default-path one.
An assumed-role ARN is the one exception, and it is deliberate: arn:aws:sts::<account>:assumed-role/<RoleName>/<RoleSessionName> has exactly two segments and excludes the role's path, so its role name is the first — which is how a session of a role at /service-role/ still resolves to that role's policies.
aws:ResourceTag/<key> on an IAM request
The tags published for an IAM request are the named entity's (#804). They ride out of the same read that recovers the path above, so aws:ResourceTag/<key> costs nothing beyond it, and they are attached to the ARN they belong to rather than merged across the request — a condition about one resource cannot be satisfied by a tag on another.
The IAM User Guide names four tag condition keys for IAM — aws:ResourceTag/<key>, aws:RequestTag/<key>, aws:PrincipalTag/<key> and aws:TagKeys — and no iam:-prefixed resource-tag key, so unlike EC2 there is no service-specific duplicate to publish under.
| Request | What aws:ResourceTag/<key> reports |
|---|---|
| Names a user, role, policy or instance profile | That entity's tags, from its own record |
| Names a service-linked role | The role's tags — an SLR is a role and is tagged like one |
| Names a group | Nothing. AWS does not let a caller tag a group |
| Names an entity that does not exist | Nothing — there is no record to read tags from |
Names no resource (the six List* and SimulateCustomPolicy) | Nothing. Their resource is arn:aws:iam::<account>:* — every IAM resource in the account, which names no one entity whose tags could describe it |
Two directions were wrong before that release, and the serious one is the second:
- An
Allow … if aws:ResourceTag/team=platformgranted nothing, because the key was absent from every entity-naming IAM request from the moment #770 made that the path they all take. - A
Deny … if aws:ResourceTag/env=prodsilently stopped biting. A guardrail written to fence off production entities was inert, and theAllowbeneath it decided the request — enforced on AWS, unenforced here, which is the one direction an emulated privilege boundary must not drift in. - On the six operations that name no resource, the caller's tags were published as the resource's, so a caller tagged
team=platformsatisfied a condition written about a resource taggedteam=platformwhatever the resource carried. That arm dated from before either key had a producer for IAM; the caller's tags areaws:PrincipalTag/<key>, which has had one of its own since #771.
Both doors publish the same tags for the same request, for the same reason they derive the same ARN: publishing the key at the generic gate alone would have refused, inside the handler, a request the gate had just allowed.
Multivalued condition keys: ForAllValues and ForAnyValue
A condition key is either single-valued — at most one value in the request context — or multivalued. AWS's rule is that a multivalued key "requires a condition set operator", written as a prefix on the operator itself, and that a set operator must not be used on a single-valued key. Substrate mirrors that split: the evaluator carries two context maps, and only the multivalued one is quantified over.
| In the policy | Substrate's answer |
|---|---|
StringEquals and the other eight operators, unqualified | Compares the single-valued context. Unchanged by anything in this section |
ForAllValues:<operator> | True when every value the request carries satisfies the operator — and true when the key is absent or carries no values |
ForAnyValue:<operator> | True when at least one value satisfies it; false when the key is absent or carries no values |
| Either qualifier on an operator substrate does not recognize | Does not match, absent key or not |
Either qualifier on Null | Does not match. AWS defines no set-qualified Null, and quantifying an existence test over the values whose existence it is testing has no meaning |
| Anything else in that position | Does not match. ForSomeValues:StringEquals, or a lowercase forallvalues:, denies rather than being read as unqualified |
Both absent-key rules are AWS's, quoted: ForAllValues "also returns true if there are no context keys in the request", and for ForAnyValue, "for no matching context key or if the key does not exist, the condition returns false".
Pair ForAllValues with Null. The vacuous truth above means a ForAllValues Allow permits a request that names nothing at all, which is why AWS's own note says to "always include the Null condition operator in your policy with a false value", and why all four of its aws:TagKeys examples do:
"Condition": {
"ForAllValues:StringEquals": {"aws:TagKeys": ["Department", "CostCenter"]},
"Null": {"aws:TagKeys": "false"}
}Seen from the Deny side the same vacuity bites the other way — a ForAllValues Deny fires on the request that carries no keys — so a guardrail is better written with ForAnyValue.
The two absent-key rules are AWS's, but they are also what used to make the qualifiers dangerous: substrate answered true for ForAllValues:<anything> over an absent key without consulting the operator at all, so an Allow written with an operator it could not evaluate — or with a misspelled one, which nothing rejects at write time — was granted. The vacuous truth now applies only to an operator substrate actually evaluates; an unrecognized one, and a qualified Null, deny. Treating an unrecognized qualifier or operator as a non-match is substrate's own choice — real IAM refuses the document instead — and it is the only choice that cannot turn a typo into a grant. Everything else here is documented behaviour.
Which keys are multivalued
| Key | Populated from |
|---|---|
aws:TagKeys | The tag keys the request asks to apply, on every service whose tags substrate reads: EC2's TagSpecification.N.Tag.M.Key and a direct CreateTags/DeleteTags' Tag.N.Key, IAM's and Organizations' Tags lists, Lambda's Tags map, Config's Tags, and S3's x-amz-tagging header |
| Any key a simulation supplies | ContextEntries.member.N.ContextKeyValues.member.M — every value, not just the first |
aws:TagKeys is derived from the aws:RequestTag/<key> entries substrate already read out of the request rather than gathered separately, so the two cannot disagree about what the request asked for, and its value is sorted — a ForAllValues denial that depended on Go map iteration order could not replay from the event log.
Everything else substrate populates is single-valued: aws:RequestTag/<key> and aws:ResourceTag/<key> on an authorized request, aws:CurrentTime and aws:EpochTime from the simulated clock, ec2:CreateAction on a tagged create's second authorization pass, ec2:Subnet and ec2:Vpc on a launch's networking resources, sts:ExternalId when a role is assumed, aws:PrincipalArn at every gate, and aws:ResourceAccount in a simulation. A set qualifier on one of those is evaluated over a one-element set — which is the thing AWS warns against writing, not something substrate refuses. Over one that is absent it is vacuously true, which is the other half of the same warning.
One authorization path populates neither map: iam: actions decided inside the IAM plugin. Nothing there reads the request for tags, so a condition on aws:RequestTag or aws:TagKeys cannot be satisfied through that door — worth knowing before writing one against an IAM action.
AWS managed policies are a seeded catalog
Substrate bundles 52 AWS managed policies, not the ~1,200 AWS publishes. Each carries its real ARN, policy ID, path and default version, and a policy document copied verbatim from its page in the AWS managed policy reference. GetPolicy resolves a bundled ARN exactly as it resolves one from CreatePolicy.
The catalog covers two populations:
| Population | Examples |
|---|---|
| Human-operator policies (47) | AdministratorAccess, PowerUserAccess, ReadOnlyAccess, and per-service …FullAccess / …ReadOnlyAccess pairs |
| Service-role policies (5) | AmazonSSMManagedInstanceCore, AmazonEC2ContainerRegistryReadOnly, service-role/AmazonECSTaskExecutionRolePolicy, service-role/AWSLambdaBasicExecutionRole, service-role/AWSLambdaVPCAccessExecutionRole |
The distinction matters because they are attached by different callers. A human-operator policy is attached to a user or group; a service-role policy is what an instance profile or execution role carries, and those are the ones IaC provisions. Substrate bundled AmazonSSMFullAccess — the operator policy — but not AmazonSSMManagedInstanceCore, which is the policy an SSM-managed instance actually needs.
A policy under a path reports the path in Path and keeps it out of PolicyName: service-role/AWSLambdaBasicExecutionRole has Path: /service-role/ and PolicyName: AWSLambdaBasicExecutionRole, matching AWS. The full ARN includes the path component.
Finding a policy: ListPolicies scope
Scope=AWS means substrate's 52-policy catalog, not the ~1,200 AWS publishes. Scope=Local is the policies created through CreatePolicy; All, the default, is both. PathPrefix matches against the Path field and must begin and end with a slash, as IAM requires — /service-role is refused, /service-role/ returns the three policies AWS publishes under that path (AWSLambdaBasicExecutionRole, AWSLambdaVPCAccessExecutionRole, AmazonECSTaskExecutionRolePolicy). The other two service-role policies substrate bundles, AmazonSSMManagedInstanceCore and AmazonEC2ContainerRegistryReadOnly, live at / because that is their real path — what a policy is for and where it lives are different things, and the path reported here is AWS's. OnlyAttached is computed from stored attachments, so a bundled policy that has been attached to a user, group or role does appear; the catalog's own AttachmentCount field is always 0 and is read by nothing — every shape that reports a count derives it, GetPolicy included as of #847.
PolicyUsageFilter is validated and applies no narrowing. The reference does not say which side of PermissionsPolicy/PermissionsBoundary an entirely-unused policy falls on, and in a fresh substrate every bundled policy is unused — so guessing would silently drop all 52 from a filtered listing, which is the same failure the unfiltered listing used to have. An invalid value is still refused with ValidationError.
Attaching a policy substrate does not bundle
AttachUserPolicy, AttachRolePolicy and AttachGroupPolicy check the shape of PolicyArn, not that it resolves. A value that is not a well-formed policy ARN — a bare policy name, a bucket ARN, role/ where policy/ belongs, an account that is not twelve digits — is refused with InvalidInput (400).
A well-formed ARN that resolves in neither the catalog nor state succeeds, and logs at WARN naming it. Requiring existence would refuse every attach of the ~1,150 unbundled managed policies: attaching AmazonAthenaFullAccess would hard-fail where AWS succeeds. That trades a confusing success for a wrong failure, and the wrong failure breaks working consumer code rather than merely failing to catch a typo. The warning distinguishes an unbundled AWS managed policy (expected) from a customer-managed ARN no CreatePolicy ever created (likelier a real mistake).
The consequence to know: an attached-but-unresolvable policy contributes no statements to any authorization decision, so CheckAccess and the simulator both behave as though it were not attached. A consumer who needs the attach verified should follow it with GetPolicy, which is exact.
A permissions boundary is treated the same way, and the consequence is the reverse one.PutUserPermissionsBoundary and PutRolePermissionsBoundary check the boundary ARN's shape and refuse a malformed one with InvalidInput (400) — the code both operations publish — while a well-formed ARN naming a policy substrate cannot resolve succeeds and logs at WARN, for #499's reason above: AWS says a boundary may be "an AWS managed policy or a customer managed policy", so the unbundled ~1,150 are the common case here too, and neither operation's page states what a nonexistent boundary policy produces. The warning is its own line rather than the attach path's, because an unresolvable attached policy grants nothing while an unresolvable boundaryrestricts nothing: the boundary is stored and reported, but the evaluator loads no document for it, and no document is indistinguishable from no boundary — so the entity keeps every permission its attached policies grant, where AWS would have clamped them (#846).
Two further notes on that pair. PutRolePermissionsBoundary refuses a service-linked role with UnmodifiableEntity (400) — its own prose says "You cannot set the boundary for a service-linked role", and both the sentence and the code are absent from PutUserPermissionsBoundary, which is AWS confirming the asymmetry rather than substrate inferring it from "there is no service-linked user". And PolicyNotAttachable, which both pages publish for an AWS service-role policy attached to anything but its own service-linked role, is deliberately not modelled: substrate does not record which managed policies are service-role policies, so it cannot tell the case apart, and refusing on a guess would be a refusal AWS's own description does not cover. A missing required member is ValidationError (400), which is on IAM's CommonErrors page rather than either operation's list, and is what every other IAM operation answers for one.
Detaching a policy
DetachUserPolicy, DetachRolePolicy and DetachGroupPolicy apply the same shape check as their attach counterparts, refusing a malformed PolicyArn with InvalidInput (400) (#875). Before that they answered NoSuchEntity (404) — "the policy is not attached to the specified entity" — which was true but pointed at the wrong thing: a consumer handling it inspects the attachment, finds the policy attached under its real ARN, and has been told the opposite of its actual mistake, which was the string it typed. API_DetachUserPolicy publishes InvalidInput for "an invalid or out-of-range value ... supplied for an input parameter", and publishes PolicyArn's length range as 20-2048, so both refusals come from the API model.
A well-formed ARN that is not attached still answers NoSuchEntity (404), which is also published, and which is not in tension with the attach side's refusal to require the policy to exist. The two are about different facts. Substrate holds every attachment it was told about, in full, so an absent attachment is a fact it can report exactly; policy existence is a fact it cannot report, bundling 52 of roughly 1,200 managed policies — which is why the attach warns instead of refusing. A detach therefore requires the attachment, never the policy.
Policy documents and versions
GetPolicy returns metadata only — policy ID, name, ARN, path, default version, attachment count and dates — which is what AWS returns. The document comes from GetPolicyVersion, URL-encoded per RFC 3986, for a bundled policy and a created one alike.
Substrate stores one document per policy, so ListPolicyVersions returns exactly one version and GetPolicyVersion answers NoSuchEntity for any VersionId other than the policy's default. This is reachable immediately: the catalog reports real AWS defaults, so AmazonSSMManagedInstanceCore has DefaultVersionId: v2 and v1 does not exist here. Claiming v1 resolves when the policy reports v3 as its default would be a fabricated document. A VersionId that is not version-shaped at all is InvalidInput (400).
The seeded documents are also readable in process through emulator.GetManagedPolicy, and are what the IAM policy evaluator reads.
CloudFormation resource types
| Type | Ref | Notes |
|---|---|---|
| AWS::IAM::Role | RoleName | Supports AssumeRolePolicyDocument, ManagedPolicyArns |
| AWS::IAM::Policy | PolicyName | |
| AWS::IAM::User | UserName | |
| AWS::IAM::InstanceProfile | InstanceProfileName | Attaches each entry in Roles; resolvable by an AWS::EC2::Instance's IamInstanceProfile |
| AWS::IAM::Group | GroupName |
Cost
IAM operations are free.
STS
Endpoint: sts.amazonaws.comProtocol: AWS Query (form-encoded)
Supported operations
| Operation | Notes |
|---|---|
| GetCallerIdentity | Returns account 123456789012 by default |
| AssumeRole | Evaluates the role's trust policy, then returns temporary credentials |
| GetSessionToken | Returns stub temporary credentials |
What GetCallerIdentity reports as UserId
AWS's identifiers reference documents three forms, and names GetCallerIdentity as the way to read one:
| Caller | UserId |
|---|---|
| An IAM user | the user's unique ID, AIDA… |
| An assumed role | AROA…:<role-session-name> — the role's unique ID, then the session name the caller chose |
| The account root | the account ID |
Substrate reported the caller's friendly name until #805 — alice, and worker/sess1 for a session, which is the ARN's last two segments rather than any documented shape. It is the same value aws:userid publishes, so a policy conditioned on that key and an assertion on UserId describe one caller.
A caller who resolves to no IAM entity keeps the third row's answer or, if a credential names a user who has since been deleted, that user's name. Every caller on AWS has a unique ID, so this case is substrate's own: a name identifies the caller, where an empty member could not be told from a bug.
A role's trust policy is enforced, and sts:ExternalId with it
AssumeRole evaluates the role's AssumeRolePolicyDocument before minting a session, so a caller the trust policy does not admit is refused with AccessDenied (403). Two gates apply, and they answer different questions: the caller's own policies must allow sts:AssumeRole ("what may this caller do"), and the role's trust policy must admit the caller ("who may become this role"). Both report the same code, so a consumer cannot tell them apart by code alone — the message says which one refused.
That code follows the service's wire protocol rather than which gate refused or which service was called: AccessDenied on the XML protocols (Query, REST-XML and EC2 — so STS, IAM, CloudFormation, SNS, S3, EC2) and AccessDeniedException on the JSON ones (so SQS, DynamoDB, Lambda, SSM). That is what AWS does, and it is the same split every AWS service model shows.
That makes the confused-deputy pattern testable. A trust policy conditioning on sts:ExternalId refuses a caller who cannot present the secret:
{"Version": "2012-10-17", "Statement": [{
"Effect": "Allow",
"Principal": {"AWS": "123456789012"},
"Action": "sts:AssumeRole",
"Condition": {"StringEquals": {"sts:ExternalId": "secret-123"}}
}]}A Principal naming a bare account ID or an account-root ARN (arn:aws:iam::123456789012:root) admits any principal in that account; an exact ARN admits only that entity. ExternalId is validated at 2–1224 characters.
AWS distinguishes the two refusals only in the message, and substrate matches: "because no role trust policy allows the sts:AssumeRole action" for no matching statement, and "with an explicit deny in the role trust policy" for an explicit Deny.
Writing a trust policy is the opt-in. A role created without one — which substrate permits, though AWS's CreateRole does not — is not enforced, so a test that never wrote a trust policy is unaffected. Enforcement is also skipped for an unauthenticated caller, who resolves to no principal for a Principal element to be true of; this is the same rule described in Testing IAM permissions.
iam:UpdateAssumeRolePolicy replaces the document in place, which is how a test exercises the update-in-place shape IaC emits: assume, tighten, and be refused on the same role. The replacement takes effect on the next AssumeRole; sessions already minted under the old policy stay valid, as on AWS. GetRole reports the stored document, so a test can assert what it set.
Cost
STS operations are free.
S3
Endpoint: s3.amazonaws.com / {bucket}.s3.amazonaws.comProtocol: REST/XML
Supported operations
| Operation | Notes |
|---|---|
| CreateBucket | Stores an ACL named by x-amz-acl / x-amz-grant-* — see Access control lists |
| HeadBucket | |
| DeleteBucket | |
| ListBuckets | Honours max-buckets, continuation-token, prefix and bucket-region; reports Owner and a conditional BucketRegion — see Listing buckets |
| PutObject | Supports Content-Type, metadata headers; Cache-Control, Content-Disposition, Content-Language, Expires — see Object system metadata; Content-Encoding less any aws-chunked — see Content-Encoding and aws-chunked; x-amz-storage-class — see Storage classes; conditional writes, including a seedable 409 ConditionalRequestConflict — see Conditional requests; verifies x-amz-checksum-* — see Additional checksums; records the x-amz-server-side-encryption family — see Server-side encryption; stores an ACL named by x-amz-acl / x-amz-grant-* — see Access control lists |
| GetObject | Echoes recorded system metadata — see Object system metadata; supports Range header — see Ranged reads; preconditions — see Conditional requests; 403 InvalidObjectState on archived objects — see Storage classes; x-amz-checksum-mode — see Additional checksums; synthesizes a seedable task-completion record — see Task-completion records; echoes recorded encryption — see Server-side encryption |
| HeadObject | Echoes recorded system metadata — see Object system metadata; supports Range header — see Ranged reads; preconditions — see Conditional requests; succeeds on archived objects — see Storage classes; x-amz-checksum-mode — see Additional checksums; resolves a synthesized task-completion record exactly as GetObject does — see Task-completion records; echoes recorded encryption — see Server-side encryption |
| DeleteObject | Fires S3 notifications if configured |
| CopyObject | Honors both destination and x-amz-copy-source-if-* preconditions, including a seedable 409 ConditionalRequestConflict on the destination — see Conditional requests; x-amz-metadata-directive / x-amz-tagging-directive and storage-class transitions — see Copying objects; recomputes the checksum — see Additional checksums; records no encryption, deliberately — see Server-side encryption; takes its ACL from the copy request and never from the source — see Access control lists |
| ListObjects | Emits <StorageClass> per object |
| ListObjectsV2 | Supports Prefix, Delimiter, MaxKeys, ContinuationToken; refuses an undecodable continuation-token with 400 InvalidArgument — see A pagination token substrate never issued; emits <StorageClass> per object |
| CreateMultipartUpload | Accepts x-amz-storage-class and the system-metadata family, applied to the assembled object; Content-Encoding less any aws-chunked — see Content-Encoding and aws-chunked; x-amz-checksum-algorithm / x-amz-checksum-type — see Additional checksums; records the encryption for the whole upload — see Server-side encryption; records the ACL for the whole upload — see Access control lists |
| UploadPart | Verifies the part checksum, including a trailing one — see Additional checksums |
| UploadPartCopy | Copies an existing object, or a byte range of one, into a part — see Copying into a part |
| ListParts | Lists an upload's stored parts, with max-parts / part-number-marker paging; an upload with no parts is 200 with an empty list |
| CompleteMultipartUpload | Validates part order, ETags, and part sizes — see Multipart upload validation; conditional writes, including a seedable 409 ConditionalRequestConflict that invalidates the upload — see Conditional requests; assembles the object checksum — see Additional checksums; reports the upload's recorded encryption — see Server-side encryption; applies the upload's recorded ACL — see Access control lists |
| AbortMultipartUpload | |
| ListMultipartUploads | Emits <StorageClass> per in-progress upload |
| GetBucketPolicy | |
| PutBucketPolicy | 403 AccessDenied for a public policy when BlockPublicPolicy is set — see Block Public Access |
| DeleteBucketPolicy | |
| PutPublicAccessBlock | Records the configuration and enforces BlockPublicAcls / BlockPublicPolicy; a partial body reports omitted settings as false — see Block Public Access |
| GetPublicAccessBlock | 404 NoSuchPublicAccessBlockConfiguration when the bucket has none — see Block Public Access |
| DeletePublicAccessBlock | Idempotent; removes only the configuration, never the bucket — see Block Public Access |
| GetBucketAcl | Reports the stored ACL, or the default owner-only one — see Access control lists |
| PutBucketAcl | Accepts an XML body, a canned x-amz-acl or the x-amz-grant-* family — see Access control lists; 403 AccessDenied for a public ACL when BlockPublicAcls is set — see Block Public Access |
| GetObjectAcl | Reports the stored ACL, or the default owner-only one — see Access control lists |
| PutObjectAcl | Accepts an XML body, a canned x-amz-acl or the x-amz-grant-* family — see Access control lists; 403 AccessDenied for a public ACL when the bucket has BlockPublicAcls set — see Block Public Access |
| GetBucketNotificationConfiguration | Reports the stored configuration in the API's element names; an unconfigured bucket is an empty NotificationConfiguration — see Event notifications |
| PutBucketNotificationConfiguration | Dispatches to Lambda, SQS and SNS on PutObject/DeleteObject; a body naming no recognized element is 400 MalformedXML — see Event notifications |
| PutBucketTagging | |
| GetBucketTagging | Reports the bucket's TagSet sorted by key — see A tag set read back out of a map |
| DeleteBucketTagging | |
| PutObjectTagging | |
| GetObjectTagging | Reports the object's TagSet sorted by key — see A tag set read back out of a map |
| DeleteObjectTagging |
Listing buckets
ListBuckets honours all four of its query parameters (#884). It previously honored none of them, which mattered most for the paging pair: a dropped max-buckets is invisible, because a short page and a complete listing are the same shape, and a dropped continuation-token returns page one forever.
| Parameter | Behaviour |
|---|---|
max-buckets | Bounds the page. Valid range 1–10000, per the published Valid Range; the default is 10000. A value outside the range, or one that is not an integer, is refused with 400 InvalidArgument rather than clamped. |
continuation-token | Pages over the bucket-name order. Base64 of the last bucket returned, matching what ListObjectsV2 emits — AWS says only that the token "is obfuscated and is not a real key". Refused with 400 InvalidArgument if it is not decodable or exceeds the documented 1024-character ceiling. |
prefix | Filters by bucket-name prefix, and is echoed back as Prefix when sent. |
bucket-region | Filters by the Region the bucket was created in, which CreateBucket has always recorded. |
The parameters compose: a request naming a prefix, a Region and a page size narrows by all three.
ContinuationToken is the next page's token, not an echo of the request's. This is an asymmetry with substrate's other S3 listings and it is AWS's, not substrate's: ListBuckets publishes no NextContinuationToken at all and reuses the one name for the forward cursor, where ListObjectsV2 publishes both. The element appears only when the listing was truncated, so a caller loops until it is absent. A listing whose last page exactly fills max-buckets carries no token, because AWS ties the token to there being "more buckets that can be listed" rather than to a full page.
The order is lexicographic by bucket name, and that is substrate's reading — see The order a listing returns its members in. Pagination is why the order has to be guaranteed rather than merely tidy: a cursor over an unstable order both omits and repeats members between pages.
Owner is reported unconditionally, and its ID is the account ID. Substrate has no account-owner concept beyond the account ID, so that is the only account-scoped identifier it holds; real S3 returns a 64-character hex canonical user ID unrelated to the account number, so a consumer must not read the value as one. DisplayName is omitted — it carries no description on the Owner type and none of AWS's five published examples renders it.
BucketRegion is conditional on the request naming at least one parameter, quoting the Bucket type: "If the request contains at least one valid parameter, it is included in the response." An unparameterised listing therefore reports Name and CreationDate only, which is what AWS's own unpaginated example shows.
BucketArn is never reported, deliberately. The Bucket type says it "is only supported for S3 directory buckets" and ListBuckets "is not supported for directory buckets", so no ListBuckets response AWS produces carries one. Synthesizing a general purpose bucket ARN would hand a consumer a field it could read here and never against AWS.
InvalidArgument is substrate's reading of the error code, not a sourced one: API_ListBuckets publishes no Errors section, and the S3 error-code reference could not be retrieved to confirm what AWS returns for an out-of-range max-buckets. Refusing rather than clamping is the deliberate choice, on the same reasoning as the defect itself — silently substituting a value the caller did not ask for is invisible in a well-formed response.
The documented restriction that "Requests made to a Regional endpoint that is different from the bucket-region parameter are not supported" is not enforced. AWS names no error code for it, so refusing would mean inventing one, and substrate's endpoint is not Regional in the way that rule presumes.
Storage classes
PutObject, CopyObject and CreateMultipartUpload accept x-amz-storage-class and record it on the object. An absent header means STANDARD, S3's documented default for a newly created object. All thirteen documented values are accepted:
STANDARD REDUCED_REDUNDANCY STANDARD_IA ONEZONE_IA INTELLIGENT_TIERING
GLACIER DEEP_ARCHIVE OUTPOSTS GLACIER_IR SNOW EXPRESS_ONEZONE
FSX_OPENZFS FSX_ONTAPAny other value — including a lowercase or whitespace-padded one — is 400 InvalidStorageClass, rejected before anything is written, so the key does not appear. The classes reachable only through Outposts, Snow, Express One Zone and the FSx-backed tiers are accepted but carry no distinct behaviour beyond being recorded.
How the class is reported back differs between the header and the XML, which is easy to get wrong in both directions:
| Surface | STANDARD | Every other class |
|---|---|---|
x-amz-storage-class response header on GetObject/HeadObject | Omitted | Present |
<StorageClass> in ListObjects, ListObjectsV2, ListObjectVersions, ListMultipartUploads | STANDARD | The class |
An absent header therefore means STANDARD, not "unknown". A <DeleteMarker> entry in ListObjectVersions carries no <StorageClass>, matching S3's response shape.
Archived objects. A GetObject of a GLACIER or DEEP_ARCHIVE object is 403 InvalidObjectState with the message The action is not valid for the object's storage class, and so is a CopyObject that names one as its source — S3 requires a restore first. The check precedes the Range step, so a ranged read of an archived object is the same 403, not a 206.
GLACIER_IR is not archival. It is the instant-retrieval tier and reads normally; so do STANDARD_IA, ONEZONE_IA and INTELLIGENT_TIERING.
HeadObject of an archived object is a 200, not a 403. The HeadObject reference documents no InvalidObjectState and states that "even if the object is stored in S3 Glacier, all object metadata is still available" — which is what makes HEAD the way a consumer discovers that a GET would need a restore first. A test asserting 403 on HEAD is asserting behaviour real S3 does not have.
RestoreObject and the x-amz-restore response header are not implemented, so an archived object stays unreadable for the lifetime of the emulator run. Restoring is modeled by copying the object to a non-archival class.
Intelligent-Tiering archive access tiers are not modeled, so the InvalidObjectState variant carrying <StorageClass> and <AccessTier> children is never returned.
Content-Encoding and aws-chunked
PutObject and CreateMultipartUpload record Content-Encoding on the object and GetObject/HeadObject echo it back. The aws-chunked token is stripped before the value is recorded, on both write paths:
Request Content-Encoding | Recorded, and returned on a read |
|---|---|
gzip | gzip |
| absent | absent — no header on the response |
aws-chunked | absent |
aws-chunked, gzip | gzip |
gzip, aws-chunked | gzip |
aws-chunked is a transfer encoding: it describes the chunk-signature framing a SigV4 streaming upload arrived in, which substrate decodes before storing the body (see Additional checksums for the trailer that framing carries). The bytes at rest are plain, and the API reference defines Content-Encoding as "what content encodings have been applied to the object and thus what decoding mechanisms must be applied" — so persisting aws-chunked would hand a consumer a codec name for content that needs no decoding. PutObject does not document it as persisted metadata, and CreateMultipartUpload scopes that value to directory buckets.
A genuine codec alongside it is kept, in order, because an SDK streaming a compressed body sends both tokens and dropping the header wholesale would lose the codec that is applied to the stored object.
The header name is matched case-insensitively on both paths.
Object system metadata
Cache-Control, Content-Disposition, Content-Language and Expires are recorded on write and returned on every read, on all three write paths — PutObject, CreateMultipartUpload → CompleteMultipartUpload, and CopyObject. Substrate previously accepted them and discarded them, so a test asserting "the download filename survives an upload" passed while verifying nothing.
They are stored verbatim and never interpreted: substrate does not evaluate a Cache-Control lifetime, parse a Content-Disposition filename, or apply an Expires date to anything. What is modeled is the observation — that a read reports what the write set.
An absent header is absent on the response, not empty. Cache-Control: and no Cache-Control are different observations, and an SDK distinguishing nil from "" would otherwise report the wrong one.
Expires is a string, never a parsed date. A malformed value round-trips unchanged rather than being normalized or dropped. Real S3 stores and returns what the caller sent, and the Go SDK's own GetObject output deprecates its time.TimeExpires in favour of ExpiresString — "the unparsed value of the Expires field from the service response". Parsing here would be lower fidelity, and would make a consumer's parse-failure branch unreachable.
Content-Type, Content-Encoding and the storage class are user-controlled system metadata too, but each has resolution rules of its own — a default of application/octet-stream, aws-chunked filtering, and a STANDARD-means-absent read rule — so they are documented in their own sections above.
Server-side encryption
Three headers are recorded on write and returned on every read:
| Header | Recorded | Echoed |
|---|---|---|
x-amz-server-side-encryption | Verbatim — AES256, aws:fsx, aws:kms, aws:kms:dsse, or any other token | Whenever set |
x-amz-server-side-encryption-aws-kms-key-id | Verbatim, in whichever form was sent | Only alongside an algorithm, and only when a key was named |
x-amz-server-side-encryption-bucket-key-enabled | As a boolean; only true (any case) enables it | Only when enabled |
No cryptography is performed. The object body is stored exactly as it arrived. Encryption at rest is not observable through an API call, but the encryption S3 reports for an object is — and that report is the assertion a consumer is making. Substrate previously accepted these headers and discarded them, so an object written with encryption read back byte-identical to one written without it: a test could only assert on what its own request carried, which proves the line that filled in the request and nothing about the stored object.
They are recorded on PutObject and on CreateMultipartUpload, and echoed on those two responses plus GetObject, HeadObject and CompleteMultipartUpload. CreateMultipartUpload is the only place a multipart upload's encryption can be supplied — Complete's request accepts only the SSE-C headers — so it is fixed for the whole upload at creation and carried onto the assembled object.
An absent header is absent on the response, not false or empty. A write that never mentioned the bucket-key header produces no bucket-key header, since an SDK distinguishing a nil *bool from a false one would otherwise report the wrong answer. The same rule keeps "no encryption named" distinguishable from "encryption named", which is the observation that makes recording worth anything.
The KMS key ID round-trips verbatim, which is a deliberate divergence. KMS accepts four forms — a bare UUID, alias/name, a key ARN and an alias ARN — and real S3 resolves any of them to the key ARN before reporting it. Substrate returns the string the caller sent, because that is the string the consumer's configuration produced and therefore the assertion they are trying to make. Resolving it would mean modeling KMS aliases and cross-account ARNs to answer a question no consumer has asked. The difference is observable: if key resolution is ever modeled, this decision has to be revisited rather than silently overtaken.
Nothing is validated. A key ID sent with AES256, a bucket-key flag without aws:kms, and an unrecognized algorithm token are all accepted and recorded, where real S3 answers 400 InvalidArgument; UploadPart restating an encryption header is likewise accepted rather than refused. Those four rejections are #493.
Also out of scope there, and worth knowing before relying on this:
- Bucket default encryption.
PutBucketEncryptionand its siblings are not modeled, so a write naming no encryption records none. Real S3 has applied SSE-S3 to every new object unconditionally since January 2023, so a real bucket never stores an unencrypted object — modeling that default would remove the absent-versus-set distinction above, which is why it is a deliberate decision rather than a side effect. CopyObjectrecords no encryption at all. A copy's encryption comes from the request and, failing that, from the bucket default — never from the source. Neither exists yet, so substrate reports none for a copy rather than inheriting the source's. That is a stated gap, not a wrong answer: silently inheriting would hide exactly the bug this half exists to expose, where an in-place metadata copy or a storage-tier transition moves an SSE-KMS object off its customer managed key.- SSE-C (
x-amz-server-side-encryption-customer-*) is out of scope entirely; its key material would have to be discarded rather than recorded.
Copying objects
CopyObject's metadata behaviour is governed by two independent directives, both defaulting to COPY when absent. An unrecognized value on either is 400 InvalidArgument rather than a silent fall back to the default — a typo that quietly preserved metadata is the kind of false success this emulator exists to surface.
x-amz-metadata-directive | Destination Content-Type, Content-Encoding, Cache-Control, Content-Disposition, Content-Language, Expires, x-amz-meta-* |
|---|---|
COPY (default) | Taken from the source; headers restated on the request are ignored |
REPLACE | Taken from the request; anything not restated is dropped |
COPY preserving Content-Encoding is the documented behaviour: "when you copy an object, user-controlled system metadata and user-defined metadata are also copied", and Content-Type, Content-Encoding, Content-Disposition and Cache-Control are all user-controlled. Only x-amz-website-redirect-location is documented as not copied, and substrate does not model it.
The loss case is REPLACE: "you must explicitly specify all of the user-configurable metadata present on the source object in your request, even if you are changing only one of the metadata values". A REPLACE that omits Content-Encoding drops it, and Content-Type falls back to application/octet-stream.
The one directive governs the whole family. S3 documents no per-header variant of x-amz-metadata-directive, so a REPLACE restating only Content-Type also drops the Content-Disposition download name and the Cache-Control lifetime the source carried. Each header is independently restatable, but each must actually be restated.
CopyObject applies no aws-chunked filtering of its own and does not need to: under COPY it inherits the source object's already-filtered value, and a copy request carries no body, so no SDK sends a transfer encoding on it.
x-amz-tagging-directive works the same way for the tag-set: COPY (the default) carries the source's tags, REPLACE takes them from x-amz-tagging as URL query parameters (stage=prod&owner=alice), defaulting to an empty tag-set when that header is absent.
Storage class is never inherited. "If the x-amz-storage-class header is not used, the copied object will be stored in the STANDARD Storage Class by default" — so an unqualified copy of a STANDARD_IA object yields a STANDARD one. This is what makes an in-place CopyObject onto an object's own key with a new x-amz-storage-class the tier-transition mechanism, and it is also the trap: a transition that means to change only the class must restate the metadata it wants to keep if it uses REPLACE.
Every request-derived value — storage class, both directives, both precondition sets — is resolved before the first write, so a rejected copy leaves the destination untouched.
Ranged reads
GetObject and HeadObject honor a single-range Range header, returning 206 Partial Content with Content-Range and a Content-Length equal to the range served. Both advertise Accept-Ranges: bytes. HeadObject returns the same status and headers with no body.
The edge cases matter more than the happy path, because S3 does not report an error for most bad ranges — a caller cannot use a 416 to detect a malformed request:
Range (1000-byte object) | Result |
|---|---|
bytes=0-99 | 206, Content-Range: bytes 0-99/1000 |
bytes=900- | 206, bytes 900-999/1000 |
bytes=-100 | 206, bytes 900-999/1000 (suffix range) |
bytes=0-99999 | 206, clamped to bytes 0-999/1000 — past EOF is not an error |
bytes=1000-1099 | 416 InvalidRange with Content-Range: bytes */1000 |
bytes=-0 | 416 InvalidRange — a zero-length suffix is unsatisfiable |
bytes=abc, bytes=500-100 | 200 with the whole object — malformed ranges are ignored |
bytes=0-99,200-299 | 200 with the whole object — S3 serves only one range per GET |
items=0-99 | 200 with the whole object — units other than bytes are ignored |
Every range against a zero-byte object is unsatisfiable. A 416 body carries <ActualObjectSize> and <RangeRequested> so a caller can correct the request without a second round trip. Ranges compose with versionId.
Retrieving a specific part by ?partNumber=N is not implemented.
Conditional requests
Conditional writes
PutObject, CopyObject and CompleteMultipartUpload honor If-None-Match and If-Match, evaluated against the current version of the destination key:
| Header | Destination state | Result |
|---|---|---|
If-None-Match: * | Key absent | 200 — the write proceeds |
If-None-Match: * | Key present | 412 PreconditionFailed |
If-None-Match: * | Current version is a delete marker | 200 — a delete marker is not an object |
If-None-Match: <anything but *> | Any | 412 PreconditionFailed — S3 expects only * on a write |
If-Match: "<etag>" | ETag matches | 200 — the write proceeds |
If-Match: "<etag>" | ETag differs | 412 PreconditionFailed |
If-Match: "<etag>" | Key absent, or the current version is a delete marker | 404 NoSuchKey |
| either header | A conflict is seeded on the key | 409 ConditionalRequestConflict |
A rejected conditional write is a no-op: the stored object is byte-identical afterwards — body, ETag, size, Content-Type and user metadata all unchanged — and a 412-rejected CompleteMultipartUpload additionally leaves its upload open to be retried or aborted. A 409-rejected one does not; see below.
Concurrency. N concurrent If-None-Match: * writes to one key yield exactly one 200 and N-1 412s; the same holds for N concurrent If-Match writes asserting the same ETag, which is the compare-and-swap primitive optimistic locking needs. This guarantee is process-local. Substrate implements it with a per-key mutex held across the existence check and the write, because StateManager exposes no compare-and-swap; it therefore holds for any number of goroutines or HTTP clients against one emulator process, but would not hold across two emulator processes sharing one state backend.
Seeding ConditionalRequestConflict
S3 returns 409 ConditionalRequestConflict when a concurrent operation — in the documented case, a delete — interferes with a conditional write between its evaluation and its completion. It is a timing accident rather than a state a request can assert, so substrate cannot derive it the way it derives a 412 from the current object. Seeding is what makes the branch reachable.
The branch matters because the three outcomes select different recovery paths, and they are not interchangeable:
| Outcome | What it means | The recovery AWS documents |
|---|---|---|
412 PreconditionFailed | Another writer won the race | Re-read, recompute, retry the compare-and-swap |
404 NoSuchKey on If-Match | The object is gone | Re-upload rather than retry the CAS |
409 on PutObject / CopyObject | A delete interleaved | Retry the request as-is (for If-Match, fetch the current ETag first) |
409 on CompleteMultipartUpload | A delete interleaved | Abandon the upload ID — re-do CreateMultipartUpload and re-upload every part |
A compare-and-swap loop that answers the last case like the first re-sends CompleteMultipartUpload with an upload ID that can never complete again, and spins until it gives up. Substrate models that consequence: consuming a seeded multipart conflict invalidates the upload, so a same-ID retry gets 404 NoSuchUpload and ListParts on it is gone too. That inference is substrate's — AWS documents the recovery advice, not the ID's fate — but without it the broken loop passes.
# The next conditional PutObject on cond/k reports ConditionalRequestConflict.
curl -X POST http://localhost:4566/v1/s3/conditional-conflict \
-d '{"bucket":"cond","key":"k","putConflicts":1}'
# CopyObject (evaluated against the destination key) and
# CompleteMultipartUpload have their own independent counters.
curl -X POST http://localhost:4566/v1/s3/conditional-conflict \
-d '{"bucket":"cond","key":"dst","copyConflicts":1}'
curl -X POST http://localhost:4566/v1/s3/conditional-conflict \
-d '{"bucket":"cond","key":"big","completeConflicts":1}'
# Apply to any key (wildcard).
curl -X POST http://localhost:4566/v1/s3/conditional-conflict -d '{"putConflicts":3}'
# Clear one, or all.
curl -X DELETE 'http://localhost:4566/v1/s3/conditional-conflict?bucket=cond&key=k'
curl -X DELETE http://localhost:4566/v1/s3/conditional-conflictbucket and key must be given together; supplying one alone is a 400, because such a seed would be stored under a key no write can match — it would look armed and never fire. A key-scoped seed is consulted before the wildcard, and when it is exhausted the write falls through to the wildcard, so a spent key-scoped seed does not mask a wildcard that still has budget.
Conflicts are counted in occurrences, not measured as a duration, for the same reason as the SQS consistency window: substrate's simulated clock advances with wall time from its baseline, so a duration-based window would expire partway through a test and make assertions wall-clock dependent. A counter is exactly reproducible.
Two ordering rules make the seed usable from a harness:
- A conflict is consumed only after the preconditions pass. A
412or404is a determinate observation of the destination's current state and is reported as itself rather than replaced by a seeded race — and it does not spend the budget, so a request that was going to fail anyway cannot silently consume what the test armed for the conflict. - An unconditional write to a seeded key is untouched and spends nothing. AWS documents this code only on the
If-MatchandIf-None-Matchmembers, so a plainPutObjectnever reports it.
The code and the 409 status are documented in those member docs and in the S3 user guide's conditional-writes page; no message text is documented anywhere, and the API model carries no ConditionalRequestConflict shape, so substrate's message is its own. Assert on the code and the status.
The 404 real S3 can return when a concurrent delete lands mid-write is still not modeled as a race: substrate reaches that outcome deterministically, from an If-Match against a key that is genuinely absent (the row above).
Conditional reads
GetObject and HeadObject honor all four RFC 9110 preconditions. They are evaluated before the Range header, so a failed precondition is reported rather than a partial response served:
| Header | Evaluation | Result |
|---|---|---|
If-None-Match | Matches the object's ETag (or is *) | 304 Not Modified, no body, ETag echoed |
If-None-Match | Does not match | 200 |
If-Match | Matches (or is *) | 200 |
If-Match | Does not match | 412 PreconditionFailed |
If-Modified-Since | Object not modified since the date | 304 Not Modified |
If-Unmodified-Since | Object modified since the date | 412 PreconditionFailed |
Two combination rules from the GetObject reference are implemented, both of which stop a coarse date condition from overriding an exact entity assertion:
If-Matchtrue andIf-Unmodified-Sincefalse →200, not412.If-None-Matchfalse andIf-Modified-Sincetrue →304, not200.
A precondition against an absent key is still 404 NoSuchKey — there is no ETag to compare. An unparseable date makes its condition inapplicable rather than failed (per RFC 9110), so a malformed date never produces a spurious 412. The three date formats RFC 9110 requires a recipient to accept are all parsed. An empty header value is a condition that cannot be met, distinct from an absent header.
ETag comparison ignores surrounding quotes, W/ weak-validator prefixes, hex case and whitespace, and a comma-separated list matches if any member does. Header names are matched case-insensitively.
Conditional copies
CopyObject carries two independent sets: the unprefixed headers above gate overwriting the destination, while x-amz-copy-source-if-match, x-amz-copy-source-if-none-match, x-amz-copy-source-if-modified-since and x-amz-copy-source-if-unmodified-since gate reading the source. Both are evaluated before anything is written, so a rejected copy leaves the destination untouched.
Every failed copy-source condition is a 412, including the case where the equivalent GetObject would be a 304: there is no cached entity for a server-side copy to revalidate against.
Multipart upload validation
CompleteMultipartUpload validates the parts list before assembling anything, so the failure paths a consumer's retry and cleanup code exists to handle are reachable:
| Condition | Result |
|---|---|
| A part other than the highest-numbered one is under 5 MB (5,242,880 bytes) | 400 EntityTooSmall |
| A referenced part was never uploaded | 400 InvalidPart |
A supplied ETag does not match the stored part | 400 InvalidPart |
| Part numbers not strictly ascending (including duplicates) | 400 InvalidPartOrder |
No Part elements, or a body that does not parse | 400 MalformedXML |
uploadId unknown, or already completed or aborted | 404 NoSuchUpload |
uploadId valid but for a different bucket or key | 404 NoSuchUpload |
The final part may be any size, including zero, and a single-part upload is exempt from the minimum entirely. Supplied ETags are compared ignoring surrounding quotes, hex case, and whitespace, since clients differ on whether they echo back the quotes S3 sends.
A rejected CompleteMultipartUpload writes nothing: no object appears at the key, and the upload stays open — ListMultipartUploads still reports it until AbortMultipartUpload (or a successful Complete) ends it. That makes "no orphan upload was left behind" a property a test can assert by observing the emulator.
The EntityTooSmall body identifies the offending part:
<Error>
<Code>EntityTooSmall</Code>
<Message>Your proposed upload is smaller than the minimum allowed object size. Each part must be at least 5 MB in size, except the last part.</Message>
<RequestId>SUBSTRATE</RequestId>
<ETag>b6d81b360a5672d80c27430f39153e2c</ETag>
<MinSizeAllowed>5242880</MinSizeAllowed>
<ProposedSize>1024</ProposedSize>
<PartNumber>1</PartNumber>
</Error>Copying into a part
UploadPartCopy — a PUT to the destination key carrying partNumber, uploadIdand x-amz-copy-source — copies an existing object into a part of an open upload. What distinguishes it from CopyObject is where the bytes land: the destination key stays absent until CompleteMultipartUpload assembles it. A HeadObject on the destination mid-upload is a 404, and ListParts is the only place the copied bytes are observable. Copied and uploaded parts mix freely in one upload, and a copied part's checksum is computed under the upload's algorithm, so CompleteMultipartUpload still assembles a COMPOSITE object checksum over the mixture.
x-amz-copy-source-range selects a byte range of the source. Unlike a GET's Range header — which S3 treats as advisory, ignoring a malformed value and clamping one that runs past the end — a copy-source range is part of the request's meaning, so substrate refuses a range it cannot honor rather than silently copying different bytes:
| Condition | Result |
|---|---|
bytes=first-last, both offsets within the source | that range is copied |
| No range header | the whole source object is copied |
Malformed, or missing either offset (bytes=0-, bytes=-9) | 400 InvalidArgument |
last at or beyond the source's size | 400 InvalidArgument |
| Any range against a source of 5 MB or less | 400 InvalidRequest — the reference's documented special error, since "you can copy a range only if the source object is greater than 5 MB" |
uploadId unknown, or for a different bucket or key | 404 NoSuchUpload |
| Copy source does not exist | 404 NoSuchKey |
The x-amz-copy-source-if-* preconditions gate reading the source and answer 412 PreconditionFailed on failure. There are no destination preconditions, since there is no destination object yet.
Metadata carried from CreateMultipartUpload
CompleteMultipartUpload accepts no object-metadata headers — per the AWS API reference it takes only the checksum family, x-amz-mp-object-size, request-payer, SSE-C and the conditional headers. So anything describing the finished object must be supplied at CreateMultipartUpload and is carried on the upload until the object is assembled:
| Supplied at Create | Applied to the assembled object |
|---|---|
Content-Type | yes (defaults to application/octet-stream) |
Content-Encoding | yes, less any aws-chunked token — see Content-Encoding and aws-chunked |
Cache-Control | yes — see Object system metadata |
Content-Disposition | yes |
Content-Language | yes |
Expires | yes, stored verbatim |
x-amz-storage-class | yes (empty means STANDARD) |
x-amz-checksum-algorithm | yes — see Additional checksums |
x-amz-meta-* | yes |
Setting one of these at Complete instead has no effect — the reference lists no object-metadata header there, so substrate ignores them rather than applying them late.
Additional checksums
PutObject, UploadPart, CopyObject, CreateMultipartUpload and CompleteMultipartUpload honor the x-amz-checksum-* family, and verify any value the caller supplies. A wrong value is 400 BadDigest and nothing is written — the object does not appear at the key, and a rejected UploadPart leaves no part for a later Complete to pick up.
All ten documented algorithms are recognized. Seven are computed and verified:
| Algorithm | Header | FULL_OBJECT | COMPOSITE |
|---|---|---|---|
CRC32 | x-amz-checksum-crc32 | yes | yes |
CRC32C | x-amz-checksum-crc32c | yes | yes |
CRC64NVME | x-amz-checksum-crc64nvme | yes | no |
SHA1 | x-amz-checksum-sha1 | no | yes |
SHA256 | x-amz-checksum-sha256 | no | yes |
SHA512 | x-amz-checksum-sha512 | no | yes |
MD5 | x-amz-checksum-md5 | no | yes |
XXHASH64, XXHASH3 and XXHASH128 are recognized but answered with 501 NotImplemented, because substrate has no implementation to check a supplied value against. That is deliberate: storing a checksum nobody verified would make a consumer's test pass on data real S3 would have rejected, which is the failure this section exists to prevent. An algorithm name outside all ten is 400 InvalidRequest.
Three request shapes are honored on a write:
| Request | Behavior |
|---|---|
x-amz-checksum-<alg>: <base64> | Verified against the body; mismatch is 400 BadDigest |
x-amz-sdk-checksum-algorithm: <NAME> alone | Substrate computes and records the digest |
| Both, naming different algorithms | 400 BadDigest |
Two different x-amz-checksum-* headers | 400 InvalidRequest, "Multiple checksum Types are not allowed" |
| A base64 value of the wrong width for the algorithm | 400 InvalidRequest, distinct from BadDigest |
Trailing checksums are read. When the body is aws-chunked and x-amz-trailer names a checksum header, the value is taken from the trailer that follows the completion chunk — which is where every AWS SDK puts the checksum of a streamed upload. A trailer whose name differs from what x-amz-trailer declared is 400 MalformedTrailerError; so is a declared trailer that never arrives.
Reading a checksum back requires x-amz-checksum-mode: ENABLED on GetObject or HeadObject. Without it the response carries no x-amz-checksum-* header and no x-amz-checksum-type, so the absence is observable. A ranged GET returns the whole object's checksum, not the range's.
Multipart. CreateMultipartUpload takes x-amz-checksum-algorithm (not the x-amz-sdk- form) and an optional x-amz-checksum-type, echoing both back. An absent type defaults to COMPOSITE, except for CRC64NVME, which has no composite form and defaults to FULL_OBJECT. An unsupported algorithm/type pairing is 400 InvalidRequest at creation, before any part is uploaded. A part supplying a checksum under a different algorithm than the upload's is 400 InvalidRequest.
CompleteMultipartUpload returns the object checksum as an XML element, not a header:
<CompleteMultipartUploadResult>
<Location>/bucket/key</Location>
<Bucket>bucket</Bucket>
<Key>key</Key>
<ETag>"b6d81b360a5672d80c27430f39153e2c-2"</ETag>
<ChecksumCRC32>Zm9vYmFy-2</ChecksumCRC32>
<ChecksumType>COMPOSITE</ChecksumType>
</CompleteMultipartUploadResult>A COMPOSITE value is the digest of the concatenated raw part digests with a -<part count> suffix; a FULL_OBJECT value is the digest of every byte of the assembled object, with no suffix. For the same bytes the two differ, which is the point of distinguishing them. A FULL_OBJECT multipart checksum equals what a single-part PutObject of the same bytes produces — a property a test can assert. Complete also verifies a whole-object checksum supplied on the request itself, and rejects an x-amz-checksum-type that disagrees with the upload's.
CopyObject recomputes. The destination's checksum is always a direct full-object checksum of the copied bytes, under the source's algorithm unless the copy names a new one. Copying a COMPOSITE multipart object therefore changes both the value and the type even though the data is identical, matching S3.
One deliberate divergence. Real S3 attaches a default CRC64NVME checksum to every object uploaded without one, so a checksum-mode GET always returns something. Substrate records no checksum in that case. Synthesizing one would make a round-trip assertion pass whether or not the consumer's writer actually sends a checksum — the exact defect this support was added to expose. An absent checksum in substrate means "your writer sent none".
Task-completion records
A read of tasks/<task_id>/completion.json on any bucket resolves to a synthesized spore.host task-completion record when no real object exists at that key. Substrate does not run the task — this is the seedable completion observation only, so a consumer's poll-until-done loop can be exercised instantly and reproducibly.
Absent a seed the key resolves to the nominal success record, so the happy path needs no setup:
$ aws s3api get-object --bucket results --key tasks/t1/completion.json /dev/stdout
{"task_id":"t1","exit_code":0,"state":"completed","started_at":"…","ended_at":"…"}Seed an alternate outcome — a non-zero exit, a failed state, or a completion time in the simulated future:
POST /v1/spawn/task-completion {"task_id","exit_code","state","started_at","ended_at"}
DELETE /v1/spawn/task-completion?taskId=<id> (or with no query, clear all)ended_at gates presence on the simulated clock. Before that time the record reads as absent — 404 NoSuchKey — which is the "still running" observation a poll loop needs in order to loop at all. After it, the record is served.
HeadObject resolves the record exactly as GetObject does, reporting the same Content-Length, ETag, Content-Type and Last-Modified, and honoring the same clock gate. This matters because aws s3 cp and aws s3 sync HEAD before they GET, so a HEAD that 404'd made the record unreadable through the CLI even though the GET worked; an SDK HeadObject existence poll had the same problem in the worse direction, since absence reads as "still running" and the loop never terminates.
A real object always wins. Staging an actual object at the completion key serves it verbatim; the resolver only runs when the key is absent. A read naming an explicit versionId never resolves either, since a synthesized record has no version history. Both reads and the resolver's response path are otherwise ordinary, so ranged and conditional reads apply to a synthesized record as to any other object.
ListObjectsV2 deliberately does not enumerate synthesized records. A keyed read works because the caller names the task, so the resolver has something to answer about. A list is unkeyed, and substrate cannot enumerate the set of task IDs a consumer might ask about — a list that invented entries would be a wrong answer, not a more complete one. This asymmetry is a decision, not an oversight.
Block Public Access
The ?publicAccessBlock subresource is addressed as a bare query key on the bucket, which is why it needs explicit routing: an unrouted DELETE /bucket?publicAccessBlock is indistinguishable from DeleteBucket.
PUT /bucket?publicAccessBlock → 200, empty body
GET /bucket?publicAccessBlock → 200 + PublicAccessBlockConfiguration, or 404
DELETE /bucket?publicAccessBlock → 204All four settings are always reported. BlockPublicAcls, IgnorePublicAcls, BlockPublicPolicy and RestrictPublicBuckets are each optional on the request, and every one a PUT omits is recorded — and reported back — as false, matching S3. PutPublicAccessBlock replaces the whole document rather than merging into it, so a second call naming fewer settings clears the rest.
An unconfigured bucket is a 404, not an all-false 200. A bucket that has never been the subject of a PutPublicAccessBlock returns 404 NoSuchPublicAccessBlockConfiguration. The two states are deliberately distinguishable: an all-false configuration a consumer wrote on purpose is a 200 carrying four false elements. Reporting the unset case as all-false would tell a caller "public access is not blocked" where AWS says "nothing is configured".
Substrate does not apply S3's April 2023 default. Real S3 enables all four settings on buckets newly created through the API, CLI, SDKs or CloudFormation. In substrate a new bucket has no configuration at all. That default is a property of AWS-managed account and organization state substrate does not model, and seeding every bucket with a configuration would make the NoSuchPublicAccessBlockConfiguration path — the branch a consumer's error handling exists for — unreachable through the public API. Call PutPublicAccessBlock to get a configured bucket, which is what the SDKs and CloudFormation both do.
DeletePublicAccessBlock is idempotent and touches nothing but the configuration. Deleting one that was never written is a 204, which is what a teardown path that deletes unconditionally relies on.
BlockPublicAcls and BlockPublicPolicy are enforced at request time. A bucket carrying either setting refuses the call that would make it public:
| Setting | Refuses | Response |
|---|---|---|
BlockPublicAcls | PutBucketAcl, PutObjectAcl with a public ACL | 403 AccessDenied / Access Denied |
BlockPublicAcls | PutObject, CopyObject, CreateMultipartUpload whose request includes a public ACL | 403 AccessDenied / Access Denied |
BlockPublicPolicy | PutBucketPolicy with a public policy | 403 AccessDenied / Access Denied |
The three create operations are their own bullet on the setting — "PUT Object calls fail if the request includes a public ACL" — and were unenforceable until substrate read an ACL from a create at all. The refusal precedes every write, so a refused PutObject stores no object, a refused CopyObject stores no destination object, and a refused CreateMultipartUpload leaves no upload ID behind; an overwrite refused this way leaves the object that was already at the key untouched, body and ACL both. The configuration consulted on a copy is the destination bucket's, since that is where the object lands.
CreateBucket is the documented case substrate does not refuse; see below.
A rejection stores nothing: the bucket or object keeps the ACL or policy it already had, matching "existing policies and ACLs for buckets and objects aren't modified". Deleting the configuration re-allows what it was refusing, per "removing a block public access setting causes a bucket or object with a public policy or ACL to again be publicly accessible". The configuration read for PutObjectAcl is the bucket's — "Amazon S3 doesn't support block public access settings on a per-object basis".
Neither operation documents an Errors section covering this, so the AccessDenied / Access Denied / 403 triple comes from observed real-AWS behaviour rather than from the API model: a blocked PutBucketPolicy surfaces through the CLI as An error occurred (AccessDenied) when calling the PutBucketPolicy operation: Access Denied.
A public ACL is one that grants any permission to a predefined public group. Substrate matches the grantee URI against http://acs.amazonaws.com/groups/global/AllUsers and .../AuthenticatedUsers, per "Amazon S3 considers a bucket or object ACL public if it grants any permissions to members of the predefined AllUsers or AuthenticatedUsers groups". AuthenticatedUsers is every AWS account, not every account in yours, which is why it counts despite the name. The permission itself is not inspected — READ, WRITE, READ_ACP, WRITE_ACP and FULL_CONTROL all count. All three ways a public ACL arrives are covered: the x-amz-acl canned header (public-read, public-read-write, authenticated-read), an XML Grant naming a public group URI, and an x-amz-grant-* header whose grantee list contains one. See Access control lists for how each form resolves.
CreateBucket with a public ACL is accepted, and that is a stated gap. The setting's third bullet says "PUT Bucket calls fail if the request includes a public ACL", but the configuration that refuses such a call is the account-level one — a bucket-level configuration cannot exist before the bucket does. Substrate models no account-level Block Public Access, so gating this one operation would mean modeling a control the emulator does not otherwise have. A bucket created with --acl public-read therefore succeeds and reports the public grant, and the next PutBucketAcl on it is subject to whatever configuration has since been written.
A public policy is decided by assuming public and then trying to disqualify — not by looking for Principal: "*". This is stronger than wildcard-detection and is the part a naive implementation gets backwards. Per "When evaluating a bucket policy, Amazon S3 begins by assuming that the policy is public. It then evaluates the policy to determine whether it qualifies as non-public", a statement is non-public only when it grants access solely to fixed values — no *, no ?, no ${...} IAM policy variable — either through its Principal or through a Condition on one of aws:SourceIp, aws:SourceArn, aws:SourceVpc, aws:SourceVpce, aws:SourceOwner, aws:SourceAccount, aws:userid, aws:PrincipalOrgID, aws:PrincipalArn, aws:PrincipalAccount, s3:DataAccessPointArn or s3:DataAccessPointAccount.
The consequence, and AWS's own example:
| Policy | Public? |
|---|---|
Principal: "*", no condition | yes |
Principal: "*" + StringLike aws:SourceVpc: "vpc-*" | yes — the narrowing value is itself a wildcard |
Principal: "*" + StringEquals aws:SourceVpc: "vpc-91237329" | no |
Principal: {"AWS": "arn:aws:iam::123456789012:root"} | no |
Principal: {"AWS": "arn:aws:iam::123456789012:user/*"} | yes |
Principal: "*" + aws:SourceIp: "203.0.113.0/24" | no |
Principal: "*" + aws:SourceIp: "0.0.0.0/1" | yes — broader than /8 |
Effect: Deny, Principal: "*" | no |
| A fixed cross-account grant plus one public statement | yes |
Three further rules follow from the same page. Only an Allow can make a policy public. A single surviving public statement makes the whole policy public — the guide's worked example, where one public statement disables an otherwise-legal cross-account grant. And an aws:SourceIp range pins nothing when it is "broader than /8 for IPv4 and /32 for IPv6 (excluding RFC1918 private ranges)", so a bucket policy conditioned on 0.0.0.0/0 is public even though it contains no wildcard character; the RFC1918 exclusion is what keeps a unique-local IPv6 range from tripping the /32 bound.
A body that parses as JSON but not as a policy document is not treated as public. PutBucketPolicy already rejects a non-JSON body with 400 MalformedPolicy before this check runs, and the public-access check is not a second validity check — a malformed-but-JSON document keeps whatever answer it had before enforcement existed.
IgnorePublicAcls and RestrictPublicBuckets remain recorded-only. Both govern how an incoming request is evaluated against an existing ACL or policy rather than which write is refused, and substrate has no unauthenticated or cross-account request path to deny — every request it serves is already the bucket owner's. Substrate also does not model the guide's unsupported-action clause (S3 treats a statement granting an action S3 does not support as potentially public), which would need an authoritative list of every supported s3: action.
Access control lists
An ACL named on a write is stored, and reported by GetBucketAcl / GetObjectAcl. Six operations resolve one:
| Operation | ACL source | On no ACL header |
|---|---|---|
CreateBucket | x-amz-acl, x-amz-grant-* | nothing stored; the default owner-only ACL is reported |
PutObject | x-amz-acl, x-amz-grant-* | nothing stored, and any ACL the key already had is cleared |
CopyObject | the copy request's own headers only | as PutObject — a copy never inherits the source's ACL |
CreateMultipartUpload | x-amz-acl, x-amz-grant-*, carried to the object CompleteMultipartUpload assembles | as PutObject |
PutBucketAcl | an XML body, else the headers | an empty request resolves to private |
PutObjectAcl | an XML body, else the headers | an empty request resolves to private |
Before this, PutObject and CreateBucket read no ACL header at all, so the ACL GetObjectAcl reported was never the one the write set — and an ACL expressed through x-amz-grant-* was stored by no operation, PutBucketAcl and PutObjectAcl included: the grant headers were parsed only to decide whether Block Public Access should refuse.
A write replaces the whole ACL, not part of it. "You cannot use PutObject to only update a single piece of metadata for an existing object. You must put the entire object with updated metadata" — so an overwrite naming no ACL reports owner-only afterwards even if the key previously carried a public grant, whether that grant arrived through the original PutObject or through a later PutObjectAcl. CompleteMultipartUpload and CopyObject replace it the same way, and CreateBucket clears any ACL a same-named bucket left behind when it was deleted.
A copy takes its ACL from the request and nowhere else: "When you copy an object, the ACL metadata is not preserved and is set to private by default. Only the owner has full access control. To override the default ACL setting, specify a new ACL when you generate a copy request." That is the opposite of the metadata families, where COPY is the default directive — see Copying objects.
A multipart upload's ACL is fixed at create. CompleteMultipartUpload's request accepts no ACL header, exactly as it accepts no encryption header, so an ACL not named at CreateMultipartUpload cannot be supplied later.
The canned names resolve from the user guide's Canned ACL table, and three of them mean different things on a bucket than on an object:
x-amz-acl | Bucket | Object |
|---|---|---|
private | owner FULL_CONTROL | owner FULL_CONTROL |
public-read | + AllUsers READ | + AllUsers READ |
public-read-write | + AllUsers READ, WRITE | + AllUsers READ, WRITE |
authenticated-read | + AuthenticatedUsers READ | + AuthenticatedUsers READ |
log-delivery-write | + LogDelivery WRITE, READ_ACP | owner-only — "Applies to: Bucket" |
aws-exec-read | owner-only | owner-only |
bucket-owner-read | owner-only — S3 "ignores it" on a bucket | owner-only |
bucket-owner-full-control | owner-only | owner-only |
The owner grant is present in every row because a canned ACL is applied on top of the ACL the resource already has: "When Amazon S3 receives a request with a canned ACL in the request, it adds the predefined grants to the ACL of the resource". aws-exec-read resolves to owner-only because it grants Amazon EC2 READ and AWS does not publish that canonical user ID; the two bucket-owner-* names collapse because substrate has one owner identity per bucket, so the object owner and the bucket owner are the same principal. authenticated-read is public by Block Public Access's own definition, which is the row that matters most: substrate resolved it to owner-only before, so the block could be walked straight through.
A canned name substrate does not recognize resolves to owner-only rather than being refused. The per-operation Valid Values lists differ — CreateBucket and PutBucketAcl document four names, PutObject, PutObjectAcl, CopyObject and CreateMultipartUpload seven — and no error code is documented for a name outside them, so refusing would mean rejecting a request real S3 may accept.
The five x-amz-grant-* headers add to the default ACL, they do not replace it: "you specify explicit access permissions and grantees … These permissions are then added to the ACL on the object", so the owner keeps FULL_CONTROL.
| Header | Permission |
|---|---|
x-amz-grant-full-control | FULL_CONTROL |
x-amz-grant-read | READ |
x-amz-grant-read-acp | READ_ACP |
x-amz-grant-write | WRITE |
x-amz-grant-write-acp | WRITE_ACP |
Each value is a comma-separated list of type=value pairs — id="abc123", uri="http://acs.amazonaws.com/groups/global/AllUsers" — with the quotes optional and the type read case-insensitively. Only id and uri produce a grantee, which is the pair the user guide's "Who is a grantee?" section lists. PutObject's request syntax omits x-amz-grant-write (the permissions table gives WRITE no object meaning); substrate records it there anyway rather than inventing a rejection no error code is documented for.
An emailAddress grantee is skipped, not refused. S3 ended support for it — "As of October 1, 2025, Amazon S3 has discontinued support for Email Grantee Access Control Lists (ACLs) … the request will receive an HTTP 405 (Method Not Allowed) error" — and substrate's clock is past that date, but the 405 is Region-conditional and applies to the XML body form too, so returning it is tracked separately rather than guessed at.
The two forms are documented mutually exclusive — "If you use these ACL-specific headers, you cannot use the x-amz-acl header to set a canned ACL" — but no error code is documented for sending both, so substrate resolves rather than refuses and the grant headers win, being the more specific expression. On PutBucketAcl and PutObjectAcl an XML body wins over both.
The owner identity is derived from the bucket name (<bucket>-owner), because substrate has no canonical user IDs: there is one owner per bucket and it owns everything in it. An ACL's Owner and its CanonicalUser FULL_CONTROL grantee are therefore always the same ID, and an object's owner is its bucket's.
Event notifications
PutBucketNotificationConfiguration accepts the API's XML body and GetBucketNotificationConfiguration returns it in the same shape. Note the element names, which are not the SDK member names:
| Destination | Configuration element | Destination element |
|---|---|---|
| SNS topic | TopicConfiguration | Topic |
| SQS queue | QueueConfiguration | Queue |
| Lambda function | CloudFunctionConfiguration | CloudFunction |
| EventBridge | EventBridgeConfiguration | — (no members) |
Each configuration takes an optional Id, one or more repeated Event elements, and an optional Filter → S3Key → repeated FilterRule with Name (prefix or suffix) and Value. Substrate also accepts a JSON body keyed on the SDK's member names as a convenience; the response is always XML.
A configured notification is dispatched: PutObject and DeleteObject invoke the named Lambda function, send to the named SQS queue, and publish to the named SNS topic, with the key filter applied. EventBridge delivery is recorded and reported but not dispatched — substrate has no bus-to-target path for S3 events.
An empty NotificationConfiguration is the documented way to turn notifications off, and an unconfigured bucket reads back as one. A non-empty body naming no recognized element is 400 MalformedXML rather than being accepted as a disable: an XML decoder reports no error for a body whose elements match no field, so without that refusal a body with the wrong element names is indistinguishable from a deliberate disable, which is how a configuration could be accepted with a 200 and silently never fire (#542).
Request-rate limits are enforced per prefix
AWS states S3's request-rate ceilings per prefix within a bucket, not per bucket: "your application can achieve at least 3,500 PUT/COPY/POST/DELETE or 5,500 GET/HEAD requests per second per partitioned Amazon S3 prefix. There are no limits to the number of prefixes in a bucket." Substrate's quota gate accounts them that way (#818), so spreading writes across prefixes raises the ceiling the way AWS's own guidance says it does — "if you create 10 prefixes in an Amazon S3 bucket to parallelize reads, you could scale your read performance to 55,000 read requests per second". Accounting per bucket, which is what substrate did before, got both directions wrong: it throttled a caller who had parallelised across prefixes and did not throttle one hammering a single prefix.
A request over its prefix's ceiling gets SlowDown with HTTP 503 — the pair the performance guide names ("you may see some 503 (Slow Down) errors"), rendered as S3's bare <Error> document. It is not ThrottlingException/429, which is what every other service's quota refusal is; S3 has no such code. The message names the prefix, but it is substrate's own wording — AWS documents no message string for SlowDown — so assert on the code and the status.
Which prefix a request counts against is substrate's choice, and this is it: the object key up to and including its first /, and the bucket's root for a key with no / and for any operation that names no key. AWS cannot be copied here. It defines a prefix as "a string of characters at the beginning of the object key name" of any length and is explicit that prefixes are not directories, so a key belongs to arbitrarily many prefixes at once; which of them is a partition boundary is S3's own decision, and AWS publishes neither where a partition splits nor when it repartitions — only that the scaling "happens gradually and is not instantaneous". Two reasons for that boundary rather than a deeper one: it is the boundary AWS's own 10-prefix parallelisation example uses, and it is the coarsest choice short of the bucket, so substrate never reports headroom from a split S3 might not have made. The error is towards throttling sooner, which is the safe direction for a test whose subject is a retry loop.
The two ceilings are configurable under the rule keys s3/read and s3/write, which is how a fixture reaches a 503 without issuing thousands of requests:
quotas:
enabled: true
rules:
s3/write: {rate: 3, burst: 3}That throttles the fourth write to one prefix and leaves every other prefix untouched. An s3/read rule is independent of s3/write, because AWS publishes two figures rather than one. A rule written against an operation (s3/PutObject) or against the service (s3) still wins over the class rule it overlaps, but it is not accounted per prefix — it is substrate's own throttle rather than one of AWS's published ceilings, so it governs a single bucket like every other service's rules, and its refusal message names the rule instead of a prefix.
Two limits to know. The quota gate is exempt during replay, so a recorded SlowDown replays as a success and is reported as a response difference (#833) — a rate refusal is reproducible from a seeded rule, not from the event log. And ValidationReport.QuotaChecks compares a bucket-wide peak against the per-prefix ceiling, because a recorded event carries no object key unless bodies were recorded; it therefore warns earlier than the gate would refuse.
CloudFormation resource types
| Type | Ref | Notes |
|---|---|---|
| AWS::S3::Bucket | BucketName |
Cost
S3 operation costs match AWS list pricing. PUT/COPY/POST/LIST operations are $0.005 per 1,000. GET/SELECT operations are $0.0004 per 1,000.
Lambda
Endpoint: lambda.{region}.amazonaws.comProtocol: REST/JSON
Supported operations
| Operation | Notes |
|---|---|
| CreateFunction | Stores function metadata; no actual execution; records CodeSize and CodeSha256 from the deployment package |
| GetFunction | Reports Code.ImageUri with RepositoryType: ECR for an image-packaged function |
| UpdateFunctionCode | Re-derives CodeSize and CodeSha256 from the new package; an update carrying no package changes neither |
| UpdateFunctionConfiguration | Drops the function's warm container when Handler, Runtime or Environment changes |
| DeleteFunction | |
| ListFunctions | |
| Invoke | Answers the stub {"statusCode":200,"body":"null"} when no container executor is available; a seeded failure (POST/DELETE /v1/lambda/invoke-error) short-circuits every path and still answers 200, per the reference's "the status code in the API response doesn't reflect function errors" |
| InvokeAsync | Always 202 with {"Status":202}; the payload is not stored and nothing is queued. The operation is deprecated in AWS's own reference, and is published under its own API version date, 2014-11-13 |
| AddPermission | Adds a statement to the function's resource policy; the body is parsed before the function is looked up |
| RemovePermission | Removes the statement by StatementId; 204 with no body. An absent function, an absent policy and an unmatched StatementId are all ResourceNotFoundException/404 |
| GetPolicy | Reports the stored policy as a JSON string in Policy, as published. A function with no policy is ResourceNotFoundException/404, not an empty document |
| PutFunctionEventInvokeConfig | Records MaximumRetryAttempts and MaximumEventAgeInSeconds as intent; nothing retries, because nothing is invoked asynchronously. The body is parsed before the lookup. Published under 2019-09-25 |
| CreateEventSourceMapping | |
| GetEventSourceMapping | By UUID |
| UpdateEventSourceMapping | BatchSize and Enabled only; both optional, so an absent body is a no-op update rather than a refusal and only a present-but-unparseable body is refused. Toggling Enabled starts or stops the SQS poller |
| DeleteEventSourceMapping | |
| ListEventSourceMappings | Paginates on MaxItems/Marker; an absent MaxItems answers the published per-response cap of 100, a value outside 1–10000 is refused, and a Marker substrate did not issue is refused with InvalidParameterValueException — see Two more cursors published and unread |
| TagResource | 204 with no body. Published under 2017-03-31, as the other two tag operations are |
| UntagResource | Takes tagKeys as repeated query parameters; 204 with no body |
| ListTags | Reports Tags as a map, empty for an untagged function |
The row that used to sit here read InvokeFunction, which is not a Lambda API operation — the operation is Invoke, and InvokeFunction is the IAM action name. So the one Invoke-shaped row in the table named something no caller can call, while the eight operations the plugin actually routes had no row at all (#1015). Rows are now listed in the router's own order, which is the order a reader checking one against the other needs.
Each operation is published under its own API version date
A query-protocol service carries one Version parameter for the whole API. A REST service puts the version in the path, and Lambda dates each operation's URI at the version that operation was introduced — it has never renumbered. Four dates appear among the operations substrate routes, each read from that operation's own published Request Syntax:
| API version | Operations |
|---|---|
2014-11-13 | InvokeAsync |
2015-03-31 | the function CRUD, Invoke, the resource policy, the event source mappings |
2017-03-31 | TagResource, UntagResource, ListTags |
2019-09-25 | PutFunctionEventInvokeConfig |
Until #1142 substrate routed one of the four. The parser reached its arms by trimming the literal prefix /2015-03-31, so a path under any other date kept its version segment, matched nothing, and answered UnknownOperationException/404 — which is what lambda.TagResource got from an SDK against a function substrate had just created, even though the handler behind it merges tags and saves the function. Three operations were unreachable in a plugin that implements them, and invoke-async and event-invoke-config were reachable only at a date AWS does not serve.
A request under an undocumented date is still refused. The version is compared against the one the operation publishes rather than stripped, so /2015-03-31/tags/{Resource} — a path no AWS SDK emits and AWS itself does not serve — answers the same 404 it always did. Accepting it would make substrate the only implementation that does, which hides the defect rather than reporting it: a consumer hand-building the URI would pass here and fail at deployment.
The same resolution decides more than routing, because the parser is also what names the operation for authorization, metering and fault injection. lambda:TagResource resolved to Unknown, so an IAM policy naming the action could neither allow nor deny it and a seeded fault on it could not fire. The resource half moved with it: a tags request names its resource as a whole ARN in the path, and that ARN is what the request is authorized against — reassembling one from the caller's own account and Region, which a function-path request must do because it carries only a name, would silently retarget a cross-account ARN at the caller's own function of that name.
What CodeSize and CodeSha256 report
CodeSize is "the size of the function's deployment package, in bytes" and CodeSha256 is "the SHA256 hash of the function's deployment package". What substrate can report depends on whether it holds the package:
| Code source | CodeSize | CodeSha256 |
|---|---|---|
Code.ZipFile (inline, base64) | the decoded package's length | the real SHA256 of those bytes |
Code.S3Bucket + Code.S3Key | the S3 object's recorded length | the object's ETag, not a SHA256 — see below |
Code.S3ObjectVersion | that version's length | that version's ETag |
Code.ImageUri | 0 — an image is not a package substrate holds | a digest of the image URI |
no Code at all | 0 | empty |
Two of these are substrate's own decisions rather than the API model:
The digest of an S3-sourced package is the object's ETag. Substrate does not fetch the object's bytes — nothing executes them unless they arrived inline — so it cannot compute the SHA256 real Lambda would report. It reports the ETag instead, unquoted. For a single-part upload that ETag is the MD5 of the body, so it changes exactly when the package changes, which is what a caller comparing digests across deploys is asking. Do not assert that it equals a SHA256 you computed yourself; do assert that it changes when you upload different bytes and does not when you do not. An image-packaged function's digest is likewise derived from its URI.
An absent S3 object does not fail the create. Real Lambda refuses a CreateFunction naming an object that is not there. Substrate accepts it, logs a warning, and reports CodeSize: 0 with no digest. The reason is that substrate's S3 and Lambda state are independent and a template may legitimately name an object a test never uploaded; failing the create would make a stack undeployable for a reason unrelated to what the test is checking. A deleted object — one hidden by a delete marker — counts as absent, not as a zero-length package.
CodeSize and CodeSha256 describe the package. RevisionId does not: it is not a digest of anything and advances on every UpdateFunctionCode, including one that changes no code.
An image-packaged function reports its image
Code.ImageUri is the documented spelling and implies PackageType: Image, which a request need not state — real Lambda rejects an ImageUri alongside PackageType: Zip, so inferring it cannot contradict a valid request. A top-level ImageUri is also accepted, for compatibility with what substrate took before Code.ImageUri worked; Code.ImageUri wins when both are sent.
GetFunction reports such a function through Code.RepositoryType: ECR with ImageUri and ResolvedImageUri, and no Location — RepositoryType is "the service that's hosting the file", and reporting a presigned S3 URL for an image is a claim a caller can act on and be wrong about.
CloudFormation resource types
| Type | Ref | Notes |
|---|---|---|
| AWS::Lambda::Function | FunctionName | Code is deployed in every form; inline ZipFile is zipped |
| AWS::Lambda::EventSourceMapping | — |
A function name is unique per account per Region
Two accounts can each hold a function named orders, and one account can hold one in us-east-1 and another in us-west-2. Until #943 the second create in either pair answered ResourceConflictException/409, because the function's state key carried only its name. See A Lambda function and a DynamoDB table belong to one account in one Region for what AWS does and does not publish about that scope, and for the three sibling keys — the resource policy, the stored zip and the event-invoke configuration — that moved with it.
An inline ZipFile is zipped into a package
The resource type's Code.ZipFile and the API's Code.ZipFile are not the same thing, which is the one place the deployer does more than forward a property.
The resource type's is "the source code of your Lambda function": "CloudFormation places it in a file named index and zips it to create a deployment package". The API's is "the base64-encoded contents of the deployment package". So substrate builds the archive CloudFormation would have built — a single entry named index with the extension the runtime reads (.js for nodejs*, .py for python*, bare index otherwise) — and sends that. CodeSize is therefore the archive's length, not the source's, and CodeSha256 is a digest of real bytes.
The archive carries no timestamps, so the same template deployed twice produces the same package and the same digest. A digest that changed on every deploy would not be worth comparing.
Code.S3Bucket, Code.S3Key, Code.S3ObjectVersion and Code.ImageUri are forwarded as the API spells them, and each goes through the template's Ref and pseudo-parameter resolution like every other property.
Docker execution runs an image, never a ZIP
Docker execution is off unless lambda.docker_enabled is set, which is the default and every CI run. With it unset no executor is constructed, no container is ever started, and Invoke answers the stub payload {"statusCode":200,"body":"null"} — or a seeded failure, which short-circuits every path (see the Invoke row above). Everything in this subsection and the next applies only to the configured case.
With it set, there are two paths and they are not equally capable:
PackageType | What the container runs |
|---|---|
Image | The image's own code, which substrate neither builds nor inspects |
Zip (or unset) | Nothing. The archive is mounted, not extracted |
The ZIP path writes the stored deployment package into a temporary directory as a single file named function.zip and mounts that directory at /var/task:ro. The Lambda runtime interface expects /var/task to hold the module tree, so the container sees one archive and no index.py — the handler cannot be imported whatever the ZIP contains.
What a caller observes is not a stub, and that is the part worth knowing. docker run succeeds, the container passes its readiness check, it is pooled, and the invoke returns what the runtime interface answered: HTTP 200 with an import-error body and X-Amz-Function-Error: Unhandled, which substrate forwards verbatim. So Invoke reports that the caller's handler raised — for code that was never loaded, and indistinguishable from a genuine Runtime.ImportModuleError. The stub is returned only when the docker binary cannot be run or the container fails to start.
Extraction is deliberately not implemented (#1079). Running a caller's handler is outside substrate's scope by name — it is the workload behind the API rather than an observation through it — and a test of it could not avoid container-start latency, the handler's own I/O and clock, and an image pull over the network, all of which this project forbids a test to depend on. The ZIP write stays as the recorded intent the same boundary asks for: the bytes are what GetFunction's CodeSha256 and CodeSize are computed from, and they are what an UpdateFunctionCode changes.
So the execution semantics a real Lambda has — a timeout firing, a handler exception becoming Handled, an Environment variable reaching the code — are not reachable through either path here. X-Amz-Function-Error and a function-error payload are still fully testable, because they are seedable through POST/DELETE /v1/lambda/invoke-error — which is the mechanism substrate offers in place of running the work, and which answers instantly and reproducibly where a real handler would not.
Which operations drop a warm container
This applies only when Docker execution is configured. Without it there is no executor and no pool, which is the default and every CI run.
A warm container is pooled by function ARN alone, and the handle records no code identity — no CodeSha256, no RevisionId, no image URI — so nothing in the invoke path can notice that a container is running code the function no longer has. Until #1035 only a shutdown, a state reset and the idle TTL ever dropped an entry, and the TTL measures idle time, so a function invoked in a loop kept its stale container indefinitely: GetFunction reported the new CodeSha256 while Invoke returned the previous code's output, and the response a caller was asserting on was the stale one.
Three operations now drop the container for the function they name, and the line between them and the rest of the plugin is what a container is started from rather than what the API publishes as mutable:
| Operation | Why |
|---|---|
UpdateFunctionCode | The old package is mounted into the container, or the old image URI is baked into its docker run |
UpdateFunctionConfiguration | Runtime chooses the image, Handler is both an environment variable and the container's command argument, and each Environment entry is a -e flag — all fixed at start |
DeleteFunction | The ARN is derived from account, Region and name, so a function recreated under the same name would inherit the dead one's entry |
MemorySize and Timeout are the two UpdateFunctionConfiguration members that reach no container at all, so they would not need the eviction. It happens anyway: distinguishing them means comparing five members against a handle that stores none of them, and dropping a container costs one cold start where keeping a stale one serves the wrong answer. The eviction is per ARN — one function's update leaves every other function's container alone — and it runs after the write, so a refused update disturbs nothing.
PublishVersion and the alias operations do not drop a container: the pool is keyed by the unqualified ARN and the invoke path resolves a qualifier to the same function record, so neither changes what a container should be running.
DeleteFunction also releases the stored deployment package, which it did not before #1035. No stale read followed from that — the invoke path is gated on whether a package is staged, which a recreated function sets for itself — so it was a leak rather than a wrong answer.
A caveat on all of the above: substrate writes a ZIP package into the mounted directory without extracting it, so the runtime interface never finds a module tree and no container down that path runs a caller's handler at all. Per this repository's scope boundary, running a Lambda's code is out of scope; whether the path should therefore be documented as inert or completed is #1079. The invalidation above is about container identity, which is observable either way.
Cost
Lambda invocations: $0.0000002 per request.
SQS
Endpoint: sqs.{region}.amazonaws.comProtocol: AWS Query (form-encoded, Action= parameter)
Supported operations
| Operation | Notes |
|---|---|
| CreateQueue | Supports FifoQueue, VisibilityTimeout attributes; QueueNameExists when a name is reused with differing attributes; seedable QueueDeletedRecently; stores a create-time tag set |
| GetQueueUrl | QueueDoesNotExist when the queue is absent; seedable consistency window |
| GetQueueAttributes | QueueDoesNotExist when the queue is absent; seedable consistency window; attribute defaults |
| SetQueueAttributes | QueueDoesNotExist when the queue is absent |
| DeleteQueue | QueueDoesNotExist when the queue is absent |
| ListQueues | Scoped to the caller's account and the endpoint's Region; filters on QueueNamePrefix; see One queue name is one queue per Region |
| SendMessage | Returns MessageId; QueueDoesNotExist when the queue is absent; enforces MaximumMessageSize; stores message attributes and returns MD5OfMessageAttributes; enforces the attribute count, name, type and Number rules |
| SendMessageBatch | Enforces both the per-message and batch-total size limits; stores message attributes per entry; reports an attribute-rule violation per entry in Failed at HTTP 200 |
| ReceiveMessage | Supports MaxNumberOfMessages, WaitTimeSeconds; QueueDoesNotExist when the queue is absent; returns message attributes for the names requested |
| DeleteMessage | QueueDoesNotExist when the queue is absent |
| DeleteMessageBatch | |
| ChangeMessageVisibility | QueueDoesNotExist when the queue is absent |
| PurgeQueue | QueueDoesNotExist when the queue is absent |
One queue name is one queue per Region
Two Regions can each hold a queue named orders, and a ListQueues answers for the endpoint it was sent to. Until #1088 neither held: the queue's state key was queue:{account}/{name}, built from the last two components of a queue URL — which skips the Region, because a queue URL carries it in the host.
The consequence was not merely colliding state. A CreateQueue for a name another Region already held found that record, took its idempotent branch and answered the other Region's URL — so the caller's next call addressed the wrong endpoint for a queue it had just created, every operation on it succeeded against a record it had not asked for, and nothing refused anything. The key is now queue:{account}/{region}/{name}, and the msg:, msg_ids: and fifo_dedup: keys derived from it carry the same triple.
| Before #1088 | Now |
|---|---|
CreateQueue for orders in eu-west-1 after one in us-east-1 answered the us-east-1 URL | each Region answers its own URL, and the two hold separate attributes and messages |
| A queue URL from another Region resolved that Region's queue | it resolves nothing at this endpoint and answers QueueDoesNotExist |
ListQueues reported every queue in every account and every Region | it reports the caller's own account's queues in the endpoint's Region |
The Region comes from the request, not from the queue URL, and that is the substantive choice. Substrate's URL does carry the Region in its host, so a host parse is available — but a URL reaching a handler may have been built by an SDK against a custom endpoint whose host says nothing about a Region, so parsing it out is a guess at the one value the endpoint already knows for certain. API_GetQueueUrl publishes no Region parameter for the same reason: AWS takes it from the endpoint too. Taking it from the request is also what gives the third row above its answer, where a host parse would have silently served the other Region's queue.
The provenance is structural rather than quoted, and that is worth stating plainly. AWS publishes no sentence anywhere scoping a queue name to a Region — unlike DynamoDB's CreateTable, which says so outright. The whole of the argument is the shape of the identifier AWS hands back: the sample queue URL carries the Region in its host on all four published protocol variants, and two endpoints are therefore two namespaces or the URL in the response is wrong. This is the same reading, and the same kind of reading, that #943 recorded for Lambda — see A Lambda function and a DynamoDB table belong to one account in one Region, which sets out when an inference from an ARN's shape is and is not enough. SQS was the last service in that class: Budgets, Organizations and IAM are global and legitimately carry no Region, and ELB's prefix was already account- and Region-scoped.
ListQueues became scoped in the same change, and it had to. It read one flat queue_names key holding every queue URL substrate had ever created, with no account and no Region in the key, and filtered on QueueNamePrefix alone — so it already crossed accounts before anything about the Region changed. Once two Regions can hold one name, an unscoped list answers one endpoint with two URLs for the same name, which is incoherent rather than merely over-broad. The index is gone: the list is a prefix scan over the queue records themselves, which has no second copy to go stale, needs no prune on delete, and takes its scope from the key rather than from a filter written beside it.
QueueDoesNotExist
Every operation that names a queue fails with QueueDoesNotExist, HTTP 400, when that queue is absent — not the legacy AWS.SimpleQueueService.NonExistentQueue.
The distinction decides whether a consumer can catch the error as a typed exception. SQS is an awsQueryCompatible JSON service, and the dotted form is the query-compatibility alias AWS sends in an x-amzn-query-error header, not in __type:
- botocore derives the exception class from the resolved error code, so the legacy string resolves to a bare
ClientErrorandexcept sqs.exceptions.QueueDoesNotExistnever matches. - aws-sdk-go-v2 dispatches on
strings.EqualFold("QueueDoesNotExist", …); the legacy string appears nowhere in thesqsmodule, soerrors.As(err, &types.QueueDoesNotExist{})never matched either.
SQS errors are emitted as JSON regardless of the request protocol, since substrate resolves the error protocol per service and SQS is JSON-RPC. A query-protocol request therefore gets a JSON error document rather than the XML <Error> shape.
Seeding the create-then-lookup consistency window
AWS documents that you "must wait at least one second after the queue is created to be able to use the queue", so a real CreateQueue → GetQueueUrl → retry loop can legitimately see QueueDoesNotExist for a queue that exists. Substrate resolves a new queue instantly, which makes that retry path unreachable and any test of it vacuous. Seeding is what makes the window observable:
# The next 2 GetQueueUrl calls on run-q report QueueDoesNotExist.
curl -X POST http://localhost:4566/v1/sqs/consistency \
-d '{"queueName":"run-q","getUrlMisses":2}'
# GetQueueAttributes has its own independent counter.
curl -X POST http://localhost:4566/v1/sqs/consistency \
-d '{"queueName":"run-q","getAttributesMisses":1}'
# Apply to any queue (wildcard).
curl -X POST http://localhost:4566/v1/sqs/consistency -d '{"getUrlMisses":3}'
# Clear one, or all.
curl -X DELETE 'http://localhost:4566/v1/sqs/consistency?queueName=run-q'
curl -X DELETE http://localhost:4566/v1/sqs/consistencyA name-scoped seed is consulted before the wildcard. When a name-scoped seed is exhausted the lookup falls through to the wildcard, so an empty named seed does not mask a wildcard that still has budget.
A budget is consumed exactly once per miss, including under concurrency. That matters most for a wildcard seed, which is shared across every queue: seeding {"getUrlMisses":16} and then driving concurrent lookups on sixteen different queues reports exactly sixteen misses, not more. Substrate serializes the read-decrement-write on one lock, the same way it does for the S3 conditional-conflict seed. The guarantee is process-local, so it covers a single substrate server and not two of them sharing one state backend.
The window is counted in misses, not measured as a duration. Substrate's simulated clock advances with wall time from its baseline, so a duration-based window would expire partway through a test and make "still missing" assertions wall-clock dependent — which no test here may be. A miss counter is exactly reproducible.
Two ordering rules make the seed usable from a harness:
- A lookup miss is consumed only when the queue actually exists. Seeding before
CreateQueueis safe: lookups against the genuinely absent queue still fail, but they do not spend the budget. (deletedRecentlyMissesis the deliberate exception — see below.) - A seed counts down the next N misses and does not re-arm on
CreateQueue.CreateQueueis idempotent here (it returns the existing URL), so "after its CreateQueue" would be ambiguous when create runs twice, and re-arming would mean the data path writes control-plane state.
All three counters — including deletedRecentlyMisses — default to 0, so an unseeded queue behaves exactly as before: instantly resolvable. Seeds live in the state store, so POST /v1/state/reset clears them along with everything else.
Queue attribute defaults
GetQueueAttributes reports these for a queue created without naming them:
| Attribute | Default |
|---|---|
VisibilityTimeout | 30 |
MaximumMessageSize | 1048576 — 1 MiB, per the CreateQueue reference |
MessageRetentionPeriod | 345600 |
DelaySeconds | 0 |
ReceiveMessageWaitTimeSeconds | 0 |
262144 (256 KiB) is the historical limit rather than the current default, and is what substrate reported until #439. An explicitly requested value is always honored — 256 KiB is still a legal size.
These defaults also decide what counts as a QueueNameExists conflict, since an existing queue's unset attributes are resolved through them before comparing.
A tag set at create time, and the query spelling AWS publishes
API_CreateQueue publishes a tags member (String to string map, Required: No) and its own JSON sample sends one. Until #1087 createQueue decoded QueueName and Attributes only, so the call answered 200, the queue existed, and ListQueueTags reported nothing — a consumer that tags on create, which is the only way to tag atomically and the shape CDK and Terraform both emit, could not tell a dropped tag set from a bug in its own code.
The query spelling is the finding. AWS's query sample is unindexed:
&Tag.Key=QueueType&Tag.Value=Productionand that is the sample on API_TagQueue's page as well. AWS publishes no Tag.N.Key form for either operation, anywhere on either page — even though the same CreateQueue sample indexes its attributes (&Attribute.1.Name=&Attribute.1.Value=). Substrate's TagQueue parsed Tag.N.Key and nothing else, so it handled a spelling AWS publishes nowhere and dropped the only one it does. The indexed form still has to be read, because it is what a query-protocol SDK serialiser emits for a map member and therefore the form real calls arrive in, so both are accepted — indexed first, and the unindexed pair only when the indexed scan matched nothing, so a request carrying both resolves the same way every time. One parser serves both operations, which widened TagQueue to the published form; nothing it accepted before is refused now.
The JSON member differs in case between the two operations — lowercase tags on CreateQueue, capitalised Tags on TagQueue, AWS's own inconsistency — and needs no code, because encoding/json falls back to a case-insensitive member match when no exact one is found.
Substrate enforces no tag limit here, and that is the published contract rather than an omission. The tags member carries no Map Entries and no Length constraint at all; the fifty-tag figure is worded as a recommendation — "Adding more than 50 tags to a queue isn't recommended" — on both this page and TagQueue's; and neither Errors list carries a too-many-tags code. There is nothing to refuse with, so sixty tags are stored. Kinesis's CreateStream publishes constraints and therefore refuses (see How many tags a stream may carry); the difference is in the references, not in substrate, and is pinned by a test so the missing SQS refusal is not later "fixed" by analogy.
A tag set on an idempotent hit is not applied. A second CreateQueue for an existing queue with the same attributes succeeds and reports the same URL; AWS publishes nothing about what happens to tags on that request, and substrate's reading is that a call which created nothing tagged nothing. TagQueue is the door that publishes retagging.
EverTagged is deliberately not stamped by either create path, per the recorded decision in tagging_ever_tagged.go: the flag is only consulted when a record's tag set is empty, and the writer that empties it counts the set before removing from it. See GetResources reports what has been tagged.
Message size enforcement
SendMessage rejects a message larger than the queue's effectiveMaximumMessageSize with InvalidParameterValue, HTTP 400:
One or more parameters are invalid. Reason: Message must be shorter than 1048576 bytes."Effective" means resolved through the default when the attribute is unset, so a queue created with no attributes enforces 1 MiB. The limit is read through the same default GetQueueAttributes reports, so the number a caller reads back is by construction the number that is enforced. The limit named in the message is the one that actually applied — a queue configured at 1 KiB says 1024.
The boundary is inclusive: a message of exactly the limit is accepted. AWS's wording is "must be shorter than N bytes", but N is the documented maximum size, so the largest legal message is N bytes.
Message attributes count toward the size. The measured total is the body plus, for every attribute, its name, its data type, and its value — a binary value counted as its raw (decoded) byte length. The developer guide is explicit that "all components of a message attribute are included in the 1 MiB message size restriction", and the per-component breakdown is the one AWS's own Extended Client Library uses to decide whether a payload needs offloading to S3. Message system attributes are excluded, per the SendMessage reference.
Attributes are also stored and returned — see Message attributes.
SendMessageBatch enforces two limits, both 1 MiB, as the reference states: "the maximum allowed individual message size and the maximum total payload size (the sum of the individual lengths of all of the batched messages) are both 1 MiB".
| Condition | Error |
|---|---|
| Combined payload of all entries over 1 MiB | BatchRequestTooLong |
| One entry over the queue's per-message limit | InvalidParameterValue |
Because the two limits are equal on a default queue, a batch carrying a single oversized entry breaches the total as well — and real AWS reports BatchRequestTooLong for that case, not the per-message error, so the total is checked first. The queue's MaximumMessageSize is a per-message cap and does not lower the request payload cap: ten legal 1 KiB entries on a queue configured at 1 KiB are accepted.
A rejected send or batch enqueues nothing — a partially applied batch would leave a retry to re-send the entries that already landed. On a FIFO queue the size check runs before the deduplication ID is recorded, so a corrected retry reusing the same MessageDeduplicationId is delivered rather than swallowed as a duplicate.
An unparseable or non-positive MaximumMessageSize falls back to the default rather than to zero, which would make the queue reject every message including an empty one.
Provenance. SendMessage declares no oversized-message error in the API model: its InvalidMessageContents is documented as a character-set error, and BatchRequestTooLong is declared only on SendMessageBatch. The per-message code and both message wordings therefore come from observed real-AWS responses rather than a doc citation — captured SDK errors carrying code: 'InvalidParameterValue' with HTTP 400, and BatchRequestTooLong: Batch requests cannot be longer than N bytes. You have sent M bytes. The same strings appear in independent reimplementations, which corroborates them as transcribed AWS text.
Message attributes
User-defined message attributes are stored on send and returned on receive, for both SendMessage and SendMessageBatch, under both protocols. A consumer routing on an attribute — a messageType discriminator, a trace ID, a tenant key — reads back what it sent.
Attributes are returned only when the receive asks for them. This is the part that is easy to get wrong in the permissive direction: a consumer whose production caller never sets MessageAttributeNames would pass a test against an emulator that volunteered them, then read none from real SQS.
MessageAttributeName | Returned |
|---|---|
| omitted | nothing |
All | every attribute |
.* | every attribute |
messageType | that attribute, if the message carries it |
trace.* | every attribute whose name starts with trace. |
A named attribute the message does not carry is simply absent, not an error — the selector says what to return, not that it must exist. The query protocol numbers the selectors MessageAttributeName.1, .2, …; the JSON protocol sends a MessageAttributeNames array.
MD5OfMessageAttributes is returned on SendMessage, on each SendMessageBatch result entry, and on each received message. It is computed with the algorithm published in the developer guide under "Calculating the MD5 message digest for message attributes": sort by name, then per attribute append a 4-byte big-endian length and the UTF-8 name, the same for the data type, one transport byte (1 for String and Number, 2 for Binary), then the value's length and bytes.
Two details of that algorithm are load-bearing, and substrate's implementation is pinned against three real-AWS digests that fail if either is wrong:
- A binary value is hashed raw, not base64. Base64 is the wire form, so hashing what travels is the natural mistake; it produces
5ff413c9dc7bd18abea88ca05643f902where AWS produces049075255ebc53fb95f7f9f3cedf3c50for the same input. This is the same raw-versus-encoded distinction message size enforcement makes, now with a hash to settle it. - A custom data-type suffix is included in full.
Number.java.lang.Longhashes as the whole 21-byte string, not as itsNumberbase type.
MD5OfMessageAttributes is omitted entirely from a response for a message with no attributes, rather than reported as the MD5 of zero bytes. A digest of nothing is a value a caller could compare against and "successfully" verify, which is worse than no value at all.
On a receive, the digest covers what is being returned, not what was sent: a request naming a subset gets that subset's digest, since the digest exists so a caller can checksum the attributes in hand. Attributes come back in name order, which real SQS does not promise but determinism here does.
A deduplicated FIFO send reports the digest of that request's attributes rather than the stored original's, for the same reason: the digest is a checksum of what the caller sent.
Attribute rules
Every rule below is checked on SendMessage and on each SendMessageBatch entry, under both protocols, and every rejection is InvalidParameterValue with HTTP 400. A rejected send enqueues nothing — on a FIFO queue the check runs before the deduplication ID is recorded, so a corrected retry reusing the same MessageDeduplicationId is delivered rather than swallowed as a duplicate.
| Rule | Rejected |
|---|---|
| count | more than 10 attributes on one message |
| name length | 256 bytes or more |
| name characters | anything outside A-Z, a-z, 0-9, _, -, . |
| reserved prefix | starting with AWS. or Amazon., any casing |
| periods | a leading or trailing ., or two in sequence |
| type | a DataType not prefixed String, Number or Binary; an absent one |
| type length | 256 bytes or more |
| empty value | a String attribute with no value |
Number value | not a decimal number, or outside −10^128 … 10^126 |
Both length bounds are exclusive: 255 bytes is legal, 256 is not. The developer guide says "up to 256 characters" while the error text says "must be shorter than 256 Bytes"; the error is the more specific evidence, and the conflict is recorded rather than quietly resolved.
A custom suffix on a type is legal and preserved — Number.java.lang.Long, Binary.gif — and a Number is range-checked whatever its suffix. Scientific notation (1e5) is accepted, which is the permissive reading of a detail no source settles. Uniqueness within a message is structural rather than checked: attributes are keyed by name.
The reserved-prefix check is case-insensitive, so aws.trace and AwS.trace are refused alongside AWS.trace. This is the opposite of EC2's aws: tag-key rule, where AWS:foo is a legal key — the two services document different rules, and unifying them would break one of the two. A name merely beginning with those letters and no period (AWSfoo) is not reserved.
A message breaking two rules always reports the same one: attributes are visited in sorted name order, because a walk over the Go map would report one error on some runs and the other on others.
Batch failures are per entry
SendMessageBatch reports an attribute violation as a BatchResultErrorEntry in Failed with SenderFault: true, at HTTP 200 — the offending entry does not enqueue while its siblings do. This is what the reference warns about: "you should check for batch errors even when the call returns an HTTP status code of 200". Failed is always present, empty rather than absent on a fully successful batch. The ten-attribute maximum is per message, so three entries of nine attributes each is legal.
That differs deliberately from the size checks ten lines away in the same operation, which fail the whole request with BatchRequestTooLong: the payload cap is a documented property of the aggregate the caller transmitted, while a malformed attribute is a defect in one entry.
Provenance
Message text carries different weights, and the code says which is which:
- Real-AWS captures: the count rejection (an SDK exception quoting a Request ID and status 400), the
Numbercast failure (Can't cast the value of message (user) attribute '…' to a number., captured from boto3 against live SQS, code and message together), and the empty-String-value message (captured twice independently). - Snapshot-tested reimplementation: the name and type messages, from LocalStack's
check_attributes, which snapshot-tests against real AWS. The character-class message is reproduced verbatim including its odd "upper and lower score characters" phrasing and its trailing space — a tidied string is no longer the one a consumer sees. - A single reimplementation, and the weakest claim here: the
Numberrange message (Number attribute value … should be in range (-10**128..10**126)), which only elasticmq supplies.
The count rejection's code is not in the capture; it comes from agreement across five reimplementations. Neither moto nor LocalStack enforces the count at all, which is why substrate accepting an eleventh attribute went unnoticed until message attributes became observable.
The rules apply on send, not on receive
A message written into state before the rules existed — replayed from an older event log — is returned as stored, in full: same body, same attributes, nothing filtered, nothing corrected. ReceiveMessage logs a warning at WARN naming the queue, the message ID and the violated rule, and that is the whole of the receive-side behaviour.
Returning it is the decision, not an omission. Substrate's core property is that replaying an event log reproduces the same observations, and the message was accepted by the substrate that recorded it. Withholding or dropping it now would make a recorded run unreplayable — the one property the emulator rests on — and a receive-time rejection has no AWS behaviour to imitate: real SQS never accepted the message, so there is nothing to copy. Rejecting on receive is deliberately not done, and the test suite fails if anyone adds it.
The warning covers the whole stored attribute set, not the subset the request named. The violation is a property of what is in state, and a request that names no attribute names gets no attributes back, so checking only the selection would hide it from exactly the caller most likely to be replaying an old log. It also fires on every receive of the same message rather than once: a redelivery after the visibility timeout is the fixture being exercised again, and remembering which messages had already warned would be per-process state a replay could not reproduce.
Send-time rejection is unchanged, so no new run can produce this state.
QueueNameExists
CreateQueue fails with QueueNameExists, HTTP 400, when the named queue already exists with attribute values differing from the ones requested. Same name with the same values stays idempotent and returns the existing URL, as AWS documents.
This needs no seed: the condition is entirely determined by state, so it fires on the real mistake — two stacks or two test cases claiming one queue name with different settings — rather than only when a harness remembers to arm it.
Only attributes present in the request are compared. An omitted attribute is treated as "no opinion", not as an assertion of its default. That reading comes from the error's own definition — "Amazon SQS returns this error only if the request includes attributes whose values differ from those of the existing queue" — which scopes the comparison to what the request includes. It is also what keeps CloudFormation re-deploys working, since a template forwards only the properties it declares.
An existing queue's unset attributes are resolved through their defaults before comparing, so these are all idempotent:
| First create | Re-create | Result |
|---|---|---|
| no attributes | no attributes | idempotent |
| no attributes | VisibilityTimeout=30 | idempotent — 30 is what the queue already reports |
VisibilityTimeout=30 | no attributes | idempotent |
VisibilityTimeout=45, DelaySeconds=5 | VisibilityTimeout=45 | idempotent — subset |
VisibilityTimeout=45 | VisibilityTimeout=90 | QueueNameExists |
| no attributes | DelaySeconds=10 | QueueNameExists — effective value is 0 |
orders.fifo, no attributes | FifoQueue=true | idempotent — derived from the .fifo suffix |
orders.fifo, no attributes | FifoQueue=false | QueueNameExists |
Comparison is exact string equality on resolved values, so 5 and 05 differ, and two semantically identical Policy documents differing in whitespace or key order read as a conflict. Any SDK or template re-sending its own serialization matches, which is the case that has to work; semantic JSON comparison is not attempted.
The message carries AWS's documented wording plus the name of the offending attribute, which AWS's own text omits — without it the error is nearly undiagnosable for a caller holding a large attribute set. When several attributes differ, the alphabetically first is named, so the message is reproducible across runs.
Seeding QueueDeletedRecently
AWS requires a 60-second wait after DeleteQueue before a queue of the same name can be created, raising QueueDeletedRecently, HTTP 400, in the meantime. Substrate keeps no memory of a delete, so a consumer's delete → recreate → retry loop had no reachable error branch.
# The next 2 CreateQueue calls naming run-q report QueueDeletedRecently.
curl -X POST http://localhost:4566/v1/sqs/consistency \
-d '{"queueName":"run-q","deletedRecentlyMisses":2}'It shares the /v1/sqs/consistency endpoint, the name-over-wildcard precedence, and the DELETE clearing described above, and it is counted rather than timed for the same reason: the real condition is a wall-clock window, and a wall-clock window would make the assertion depend on how long the rest of the test took.
Unlike the two lookup counters, this one applies only while the name is free. QueueDeletedRecently describes a name too recently freed, so an existing queue is the one case it cannot describe — a CreateQueue that hits an existing queue is an idempotent success and does not spend the budget. Substrate cannot know whether a name was "recently deleted", which is why the condition is seeded rather than inferred: a seeded name is refused on its next create whether or not a delete preceded it.
CloudFormation resource types
| Type | Ref | Notes |
|---|---|---|
| AWS::SQS::Queue | QueueUrl | FifoQueue attribute supported |
Cost
SQS requests: $0.0000004 per request.
DynamoDB
Endpoint: dynamodb.{region}.amazonaws.comProtocol: JSON (application/x-amz-json-1.0, X-Amz-Target: DynamoDB_20120810.{Op})
Supported operations
| Operation | Notes |
|---|---|
| CreateTable | Supports GSI, LSI, billing mode; reports only TableDescription's own members |
| DescribeTable | reports only TableDescription's own members |
| DeleteTable | reports only TableDescription's own members |
| ListTables | |
| PutItem | Supports ConditionExpression |
| GetItem | Supports ProjectionExpression |
| UpdateItem | Supports UpdateExpression (SET/REMOVE/ADD/DELETE) |
| DeleteItem | Supports ConditionExpression |
| Query | Supports FilterExpression, GSI/LSI via IndexName |
| Scan | Supports FilterExpression, GSI/LSI via IndexName |
| BatchGetItem | |
| BatchWriteItem | |
| TransactGetItems | |
| TransactWriteItems | |
| UpdateTimeToLive |
CloudFormation resource types
| Type | Ref | Notes |
|---|---|---|
| AWS::DynamoDB::Table | TableName | GSI, LSI, TTL supported |
A table name is unique per Region, not per account
CreateTable states it outright: "In an AWS account, table names must be unique within each Region. That is, you can have two tables with same name if you create the tables in different Regions." Until #943 substrate keyed a table as table:{account}/{name}, so one account could hold one table name once across every Region and the second create answered ResourceInUseException/400 — which is what a consumer deploying one stack to two Regions hit. The key is now table:{account}/{region}/{name}; see A Lambda function and a DynamoDB table belong to one account in one Region.
A table description reports only its own members
CreateTable, UpdateTable, DeleteTable and DescribeTable project the stored table onto TableDescription rather than handing back the record. Until #1013 they handed back the record, and it carries two fields substrate needs that TableDescription does not publish: a CreateTable with tags answered "Tags":{"env":"test"} inside its TableDescription, and a DescribeTable on a table with TTL enabled answered "TTLAttribute":"expiresAt". Tags was additionally rendered as a JSON object, where AWS's Tags is everywhere a list of {Key, Value} — so the invented member was also in a shape the API does not use anywhere.
Both values remain readable where AWS publishes them: a table's tags through ListTagsOfResource, and its TTL attribute through DescribeTimeToLive. Only the member TableDescription does not have is gone.
The projection is the point rather than the two names: a state record accumulates fields for the emulator's own bookkeeping, and one marshalled straight onto the wire turns each of them into a response member. This is the pattern #529 established for API Gateway v1, where the opposite failure — the state struct's PascalCase members against a lowerCamel model — made an AWS SDK parse a populated response to an empty result. Twenty-eight of the twenty-nine record types the Resource Groups Tagging API scans already had such a projection; DynamoDBTable was the twenty-ninth, which is why AccountID and Region were never at risk of appearing but Tags was.
Members substrate does not model are absent from the projection rather than present and empty, which is the honest-empty reading #827 established, applied to a shape: nothing reports TableId, SSEDescription, Replicas or the twelve others AWS publishes as optional.
Cost
DynamoDB write operations: $0.00000125 per WCU. Read operations: $0.00000025 per RCU.
EC2
Endpoint: ec2.{region}.amazonaws.comProtocol: AWS Query (form-encoded, Action= parameter)
Supported operations
All 92 operations the EC2 plugin routes have a row below. The table and the plugin's dispatch switch agree in both directions — no row names an action the switch does not handle, and no action the switch handles is missing a row. Thirty-six of the rows were added by #1094, which is also where the count came from: it is stated here so that the next reader of this section corrects a number rather than re-deriving one. The table is hand-maintained — only the coverage matrix at the top of this file is generated — so a new case in the switch has to be given a row by hand.
| Operation | Notes |
|---|---|
| RunInstances | Auto-creates default VPC (172.31.0.0/16); requires an AMI that resolves, from the caller's own images or the bundled catalog; merges a named launch template field by field; validates MinCount/MaxCount; refuses an invalid block device mapping; reports groupSet, blockDeviceMapping and placement; the launched instance is born pending — see Seeding an instance-state progression |
| DescribeInstances | Explicit resource IDs; reports groupSet, blockDeviceMapping and placement; eleven filters, and filter names are checked. Paginates on MaxResults/NextToken, counting instances rather than reservations — see One offset paginator, shared |
| TerminateInstances | Explicit resource IDs; honours termination protection, per Availability Zone; answers shutting-down as its own currentState — see Seeding an instance-state progression |
| StopInstances | Explicit resource IDs; answers stopping as its own currentState, and refuses a terminated instance — see Seeding an instance-state progression |
| StartInstances | Explicit resource IDs; answers pending as its own currentState, and refuses a terminated instance — see Seeding an instance-state progression |
| RebootInstances | InstanceId.N is Required: Yes and nothing reads it: the operation answers <return>true</return> without resolving an ID or touching state. An unchanged instanceState is the nominal answer — AWS documents the reboot as asynchronous ("it only queues a request to reboot") and as ignoring a terminated instance — so what diverges is only the refusal: an absent or unknown InstanceId.N is answered identically, where every other instance operation is decided against every ID it names |
| DescribeInstanceStatus | Explicit resource IDs; three of eighteen filters, and filter names are checked; reports availabilityZone, and a seeded transient state both in instanceState and to its own instance-state-name filter. Paginates on MaxResults/NextToken, with no published range — see One offset paginator, shared |
| DescribeInstanceAttribute | Five attributes, scalars <value>-wrapped — see Instance attributes |
| ModifyInstanceAttribute | InstanceType.Value, UserData.Value, DisableApiTermination.Value; the first two require a stopped instance |
| CreateVpc | Renders the same VPC DescribeVpcs does, ownerId and tagSet included — see Twelve describes gained filters |
| DescribeVpcs | Explicit resource IDs; six of fifteen filters, and filter names are checked; reports state, ownerId and tagSet. Paginates on MaxResults/NextToken, over the published 5–1000 range — see One offset paginator, shared |
| ModifyVpcAttribute | VpcId is required and checked (Explicit resource IDs). EnableDnsSupport and EnableDnsHostnames are the two modelled attributes; EnableNetworkAddressUsageMetrics is unread. Both are read under a parameter name AWS does not publish — EnableDNSSupport.Value/EnableDNSHostnames.Value, which no SDK sends — so a modify is answered return=true and discarded (#1151). Neither attribute is readable back either way: DescribeVpcs publishes no member for them, and DescribeVpcAttribute is not routed. The two published combination rules — not both attributes in one request, and hostnames only where support is already enabled — are not enforced |
| DeleteVpc | Explicit resource IDs |
| CreateSubnet | TagSpecification.N scoped to subnet, and it renders the same subnet DescribeSubnets does — see A subnet reports its tags, and filters on them |
| DescribeSubnets | Explicit resource IDs; fourteen filters, and filter names are checked; reports tagSet, subnetArn, ownerId and defaultForAz. Paginates on MaxResults/NextToken, over the published 5–1000 range — see One offset paginator, shared |
| ModifySubnetAttribute | SubnetId is required and checked (Explicit resource IDs). MapPublicIpOnLaunch is the one modelled attribute — it decides whether a launch into the subnet gets a public IPv4 address, and it is readable back through DescribeSubnets — and the other ten published parameters are unread. It too is read under an unpublished parameter name (MapPublicIPOnLaunch.Value), so a modify is answered return=true and discarded (#1151). AWS's "You can only modify one attribute at a time" is not enforced, which is inert while one attribute is modelled |
| DeleteSubnet | Explicit resource IDs |
| CreateSecurityGroup | |
| DescribeSecurityGroups | Explicit resource IDs; filter names are checked. Paginates on MaxResults/NextToken, over the published 5–1000 range, and GroupName.N does not conflict with MaxResults — see One offset paginator, shared |
| DeleteSecurityGroup | Explicit resource IDs |
| AuthorizeSecurityGroupIngress | Supports source security groups (IpPermissions.N.Groups.M.GroupId), including self-referencing rules |
| AuthorizeSecurityGroupEgress | Supports destination security groups |
| RevokeSecurityGroupIngress | Matches on protocol, ports, and source |
| RevokeSecurityGroupEgress | |
| CreateInternetGateway | Renders the same gateway DescribeInternetGateways does — see Twelve describes gained filters |
| AttachInternetGateway | |
| DetachInternetGateway | InternetGatewayId is required and checked (Explicit resource IDs), and the named VPC is removed from the gateway's attachmentSet, so the detach is visible through DescribeInternetGateways. VpcId is Required: Yes on the page but is read without being resolved, so a detach naming a VPC the gateway is not attached to removes nothing and still answers return=true. AWS's precondition — "The VPC must not contain any running instances with Elastic IP addresses or public IPv4 addresses" — is not enforced |
| DescribeInternetGateways | Explicit resource IDs; all six filters, and filter names are checked; reports ownerId, attachmentSet and tagSet. Paginates on MaxResults/NextToken, over the published 5–1000 range — see One offset paginator, shared |
| DeleteInternetGateway | Explicit resource IDs |
| DescribeAvailabilityZones | Three zones per region, from the same list the offerings and spot-price operations use — see Instance types are a seeded catalog. ZoneName.N, ZoneId.N, and four of eleven filters. zoneId takes AWS's published shape (use1-az1) and always maps zone a to -az1 — see Zone IDs |
| DescribeRegions | All three filters, and filter names are checked. AllRegions is accepted and inert — every seeded region is opt-in-not-required, so it is already in the answer |
| DescribeInstanceTypes | Answers from a seeded catalog. InstanceType.N is an assertion: a type outside the catalog is refused with InvalidInstanceType. Six of fifty-six filters, and filter names are checked. Paginates on MaxResults/NextToken, over the published 5–100 range, and InstanceType.N does not conflict with MaxResults — see One offset paginator, shared |
| DescribeInstanceTypeOfferings | instance-type and location filters (both with wildcards) and the LocationType parameter; an unmatched filter is an empty answer, not an error. Paginates on MaxResults/NextToken, over the published 5–1000 range and counting offerings rather than types — see One offset paginator, shared |
| DescribeSpotPriceHistory | One stub price per catalog type per zone. InstanceType.N here is a filter, so an unknown type is an empty history — see below. ProductDescription.N is read at every index, and five of six filters. Paginates on MaxResults/NextToken, with no published range and no InvalidParameterCombination — one of the three converted describes with no ID-list parameter, see One offset paginator, shared |
| GetSpotPlacementScores | Scores the three seeded regions, or their nine zones under SingleAvailabilityZone=true, by AZ ID. TargetCapacity is required and range-checked; InstanceType.N and RegionName.N are singular; MaxResults floors at 10, which is this operation's own published range. The score itself is seeded, not computed |
| CreateRouteTable | |
| AssociateRouteTable | |
| ReplaceRouteTableAssociation | Both IDs resolve before anything is written: an unknown RouteTableId is refused through the route-table kind and an AssociationId naming nothing answers InvalidAssociationID.NotFound/400 — the ordering #713 fixed, since the refusals used to sit after the source association had already been removed, so a bogus target destroyed the association it was asked to move. A fresh rtbassoc- is minted on replacement, matching AWS, and the subnet and main-table flag move with it |
| DisassociateRouteTable | AssociationId is resolved by scanning the region's route tables, and the matching association is removed, so the disassociation is visible through DescribeRouteTables. Unlike ReplaceRouteTableAssociation it answers return=true for an AssociationId naming nothing rather than InvalidAssociationID.NotFound, and the main-table association — which AWS refuses to disassociate — is removed like any other |
| DescribeRouteTables | Explicit resource IDs; filter names are checked. Paginates on MaxResults/NextToken, over the published 5–100 range — the ceiling two of its siblings do not share, see One offset paginator, shared |
| DeleteRouteTable | Explicit resource IDs |
| CreateRoute | RouteTableId is required and checked (Explicit resource IDs); the route is appended with state active. Three published rules are not enforced: a destination is not required (AWS: "You must specify either a destination CIDR block or a prefix list ID"), a target is not required and not resolved (AWS: "You must also specify exactly one of the resources from the parameter list"), and a second route to the same destination is appended rather than refused, so one route table can report two routes to 0.0.0.0/0. GatewayId is the only target parameter read here, where ReplaceRoute reads six — so a NatGatewayId route is stored with an empty target |
| ReplaceRoute | RouteTableId is required and checked; the route matching DestinationCidrBlock has its target and state rewritten, and a destination no route carries answers InvalidRoute.NotFound/400. The target is taken from the first of GatewayId, NatGatewayId, InstanceId, NetworkInterfaceId, TransitGatewayId and VpcPeeringConnectionId that is set, normalised onto the one gateway field substrate stores per route — so which of the six a caller used is not recoverable |
| DeleteRoute | RouteTableId is required and checked; every route matching DestinationCidrBlock is dropped. Silently idempotent: a destination no route carries answers return=true, where ReplaceRoute refuses the same absence with InvalidRoute.NotFound. Neither page publishes a service-specific error, so the divergence is between substrate's two readings rather than against a published code |
| CreateVolume | TagSpecification.N scoped to volume, validated before the volume is written — see A volume carries tags. Exactly one of AvailabilityZone/AvailabilityZoneId and at least one of Size/SnapshotId are required, both refused with InvalidParameterCombination; a named snapshot is resolved, must be observed completed (IncorrectState), and sets the size floor — see CreateVolume invents neither a size nor a zone. VolumeType defaults to gp2, and Iops/Throughput are stored without the per-type rules AWS publishes for them |
| DescribeVolumes | Explicit resource IDs — VolumeId.N asserts existence as of #731, where it used to answer a superset; eleven of twenty filters, and filter names are checked; reports tagSet and the volume's attachmentSet. Paginates on MaxResults/NextToken, with no published range — so a floor of 1 and no ceiling, and VolumeId.N with MaxResults is InvalidParameterCombination, see One offset paginator, shared |
| DeleteVolume | VolumeId is required and checked (Explicit resource IDs); an in-use volume is refused with VolumeInUse/400, and the record is deleted rather than marked, so the delete is not idempotent |
| AttachVolume | VolumeId, InstanceId and Device are all Required: Yes; the first two are resolved and a missing one answers MissingParameter, while Device defaults to /dev/xvdf rather than being required. A volume not available is refused with IncorrectState. The attachment is recorded on the volume, with deleteOnTermination false — but not on the instance, so an instance reports its own block devices from its launch mapping alone and an attached volume does not appear there |
| DetachVolume | VolumeId is required and checked; the response reports the attachment as it was before the detach — the previous instanceId and device — with status detached, and the volume's attachmentSet is then cleared and its state set to available. InstanceId, Device and Force are accepted and inert, so a detach naming the wrong instance still succeeds, and a volume that carried no attachment is detached rather than refused |
| CreateSnapshot | VolumeId is required and checked; volumeSize and encrypted come from the source volume, and status is completed at once — see A snapshot has a real size |
| CreateSnapshots | Snapshots every volume attached to InstanceSpecification.InstanceId, in one atomic call: a request refused for any one volume writes nothing. CopyTagsFromSource=volume copies each volume's tags, with the request's TagSpecification.N winning a collision. The response element is snapshotSet of SnapshotInfo, which names the state member state where CreateSnapshot and DescribeSnapshots both name it status, and each snapshot in the set carries its own seeded countdown — see The rest of the snapshot family |
| DescribeSnapshots | Explicit resource IDs; ten filters, and filter names are checked; Owner.N and RestorableBy.N — see A snapshot filters on its own members. Paginates on MaxResults/NextToken, as do DescribeVolumes and DescribeImages — see One offset paginator, shared |
| DeleteSnapshot | SnapshotId is required and checked; refuses one a registered AMI still references with InvalidSnapshot.InUse, and is not idempotent — see Deleting a snapshot |
| CopySnapshot | A same-region copy, which is what AWS's own text makes of a single-region endpoint — DestinationRegion is a PresignedUrl artifact, not routing. SourceRegion and SourceSnapshotId are both required and the source is resolved; a SourceRegion that is not the request's answers SnapshotCopyUnsupported.InterRegion. Encrypted=false and a KmsKeyId without encryption are both refused; TagSpecification.N is the copy's only source of tags, and volumeId is a fresh ID naming no volume, as AWS's own does. The response carries snapshotId and tagSet and nothing else — see The rest of the snapshot family |
| DescribeSnapshotAttribute | createVolumePermission and productCodes, one at a time; the name is validated before the snapshot is resolved, and both answer as a present-but-empty element rather than an omitted one — see The rest of the snapshot family |
| ModifySnapshotAttribute | Both wire forms of a permission change — structured CreateVolumePermission.Add.N/.Remove.N and flat OperationType with UserId.N/UserGroup.N. Group accepts only all; sharing an encrypted snapshot publicly, adding and removing the same account ID, productCodes, and more than 500 modifications in one call are each refused, while adding a group while removing an account is allowed because that is AWS's own Example 2. The recorded permission grants nothing — substrate is single-account — so it is readable intent |
| ResetSnapshotAttribute | createVolumePermission only; productCodes is refused. Clears the list rather than restoring a remembered default, since an empty list is what a snapshot is created with — see The rest of the snapshot family |
| CreateImage | InstanceId and Name are both required. TagSpecification.N is honored per ResourceType, image and snapshot scoped separately — see Tag scoping on CreateImage. A backing snapshot is always materialised, at the instance's own root volume size — see A snapshot has a real size — and the AMI inherits only what the operating system decides from its parent: architecture, platform, virtualization type and root device name, but not ownerAlias, publicSsmParameterName or public launch permissions. imageState is available at once, and the response carries imageId alone |
| RegisterImage | Name is required; TagSpecification.N must be scoped to image and any other type is refused before anything is written. The whole block device mapping is recorded, and each mapping naming a snapshot is resolved, so a dangling reference cannot be stored — but only that rule of the launch path's set, since the rest is not published for this operation. Architecture defaults to i386 and VirtualizationType to paravirtual, which are AWS's published defaults rather than the x86_64/hvm a modern caller expects; rootDeviceType is derived from whether ImageLocation is present. Response is imageId alone |
| DescribeImages | Explicit resource IDs — ImageId.N asserts existence as of #731, where before it was not read at all; eighteen of forty-three filters, and filter names are checked — see DescribeImages filters. Reports architecture, platform, the root device and the block device mapping. Owner.N and ExecutableBy.N are not read, deliberately: substrate stores only images the account owns, so reading them would report a narrowing that did not happen. A bundled AMI resolves by explicit ID but is not listed by an unqualified describe. Paginates on MaxResults/NextToken, with no published range, and ImageId.N with MaxResults is InvalidParameterCombination — see One offset paginator, shared |
| DeregisterImage | ImageId is required; the record is deleted, so the AMI stops resolving for RunInstances and stops being reported by DescribeImages — and a snapshot it protected becomes deletable. Silently idempotent: an ImageId naming nothing answers return=true, and the ID is not checked for shape, so it is the one AMI operation that does not resolve what it names |
| AllocateAddress | Domain defaults to vpc; the publicIp is derived deterministically from the minted allocationId, so the same event log replays the same address. Four of the eight published response elements are rendered — publicIp, allocationId, domain and networkBorderGroup, the last hardcoded to the request's Region — and the carrier, customer-owned and publicIpv4Pool members are absent because none is modelled. TagSpecification.N is published here and is not read, so tags on a new address are silently dropped and DescribeAddresses reports the address untagged |
| AssociateAddress | AllocationId is required and checked (Explicit resource IDs), and a fresh associationId is minted. When InstanceId names an instance the instance's publicIp and publicDnsName are set, so the association is visible through DescribeInstances as well as DescribeAddresses — but InstanceId and NetworkInterfaceId are not resolved, so an association naming neither, or naming an instance that does not exist, is recorded and answered return=true. AllowReassociation is inert, and the deprecated EC2-Classic PublicIp form is not read |
| DescribeAddresses | Explicit resource IDs; AllocationId.N and PublicIp.N union; eight of ten filters, and filter names are checked; reports tagSet |
| DisassociateAddress | AssociationId is resolved by scanning the region's addresses; the association is cleared and, where it named an instance, the instance's publicIp and publicDnsName are cleared with it. An AssociationId naming nothing answers return=true — which is not a divergence here: AWS publishes "This is an idempotent operation. If you perform the operation more than once, Amazon EC2 doesn't return an error." The page marks AssociationId Required: No while its own prose says "This parameter is required", and substrate follows the prose only to the extent of reading it |
| ReleaseAddress | AllocationId is required and checked; an address still associated is refused with InvalidIPAddress.InUse/400, which is why a release is preceded by a disassociate. The record is deleted rather than marked, so the release is not idempotent — a second one answers InvalidAllocationID.NotFound |
| CreateNatGateway | SubnetId is required and checked (Explicit resource IDs), and the VPC is taken from the subnet rather than the request. TagSpecification.N scoped to natgateway, validated before the gateway is written. ConnectivityType defaults to public; privateIp is derived deterministically from the minted gateway ID, so it replays identically. state is available at once, with no pending to poll through. AllocationId is looked up but not resolved: one naming nothing leaves allocationId and publicIp silently absent from a public gateway rather than refusing the request |
| DescribeNatGateways | Explicit resource IDs; filter names are checked. Paginates on MaxResults/NextToken, over the published 5–1000 range — see One offset paginator, shared |
| DeleteNatGateway | NatGatewayId is required and checked (Explicit resource IDs); the record is kept with state deleted rather than removed, so DescribeNatGateways keeps reporting it — as AWS does, though AWS stops after about an hour and substrate reports it for the life of the store. There is no deleting state to poll through, and a second delete answers deleted again rather than refusing |
| CreateKeyPair | KeyName is required (MissingParameter), and a name already held answers InvalidKeyPair.Duplicate/400. TagSpecification.N scoped to key-pair; tagSet is omitted rather than empty for an untagged key. keyMaterial is a real PKCS#8 PEM private key, so a caller can parse it — but it is always EC P-256, where AWS publishes rsa | ed25519, and KeyType is echoed back unvalidated (defaulting to rsa), so the reported type and the material disagree. keyFingerprint is the SHA-256 digest of the public key DER, where AWS publishes the SHA-1 digest of the DER-encoded private key for an RSA pair. KeyFormat (pem | ppk) is not read — the answer is always PEM — and the published 5,000-per-Region quota is not enforced. The response also renders keyType, which this operation's Response Elements do not publish |
| ImportKeyPair | KeyName and PublicKeyMaterial are both required, and a duplicate name answers InvalidKeyPair.Duplicate. The material is base64-decoded, falling back to the raw bytes when it is not base64, and is fingerprinted as given rather than re-derived, so the fingerprint follows what the caller supplied. keyType is inferred from the SSH prefix — ssh-ed25519 gives ed25519 and everything else, including ecdsa-*, gives rsa. TagSpecification.N scoped to key-pair; no keyMaterial is returned, matching AWS |
| DescribeKeyPairs | KeyName.N and KeyPairId.N union and narrow rather than assert: an unknown name or ID is an empty answer where AWS publishes InvalidKeyPair.NotFound — one of the five selector families whose assertion is unimplemented rather than declined. All five filters are evaluated, and filter names are checked. Reports keyPairId, keyName, keyFingerprint, keyType, createTime and tagSet. No pagination, matching AWS, which publishes none |
| DeleteKeyPair | Either KeyName or KeyPairId; both absent answers MissingParameter, which is substrate's reading — AWS marks both Required: No and publishes no error for the empty request. A key--prefixed value is resolved by scanning the region's key pairs, and anything else is taken as a name. Silently idempotent: a key pair naming nothing answers return=true, matching AWS, whose page publishes no error for a nonexistent key pair. The published keyPairId response element is not rendered — the answer is return alone |
| CreatePlacementGroup | GroupName is required here, where AWS marks it Required: No; a name already held answers InvalidPlacementGroup.Duplicate/400. Strategy defaults to cluster and is stored unvalidated, so partition is accepted without a PartitionCount and SpreadLevel is not read. TagSpecification.N scoped to placement-group — which until #708 was the only way a placement group's tags were settable at all. Renders groupName, groupId, groupArn, strategy, state (available at once) and tagSet; partitionCount is not rendered |
| DescribePlacementGroups | GroupName.N and GroupId.N union and narrow rather than assert, where AWS publishes InvalidPlacementGroup.Unknown — see Which selectors assert existence. Six of seven filters, spread-level inert, and filter names are checked; the page publishes no group-id filter, so group-arn is the ID-shaped one. Reports tagSet, omitted rather than empty for an untagged group. No pagination, matching AWS |
| DeletePlacementGroup | GroupName is required and resolved: a name naming nothing answers InvalidPlacementGroup.Unknown/400, so the delete is not idempotent. AWS's two preconditions — every instance in the group must be terminated first, and a parent of a cluster group cannot be deleted — are not enforced, so a group holding running instances is deleted and those instances keep reporting it in their placement |
| CreateLaunchTemplate | Creates version 1. Does not validate the AMI, matching AWS, which reports mapping problems here through warning. Networking is read from every NetworkInterface.N.* — see Launch template networking. The top-level TagSpecification.N scoped to launch-template tags the template itself, separately from LaunchTemplateData's, which tags what a launch creates |
| DescribeLaunchTemplates | Summary only — no launchTemplateData, matching AWS. Use DescribeLaunchTemplateVersions to read a template's parameters. All four filters, and filter names are checked; LaunchTemplateId.N and LaunchTemplateName.N are read at every index and union. Paginates on MaxResults (1–200) and NextToken; LaunchTemplateId.N with MaxResults is InvalidParameterCombination, LaunchTemplateName.N is not. IncludeManagedResources is inert |
| DeleteLaunchTemplate | |
| CreateLaunchTemplateVersion | SourceVersion inheritance — see Launch template versions |
| ModifyLaunchTemplate | SetDefaultVersion only, which is AWS's only modifiable attribute |
| DescribeLaunchTemplateVersions | Numbers, $Latest, $Default, MinVersion/MaxVersion, MaxResults/NextToken (1–200, through the shared paginator), and the account-wide form. Four of fourteen filters, applied before pagination, and filter names are checked before the template is resolved — as is the token, so a malformed one is refused whether or not the template exists |
| DeleteLaunchTemplateVersions | Reports per version at HTTP 200; the default version cannot be deleted |
| CreateFleet | Instances launch through the RunInstances path, so they are visible to DescribeInstances, need an AMI that resolves, and carry the reserved aws:ec2:fleet-id tag. Partial fulfillment is seedable — see below |
| DescribeFleets | An instant fleet is returned only when its ID is named explicitly, matching AWS; filter names are checked, and it documents no tag filter. Paginates on MaxResults/NextToken, with no published range — and an instant fleet therefore never lands on a paginated page, see One offset paginator, shared |
| DeleteFleets | TerminateInstances=true (and any instant fleet) terminates the fleet's instances, subject to termination protection |
| CreateCapacityReservation | Reserves capacity immediately, in active state, and returns the whole capacityReservation structure. InstanceCount, InstancePlatform and InstanceType are the only required parameters — AvailabilityZone is not one — and InstanceCount is range-checked 1–1000. Honours TagSpecification.N. EndDateType is inferred from EndDate rather than defaulted; a future-dated reservation is refused rather than answered falsely, and the outcome is seedable — see A Capacity Reservation is never consumed |
| DescribeCapacityReservations | CapacityReservationId.N is singular and narrows rather than asserting, while a malformed ID is refused; all twelve filters, and filter names are checked — the page documents no tag filter, so use DescribeTags. MaxResults 1–1000 and NextToken through the shared paginator. A reservation past its EndDate reports expired, derived from the simulated clock |
| CancelCapacityReservation | Sets the state to cancelled and releases the capacity, so availableInstanceCount becomes zero while totalInstanceCount keeps reporting what was reserved. A well-formed ID naming nothing answers InvalidCapacityReservationId.NotFound; a second cancel answers IncorrectState, which is substrate's reading |
| CreateTags | Rejects reserved aws: keys, over-long keys and values, and more than 50 tags per resource; reaches all sixteen taggable ID prefixes and refuses anything else with InvalidID; authorized against every resource named |
| DeleteTags | Rejects reserved aws: keys and over-long keys; resolves the same sixteen prefixes; authorized against every resource named |
| DescribeTags | Every tag in the region, across the same sixteen resource types CreateTags writes. Five filters with wildcards, MaxResults 5–1000 and NextToken (through the shared paginator), and a deterministic order — see Finding a resource by tag |
One rule for an unrecognized filter name
A Filter.N.Name the operation's own API reference does not list is refused, with InvalidParameterValue / The filter "<name>" is not valid for this request, HTTP 400. The check runs before the state scan, so the refusal never depends on whether a resource happened to match: an empty account and a populated one answer a typo the same way.
This replaced three different answers across substrate's filter-parsing EC2 operations — dropped on volumes, snapshots, security groups, route tables, NAT gateways, fleets and images; matched nothing on instances; refused on instance-type offerings — with the one real EC2 gives. DescribeSubnets and DescribeTags were the tenth and eleventh operations the rule covered, and the only two that arrived with both halves at once: the first parsed no Filter.N at all before it gained the filters below, and the second did not exist.
#695 then brought twelve more the same way — every EC2 describe that had never parsed Filter.N at all — so the rule now covers twenty-three operations. That is every EC2 describe substrate serves but one: DescribeInstanceAttribute, whose reference page documents no Filters parameter, so there is no set to check a name against. See Twelve describes gained filters for what changed besides the filters.
This is a behaviour change, and the loudest one in the release that brought it. A misspelled filter name previously came back as a successful response: dropped, so the query returned everything, or match-nothing, so it returned nothing. Either way a consumer's test could pass on a filter that was never applied. It now fails, which is what real EC2 does.
Provenance. Refusal is real EC2's observed behaviour, not its documented behaviour. Neither the filtering guide nor the Filter type says what happens to a name outside the documented set — both are silent. The code InvalidParameterValue is documented; the message text is substrate's own, so dispatch on the code.
What is refused, and what is merely inert
The refusal reproduces AWS's set rather than substrate's coverage, so a filter splits three ways:
| The name | Answer |
|---|---|
| Documented and evaluated | Applied |
| Documented, not evaluated — substrate keeps no state to answer it | Inert: it constrains nothing, so the operation returns every resource it would have returned without it |
| Not documented for that operation | Refused, 400 |
Refusing the middle row would deny filters real EC2 accepts — ipv6-cidr-block-association.state is a filter, not a typo — so it stays accepted, and it is listed by name below rather than left for a caller to discover. Inert is also a change for DescribeInstances, which used to match nothing for such a name: an empty answer is indistinguishable from "the resource does not exist", so a wait loop polls forever, where over-matching fails once and visibly.
The lists live in emulator/ec2_filters.go, one per operation, each transcribed from that operation's own reference page. AWS documents different filters for each, so a name valid on one is refused on its neighbour — tag:<key> most conspicuously (see below).
| Operation | Evaluated | Documented but inert |
|---|---|---|
| DescribeInstances | availability-zone, image-id, instance-id, instance-state-code, instance-state-name, instance-type, key-name, subnet-id, tag-key, tag:<key>, vpc-id | the other 125 — the network-interface.*, block-device-mapping.*, metadata-options.*, capacity-reservation*, private-dns-name-options.*, iam-instance-profile.* and operator.* families, plus architecture, platform, tenancy, root-device-type, owner-id and the rest |
| DescribeImages | architecture, block-device-mapping.snapshot-id, description, hypervisor, image-id, image-type, is-public, name, owner-alias, owner-id, platform, public-ssm-parameter-name, root-device-name, root-device-type, state, tag-key, tag:<key>, virtualization-type | the other 25 — the block-device-mapping.* (bar snapshot-id), image-watermark.*, product-code*, source-image* and state-reason-* families, plus creation-date, the ENA/sriov and kernel/ramdisk names, the Allowed-AMIs and Free-Tier markers and the rest |
| DescribeVolumes | attachment.delete-on-termination, attachment.device, attachment.instance-id, availability-zone, size, snapshot-id, status, tag-key, tag:<key>, volume-id, volume-type | attachment.attach-time, attachment.status, availability-zone-id, create-time, encrypted, fast-restored, multi-attach-enabled, operator.managed, operator.principal |
| DescribeSnapshots | description, encrypted, owner-id, progress, snapshot-id, start-time, status, tag-key, tag:<key>, volume-id, volume-size | owner-alias, storage-tier, transfer-type |
| DescribeSubnets | availability-zone, cidr-block, default-for-az, map-public-ip-on-launch, owner-id, state, subnet-arn, subnet-id, tag-key, tag:<key>, vpc-id, plus AWS's four alias spellings availabilityZone, cidr, cidrBlock and defaultForAz | availability-zone-id/availabilityZoneId, available-ip-address-count, customer-owned-ipv4-pool, enable-dns64, enable-lni-at-device-index, ipv6-native, map-customer-owned-ip-on-launch, outpost-arn, and the three ipv6-cidr-block-association.* and three private-dns-name-options-on-launch.* filters |
| DescribeSecurityGroups | group-id, group-name, vpc-id | description, owner-id, tag-key, tag:<key>, and the twenty ip-permission.*/egress.ip-permission.* rule filters |
| DescribeRouteTables | association.route-table-id, association.subnet-id, vpc-id | association.gateway-id, association.main, association.route-table-association-id, owner-id, route-table-id, tag-key, tag:<key>, and the eleven route.* filters |
| DescribeNatGateways | state, vpc-id | nat-gateway-id, subnet-id, tag-key, tag:<key> |
| DescribeFleets | activity-status, fleet-state, type | excess-capacity-termination-policy, replace-unhealthy-instances |
| DescribeCapacityReservations | availability-zone, end-date, end-date-type, instance-match-criteria, instance-platform, instance-type, outpost-arn, owner-id, placement-group-arn, start-date, state, tenancy — all twelve | — |
| DescribeInstanceTypeOfferings | instance-type, location (both with wildcards) | — |
| DescribeTags | key, resource-id, resource-type, value, tag:<key> — all five AWS documents, all with wildcards | — |
| DescribeInstanceStatus | availability-zone, instance-state-code, instance-state-name | the other fifteen — the event.*, system-status.*, instance-status.* and operator.* families, plus application-status.status, attached-ebs-status.status and availability-zone-id |
| DescribeVpcs | cidr, is-default, owner-id, state, tag-key, tag:<key> | dhcp-options-id, and the eight cidr-block-association.*/ipv6-cidr-block-association.* filters |
| DescribeInternetGateways | attachment.state, attachment.vpc-id, internet-gateway-id, owner-id, tag-key, tag:<key> — all six | — |
| DescribeKeyPairs | fingerprint, key-name, key-pair-id, tag-key, tag:<key> — all five | — |
| DescribeAvailabilityZones | region-name, state, zone-id, zone-name | group-long-name, group-name, message, opt-in-status, parent-zone-id, parent-zone-name, zone-type |
| DescribePlacementGroups | group-arn, group-name, state, strategy, tag-key, tag:<key> | spread-level |
| DescribeAddresses | allocation-id, association-id, instance-id, network-interface-id, private-ip-address, public-ip, tag-key, tag:<key> | network-border-group, network-interface-owner-id |
| DescribeRegions | endpoint, opt-in-status, region-name — all three | — |
| DescribeInstanceTypes | current-generation, instance-type, memory-info.size-in-mib, processor-info.supported-architecture, supported-usage-class, vcpu-info.default-vcpus | the other fifty — the ebs-info.*, network-info.*, instance-storage-info.*, nitro-tpm-info.*, vcpu-info.* (bar default-vcpus) and processor-info.* (bar supported-architecture) families, plus auto-recovery-supported, bare-metal, burstable-performance-supported, dedicated-hosts-supported, free-tier-eligible, hibernation-supported, hypervisor, instance-storage-supported, nitro-enclaves-support, nitro-tpm-support, reboot-migration-support, supported-boot-mode, supported-root-device-type and supported-virtualization-type |
| DescribeSpotPriceHistory | availability-zone, instance-type, product-description, spot-price, timestamp | availability-zone-id |
| DescribeLaunchTemplates | create-time, launch-template-name, tag-key, tag:<key> — all four | — |
| DescribeLaunchTemplateVersions | create-time, image-id, instance-type, is-default-version | host-resource-group-arn, iam-instance-profile, kernel-id, license-configuration-arn, network-card-index, ram-disk-id, and the four ebs-optimized/http-* metadata filters |
tag:<key> is refused on nine operations that document no tag filter at all — neither tag:<key> nor tag-key: DescribeFleets, DescribeInstanceTypeOfferings, DescribeInstanceStatus, DescribeAvailabilityZones, DescribeRegions, DescribeInstanceTypes, DescribeSpotPriceHistory, DescribeLaunchTemplateVersions and DescribeCapacityReservations. Some of those describe resources that plainly carry tags — a fleet and a Capacity Reservation each carry tags and DescribeFleets and DescribeCapacityReservations render them, and DescribeLaunchTemplates documents both tag filters while DescribeLaunchTemplateVersions, next to it in the same family, documents neither. That is AWS's set, not an omission here; to find such a resource by tag, use DescribeTags or Resource Groups Tagging.
tag-key runs the other way: every tag-bearing describe documents it except DescribeTags, which has no such filter because its key filter already asks that question. So tag-key is refused there and accepted on its neighbours — again AWS's set.
Read the tag rows in the other direction and ten describes filter on tags: DescribeInstances, DescribeVolumes, DescribeImages, DescribeSnapshots, DescribeSubnets, DescribeVpcs, DescribeInternetGateways, DescribeKeyPairs, DescribePlacementGroups and DescribeAddresses — the last five since #695, and the two of those #708 had to give tag storage to first. On security groups, route tables and NAT gateways a tag:<key> filter is accepted and inert, so "find the security groups tagged Env=prod" answers with all of them. Real EC2 offers three routes to finding a resource by tag: the describe filters, DescribeTags, and Resource Groups Tagging. Substrate serves all three — the first on ten operations, and the other two in full.
Filter semantics that apply everywhere
- Separate
Filter.Nentries AND; the values inside one filter OR. Both are documented. - Repeated filter names OR their values. Two
Filter.Nentries sharing a name are one filter with the values of both. The second used to silently replace the first everywhere exceptDescribeImages. - Filter names and values are case-sensitive, per the
Filtertype.VPC-Idis notvpc-idand is refused. - A filter naming no values at all matches nothing — on every operation and every filter name,
tag:<key>included. AWS says only that a filter value cannot be null, so matching nothing is substrate's reading;tag-keyis the documented way to ask the any-value question. Note the distinction from a filter carrying one empty value:Filter.1.Value.1=asks for the empty string, whichDescribeTags' own Example 6 does, and that is a value like any other. Until #696,DescribeSecurityGroups,DescribeTagsandDescribeInstanceTypeOfferingsanswered a valueless filter with every resource, because they shared a matcher that read it as an absent filter. An unfiltered answer to a request that asked for a subset is the more dangerous silence of the two: a caller cannot tell it from a genuine match on everything. An absent filter still constrains nothing, as it always has. - Wildcards work in every filter value, on every operation — see below.
- The documented request limits are enforced: at most 50 filters and 200 total filter values per request, and at most 255 characters per filter value. Exceeding any of them is
InvalidParameterValue, HTTP 400. The limits are AWS's, from Using_Filtering's "Filtering considerations"; the error code is substrate's reading, because EC2's error tables publish no filter-limit code — every*LimitExceededcode there names a resource quota, not a request-shape limit. The 255 applies to values, not names: every documented name is a short fixed literal, so a length rule on names could only fire after the refusal already had.
Wildcards in filter values
Since #697, one matcher backs every EC2 describe filter, so these rules hold for all of them — previously only DescribeInstanceTypeOfferings and DescribeTags honored wildcards, the two operations whose reference pages state them outright, and the other nine compared exactly.
| Value | Matches |
|---|---|
c5.2xlarge | that value exactly |
c5.* | the eight c5 sizes — * matches zero or more characters |
c5* | c5 and c5a, since * also matches the a |
t3?.micro | t3.micro and t3a.micro — ? matches zero or one character |
m5.larg\* | the literal string m5.larg*; a backslash escapes a wildcard |
M5.XLarge | nothing — values are case-sensitive |
? matches zero or one character, and AWS's page disagrees with itself about that. The resolution is substrate's, and it is the reading two normative statements and three worked examples support: Using_Filtering's "Filtering considerations" list — the one that governs the API rather than the console — says "a question mark (?) matches zero or one character", and the console's wildcard section says the same and works it through (prod? matches prod and prods, not production). One sentence in the CLI examples says "The ? wildcard matches exactly 1 character" and is refuted by its own example in the next breath, which returns descriptions that are "database" or "database" plus one character, and by database???? returning "database" plus up to four. DescribeTags' Example 4 settles nothing either way: ?ebserver finding webserver or Webserver is consistent with both readings, because the string a zero-or-one ? would additionally match is not in AWS's data set.
Two comparisons deliberately stay exact, because they are not filter values. Identifier parameters — KeyName.N, KeyPairId.N, GroupName.N, GroupId.N, ZoneName.N, ZoneId.N, AllocationId.N, PublicIp.N, LaunchTemplateId.N, LaunchTemplateName.N, FleetId.N, RegionName.N, InstanceType.N — assert the resource exists and answer Invalid*.NotFound when it does not, so globbing them would turn a NotFound contract into a match. And filter names, which are one of a fixed documented set.
A filter whose values happen to be identifiers is still a filter: DescribeRouteTables' association.subnet-id, DescribeSubnets' vpc-id, DescribeSnapshots' volume-id and the rest all glob. So association.subnet-id=subnet-* narrows the answer to the route tables associated with any subnet at all, where the same string in SubnetId.N is a malformed ID.
One case-insensitive comparison was removed: attachment.delete-on-termination on DescribeVolumes accepted True for true, which AWS's "Filter values are case sensitive" does not, and which no sibling boolean filter did.
Twelve describes gained filters
Until #695, twelve EC2 describes never parsed Filter.N at all: the parameter reached the handler and was discarded, so a filtered request was answered with every resource in the region and nothing in the response said the filter had been dropped. That was the same defect #685 fixed for DescribeSubnets, twelve times over, and it is the most consequential kind of silence in an emulator: a consumer's test asserting "the query returns only the tagged VPC" passed while the query was never applied.
All twelve now carry a filter spec, so each one applies what it can answer, accepts the documented names it cannot, and refuses the rest — see the table above for the split per operation. What follows is everything else that had to change to make those filters readable, plus the gaps left standing.
Response members were added, because a filter you cannot read back is not usable. Filtering on a value the response never renders leaves a caller unable to tell a correct answer from a wrong one. Six members are new:
| Operation | New in the response |
|---|---|
DescribeVpcs, CreateVpc | ownerId, tagSet, and state renamed from vpcState |
DescribeInternetGateways, CreateInternetGateway | ownerId, attachmentSet, tagSet |
DescribePlacementGroups | groupArn |
DescribeAddresses | tagSet |
DescribeInstanceStatus | availabilityZone |
The vpcState → state rename is a wire fix: AWS's own samples for both CreateVpc and DescribeVpcs render <state>available</state>, so an SDK caller decoded an empty State from a VPC that was in fact available — and could not distinguish that from a VPC whose state substrate had failed to set. Nothing in substrate pinned the old spelling.
CreateVpc and CreateInternetGateway now render through the same code as their describes, so the two responses cannot drift — the reason DescribeSubnets shares a renderer with CreateSubnet.
An empty tagSet follows the operation's own page, not a house rule.DescribeInternetGateways renders a present-but-empty <tagSet/> and <attachmentSet/> on an unattached, untagged gateway, because its AWS sample does. DescribeVpcs and DescribeAddresses omit an empty tagSet, because theirs do. The inconsistency is AWS's, and reproducing it per page is the point: a sweep "for consistency" would break one of them against its own reference.
Paired identity parameters union rather than intersect. Six operations take two lists that name the same resource two ways — KeyName.N+KeyPairId.N, ZoneName.N+ZoneId.N, AllocationId.N+PublicIp.N, LaunchTemplateId.N+LaunchTemplateName.N, and GroupName.N+GroupId.N on both DescribePlacementGroups and — since #749 — DescribeSecurityGroups. A request carrying both is answered with the resources named by either:
DescribeKeyPairs&KeyPairId.1=key-0abc…&KeyName.1=deploy → both key pairsThis reading is substrate's — AWS documents no rule for the combination. It follows from what AWS does document: an unresolvable name or ID in either list is a NotFound error, which only makes sense if every resource named is expected in the answer. Intersecting would answer empty while reporting both lists resolved.
Two of those lists were also read only at index 1 before #695 — DescribeLaunchTemplates' LaunchTemplateId/LaunchTemplateName, and DescribeSpotPriceHistory' ProductDescription — so a caller naming three templates was answered about one, indistinguishable from the other two not existing. All indices are read now. Naming one resource in both lists still returns it once.
Filters apply before pagination on DescribeLaunchTemplateVersions — and, since #1024, on DescribeLaunchTemplates too. A page holds MaxResults matching records; filtering after paging would answer MaxResults=1 with an empty page and a nextToken, which reads as "nothing matches" to a caller that does not follow the token. DescribeLaunchTemplateVersions' filter names are also checked before the template is resolved, so a typo answers InvalidParameterValue rather than InvalidLaunchTemplateId.NotFound; at DescribeLaunchTemplates the MaxResults and NextToken refusals come first, ahead of the filter-name check, which is the order every operation on the shared paginator uses.
Gaps left standing, each deliberate:
- No pagination was added by #695.
DescribeAddresses,DescribeKeyPairs,DescribeAvailabilityZonesandDescribePlacementGroupsdocument noMaxResultsorNextTokenat all, so there is nothing to add. Every operation here that does document the pair now reads it:DescribeVpcswas the first out of this list when #917 converted it, along withDescribeSubnets,DescribeSecurityGroups,DescribeInstances,DescribeImages,DescribeVolumesandDescribeSnapshots; #1024 convertedDescribeInstanceStatus,DescribeSpotPriceHistoryandDescribeFleets, thenDescribeInternetGateways,DescribeNatGateways,DescribeRouteTables,DescribeInstanceTypesandDescribeInstanceTypeOfferings, and finallyDescribeLaunchTemplates— see One offset paginator, shared. IncludeAllInstancesis not read onDescribeInstanceStatus. AWS defaults it tofalse, meaning "running instances only"; substrate reports every instance whatever its state, so a caller relying on the default to exclude stopped instances gets them. Filter oninstance-state-nameinstead, which is evaluated.AllRegionsis inert onDescribeRegions, and harmlessly so: every seeded region isopt-in-not-required, so the opt-in filtering the parameter controls has nothing to exclude.IncludeUnsupportedInRegionis not read onDescribeInstanceTypes; the seeded catalog is the same in every region.RunInstancesdoes not validateInstanceTypeagainst the catalog, so it launches a typeDescribeInstanceTypesrefuses in the same session. Deliberate, because the catalog is not exhaustive; see RunInstances accepts a type DescribeInstanceTypes refuses for why and for what an instance's reportedinstanceTypedoes and does not mean.- Nine selector families answer an empty set where AWS answers
NotFound.KeyName.NandKeyPairId.N(AWS:InvalidKeyPair.NotFound),GroupName.NandGroupId.NonDescribePlacementGroups(InvalidPlacementGroup.Unknown),ZoneName.N/ZoneId.N,PublicIp.N,DescribeLaunchTemplates' selectors,DescribeFleets'FleetId.N,DescribeCapacityReservations'CapacityReservationId.N,DescribeRegions'RegionName.NandDescribeSecurityGroups'GroupName.N(whoseGroupId.Ndoes assert) all select by membership rather than through an ID assertion — so naming one that does not exist narrows the answer to nothing instead of failing. This read "six of the new selectors" and named four families until #731, which addedDescribeLaunchTemplatesand the two whose reasons are permanent rather than pending, and recorded a reason for each; see Which selectors assert existence, where the two whose reasons are permanent are separated from the five that are merely unimplemented. Within the twelve,InstanceId.N,VpcId.N,InternetGatewayId.N,AllocationId.NandDescribeLaunchTemplateVersions' template selector do assert existence, as doesDescribeInstanceTypes'InstanceType.N(InvalidInstanceType).
RunInstances requires a resolvable AMI
RunInstances must end up with an AMI from some source, or it fails with MissingParameter / "The request must contain the parameter ImageId", HTTP 400.
AWS documents ImageId as Required: No only because a launch template may supply it, so substrate checks after template resolution rather than on the way in. Both of these are valid:
ImageIdgiven directly.LaunchTemplate.LaunchTemplateId(or…Name) naming a template whose data carries anImageId.
The request fails when neither applies — including when the named template resolves but carries no AMI of its own, and when the template name does not exist at all.
Note that ImageId is an optional *string in the typed SDKs, so aws.String("") serializes as absent from the wire: an empty AMI reaches the service rather than being caught client-side. That is the shape this check exists for.
The AMI must also exist. Once a value is in hand, substrate answers the way EC2 does:
| The value | Answer |
|---|---|
ami- + a run of lowercase hex naming an AMI substrate can resolve | the launch proceeds |
ami- + a run of lowercase hex naming nothing | InvalidAMIID.NotFound / "The image ID '…' does not exist", HTTP 400 |
anything else — not-an-ami, ami-EXAMPLE, ami-zzzzzzzz, a bare ami- | InvalidAMIID.Malformed / Invalid id: "…", HTTP 400 |
Syntax is reported before absence, as everywhere else substrate names an EC2 resource — see Explicit resource IDs, whose table this AMI pair now joins. Length is deliberately not part of the syntax rule: AWS itself accepts both the legacy 8-character and the current 17-character form.
Substrate raises InvalidAMIID.Unavailable nowhere. AWS publishes it for an AMI "deregistered and no longer available", and substrate models no such state — DeregisterImage deletes the record — so an unavailable AMI reads as absent, which is what a caller polling for one observes anyway.
CreateFleet inherits the rule, because its instances launch through the same path. CreateLaunchTemplate and CreateLaunchTemplateVersion deliberately do not: AWS reports mapping problems on those operations through the response's warning member rather than by refusing, so an AMI rule there would be a refusal AWS does not make. A template may therefore carry an AMI that no longer resolves; the launch from it is where that surfaces.
Which AMIs resolve
Three kinds of AMI resolve, and nothing else does.
One the caller made — through
CreateImageorRegisterImage, in the caller's own account and region.A bundled public AMI, keyed by the SSM public parameter a consumer discovers it through. Substrate answers nine parameter names with a real image:
Parameter Image /aws/service/ami-amazon-linux-latest/al2023-ami-kernel-default-x86_64Amazon Linux 2023, x86_64 /aws/service/ami-amazon-linux-latest/al2023-ami-kernel-default-arm64Amazon Linux 2023, arm64 /aws/service/ami-amazon-linux-latest/al2023-ami-minimal-kernel-default-x86_64Amazon Linux 2023 minimal, x86_64 /aws/service/ami-amazon-linux-latest/al2023-ami-minimal-kernel-default-arm64Amazon Linux 2023 minimal, arm64 /aws/service/ami-windows-latest/Windows_Server-2022-English-Full-BaseWindows Server 2022 Full Base /aws/service/ecs/optimized-ami/amazon-linux-2023/recommended/image_idECS-optimized Amazon Linux 2023 /aws/service/ecs/optimized-ami/amazon-linux-2/recommended/image_idECS-optimized Amazon Linux 2 /aws/service/canonical/ubuntu/server/24.04/stable/current/amd64/hvm/ebs-gp3/ami-idUbuntu Server 24.04 LTS, amd64 /aws/service/canonical/ubuntu/server/22.04/stable/current/amd64/hvm/ebs-gp2/ami-idUbuntu Server 22.04 LTS, amd64 Any other
/aws/service/…AMI parameter path, which resolves to its family's entry above — a Windows path to the Windows image, an Ubuntu path to Ubuntu 24.04, an ECS path to ECS-on-AL2023, anything else to Amazon Linux 2023 on x86_64. Substrate has answered every AMI-shaped/aws/service/path since it gainedGetParameter, and this is why: an unlisted path must still resolve to something launchable, orGetParameterwould hand out an AMI thatRunInstancesthen refuses. The cost is that two unlisted paths in one family share an image.
The AMI ID a bundled image gets is derived — sha256(region + ":" + parameter), rendered as ami- + 17 hex characters — not random and not written into state at startup. So it is the same across runs, processes and replays, and it differs per region exactly as on AWS, where an AMI ID names an image in one region and nothing in any other. The Go helper emulator.BundledImageID(region, parameterName) returns it, which is what a fixture should name rather than inventing a literal.
Two things follow from bundled images living outside state.
- They are not enumerated.
DescribeImageslists what the account owns, and a public AWS-owned AMI is not that, so an unqualifiedDescribeImagesdoes not return them. This is substrate's reading, not AWS's behaviour — real AWS would return every public image, tens of thousands of them. A bundled AMI is resolvable and nameable; it is not inventory. Naming one inImageId.Ndoes answer with it, because a describe and a launch must agree about which AMIs exist — see Explicit resource IDs, whose rulesDescribeImagesfollows for every other ID. - They are not the caller's. A bundled image reports no owner, so it is not taggable, an
Owners=selfdescribe does not match it, and theowner-idfilter selects nothing for it. It reportsimageOwnerAliasinstead where AWS publishes one — see An AMI reports its architecture, platform and root device. Register your own AMI when the test is about owning one.
Deliberately not bundled: the example IDs AWS's own reference pages use, ami-0abcdef1234567890 and ami-1234567890abcdef0. Generated IaC copies them, and it fails on real AWS when it does. Making them launch here would recreate the exact divergence this check removes, so substrate refuses them like any other AMI that names nothing.
A launch template merges with the request, field by field
Naming a launch template does not replace the request, and the request does not replace the template: the two are merged per field, with the request winning any field it names. AWS's RunInstances reference states the rule directly — "Any additional parameters that you specify for the new instance overwrite the corresponding parameters included in the launch template."
| Field | Request | Template | Result |
|---|---|---|---|
ImageId | ami-request | ami-template | ami-request |
ImageId | absent | ami-template | ami-template |
InstanceType | m5.large | c5.xlarge | m5.large |
InstanceType | t3.micro | m5.large | t3.micro |
InstanceType | absent | m5.large | m5.large |
InstanceType | absent | absent | t3.micro (substrate's default) |
KeyName | k-request | k-template | k-request |
TagSpecification (instance-scoped) | Env=req | Env=tmpl,Team=x | Env=req alone — replace, not merge |
TagSpecification (instance-scoped) | absent | Env=tmpl | Env=tmpl |
TagSpecification (volume-scoped) | absent | Env=tmpl | Env=tmpl on every volume the launch makes |
IamInstanceProfile | p-request | p-template | p-request |
IamInstanceProfile | absent | p-template | p-template |
SubnetId, security groups, AssociatePublicIpAddress | see Launch template networking |
Which version of the template supplies those values is resolved from LaunchTemplate.Version; an absent version means the template's default version, not its latest. See Launch template versions.
Two details are worth stating, because both were wrong before and each fails silently rather than loudly.
Substrate used to consult the template only when the request omitted ImageId. A request naming both an AMI and a template therefore ignored the template entirely — its instance type, key name, user data, subnet, security groups and public-IP preference were all dropped — and the launch still succeeded. The instance simply was not the one that was asked for, which is the hardest kind of infidelity to notice from a test that only checks that the call worked.
The t3.micro default is now applied last, after the template has had its chance. It used to be applied first, and the template fallback then treated t3.micro as a proxy for "the request named no instance type" — so a request explicitly asking for t3.micro alongside a template naming something else got the template's type, exactly inverting the documented precedence. An explicit t3.micro is now honored, and the default applies only when neither side names a type.
A template's TagSpecifications and IamInstanceProfile used to be accepted and stored nowhere, so a template that tagged its instances produced untagged ones and a template naming a role produced an instance with none — with nothing failing to say so. That is worse than a dropped KeyName, because a tag: filter is how IaC finds the resources it just created: DescribeInstances --filters tag:Env,Values=prod simply returned nothing, and a suite asserting on the tags it asked for had an assertion that could not pass.
Both now participate in the merge, with two things worth stating:
- Tags replace rather than merge. A request naming
Env=reqagainst a template namingEnv=tmpl,Team=xyieldsEnv=reqalone;Teamis not inherited. The reference gives noTagSpecifications-specific merge semantics, only the general "overwrite the corresponding parameters" rule quoted above, and replacement is that rule applied to the whole specification. - Substrate's own
aws:ec2:fleet-idstamp does not count as the request naming tags. A fleet instance already carries that reserved key by the time the merge runs, so the fallback tests for a non-reserved key rather than for an empty set — otherwise a fleet launched from a tagging template would silently lose the template's tags. See Reserved tag keys.
The instance and volume scopes are modelled, and they resolve independently. Each is stored in its own field, so a request naming volume tags alone still inherits the template's instance tags and vice versa, and each replaces rather than merges within its own scope. The volume scope applies to every volume the launch materializes, including the root volume substrate synthesizes when no mapping declares one — AWS's structure has no way to tag one mapping's volume differently from another's.
Keeping the two in separate fields is deliberate rather than incidental. Widening the existing instance field to carry a ResourceType discriminator would unmarshal every template already in an event log without error — into an element with an empty resource type and no tags — so every stored template would silently start launching untagged instances. A change that breaks replay while compiling is exactly the shape this project's persisted structures are grown to avoid.
A template may also scope tags to network-interface or spot-instances-request. Those are still recorded nowhere rather than misapplied: they neither reach the launch nor read back from DescribeLaunchTemplateVersions, because a recorded tag no read surfaces is indistinguishable from a discarded one.
Note that a template's instance-scoped tags land on the instance, not on the template — the reference is explicit that "these tags are not applied to the launch template."
A template's tags are subject to both tag rules, so a template is not a second unrestricted tagging path: a TagSpecifications naming an aws:-prefixed key or exceeding the 50-tag limit is rejected at CreateLaunchTemplate and at CreateLaunchTemplateVersion (after any SourceVersion inheritance, so an inherited violation is caught too). The launch checks again, because a template written straight into state by a replayed event log can predate those checks.
The instance profile is stored as the single string the request supplied, matching the shape an instance holds, so it is echoed back from DescribeLaunchTemplateVersions in whichever member it arrived in — arn for an arn:-prefixed value and name otherwise. DescribeInstances surfaces it as an ARN either way, because AWS's instance response shape has no name member; for a template read-back, synthesizing the other member would report the template as naming something the caller never wrote.
Launch template versions
Launch templates are versioned. CreateLaunchTemplate creates version 1, each CreateLaunchTemplateVersion appends the next number, and ModifyLaunchTemplate moves the default.
An absent LaunchTemplate.Version means the default version, not the latest. aws-sdk-go-v2 documents this on LaunchTemplateSpecification.Version — "Default: The default version of the launch template" — and it is the detail worth stating loudest, because a new version does not become the default. A consumer that creates version 2 and launches without naming a version still gets version 1.
LaunchTemplate.Version | Resolves to |
|---|---|
| absent | the default version |
$Default | the default version |
$Latest | the highest version number |
| a number | that version, or InvalidLaunchTemplateId.VersionNotFound |
Both aliases are matched case-insensitively, so a hand-built $latest works. A version that does not exist is an error rather than a silent fallback: a fallback would launch instances from parameters the caller never asked for.
CreateLaunchTemplateVersion's SourceVersion is the asymmetry to know:
- With
SourceVersion, the new version inherits that version's parameters and the request's values overwrite the ones they name. Every parameter substrate stores is inherited, including block device mappings and volume tag specifications — those two were silently dropped until #693, so a version derived from a template with a 25 GiB mapping launched an 8 GiB default root device instead, and nothing in the response revealed the loss. - Without it, the new version holds only what the request names. Nothing is inherited — not from version 1, not from the latest.
DeleteLaunchTemplateVersions reports per version, at HTTP 200: successfullyDeletedLaunchTemplateVersionSet and unsuccessfullyDeletedLaunchTemplateVersionSet. A request naming a deletable and an undeletable version puts one entry in each set and still returns 200, so a caller checking only the status code sees success. The default version cannot be deleted ("you must first assign a different version as the default"), and a deleted version number is never reused.
The responseError.code on a failed item is launchTemplateVersionDoesNotExist for a missing version. For the default-version rejection substrate emits unexpectedError: ResponseError.code is a closed six-value enum in the AWS SDK models (launchTemplateIdDoesNotExist, launchTemplateIdMalformed, launchTemplateNameDoesNotExist, launchTemplateNameMalformed, launchTemplateVersionDoesNotExist, unexpectedError) with no default-version member, and a typed SDK deserializes anything outside it as an unknown variant. AWS's real code for this case is not published and no capture of the rejection exists — the code is the modeled catch-all and the message is the reference's own sentence. Both are inferred, not captured.
Omitting both LaunchTemplateId and LaunchTemplateName from DescribeLaunchTemplateVersions selects the account-wide form, which lists every template's $Latest and/or $Default. As AWS does, it accepts only those two aliases — a version number means nothing across templates — and rejects a request naming neither.
A template stored before versioning existed reads back as version 1, default. Its single stored parameter set is its version 1, synthesized on read, so a replayed event log recorded against an earlier substrate still launches instances and still describes correctly. No event rewriting is involved.
Launch template networking
A launch template's subnet, security groups and public-IP preference are read from its primary network interface — the one whose DeviceIndex is lowest:
LaunchTemplateData.NetworkInterface.N.SubnetId
LaunchTemplateData.NetworkInterface.N.SecurityGroupId.N
LaunchTemplateData.NetworkInterface.N.AssociatePublicIpAddressThat is not a stylistic choice. AWS's RequestLaunchTemplateData has no top-level SubnetId member — a network interface is the only place a template can name a subnet, and the only place AssociatePublicIpAddress exists at all. So a template configured the way AWS requires is precisely the one whose networking substrate used to discard.
Note the group parameter name: the AWS model calls that member Groups but gives it the locationName SecurityGroupId, so real SDKs send SecurityGroupId.N. Substrate accepts Groups.N as well, for hand-built requests.
Precedence when the same value is available from several sources, matching AWS:
| Source | Wins over |
|---|---|
The request itself — SubnetId, or the primary NetworkInterface.N.SubnetId | everything below |
A CreateFleet override's SubnetId | the template (it reaches RunInstances as a request-level value) |
| The launch template's network interface | the default VPC |
| The auto-created default VPC | — |
AssociatePublicIpAddress is three-valued, and only a non-default subnet without MapPublicIPOnLaunch distinguishes them: absent uses the subnet's own behavior, true forces a public IP anyway, and false suppresses one.
Every declared interface is parsed, on both RunInstances and launch templates — see Multiple network interfaces. The flat fields above describe the primary interface, which is what a launch from a multi-interface template resolves its subnet and groups from.
Security groups on an instance
Both RunInstances and DescribeInstances report an instance's security groups as groupSet>item, with groupId and groupName — the same shape as AWS's GroupIdentifier. Groups appear in the order the launch resolved them, whichever source supplied them:
SecurityGroupId.N(orSecurityGroupIds.N) on the request, or the nestedNetworkInterface.N.SecurityGroupId.N/NetworkInterface.N.Groups.Nof the primary interface.- The launch template's network interface.
- The auto-created default VPC's
defaultgroup, when the launch names none.
groupName is omitted when the group cannot be resolved — for example after the group is deleted while the instance it was launched with still exists. The groupId is still reported, because that is what the launch actually recorded; a name is not invented to fill the field.
The top-level groupSet reports the primary interface's groups, matching AWS. A secondary interface's own groups appear on that interface inside networkInterfaceSet.
Multiple network interfaces
RunInstances and CreateLaunchTemplate parse every declared NetworkInterface.N.*, contiguously from 1 and stopping at the first missing index — the same convention every other indexed list follows. Both RunInstances and DescribeInstances report them as networkInterfaceSet>item, which is what the RunInstances reference's own sample response shows:
<networkInterfaceSet>
<item>
<networkInterfaceId>eni-1a2b3c4d</networkInterfaceId>
<subnetId>subnet-0123456789abcdef0</subnetId>
<status>in-use</status>
<privateIpAddress>172.31.1.10</privateIpAddress>
<groupSet><item><groupId>sg-…</groupId><groupName>default</groupName></item></groupSet>
<attachment>
<deviceIndex>0</deviceIndex>
<status>attached</status>
<deleteOnTermination>true</deleteOnTermination>
</attachment>
</item>
</networkInterfaceSet>Identity is DeviceIndex, not the parameter index. AWS documents DeviceIndex as "the position of the network interface in the attachment order", and it is not required to agree with the position the request happens to write it at, so NetworkInterface.1.DeviceIndex=3 and NetworkInterface.2.DeviceIndex=0 makes the second one primary. Interfaces are reported in DeviceIndex order.
The instance's flat subnetId, privateIpAddress and groupSet describe the primary interface, as real EC2 does — they are not superseded by the set. AssociatePublicIpAddress is honored only on the primary, which is what the reference requires: it "can only be assigned to a network interface for eth0".
DeleteOnTermination defaults to true for an interface the launch creates and false for an existing one it attaches by NetworkInterfaceId — deleting an interface the caller brought would destroy something the launch did not make. An explicit value wins over either default.
Substrate's own choices, where the API model does not decide:
- A secondary interface that names no
PrivateIpAddressis given one derived from its instance index and device index, so every interface of every instance in a multi-Countlaunch has a distinct address a test can assert on. The primary's address is the instance's own. - There are no standalone ENI resources —
CreateNetworkInterfaceis not modeled, so an interface exists only as part of the instance that declared it, and aneni-ID is minted for one the request did not name. A launch declaring no interface reports an emptynetworkInterfaceSetrather than a synthesized phantom interface. - Interfaces report
status: in-useand attachmentstatus: attachedimmediately, because substrate's instances are alreadyrunningby the timeRunInstancesanswers; anattachingattachment would contradict the instance state reported beside it. InterfaceTypedefaults tointerface;efaandefa-onlyare recorded as given.NetworkCardIndexis recorded as given and defaults to 0.
Launch-time storage
RunInstances and CreateLaunchTemplate parse every declared BlockDeviceMapping.N.*, contiguously from 1 and stopping at the first missing index, and a launch materializes the EBS volumes those mappings describe. They are real volumes in the same store CreateVolume writes to, so DescribeVolumes is the one place to observe an instance's storage, whether the volume was provisioned separately or created by the launch — which is how real EC2 unifies the two. Naming a VolumeId.N there is an assertion that the volume exists, so a mapping whose volume a test expects is confirmed by an error rather than by a silently empty answer.
DescribeVolumes is also the only place a launch-specified size is observable. AWS's EbsInstanceBlockDevice — the shape an instance renders per device — carries volumeId, attachTime, deleteOnTermination and status, but no size, so a caller that wants to know how large a launch made its root volume has nowhere else to ask.
Every launch produces at least one volume. A real instance always has a root volume whether or not the request mentions one, and DescribeImages already reports an 8 GiB /dev/sda1 mapping for every AMI substrate serves, so a launch that declares no mapping gets a synthesized 8 GiB gp2 volume at /dev/sda1 rather than none. A mapping that does name a root device configures that root volume instead of adding a device beside it, which is AWS's own rule for a mapping whose device name the AMI's mapping already uses.
Root devices are recognized by name: /dev/sda1 and /dev/xvda, the two spellings AWS's device-naming reference gives for the HVM root ("Differs by AMI"). Substrate stores no per-AMI root device to compare against, so an AMI whose real root device is neither is out of reach here.
DeleteOnTermination defaults to true for the root volume and false for a data volume, and that split is substrate's resolution of a conflict between two current AWS pages rather than a value either one simply states.
The API reference documents no default for EbsBlockDevice.DeleteOnTermination at all — its only pointer on the subject is a link that no longer resolves to the content it cites. Two guide pages do, and they disagree:
block-device-mapping-conceptsgives exactly this split: the default is "truefor the root volume andfalsefor attached volumes".preserving-volumes-on-terminationcarries a console-vs-CLI table that lists a data volume created at launch via the CLI as Delete.
Substrate previously followed the second page and applied true to every launch-created volume. The split wins now for two reasons. Both pages agree that the real launch default comes from the AMI's own block device mapping, and substrate's AMIs carry none to inherit — so the value is substrate's choice either way, and the non-destructive side of a genuine ambiguity is the one a test emulator should take: a volume wrongly preserved is visible and correctable, while one wrongly deleted is gone and the caller learns of it by its absence rather than by an error. And a consumer reading block-device-mapping-concepts — the page that describes the mapping shape they are writing — would find substrate wrong rather than opinionated.
A volume attached later with AttachVolume defaults to false, because deleting a volume the caller brought would destroy something the launch did not make. Both pages agree about that one, so nothing here changes it. An explicit value wins over either default.
TerminateInstances settles the volumes accordingly: one whose attachment deletes on termination is removed outright, and one that does not becomes available with no attachment, which is what a preserved volume looks like once its instance is gone.
A fleet reaches mappings only through its launch template — CreateFleet forwards the template reference rather than the caller's own mappings — and the template merge is all-or-nothing, as it is for every other field: a request naming one mapping of its own does not inherit a second from the template.
Modeled per mapping: DeviceName, VirtualName, NoDevice, and Ebs.{SnapshotId, VolumeSize, VolumeType, Iops, Throughput, Encrypted, DeleteOnTermination}. NoDevice suppresses the device outright, and a VirtualName with no Ebs.* members names an instance store device, which is not an EBS volume and has no presence in DescribeVolumes — either way the mapping is recorded intent that materializes nothing.
DescribeVolumes renders iops and throughput on the volume, deleteOnTermination on each attachment, and a tagSet — and filters on volume-id, status, size, volume-type, availability-zone, snapshot-id, attachment.instance-id, attachment.device, attachment.delete-on-termination, tag:<key> and tag-key.
Substrate's own choices, where the API model does not decide:
VolumeTypedefaults togp2.EbsBlockDevicedocuments no default at all;CreateVolumedocumentsgp2, and substrate uses it for both so there is one default in one place rather than two that can drift. Real AMIs specifygp3in their own mapping, which substrate's AMIs do not carry.- A volume takes the instance's Availability Zone, not the region's first: a volume must be in the same zone as the instance it is attached to, so deriving it any other way would produce an attachment real EC2 cannot have.
Not modeled: Ebs.KmsKeyId, left out rather than stored and hidden.
A volume carries tags
A volume is taggable on every path the other EC2 resources are. Until this landed it was the one taggable EC2 resource with no working path at all: CreateVolume accepted TagSpecification.N and stored nothing, CreateTags on a vol- ID answered <return>true</return> and wrote nothing, and DescribeVolumes rendered no tagSet and matched no tag filter. Every call returned success, so an IaC consumer's tag-everything convention appeared to hold and nothing could observe that it had not.
Four paths, all of them now real:
CreateVolumeappliesTagSpecification.Nscoped tovolumeand echoes the result in atagSet. The element carries noomitempty: AWS's own secondCreateVolumeexample renders<tagSet/>for an untagged volume, and an SDK tells a present-but-empty element ("no tags") from an omitted one ("unknown").RunInstancesapplies its volume-scopedTagSpecification.Nto every volume the launch materializes, the synthesized root volume included. The instance scope is unaffected: a request naming both gets each on its own resource.- A launch template's volume-scoped tags reach the launch the same way, on their own merge gate; see A launch template merges with the request, field by field.
CreateTags/DeleteTagsaccept avol-ID like any other taggable ID.
Both tag rules apply on every one of them — the reserved aws: prefix and the 50-tag limit — and on a launch the check runs before the launch loop, because a volume is written after its own instance and a refusal inside the loop would leave the first instance of a multi-count launch behind. AWS documents no volume-specific tag constraint, so there is nothing else to enforce.
DescribeVolumes supports exactly the two tag filters AWS documents for it, tag:<key> and tag-key. There is no tag-value; AWS does not define one for this operation. A tag:<key> filter with no value matches nothing — substrate's reading, since the filtering guide says only that a filter value cannot be null and offers tag-key for the any-value question.
An unrecognized filter name is refused, and one AWS documents that substrate cannot evaluate (encrypted, create-time, fast-restored and six others) is accepted and inert. Both follow the rule that now governs every EC2 describe — see One rule for an unrecognized filter name, which lists this operation's eleven evaluated names and nine inert ones.
CreateVolume invents neither a size nor a zone
AWS puts two combination rules on this operation and marks every member involved Required: No, because in each case the requirement is on a pair:
| Rule | AWS's words | Substrate's answer when it is broken |
|---|---|---|
| Size or snapshot | "You must specify either a snapshot ID or a volume size." | InvalidParameterCombination, 400 |
| Zone name or zone ID | "Either AvailabilityZone or AvailabilityZoneId must be specified, but not both." | InvalidParameterCombination, 400 — for neither and for both |
A Size that is not a positive integer | — | InvalidParameterValue, 400 |
Substrate read neither rule before this. A request naming no size got a silent 8 GiB volume, one naming Size=-1 or Size=eight got the same, and one naming no zone got <region>a — including a request that named the zone by ID, since AvailabilityZoneId was ignored outright. Every one of those returned 200 with a volume ID, which is the failure a refusal exists to prevent: the volume was real, at a size and in a zone the caller never asked for, and the first visible symptom was an attach failing later with nothing to point at.
AvailabilityZoneId now resolves to the zone's name, through the same derivationDescribeAvailabilityZones renders — a zone ID read out of one operation is one the other accepts. Two deliberate asymmetries:
- A zone name is recorded as given and is not checked against the three seeded zones; a zone ID must resolve, because it has to be translated before it can be stored at all. Validating names would be a wider change than the pair rule, and one substrate makes on no zone-taking operation.
- An unresolvable zone ID answers
InvalidParameterValue, notInvalidParameterCombination.
The refusal codes are substrate's reading: CreateVolume's Errors section is empty, so it publishes no operation-specific error, and InvalidParameterCombination's client-error gloss is the only one whose shape fits — "The request includes an incorrect combination of parameters, or a missing parameter."
Still not enforced, and stated here rather than left to be found: AWS's per-type size ranges (gp2 1–16384, io1 4–16384, st1/sc1 125–16384, standard 1–1024, …). Iops and Throughput also keep their tolerance — an unparseable value leaves the field at zero and omits it from the response. That differs from Size because the absences differ: a volume must have a size, so an unusable Size has no defensible reading, while omitting Iops is the ordinary case for the five volume types that do not take one. The 8 GiB ec2DefaultVolumeSizeGiB still backs the launch path, where a mapping that omits a size is legal and AWS does document a default.
A mapping AWS refuses is refused, with InvalidBlockDeviceMapping
A launch carrying a mapping real EC2 rejects fails here too, before anything is written. Through the versions that first parsed these mappings, every mapping substrate could parse was accepted and materialized, so a consumer whose IaC carried an invalid mapping got a green test and a failure on real AWS — the same class of defect an empty ImageId used to have.
Eight refusals:
| Refused | Provenance |
|---|---|
An Ebs structure naming neither Ebs.VolumeSize nor Ebs.SnapshotId | Documented verbatim on EbsBlockDevice.VolumeSize: "You must specify either a snapshot ID or a volume size." |
Ebs.SnapshotId naming a snapshot the account does not hold | InvalidSnapshot.NotFound (or InvalidSnapshotID.Malformed for the syntax) — both codes documented in EC2's client-error table |
Ebs.VolumeSize smaller than the named snapshot's | Documented verbatim on EbsBlockDevice.VolumeSize: "You can specify a volume size that is equal to or larger than the snapshot size." |
Ebs.Throughput on an explicitly named type that is not gp3 | Documented verbatim: "This parameter is valid only for gp3 volumes." |
Ebs.Iops on an explicitly named standard, st1 or sc1 | The sibling launch-template shape — substrate's reading, see below |
An unparseable numeric value for Ebs.VolumeSize, Ebs.Iops or Ebs.Throughput | Substrate's own |
Two mappings naming one DeviceName | Substrate's own — AWS documents no rule |
A VirtualName beside any Ebs.* member | Substrate's own — AWS documents no rule |
The two snapshot refusals carry different codes on purpose. A snapshot substrate cannot find is an InvalidSnapshot.NotFound about the ID — which is what a caller naming a snapshot from another account or a previous run has done — while a size below the snapshot's is an InvalidBlockDeviceMapping about the mapping, like every other row above.
The error code is documented — EC2's client-error table lists InvalidBlockDeviceMapping as "A block device mapping parameter is not valid. The returned message indicates the incorrect value." The 400 is a class-level inference: the table says only that client errors are "accompanied by a 400-series HTTP response code", and every message is substrate's own, since AWS publishes no wording. Each message names the offending device, because a request can carry many mappings and the code alone does not say which was refused.
The scope is only what the API model states. Per-type size and IOPS ranges are deliberately not encoded even though the reference lists them: they change, and a stale range is a false deny — worse than the silence it replaces, because a caller cannot work around a refusal of a request AWS accepts. For the same reason both type-scoped rules key off the explicitly named VolumeType, never the resolved one. Substrate resolves an absent type to gp2, but on real EC2 it comes from the AMI's own mapping, commonly gp3, so refusing Throughput on a mapping that named no type would refuse a launch real EC2 accepts.
Three reading calls worth stating outright:
- The size-or-snapshot sentence lives on
EbsBlockDevice.VolumeSize, a member of theEbsstructure. A mapping carrying noEbsstructure at all — a bareDeviceName— names no EBS block device for the requirement to have a subject, so it is still accepted and still takes substrate's 8 GiB default. Ebs.Iopsis refused by a short deny list rather than AWS'sio1 | io2 | gp3allow list. The "supported forio1,io2, andgp3volumes only" sentence is onLaunchTemplateEbsBlockDeviceRequest.Iops, not on theEbsBlockDeviceshapeRunInstancesaccepts, and the very same member's own paragraph — on both shapes — explains whatIopsmeans "forgp2volumes". AWS contradicts itself inside one member, so substrate refuses only the three types that appear in neither list and takes the permissive reading ofgp2.- A mapping naming a snapshot and no size takes the snapshot's size, which AWS documents on the same member: "If you specify a snapshot, the default is the snapshot size." It took substrate's 8 GiB default instead until #689, so a restore from a 30 GiB snapshot produced an 8 GiB volume.
CreateVolumehad the identical gap independently and applies the same two rules through the same comparison.
A refusal writes nothing. The validator runs after a launch template has been merged in — so a mapping that reaches a launch through a template is refused at RunInstances time, and one validator covers requests, templates, template versions and fleet launches alike, since a fleet reaches mappings only through a template — and before the default-VPC branch, which commits a VPC, subnet, security group, internet gateway, route table and four index mutations. A refusal past that point would leave state the next request in the same test could see.
CreateLaunchTemplate does not refuse — it warns. Its response carries a documented warning member of type ValidationWarning that exists precisely for "parameters or parameter combinations that are not valid", and its Errors section lists none — so a 400 there would be substrate's invention. That an invalid block device mapping belongs in that warning rather than in an error is substrate's reading; AWS documents the member's purpose but never says which validations use it.
Since #693 both CreateLaunchTemplate and CreateLaunchTemplateVersion render it: warning (singular) holding errorSet>item of {code, message}. The warning and the refusal are the same diagnosis by construction — one collector produces the problems and the refusal is a thin wrapper returning its first — so the code and message a caller reads at create time are byte-identical to what the launch would have refused with. The warning is wider in one respect: it reports every problem rather than the first, because AWS documents one entry "for each issue that's found". A valid template's response carries no warning element at all.
| Operation | On an invalid mapping |
|---|---|
CreateLaunchTemplate | 200 + warning; the template is created |
CreateLaunchTemplateVersion | 200 + warning; the version is created, including for a mapping inherited through SourceVersion |
RunInstances | 400 InvalidBlockDeviceMapping (or InvalidSnapshot.NotFound); nothing is written |
CreateFleet | as RunInstances, since a fleet reaches mappings only through a template |
The mapping a warning is about reads back through DescribeLaunchTemplateVersions, which renders blockDeviceMappingSet>item — deviceName, virtualName, noDevice and the full ebs structure. noDevice is the present-and-empty element AWS documents, and deleteOnTermination keeps its three states rather than collapsing an unstated value to false. Nothing rendered the member before #693, so a template's mappings were write-only.
An instance reports its own block devices
DescribeInstances, RunInstances and DescribeInstanceAttribute each render a blockDeviceMapping set for an instance, so a caller no longer has to go to DescribeVolumes and filter on attachment.instance-id to learn which volume is on which device.
The set is derived from the volume records, not from the mappings the launch recorded. That is what makes it track reality rather than intent: a volume attached with AttachVolume after launch appears, one detached stops appearing, and an instance store device never appears because it never became a volume. EC2Instance carries no block-device field of its own, so the volumes are also the only available source.
Each item is AWS's InstanceBlockDeviceMapping — deviceName plus an ebs sub-element — and the ebs element carries the four members AWS's own sample shows: volumeId, status, attachTime and deleteOnTermination. AWS's EbsInstanceBlockDevice has eight members and no size, which is why this set does not replace DescribeVolumes for the question above: the other four (associatedResource, ebsCardIndex, operator, volumeOwnerId) are omitted rather than defaulted, since substrate records nothing behind them and a fabricated volumeOwnerId would be indistinguishable from a real one. status uses AWS's four-value AttachmentStatus enum, which is not the five-value volume-side enum DescribeVolumes renders.
Two substrate choices, where the API model does not decide:
RunInstancesrenders the set populated. The reference's onlyRunInstancessample response emits<blockDeviceMapping />empty, on apendinginstance whose request declared no mappings at all. Substrate's instances are running by the timeRunInstancesanswers and their volumes already exist — the same groundnetworkInterfaceSetis rendered on.- The set is ordered by device name, with the volume ID breaking a tie.
DescribeInstancesstates outright that its own order may vary, so no fidelity claim rests on this; a deterministic emulator answering one request two ways would break the guarantee the project exists for.
Every resource a request names is authorized
Three EC2 operations name more than one resource, and each is decided against all of them: RunInstances, and the tagging pair CreateTags/DeleteTags. The rule is the service-agnostic one Organizations' MoveAccount note states; these are EC2's instances of it. Every other EC2 operation still resolves to one ARN.
A launch is authorized against every resource it names
RunInstances is the EC2 action the Service Authorization Reference marks with the most required resource types — image, instance, network-interface, security-group and subnet — and the caller's policies are evaluated against every one of them. A policy that allows ec2:RunInstances on the AMI and the instance but not the subnet cannot launch into that subnet, and, in the other direction, an ARN-scoped Deny on one subnet, AMI or security group fences off every launch that reaches it. That is what a policy written to keep workloads out of a shared or private subnet depends on.
A permission boundary is applied to every one of those resources too, since a boundary checked against a subset is not a boundary. Each ARN is matched against the tags of the resource it names, so an aws:ResourceTag condition written about the subnet cannot be satisfied by a tag on the AMI. The denial names the first resource the policies do not allow — resolved in a fixed order of image, subnet, security groups, network interfaces, instance — which is the only place the missing ARN surfaces, and so the ARN a caller has to add.
The AMI's ARN carries no account ID (arn:aws:ec2:{region}::image/{ami}), which is the format the Service Authorization Reference gives, because an AMI is shareable. The other four are arn:aws:ec2:{region}:{account}:{type}/{id}. Two of them name resources that do not exist when the decision is made, so they are wildcards: the instance is always instance/*, and an interface the launch creates is network-interface/*, while one the request brings by ID is named. The cost of that is a real limit — a policy scoped to instance/i-* matches on AWS and not here, because the statement is the pattern and * is the value it is matched against.
Resources a launch template supplies are authorized as if the request had named them, under the same field-by-field precedence the launch itself uses, so a policy scoped to one subnet fences off a launch that reaches another through a template. AWS states this only in CreateFleet's description: "Resource-level permissions for this action do not include the resources specified in a launch template. To specify resource-level permissions for resources specified in a launch template, you must include the resources in the RunInstances action statement." A template that cannot be read contributes nothing to the resource list rather than failing the request, so a missing template still answers InvalidLaunchTemplateId.NotFound rather than a denial.
A resource the request does not name is skipped, not resolved to * — * for an unknown resource would widen the policy the caller wrote instead of narrowing it.
A launch that omits SubnetId is authorized against the default VPC's resources
A launch that names no subnet does not run without one: substrate resolves the default VPC's default subnet and its default security group — the VPC being the 172.31.0.0/16 one substrate auto-creates on the first launch that needs it — creating them when the account has none. Both are part of the decision, resolved from state before it is made:
| State when the launch arrives | Subnet in the decision | Security group in the decision |
|---|---|---|
| A default VPC with a default subnet | that subnet's ARN | the default VPC's group, if the request named none |
| A default VPC with no default subnet | subnet/* | the default VPC's group, if the request named none |
| No default VPC | subnet/* | security-group/* |
A launch that names its own security groups is authorized against those and not additionally against the default one, because that is what it attaches. The default subnet applies only when nothing else supplied one — a request parameter, a nested NetworkInterface.N.SubnetId and a launch template all take precedence, in the same order the launch itself applies them.
The two wildcards are resources the launch is about to create, which is why they are wildcards rather than skipped: the skip-don't-widen rule above is about a resource the request omits. A Deny on subnet/* therefore still matches, and a least-privilege Allow naming one specific subnet correctly refuses a launch that will mint a different one.
The resource ARN is substrate's reading, not AWS parity. The Service Authorization Reference's RunInstances scenario rows require subnet* only in the EC2-VPC-EBS-Subnet and EC2-VPC-InstanceStore-Subnet scenarios, so a launch that omits the subnet is, read straight, not authorized against one. Substrate diverges because a guardrail a caller defeats by omitting a parameter is useless for the purpose substrate exists for: a test that proves a policy keeps workloads out of a subnet has to fail when the launch lands in it.
AWS's own recommended subnet guardrail is a condition key rather than a resource ARN — ec2:Subnet on network-interface/* — and substrate populates that too, from the same resolution, so both spellings of the guardrail hold for a launch that names no subnet. See the condition keys a launch's networking resources carry.
A launch's networking resources carry ec2:Subnet and ec2:Vpc
These are substrate's first ec2:-prefixed condition keys, and they are what AWS's own example policies reach for to fence a launch into one subnet or one VPC. The subnet example is a Deny: "you could create a policy that denies users permissions to launch an instance into any other subnet. The statement does this by denying permission to create a network interface, except where subnet subnet-12345678 is specified."
{
"Effect": "Deny",
"Action": "ec2:RunInstances",
"Resource": "arn:aws:ec2:us-east-1:111122223333:network-interface/*",
"Condition": {
"ArnNotEquals": {
"ec2:Subnet": "arn:aws:ec2:us-east-1:111122223333:subnet/subnet-12345678"
}
}
}Both keys' values are full ARNs. AWS says so outright for the VPC — "To specify a VPC for the ec2:Vpc condition key, you must specify the full ARN of the VPC" — and its machine-readable service reference declares the Type of both keys as ARN. Its two example policies nonetheless use different operator families, ArnNotEquals for ec2:Subnet and StringEquals for ec2:Vpc; both work here, because a full ARN satisfies either.
Each key is scoped to the resources the reference lists it on, not merged into the request, so a condition written about the interface cannot be satisfied by the AMI:
| Resource in the decision | ec2:Subnet | ec2:Vpc |
|---|---|---|
network-interface/* or network-interface/{id} | the launch's subnet | the launch's VPC |
subnet/{id} or subnet/* | — | the launch's VPC |
security-group/{id} | — | that group's own VPC |
image/{ami}, instance/*, security-group/* | — | — |
A security group reports the VPC from its own record rather than the launch's, because a group in another VPC is exactly the mismatch such a policy is written to catch. The VPC itself comes from the default-VPC lookup when the launch names no subnet, and otherwise from the resolved subnet's record — including a subnet a launch template supplied, on the same field-by-field precedence the rest of the decision uses.
A key with nothing to report is absent, not "*". A launch with no subnet and no default VPC is about to create both, so there is no subnet and no VPC to name; a wildcard value would be an ARN-shaped string no caller's ArnEquals could ever match. Two consequences, both AWS's documented behaviour rather than substrate quirks:
- An
Allowgated on an absent key does not match, so such a launch is refused — which is the safe direction, and the reason the omitted-not-wildcarded choice is not a loophole. - A
Denygated on one with a positive operator does not fire, and a set qualifier (ForAllValues:) over it is vacuously true — which is why AWS says not to use set operators on single-valued keys.
Not covered: ec2:Vpc on any action other than RunInstances — the reference lists 28 action×resource pairs carrying ec2:Subnet service-wide, and this covers the launch path only.
A fleet's launches are authorized, not exempted
CreateFleet launches its instances internally, through the same RunInstances path a direct call takes, so the API's authorization pipeline sees only the CreateFleet request. Each pool's launch is therefore authorized separately, against the resources that pool resolves to — its launch template's AMI, the override's subnet, and the instance and interface wildcards. A caller needs ec2:RunInstances on those resources in addition to ec2:CreateFleet, which is what AWS requires too: "Resource-level permissions for this action do not include the resources specified in a launch template. To specify resource-level permissions for resources specified in a launch template, you must include the resources in the RunInstances action statement."
Every pool is decided before the first one launches, so a refused fleet leaves no instances and no fleet record behind.
Not covered:
volume. Its asterisk appears only inside the reference's scenario rows, never on the action's own resource list, and the ID does not exist until the launch runs. Requiring it would refuse a policy naming exactly the five documented types.- The launch-template ARN, which the reference does not mark required for
RunInstances— so requiring it would refuse the same policy. - Most of the EC2 condition keys (
ec2:InstanceType,ec2:IsLaunchTemplateResourceand the rest). Resource resolution is what this covers; the condition keys substrate populates are theaws:-prefixed ones,ec2:CreateActionon the tagging pass, andec2:Subnet/ec2:Vpcon a launch's networking resources.
Tagging is authorized against every resource it names
CreateTags and DeleteTags accept up to 1000 resource IDs in ResourceId.N, of mixed types, and the caller's policies are evaluated against every one of them. A Deny naming any single resource refuses the whole call, and the denial names that resource — so a policy written to keep a shared VPC or a production volume out of reach of a pipeline that re-tags everything it can see is a boundary. In the other direction, a least-privilege Allow listing the ARNs a call touches permits it; a policy missing one refuses the call and says which ARN to add. A permission boundary applies to every resource named, and each ARN is matched against the tags of the resource it names.
Both actions have no required resource type at all — the Service Authorization Reference marks zero of the 105 it lists for either one. Authorizing against every resource named is therefore substrate's reading of AWS's general rule for an action naming several resources, supported by AWS's own scoped tagging examples, which write a resource ARN as the Resource of an ec2:CreateTags statement and would be pointless if only one resource of a batch were evaluated. The two actions resolve identically. They differ in condition-key surface, and the difference that matters is modelled: neither carries ec2:CreateAction, because neither is a create. See a tagged create is authorized twice.
The ARN a caller writes uses the resource type the reference documents, which for five of the nine taggable types is not the abbreviation substrate's own IDs suggest:
| ID prefix | ARN resource type |
|---|---|
i- | instance |
vol- | volume |
vpc- | vpc |
subnet- | subnet |
sg- | security-group |
igw- | internet-gateway |
rtb- | route-table |
eipalloc- | elastic-ip |
nat- | natgateway |
An ID whose prefix names none of those is skipped, not denied: substrate's handler treats such an ID as a no-op, and AWS answers an unparseable tagging ID with InvalidID ("The specified ID for the resource you are trying to tag is not valid") rather than AccessDenied. Skipping is also the only safe direction — widening it to * would hand the caller the one resource a broad Allow matches. A call naming only such IDs is decided against a single *, as an EC2 operation naming no resource substrate can resolve is.
aws:RequestTag/{key} is populated from the tags a tagging call carries (Tag.N.Key/Tag.N.Value), so the "tag only with the keys and values we prescribe" condition AWS's tagging guardrails are written around evaluates. DeleteTags treats a tag's value as optional, and a request naming only a key records the empty string — indistinguishable from an absent key to every condition operator, including Null.
The refusal is whole: authorization runs before the handler, so a call that names one resource the policy denies tags none of them — the same all-or-nothing shape the reserved-key and tag-limit checks already have.
aws:TagKeys is populated too — the sorted list of keys the request names — so the ForAllValues:StringEquals form several of AWS's DeleteTags examples are written in now evaluates. See multivalued condition keys for the set-qualifier rules those examples depend on, including why AWS pairs them with Null.
An operation naming resources by ID is decided against every one of them
Four request parameters resolve to the resources they name, so the operations carrying them are authorized against real ARNs and against those resources' own tags rather than against the literal *:
| Parameter | Resolves to | The bundled actions that need it |
|---|---|---|
InstanceId.N | instance/<id> | — (it is the one parameter that already resolved) |
GroupId.N | security-group/<id> | ec2:DeleteSecurityGroup |
RouteTableId.N | route-table/<id> | ec2:DeleteRouteTable, ec2:DeleteRoute |
InternetGatewayId.N | internet-gateway/<id> | ec2:DeleteInternetGateway |
The list is short for a reason: each entry is a parameter one of the two ec2:ResourceTag statements in the bundled AWS managed policies names its target with. DeleteRoute appears under RouteTableId because the route table is the resource AWS authorizes a route change against. The indexed and un-indexed spellings are the same request, matching what the handlers themselves accept.
Every ID a request names is authorized, not the first alone (#744). A TerminateInstances naming three instances was decided against InstanceId.1's ARN and InstanceId.1's tags, so a policy allowing instances tagged Env=dev and denying the rest permitted a call naming one dev instance and two production ones — provided the dev one came first. Each named resource now carries its own ARN and its own tags into its own decision, which is the same reading tagging already applies to ResourceId.N and AWS applies to any action naming several resources. Two consequences a consumer's test will see:
- The denial names the first resource the policy does not allow, in fixed parameter order and then request-index order. That is deterministic and replay-stable, and it is the resource a caller can act on: drop it from the batch and the call proceeds.
- A permission boundary sees the whole batch too. It is loaded once and evaluated against each resource, so a boundary naming only the first instance refuses the rest.
The operation decides whether a request has a resource at all (#762). An ID is resolved only when AWS documents the operation as supporting that resource type. Whether a request has a resource is not a property of its parameters: AWS publishes, per action, which resource types the action supports, and an action supporting none is authorized against * however many identifiers the request carries. ec2:DescribeInstances is exactly that action, so a policy scoping it to an instance ARN grants nothing on AWS — and granted precisely those instances here, which is the direction that matters, because a test written against substrate passed while the deployment behind it failed.
The classification is not a hand-written operation list. It comes from AWS's Service Reference Information, whose per-service JSON names the resource types each action supports and where an action with no Resources list supports none. Snapshots of ec2 and iam are vendored under emulator/authzref, generated into a Go table, and make authz-reference-check fails if the table and its snapshots disagree — so a refreshed snapshot cannot silently widen a decision. The Service Authorization Reference HTML pages carry the same data but render their tables in JavaScript and cannot be read by a fetch — the dead end recorded for both ELB pages under ELB v2, which is why ELB's Names.member.N is still decided against *.
An operation absent from that table is also decided against * — the same answer AWS gives an action with no published resource types, and the safe direction of the two: a resource narrower than the one AWS would use is a grant substrate would be inventing, and inventing a grant is worse than inventing a refusal.
Two consequences worth stating. GroupId is overloaded — DescribePlacementGroups reads pg- IDs through it — and that operation supports no resource-level permissions, so it is decided against * and the placement-group ARN is never built for it; the name-form ARN translation is still exercised by CreateTags, which AWS does scope to placement-group. And an ec2:ResourceTag/<key> condition on a describe can no longer match, because with the request resource * there is no resource whose tags to read. That is AWS's behavior too, and it is why the bundled policies put those conditions on the mutating operations.
aws:ResourceTag/<key> and ec2:ResourceTag/<key> both report the resolved resource's tags. EC2 reports a resource's tags under both prefixes because AWS publishes both — aws:ResourceTag as a global key and ec2:ResourceTag as EC2's own — and AWS's own bundled policies use the service-specific one. The two prefixes are not folded together: they are two keys, not one key compared case-insensitively, and only EC2 gets a second prefix. Reporting, say, an S3 bucket's tags under an s3:ResourceTag/ key AWS does not publish would honor a policy real AWS ignores, which is a divergence in the granting direction.
The bundled statement that motivated the prefix conditions on ec2:ResourceTag/aws:cloudformation:stack-name, and that tag is not one a caller can set — CreateTags refuses a key beginning with aws:, as AWS does. CloudFormation stamps its own tags on the resources it creates (#746 for EC2, #765 for everything else), which is what makes ManagedCloudformationResourcesCleanupPolicy's statement satisfiable rather than inert: a resource a stack creates carries aws:cloudformation:stack-name, aws:cloudformation:stack-id and aws:cloudformation:logical-id, so the bundled statement's StringLike on EC2ContainerService-* now grants the four EC2 deletes for a resource an EC2ContainerService-… stack created and still refuses them elsewhere.
The stamp is written to state by the deployer rather than sent as a TagSpecification, for two reasons a consumer can observe: a synthesized CreateTags would refuse the aws: keys it is carrying, and every synthesized request is authorized, so the parameter route would newly require ec2:CreateTags of every principal whose deployment succeeds today. The three values are the stack's own — the stack ID is the same ARN AWS::StackId resolves to, not a second derivation of it — and the write is an upsert, so re-deploying a stack rewrites the three rather than accumulating them. aws: keys are already excluded from the 50-tag limit, so the stamp cannot push a caller's own tags over it.
What the stamp reaches
Forty CFN resource types are stamped, across twenty-one services, and each tag is readable through that service's own tag call rather than only out of state:
| Service | CFN types stamped | Read back with |
|---|---|---|
| EC2 | VPC, Subnet, SecurityGroup, InternetGateway, RouteTable, EIP, NatGateway, LaunchTemplate, Instance | DescribeTags |
| S3 | AWS::S3::Bucket | GetBucketTagging |
| Lambda | AWS::Lambda::Function | ListTags |
| SQS | AWS::SQS::Queue | ListQueueTags |
| DynamoDB | AWS::DynamoDB::Table | ListTagsOfResource |
| ELBv2 | LoadBalancer, TargetGroup, Listener, ListenerRule | DescribeTags |
| Step Functions | AWS::StepFunctions::StateMachine, AWS::StepFunctions::Activity | ListTagsForResource |
| ECR | AWS::ECR::Repository | ListTagsForResource |
| ECS | AWS::ECS::Cluster, AWS::ECS::Service, AWS::ECS::TaskDefinition | ListTagsForResource |
| EFS | AWS::EFS::FileSystem, AWS::EFS::AccessPoint | ListTagsForResource |
| ElastiCache | AWS::ElastiCache::CacheCluster | ListTagsForResource |
| RDS | AWS::RDS::DBInstance, AWS::RDS::DBCluster, AWS::RDS::DBSubnetGroup | ListTagsForResource |
| Kinesis | AWS::Kinesis::Stream | ListTagsForStream |
| Glue | AWS::Glue::Database | GetTags |
| KMS | AWS::KMS::Key, AWS::KMS::ReplicaKey | ListResourceTags |
| Secrets Manager | AWS::SecretsManager::Secret | DescribeSecret |
| SNS | AWS::SNS::Topic | ListTagsForResource |
| SSM | AWS::SSM::Parameter | ListTagsForResource |
| ACM | AWS::CertificateManager::Certificate | ListTagsForCertificate |
| CloudFront | AWS::CloudFront::Distribution | ListTagsForResource |
| AWS Config | AWS::Config::ConfigRule, AWS::Config::ConfigurationRecorder | ListTagsForResource |
The last fifteen services carry #819's twenty-three types, added in three parts. Two conditions decided that cut, both checked against the owning plugin rather than assumed: the service's tag record has a merge arm behind substrate's one tag writer — the same writer the Resource Groups Tagging API uses, so a stamp and a TagResources call cannot merge differently — and the physical ID CloudFormation records is already exactly the identifier that plugin keys its record by. A service that fails the second condition needs the key re-derived, which is where a stamp lands somewhere nothing reads.
Twelve of the twenty-three waited on the first condition — their services kept tag state no merge arm reached, so there was nowhere for a stamp to land that the owning service would read, and TagResources could not reach them either: one defect with two symptoms, closed as #835. Seven of those twelve then failed the second condition and are resolved individually rather than from the type table, and the reasons are worth naming because each is a way the two identifiers can silently differ: a KMS key's, a secret's and a topic's physical ID is an ARN where the record's key holds a bare ID or name, so each is resolved through its own service's ARN resolver; a distribution's key carries no Region, because CloudFront is global and its ARN's Region field is empty; a parameter's key carries the leading / that PutParameter adds and the physical ID does not; and an ECS service's and task definition's key has four segments, one of which — the cluster, and the revision — appears only in the ARN.
Read-back is through whatever the owning service calls its tag-reading operation, which is not always ListTagsForResource: ACM publishes ListTagsForCertificate, and Secrets Manager publishes no tag-reading operation at all, so a secret's tags are read where a caller reads them — off DescribeSecret.
Two resolvers sit behind the one writer. EC2's keys on the physical ID's prefix, because an EC2 ID carries its type; every other service's keys on the CloudFormation resource type, because outside EC2 a physical ID is a bare name — a bucket named orders and a queue named orders are the same string, so there is nothing in the ID to switch on. EC2's resolver is tried first, and a type neither claims is skipped.
A resource whose service models no tags is skipped silently, and that is deliberate rather than an omission. AWS declines to publish an exhaustive propagation list of its own: "The propagation of stack-level tags to resources, including tags with the aws: prefix, varies by resource type. For example, tags aren't propagated to Amazon EBS volumes that are created from block device mappings." So substrate states its rule instead — a resource is stamped when substrate models tags for its service and a caller can read the tag back through that service's own API — and names what that leaves out, in three groups. There is no log line per skipped resource: a stack creates far more of those than of the kinds that can be stamped, so a warning each would bury a real one.
Tag state but no tagging operation: out of scope. An IAM user or role, an API Gateway v1 or v2 API and a Cognito user pool each carry a tag field in substrate's records, but no operation reads or writes it. Stamping them would write a tag no API call could observe, and an observation a caller cannot make is outside substrate's emulation boundary — the boundary is what an AWS API call can see, not what a record happens to hold (see the Scope section of CLAUDE.md). IAM has a second, independent reason: no AWS page states that an IAM entity receives the stamp, and substrate's TagRole refuses an aws:-prefixed key, so a stamped IAM tag would be one no caller could ever set or remove. These three are therefore decided out rather than deferred. Should one of them gain a tagging surface, the stamp becomes observable and the decision is worth revisiting on that ground alone.
No tag state at all: deferred, on AWS's own licence. Roughly twenty services the deployer can create resources in keep no tag state whatsoever, among them CloudWatch Logs, EventBridge, Route 53, Athena, CodeBuild, CodePipeline, CodeDeploy, CloudTrail, OpenSearch, WAFv2, Backup, Budgets, Firehose, MSK, Transfer, SES v2 and AppSync. Each needs a tag store and a tagging API before a stamp could be observed at all, which is a per-service piece of work rather than a line here. The omission is licensed by the sentence quoted above: propagation "varies by resource type", so a partial cut is what AWS itself describes rather than a shortfall against a published list. #819 keeps the list in one place; it is split per service when one is picked up.
Every type that had a tagging surface and no stamp now has both. The missing piece for twelve of them was never a resolver arm alone but an arm in that shared writer, so the same gap also meant the Resource Groups Tagging API could not tag them: one defect with two symptoms, closed as #835 a row at a time — ECS's service and task definition, an RDS DB cluster and DB subnet group, a Step Functions activity, an ACM certificate, a CloudFront distribution, a KMS key, an SNS topic, a Secrets Manager secret and an SSM parameter — each keying through the one state-key builder the owning service's own tag operation uses, and each merging its record as raw JSON so a member the writer does not model is preserved rather than dropped. The last piece of that was the ECS namespace's scanner half: GetResources enumerated ECS clusters only, so a tag TagResources had written to a service, a task or a task definition was readable through ECS's own ListTagsForResource and invisible to the tagging API's own inventory call (#935). With the writer in place, the CloudFormation half followed: all twelve are in the table above.
AWS Config was the thirteenth, and it needed a writer of its own rather than an arm in the shared one. Config keeps a resource's tags in a side-car state record keyed by ARN whose whole document is the tag map, rather than on the resource — which is deliberate, since neither AWS's ConfigurationRecorder shape nor its ConfigRule shape has a Tags member and a tag field on either would emit a member AWS never emits. The shared writer's two merge helpers both look for a tag member inside a record, and here there is none to merge into. Three consequences shape the arm, and each is observable:
- The stamp creates the side-car, where every other arm refuses a record that is absent — a resource created with no tags has no side-car at all.
- Because the record proving the resource exists is a different key from the one holding its tags, the existence check is made against the resource, through the same lookup
ListTagsForResourceuses rather than a second copy of it. Reading the side-car alone cannot tell "this rule has no tags" from "there is no such rule". - The key is the ARN, not the physical ID. A rule's physical ID is its name while its ARN names it by a hashed
ConfigRuleId, and a recorder's ARN carries a mintedRecorderIdno API member holds, so both are read back from the service at deploy time; an ARN that could not be read leaves the resource unstamped rather than stamped under a guess.
AWS::Config::DeliveryChannel is the one type the deployer creates that stays out, and not for want of a writer. TagResource's ResourceArn enumerates the nine resources Config can tag — a configuration recorder, a Config rule, an organization Config rule, a conformance pack, an organization conformance pack, a configuration aggregator, an aggregation authorization, a stored query and a connector — and a delivery channel is not among them. So no TagResource call can name one, the DeliveryChannel shape has no arn member to be named by, and substrate's deploy records none. It deploys and is skipped in silence.
One further limit, stated because the side-car makes it look like a gap. Config's writer deletes the side-car outright when the last tag leaves it, so an untagged resource stays distinguishable from one holding {}. A stack's tag reconciliation cannot reach that case: the three aws:cloudformation:* keys are stamped before the stack tags are reconciled, so a resource a stack touched always carries at least three tags. The rule therefore lives with Config's own UntagResource, which can reach it, rather than being restated at the CloudFormation caller where nothing could exercise it.
Three further limits, each named because a policy or an assertion written against the stamp will otherwise assume more:
- A resource that failed to deploy is not stamped, and neither is one with no physical ID. Tagging it would put a stack's bookkeeping on something the stack does not own.
- Caller-supplied stack tags reach the same resources, by a different rule.
CreateStack'sTags.member.Nis propagated as well (#764), to exactly the services in the table above — but the three keys here are substrate's own and are upserted unconditionally, where a stack tag yields to a value the caller set directly. See A stack tag reaches the resources the stack creates. - A physical ID that merely looks like an EC2 ID is stamped as one. EC2's resolver is tried first and keys on the prefix alone, so an S3 bucket a template names
i-somethingresolves as an instance. No AWS naming rule prevents it and substrate does not check for it.
Provenance: all three keys are documented, on the Template Reference's Resource tag page — "CloudFormation automatically creates the following stack-level tags with the aws: prefix: aws:cloudformation:NaN, aws:cloudformation:NaN, aws:cloudformation:NaN". This corrects an earlier note here which said that only aws:cloudformation:stack-name was reachable and that the triple was observed behaviour: the pages documenting the set returned empty bodies when the stamp was first implemented, and the Template Reference page settles it. The same page supplies the "varies by resource type" sentence and the EBS carve-out quoted above.
What still resolves to *, each named because a policy written against it will not behave as AWS would:
NetworkInterfaceId. Substrate stores no standalone taggable ENI record, so there are no tags to compare andAmazonEKSClusterPolicy'sec2:DeleteNetworkInterfacestatement stays inert. Fabricating an empty tag set would be worse: it would turn "substrate cannot tell" into "the tag is absent", deciding the condition rather than admitting it cannot.VpcIdandSubnetId. Several creates carry one as the container the new resource goes into rather than as the resource acted on, and authorizing a create against its parent's ARN is not what AWS evaluates it against.
An ID whose prefix names no type substrate can tag, or a pg-/key- ID naming no record, is skipped: it contributes no resource to the decision, so a batch of one resolvable and one unparseable ID is authorized as the batch of one. The refusal such an ID is owed is the handler's, as Malformed or NotFound, not an AccessDenied naming a resource that does not exist — and for the operation where it would matter most it cannot launder a state change past a policy, because AWS documents TerminateInstances as all-or-nothing ("If you specify multiple instances and the request fails (for example, because of a single incorrect instance ID), none of the instances are terminated") and substrate resolves every ID before it terminates any. StopInstances and StartInstances are not atomic, so there the batch is simply authorized as the resources that exist. Substrate states no ordering between the 403 and the 404: no AWS page publishes one.
The case is narrower than it sounds, and worth stating so a policy is not written against a wrong reading of it: a well-formed ID naming no record is not skipped. An instance's ARN is built from its ID rather than looked up, so i-0123… naming nothing still gets its own ARN with an empty tag set, and a least-privilege policy still has to name it.
A request naming no resolvable ID at all falls back to *. That fallback is safe in one direction only, which is why it is the fallback: * as the request resource is not a wildcard, because resourceMatches uses the statement's own Resource as the pattern, so * matches only a statement whose Resource begins with *. Replacing it with concrete ARNs can therefore narrow a grant but never widen one.
A tagged create is authorized twice
A create that carries tags is two authorization decisions, not one. AWS: "If tags are specified in the resource-creating action, Amazon performs additional authorization on the ec2:CreateTags action to verify if users have permissions to create tags. Therefore, users must also have explicit permissions to use the ec2:CreateTags action."
So ec2:RunInstances alone launches an untagged instance and refuses a tagged one — which is what real EC2 does, and the point of the rule: a policy that withholds ec2:CreateTags is how AWS expects you to stop a caller writing tags at all.
The second pass runs only when the request actually carries tags, per AWS: "The ec2:CreateTags action is only evaluated if tags are applied during the resource-creating action." An untagged create is decided exactly as before, as is a TagSpecification.N that names a ResourceType but no Tag.M. It also runs after the primary decision succeeds, so a caller missing both permissions is told about the create first.
What it is authorized against is the resources the create will make, one wildcard per distinct TagSpecification.N.ResourceType:
| Request | Second pass authorizes ec2:CreateTags on |
|---|---|
RunInstances tagging instance | arn:aws:ec2:<region>:<account>:instance/* |
RunInstances tagging instance and volume | both instance/* and volume/* |
CreateVolume with TagSpecification | arn:aws:ec2:<region>:<account>:volume/* |
CreateSubnet with TagSpecification | arn:aws:ec2:<region>:<account>:subnet/* |
Not the resources the create reads. A launch is authorized against its image, subnet and security group; its tags land on an instance and a volume, and AWS's own example turns on that distinction — it scopes the grant to instance/* and says "Users cannot tag existing resources, and users cannot tag volumes using the RunInstances request." A launch that tags both scopes therefore needs the grant on both types; one written for instance/* alone refuses the volume half, and the denial names volume/* so it is clear which ARN to add.
The <type>/* wildcard is substrate's reading, not a documented rule — the resources have no IDs when the decision is made, so no concrete ARN exists to authorize against. It is the shape all four of AWS's example policies for this key are written against (*/*, instance/*), which is why it is the one chosen. A ResourceType substrate does not otherwise model is passed through as written rather than filtered out; filtering could only ever produce a false allow.
ec2:CreateAction carries the creating operation's name — RunInstances, CreateVolume — so AWS's documented shape works verbatim:
{"Effect": "Allow", "Action": "ec2:CreateTags",
"Resource": "arn:aws:ec2:us-east-1:111122223333:instance/*",
"Condition": {"StringEquals": {"ec2:CreateAction": "RunInstances"}}}The key is absent on a direct CreateTags or DeleteTags, which is what makes that statement mean "tag during a launch, and not otherwise" — AWS's "Users cannot tag existing resources". A policy wanting to refuse only standalone tagging gates on "Null": {"ec2:CreateAction": "false"}, the one construct that tells an absent key apart from one present with a different value. The value is case-sensitive, so a grant written for CreateVolume does not admit a RunInstances; the key name is not, so a policy writing ec2:createaction is evaluated exactly as one writing ec2:CreateAction — see condition key names are matched case-insensitively, which is global to the evaluator rather than specific to this key.
Tags a launch template supplies are authorized the same way, per AWS: "The ec2:CreateTags action is also evaluated if tags are provided in a launch template." The template is resolved through the same lookup the launch's resource authorization uses, so one launch's two decisions cannot read different template versions. Precedence is per scope and mirrors the handler: the template's instance tags apply only when the request named none of its own, and its volume tags only when the request named no volume tags — so a request that overrides a scope authorizes the tags it actually sends for that scope.
aws:RequestTag/{key} and aws:TagKeys in the second pass are the first pass's values plus whatever the template contributed, so the two decisions cannot disagree about what the request asked for. No aws:ResourceTag/* is populated: the resources do not exist yet, so they have no tags to match.
A permission boundary applies to the second pass as it does to the first — a boundary that omits ec2:CreateTags refuses a tagged create even when the identity policy permits it.
Instance attributes
DescribeInstanceAttribute reads one attribute off an instance. It is the only way to read an instance's user data back: RunInstances recorded UserData and nothing could observe it, so a consumer could not assert that the user data their IaC intended reached the instance — including the value a launch template supplied.
Five attributes are readable, being the ones that correspond to state substrate holds:
Attribute | Reports |
|---|---|
userData | The value as stored, still base64-encoded |
instanceType | |
disableApiTermination | true or false; recorded at launch and by ModifyInstanceAttribute |
groupSet | groupSet>item with groupId/groupName, the same shape DescribeInstances reports |
blockDeviceMapping | blockDeviceMapping>item, the same set an instance reports |
Scalar values are wrapped in a <value> element, which all three of the reference's worked examples show and which matches the AttributeValue type the response elements carry:
<DescribeInstanceAttributeResponse xmlns="http://ec2.amazonaws.com/doc/2016-11-15/">
<instanceId>i-0123456789abcdef0</instanceId>
<instanceType><value>t3.micro</value></instanceType>
</DescribeInstanceAttributeResponse>Exactly one attribute appears per response — the one asked for. groupSet and blockDeviceMapping are the exceptions to the wrapper: they are arrays, not AttributeValues. Both are rendered as a present-but-empty element when the instance has none, rather than an omitted one, because an SDK maps a present-but-empty element to an empty slice and an omitted one to nil.
Attribute is Required: Yes, so an absent one fails with MissingParameter (The request must contain the parameter Attribute). An unknown instance ID fails with InvalidInstanceID.NotFound, and a malformed one with InvalidInstanceID.Malformed, as everywhere else.
Unmodelled attributes are refused, not defaulted
Every other name in the documented valid-values list — kernel, ramdisk, sourceDestCheck, productCodes, ebsOptimized, rootDeviceName, sriovNetSupport, enaSupport, enclaveOptions, instanceInitiatedShutdownBehavior, disableApiStop — is rejected:
InvalidParameterValue: Value (enaSupport) for parameter attribute is invalid. Unknown attribute.The status is 400, and the offending value is interpolated. Refusing is deliberate rather than a gap: answering sourceDestCheck with a default false would be indistinguishable from a real instance that has it disabled, and a consumer asserting on it would get a green test built on a value substrate invented.
This message has the strongest provenance of any in the EC2 plugin. It is captured from real AWS in aws/aws-cli#4273, where aws ec2 describe-instance-attribute --attribute enaSupport returns exactly it, and it is byte-identical to moto's string — a capture and an independent reimplementation agreeing. The reference could not have supplied it: DescribeInstanceAttribute's Errors section is empty.
enaSupport is the case that makes the boundary concrete. It is in AWS's own valid-values list, and the same reference says "Note that the enaSupport attribute is not supported." Real AWS rejects a value its documentation lists, and #4273 is the capture of that rejection — so substrate rejecting it is fidelity rather than a shortfall.
Attribute names are matched case-sensitively, as AWS's valid values are: InstanceType is rejected, instanceType is not.
An attribute that was never set
An unset attribute is reported as a present but empty element — <userData></userData> — rather than an omitted one. An empty groupSet likewise appears, empty.
This is the one shape here the reference cannot settle: all three of its worked examples show an attribute that has a value. It ships from moto's test_describe_instance_attribute, which asserts response["UserData"] == {} — an empty mapping, which is what an SDK produces from a present element with no children. That is weaker provenance than a capture, and worth stating, because the two shapes are not interchangeable to a caller: an SDK maps a present-but-empty element to an empty struct and an omitted one to nil, so resp.UserData.Value panics under one and not the other.
Modifying an attribute requires a stopped instance
ModifyInstanceAttribute writes InstanceType.Value, UserData.Value and DisableApiTermination.Value. The first two require the instance to be stopped:
IncorrectInstanceState: The instance 'userData' attribute cannot be modified while the
instance is in the 'running' state; stop the instance firstThe status is 400. The code is documented — EC2's client-error table lists IncorrectInstanceState as "some instance attributes, such as user data, can only be modified if the instance is in a 'stopped' state" — while the message text is substrate's own, since the table describes the condition rather than quoting the string AWS sends, and no capture of this rejection was found.
This is a behaviour change for instanceType, which substrate previously changed on a running instance. ModifyInstanceAttribute's Example 1 states the requirement plainly: "The instance must be in the stopped state." A test that asserted the old behaviour will now see a 400.
disableApiTermination is deliberately exempt from the gate, because RunInstances' reference says so: "You can enable termination protection when you launch an instance, while the instance is running, or while the instance is stopped." Gating it would refuse a call real EC2 accepts — the same class of defect as accepting one real EC2 refuses, just harder to notice, since it looks like extra rigor.
UserData.Value is read with a presence check rather than a non-empty one, so clearing an instance's user data is expressible: UserData.Value= on a stopped instance empties it, and the attribute then reads back as the empty element above.
Termination protection is honored, one Availability Zone at a time
TerminateInstances refuses a protected instance with OperationNotPermitted, HTTP 400, and the instance stays running. The code is documented — EC2's client-error table lists OperationNotPermitted as "The specified operation is not allowed" and names this case first among its examples, "you might be trying to terminate an instance that has termination protection enabled" — while the message text is substrate's own, since no capture of the string AWS sends was found. It interpolates the instance ID and names the attribute to clear, which is what a caller acts on:
OperationNotPermitted: The instance 'i-0123456789abcdef0' may not be terminated. Modify
its 'disableApiTermination' instance attribute and try again.A request naming both protected and unprotected instances is where this gets counter-intuitive, and it is worth reading the reference's own words, because the answer is neither "the whole request is refused" nor "the unprotected instances are terminated":
If you terminate multiple instances across multiple Availability Zones, and one or more of the specified instances are enabled for termination protection, the request fails with the following results:
- The specified instances that are in the same Availability Zone as the protected instance are not terminated.
- The specified instances that are in different Availability Zones, where no other specified instances are protected, are successfully terminated.
Partial failure is scoped to the Availability Zone. So for the reference's own worked example — A and B unprotected in us-east-1a, C protected and D unprotected in us-east-1b, all four named in one request:
| Instance | Zone | Protected | Outcome |
|---|---|---|---|
| A | us-east-1a | no | terminated |
| B | us-east-1a | no | terminated |
| C | us-east-1b | yes | still running |
| D | us-east-1b | no | still running — it shares C's zone |
The request itself reports OperationNotPermitted, naming C, after the terminations in us-east-1a have been persisted. An unprotected instance sharing a zone with a protected one survives; an unprotected instance in another zone does not.
Because the grouping key is the zone, every instance carries one. It is resolved at launch from Placement.AvailabilityZone, or from the subnet's zone when the launch named only a SubnetId, or from the region's first zone (<region>a) when it named neither — matching the reference's "EC2 automatically selects an Availability Zone for you". AvailabilityZoneId is not modelled. The zone is reported by RunInstances and DescribeInstances as <placement><availabilityZone>, and DescribeInstances accepts an availability-zone filter, so a caller can work out in advance which of their instances a terminate would spare. (The filter is named availability-zone, not placement.availability-zone — the placement family's filter names are spelled out individually in the reference's list.)
An instance unmarshalled from an event log recorded before this field existed reads back with an empty zone, which groups all such instances together. That is the conservative reading, being what a single-zone account looks like.
A bad instance ID still fails the whole request before anything is written, per "If you specify multiple instances and the request fails (for example, because of a single incorrect instance ID), none of the instances are terminated." The protection scan runs as a second pre-flight pass, after every named instance resolves, so no state is written for a zone that is about to be refused. Terminating an already-terminated instance still succeeds; the operation is idempotent and the protection check does not change that.
DeleteFleets --terminate-instances goes through the same handler, so the rule applies there too: a protected fleet instance survives its fleet's deletion, an unprotected sibling in another zone does not, and DeleteFleets propagates the OperationNotPermitted rather than folding it into unsuccessfulFleetDeletionSet. DeleteFleetError's documented codes are exactly fleetIdDoesNotExist, fleetIdMalformed, fleetNotInDeletableState and unexpectedError, none of which covers termination protection, so folding it in would mean answering unexpectedError and losing the code the caller acts on.
One divergence remains, unrelated to protection and pre-existing: substrate reports a terminated instance as code 48 terminated immediately, where real EC2 reports code 32shutting-down first and settles to 48. A consumer polling for shutting-down will never observe it. That is tracked separately.
MinCount and MaxCount
A count that is present but invalid fails with InvalidParameterValue, HTTP 400. A count that is absent still defaults.
| Request | Result |
|---|---|
| Neither given | 1 instance |
MinCount=2 alone | 2 instances — an absent MaxCount defaults to MinCount, not to 1 |
MaxCount=4 alone | 4 instances |
MinCount=1&MaxCount=3 | 3 instances |
MinCount=0, or either count < 1 | Invalid value '0' for parameter minCount. It must be at least 1. |
| Either count unparseable | Invalid value 'abc' for parameter minCount. It must be an integer. |
MinCount=3&MaxCount=1 | Invalid value '1' for parameter maxCount. The maxCount must be equal to or greater than the minCount '3'. |
A successful launch always creates MaxCount instances. AWS "launches the largest possible number of instances above the specified minimum count", and substrate models no capacity ceiling, so the largest possible number is always the maximum asked for and MinCount can only ever be satisfied.
Absence defaults rather than erroring even though AWS marks both Required: Yes, because in every typed SDK they are required members that fail client-side — so a consumer bug there cannot reach the wire, while requiring presence here would break hand-built form-encoded requests. A value that is present and invalid is reachable: the query protocol carries these as strings, and neither botocore's ParamValidator nor aws-sdk-go-v2 range-checks them.
The error code is the common-error InvalidParameterValue because the RunInstances reference documents no action-specific error for these. MinCount > MaxCount uses it too, rather than InvalidParameterCombination — that code is defined as "Parameters that must not be used together were used together", which cannot describe two parameters AWS documents as used together; the defect is the value.
The upper bound is a per-account, per-instance-type quota substrate does not model, so it is not enforced: any count at or above 1 is accepted.
Instance types are a seeded catalog
DescribeInstanceTypes, DescribeInstanceTypeOfferings and DescribeSpotPriceHistory all answer from one seeded catalog. It is not exhaustive — EC2 offers some 800 types — but it is complete per family:
| Family | Sizes |
|---|---|
t3, t3a | nano, micro, small, medium, large, xlarge, 2xlarge |
m5, m5a, r5, c5a | large, xlarge, 2xlarge, 4xlarge, 8xlarge, 12xlarge, 16xlarge, 24xlarge |
c5 | large, xlarge, 2xlarge, 4xlarge, 9xlarge, 12xlarge, 18xlarge, 24xlarge — note the ladder is not the same as c5a's |
p3 | 2xlarge, 8xlarge, 16xlarge |
p4d, p4de | 24xlarge — AWS publishes these as two families, not two sizes of one |
p5 | 4xlarge, 48xlarge |
g4dn | xlarge, 2xlarge, 4xlarge, 8xlarge, 12xlarge, 16xlarge |
g5, g6 | xlarge, 2xlarge, 4xlarge, 8xlarge, 12xlarge, 16xlarge, 24xlarge, 48xlarge |
inf1 | xlarge, 2xlarge, 6xlarge, 24xlarge |
inf2 | xlarge, 8xlarge, 24xlarge, 48xlarge |
trn1 | 2xlarge, 32xlarge |
trn2 | 3xlarge, 48xlarge |
Whole families rather than a sample, because an absent type is refused (below) — a catalog stopping at c5.xlarge would answer InvalidInstanceType for c5.large, which is the right code for a bogus type and the wrong one for a real one. Bare-metal sizes (m5.metal, g4dn.metal and friends) are deliberately excluded: they are real types, but nothing else in the plugin models their behaviour, so returning them would advertise fidelity that is not there. vCPU and memory figures come from the AWS instance-type guides — general purpose, compute optimized, memory optimized, and, for the accelerated families, accelerated computing (p3 from the previous generation page).
A family absent from the table above is refused outright, so the ones deliberately left out are worth naming: p3dn, trn1n, p5e and p5en are single-size families AWS publishes beside the ones here, and trn2u.48xlarge is published with no accelerator count at all — almost certainly a documentation gap rather than a zero-accelerator instance, and inferring 16 from trn2.48xlarge would be substrate inventing a spec. Graviton-based accelerated families (g5g) are out because every catalog entry reports x86_64. Widening later is additive (#896).
Which member reports which family's accelerator count
InstanceTypeInfo splits accelerators across five members — gpuInfo, neuronInfo, inferenceAcceleratorInfo, fpgaInfo and mediaAcceleratorInfo. Substrate models two of them, and a type reports its count through exactly one:
| Families | Member | Element |
|---|---|---|
p3, p4d, p4de, p5, g4dn, g5, g6 | gpuInfo | gpuInfo>gpus>item>count |
inf1, inf2, trn1, trn2 | neuronInfo | neuronInfo>neuronDevices>item>count |
So an Inferentia or Trainium type renders no gpuInfo element at all — not a zero count — and an NVIDIA type renders no neuronInfo. A non-accelerated type renders neither.
The gpuInfo half of that predates #896 and matches real EC2. The neuronInfo half is #1029, and it replaces an earlier reading: substrate previously held the Inferentia and Trainium counts internally and reported them nowhere, on the ground that no reference page states which family populates which member. Half of that still stands — neither NeuronInfo nor InferenceAcceleratorInfo names a family — but the conclusion does not, because InferenceAcceleratorInfo's own page carries "Amazon Elastic Inference is no longer available", which removes it as a candidate and leaves neuronInfo as the one live member for a device the Neuron SDK drives. Reporting a count substrate already had is a smaller reading than silently dropping it.
Only count is reported, and the omissions are deliberate. NeuronDeviceInfo publishes count, name, coreInfo and memoryInfo, and NeuronInfo publishes totalNeuronDeviceMemoryInMiB; all are Required: No and not one of the four besides count carries a valid-values list or an example, so substrate would be inventing a device name and two memory figures per type. It reports the count it can source and omits the rest rather than reporting a placeholder (#1013's rule: a member substrate does not model is absent, not empty). One neuronDevices item is rendered per type, the way gpuInfo renders one gpus item: the catalog carries a single count per type, so the list is that count.
Accelerator counts are not monotonic in size and AWS publishes them that way, so do not derive one from the size: g4dn, g5 and g6 each have a 12xlarge carrying four accelerators and a 16xlarge carrying one, g5/g6 have a 24xlarge carrying four below a 48xlarge carrying eight, and inf2's counts run 1, 1, 6, 12. Read the count from DescribeInstanceTypes.
currentGeneration is per family, and p3 is the one that is false
DescribeInstanceTypes reports currentGeneration from the family, not as a constant: ninety-two of the ninety-five catalogued types report true and p3's three report false. The current-generation filter is evaluated to match, so Name=current-generation,Values=false selects exactly p3.2xlarge, p3.8xlarge and p3.16xlarge, and Values=true selects the other ninety-two.
The source is the EC2 Instance Types guide's Specifications for Amazon EC2 previous generation instances, whose Instance family table publishes fifteen families — A1, C1, C3, C4, G3, I2, M1, M2, M3, M4, P3, P3dn, R3, R4, T1 — and spells P3 as p3.2xlarge | p3.8xlarge | p3.16xlarge. API_DescribeInstanceTypes describes the member only as "Indicates whether the instance type is current generation" and names no family, so the guide is where the enumeration comes from. Substrate transcribes all fifteen rather than special-casing p3, so a family added to the catalog later is classified by AWS's answer; fourteen of the fifteen are families the catalog does not carry at all.
There is a second AWS list and it is not the one used here.https://aws.amazon.com/ec2/previous-generation/ omits P3 and P3dn entirely, names G2 where the guide names G3, adds C2, CR1 and HS1, and lists M4, R4 and D2 as upgrade targets — i.e. current. Taken as authority it would make every catalogued family current generation. It is a marketing page about hardware AWS is steering customers off rather than a statement about what the API reports, so the documentation page governs.
Until #1028 this value was a hardcoded true and the filter was inert, which hid each other: a constant cannot be narrowed on, and a filter that narrows nothing cannot contradict a constant. Do not use currentGeneration to decide whether substrate models a type — the catalog carries current and previous generations alike, and a type it does not carry is refused with InvalidInstanceType regardless of generation. Use the family table above.
A type outside the catalog: refused, or empty?
Both — and which one depends on whether the parameter is an assertion or a filter. This asymmetry is deliberate and matches real AWS; #485 diffed all three operations against us-east-1.
| Request | Answer |
|---|---|
DescribeInstanceTypes --instance-types zz9.bogus | InvalidInstanceType, HTTP 400 — InstanceType.N asserts the types exist |
DescribeInstanceTypeOfferings --filters Name=instance-type,Values=zz9.bogus | 0 offerings, HTTP 200 — a filter that matches nothing is a legitimate empty answer |
DescribeSpotPriceHistory --instance-types zz9.bogus | Empty history, HTTP 200 — the reference describes this parameter as filtering the results |
Every unknown type in one DescribeInstanceTypes request is collected into a single error, in request order:
InvalidInstanceType: The following supplied instance types do not exist: [zz9.bogus, aa1.nope]One bad type fails the whole request; the known types are not returned. The message is verbatim from a real us-east-1 capture for the single-type case; the ", " separator for a list is substrate's choice, so dispatch on the code.
RunInstances accepts a type DescribeInstanceTypes refuses
RunInstances does not validate InstanceType against the catalog. It stores whatever string it is given, and DescribeInstances reports it back, so:
aws ec2 run-instances --instance-type m7i.large ... # succeeds
aws ec2 describe-instance-types --instance-types m7i.large # InvalidInstanceType, HTTP 400Two operations therefore disagree about whether a type exists, and the permissive one is the one that creates state. Real EC2 refuses at launch. Substrate does not, and the reason is the catalog's deliberate non-exhaustiveness: refusing here would move the very failure the completeness invariant exists to prevent onto the operation that creates state, and it would refuse types AWS plainly offers. That is not hypothetical — m7i.large, t2.micro, c7i.xlarge, t4g.nano, m7i.xlarge and c6a.xlarge are all launched by fixtures in this repository and none of them is in the catalog.
The consequence for a caller: an instance's instanceType is recorded intent, not an assertion that substrate models the type. A launch tells you nothing about whether DescribeInstanceTypes, DescribeInstanceTypeOfferings or DescribeSpotPriceHistory will report that type. Closing the divergence means widening the catalog to every type a consumer launches, which is the direction #896 took rather than tightening the launch path.
DescribeInstanceTypes applies six of the fifty-six filter names its reference documents — current-generation, instance-type, memory-info.size-in-mib, processor-info.supported-architecture, supported-usage-class and vcpu-info.default-vcpus. The other fifty are over response fields the seeded catalog does not carry, so they are accepted and inert, and an undocumented name is refused. That split is what closed #495's filter half: the concern was never that the answerable handful should go unapplied, but that dropping the rest silently is indistinguishable from applying them — which the evaluated/inert table resolves by naming every inert one.
Fifty-six, corrected from fifty-seven. Every count site said fifty-seven from #695 until #1028. The names substrate accepts were re-diffed against API_DescribeInstanceTypes one by one on 2026-09-19 — nothing missing, nothing extra — and they come to fifty-six. There is no tag filter to account for the difference: an instance type is not a taggable resource and the page lists no tag: entry. Whether the page once published a fifty-seventh that has since been withdrawn cannot be recovered from it, so the number states what it publishes now.
The numeric filters compare as strings, because AWS supports no greater-than or less-than in a filter value: memory-info.size-in-mib=4096 selects the types with exactly that much memory, and 4097 selects none rather than "more than 4096". current-generation compares the same way, against the literals true and false — see currentGeneration is per family.
Offerings filters and wildcards
DescribeInstanceTypeOfferings accepts exactly the two filter names its reference documents — instance-type and location — and there is nothing it accepts but cannot answer. Any other name is refused with InvalidParameterValue, which is now what every EC2 describe does; this operation is where that rule started. Multiple Filter.N.Value.M values are an OR; separate Filter.N entries AND together.
Filter values honour EC2's documented wildcards and are case-sensitive, per the rules that apply to every EC2 filter. This operation is where that matcher started, which is why the examples there use instance types.
LocationType is a top-level parameter, not a filter name (location-type is refused as a filter). availability-zone is the default, region returns one offering per type located at the region, and availability-zone-id returns one per type per zone located at the zone's AZ ID — the same zoneIdDescribeAvailabilityZones reports for that zone, since both read one derivation (#893). A location filter under this locationType therefore matches an AZ ID: use1-az1 selects, us-east-1a selects nothing. Read the ID out of DescribeAvailabilityZones rather than hardcoding it — the name→ID pairing substrate reports is stable and a real account's is not, per the note below.
outpost is the one valid AWS value substrate does not model, and it is refused with a message naming substrate — its location is an Outpost ARN, substrate seeds no Outpost, and answering with zone names under a locationType claiming they are Outpost ARNs is what a caller matching the two would silently mis-read.
The three zones DescribeAvailabilityZones reports are the same three the offerings and spot-price operations use, so filtering an offerings query by a zone you just enumerated always returns an answer.
Zone IDs take AWS's published shape, and always map zone a to -az1
zoneId is derived from the region: us-east-1a is use1-az1, eu-west-1b is euw1-az2, ap-southeast-2c is apse2-az3. Every prefix reproduces a row of AWS's Availability Zones reference. Substrate emitted a different shape entirely until this release — us-east-1 produced ue11, with a doubled digit — which nothing caught because zone IDs were only ever emitted: a test that read one out of a response and filtered on it was self-consistent whatever the string was. CreateVolume's AvailabilityZoneId made it an input, and a consumer's fixture carrying the real use1-az1 would have been refused.
The derivation is one letter per compass word, not AWS's own summarising sentence ("the first three letters of the Region code, followed by the number at the end"). That sentence is refuted by the table it introduces: ap-southeast-2 is apse2, not aps2, and ap-northeast-1 is apne1. The published table wins.
What substrate does not model is the per-account shuffle. AWS: "we independently map Availability Zones to codes for each AWS account", so a real us-east-1a is use1-az1 in one account and use1-az3 in another — which is the whole reason AZ IDs exist. Substrate maps zone a to -az1 in every account, because a deterministic emulator cannot hold a per-account secret and a test asserting the pairing has to be able to pass. Do not use substrate to verify that code correctly treats the name→ID mapping as account-specific; it will agree with an assumption real AWS breaks.
Spot prices are stubs
The spotPrice values are deterministic stubs, not AWS prices: substrate has no price feed, and the numbers exist so a spot-price response has a plausible, stable figure in it. Within a family they are a fixed rate per GiB, so they stay monotonic in size. Assert on the shape of a spot-price response, never on the amount. Every catalog type has a price — the two are generated together, so a type cannot appear in DescribeInstanceTypes and be missing from DescribeSpotPriceHistory.
Explicit resource IDs
Naming a resource ID explicitly is an assertion that the ID exists, and EC2 answers it with an error rather than an empty result. DescribeVpcs() with no arguments legitimately returns []; DescribeVpcs(VpcIds=["vpc-…"]) where that VPC is absent fails.
- An ID that resolves to nothing →
Invalid<Type>.NotFound, HTTP 400. - A syntactically invalid ID →
Invalid<Type>.Malformed, HTTP 400. Syntax is checked before existence, so a request naming both a malformed and an absent ID reportsMalformed. - One present plus one absent ID fails the whole call — EC2 does not return the partial set.
- An ID excluded by a
Filterrather than by absence still counts as resolved: an existing ID plus a non-matching filter returns 200 and an empty set. - No explicit IDs → every resource matches, and an empty account returns 200 and an empty set.
AWS's casing is inconsistent across these codes and SDK callers match the literal string, so substrate mirrors each pair exactly:
| Resource | NotFound | Malformed |
|---|---|---|
| Instance | InvalidInstanceID.NotFound | InvalidInstanceID.Malformed |
| VPC | InvalidVpcID.NotFound | InvalidVpcID.Malformed |
| Subnet | InvalidSubnetID.NotFound | InvalidSubnetID.Malformed |
| Security group | InvalidGroup.NotFound | InvalidGroupId.Malformed |
| Internet gateway | InvalidInternetGatewayID.NotFound | InvalidInternetGatewayId.Malformed |
| Route table | InvalidRouteTableID.NotFound | InvalidRouteTableId.Malformed |
| Snapshot | InvalidSnapshot.NotFound | InvalidSnapshotID.Malformed |
| Volume | InvalidVolume.NotFound | InvalidVolumeID.Malformed |
| Image (AMI) | InvalidAMIID.NotFound | InvalidAMIID.Malformed |
| Elastic IP allocation | InvalidAllocationID.NotFound | — |
| NAT gateway | InvalidNatGatewayID.NotFound | NatGatewayMalformed |
AMIs are the one family whose absence code names no ID — InvalidAMIID.NotFound is "The specified AMI doesn't exist" — the mirror image of the cross-naming snapshots carry. Which AMIs an ID can resolve to is its own question; AWS's third AMI code, InvalidAMIID.Unavailable, is raised nowhere, because substrate models no deregistered-but-extant image.
EC2 publishes no Malformed variant for allocation IDs; a malformed allocation ID surfaces as InvalidAllocationID.NotFound. NAT gateways are the one family whose malformed code sits outside the Invalid*ID.Malformed naming entirely — the reference publishes it as NatGatewayMalformed, "The specified NAT gateway ID is not formed correctly. Ensure that you specify the NAT gateway ID in the form nat-xxxxxxxxxxxxxxxxx."
AWS publishes two absence codes for NAT gateways — NatGatewayNotFound ("The specified NAT gateway does not exist.") and InvalidNatGatewayID.NotFound — and no per-operation page says which operation raises which; DeleteNatGateway's Errors section is empty, as EC2's pages generally are. Substrate answers InvalidNatGatewayID.NotFound everywhere, because that is the spelling every other row above follows and the one DescribeNatGateways has always published.
Mutations answer from the same table. Every operation that names a resource of one of these families — a Describe* reading <Type>Id.N, or a mutation reading a single <Type>Id — produces its code, message and status from the one entry, so the two halves of the API cannot drift apart:
- An absent ID → the row's
NotFound, with the messageThe <noun> ID '<id>' does not exist. - A syntactically invalid ID → the row's
Malformed, with the messageInvalid id: "<id>". This is checked before existence on a mutation exactly as it is on a describe, which matters because the two branches mean different things to a caller:Invalid*.NotFoundcan be retried after creating the resource, and*.Malformednever can. - An omitted single ID parameter →
MissingParameter, "The request must contain the parameter<Name>" — not aMalformednaming a parameter the caller never sent.AttachVolumenames its two required IDs separately, so a caller who sent one of the two learns which is missing.
Four codes are outside the table and stay hand-written, because AWS gives their ID families no prefix rule of the shape above: InvalidRoute.NotFound, InvalidAssociationID.NotFound, InvalidLaunchTemplateId.NotFound and InvalidLaunchTemplateName.NotFoundException.
One refusal reuses a code above for a different condition. A RunInstances naming a security group that exists but lives in another VPC answers InvalidGroup.NotFound with does not belong to VPC … — AWS's own gloss for that code is "the specified security group does not exist", and from the target VPC's point of view it does not. That is membership rather than absence, so it does not share the table's message.
A refused mutation writes nothing. An operation naming two resources resolves both before its first write. ReplaceRouteTableAssociation is the one that had this wrong: it removed the source association and committed it before resolving the target RouteTableId, so a request naming an absent or malformed route table deleted the association it was asked to move, left the subnet with no route table at all, and then reported a failure — and a retry with the ID corrected answered InvalidAssociationID.NotFound for the association the first call had eaten.
An ID is well formed when it has the resource's prefix followed by at least one lowercase hex digit. Length is deliberately not checked: substrate's generators emit 16 hex characters where AWS emits 8 or 17, and AWS itself still accepts the legacy 8-character form for several resources.
Which selectors assert existence
Every Describe* whose ID family has a row above resolves the IDs a caller names. Twelve do: DescribeInstances, DescribeInstanceStatus, DescribeVpcs, DescribeSubnets, DescribeSecurityGroups, DescribeInternetGateways, DescribeRouteTables, DescribeSnapshots, DescribeAddresses, DescribeNatGateways, DescribeVolumes and DescribeImages. The last two joined with #731; before that each answered a superset rather than an error, and DescribeImages did not read ImageId.N at all — a caller naming one AMI was answered with every AMI the account owned. A superset is the worse failure of the two, because an error is visible and a superset reads as a successful narrowing.
DescribeInstanceTypes' InstanceType.N also asserts existence, answering InvalidInstanceType.
Nine selector families deliberately do not, and answer an empty set where AWS answers NotFound. Four have reasons that would not change if a kind were registered for them:
| Selector | Why not |
|---|---|
DescribeFleets' FleetId.N | AWS publishes no InvalidFleetId.NotFound. The only fleet-ID absence code in the reference is InvalidSpotFleetRequestId.*, which is a sfr- request, not a fleet- fleet. |
DescribeCapacityReservations' CapacityReservationId.N | AWS does publish InvalidCapacityReservationId.NotFound, and CancelCapacityReservation answers it — but nothing says whether a describe raises it, since the operation's Errors section is the common-types boilerplate. Narrowing is what keeps a sweep over a list of IDs from failing because one reservation had already been cancelled and swept. A malformed ID is refused here, because that is a mistake in the request rather than an absent resource. |
DescribeRegions' RegionName.N | The parameter explicitly permits naming any Region, enabled for the account or not, so "this Region is not in your answer" is not absence. |
DescribeSecurityGroups' GroupName.N | The kind is registered and its GroupId.N asserts, but the code is InvalidGroup.NotFound and both AWS's client-error table and substrate's message for it describe a missing security group ID. This operation's own Errors section is empty, so a name-shaped refusal would be invented wording. AWS also scopes the parameter to the default VPC where substrate matches account-wide, so absence here is not the absence AWS would be reporting. |
Five more have a published code but no registered ec2IDKind, so the assertion is unimplemented rather than declined: DescribeKeyPairs' KeyName.N/KeyPairId.N (InvalidKeyPair.NotFound), DescribePlacementGroups' GroupName.N/GroupId.N (InvalidPlacementGroup.Unknown), DescribeAvailabilityZones' ZoneName.N/ZoneId.N, DescribeAddresses' PublicIp.N (its AllocationId.N does assert), and DescribeLaunchTemplates, whose InvalidLaunchTemplateId.NotFound is one of the four hand-written codes above.
DescribeSecurityGroups' GroupName.N selects as of #749 — it was read by nothing before, so a caller naming one group by name was answered about every group in the account — and it unions with GroupId.N like the other paired identity parameters. Two things about it are substrate's reading:
- A name is matched account-wide, where AWS scopes the parameter to the default VPC ("[Default VPC] The names of the security groups"). Substrate does model a default VPC, but creates it lazily — only when a launch path asks for one — so scoping the parameter to it would make
GroupName.Nanswer nothing at all in a fresh account. That is the same invisible-wrong-answer failure as the superset it replaces, in the other direction. A consequence: a name may legitimately match several groups here, becauseCreateSecurityGroupenforces no name uniqueness where AWS's is per-VPC. Narrow with thevpc-idfilter, which composes with the name. - A name matching nothing answers an empty set, not
InvalidGroup.NotFound. This operation's Errors section is empty, and EC2's client-error table describes that code as a missing security group ID — which is what substrate's own message for it says — so refusing here would mean inventing wording AWS does not publish for this operation, on top of asserting a default-VPC scope substrate does not implement. The ID half keeps its full contract either way: a malformedGroupId.Nis refused before the walk and an unresolved one after it, whether or not a name selected something.
DescribeImages also does not read Owner.N or ExecutableBy.N. Substrate stores only images the account owns, so self is the answer to every describe and AWS's other three Owner values — amazon, aws-marketplace, another account ID — select sets substrate does not model. Reading the parameter would let a caller believe a narrowing happened. A bundled public AMI is reachable by naming its ID, which is the case generated IaC actually produces; see Which AMIs resolve.
Both ID lists are read in every form AWS accepts — VolumeId.1, VolumeId.2, … and the un-indexed VolumeId — and an explicitly empty value is an ID the caller sent, so it is reported as Malformed rather than truncating the list.
Finding a fleet's instances
Every instance CreateFleet launches is tagged aws:ec2:fleet-id with the fleet that created it, so the fleet's instances are reachable with an ordinary DescribeInstances tag filter:
aws ec2 describe-instances \
--filters "Name=tag:aws:ec2:fleet-id,Values=fleet-12a34b56-7890-1cde-2f34-abcdef567890"For an instant fleet this is the only route from a fleet back to its live instances. DescribeFleetInstances rejects instant fleets outright, and the fleetInstanceSet in a CreateFleet/DescribeFleets response is a record of what was launched — it never drops instances that have since terminated. Without the tag a fully-running fleet is indistinguishable from an empty one.
This tag is modelled from observed behaviour on real AWS rather than from a documented API contract: it appears in neither the EC2 API reference nor the fleet tagging and describe pages. It is applied to every fleet type, and — unlike a caller's own TagSpecification entries — it is not scoped by ResourceType.
A caller cannot delete this tag — see reserved tag keys — which matches the rule AWS attaches to the aws: prefix.
Reserved tag keys
Every path that assigns a tag rejects any key beginning with aws:, the prefix EC2 reserves for its own use — CreateTags, DeleteTags, and tag-on-create through RunInstances, CreateFleet, CreateImage and CreateNatGateway:
InvalidParameterValue: Tag keys starting with 'aws:' are reserved for internal useThe status is 400. The whole request is refused before any resource is modified, so a request that mixes a legal tag with a reserved one leaves every resource it named untouched — CreateTags accepts up to 1000 resource IDs, and a partial application is a state real EC2 never produces.
The match is case-sensitive. AWS documents tag keys and values as case-sensitive, so AWS:foo and Aws:foo are ordinary user tags and are accepted; only the lowercase aws: prefix is reserved.
On a tag-on-create path the rejection happens before the resource is created, so a refused RunInstances launches no instance, a refused CreateImage leaves behind neither the AMI nor its backing snapshot, and a refused CreateNatGateway creates no gateway. This follows the tagging documentation directly: "If tags cannot be applied during resource creation, we roll back the resource creation process. This ensures that resources are either created with tags or not created at all."
Provenance: the CreateTags API reference has an empty Errors section, so neither the code nor the message above is derivable from the API model. Both come from observed real-AWS responses — and both captures are in fact of RunInstances tag-on-create, so that path has the strongest claim to this wording. The DeleteTags rejection is a step weaker — substrate found no captured DeleteTags error and inherits the wording from the same capture. What the tagging documentation does state plainly is the outcome: such a tag "can't be edited or deleted" by a caller.
How substrate's own fleet tag is exempt
Substrate stamps aws:ec2:fleet-id on every fleet instance, which is a reserved key on a tag-on-create path — the reason this check was previously left off that path entirely.
It is exempt structurally rather than by a flag. CreateFleet parses the caller's TagSpecification.N tags and checks them exactly as RunInstances does; the fleet-ID tag is appended to the resulting value after that check, on an internal launch entry point that takes already-parsed tags. There is no param a request could set to reach it. A validation-skipping flag would have made the outcome depend on internal state a consumer cannot observe, which is the opposite of the deterministic-replay property substrate exists for.
So a caller naming aws: anything in a CreateFleet request is rejected — instance- and fleet-scoped alike — while the fleet's own stamp is still applied, and both coexist with the caller's legal tags on the same instance.
One limit of the current scope, stated rather than implied: only tags scoped to a resource substrate tags are checked. A TagSpecification naming network-interface or spot-instances-request on RunInstances, or inside a launch template's LaunchTemplateData, is skipped, because substrate does not tag those resources at all; real EC2 would reject a reserved key there too. The volume scope used to be skipped for the same reason and no longer is: now that a launch tags its volumes, a reserved key there refuses the launch, and the refusal happens before the launch loop so no instance is left behind by it.
A launch template's own tags are checked, on both modelled scopes, at CreateLaunchTemplate and CreateLaunchTemplateVersion as well as at every launch that names the template — so a template cannot serve as an unchecked second path to a reserved key. Each scope is counted against the 50-tag limit on its own, because the limit is per resource and an instance and its volumes are different resources. See A launch template merges with the request, field by field.
The 50-tag-per-resource limit
A resource carrying more than 50 user tags is refused, on CreateTags and on every tag-on-create path:
TagLimitExceeded: The maximum number of Tags for a resource has been reached.The status is 400. From the tagging documentation's restrictions: "Maximum number of tags per resource – 50".
Two rules make this less arithmetic than it looks, and both are modelled.
Tags with the aws: prefix do not count. The documentation says so directly: "Tags with the aws: prefix do not count against your tags per resource limit." This is load-bearing rather than pedantry, because substrate stamps aws:ec2:fleet-id on every fleet instance — a counter that included reserved keys would refuse a fleet launch whose template names the full 50 user tags, which real EC2 accepts. A fleet instance therefore holds 51 tags legally: 50 of the caller's and one of substrate's.
Overwriting an existing key at the limit succeeds. The count is over the post-merge key set, so a key already on the resource adds nothing. CreateTags on a 50-tag instance changing the value of key7 is accepted and the value changes; adding a new key51 to the same instance is refused. Written as len(existing) + len(incoming) both would fail, and real AWS permits the first — getmoto/moto#8151 reports exactly that case.
As with reserved keys, the whole request is refused before anything is modified. CreateTags naming two instances — one with room, one at the limit — tags neither. A resource ID that names nothing is not counted against, because the apply step ignores it: checking it would refuse a request real EC2 accepts as a no-op.
Provenance is split, and the weaker half is the message. The code is documented: EC2's client-error table lists TagLimitExceeded as "You've reached the limit on the number of tags that you can assign to the specified resource." The wire message is published nowhere, so the wording above is moto's, from a reimplementation rather than a captured response. That is a weaker claim than the code's, and is a distinction worth stating: SDKs dispatch on Error.Code, so the code is the part a consumer's error branch turns on.
A launch template's instance-scoped tags are counted the same way, at CreateLaunchTemplate and CreateLaunchTemplateVersion as well as at every launch that names the template. Exactly 50 template tags launch; 51 are refused at template creation.
The third restriction from the same table — the key and value length limits — is enforced too, with one deliberate difference in how reserved keys are treated. See Tag key and value length limits.
Tag key and value length limits
A tag key longer than 128 characters or a value longer than 256 is refused, on CreateTags, DeleteTags and every tag-on-create path:
InvalidParameterValue: Tag key must be no more than 128 Unicode characters in UTF-8; the supplied key is 129The status is 400. The message names which of the two limits was exceeded and by how much, because that is the only place a caller learns it. From the same restrictions list that gives the 50-tag count: "Maximum key length – 128 Unicode characters in UTF-8" and "Maximum value length – 256 Unicode characters in UTF-8".
The unit is Unicode characters, not bytes. A key of 128 emoji is 128 characters and 512 bytes, and it is legal; a byte-counting check would refuse it, and refuse it while reporting a length the caller never sent. The two counts agree on ASCII, so a suite that only tests ASCII keys cannot tell them apart.
There is no lower bound. The documentation states that "You can set the value of a tag to an empty string, but you can't set the value of a tag to null", so an empty value is legal and the check is an upper bound only. That is also what makes DeleteTags work unremarkably: it names keys and treats the value as optional, so a request with no Tag.N.Value supplies the empty string and passes. A key is required by the query encoding rather than by this check — the tag walk ends on an absent or empty Tag.N.Key — so an empty key is not expressible in the first place.
As with the other two tag restrictions, the whole request is refused before anything is modified, and on a tag-on-create path before the resource is created: a refused RunInstances launches no instance, a refused CreateImage creates neither the AMI nor its snapshot, a refused CreateNatGateway creates no gateway, and a refused CreateLaunchTemplate creates no template. A launch template is checked at CreateLaunchTemplate and CreateLaunchTemplateVersion as well as at every launch that names it, so a consumer hears about an over-long template tag once, at the operation that named it.
Reserved keys are not exempt from the lengths, though they are from the count. The exemption in the restrictions list is scoped to the count alone — "Tags with the aws: prefix do not count against your tags per resource limit" — and nothing in it exempts a reserved key from either length, so substrate checks them. In practice that decides no observation: the reserved-key check runs first, so a caller's aws:-prefixed key is refused for being reserved before its length is measured. It matters for the code rather than the wire, and it is recorded here because the adjacent count check does the opposite. Where the length check does bite is the case-sensitive edge: AWS: is an ordinary user tag, so an over-long AWS:-prefixed key is refused for its length.
Provenance is the weakest of the three tag restrictions, and is marked as such deliberately. The code InvalidParameterValue with 400 is by analogy with the reserved-key rejection — the other tag-restriction violation on the same operations, and CreateTags' Errors section is empty so the API model supplies nothing. The message text is substrate's own: no captured real-AWS length rejection was found, in moto or LocalStack either. It follows the SendMessage size-limit message's precedent of interpolating the limit and the actual value. A consumer's error branch should dispatch on the code, which is the part that rests on something.
Tag scoping on CreateImage
CreateImage accepts two tag scopes, and substrate now honours the distinction: ResourceType=image tags the AMI, and ResourceType=snapshot tags the backing snapshot substrate materializes for the AMI's root device. Per the reference, "the same tag is applied to all of the snapshots that are created."
This is a behaviour change. CreateImage previously read TagSpecification.1's tags whatever they were scoped to, so a request that tagged only its snapshots put those tags on the AMI instead. A caller asserting on DescribeImages tags that were actually snapshot-scoped will now see them on DescribeSnapshots, which is where real EC2 puts them.
RegisterImage records the whole block device mapping
RegisterImage read exactly one thing out of the mapping it was sent — the first BlockDeviceMapping.N.Ebs.SnapshotId, found by a hand walk of indexes 1 to 32 — and discarded the rest of every entry: device names, sizes, volume types, and every mapping after the first that named a snapshot. AWS's own third example for this operation registers three volumes (two snapshots and an empty 100 GiB volume), so a caller sending AWS's documented request got one volume back, on a /dev/sda1 the request need never have mentioned.
| Request | Answer |
|---|---|
Every BlockDeviceMapping.N entry | Stored as sent and rendered back by DescribeImages in request order |
| A mapping naming a snapshot that is malformed or names nothing | InvalidSnapshotID.Malformed / InvalidSnapshot.NotFound, before anything is written |
Ebs.VolumeSize below the snapshot's size | InvalidBlockDeviceMapping, naming the device |
| A mapping with a size and no snapshot | Registered — AWS's third example volume is exactly that |
| A mapping absent a size but naming a snapshot | The snapshot's size, per EbsBlockDevice.VolumeSize: "If you specify a snapshot, the default is the snapshot size" |
RootDeviceName | Read, to decide which mapping is the root device's; absent, the first mapping naming a snapshot is |
TagSpecification.N with ResourceType=image | Tags the AMI, through the same walk and the same tag rules as every other tag-on-create |
TagSpecification.N with any other ResourceType | InvalidParameterValue. AWS: "If you specify another value for ResourceType, the request fails" |
Name | Still the only required parameter, as AWS marks it |
The mapping is now read by the same parser RunInstances and a launch template use, which brings two changes a caller can notice beyond getting its volumes back. The walk is unbounded, so a request with more than 32 mappings no longer has the remainder dropped; and it stops at the first absent index instead of tolerating a gap. AWS's query protocol indexes contiguously and every other indexed walk in substrate assumes it, so a sparse request is not one an SDK produces — but a hand-built request that skipped an index used to have its later mappings read, and no longer does.
Three mapping shapes render differently, because they are different things: an EBS volume gets an ebs element, an instance store device gets a virtualName and no ebs, and a suppressed device gets a noDevice element whose value is empty. Giving every mapping an ebs element would report a phantom 0 GiB volume for the latter two.
block-device-mapping.snapshot-id widened with this: it matches on every snapshot the AMI's mapping names, not only the root device's. Consulting the root alone meant a filter naming a volume DescribeImages had just rendered found no AMI — two operations contradicting each other about one record.
Two things this does not do. It applies only the snapshot rule from the shared mapping validator, not the rest of it (duplicate device names, virtualName spellings, gp3-only Throughput): none of those is a rule AWS states for this operation, and arriving on a published path unannounced is how a consumer's working request starts failing. And Name's documented character constraints ("3-128 alphanumeric characters, parentheses…") stay unenforced — only an empty Name is refused.
Provenance: API_RegisterImage.html's Errors section is empty, so no code here is quoted from the operation's own page. The snapshot codes come from EC2's client-error table and predate this change. The ResourceType code is substrate's reading — AWS says only that "the request fails" — chosen because InvalidParameterValue is EC2's gloss for "A value specified in a parameter is not valid, is unsupported, or cannot be used".
An AMI reports its architecture, platform and root device
DescribeImages rendered six members — imageId, name, description, imageState, imageOwnerId and creationDate — and nothing about the image itself. So a caller that branched on architecture to pick an instance type, or on the platform to decide whether to send PowerShell or bash user data, or on the root device name to build a block device mapping, read an empty string out of an AMI substrate knew the answer for. Twelve members now render (#750):
| Member | Bundled AMI | Caller's AMI |
|---|---|---|
architecture | from the catalog — x86_64 or arm64 | RegisterImage's Architecture, or inherited by CreateImage |
platform | windows on the Windows entry, omitted on the eight Linux ones | omitted; RegisterImage takes no such parameter |
platformDetails, usageOperation | Linux/UNIX+RunInstances, or Windows+RunInstances:0002 | omitted, unless inherited by CreateImage |
rootDeviceName | /dev/xvda on the Amazon Linux and ECS entries, /dev/sda1 on Windows and Ubuntu | RegisterImage's RootDeviceName, or inherited |
rootDeviceType | ebs | instance-store when ImageLocation was sent, otherwise ebs |
virtualizationType | hvm | RegisterImage's VirtualizationType, or inherited |
hypervisor | xen | xen |
imageType | machine | machine |
imageOwnerAlias | amazon on the seven AWS-published entries, omitted on the two Canonical ones | omitted |
isPublic | true | false |
publicSsmParameterName | the parameter the AMI was discovered through | omitted |
Four of those cells are the interesting ones, because they are where a default written for the bundled catalog's benefit would have been wrong for a registered image — both passes render through one closure, so a value written once attaches to both:
imageOwnerAliasis not on the Ubuntu entries. The alias is "an Amazon-maintained list", and Canonical is not on it.publicSsmParameterNameis not on a caller's AMI. No public parameter names it, and the member exists precisely to let a caller round-trip the parameter it resolved an AMI through — which is now assertable in both directions, since substrate's SSM and EC2 plugins answer from the same catalog key.platformDetailsandusageOperationare absent on a registered AMI. Both are billing facts AWS derives from the image's product codes, which substrate does not model, so reportingLinux/UNIXfor an AMI a caller registered would be a guess about what it contains.CreateImageinherits them, because an image of a running instance runs the same operating system its parent AMI does.imageOwnerIdis now omitted rather than rendered empty for a bundled AMI. The member had noomitempty, so a named bundled image answered with an empty<imageOwnerId>— a member present and blank, which an SDK reads as "the owner is the empty string" rather than as "there is no owner". Amazon's AMI-owning account varies by Region and AWS publishes no stable mapping, so the alias is rendered and the account is left out.
RegisterImage's defaults are AWS's documented ones: Architecture defaults to i386 ("Default: For Amazon EBS-backed AMIs, i386") and VirtualizationType to paravirtual. No AMI a caller would register today is either, and both will look wrong. They are used anyway, because inventing x86_64/hvm would mean a request that omits the parameter reports one thing here and another on AWS — a divergence in the direction that makes a passing test meaningless. Neither parameter's value is validated against AWS's enum, following the same reasoning the mapping rules do: a new refusal on a published path arrives unannounced.
Each entry's root device name is substrate's reading, not AWS's published text. AWS's device naming reference says only "Differs by AMI — /dev/sda1 or /dev/xvda" and publishes no per-AMI table, so the split above follows each publisher's own convention: Amazon Linux and the ECS-optimized AMIs use /dev/xvda, Windows and Canonical's Ubuntu images use /dev/sda1. The launch path still accepts either spelling as a root device, as it did before.
Two places where AWS contradicts itself, and the side substrate took:
platform's casing. TheImagetype page lists the valid value asWindowsand in the same entry says the member "is set towindowsfor Windows AMIs";DescribeImages' second example response renders<platform>windows</platform>and itsplatformfilter says "The only supported value iswindows". Substrate renders lowercase — three statements against one.imageOwnerId/isPublicagainstownerId/public.DescribeImages' first example uses the short spellings; its second and third examples and theImagetype page use the long ones. Substrate follows the type page, which is the spelling it already used for the owner.
CreateImage inherits architecture, platform, platformDetails, usageOperation, virtualizationType and rootDeviceName from the AMI its source instance runs, rather than leaving them empty: an AMI made from an arm64 Amazon Linux instance runs arm64 Amazon Linux, and reporting nothing while its parent reported arm64 would be two of substrate's own answers disagreeing about one lineage. What it does not inherit is ownership — imageOwnerAlias, publicSsmParameterName and isPublic are the new AMI's own, and it is the caller's private image whatever it was made from. The fabricated root mapping follows the inherited device name, so an AMI's rootDeviceName and its blockDeviceMapping cannot disagree.
One thing this makes newly visible rather than fixes: a bundled AMI now names a rootDeviceName while its blockDeviceMapping stays empty. Bundled images live outside state, so no snapshot backs one, and fabricating a mapping would mean rendering an ebs element naming a snapshot ID that resolves to nothing — a worse answer than none. A caller that needs a mapping to read should register or image its own AMI.
DescribeImages filters
Eighteen filter names are applied: image-id, block-device-mapping.snapshot-id, tag:<key>, tag-key, and — with the members above, because a rendered member a caller cannot filter on is half an answer — architecture, description, hypervisor, image-type, is-public, name, owner-alias, owner-id, platform, public-ssm-parameter-name, root-device-name, root-device-type, state and virtualization-type. Fourteen of those were previously accepted and inert, which is worse than the alternatives in both directions: a filter on architecture returned every AMI in the account, and a caller reads a non-empty answer as a match.
AWS documents twenty-five more, which need state substrate does not keep: those are accepted and inert, and anything outside AWS's list is refused — see One rule for an unrecognized filter name. The remainder are the product-code*, image-watermark.*, source-image* and state-reason-* families, the block-device-mapping.* names other than snapshot-id, the ENA/sriov and kernel/ramdisk names, manifest-location, the Allowed-AMIs and Free-Tier markers, and creation-date, whose documented rule is an ISO-8601 prefix match with wildcards rather than the equality the others use.
owner-id compares against the account that owns the AMI, so it matches nothing for a bundled image — which reports no owner — exactly as Owners=self does not. is-public is compared as the rendered true/false, so is-public=false selects the caller's own AMIs and excludes every bundled one.
Two behaviour changes came with tag-key, both of which bring this operation into line with DescribeInstances and DescribeVolumes rather than leaving it with its own rules:
- A
tag:<key>filter with no value now matches nothing, where it previously matched any value. That any-value question is whattag-keyspells, which is why the filter had to arrive in the same change — otherwise the correction would have removed the only way to ask it. AWS settles neither shape: its filtering guide says only that a filter value cannot be null, so matching nothing is substrate's reading, and it is now the same reading everywhere. - An explicitly empty filter value is a value.
Filter.1.Value.1=asks for an image whose tag is the empty string; it previously arrived indistinguishable from naming no values at all, because this operation parsedFilter.Nitself and stopped at the first empty string.
Repeated filter names changed for every EC2 describe, not only this one: two Filter.N entries sharing a name now OR their values instead of the second silently replacing the first. AWS documents that filters are ANDed and the values within a filter ORed, and says nothing about a name appearing twice, so the merge is substrate's reading. What it replaces was not a reading of anything — the earlier entry was discarded with no indication. Differently-named filters are ANDed, as documented, and always were.
A subnet reports its tags, and filters on them
EC2Subnet has carried a Tags field since it existed, and CreateTags on a subnet- ID has always written to it. Only the reader was missing: DescribeSubnets rendered six members and no tagSet, and it parsed no Filter.N at all — so a request for "the subnets of vpc-x" answered with every subnet in the region, with nothing in the response to say the filter had not been applied. That is the worst shape of an ignored filter: a consumer walking a VPC's subnets silently got its neighbours' as well.
Three things changed together, because each is useless without the others:
CreateSubnethonoursTagSpecification.Nscoped tosubnet, under the same two rules every other tag path enforces — the reservedaws:prefix and the 50-tag limit — and a refused request creates no subnet. This is how CDK and Terraform set the tags a caller then filters on, so without it the filters below had nothing to find.CreateSubnetandDescribeSubnetsrender oneSubnetelement, which is what AWS documents. The two previously rendered different subsets of it:CreateSubnetomittedmapPublicIpOnLaunch, so a caller reading the create response saw a different subnet from the one it could then describe. A regression test asserts the two are equal.- The element gained
tagSet,subnetArn,ownerIdanddefaultForAz. The ARN and the owner are what thesubnet-arnandowner-idfilters compare against, so rendering them is what lets a caller round-trip a value it read.
tagSet is absent on an untagged subnet rather than present-and-empty, which is what both of AWS's DescribeSubnets samples show. That deliberately differs from DescribeSnapshots, whose own page shows the empty element — each follows its own operation's samples rather than one house rule. No sample on either page shows a tagged subnet, so tagSet's position last in the element is substrate's choice.
Five members AWS's samples carry stay absent: availableIpAddressCount, availabilityZoneId, assignIpv6AddressOnCreation, ipv6CidrBlockAssociationSet and the blockPublicAccessStates/privateDnsNameOptionsOnLaunch structures. Nothing in state backs them, and deriving an address count from the CIDR would be fabrication — the real count depends on how many addresses AWS reserves and on every interface in the subnet.
Eleven filter names are applied, plus the four alias spellings the reference documents inline ("You can also use availabilityZone as the filter name"); the aliases are separate names on the wire, so each is answered beside its canonical form rather than normalized away. The fourteen names AWS documents that substrate keeps no state for are accepted and inert, and anything outside the twenty-five is refused — the same rule as everywhere else, which lists both sets.
cidr-block is exact, per AWS: "The CIDR block you specify must exactly match the subnet's CIDR block for information to be returned for the subnet." A caller asking for 10.0.0.0/16 does not get 10.0.1.0/24.
A snapshot filters on its own members, and scopes by account
DescribeSnapshots evaluated exactly one filter — snapshot-id — and silently dropped the other thirteen, so status=pending or tag:Env=prod returned every snapshot in the account. Eleven are now applied: description, encrypted, owner-id, progress, snapshot-id, start-time, status, tag-key, tag:<key>, volume-id and volume-size. The remaining three — owner-alias, storage-tier and transfer-type — name members substrate does not render, so there is nothing to compare a value against; they are accepted and inert.
progress was the fourth inert name until substrate had a progress to render (see Seeding a snapshot progression). It compares against the percent-suffixed string the response carries, which is the form AWS's own filter text shows ("for example, 80%"): a caller filters on 100%, not on 100.
status and progress compare against what this request reports, not against the stored record. The two differ only under a seeded progression, and that is the one case a poll loop cares about — filtering on the record would make status=pending select nothing at the very moment the caller is being told the snapshot is pending. It also means the CLI's own aws ec2 wait snapshot-completed, which polls --filters Name=status,Values=completed, terminates: an observation is taken before the filters run, so a snapshot the filter excludes has still advanced its countdown.
Owner.N and RestorableBy.N were read by neither the ID selection nor the filters, and are now honored. Both sit outside Filter.N and both accept self, an account ID, or one of AWS's aliases. Substrate is single-account, so self and the requesting account's ID match everything, and anything else — including amazon — matches nothing: answering "snapshots owned by amazon" with the account's own snapshots would claim they were public. Values within either parameter OR, and the two AND with the filters and with the ID list.
A snapshot a filter excluded still counts as resolved, so naming an existing snapshot and filtering it out is an empty HTTP 200 rather than InvalidSnapshot.NotFound — the rule Explicit resource IDs states, and the distinction between "not yet" and "never" that a consumer's wait loop turns on. DescribeSubnets follows it too.
A snapshot has a real size
Until #689 every snapshot in substrate reported volumeSize as the literal 8, because CreateImage was the only thing that wrote one and it wrote the constant. So the volume-size filter above compared against a constant, the mapping rule "a size must not be smaller than its snapshot's" had nothing to test, and DescribeImages rendered a second, independent 8 for the same snapshot — two constants that agreed only for as long as neither knew a real size.
CreateSnapshot closes it at the source:
| Behaviour | Answer |
|---|---|
VolumeId | Required, and checked against state. Absent is MissingParameter; a syntactically invalid ID is InvalidVolumeID.Malformed; one that names nothing is InvalidVolume.NotFound — so a snapshot cannot exist for a volume that does not |
volumeSize | The source volume's size, which is what AWS documents the member as: "the size of the volume, in GiB" |
encrypted | The source volume's. "Snapshots that are taken from encrypted volumes are automatically encrypted" |
status | completed at once by default, not the pending AWS's own sample response shows — unless a progression is seeded, in which case the snapshot is born in the seeded state |
progress | Rendered, since AWS documents it as a member of the Snapshot that CreateSnapshot returns. 100% for an unseeded snapshot; the ramp's current position under a seed |
statusMessage | Not rendered, because AWS scopes it: "this parameter is only returned by DescribeSnapshots" — where an SDK reads the same element as StateMessage, the third of these name splits after status/State and state/State |
Description, TagSpecification.N | Read; the tags go through the same walk and the same tag rules as every other tag-on-create, checked before anything is written |
Location, OutpostArn | Not read. Both are documented as supported only for a Local Zone or an Outpost, neither of which substrate models, so honoring them would place the snapshot somewhere substrate cannot describe it from |
status being completed immediately is the deliberate divergence. Substrate advances no snapshot asynchronously, so a caller's waiter succeeds on its first poll rather than depending on wall-clock time — which is the point of a deterministic emulator. The waiting path is exercised instead by seeding it: see Seeding a snapshot progression.
CreateImage's own snapshot now reads the instance's root volume too, recording both its size and its ID — so an AMI made from a 40 GiB root volume reports 40 GiB through DescribeSnapshots and through DescribeImages, which reads the snapshot record rather than rendering its own constant. An AMI whose snapshot is missing falls back to the 8 GiB default rather than reporting 0, since a caller sizing a volume off that member can act on the default. Two things reach that state, and RegisterImage naming a snapshot that does not exist is no longer one of them — that request is refused, see RegisterImage records the whole block device mapping. What remains is deleting the snapshot a non-root mapping names, which DeleteSnapshot permits because AWS scopes its refusal to the root device (below), and an AMI record written directly into state.
The rest of the snapshot family
CreateSnapshots, CopySnapshot and the three attribute operations answered InvalidAction until #709 — the answer that tells a caller the endpoint does not speak EC2, rather than that substrate has a gap. All five are implemented. None of the five publishes an operation-specific error: every Errors section points only at the common types, and CreateSnapshots has no Examples section at all. So each refusal below is either a code from EC2's client-error table (which describes a condition rather than quoting a wire message) or substrate's reading of a rule AWS states in prose; the table says which.
CreateSnapshots snapshots every EBS volume attached to one instance in a single call.
| Behaviour | Answer |
|---|---|
InstanceSpecification.InstanceId | Required; absent is MissingParameter. Resolved against state, so an absent instance is InvalidInstanceID.NotFound rather than an empty snapshotSet — an empty set is the true answer for an instance with nothing left to snapshot, and a consumer's wait loop turns on the difference |
| Order | snapshotSet is ordered by device name. State keys come back in map order, so anything else would answer differently on each run and snapshotSet[0] would be a coin toss |
ExcludeBootVolume | Drops the volume attached at a root device name |
ExcludeDataVolumeId.N | Singular member, indexed — not a plural. Up to 40 per request, per AWS; a 41st is InvalidParameterValue. An ID that is not attached excludes nothing, which is what the parameter asks for |
The root volume named in ExcludeDataVolumeId.N | Refused, naming ExcludeBootVolume. AWS: "If you specify the ID of the root volume, the request fails. To exclude the root volume, use ExcludeBootVolume." The code is substrate's; AWS says only that the request fails |
CopyTagsFromSource | Valid value volume, and any other value is refused rather than treated as "do not copy". Each snapshot inherits its own source volume's tags |
Description, TagSpecification.N | Applied to every snapshot the call creates. Where a request tag collides with a copied volume tag, the request wins — AWS settles neither the precedence nor the ordering, so both are substrate's |
state | completed at once, for the reason CreateSnapshot's status is — and seedable the same way. A "*" seed governs every snapshot in the set, each with its own countdown, so snapshotting five volumes under a two-poll seed leaves each of the five with its own two polls |
progress | Rendered, as CreateSnapshot's is |
Location, OutpostArn | Not read, as on CreateSnapshot |
The response element is snapshotSet of SnapshotInfo, which names the state member state where CreateSnapshot and DescribeSnapshots both name the same thing status. A caller unmarshalling SnapshotInfo reads State. availabilityZone, outpostArn and sseType are not rendered: availabilityZone is the Local-Zone placement member — the singular CreateSnapshot response has no such member — so rendering the volume's AZ would claim a local snapshot, and neither Outposts nor a specific server-side encryption type is modelled. SnapshotInfo has no statusMessage member at all; AWS publishes that one on Snapshot alone.
A refused request writes nothing: every snapshot is built and its tags checked before the first write, so a five-volume instance whose fourth volume carries a reserved tag key does not leave three snapshots behind.
CopySnapshot copies a snapshot within the region, which AWS's own text makes coherent for a single-region emulator twice over. "If the source snapshot is in a Region, you can copy it within that Region" makes a same-region copy legal rather than degenerate, and AWS's Example 1 is one. And DestinationRegion is not routing — "this parameter is only valid for specifying the destination Region in a PresignedUrl parameter … the snapshot copy is sent to the regional endpoint that you sent the HTTP request to" — so the destination is the endpoint, and the endpoint is the one region substrate serves.
| Behaviour | Answer |
|---|---|
SourceRegion, SourceSnapshotId | Both required; absent is MissingParameter. A malformed source is InvalidSnapshotID.Malformed, one naming nothing InvalidSnapshot.NotFound |
A SourceRegion that is not the request's region | SnapshotCopyUnsupported.InterRegion, whose published description — "inter-region snapshot copy is not supported for this AWS Region" — is precisely substrate's situation. The code is AWS's; the message is substrate's |
Encrypted | Accepted only as true. A copy of an encrypted snapshot is encrypted whatever the request says; Encrypted=false is refused rather than silently honored or silently dropped |
KmsKeyId | Validated, not stored: "if KmsKeyId is specified, the encrypted state must be true", so naming a key for a copy that will not be encrypted is refused. Substrate models no KMS key on a snapshot, so the rule is observable only as that refusal |
Description | The request's, empty when absent. AWS documents nothing for an omitted one, so substrate invents no "Copied from …" string a consumer might assert on |
TagSpecification.N | The only source of the copy's tags. CopySnapshot has no CopyTagsFromSource — that is CreateSnapshots' parameter — so the source's tags are deliberately not carried over |
volumeId | A freshly generated ID that names no volume, which is what AWS produces: "snapshots copies have an arbitrary source volume ID. Do not use this volume ID for any purpose." Rendering the source's would hand a caller a reference that resolves, inviting exactly that use |
CompletionDurationMinutes, DestinationAvailabilityZone, DestinationOutpostArn, DestinationRegion, PresignedUrl | Not read. A time-based copy has nothing to slow down here, the next two place the copy somewhere substrate does not model, and the last two are artifacts of signing a cross-region request |
The response carries snapshotId and tagSet and nothing else — no status, no size — so a caller polls DescribeSnapshots for the rest.
The attribute trio — DescribeSnapshotAttribute, ModifySnapshotAttribute and ResetSnapshotAttribute — follows Instance attributes in four respects: the attribute name is validated before the snapshot is resolved (a bad name is a defect no retry fixes; an absent snapshot may be one the caller is still waiting on), exactly one attribute element marshals, a present-but-empty element is not the same observation as an omitted one, and an attribute substrate will not answer is refused rather than answered with an invented default.
| Behaviour | Answer |
|---|---|
Attribute | Required: Yes on the describe and the reset — "you can specify only one attribute at a time" — and Required: No on the modify, where the wire form carries the selection. Valid values createVolumePermission and productCodes; anything else is InvalidParameterValue |
productCodes on the describe | Answered, as a present but empty element. Nothing in substrate assigns a product code, so "none" is a fact about every snapshot it can produce rather than an invented default — and an SDK reads a present-but-empty element as an empty list where it reads an omitted one as nil |
productCodes on the modify | Refused: "only volume creation permissions can be modified" |
productCodes on the reset | Refused: "only the attribute for permission to create volumes can be reset" |
createVolumePermission on a fresh or copied snapshot | Present and empty. AWS describes a reset snapshot as "a private snapshot that can only be used by the account that created it", which is what a created one already is |
| The two wire forms of a modification | Both read. Structured is CreateVolumePermission.Add.N.{UserId,Group} and …Remove.N.…, which is what AWS's own examples send; flat is OperationType (add/remove) with UserId.N and UserGroup.N — the wire member, where the CLI spells the same thing --group-names and an SDK spells it GroupNames. A request using both simply concatenates; AWS documents no precedence |
Group | Only all. Any other group is refused rather than stored, since a stored group nothing can grant is a permission a caller reads back and cannot act on |
| Adding and removing an account ID in one request | Refused. AWS: "You may add or remove specified AWS account IDs … but you cannot do both in a single operation" |
| Adding a group while removing an account | Allowed — that is AWS's own Example 2, which adds all while removing 111122223333. The sentence above is scoped to account IDs, so the refusal is too; read as a blanket rule it would reject AWS's example |
| Sharing an encrypted snapshot publicly | Refused: "you can share only unencrypted snapshots publicly". Sharing an encrypted snapshot with a named account is not refused — the rule is about the group. The companion rule, that a snapshot carrying a Marketplace product code cannot be made public, is unreachable rather than unmodelled: substrate assigns no product codes |
| More than 500 modifications | Refused: "you can make up to 500 modifications to a snapshot in a single operation" |
A flat UserId.N/UserGroup.N with no OperationType | MissingParameter. The values name no list to join, and accepting the request would silently discard the permission |
| A modify naming no modification at all | Succeeds and changes nothing. Every parameter but SnapshotId is optional and AWS publishes no error for the empty case |
| Adding a permission already present, or removing one that is not | Neither is an error, and neither duplicates. AWS documents no refusal for either, and both are idempotent in the direction a cleanup loop needs |
| The reset | Clears the list rather than restoring a remembered default, since an empty list is what a snapshot is created with |
Substrate is single-account, so a permission recorded here grants nothing. It is recorded intent a caller can read back, which is the observable half of sharing — and the observable half is the whole of what substrate models.
Deleting a snapshot refuses what AWS refuses
DeleteSnapshot did delete the record — it has since #325, whatever its doc comment claimed — and validated nothing whatever. Every well-formed ID answered HTTP 200 and <return>true</return>, which is the answer a caller reads as "deleted": a cleanup loop deleting a typoed ID, an ID from another region, or the same ID twice was told each time that it had removed a snapshot.
| Request | Answer |
|---|---|
SnapshotId omitted | MissingParameter — AWS marks it Required: Yes |
Not a snapshot ID (vol-…, ami-…, no prefix, non-hex, uppercase) | InvalidSnapshotID.Malformed |
| Well-formed, names nothing — including a second delete of the same ID | InvalidSnapshot.NotFound |
| A registered AMI's root device snapshot | InvalidSnapshot.InUse, naming both the snapshot and the AMI |
| Anything else | Deleted; a subsequent DescribeSnapshots does not report it |
The in-use rule is AWS's: "You cannot delete a snapshot of the root device of an EBS volume used by a registered AMI. You must first deregister the AMI before you can delete the snapshot." Its scoping — the root device specifically — is load-bearing, not incidental. An AMI records the whole mapping it was registered with, so it can reference snapshots that are not its root device's, and those are not protected: deleting one succeeds and leaves the mapping pointing at a snapshot that no longer exists, which DescribeImages then renders at the 8 GiB default. That is what AWS's sentence says, and following the API model rather than a wider guess is the standing rule here. Which mapping is the root device's is decided by the request's RootDeviceName, falling back to the first mapping that names a snapshot; a CreateImage-minted AMI has one snapshot and it is the instance's root volume's.
Two consequences worth planning for:
- The operation is not idempotent, and against AWS it never was. A consumer whose teardown is written to be re-runnable has to tolerate
InvalidSnapshot.NotFound, which is what it has to tolerate against AWS. Substrate answering 200 was hiding that requirement. - Order matters: deregister the AMI, then delete its snapshot. Substrate previously let the reverse order succeed, leaving an AMI pointing at a snapshot that no longer existed — a state real EC2 cannot be put into.
When two AMIs reference one snapshot — reachable since #328, because RegisterImage against an existing snapshot shares it — the refusal names the lowest image ID. That tie-break exists so identical inputs produce an identical response body on replay; without it the message would follow Go's map iteration order.
Provenance: none of these codes is on API_DeleteSnapshot.html, whose Errors section is empty. All three come from EC2's client-error table, and its InvalidSnapshot.InUse entry describes the condition ("The snapshot that you are trying to delete is in use by one or more AMIs") rather than quoting a wire message, so the message substrate returns is its own wording of AWS's description — match on the code, not the string.
Every taggable ID prefix is reachable
CreateTags resolved ten ID prefixes and silently ignored the rest: a request naming an ami-, lt-, fleet-, pg- or key- ID answered <return>true</return> and wrote nothing. Every one of the five was well-formed and named a resource substrate stores, so there was no way for a caller to tell — the answer a consumer's tag-everything step reads is the same answer it gets when the tags land.
Sixteen prefixes now resolve, on CreateTags and DeleteTags alike — the fifteen the resolution below covers, and cr- with the Capacity Reservation operations (#891):
| Prefix | Resource type | Prefix | Resource type |
|---|---|---|---|
i- | instance | snap- | snapshot |
vpc- | vpc | ami- | image |
subnet- | subnet | lt- | launch-template |
sg- | security-group | fleet- | fleet |
igw- | internet-gateway | pg- | placement-group |
rtb- | route-table | key- | key-pair |
eipalloc- | elastic-ip | vol- | volume |
nat- | natgateway | cr- | capacity-reservation |
A prefix naming no taggable type is now refused rather than ignored, before anything is written:
InvalidID: The ID 'tgw-0abc11112222333d' for the resource you are trying to tag is not
valid. Ensure that you provide the full resource ID; for example, ami-2bb65342 for an AMI.The status is 400, and the check runs over every ResourceId.N before the first tag is applied — so a request naming a good instance ID first and a bad ID second tags neither, the same all-or-nothing rule the reserved-key check follows.
A well-formed prefix naming nothing stays a no-op at HTTP 200, and is not counted against the 50-tag limit: there is nothing to apply the tags to, so refusing would reject a request real EC2 accepts.
Two of the sixteen are keyed by name rather than by ID in substrate's state, and by name in AWS's ARN as well — arn:aws:ec2:${Region}:${Account}:placement-group/${PlacementGroupName} and …:key-pair/${KeyPairName}. CreateTags takes the pg-/key- form and translates by scanning the namespace for the ID inside each record; DescribeTags reports the resourceId member the record carries, not the name its key ends in, so what comes back is what a caller can pass in again.
Whether real CreateTags takes a placement group by ID or by name is not settled by AWS's documentation: the ARN is by name and DescribePlacementGroups publishes no group-id filter, but the client-error table publishes InvalidPlacementGroupId.Malformed "in the form pg-xxxxxxxxxxxxxxxxx", which only an ID-taking operation can raise. Substrate accepts the pg- form.
Three things had to change alongside the resolution:
- A launch template's own tags were settable by no path at all. The
TagSpecificationinCreateLaunchTemplate'sLaunchTemplateDatais scoped to the resources a launch creates — "for the resources that are created when an instance is launched" — which is a different parameter from the top-levelTagSpecification.N, "the tags to apply to the launch template on creation. To tag the launch template, the resource type must belaunch-template". Substrate read only the inner one. It now reads both, each on its own scope. CreateKeyPair,ImportKeyPairandCreatePlacementGrouphonourTagSpecification.Nand echo the result, andDescribeKeyPairs/DescribePlacementGroupsrendertagSet.EC2KeyPairhad no tags field to write to.- An
imageand asnapshotARN have an empty account field —arn:${Partition}:ec2:${Region}::image/${ImageId}— where the other fourteen carry${Account}. The authorizer stamped the account on unconditionally, which makes an ARN-scopedDenynaming an AMI inert and a least-privilegeAllowunable to grant the call. Thesnap-arm shipped with that defect in #689 and is fixed here too.
An untagged resource omits tagSet
Seven renderers previously answered <tagSet></tagSet> for a resource with no tags and now omit the element: CreateKeyPair, ImportKeyPair, DescribeKeyPairs, CreatePlacementGroup, DescribePlacementGroups, CreateLaunchTemplate and DescribeLaunchTemplates. This is a wire change, and it follows AWS's own examples — CreateLaunchTemplate's Example 1 and CreatePlacementGroup's Example 2 each create an untagged resource and neither response carries a tagSet at all, while CreateKeyPair's tagged example does. An SDK distinguishes an absent list from an empty one, which is the same distinction DescribeTags keeps <value/> present-but-empty for.
DescribeFleets is the one place the old shape stays. Its API reference page publishes no example response, so there is no untagged sample to follow and changing it would be substrate guessing rather than substrate reading. DescribeSnapshots keeps its present-but-empty element for the opposite reason: its own page shows it. Each response answers to the page that documents it.
Finding a resource by tag
DescribeTags — the operation whose whole job is this question — did not exist until #688, and answered InvalidAction / HTTP 400, while four of substrate's bundled managed policies grantedec2:DescribeTags: AmazonVPCFullAccess and AmazonVPCReadOnlyAccess name it outright, and AmazonEC2FullAccess and AmazonEC2ReadOnlyAccess reach it through ec2:* and ec2:Describe*. A policy permitted an operation nothing served, and the only routes left were the per-operation tag:<key> filters (five operations) and Resource Groups Tagging.
It reports every tag stored in the request's account and region. Every EC2 record key is <namespace>:<account>/<region>/<id>, so the scan is regional by construction, which is also real EC2's scope for this operation.
The scan and what CreateTags can write are the same sixteen types. They were not: DescribeTags read thirteen and CreateTags reached ten, so an image, launch-template or fleet tag could be reported and not changed, while a placement-group or key-pair tag could be neither — both were absent from the scan as well. See every taggable ID prefix is reachable for the prefixes and for what a request naming something else now answers.
| Behaviour | Answer |
|---|---|
| Filters | key, resource-id, resource-type, value, tag:<key> — every name AWS documents, all evaluated, none inert |
tag-key | Refused. This operation documents no such filter, alone in the describe family, because key already matches a key whatever its value |
| Wildcards | * and ? work in every filter value, which AWS's Example 4 states outright ("specify the value as ?ebserver to find tags with the key webserver or Webserver"). Since #697 that is true of every EC2 filter, not just this one |
| An empty value | A tag whose value is the empty string renders <value/> and is matched by Filter.N.Value.1= — AWS's Example 1 and Example 6 respectively |
MaxResults | 5–1000. A value outside the range is refused with InvalidParameterValue, not clamped: a caller who asked for 2000 asked for something the operation cannot do |
NextToken | A decimal offset. A malformed token is refused; an offset past the end is clamped to an empty last page, so resuming after a tag was deleted is not an error |
| Order | Sorted by resourceId, then resourceType, then key |
The sort is stricter than AWS, which says its own order "might vary" and that applications should not rely on it. Substrate must be stricter: StateManager.List promises no ordering either, so an offset-based token over an unordered list could skip or repeat a tag between pages, and two replays of one recorded request could answer differently. A deterministic emulator cannot answer one request two ways.
Authorization needs nothing special: the Service Authorization Reference gives DescribeTags resource type "—", and substrate authorizes it against *.
One offset paginator, shared
DescribeTags and DescribeLaunchTemplateVersions each carried their own copy of the same three rules — read MaxResults, decode NextToken, cut the page — while sixteen other routed describes published both parameters and implemented neither. Those answered the whole listing with no token, which is the one divergence a paginating caller cannot see: the loop terminates on the first page against substrate and finds a second page in production. #917 replaced the two copies with one shared paginator and converted seven of the sixteen onto it, so the count of implementations went down rather than up. #1024 converted the remaining nine, in three parts grouped by the range each page publishes: the first is the three publishing no range — DescribeInstanceStatus, DescribeSpotPriceHistory and DescribeFleets — the second is the five publishing a floor of five, DescribeInternetGateways, DescribeNatGateways, DescribeRouteTables, DescribeInstanceTypes and DescribeInstanceTypeOfferings, and DescribeLaunchTemplates alone is the third, because its is the one page in the set whose published floor is not five. No routed describe publishing both parameters implements neither any longer.
The eighteen that page are therefore DescribeTags and DescribeLaunchTemplateVersions, which already did, plus DescribeVolumes, DescribeSnapshots, DescribeImages, DescribeVpcs, DescribeSubnets, DescribeSecurityGroups and DescribeInstances from #917, plus DescribeInstanceStatus, DescribeSpotPriceHistory and DescribeFleets from #1024's first part, DescribeInternetGateways, DescribeNatGateways, DescribeRouteTables, DescribeInstanceTypes and DescribeInstanceTypeOfferings from its second, and DescribeLaunchTemplates from its third. Wire behaviour for a caller that sends neither parameter is unchanged at every one of them: the count is exact and audited, rather than the "roughly twenty" this paragraph used to estimate. DescribeCapacityReservations joined the paginating set later (#891) rather than being converted, so it is a nineteenth: its published range is 1–1000, and it reads both parameters from the start.
AWS publishes the mechanism once for the whole service, in the Query Requests page's Pagination section rather than per operation, and two of its sentences decide the design:
With pagination, you continue to call the action until
nextTokenis null, even if you receive less thanMaxResultsitems, including zero items.
If you call a describe API action with both a list of IDs and
MaxResults, the request fails with the errorInvalidParameterCombination.
So a short page is not the end of the listing — the token is emitted from whether a further record exists, not from whether the page filled up, which is also why a listing whose size is an exact multiple of the page size costs no extra round trip to an empty page. And the last page carries no token at all, because a caller told to keep calling until the token is null would otherwise loop forever.
| Behaviour | Answer |
|---|---|
An absent MaxResults | The whole listing, with no nextToken element. API_DescribeSecurityGroups is the one page that states this outright — "If this parameter is not specified, then all items are returned" — and it is what every converted operation answered before it paginated. DescribeTags and DescribeLaunchTemplateVersions keep their own defaults of 1000 and 200, since neither page publishes the sentence and both defaults shipped deliberately — which is why DescribeLaunchTemplates and DescribeLaunchTemplateVersions share a published range and not a default: the first reports the whole listing, the second pages at 200 |
MaxResults out of range | Refused with InvalidParameterValue / 400, never clamped: a caller who asked for 2000 items asked for something the operation cannot do, and silently answering 1000 hides that |
NextToken | A plain decimal offset, and a token that is not a non-negative integer is refused with InvalidParameterValue / 400. It is validated before any state is read, so the refusal does not depend on how many resources happen to exist |
| An offset past the end | Clamped to an empty last page rather than refused — a caller resuming a walk after a record was deleted holds a token that was valid when it was issued |
An ID list and MaxResults | InvalidParameterCombination / 400. Checked before the ID list's own syntax: whether two parameters may appear together does not depend on either being well formed. Which refusal AWS answers first is not published, so the ordering is substrate's |
The range is per operation, and seven of the eighteen pages publish none. API_DescribeVpcs, API_DescribeSubnets, API_DescribeSecurityGroups, API_DescribeInternetGateways, API_DescribeNatGateways and API_DescribeInstanceTypeOfferings publish Valid Range: Minimum value of 5. Maximum value of 1000.; API_DescribeRouteTables and API_DescribeInstanceTypes publish the same floor with Maximum value of 100.; API_DescribeLaunchTemplates publishes Minimum value of 1. Maximum value of 200. and repeats it in prose; API_DescribeTags (5–1000) and API_DescribeLaunchTemplateVersions (1–200) state theirs in prose only; API_DescribeInstances, API_DescribeImages, API_DescribeVolumes, API_DescribeSnapshots, API_DescribeInstanceStatus, API_DescribeSpotPriceHistory and API_DescribeFleets publish no bound at all — only "the maximum number of items to return for this request", type Integer. Substrate does not borrow 5–1000 from the siblings at those seven, per the scope rule that only what the API model states is modelled: MaxResults=5000 is accepted on all seven, and MaxResults=1 is accepted there and refused on the eight that publish a floor of five. A floor of one is therefore reached two different ways — published at the two launch-template pages and substrate's reading at those seven — and the distinction is why they are separate constants even though the number is the same.
The ceiling is per operation too, and two neighbouring pages disagree about the same value.MaxResults=1000 is accepted on DescribeNatGateways and refused on DescribeRouteTables, which publishes 100 — two operations a caller reaches in the same breath while wiring a VPC, whose requests substrate must answer differently because their pages do. DescribeInstanceTypes (100) sits beside DescribeInstanceTypeOfferings (1000) the same way, and DescribeLaunchTemplates (200) refuses it too. Harmonising any of those pairs would be a one-character edit and would make substrate accept a request AWS rejects, which is the direction of divergence that matters: the code would work here and fail in production.
The floor of one at those seven pages is substrate's reading, and it is the single bound the published pagination rule forces. MaxResults=0 under "you continue to call the action until nextToken is null, even if you receive less than MaxResults items, including zero items" describes a walk that can never advance — every call answers nothing and hands back a token — so refusing it is the only answer that does not invite an infinite loop.
The token is a decimal offset rather than the base64 form the CloudWatch, Systems Manager and S3 listings use (see A pagination token substrate never issued). That is the shape both original EC2 operations already issued, so it is part of every run already recorded against them; and the property base64 buys — that a token substrate never issued is detectable — is worth less here, because an invented decimal offset resumes the walk from that offset instead of silently restarting it.
Paging is a cut of an already-assembled answer rather than an early exit from the scan, which is load-bearing at DescribeSnapshots: a seeded status progression advances once per observation, so stopping the scan at the page boundary would make a countdown advance by an amount that depended on the caller's page size.
DescribeInstances counts instances, not reservations — substrate's reading, because its answer is the one nested listing in the set: reservationSet > item > instancesSet. The page says only "the maximum number of items" and never states which of the two lists an item is. Counting reservations would leave MaxResults unable to bound a response at all, since one RunInstances with MinCount=500 is a single reservation and a page of five could hold five hundred instances; and NextToken's own text — "Pagination continues from the end of the items returned by the previous request" — describes a position in a flat sequence rather than in the grouping.
The visible consequence, also substrate's reading and also unpublished, is that a reservation whose instances straddle a page boundary is reported on both pages, each carrying only the instances belonging to that page. The alternative — never splitting a reservation — would have to answer either more instances than MaxResults asked for or fewer than are available alongside a token, each of which contradicts the parameter more visibly than a repeated reservation ID does. An instance is still reported on exactly one page, which is what a caller assembling a walk relies on.
GroupName.N does not conflict with MaxResults on DescribeSecurityGroups, where GroupId.N does — substrate's reading again. The service-wide rule is stated against "a list of IDs", and a group name is not an ID; it is not what InvalidGroup.NotFound is about either (see Which selectors assert existence). AWS publishes nothing about the combination, so refusing the name form would mean extending a published rule to a parameter it does not name. DescribeLaunchTemplates is the second operation that reading applies to, and the only one where the two selectors union: LaunchTemplateId.N forbids MaxResults and LaunchTemplateName.N pages alongside it, so a request naming both lists and MaxResults is still refused — the rule is about the ID list appearing, not about it being the only selector.
Three paginating describes carry no InvalidParameterCombination refusal at all, because the service-wide rule is stated against "a list of IDs" and none of the three has one. DescribeInstanceTypeOfferings is the plainest: its whole request is DryRun, Filter.N, LocationType, MaxResults and NextToken, so there is no candidate parameter even to argue about. DescribeSpotPriceHistory's InstanceType.N is documented as "Filters the results by the specified instance types" — a filter, and an instance type is not a resource ID, which is the same reading that makes an unknown type an empty history there rather than InvalidInstanceType.
DescribeInstanceTypes is the interesting one, and it lands the same way. Its InstanceType.N is a stronger parameter than the spot-price namesake — it asserts the types exist, so an unknown one is refused with InvalidInstanceType — but what it names are catalog members rather than resources the account holds, and the published rule is about resource IDs. So MaxResults and InstanceType.N are read together at all three, which is asserted rather than assumed, because a sweep is exactly where a published rule gets applied one operation too far.
The opposite case is DescribeInstanceStatus, where the prohibition is published twice over: the service-wide sentence, and its own page repeating it against its own parameter in the same words API_DescribeInstances uses — "You cannot specify this parameter and the instance IDs parameter in the same request."
An instant fleet can never appear on a paginated DescribeFleets page. That is AWS's arithmetic from two published rules rather than substrate's choice: a fleet of type instant is reported only when its ID is named (see Seeding EC2 Fleet partial fulfillment for the fleet types substrate models), and naming an ID list forbids MaxResults. So the two conditions cannot hold at once, and substrate's own pagination tests build maintain fleets for that reason.
One published shape substrate does not take: API_DescribeSpotPriceHistory's Example Response shows <nextToken/> on a last page, while the same member is documented as "an empty string ("") or null when there are no more items". Both shapes are published, and substrate omits the element — the answer every other converted describe gives, and one a caller decoding into a string reads as "" either way.
DescribeLaunchTemplates was the last of the sixteen, and it is a part of its own rather than a sixth row of the five above because API_DescribeLaunchTemplates publishes 1–200 — the only page in the whole set whose floor is 1 rather than 5, stated as a Valid Range line and repeated in prose. API_DescribeLaunchTemplateVersions states the same range in prose alone, so the two share one pair of constants without either borrowing the other's bound; what they do not share is the default, since neither page publishes one and each keeps the behaviour it shipped with.
Two further readings are recorded there. Its offset counts positions in the account's launch-template index, which is kept sorted by ID rather than in creation order, so the same offset names the same template on two calls without an explicit sort — the one converted describe whose ordering comes from an index rather than from StateManager.List. And IncludeManagedResources is published and read nowhere: substrate models no launch template owned by another service, so the parameter has nothing to include or hide, and it is recorded here rather than refused, because refusing a published parameter is the larger divergence.
GetSpotPlacementScores is the sharpest illustration of the range being per operation, and it is not part of that count because it reads both parameters already: its published floor is 10, higher than any Describe* in the service. A shared floor would accept MaxResults=1 there, which AWS refuses.
Seven further routed Describe* operations publish neither parameter and so are not part of that count: DescribeKeyPairs, DescribePlacementGroups, DescribeAvailabilityZones, DescribeAddresses, DescribeRegions, and the two single-attribute reads DescribeInstanceAttribute and DescribeSnapshotAttribute. Answering everything in one page is what AWS describes at each, so there is nothing there to convert.
Seeding EC2 Fleet partial fulfillment
CreateFleet fulfills its whole TotalTargetCapacity by default. Partial fulfillment — the case callers most often get wrong, since a fleet that asks for 12 and receives 8 still returns a fleet ID and echoes the request in TotalTargetCapacity — is reachable by seeding a shortfall:
# Fulfill 8 instances and report the remainder as a capacity failure.
curl -X POST http://localhost:4566/v1/ec2/fleet-shortfall \
-d '{"launchTemplate":"lt-0abc123","fulfill":8,
"errorCode":"InsufficientInstanceCapacity","lifecycle":"spot"}'
# Clear one seed, or all of them.
curl -X DELETE 'http://localhost:4566/v1/ec2/fleet-shortfall?launchTemplate=lt-0abc123'
curl -X DELETE http://localhost:4566/v1/ec2/fleet-shortfalllaunchTemplate matches a launch template ID or name, or * (the default) for any. The shortfall is spread across the request's capacity pools, so errorSet reports one item per pool that came up short, and DescribeFleets reports the result in fulfilledCapacity.
Seeding a snapshot progression
Every snapshot substrate writes is born completed, so the one loop callers actually write around this API — poll DescribeSnapshots until status is completed, which is what CDK's custom resources, Terraform's aws_ebs_snapshot and aws ec2 wait snapshot-completed all do — exits on its first iteration. The retry, timeout and error branches such a loop carries are never taken, so a consumer whose polling is broken, or which treats error as retryable forever, passes against substrate and fails against AWS.
# The next four observations report pending; the fifth reports completed.
curl -X POST http://localhost:4566/v1/ec2/snapshot-status \
-d '{"snapshotId":"*","pendingObservations":4}'
# Fail immediately, with the diagnostic AWS publishes for a failed copy. DescribeSnapshots
# renders it as `statusMessage`, which an SDK reads as `StateMessage`.
curl -X POST http://localhost:4566/v1/ec2/snapshot-status \
-d '{"snapshotId":"snap-0abc123","pendingObservations":0,
"finalState":"error","stateMessage":"Given key ID is not accessible"}'
# Clear one seed, or all of them.
curl -X DELETE 'http://localhost:4566/v1/ec2/snapshot-status?snapshotId=snap-0abc123'
curl -X DELETE http://localhost:4566/v1/ec2/snapshot-statusA seeded error is what makes the CLI waiter's own failure path reachable: botocore defines SnapshotCompleted with a pathAny Snapshots[].State == error acceptor alongside the success one — a fact the CLI's own documentation page omits — so aws ec2 wait snapshot-completed exits 255 with "encountered a terminal failure state" rather than retrying for ten minutes. That is the branch a consumer's error handling exists for, and it was unreachable before.
snapshotId matches one snapshot ID or * (the default) for any; an ID-scoped seed wins over the wildcard. state defaults to pending and finalState to completed, and both accept any of the five values AWS publishes for the member — pending, completed, error, recoverable, recovering. A value outside those five is refused rather than stored: it is one no SDK can map and no consumer can branch on, so the seed would look accepted and produce a response the caller's own model rejects. Seeding again restarts the countdown, so a test that seeds twice gets two full progressions rather than the remainder of the first.
The progression is counted in observations, not measured as a duration. The simulated clock advances with wall time from its baseline, so a duration seed would expire partway through a test and make every "still pending" assertion depend on how long the rest of the test took — which no test here may be. This follows the reasoning the SQS consistency seed records. A count is also the more useful unit: "the next two polls see pending" is what a test of a poll loop wants to say.
The count is per snapshot even under a "*" seed. Without that, one DescribeSnapshots over five snapshots would burn five observations off a single shared countdown, and the snapshot a test was actually watching would complete early.
progress is the fraction of the countdown already spent, so a four-observation seed reports 0%, 25%, 50%, 75% and then 100%. AWS documents the member only as "the progress of the snapshot, as a percentage", so the schedule is substrate's; it is deterministic, which is the property that matters, and a consumer must no more assert on an exact intermediate value than it may against AWS — whose own CreateSnapshot sample response implausibly shows a freshly created snapshot at 60%. Progress reaches 100% whatever the terminal state, rather than freezing below it for a failure: AWS's one observable data point says so, its restore-snapshot-from-recycle-bin example showing "Progress": "100%" beside "State": "recovering". So progress measures how far the progression ran, not whether it succeeded.
Several operations read the state without consuming an observation, since none of them is a poll:
CreateVolumerefuses a snapshot that is notcompletedwithIncorrectState. The rule is AWS's — its snapshot-states table says "a snapshot can't be used while it is in thependingstate" and "a snapshot can't be used if it is in theerrorstate" — thoughCreateVolume's own page publishes no error for it, so the code is substrate's choice from EC2's client-error table, the one it already answers with for a volume in the wrong state.- A block device mapping naming such a snapshot is refused the same way, with the same code, wherever the mapping is consumed: on the launch path (
RunInstances, andCreateFleetthrough it) and inRegisterImage. One rule, one place, two doors — because a launch that restores a volume from a snapshot is doing whatCreateVolumedoes, and the two disagreeing meant a consumer restoring through a launch never reached its own error branch (#732). AWS's snapshot-states table covers all four non-completedstates: arecoverablesnapshot "must first [be recovered] from the Recycle Bin", and arecoveringone is "ready for use" only once it reachescompleted. So the rule iscompleted-or-refused, not pending-and-error-only.RegisterImagepublishes no error for it either — its Errors section is empty — so again the code is substrate's choice. This is the one of these reads a single request can make more than once, one mapping each, so spending an observation on it would makependingObservations: 2mean something different depending on how many volumes a launch declared. - The two
CreateLaunchTemplateoperations are exempt, deliberately. A template records a mapping rather than consuming one, both report mapping problems through AWS's documentedwarningmember and refuse nothing, and a snapshot that ispendingwhen a template is written may legitimately becompletedby the time the template is used — so refusing at write time would forbid an ordering AWS permits. A launch from such a template is refused, which is where the mapping is used. DeleteSnapshotdoes not refuse one, because AWS permits it in so many words: "although you can delete a snapshot that is still in progress, the snapshot must complete before the deletion takes effect." The deferred-effect half is not modelled — AWS publishes nothing observable about the interval, since a caller cannot see the snapshot after the request returns either way.
A seed governs what an observation reports and never rewrites the snapshot record, whose state stays completed. So clearing a seed — or POST /v1/state/reset, which clears the whole namespace — makes every snapshot read completed again, and a snapshot with no seed against it is untouched.
A seed survives a replay: the POST above is recorded as an event and re-applied where it was written, so a stream whose four pending observations preceded a completed replays as exactly that rather than as five completeds. The countdown restarts from zero, because the reset still wipes the observed: counters and the recorded POST re-arms the seed. See How a seed survives a replay for the mechanism and for the one thing a programmatic replay has to pass.
There is no Python helper for this endpoint: pytest_substrate's seeding helpers are hardcoded to the Athena, Redshift Data and Timestream result endpoints, so drive this one with raw HTTP, as the fleet seed above is driven.
Seeding an instance-state progression
Every state change substrate applies reached its terminal state in the same request, so pending, stopping and shutting-down were three of the six codes AWS publishes for instanceState that no code path could produce. The loop callers actually write around this API — run, start or stop, then poll DescribeInstances until the state settles, which is what aws ec2 wait instance-running, Terraform's aws_instance and CDK's own custom resources all do — exited on its first iteration, so the retry, timeout and give-up branches those loops carry were never taken (#514).
Two halves, and only the second needs a seed.
The operation's own response reports the transient state, unconditionally. AWS publishes it in the sample responses of the operations themselves: API_StartInstances shows currentState 0 / pending beside previousState 80 / stopped, and API_StopInstances shows 64 / stopping beside 16 / running. Substrate reported the settled state at all four write sites, so a consumer reading the transition out of the call it just made — which is what a waiter's first observation is — saw a transition that had already finished. That is a published-response divergence with nothing to do with seeding, so it is corrected for every caller rather than behind a seed: RunInstances answers pending, StartInstances pending, StopInstances stopping and TerminateInstances shutting-down, while the record itself settles as before. An unseeded test therefore sees the transient state once, in the operation's own response, and the settled state on its first describe — AWS with an instantaneous transition.
How long the transient state is reported is seeded.
# The next two observations of any instance report the transient state; the third settles.
curl -X POST http://localhost:4566/v1/ec2/instance-state \
-d '{"instanceId":"*","transientObservations":2}'
# One instance only, and back to instantaneous.
curl -X POST http://localhost:4566/v1/ec2/instance-state \
-d '{"instanceId":"i-0abc123","transientObservations":5}'
# Clear one seed, or all of them.
curl -X DELETE 'http://localhost:4566/v1/ec2/instance-state?instanceId=i-0abc123'
curl -X DELETE http://localhost:4566/v1/ec2/instance-stateinstanceId matches one instance ID or * (the default) for any; an ID-scoped seed wins over the wildcard. transientObservations defaults to 0, which is the instantaneous behaviour the whole existing suite pins, and a negative count is refused rather than stored. The seed is read by DescribeInstances and DescribeInstanceStatus, the two operations a poll loop uses; every other read of an instance is unaffected.
The transient state is derived, not chosen. Unlike a snapshot's status — a free choice from a five-value enumeration, which is why the snapshot seed carries state and finalState — the state on the way to a target is fixed by AWS's own lifecycle: pending is "preparing to enter the running state … when it is launched or when it is started after being in the stopped state", stopping is "preparing to be stopped", shutting-down is "preparing to be terminated". So the seed carries a count and nothing else, and seeding a state substrate would not transition through is not offered.
The progression is counted in observations, not measured as a duration, for the reason the snapshot seed records: the simulated clock advances with wall time from its baseline, so a duration seed would make every "still pending" assertion depend on how long the rest of the test took. The count is per instance even under a "*" seed, so one DescribeInstances over five instances does not burn five observations off a single shared countdown. Every state change restarts the count, which is what makes one seed serve a stop-then-start sequence rather than only the first transition in it.
A seed governs what an observation reports and never rewrites the instance record, which is load-bearing here in a way it is not for a snapshot: because the stored state is always the settled one, a StartInstances that follows a StopInstances still sees stopped and succeeds, so seeding a progression cannot break a caller's own sequence. It is also why no operation ever observes a transient state, and therefore why substrate refuses nothing on account of one — neither API_StartInstances nor API_StopInstances publishes a state precondition at all, both Errors sections being empty.
One refusal is published, and substrate answers it now: a terminated instance can be neither started nor stopped. The lifecycle page's state table says such an instance "has been permanently deleted and cannot be started", so the start refusal rests on that sentence directly; the stop refusal rests on "permanently deleted" alone, no page found stating a stop precondition, and is substrate's reading rather than letting a stop resurrect a deleted instance into stopped — from which it would then start. The code is IncorrectInstanceState / 400, whose published description is the general rule this is an instance of ("The instance is in an incorrect state for the requested action"); neither operation page publishes an error of its own, so the message text is substrate's.
Seeding a Spot placement score
GetSpotPlacementScores answers a recommendation AWS computes from live Spot capacity, and substrate models no capacity broker — so the observations a consumer actually branches on cannot be derived from anything substrate knows. Its usual shape is "sample the free score, and only pay for a fulfillment probe where it looks promising", which makes the low-score branch the one most worth testing and the one that is unreachable without a seed.
# One region scores badly; the others stay nominal.
curl -X POST http://localhost:4566/v1/ec2/spot-placement-scores \
-d '{"region":"us-east-1","score":2}'
# One zone scores lower than its siblings. Observable only under
# SingleAvailabilityZone=true, since a region-scored answer names no zone.
curl -X POST http://localhost:4566/v1/ec2/spot-placement-scores \
-d '{"availabilityZoneId":"use1-az2","score":1}'
# One instance type is scarce everywhere.
curl -X POST http://localhost:4566/v1/ec2/spot-placement-scores \
-d '{"instanceType":"inf2.48xlarge","score":1}'
# Clear one scope, or all of them.
curl -X DELETE 'http://localhost:4566/v1/ec2/spot-placement-scores?region=us-east-1'
curl -X DELETE http://localhost:4566/v1/ec2/spot-placement-scoresA seed is scoped to one Availability Zone, one region, or every region, and to one instance type or every instance type. The most specific scope carrying a seed decides — zone, then region, then the wildcard — so seeding one bad zone inside an otherwise-seeded region works rather than being overwritten by the coarser seed. Naming both a region and an availabilityZoneId is refused rather than resolved by precedence: an AZ ID already fixes its region, which is why AWS reports the ID here, so a seed naming a zone in a different region has no reading that is not a guess.
Within one scope, a request naming several instance types takes the lowest seeded score, and only types that carry a seed are considered at all. Both halves follow from what a seed is for: including unseeded types at their default would let the default mask a seed, and taking the maximum would let a nominal sibling mask the scarce type a test seeded. It is also the reading closest to AWS's own, whose score describes fulfilling the whole request rather than its easiest member.
score is required and refused outside 1 to 10. That range is published as prose on the operation — "scored on a scale from 1 to 10" — and not as a Valid Range: line on SpotPlacementScore.score, so it is enforced here, where substrate owns the refusal, rather than being asserted as a response invariant AWS guarantees.
Absent a seed, the answer follows the one relationship between a request and its score that AWS publishes: "if you specify one or two instance types … the returned placement score will always be low." So a request naming one or two types scores 3 and everything else scores 7. Those two numbers are substrate's reading, chosen inside the published scale and far enough apart for a test to tell them apart — a 1 would claim there is no capacity anywhere and a 10 that fulfillment is certain, and substrate models nothing that could know either. A request naming no instance types scores nominally rather than low: naming none is legal (InstanceType.N publishes a minimum of 0 items) and has not met the documented condition, which is about specifying one or two rather than about specifying few.
The answer is ordered by score descending, then by region and zone ID ascending. AWS describes it as "the top 10 Regions or Availability Zones", which fixes the primary key; the tie-break is substrate's, and it is what makes the answer reproducible when several scopes share a score — which the page says they may. Without it, two runs of one request could order the same scores differently and an assertion on the first element would be a coin toss.
No nextToken is reachable here, and that is a consequence of two AWS facts meeting rather than a gap: MaxResults floors at 10, and substrate seeds three regions of three zones each, so the largest answer it can build is nine items and no legal page size can truncate it. A caller looking for a pagination loop to exercise will not find one; NextToken is still parsed and refused when malformed, as at every other paginated EC2 describe.
DryRun is accepted and inert, as it is at every EC2 operation substrate routes.
A Capacity Reservation is never consumed
CreateCapacityReservation, DescribeCapacityReservations and CancelCapacityReservation answer as of #891; before that all three reached the dispatcher's default arm and answered InvalidAction, so a consumer whose probe primitive is an immediate reservation — reserve, read the outcome, cancel — could not run at all.
A reservation is created active, which is what AWS's own examples show and what the User Guide describes ("the reserved capacity becomes available for use immediately after you create it"). pending is reachable by seeding rather than as a stage every create passes through.
Nothing consumes a reservation. RunInstances has no CapacityReservationTarget parameter here, so availableInstanceCount equals totalInstanceCount for as long as a reservation holds capacity and no instance ever occupies one — including a targeted reservation, whose instanceMatchCriteria is recorded and matches nothing. Only a cancel releases capacity, and it takes availableInstanceCount to zero while totalInstanceCount keeps reporting what was reserved; AWS publishes what neither becomes, and of the two readings this is the one that does not report remaining capacity on a reservation that has none. A consumer testing "did my launch land in the reservation I paid for" cannot ask that question here; what it can test is that the reservation exists, reports the capacity it asked for, is discoverable by filter and by tag, and reaches the state its outcome implies.
A future-dated reservation is refused rather than answered falsely. StartDate and CommitmentDuration each answer Unsupported / 400. A future-dated reservation is a different observable thing from an immediate one — it is assessed, then scheduled, then delivered or delayed or unsupported — and eight of the state member's thirteen values exist only for one of those or for a Capacity Block: assessing, scheduled, delayed, unsupported, cancelling, payment-pending, payment-failed, and unavailable, which appears in the Valid Values line with no prose description anywhere on the page. Answering active to a request for capacity two days out would be a false observation with nothing in it to tell a caller the request was not modelled. The code is substrate's reading: the operation's Errors section is the common-types boilerplate, and Unsupported ("The specified request is unsupported") is the client-table code whose gloss covers the shape.
Also absent, and answering InvalidAction: ModifyCapacityReservation — so a reservation's InstanceCount and EndDate cannot be changed after it is created — GetCapacityReservationUsage, GetGroupsForCapacityReservation, the four Capacity Reservation fleet operations, and PurchaseCapacityBlock/DescribeCapacityBlockOfferings. reservationType is therefore always default; no path here creates a capacity-block.
| Behaviour | Answer |
|---|---|
EndDateType | Inferred, not defaulted. AWS publishes no Default: line and forbids exactly two combinations — limited with no EndDate, and unlimited with one — so an EndDate alone infers limited and no EndDate infers unlimited. Defaulting an absent EndDateType to unlimited would turn a request naming only an EndDate, which nothing forbids, into the second refusal |
| Expiry | Derived at observation time from the simulated clock, per endDate's own sentence ("the Capacity Reservation's state changes to expired when it reaches its end date and time"), so it is assertable without depending on wall-clock time. Only an active reservation expires — a seeded failed one has already reached a terminal state, and reporting expired for it would lose the outcome the test seeded |
| A zone | AvailabilityZone and AvailabilityZoneId are both Required: No and the page states no rule on the pair, so a request naming neither is accepted and reports neither element. A zone name is recorded as given and never validated; a zone ID must resolve, because it has to be translated. A pair naming two different zones is InvalidParameterCombination |
ClientToken | Accepted and inert. Two identical creates make two reservations, where AWS's token would make the second idempotent |
| Cancelling twice | IncorrectState / 400, which is substrate's reading. AWS publishes no code for cancelling from a non-cancellable state: the operation's Errors section is boilerplate, the only state-shaped code in the reference is InvalidCapacityReservationState.PendingActivation (a Capacity Block that is not active yet), and AWS's own sample tooling pre-checks the state through a describe rather than catching an error. IncorrectState is the code substrate already answers for this shape on a volume and a snapshot |
| Finding one by tag | Through DescribeTags, or CreateTags/DeleteTags on the cr- ID — not through a filter on the describe, which documents none, and not through Resource Groups Tagging, which has no scanner for the type |
| Member order | Alphabetical, from the CapacityReservation type page. None of the four pages publishes a sample response, so there is no rendered order to copy; the seven members a request can leave unset are absent rather than empty, including tagSet |
InvalidCapacityReservationId.NotFound and InvalidCapacityReservationId.Malformed are both in EC2's client-error table, and the casing is AWS's: Id with a lowercase d, where the sibling codes are InvalidInstanceID.NotFound and InvalidAllocationID.NotFound. The NotFoundmessage wording follows an observed AWS response rather than the table, which only describes the condition — match on the code, not the string.
Seeding a Capacity Reservation outcome
The reason a consumer reserves capacity at all is that capacity is finite, so the observations worth testing are the ones where the request does not succeed: not enough capacity in the cell, an On-Demand quota already spent, a Region or tenancy that cannot serve the instance type, a throttled caller. Substrate models no capacity broker and no quota ledger, so none of those can be derived from anything it knows.
A capacity failure has two documented observable shapes, and that is what shapes the seed. Either the call fails outright with a code from EC2's error tables, or it succeeds and the reservation reports a non-nominal state — AWS's own prose for failed is "A request can fail due to request parameters that are not valid, capacity constraints, or instance limit constraints." Which of the two AWS produces for a given cell is not documented, so a seed selects one rather than substrate choosing on the caller's behalf, and a seed naming both is refused.
# One instance type is short everywhere.
curl -X POST http://localhost:4566/v1/ec2/capacity-reservation-outcomes \
-d '{"instanceType":"p5.48xlarge","errorCode":"InsufficientInstanceCapacity"}'
# One zone cannot serve anything, with the caller's own message.
curl -X POST http://localhost:4566/v1/ec2/capacity-reservation-outcomes \
-d '{"availabilityZone":"us-east-1a","errorCode":"Unsupported",
"errorMessage":"that zone does not offer this configuration"}'
# The other shape: the create succeeds and the reservation reports a state.
curl -X POST http://localhost:4566/v1/ec2/capacity-reservation-outcomes \
-d '{"instanceType":"g6.xlarge","availabilityZone":"us-east-1b","state":"failed"}'
# Clear one cell, or every seed.
curl -X DELETE 'http://localhost:4566/v1/ec2/capacity-reservation-outcomes?instanceType=g6.xlarge&availabilityZone=us-east-1b'
curl -X DELETE http://localhost:4566/v1/ec2/capacity-reservation-outcomesA seed is scoped to an (instance type, Availability Zone) pair, because that pair is what a consumer treats as one capacity cell, and either half may be omitted to mean "every". The most specific scope carrying a seed decides — the exact cell, then the type in any zone, then any type in the zone, then the wildcard — so seeding one scarce type inside an otherwise-seeded zone works rather than being overwritten by the coarser seed. Type before zone is substrate's ordering: a seed naming an instance type is a statement about that type's scarcity, which is the narrower claim of the two.
| Seedable | Values |
|---|---|
errorCode | InsufficientInstanceCapacity, RequestLimitExceeded, InstanceLimitExceeded, VcpuLimitExceeded, Unsupported. A code outside the five is refused rather than defaulted, because the HTTP class is the half substrate cannot derive |
state | active, pending, failed, expired, cancelled — five of the thirteen. The other eight belong to a future-dated reservation or a Capacity Block, which substrate models neither of, so seeding one would publish an observation nothing else in the emulator is consistent with |
errorMessage | Optional; falls back to AWS's own words for the code |
Two of the five codes answer a 500-series status, and that is deliberate. EC2's errors-overview page puts InsufficientInstanceCapacity and RequestLimitExceeded in its server error table, whose preamble says such errors "are accompanied by a 500-series HTTP response code", while the other three are in the client table. A consumer reads InsufficientInstanceCapacity as a capacity signal and would expect a 400; answering one would let retry logic pass here and fail in production. The exact number is unverified — neither page assigns one, only a class — so 500 and 400 are substrate's reading of "500-series" and "400-series". The same page contradicts itself once, writing the throttle code as Client.RequestLimitExceeded in prose while listing RequestLimitExceeded in the server table, and CommonErrors does not list it at all; the server table is followed because it is the one place the code appears in a table at all.
A seeded state is a property of the reservation the create writes, so it survives into every later DescribeCapacityReservations — unlike a seeded error, which prevents the create from writing anything at all.
The seed survives a replay, and getting that right matters more here than for a progression seed. A progression seed diverges an observation, which the next call re-derives; this one is resolved once at create time and persisted, so a replay that lost the seed would write a reservation whose stored state was active and answer every later DescribeCapacityReservations from it. The seed is recorded as an event and re-applied before the recorded CreateCapacityReservation is re-executed — see How a seed survives a replay.
CloudFormation resource types
| Type | Ref | Notes |
|---|---|---|
| AWS::EC2::VPC | VpcId | |
| AWS::EC2::Subnet | SubnetId | |
| AWS::EC2::SecurityGroup | GroupId | Inline SecurityGroupIngress/SecurityGroupEgress rules are authorized |
| AWS::EC2::SecurityGroupIngress | GroupId | Standalone rule; resolves SourceSecurityGroupId through Ref/GetAtt, so self- and mutually-referencing groups work |
| AWS::EC2::SecurityGroupEgress | GroupId | Standalone rule; supports DestinationSecurityGroupId |
| AWS::EC2::Instance | InstanceId | Passes through IamInstanceProfile, KeyName, and SecurityGroupIds |
| AWS::EC2::InternetGateway | InternetGatewayId | |
| AWS::EC2::LaunchTemplate | LaunchTemplateId | Ref is the real lt-… ID, usable by CreateFleet |
Cost
EC2 instance costs approximate on-demand pricing for the instance type.
ELB v2
Endpoint: elasticloadbalancing.{region}.amazonaws.comProtocol: AWS Query (form-encoded, Action= parameter)
Supported operations
| Operation | Notes |
|---|---|
| CreateLoadBalancer | ALB and NLB supported; accepts Tags.member.N |
| DescribeLoadBalancers | Names.member.N and LoadBalancerArns.member.N |
| DeleteLoadBalancer | |
| DescribeLoadBalancerAttributes | |
| ModifyLoadBalancerAttributes | |
| CreateTargetGroup | Accepts Tags.member.N |
| DescribeTargetGroups | |
| DeleteTargetGroup | |
| ModifyTargetGroup | |
| RegisterTargets | |
| DeregisterTargets | |
| DescribeTargetHealth | |
| CreateListener | Accepts Tags.member.N |
| DescribeListeners | |
| DeleteListener | |
| ModifyListener | |
| CreateRule | Accepts Tags.member.N |
| DescribeRules | |
| DeleteRule | |
| SetRulePriorities | |
| AddTags | Up to 50 user tags per resource |
| RemoveTags | |
| DescribeTags | At most 20 resources per request |
| DescribeAccountLimits | Reports 23 limits, seedable; Marker/PageSize paginated |
Every operation whose output shape carries no members answers <OperationResponse><OperationResult/><ResponseMetadata>…</ResponseMetadata></OperationResponse>. The empty result element is not decoration: ELBv2 speaks the Query protocol, where each output shape declares a resultWrapper, and botocore looks that wrapper up by name — so a bare <OperationResponse/> makes the AWS CLI and boto3 raise KeyError rather than report success. DeleteLoadBalancer, DeleteTargetGroup, DeleteListener, DeleteRule, RegisterTargets and DeregisterTargets answered that way and were unusable from a real client while substrate's own tests passed, because those tests read the XML directly instead of through an SDK's parser.
The response envelope, and which plugins still lack it
Every ELB response — both generations, result-bearing and memberless alike — closes with the Query protocol's ResponseMetadata:
<CreateLoadBalancerResponse xmlns="http://elasticloadbalancing.amazonaws.com/doc/2012-06-01/">
<CreateLoadBalancerResult>
<DNSName>my-vpc-loadbalancer-1234567890.us-east-1.elb.amazonaws.com</DNSName>
</CreateLoadBalancerResult>
<ResponseMetadata>
<RequestId>1549581b-12b7-11e3-895e-1334aEXAMPLE</RequestId>
</ResponseMetadata>
</CreateLoadBalancerResponse>It is built in one place (elb_response_envelope.go) rather than in the ~30 handlers, and that is the substantive part of the rule. Substrate emitted the element nowhere in this plugin for exactly the reason a per-handler convention fails: each handler declared its own inline response struct, so the member was something every handler could omit, and every handler did. The handlers now pass a result and the envelope decides the root element, the namespace, the result wrapper and the metadata; the two generations differ in the namespace and in nothing else.
The RequestId is the request's own — the value Event.RequestID records and replayRequestID reproduces — never a freshly minted one. That is what keeps a replayed ELB response byte-identical to its recording; an id minted per call would make every ELB body in a recorded stream diverge on replay, which the replay engine's body comparison reports as a difference.
Which other Query-protocol plugins answer the envelope today:
| Plugin | Envelope | Notes |
|---|---|---|
| CloudFormation, CloudWatch, IAM, SNS, SQS, STS | yes | — |
| ELB (both generations) | yes | all 27 routed actions |
| EC2 | no | publishes a root-level lowercase <requestId>, not ResponseMetadata — a different document |
| Redshift | no | and the result wrapper is the document root, so no SDK decodes it |
| RDS, ElastiCache | no | built exactly as ELB's were: one inline response struct per handler |
An XML error response carries no request ID for any plugin that shares error_protocol.go's ErrorResponse document, ELB included. No ELB page publishes a sample error response, so what belongs inside one is not readable off an ELB page; EC2's page does publish one, and it is a different shape again (<Response><Errors><Error>…</Errors><RequestID>). That is one cross-plugin change rather than an ELB change.
The Classic (2012-06-01) API, and the version that routes it
Elastic Load Balancing is two APIs at one endpoint. elasticloadbalancing.{region}.amazonaws.com serves both the Classic Load Balancer API (2012-06-01) and ELBv2 (2015-12-01); they share a signing name, an IAM prefix (elasticloadbalancing:) and three action names — CreateLoadBalancer, DescribeLoadBalancers and DeleteLoadBalancer — while publishing different request members, different response shapes and different errors for each. AWS tells them apart by the Query protocol's own Version parameter. Substrate read Version nowhere, so all three action names were answered by the ELBv2 handler whatever the caller sent (#844):
A Version=2012-06-01 call | answered, before |
|---|---|
CreateLoadBalancer | ValidationError/400 on Name is required — ELBv2's member name for what classic spells LoadBalancerName |
DeleteLoadBalancer | ValidationError/400 on a missing LoadBalancerArn, for an operation that publishes no errors at all and documents idempotent success |
DescribeLoadBalancers | HTTP 200 carrying an ELBv2 body |
The third is the worst of the three, and the reason is the wrapper: both generations name their result element DescribeLoadBalancersResult, so botocore finds the wrapper it is looking for and decodes an empty LoadBalancerDescriptions list. No error is raised anywhere. A consumer is simply told it owns no classic load balancers.
The discriminator is Version, and it applies to those three action names only. An absent Version resolves to ELBv2, as does one substrate does not recognize — every request substrate already answered, every fixture and every recorded event log therefore answers exactly as it did before, and no error is invented for a version AWS publishes no code for. The member names are not used to discriminate, because a classic DescribeLoadBalancers can legitimately carry no members at all and would be indistinguishable from an ELBv2 one.
Three operations are routed:
| Operation | Notes |
|---|---|
| CreateLoadBalancer | LoadBalancerName + Listeners.member.N required; accepts AvailabilityZones, Subnets, SecurityGroups, Scheme, Tags.member.N; answers DNSName alone |
| DescribeLoadBalancers | LoadBalancerNames.member.N, Marker, PageSize (1–400, default 400); answers LoadBalancerDescriptions.member.N |
| DeleteLoadBalancer | LoadBalancerName; an absent load balancer is a success |
Details a consumer can observe:
CreateLoadBalanceranswersDNSNameand nothing else. That is the whole of its published Response Elements, where the ELBv2 operation of the same name answers the load balancer it made. Aninternalscheme prefixes the name withinternal-, following AWS's published sample.- A classic record has its own key space, so one name can be held by both generations at once and each generation's
DescribeLoadBalancersreports only its own. AWS scopes the name per generation — eachCreateLoadBalancerpublishes its duplicate-name refusal against its own generation only. - A classic ARN carries one segment after
loadbalancer/(…:loadbalancer/<name>) where an ELBv2 one carries three (…:loadbalancer/app/<name>/<id>). That arity is how the two are told apart everywhere, and it comes from AWS's own Service Authorization Reference format strings. - Eight of
LoadBalancerDescription's sixteen members are absent, each because the operation that would set it is not routed: no instance is registered, no health check is configured, no policy or backend-server description exists, and no source security group is minted.VPCIdis reported empty rather than guessed, because nothing here resolves a subnet to a VPC.PolicyNamesis emitted, as an empty element, because AWS publishes it as one: "The policies. If there are no policies enabled, the list is empty." - Every refusal is a code the operation's own page publishes:
ValidationError/400 for a member that is missing or malformed (the consolidated Query Common Errors list),UnsupportedProtocol,InvalidSchemeandDuplicateLoadBalancerNameat 400,LoadBalancerNotFound/400 for a name inLoadBalancerNames.member.Nthat names nothing, andInvalidConfigurationRequestat HTTP 409 — the one non-400 amongCreateLoadBalancer's twelve published errors — for two listeners claiming oneLoadBalancerPort. That last mapping is substrate's reading:DuplicateListeneris published onCreateLoadBalancerListeners, an operation this one does not have, so borrowing it would invent a code for the page being implemented. - A create's
Tags.member.Nreaches the record, and its publishedDuplicateTagKeys/400 is answered before the record is written, so a create carrying a tag it cannot legally apply leaves no load balancer behind. The tags are readable through the Resource Groups Tagging API (see the tagging section below); the classicDescribeTagsis not routed. - A store failure is answered as one, and an unusable record is not. A create whose record could not be written still has a DNS name to report and a describe whose listing could not be read still has an empty list to report, so both propagate as a 5xx rather than as a plausible success — the same silent wrong answer this section exists to remove, arriving by another route. One record the listing cannot read among several is the opposite case and is skipped, because failing the call would hide every healthy load balancer behind one bad key; the tagging resolvers read such a record as absent for the same reason.
- An unissued
Markeris refused rather than silently restarting the listing, atValidationError. The operation publishes no token code of its own, so the code comes from the Common Errors page that covers it; the refusal itself is substrate's reading, for the reason #915 records — a paging loop cannot see a cursor that resets. ELBv2DescribeAccountLimitsstill restarts the walk for the same cursor, which is #1245. PageSizeis refused outside its published 1–400, through the rule both generations now share — see Account limits for why refusing replaced the fallback the other operation had (#1150).
What is deliberately not routed, so that three operations are not read as the whole API: the classic tag trio (AddTags, RemoveTags, DescribeTags at 2012-06-01, whose RemoveTags takes Tags.member.N of TagKeyOnly; their published cap of 10 against ELBv2's 50 is enforced already, by the create and by the tagging API, so routing the trio adds doors rather than a rule — see #1148), RegisterInstancesWithLoadBalancer, CreateLoadBalancerListeners, the health-check and policy operations, and the AWS::ElasticLoadBalancing::LoadBalancer deploy helper. Any of them answers InvalidAction/400, which is the Query family's unknown-action answer. TooManyLoadBalancers and the 20-per-Region quota are not modelled either: substrate enforces no ELB quota in either generation, and enforcing one only would be half-fidelity. A classic load balancer is therefore taggable through its own create and through the Resource Groups Tagging API, and not through classic AddTags.
Account limits
DescribeAccountLimits reports 23 Elastic Load Balancing limits. Nothing in substrate enforces the number it reports — seeded or defaulted. No ELB operation counts a load balancer, target group, listener or rule against a quota, and none is planned to. The number exists to be read: it is what a consumer's "am I approaching my quota" branch looks at, and making it seedable is what lets that branch be exercised. A constant nothing enforces would be a number that does not come from where it appears to (#885).
# One limit is nearly exhausted.
curl -X POST http://localhost:4566/v1/elb/account-limits \
-d '{"name":"application-load-balancers","max":"1"}'
# Every limit at once, for "the whole account is at quota".
curl -X POST http://localhost:4566/v1/elb/account-limits -d '{"name":"*","max":"0"}'
# Clear one seed, or all of them.
curl -X DELETE 'http://localhost:4566/v1/elb/account-limits?name=application-load-balancers'
curl -X DELETE http://localhost:4566/v1/elb/account-limitsname matches a limit name or * (the default) for every limit, resolved specific-first. max is a string because the API member is one: Limit's reference gives "Max … Type: String", which is the member a caller's code will try to treat as an integer.
The provenance of the reported set splits three ways, and the split is the point:
- The shape is the API model's —
elasticloadbalancingv2-2015-12-01'sAPI_DescribeAccountLimitsandAPI_Limit:Limits.member.NofMax/Name, plusNextMarker, withPageSizevalid 1–400 andMarkerthe cursor. The Errors section is Common Errors only; the operation publishes no error of its own. - The limit names are not published on the v2 API reference. That page enumerates none of them — it says only "The name of the limit." and links the Application, Network and Gateway Load Balancer quota user guides. The 23 names substrate reports are taken verbatim from the example output on the AWS CLI v2 reference page for the operation, the only AWS page found that renders the tokens at all. That is an illustrative example, not the model, and is recorded as such rather than presented as the published API model. (Classic's 2012-06-01
API_Limitdoes enumerate exactly three —classic-listeners,classic-load-balancers,classic-registered-instances— which is a published difference between the generations. They are absent because this handler answers v2 shapes; classic dispatch is #844.) - The values are the current defaults from those three quota user guides, since a guide is AWS's normative statement of a default and the CLI example is an illustration AWS does not keep in step with it. Where the two disagree the guide wins:
condition-wildcards-per-alb-ruleis 6 per the guide against the example's 5. Two entries no guide row names cleanly keep the example's value and say so in the source:target-id-registrations-per-application-load-balancer, which no guide names at all, andtarget-groups-per-action-on-network-load-balancer, whose nearest NLB guide row is "Target groups per listener rule action" at 5 — whether that row is this token could not be established, so the example's 1 stands.
Two behaviors are substrate's decisions rather than AWS's published text:
NextMarker | Absent when the walk is exhausted, not empty. v2 documents "Otherwise, this is null"; classic documents "If there are no additional results, the string is empty" — a present-but-empty element. Following v2 is following the shape this handler answers. The classic DescribeLoadBalancers #844 routed follows v2 here too, which is a divergence from its own page and is recorded as one |
PageSize | An absent member is the published default of 400, which is also the published maximum, so an unparameterized call returns the whole set in one page. Anything else must be an integer within 1–400 inclusive, and a value outside it or a non-numeric one is refused with ValidationError/400 naming the range — the same answer the classic DescribeLoadBalancers gives, through the same function (#1150) |
The two operations disagreed until #1150, and the disagreement was recorded here as deliberate. It was: this operation substituted its default for an unusable PageSize and answered 200, and the argument for it turned on DescribeAccountLimits publishing no operation-specific error — its Errors section is Common Errors only — so that refusing looked like inventing a code. #1064 removed that step: the consolidated Query Common Errors page each operation's Errors section links as its own publishes ValidationError at 400, and a code on the page an operation links is that operation's own vocabulary rather than a borrowing from a sibling. The second half of the old argument — that because the default equals the maximum, a PageSize above 400 is indistinguishable from a clamp — was true and was no defense of the low end: a PageSize of 0 or -1 was answered with 400 items, the largest page a caller can get in response to asking for the smallest. And a harness that asks for a page size it believes is legal, reads a 200, and concludes it is legal then fails against AWS, which is the failure an emulator exists to prevent.
The family this operation joins now agrees with it. RDS's and ElastiCache's MaxRecords and CloudWatch's took any positive integer and substituted a default for anything else until #913 made them refuse; EC2's DescribeTags already refused a MaxResults outside 5–1000. The code differs because the page does — InvalidParameterValue is what RDS and ElastiCache publish, ValidationError is what ELB's Common Errors page publishes — but the rule no longer does.
The sweep behind that decision, and its count. The plugin has six paginated operations: ELBv2 DescribeLoadBalancers, DescribeTargetGroups, DescribeListeners, DescribeRules and DescribeAccountLimits, plus classic DescribeLoadBalancers, each publishing PageSize 1–400 and Marker. Two read the member and now answer through one rule. The other four read neither Marker nor PageSize at all, which was a third answer rather than a second, and is #1244 — closing it means implementing the cursor, not validating a member, and when they gain it they take the same rule. ELBv2 DescribeTags publishes no pagination member and is correctly unpaginated.
Marker is a decimal offset into a fixed, name-ordered set, so a paged walk returns each limit exactly once. An unparseable Marker restarts the walk and an offset past the end answers an empty last page — neither being an error the operation publishes a code for.
The Marker half of this cursor is still answered two ways, and that is now a recorded defect rather than a recorded decision: the classic DescribeLoadBalancers refuses a marker it never issued, for the reason #915 gives — a paging loop cannot see a cursor that resets — while the restart above stands on the same "no published code" step #1064 answered for PageSize. It is #1245, kept out of #1150 so that one change's diff is one member of the cursor. An offset past the end stays an empty last page either way: that is a walk that has ended, not a malformed cursor, and both operations already agree on it.
Authorization needed nothing: elasticloadbalancing:DescribeAccountLimits was already in substrate's generated authorization reference with an empty resource list, which is exactly why this was worth fixing — a caller could be granted a permission substrate then answered InvalidAction for. Every ELB request is decided centrally before dispatch, so the arm in the action switch was the whole of the wiring the operation needed.
Tagging
The four creates above store the Tags.member.N they are given, and AddTags, RemoveTags and DescribeTags operate on all four resource types. Before this the parameter was accepted and silently dropped, and the three tagging operations did not exist — a Tags on a CreateLoadBalancer produced an untagged load balancer with no error.
Limits and shapes come from the Tag type's API reference: a key is 1–128 characters, a value 0–256, both matching ^([\p{L}\p{Z}\p{N}_.:/=+\-@]*)$, and an ELBv2 resource holds at most 50 tags. A Classic Load Balancer holds at most 10, and that number is the one place the classic API reference is the more specific of the two: the 2012-06-01 API_AddTags opens with "Each load balancer can have a maximum of 10 tags", where the 2015-12-01 page publishes no maximum anywhere and substrate's 50 is read off the user guide's restrictions list. The cap is resolved from the record's own kind, so the classic 10 applies to a classic load balancer's own create and to the Resource Groups Tagging API alike (#1148). AWS contradicts itself on the lengths and substrate follows the model: the ELB user guide's restrictions list says "Maximum key length—127 Unicode characters" and "Maximum value length—255", where the API model says 128 and 256. A caller who trusts the user guide's smaller numbers is inside substrate's limits either way. A violated constraint answers ValidationError/400: the constraints are the model's, but the model publishes no error code for breaking one, so the code is the common one a real service answers for a member outside its constraints and the message text is substrate's own.
Per-operation error sets are followed rather than unified, because AWS's are not uniform:
DuplicateTagKeysis refused onAddTagsandCreateLoadBalanceronly — the two operations whose Errors sections list it. OnCreateTargetGroup,CreateListenerandCreateRulea repeated key resolves last-wins, because inventing a code those three do not publish would send a consumer's error branch down a path AWS never triggers.TooManyTagsis published byAddTagsand by all four creates, so every path that can apply a tag can raise it.- An ARN naming nothing answers the code for its type —
LoadBalancerNotFound,TargetGroupNotFound,ListenerNotFoundorRuleNotFound— each at HTTP 400, which is the ELB API's own choice and not the 404 a reader expects.TrustStoreNotFound, the fifth code those operations list, cannot occur: substrate models no trust store. - An ARN naming no ELB resource type at all answers
ValidationError. So does a classic load-balancer ARN, whose one segment afterloadbalancer/is an arity no ELBv2 type has, and which these three operations refuse even now that substrate holds classic records: ELBv2'sAddTagsenumerates the resources it tags — load balancers, target groups, listeners and rules — and Classic is absent from that list. The tagging API reads the same ARN differently; see An ELBv2 resource is reachable through the tagging API.
Two readings are substrate's rather than AWS's published text, both recorded because a consumer can observe them:
- A tagging call naming several resources applies to all of them or to none. Every ARN is resolved before any write, so an
AddTagsnaming one live load balancer and one absent one leaves the live one untouched. AWS documents no ordering for ELB's multi-resource tagging calls; the alternative is a partial write a caller cannot undo from the error alone. This mirrorsTerminateInstances, where AWS does document the whole-request refusal. - A tag key beginning
aws:is accepted. The restrictions list says "You can't edit or delete tag names or values with this prefix", but no reachable page publishes a code for that refusal, so substrate does not invent one. The prefix is excluded from the 50-tag count, which the same list states ("Tags with this prefix do not count against your tags per resource limit") and which is byte-for-byte the rule EC2 already implements. This is also what lets CloudFormation'saws:cloudformation:*stamp coexist with a caller's own 50 tags.
RemoveTags ignores a key that is not present — the operation publishes no error for one — and refuses more than 128 keys in one request, its own documented array maximum. That cap is per request and unrelated to the per-resource limit: a request may legally name more keys than any one resource holds.
Authorization
Every ELB request naming a resource by ARN is now authorized against that ARN. The members read are ResourceArns.member.N (the three tagging operations, up to 20 of them) and LoadBalancerArn, TargetGroupArn, ListenerArn and RuleArn. Previously every ELB request was decided against the literal string *, which is not a wildcard on the request side: a statement scoped to one load balancer's ARN matched nothing, so an ARN-scoped Allow denied every call and an ARN-scoped Deny was inert. A request naming several resources is denied unless every one of them is allowed.
An ARN that resolves to nothing in state is still the resource the decision is about, with no tags. Substituting * for it would let a bogus ARN reach a statement scoped to * that the real ARN would not have matched; the handler refuses it afterwards on its own terms.
A resource's tags are reported under both aws:ResourceTag/<key> and elasticloadbalancing:ResourceTag/<key>. The service-specific prefix is the ELB user guide's: "The elasticloadbalancing:ResourceTag/ condition key is specific to Elastic Load Balancing. All mutating actions support this condition key." Substrate reports it on reads too, since a read reporting fewer keys than a write could only make a condition on a describe unsatisfiable.
aws:RequestTag/<key> and aws:TagKeys are produced from Tags.member.N on AddTags and on the four creates, and from TagKeys.member.N on RemoveTags. A removal supplies no value, so the key is recorded with an empty one — indistinguishable from absent to every condition operator, while still reaching aws:TagKeys, which is what a "may only remove approved tags" policy is written against.
A tagged create is authorized twice. AWS: "If tags are specified in the resource-creating action, additional authorization is required on the elasticloadbalancing:AddTags action to verify if users have permissions to apply tags to the resources being created." So each of the four creates, when it carries Tags.member.N, is decided a second time against elasticloadbalancing:AddTags with elasticloadbalancing:CreateAction set to the bare operation name — and an untagged create needs no tagging grant at all, which is the converse AWS states explicitly. The second decision's resource is the wildcard for the created type (…:loadbalancer/*, …:targetgroup/*, …:listener/*, …:listener-rule/*); that is substrate's reading, on the same reasoning as EC2's — the resource does not exist yet, and AWS's own example policies write the AddTags statement's Resource as * or as a type wildcard. The pass runs after the create's own decision, honours a permission boundary, and does not report aws:ResourceTag/*: a condition about tags already on a resource that does not exist yet is unsatisfiable, and fabricating one would let the tags being applied stand in for tags already present.
Provenance for elasticloadbalancing:CreateAction: it is documented on the ELB user guide's "Tag your Elastic Load Balancing resources during creation" page, which is also where the two quotations above come from, and it is absent from the same guide's own list of ELB-specific condition keys (which names ListenerProtocol, SecurityPolicy, Scheme, SecurityGroup, Subnet and ResourceTag). Both Service Authorization Reference pages for ELB render their key tables in JavaScript and were unreachable. The value is the bare operation name — AWS's examples write "elasticloadbalancing:CreateAction": "CreateTargetGroup" — not a service-prefixed action. The bundled AmazonECS_FullAccess policy's ELBTaggingPolicy statement names four values: CreateTargetGroup, CreateRule, CreateListener and CreateLoadBalancer.
One deliberate gap: Names.member.N on DescribeLoadBalancers and DescribeTargetGroups is not resolved to an ARN, so those two requests are still decided against *. Whether ELB's describes support resource-level permissions could not be verified — the pages that would say are the unreachable ones above — and leaving them at * is the direction that cannot invent a grant.
The ARNs substrate mints
A listener and a listener rule are siblings of their load balancer's ARN, not children of it. AWS's four ELBv2 formats, taken from its Service Reference Information document for elasticloadbalancing (Version v1.4, vendored at emulator/authzref/elasticloadbalancing.json):
| Resource type | Format |
|---|---|
loadbalancer/app/ | arn:${Partition}:elasticloadbalancing:${Region}:${Account}:loadbalancer/app/${LoadBalancerName}/${LoadBalancerId} |
targetgroup | arn:…:targetgroup/${TargetGroupName}/${TargetGroupId} |
listener/app | arn:…:listener/app/${LoadBalancerName}/${LoadBalancerId}/${ListenerId} |
listener-rule/app | arn:…:listener-rule/app/${LoadBalancerName}/${LoadBalancerId}/${ListenerId}/${ListenerRuleId} |
So a child repeats its load balancer's name and id inside its own resource type rather than being appended to the parent's ARN, and the resource type carries the load balancer's subtype (app, net, gwy — the abbreviations, not LoadBalancerTypeEnum's application | network | gateway). Substrate asserts a minted ARN against those published format strings rather than against a template copied into a test, so a drift in either fails.
Both were wrong until #774: substrate nested the child (…:loadbalancer/app/<name>/<id>/listener/<suffix>) and spelled the subtype application. Neither is cosmetic. Every AWS policy example scopes a listener statement with …:listener/*, and that wildcard matched nothing at all against the nested shape — so a Deny written the documented way silently failed to deny, while an Allow on …:loadbalancer/* was a prefix of every listener and rule ARN and reached them. The subtype had to be fixed in the same change: a listener ARN repeats it, so leaving application in place would have minted listener/application/… and left listener/app/* matching nothing.
A CreateListener or CreateRule naming a parent ARN it cannot build a child from — a malformed ARN, or the classic-ELB …:loadbalancer/<name> form, which carries too few segments — answers ValidationError/400 rather than minting a child of the wrong arity that no policy could match.
The old nested spelling is still resolved by the tagging code, deliberately: an event log or an exported fixture recorded before #774 carries those ARNs, and a replay whose tagging calls suddenly named no resource would defeat the property the event store exists to provide. Nothing mints that shape any more.
An ELBv2 resource is reachable through the tagging API
All four types are reachable through the Resource Groups Tagging API as of #863: TagResources and UntagResources address one by ARN, and GetResources reports it. A tag written either way is readable through the other — a TagResources tag comes back from ELBv2's own DescribeTags, and an AddTags tag is reported by GetResources — which is the rule every tagging arm is held to, and which ELBv2 could not meet before: the tagging API had no arm for the service at all, so the tag store this section describes was reachable only through ELB's own three operations.
ResourceTypeFilters needs no ELB-specific handling. A filter's type is matched against the ARN's own type segment, and the four segments the formats above publish — loadbalancer, targetgroup, listener, listener-rule — each fall out of that delimiting. The pre-#774 nested listener ARN is the case worth knowing about: its type segment is loadbalancer, because that is what its ARN says, so a filter and a resource kind are not the same question for a recorded ARN of that shape.
ELB's arm finds its state key rather than building one, and it is the only one that does. A load balancer's and a target group's record is keyed by name, but a listener's and a rule's is keyed by a suffix substrate mints at create time, which appears nowhere in the ARN. So the arm delegates to the same resolver ELBv2's AddTags uses, rather than rebuilding a key beside it — the two cannot then disagree about which record an ARN names, which is the defect that reached SQS, DynamoDB and Lambda when a key was rebuilt (#826, #943). One consequence is observable: an ARN naming no such resource is discovered by the resolver rather than by the write, and it answers the same InvalidParameterException/400 that every other type's absent resource does, because a caller must not be able to tell which stage found it.
A classic load-balancer ARN is accepted here and refused at ELBv2's own tag doors, and which door it arrives at is the whole of the difference. ELB's tagging code once classified a resource type by substring, so …:loadbalancer/my-lb — AWS's classic format, one segment after the type where ELBv2's carries three — was read as a load balancer and looked for in the store where ELBv2's live. Nothing was ever found, but the code a caller got, LoadBalancerNotFound, asserted that an ELBv2 load balancer of that ARN could have existed. Classification is on the segment count the vendored format strings publish, and that arity check stands. What changed with #844 is that substrate now holds classic records, and the two callers diverge because AWS's two pages do:
- ELBv2's
AddTags,RemoveTagsandDescribeTagsstill refuse it, atValidationError/400 — matching what they already answer for an ARN of no ELB type. That page enumerates the resources it tags and Classic is absent from the list, and it publishes no code for a wrong-generation ARN. - The tagging API accepts it. RGT matches on the type segment embedded in an ARN, which both generations spell
loadbalancer, and the Service Authorization Reference lists classicloadbalancerunder a singleAddTagsaction. SoTagResourceswrites to the classic record andGetResources— including underResourceTypeFilters=elasticloadbalancing:loadbalancer— reports it, which is #765's cross-readability rule applied to a resource that finally exists. - CloudFormation's two tag writers resolve the same way, one step removed: a template names a resource type (
AWS::ElasticLoadBalancing::LoadBalanceragainst…::ElasticLoadBalancingV2::LoadBalancer), not an API generation. No template reaches the classic half yet, because the classic type has no deploy helper and falls through to the generic stub — the rule is recorded where the decision belongs, so that adding the helper is one map entry and not a second tagging decision.
A classic ARN naming nothing is still refused by the tagging API, at the same InvalidParameterException/400 every other absent resource answers: accepting the generation is not accepting a resource that is not there.
GetResources reports an ELB resource that has been tagged, including one whose tags have since all been removed, which is the general rule recorded there. Each of the four records therefore carries the same persisted ever_tagged flag every other scanned type does, written by whichever writer empties the set — ELB's own RemoveTags or the tagging API's UntagResources.
CloudFormation resource types
| Type | Ref | Notes |
|---|---|---|
| AWS::ElasticLoadBalancingV2::LoadBalancer | LoadBalancerArn | |
| AWS::ElasticLoadBalancingV2::TargetGroup | TargetGroupArn | |
| AWS::ElasticLoadBalancingV2::Listener | ListenerArn | |
| AWS::ElasticLoadBalancingV2::ListenerRule | RuleArn |
The deployer does not send Tags for any of the four, so a template's resource-level tags are still dropped. The stack-level tags do arrive: all four carry the three aws:cloudformation:* keys (#765) and any tag on the stack itself (#764), written straight to the ELB record and readable through DescribeTags.
Cost
ELB charges $0.008 per LCU-hour (approximated as flat per-request rate).
Route 53
Endpoint: route53.amazonaws.com (global) Protocol: REST/XML
Supported operations
| Operation | Notes |
|---|---|
| CreateHostedZone | Returns HTTP 201; zone IDs prefixed /hostedzone/Z |
| GetHostedZone | |
| DeleteHostedZone | |
| ListHostedZones | |
| ChangeResourceRecordSets | CREATE/DELETE/UPSERT actions |
| ListResourceRecordSets |
CloudFormation resource types
| Type | Ref | Notes |
|---|---|---|
| AWS::Route53::HostedZone | HostedZoneId | |
| AWS::Route53::RecordSet | — |
Cost
Route 53 hosted zone: $0.50/month per zone (tracked as flat cost on CreateHostedZone).
Resource Groups Tagging
Endpoint: tagging.{region}.amazonaws.comProtocol: JSON (X-Amz-Target: ResourceGroupsTaggingAPI_20170126.{Op})
Supported operations
| Operation | Notes |
|---|---|
| GetResources | All eight published request members honored; base64 pagination token, refused unless substrate issued it; reports tagged and previously tagged resources only |
| TagResources | Applies tags to existing resources by ARN |
| UntagResources | Removes tag keys from resources by ARN |
GetResources scans thirty-three resource types: S3 buckets, Lambda functions, SQS queues, DynamoDB tables, EC2 instances, IAM users and roles, API Gateway REST APIs, Step Functions state machines and activities, ECR repositories, ECS clusters, services, tasks and task definitions, Cognito user pools, Kinesis streams, RDS DB instances, DB clusters and DB subnet groups, ElastiCache cache clusters, EFS file systems, Glue databases, ACM certificates, CloudFront distributions, KMS keys, SNS topics, Secrets Manager secrets, Systems Manager parameters, and ELBv2 load balancers, target groups, listeners and listener rules.
ELBv2's four are the newest and the odd ones out, and the section on the ELB side (An ELBv2 resource is reachable through the tagging API) says why: every other arm turns an ARN into a state key by building one, while ELB's has to find the record the ARN names, because a listener's and a rule's key carries a minted suffix that no ARN component yields.
TagResources and UntagResources reach a slightly different set, because they address one named ARN rather than enumerating a namespace: they additionally reach EFS access points and Glue's other three types. Those four are the whole remaining difference between the two halves — the ECS namespace, which was the last and largest of them, gained its scanners in #935, so a tag written to an ECS service, task or task definition is now both readable through ECS's own ListTagsForResource and reported by GetResources.
An ECS task definition is reported per revision, including a deregistered (INACTIVE) one. Each revision has its own ARN and its own tags — RegisterTaskDefinition accepts tags per revision, and the tag ARN parser splits {family}:{revision} to reach exactly one of them — so reporting a family's newest revision only would hide a tag the tagging API itself had written to an older one. AWS's tagging page says nothing about deregistration, and a deregistered revision still answers DescribeTaskDefinition, so it remains an observable resource.
All four ECS scanners are scoped to the caller's account and Region, through the same shared prefix helper every other scanner now uses, so none of them can disagree about scope. Before #935 the cluster scanner prefixed by account alone, so a us-east-1 caller was reported a us-west-2 cluster while the same caller's services — keyed identically — were Region-scoped. The section below is the general rule that grew out of that one.
GetResources reports what has been tagged, not what is tagged
GetResources publishes two rules that end at the same place — a record whose tag set is empty — and until #938 substrate could not tell them apart. It "does not return untagged resources"; and, on TagFilters, "[i]f you don't specify a TagFilter, the response includes all resources that are currently tagged or ever had a tag. Resources that were previously tagged, but do not currently have tags, are shown with an empty tag set, like this: "Tags": []."
So a resource is in one of three states, and only two of them are reported:
| State | Reported | Tag set |
|---|---|---|
| Never tagged | No | — |
| Tagged | Yes | its tags |
| Previously tagged, now empty | Yes, and only when no TagFilter is given | [] |
Before #938 substrate reported all three, so a scan answered with every resource the caller had ever created — an inventory call that cannot distinguish "tagged with nothing" from "not tagged" is not an inventory of tags. The rendering matters as much as the membership: [] rather than null is what AWS publishes, and a caller decoding a tag list cannot tell the two apart, so substrate normalises an absent set to an empty list at one place in the scan rather than in each of the scanners.
The third state needs state the tags do not carry, so each scanned record gains a persisted ever_tagged boolean and the scan reads len(Tags) > 0 || ever_tagged once, in the loop, rather than once per scanner. A side-car keyed by ARN was the alternative and was rejected: a scanner already loads the record, so a second load per resource buys nothing, and a side-car and a record can disagree about a resource that was deleted and recreated under one name. Putting it on the record is safe against leaking onto the wire because #1013 established that every scanned type either is a state-only record or projects through a separate wire struct.
The flag is written by whichever writer removes a tag, not by whichever writes one: a resource holding a tag is reported for holding it, and the first writer to empty the set is the one looking at the set it is about to empty. A create-with-tags path therefore stamps nothing and does not need to. Nothing ever clears the flag — the rule is about history, and a resource that was tagged once stays previously tagged.
Both writer directions are covered: the tagging API's own TagResources/UntagResources, and each owning service's native tag operation. The one shape that has to read the set it writes is a writer that replaces a whole tag set rather than merging into it, because it never sees a per-key removal — S3's PutBucketTagging and DeleteBucketTagging and Systems Manager's PutParameter overwrite are the three in the tree, and each now carries the stored flag forward. A writer added later that rebuilds a record from the request without reading the stored one would make a previously tagged resource never-tagged again; that is the gap to check for, and it is a property of the writer rather than of the scan.
Every scanned resource is scoped to the caller's account and Region
GetResources "[r]eturns all the tagged or previously tagged resources that are located in the specified AWS Region for the account", so a scan is scoped by where a resource is — and an ARN is where AWS states that. Substrate therefore decides scope at one choke point in the scan loop, off each reported ARN's own account and Region segments, rather than once per scanner. Before #937 three scanners narrowed by nothing at all, so an S3 bucket, a Lambda function or an SQS queue in any account was reported to any caller, and fourteen more narrowed by account without the Region, so a us-east-1 caller was reported a us-west-2 EC2 instance.
Reading the scope from the reported ARN rather than from each scanner's state-key prefix is what makes it one rule instead of one per scanner. A scanner whose key carries no Region cannot express the scope in a prefix at all — that was true of DynamoDB's key, and of Lambda's and S3's, when #937 landed — and a scanner that can express it has to remember to, which the ECS cluster scanner did not. The state-key prefixes still narrow wherever the key can carry the scope, through one shared helper, but that is a narrowing of what gets loaded rather than the guarantee. This is the read-side form of the rule #826 through #932 established for the write side, where every resolver takes the account and Region from the ARN and never from the caller. Two of those keys were themselves wrong, and #943 corrected both: a Lambda function name is unique per account per Region and a DynamoDB table name per Region, so neither key could hold two resources AWS would let a caller create. Both scanners now narrow by the whole scope, and S3's is the one that still cannot, because a bucket name really is global — see A Lambda function and a DynamoDB table belong to one account in one Region.
An empty account or Region segment means the ARN states no such scope, and such a resource is in scope everywhere. IAM depends on that: an IAM ARN carries no Region, so an IAM user is reported to a caller in any Region, which is what a global service's resource should do. It is also why this rule does not replace CloudFront's own us-east-1 gate — a CloudFront ARN is Region-less too, but AWS publishes CloudFront tagging through that one Region, which is a narrower rule than an empty segment can express.
An ARN that does not parse — no arn: prefix, or fewer than six colon-separated segments — is left in scope rather than dropped. A scan that silently discarded a resource because substrate had built a malformed ARN for it would hide the ARN defect behind a missing row, which is the opposite of what #827 settled: report what is there.
An S3 bucket is the one resource scoped off its record rather than its ARN, and that part is substrate's reading. A bucket ARN is arn:aws:s3:::{name}: S3's bucket namespace is global, so the ARN carries neither an account nor a Region segment to read, and the state key cannot carry either for the same reason. The record does instead — a bucket stores the Region it was created in, and #937 added the account that created it. A record whose Region or account is empty stays in scope, on the same rule an absent ARN segment gets, so a bucket written by a path that has no request context is reported with what is known about it rather than vanishing from the listing.
Three ECS ARN shapes carry no tag, and the reason differs by shape. Each is refused rather than accepted silently — ECS's own TagResource, UntagResource and ListTagsForResource all answer InvalidParameterException/400, and the tagging API's TagResources reports a FailedResourcesMap entry:
| ARN | Reason a tag cannot be written |
|---|---|
…:capacity-provider/{name} | AWS lists a capacity provider among the taggable ECS resources, but substrate stores no such record — there is nothing for a tag to sit beside and nothing to read it back from |
…:service/{name} | AWS's short service ARN. The long form service/{cluster}/{name} is what addresses a service; the short form names no cluster, so it identifies no record |
…:task-definition/{family}:{revision} with a non-numeric revision | a revision is an integer, so web:latest addresses nothing. A family without a resolvable revision is not an addressable resource |
A container instance is the fourth type AWS lists as taggable ("capacity providers, tasks, services, task definitions, clusters, and container instances") and has no ARN shape here at all, because substrate implements no operation that registers one. That is the capacity provider's reason rather than a separate one.
Absence is a different answer from either: a well-formed ARN of a type substrate can key, naming a cluster or a revision that does not exist, answers ResourceNotFoundException through ECS's own operations. The table above is about the shape, not about a missing record.
A ResourceTypeFilters type is the ARN's own segment
A filter entry is service[:resourceType], and AWS pins what the type half means from two directions: "[s]pecifying a resource type of ec2:instance returns only EC2 instances", and "[t]he string for each service name and resource type is the same as that embedded in a resource's Amazon Resource Name (ARN)". Together those say the type is delimited by the ARN itself.
Substrate compared it as an unanchored prefix of the ARN's whole resource portion until #936, which broke the first sentence in both directions at once. Too wide: ecs:task selected a task definition, whose resource portion is task-definition/{family}:{revision} and so begins with the string task — a caller could not express "tasks only" at all. Too narrow: apigateway:restapis selected nothing, because API Gateway's resource portion begins with a slash (arn:{partition}:apigateway:{region}::/restapis/{api-id}), which a prefix comparison against restapis cannot see past.
The type is now the segment the ARN delimits, with one leading slash stripped first. That covers the four shapes in the scanned set, and an ARN embedding no type at all yields its whole resource portion:
| ARN resource portion | Type |
|---|---|
instance/i-abc123 | instance |
stateMachine:hello | stateMachine |
task-definition/sidecar:3 | task-definition |
/restapis/abc123 | restapis |
my-bucket | my-bucket (S3 embeds no type) |
The last row is the one narrowing a consumer may notice: an S3 bucket ARN is arn:aws:s3:::{name}, so there is no type string for s3:bucket to be "the same as", and a bucket named bucket-logs used to match that filter by accident of its first six characters. The service-only filter s3 is what reaches a bucket, as it always was.
This is the anchored-segment rule of #910 and #918 applied one layer up. Those fixed strings.Contains(arn, ":stateMachine:") and strings.LastIndex(arn, "distribution/") in the ARN resolvers; the filter matcher is the one comparison of that kind whose left-hand side comes from the caller, and it was in neither pass.
GetResources honours all eight of its request parameters
API_GetResources publishes eight request members, all Required: No. Substrate decoded four of them, and until #1004 and #1010 two of those four were not honored as published either:
| Member | Before | Now |
|---|---|---|
TagFilters | honored | honored |
ResourceTypeFilters | honored | honored |
ResourcesPerPage | clamped — any value ≤ 0 became 100, and 5000 was served | refused outside 1–100 |
PaginationToken | both decode errors discarded — an unissued token meant page one | refused unless substrate issued it |
ResourceARNList | dropped — and its three exclusions unenforced | selects the named ARNs |
TagsPerPage | dropped | cuts the page by tag count |
IncludeComplianceDetails | dropped | renders ComplianceDetails |
ExcludeCompliantResources | dropped | refused without its companion |
Every refusal is InvalidParameterException at HTTP 400, the operation's only published code for a bad request; its own gloss names the two conditions — "a provided string parameter is malformed" and "a provided parameter value is out of range". The message is therefore what distinguishes one refusal from another, and each one names the parameter and the bound it violated.
ResourcesPerPage is refused, not clamped. AWS states "[y]ou can specify a minimum of 1 and a maximum value of 100" in the member's own prose rather than as a Length Constraint, which an Integer member does not carry. Substrate's single if in.ResourcesPerPage <= 0 { in.ResourcesPerPage = 100 } arm was doing two jobs — supplying the default and swallowing an out-of-range value — and only the first was correct. The worse direction was upward: 5000 was honored, so one call could return every resource in the account on a page size AWS refuses, and a consumer's paging loop was never exercised. The member is now a pointer, because Required: No with a published minimum above zero means an omitted integer and an explicit 0 are different requests: absent is the default of 100, and 0 is out of range.
ResourceARNList was the costliest omission, and not because it was unimplemented. The page publishes it as mutually exclusive with ResourceTypeFilters, with TagFilters, and with all three pagination members — three sentences, each promising an "Invalid Parameter exception". So substrate accepted three request shapes AWS refuses, and in every one of them answered the account-wide scan: a caller asking for the tags on five ARNs was handed a superset at HTTP 200 with nothing in the response to say the request served was not the request sent. All three are now refused, and the third is why #1004 and #1010 are one change: distinguishing an absent ResourcesPerPage from a sent one is exactly the pointer above.
The ARN comparison is exact. ResourceARNList takes ARNs and an ARN identifies one resource, so a prefix or case-folded match would return resources the caller did not name. An ARN naming nothing is not an error — "if a resource specified by this parameter doesn't exist, it doesn't generate an error; it simply isn't included in the response" — so a list of entirely absent ARNs is an empty list at 200. Its published bounds are enforced: 1–100 items, each 1–1011 characters. An empty ResourceARNList: [] is refused rather than read as absent, because a caller that sent the member asked to filter by ARN and filtering by none of them is not the account-wide scan.
TagsPerPage is implemented rather than refused, because the page publishes exact and deterministic behaviour for it: the 100–500 range, "a resource with no tags is counted as having one tag (one key and value pair)", "does not split a resource and its associated tags across pages", and a worked example — TagsPerPage 100 against 22 resources of 10 tags each yielding pages of 10, 10 and 2 — that pins the comparison as inclusive. Both page members apply when both are sent, the page breaking at whichever limit is reached first. Note the minimum is 100 and not 1: the member counts tags rather than resources.
One departure, unobservable and recorded anyway: at least one resource is always taken. Read literally, "a PaginationToken is returned in place of the affected resource and its tags" would have a first resource whose own tags exceed the whole budget yield an empty page and a token pointing at the same resource, so a caller looping to a null token would never terminate. The case cannot arise — the minimum TagsPerPage is 100 and no service substrate models admits more than 50 tags on a resource — but progress is guaranteed rather than left to depend on that, because a hang is a worse failure than a page one tag over budget.
ComplianceDetails renders three of its four published members, and omits ComplianceStatus. It was *struct{}, so the only two documents it could produce were "absent" and {}, neither of which is the published shape. The three key arrays — KeysWithNoncompliantValues, MissingTagKeys, NoncompliantKeys — are each defined against the effective tag policy by their own descriptions, and substrate models no organization with tag policies enabled (they are "available only in an organization that has all features enabled"), so no key can be a member of any of them. [] is the derived answer, not a placeholder for one, and it is [] rather than null for #938's reason.
ComplianceStatus cannot be derived that way, and reporting true would be the tempting answer — nothing can be noncompliant with a policy that does not exist. The Organizations tag-policies guide settles it the other way: "[u]ntagged resources or tags that aren't defined in the tag policy aren't evaluated for compliance with the tag policy." Not evaluated is not compliant, so a true here would claim an evaluation that never happened. The member is Required: No, so a document without it is still the published shape.
It follows that ExcludeCompliantResources: true excludes nothing, since no resource is evaluated as compliant — stated here rather than left for a caller to discover from an unexpectedly full response. The page's own constraint on it is enforced regardless and needs no tag-policy model: it "can be used only if the IncludeComplianceDetails parameter is also set to true". An explicit false is accepted, which is substrate's reading — it asks for nothing, and AWS's own Sample Request sends the member with a non-meaningful value.
PaginationToken is emitted on every response, omitempty removed. The evidence is the operation's own Sample Response, which carries "PaginationToken": "" on a complete result, plus the prose "repeat the query … until you receive a null value" — a consumer cannot receive a value from a member that is absent. One sample is a reading rather than a citation, and is recorded as one.
Two things this does not do, so the new checks are not read as complete coverage. ResourceTypeFilters' per-item 0–256 length and its 100-item array bound, and TagFilters' 50-key and 20-values-per-key bounds, are not enforced; adding a bound without walking its citations is how an emulator starts refusing what AWS accepts. And PaginationTokenExpiredException stays unreachable: the expiry it reports is wall-clock (fifteen minutes), which no substrate behaviour may depend on, and it is not the code for an unissued token in any case — a token substrate never issued is malformed, not expired, and the two imply different consumer actions ("your code composed this wrong" against "start again from page one").
Per-member checks run before the cross-member exclusions. No ordering avoids every two-round-trip case, so the criterion is the one #991's plaintext guard and #983's policy document already use: name what is wrong with a single member before what is wrong with the request as a whole, because the first is fixable from that member's own documentation while the second makes the caller decide which of two features it wanted.
One documentation curiosity, recorded because a reader searching AWS's page for the exact string would otherwise conclude the sentence is missing: the two exclusion sentences written on ResourceTypeFilters and TagFilters both spell the member ResourceArnList, where it is ResourceARNList everywhere else on the same page.
An ARN resolves to the state key its own service uses
TagResources and UntagResources take an ARN and have to reach the record the owning service reads. That is not automatic: the ARN and the state key are derived separately, and where they disagree a tag is written to a record nothing reads — the call answers 200, the service reports no tag, and an aws:ResourceTag condition on the resource never matches.
That is what happened for SQS until #826. A queue was stored under queue:{account}/{name}, because the SQS plugin keyed on the last two components of a queue URL, but the ARN resolver dropped the account and addressed queue:{name}. The authorizer derived the same key a third way, from the last component of the request's QueueUrl, and so had the same blind spot: every aws:ResourceTag/* condition on an SQS request was unsatisfiable, which turns an explicit Deny into a silent allow. All three now derive the key from the SQS plugin's own builder rather than re-deriving it, which is how the Region #1088 added (below) reached every reader at once: the key is queue:{account}/{region}/{name}.
Every other service's arm was audited against its plugin's key at the same time and they agreed for the resource type each arm claims to name: S3, Lambda, DynamoDB, EC2, IAM (users and roles), API Gateway, Step Functions, ECR, ECS, Cognito, Kinesis, RDS, ElastiCache, EFS (file systems and access points) and Glue's four types. SQS was the only key that disagreed.
That audit was narrower than it read, and #845 found what it missed. Eight arms stripped a resource-type prefix without first checking it was there, and strings.TrimPrefix returns its input unchanged when the prefix does not match — so an ARN naming a different type under the same service built a well-formed key for the wrong kind of resource instead of failing. A Step Functions activity: ARN resolved to a state-machine key, so a tag meant for an activity landed on a same-named state machine; a Lambda layer ARN resolved to a function key. Every arm now checks what it strips. Three checks are more than a prefix test, because AWS's own ARN formats make them so: a Lambda version and alias ARN are lexically identical, so any qualified function ARN is refused rather than guessed at; a DynamoDB index and stream ARN nest under table/, so a remaining / is not a table; and Step Functions distinguishes its two types by the capital M in stateMachine: alone, so that check is case-sensitive on purpose.
The account in a key comes from the ARN, not from the calling request, for SQS as for IAM and EC2. An ARN naming another account therefore resolves that account's resource or none at all, and appears in FailedResourcesMap — rather than silently tagging the caller's own same-named resource, which would succeed against the wrong thing.
DynamoDB was the one arm that rule had missed: it built its key from the calling request's account, so an ARN naming another account's table tagged the caller's own same-named table and UntagResources stripped tags from it. The resolver no longer receives a request context at all, so no arm can reach for the caller's account again. ECS's own TagResource had the same defect and now shares one key builder with the ARN resolver, which is also what lets the tagging API reach an ECS service, task and task definition rather than a cluster only.
RDS follows the same shape as of #835: the rds arm and RDS's own three tag operations resolve through one builder, so the tagging API now reaches a DB cluster and a DB subnet group as well as an instance and a snapshot — see the RDS section for the four segments and the per-kind 404.
Sharing the resolver is only half of what that took. The rds arm merged a tag by decoding the record into an RDSDBInstance and storing the result back, whatever the key named — so tagging a cluster through the tagging API replaced the cluster record with an instance-shaped one, and every member an instance does not carry under the same name went missing. Nothing refused and the tag itself looked right; DescribeDBClusters simply stopped reporting the endpoint, the reader endpoint and the port. That is data loss rather than a missing feature, which is why the arm now edits the tags member of the raw JSON and leaves every other member untouched — ECS's pattern, generalised so the two services share it. RDS spells the member Tags and ECS spells it tags, so the member name is a parameter, and a case-differing member is an error rather than a second member written alongside the real one: every way of not writing a tag has to fail.
Step Functions followed as of #910, and it needed all three parts. The states arm and Step Functions' own three tag operations now resolve through one builder, which is what lets the tagging API reach an activity rather than a state machine only (part of #835); the arm's merge went through the same shared raw-JSON helper, because it decoded a StateMachineState whatever the key named and so replaced a tagged activity's record with a state-machine-shaped one, losing its activityArn outright; and the merge sits behind a kind guard, because the states namespace also holds executions and three index keys that no ARN addresses and that store no tags. The guard and the raw-JSON merge fix different failures and neither substitutes for the other. Step Functions' own operations took the account and Region from the calling request — see the Step Functions section for what that reached and for the 400 status ResourceNotFound carries.
AWS does not publish what a cross-account ARN does here: TagResources says only that "you can only tag resources that are located in the specified AWS Region for the AWS account", and an explicit refusal is documented for a partition mismatch but not for an account mismatch. Refusing is substrate's reading, applied uniformly.
ACM certificates and CloudFront distributions followed as of #835, and both are on AWS's own authoritative list: the tagging guide's welcome page names AWS Certificate Manager and Amazon CloudFront among the services TagResources/UntagResources support. Each row is a resolver arm, a merge arm and a scanner, and none of the three substitutes for the others — an arm without a scanner leaves the resource writable and invisible, and a scanner without an arm leaves it visible and unwritable.
Both resolvers share the key builder the owning service already uses, which is what #765's cross-readability criterion asks for. ACM's is the cheaper of the two because acmCertKey embeds the whole certificate ARN alongside the account and Region it names. That redundancy is also why ACM's own three tag operations legitimately key from the calling request rather than from the ARN, and are not an instance of the #918 defect: a lookup keyed by the caller's account can only ever find a record whose ARN names that same account, so there is no key at which the two disagree. Changing them to key from the ARN would introduce the cross-account reach that arrangement forecloses.
Both merges go through the shared raw-JSON helper and both sit behind a kind guard, for the reasons the states arm established. The guard's prefix is tested colon-terminated in each namespace, because cert: is a prefix of cert_arns: and cfdist: of cfdist_ids: — a bare-prefix test would report an index key taggable and merge a tags member into a JSON array of identifier strings. Each namespace holds a second kind no ARN addresses: ACM's certificate-ARN index, and CloudFront's distribution index plus its invalidation records and their index.
A CloudFront distribution is reported by GetResources in us-east-1 only. CloudFront is global and its ARNs carry an empty Region segment, but GetResources is a per-Region operation, so a global resource has to be attributed to exactly one Region or every Region's call would report it — and TagResources states that "you can only tag resources that are located in the specified AWS Region for the AWS account". Which Region that is, is AWS's: a global resource is attributed to us-east-1. That the gate exists at all is substrate's reading, because AWS publishes the attribution and not a GetResources rule. CloudFront's own ListDistributions keeps answering from any Region, which is not an inconsistency — it is what a global service does, and the per-Region attribution belongs to the tagging API alone.
KMS keys followed as of #835, together with the resolution fix (#922) that had to land with them. KMS is on the truncated part of the welcome page's list, so its write half is unlisted rather than refused; its read half is unconditional, like every other row's. The row is the usual three parts, and two things about it are new.
It is the first row whose tags are an array of two-field objects rather than a map, so the merge goes through a second shared helper alongside the string-map one. The element's field names are parameters because the services disagree: KMS spells them TagKey and TagValue, and SNS, Secrets Manager and Systems Manager spell them Key and Value. Both helpers sort the result by key, because a slice built by ranging the intermediate map would come out in Go's map hash order and one recorded run would not replay byte-identically — the rule #862 settled for the four EC2-shaped helpers.
And it is the first row where sharing the resolver created a refusal rather than only fixing a reach. All three KMS tagging operations publish "Cross-account use: No", but while KMS keyed from the calling request a foreign-account key ARN could only ever address the caller's own key, so that prohibition had nothing to refuse. Honouring the ARN's account without adding the refusal would have turned a wrong-record write into a genuine cross-account one. See the KMS section for the four accepted KeyId forms and for why the alias case needs an anchored cut on the first slash rather than the last.
SNS topics followed as of #835, again with the resolution fix (#925) that had to land with them, and the row is where the anchored-segment rule of #910 has nothing to anchor on: an SNS topic ARN carries no type keyword and no separator, so the resource portion is the name. The discriminator the shape offers is the colon — a resource portion containing one names a subscription — and that is what the resolver refuses. See the SNS section for the ten operations that shared the defect and for the two tag-parameter spellings AWS's reference and its own examples disagree about.
SNS is also the row where sharing the resolver created no refusal, and the contrast with KMS is the point: no cross-account statement appears on any of SNS's three tagging pages. The state key is account- and Region-qualified, so a foreign-account ARN builds a key nothing is stored at and the caller gets a FailedResourcesMap entry — isolation emerges from the key rather than from a guard, as ACM's does. Inventing a prohibition AWS does not publish would be substrate's invention rather than its reading. Its merge sits behind a colon-terminated kind guard for the reason the earlier rows' do: topic: is a prefix of topic_names:, whose value is a JSON array of names.
Secrets Manager secrets followed as of #835, with their own resolution fix (#928), and the row is the first where the identifier is legitimately either an ARN or a bare name. AWS documents SecretId as "the ARN or name of the secret", and a name carries no account — so the caller's own account is the correct source for that one case, and refusing everything that is not an ARN would have been the easy over-correction. The two are separated structurally rather than by convention: the ARN parser takes no account, Region or request context at all, and only the name path is given them. Anything beginning arn: that does not parse is refused rather than falling back to a name lookup, because a caller who wrote an ARN prefix meant an ARN.
The tagging arm takes an ARN only, with no name fallback, since its parameter is ResourceARNList and a bare name is not an ARN. Unlike SNS this shape does offer a keyword to anchor on — secret is the fifth colon-delimited segment — and the name is the whole remainder after it, so a hierarchical prod/db/password survives the round trip where taking the ARN's last component would truncate it to password. Isolation is emergent as SNS's and ACM's are, with no published cross-account prohibition to enforce.
Its merge sits behind a colon-terminated kind guard, which is now the fifth namespace to need one: secret: is a prefix of both secret_names: and secret_version:. Four of the five namespaces reached so far have collided, so the guard is the default for a new row rather than a per-service discovery. The version key is also the strongest argument for the guard anywhere in the set — its value is a caller's secret payload, and not JSON at all, so a tags member merged into it would corrupt the secret rather than merely write somewhere nothing reads.
One thing substrate deliberately does not model, because it is what makes this row's ARN-to-name derivation exact: AWS appends a hyphen and six random characters to a secret's name when it mints the ARN, and warns "do not end your secret name with a hyphen followed by six characters" precisely because trimming them back off cannot be done in general. Substrate mints no suffix, so the name is recoverable from the ARN by construction. Adding one would make the resolver ambiguous and would change a value CloudFormation records as a physical ID, so it is a separate decision.
Systems Manager parameters came next, and the row is the first where the confusion the resolver exists to prevent is between resource types rather than between accounts (#932). Every earlier row asked whose resource an identifier names; this one asks what kind. AWS publishes ResourceType as Required: Yes over a ten-value enum (Document | ManagedInstance | MaintenanceWindow | Parameter | PatchBaseline | OpsItem | OpsMetadata | Automation | Association | CloudConnector), and all three of Systems Manager's own tag operations decoded it and never read it — so {"ResourceType": "Document", "ResourceId": "MyRunbook"} tagged the parameter named /MyRunbook, and ListTagsForResource with the same pair read the tag back, confirming that the wrong resource had been tagged. A value outside the enum was accepted too, which made InvalidResourceType — a code AWS publishes at all three operations — unreachable.
The two refusals are deliberately distinct, because they tell a caller two different things. A ResourceType outside the enum is InvalidResourceType, whose own description — "The resource type isn't valid" — is the true statement about it. A ResourceType AWS publishes but substrate models no taggable resource for is InvalidResourceId: the type is real, and it is the identifier that names nothing, which is the honest-empty behavior of #827. Answering one code for both would tell a caller who mistyped Parameter that their parameter is missing. Substrate models a taggable resource for exactly one of the ten today; the other nine are enumerated so that a value AWS publishes can be told apart from a value AWS does not. Both are answered at HTTP 400, which is what all three reference pages give every code they publish: InvalidResourceId and InvalidResourceType at 400, TooManyTagsError and TooManyUpdates at 400, and InternalServerError at 500. There is no 404 anywhere in the set, so the status carries nothing a caller can branch on and the code is the whole signal — which is why #933 was worth fixing even though the refusal was already distinguishable by code.
This is also the first row where the owning service's own tag operations take no ARN at all. AWS is explicit — "For the Document and Parameter values, use the name of the resource" — so a parameter's ResourceId carries neither account nor Region, and the caller's own are the only possible source for them. "The account comes from the ARN" (#826) is therefore inapplicable to that half and has nothing to guard; the tagging API's half does take an ARN, and its parser accordingly takes no account, Region or request context, so it structurally cannot reach for the caller's. Anything beginning arn: arriving as a ResourceId is refused rather than normalized into a name, per #928: a caller who wrote an ARN prefix meant an ARN. Previously such a value became the name/arn:aws:ssm:us-east-1:123456789012:parameter/db/password and was looked up as one — refused either way, but for the wrong reason and after a normalization that makes the message nonsense.
The leading-slash tolerance in the other direction is kept, and it is substrate's reading rather than AWS's. AWS documents that tolerance explicitly only for OpsMetadata, whose ResourceID may be given as either aws/ssm/MyGroup/appmanager or /aws/ssm/MyGroup/appmanager. For a Parameter it is required by substrate's own behavior: PutParameter normalizes Name to a leading /, so a caller who created MyParam would otherwise be unable to tag it.
The tagging arm's ARN shape has one feature none of the earlier rows do: there is no separator between the type segment and the name, because the name carries its own. The resource portion of arn:aws:ssm:us-east-1:123456789012:parameter/db/password is parameter/db/password, so the first /-delimited segment must be exactly parameter (per #910's whole-segment rule — the same namespace addresses document/, servicesetting/, opsmetadata/, maintenancewindow/, patchbaseline/ and managed-instance/) and the name is the whole remainder with the / restored. Taking the ARN's last component would truncate a hierarchical name to password, and a prefix match would grow wrong the moment AWS adds a type whose name begins parameter. The state key consequently holds a doubled separator, parameter:{account}/{region}//db/password; that is the shape already on disk and is left alone, since changing it would orphan every stored parameter.
Its merge sits behind a colon-terminated kind guard, the sixth namespace to need one: parameter: is a prefix of parameter_paths:, whose value is a JSON array of names. Five of the six namespaces reached so far have collided, so the guard is now unambiguously the default for a new row. Tag order at both arms is sorted by key, because AddTagsToResource appended in Go map order while PutParameter stored the caller's order — so a parameter tagged at creation and one tagged afterwards reported their tags differently, which is the defect #862 fixed in the four merge helpers, re-verified here rather than assumed. ListTagsForResource also rendered an untagged parameter's TagList as null rather than as the empty array AWS publishes.
A Lambda function and a DynamoDB table belong to one account in one Region
Every row above is about a resolver addressing the wrong record. This one is about the record itself: two of the keys the resolvers address were not qualified enough to hold the resources AWS lets a caller create. Before #943 a Lambda function was stored under function:{name} and a DynamoDB table under table:{account}/{name}. Both are now {kind}:{account}/{region}/{name} — function:123456789012/us-east-1/orders and table:123456789012/us-east-1/orders — which is the shape Glue, Timestream and AppSync already used and the shape CloudFormation's tag stamper keys on.
What that changes is not a mis-addressed tag but a refusal. A key that cannot tell two resources apart makes the second one impossible to create:
| Before #943 | Now |
|---|---|
Two accounts each creating a function named orders — the second answered ResourceConflictException/409 | both succeed, and each GetFunction reports its own account's ARN |
One account creating a function named orders in us-east-1 and in us-west-2 — the second answered 409 | both succeed |
One account creating a table named orders in us-east-1 and in us-west-2 — the second answered ResourceInUseException/400 | both succeed, and each DescribeTable reports its own Region's ARN |
A TagResources naming another account's function ARN wrote to the caller's own function of that name | it writes to the function the ARN names, or to nothing |
The two halves rest on different provenance, and the difference is worth stating. DynamoDB's is quoted: CreateTable's own description says "In an AWS account, table names must be unique within each Region. That is, you can have two tables with same name if you create the tables in different Regions." Nothing needs inferring, and the code the old key produced — ResourceInUseException, whose first listed cause is "[y]ou attempted to recreate an existing table" — is on the same page at HTTP 400.
Lambda's is the weaker of the two, and it is an inference rather than a quotation. CreateFunction states nothing at all about the scope a function name is unique within. The whole of the argument is the shape of the value it hands back: FunctionArn's published pattern is arn:(aws[a-zA-Z-]*)?:lambda:[a-z]{2}((-gov)|(-iso([a-z]?)))?-[a-z]+-\d{1}:\d{12}:function:[a-zA-Z0-9-_\.]+, so the identifier AWS mints for a function qualifies its name by a Region and an account — and an identifier that carries a scope is not an identifier of something outside it. That is substrate's reading, not AWS's sentence. It is the same reasoning the ARN-format argument gave for SQS in #826 and for DynamoDB in #845, and it points the same way in all three cases; recording it as an inference is what distinguishes it from the DynamoDB half rather than a reason to doubt it. #1088 then applied the same reading to the last service in the class — see One queue name is one queue per Region, where the identifier carrying the scope is a queue URL's host rather than an ARN, and where the missing Region produced a create that answered another Region's URL rather than a refusal.
Three sibling keys moved with the function key, and not moving them would have been a new leak of its own. A function's resource policy, its stored zip and its event-invoke configuration were keyed function_policy:{name}, function_zip:{name} and function_invoke_config:{name}. Qualifying only function: would have left two accounts' same-named functions holding one policy, one code payload and one invoke configuration between them — so AddPermission in one account would have granted access on the other's function. Every key family in both services carries the full scope.
One behaviour changed that no key required. A Lambda event source mapping can name a queue in one account and a function in another, and the poller invoked the function using the event source's account and Region. That was already wrong and was invisible while the function key carried neither: the invoke now resolves the function in the account and Region its own FunctionARN names.
No stored state needs migrating, and that is a fact about substrate rather than a decision. The only StateManager implementation in non-test code is NewMemoryStateManager, and the server constructs it unconditionally — a SQLite backend is deferred to #2 — so a key exists only for the lifetime of one process and there is nothing written under the old shape for a later run to fail to find. The rename is therefore same-process and needs no compatibility path, which is stated here rather than left implied. When a persistent backend does arrive, a key-shape change stops being free and this paragraph is the note that says so: a fixture recorded against the old key would then need re-seeding, exactly as the account default's does above.
Which failure gets which error code
A FailedResourcesMap entry carries one of the two codes FailureInfo enumerates, split three ways, plus one case that carries the owning service's own code:
| Case | Code | Status |
|---|---|---|
| A well-formed ARN naming a resource type substrate cannot key | InternalServiceException | 500 |
| A well-formed ARN of a type substrate keys, naming a resource that does not exist | InvalidParameterException | 400 |
Not an ARN: no arn: prefix, or fewer than six colon-separated segments | InvalidParameterException | 400 |
| The merge would leave the resource over its owning service's published per-resource tag quota | that service's own code | that service's own status |
FailureInfo documents InternalServiceException as covering "the resource type in the request is not supported by the Resource Groups Tagging API", and tells the caller "it's safe to retry the request and then call GetResources to verify the changes". Which of the two codes an unsupported type earns is substrate's reading rather than AWS's: the same page's InvalidParameterException bullets also say "the target ID is invalid, unsupported, or doesn't exist", so both codes can be read to cover it. Substrate splits those two on whether the ARN parses, because that is the only distinction a caller can act on differently.
The middle row is not ambiguous, and substrate answered the 500 for it until #939. TagResources and UntagResources both list "[t]he target ID is invalid, unsupported, or doesn't exist" among InvalidParameterException's causes, and a resource that is not there is the third of those three; a 500 telling the caller it is "safe to retry" pointed at a request that could only fail again. Wherever the resolver builds an account- and Region-qualified state key from the ARN, a foreign-account or foreign-Region ARN takes this row rather than one of its own: it addresses a key nothing is stored at, so the refusal is emergent rather than a separate guard. Lambda's arm was the one exception until #943 — its key was function:{name} with no account in it, so a foreign-account function ARN reached the caller's own function of that name — and it now qualifies by both halves like the rest. An S3 bucket ARN — arn:aws:s3:::{name} — carries no account or Region to honour in the first place, so the question does not arise there.
AWS publishes a contradiction about this field, recorded here rather than resolved: FailureInfo.ErrorCode carries "Valid Values: InternalServiceException | InvalidParameterException", while the same member's prose says it "can also include any valid error code returned by the AWS service that hosts the resource that the ARN key represents" and offers AccessDeniedException — which is in neither enumerated value — as its example. Substrate reads the enumeration for every failure it can express that way, because the enumeration is the part a caller can switch on, and the prose for the one it cannot: a quota refusal (the table's fourth row). Neither enumerated code says "this resource is full", so reporting one would leave the caller to retry a request that can only fail again, which is the same reasoning that moved the "does not exist" case off InternalServiceException in #939.
Substrate does not distinguish "AWS's tagging API does not support this type" from "AWS supports it and substrate has no arm yet". AWS publishes no list that could support the distinction for a write: the supported-services page its own TagResources reference links to does not exist, and the list on the guide's welcome page — which is the authoritative one — is truncated alphabetically at IAM, so a type absent from it is unlisted rather than refused.
For a read there is no distinction to draw. The same page states that "the GetResources, GetTagKeys, and GetTagValues operations support all resource types", so every scanner substrate lacks is a gap in substrate rather than a boundary of AWS's, and the scanner half of every #835 row was in scope unconditionally — the last of them, ECS's, landed in #935. The per-service list constrains TagResources/UntagResources only.
One requirement of TagResources substrate models at no arm: AWS requires the caller to hold tag:TagResources and the owning service's own tagging permission for the type. Substrate authorizes the tagging action alone.
An ErrorMessage never carries a state key. The detail naming the offending resource portion goes to the log instead, because a state-key layout is substrate's internal business and not something an API response should publish.
A tag quota belongs to the service that owns the resource
API_TagResources states the rule itself — "Each resource can have up to 50 tags" — and four of the twenty-three namespace arms enforce a per-resource quota on their own tagging operations: EC2, ELB, IAM and Kinesis. Three are at 50, and ELB is at 50 for an ELBv2 resource and 10 for a Classic Load Balancer — the cap is resolved from the record's own state key, so a classic load balancer reached through this generation-agnostic API gets 10 because of what it is rather than because of which door the request came through (#1148). Until #1000 the shared merge consulted none of them, so TagResources was the one way in substrate to put a resource over its own service's quota — after which that service's own tagging operation refused every further add, leaving a resource in a state substrate's own reference says cannot exist, reached through substrate's own API. Two earlier statements in this file and in CHANGELOG.md claimed Kinesis was the first quota of any service and that the merge covered sixteen arms; both were wrong when written and are corrected with that issue.
Each service's own checker is called rather than a shared count, because the four disagree in ways a shared count would have to flatten:
| Service | Cap | Code | Status | Reserved aws: keys |
|---|---|---|---|---|
| EC2 | 50 | TagLimitExceeded | 400 | Excluded from the count |
| ELBv2 | 50 | TooManyTags | 400 | Excluded from the count |
| ELB Classic | 10 | TooManyTags | 400 | Excluded from the count |
| IAM | 50 | LimitExceeded | 409 | Counted |
| Kinesis | 50 | LimitExceededException | 400 | Counted |
The two ELB rows answer the same code at the same status and differ only in the number and in the message, and the numbers come from different places. ELBv2's API_AddTags (2015-12-01) publishes no maximum at all — not in its description, not as an Array Members constraint, not in its Errors section beyond naming TooManyTags — so the 50 is read off the user guide's restrictions list ("Maximum number of tags per resource—50"). The classic 10 is API-reference text, in the first sentence of the 2012-06-01 API_AddTags: "Each load balancer can have a maximum of 10 tags." The TooManyTags message differs between the two pages and each generation answers its own, so a consumer reading the message sees which generation refused it.
The reserved-key column is what each service publishes, not a choice: EC2's and ELB's restrictions state that "[t]ags with the aws: prefix do not count against your tags per resource limit" and no IAM or Kinesis page says anything of the kind. That restrictions list is written for Elastic Load Balancing rather than for one generation, and the classic API page publishes no reserved-prefix rule of its own, so the exclusion is applied to both ELB caps rather than to ELBv2's alone. It is unobservable through this API either way — a reserved key is refused upstream, so only the CloudFormation deployer's stamp can write one — and it is recorded rather than unified because unifying it would mean overruling one of the four pages.
The count is over the post-merge key set, so rewriting the value of a key a resource already carries succeeds at the quota where adding a new key is refused, and UntagResources is never checked at all: a removal only shrinks the key set, so it cannot exceed a quota, and the slot it frees is usable. That also keeps a resource written over a quota before #1000 reportable and removable rather than untouchable.
A refusal writes nothing — the check runs before the merge, per #965 — and it is one entry in the FailedResourcesMap, so the other ARNs in the same request are tagged.
The nineteen remaining arms publish no quota substrate models, and this path does not invent one for them. SQS is the case in point: API_TagQueue states a 50-tag limit in its own prose, but TagQueue does not enforce it either, and enforcing it here alone would make substrate's two tagging APIs disagree in the opposite direction from the defect being fixed.
Neither CloudFormation writer enforces the quota, and the two reasons are different. The stamp writes only aws:-prefixed keys, which two of the four services exclude from the count by their own statement. Propagation writes the caller's own stack tags, which every one of the four would count — and #1077 decided that case rather than leaving it filed. The decision is that propagation writes regardless, and the resulting over-quota resource is a recorded divergence. See propagation and a resource's own tag quota for the search behind it; in short, AWS publishes no outcome for the case and no code a refusal could carry, and refusing on no citation would fail a template real CloudFormation deploys — a false failure in a consumer's test, which is worse than the divergence. The quota is still enforced at every door AWS publishes it for, including Kinesis's own AddTagsToStream on the very stream the deployer took past fifty.
Cost
Resource Groups Tagging API operations are free.
SNS
Endpoint: sns.{region}.amazonaws.comProtocol: AWS Query (form-encoded, Action= parameter)
Supported operations
| Operation | Notes |
|---|---|
| CreateTopic | Decodes Tags, in either published spelling, and Attributes — only the 24 published keys, anything else is InvalidParameter/400 |
| GetTopicAttributes | Four attributes derived, the rest passed through as stored |
| SetTopicAttributes | Only the 25 published names are settable; anything else is InvalidParameter/400 |
| DeleteTopic | |
| ListTopics | Base64 pagination token |
| Subscribe | Supports lambda, sqs, http, https, email protocols |
| Unsubscribe | Idempotent |
| ListSubscriptions | |
| ListSubscriptionsByTopic | |
| GetSubscriptionAttributes | Seven members derived, the rest passed through as stored; InvalidParameter/400 for an ARN that is not a subscription's |
| SetSubscriptionAttributes | Only the 6 published names are settable; anything else is InvalidParameter/400. A stored FilterPolicy does not filter delivery |
| Publish | Dispatches to subscribed Lambda/SQS via cross-service dispatch |
| PublishBatch | |
| AddPermission | Accepted; no policy is stored |
| RemovePermission | Accepted; no policy is stored |
| TagResource | ResourceNotFound/404 for an absent topic; answers an empty <TagResourceResult> |
| UntagResource | Answers an empty <UntagResourceResult> |
| ListTagsForResource | Not paginated, as AWS's reference is not |
A topic ARN addresses the topic it names
An SNS topic ARN is arn:aws:sns:{region}:{account}:{topic-name}. It carries no type keyword and no separator — the resource portion is the name — which makes it the one ARN in the tree with no segment to anchor a resource-type match against.
Every operation taking a TopicArn or a ResourceArn used to split it on : and take the last segment, then key the load and the store by the caller's own account and Region. Three failures compounded (#925), across ten operations:
- A topic ARN naming another account or Region addressed the caller's own topic of that name.
UntagResourceis the damaging direction and answered200while stripping tags from it;Publishpublished to the wrong topic andDeleteTopicdeleted it. A caller could not tell any of those from a correct call. - A subscription ARN is the topic's ARN with an identifier appended —
arn:aws:sns:{region}:{account}:{topic}:{sub-id}— so its last segment is the subscription's own identifier, handed back as a topic name. - The length guard fell through to returning its argument, so any string at all —
arn:aws:sns, a bare name, a URL — became a topic name and was looked up rather than refused.
The account and Region now come from the ARN, by a parser that takes no request context at all, which is the arrangement #826 established for SQS and DynamoDB, #910 for Step Functions, #918 for CloudFront and #922 for KMS. The discriminator SNS's shape does offer is the colon: a resource portion containing one names a subscription, and is refused with InvalidParameter/400 — which is also what a malformed ARN now answers instead of being looked up verbatim.
A subscription ARN is minted under the topic's account and Region rather than the subscriber's, because the ARN is the topic's own with an identifier appended. The subscription record and the two subscription indexes are still keyed by the calling account, deliberately: a cross-account subscription is not modeled — nothing mints one and no operation distinguishes a subscriber's account from a topic's — so moving them would put the index under an account the subscriptions are not stored in and Publish would silently stop delivering. The key embeds the whole subscription ARN, which itself names an account, so a lookup keyed by the caller can only find a record whose ARN names that same account — ACM's arrangement, and not an instance of the #918 defect.
An SNS tag
Tag is Key/Value. AWS's reference names the request members Tags.member.N and TagKeys.member.N, but AWS's own request examples on the same pages wire Tags.Tag.1.Key, Tags.Tag.1.Value and TagKeys.TagKey.1. Substrate decoded only the first spelling, so a caller that followed the example got 200 with nothing written or nothing removed. Both are accepted, because the reference and its example disagree and a caller may reasonably have followed either.
CreateTopic publishes a Tags parameter that substrate read not at all, so a topic created with tags in one call reported none through either API. It is decoded now.
An absent topic answers ResourceNotFound at 404, which all three tag operations publish. Substrate answered the same status under the code name NotFound, which no SDK models — the status was right and the name was not. The plain NotFound the topic operations answer is correct for them: only the three tag pages publish ResourceNotFound.
A topic's tags are stored and reported ordered by key, on the write and on the read. SNS's own merge was already deterministic — it walks indexed request parameters, not a Go map — but it preserved insertion order while the tagging API's arm emits key-sorted order, so one topic's tags came back in two different orders depending on which API was asked (#862).
Two things are deliberately not modeled. AWS's published ListTagsForResource sample response emits <Value> before <Key> and substrate emits <Key> first; XML member order is not significant to any SDK's parser, so no test could assert a difference a caller can act on. And the 10 TPS limit on SNS's tagging actions is not modeled, which is a seedable-throttle question rather than an ARN one.
Whether the topic an ARN names has to exist
The resolution rules above settle which topic an ARN addresses. Whether that topic has to exist was a separate question, and four operations did not ask it (#926).
Subscribe, Publish, PublishBatch and ListSubscriptionsByTopic parsed the TopicArn, derived a state key from it, and then read only the subscription index — so an absent topic was indistinguishable from a real topic with no subscribers, and all four answered 200. Publish and PublishBatch minted a MessageId for a message no topic had accepted, Subscribe handed back a subscription ARN, and ListSubscriptionsByTopic reported an empty list. All four pages publish NotFound at 404, glossed "Indicates that the requested resource does not exist."
Subscribe is the worst of the four, because it is the one that writes: a subscription record and two index entries survived it, reported by ListSubscriptions ever after and delivered to had a topic of that name later been created.
The consequence is not only a wrong code. A CreateTopic → Publish → DeleteTopic → Publish sequence — a producer's teardown, or a test asserting that one surfaces an error once its topic is gone — could not be written, because the second Publish succeeded. An empty subscription list and an absent topic are now different answers rather than the same one.
All six operations that resolve a TopicArn to a topic record — the four above plus GetTopicAttributes and SetTopicAttributes, which already checked — go through one helper, on the #961/#969 precedent that a per-handler existence check drifts. The load is keyed by the ARN's own account and Region, so the check cannot reintroduce what #925 fixed: a same-named topic in the caller's own Region does not satisfy an ARN naming another, which API_Publish requires independently — "You can publish messages only to topics and endpoints in the same AWS Region." The account half needs no guard of its own, because the state key carries the ARN's account and a foreign one addresses nothing.
The check runs after the parse, so a string that is not an ARN still answers InvalidParameter/400 rather than being reported as a topic that does not exist.
PublishBatch answers at the top level, not as a per-entry BatchResultErrorEntry inside a 200. API_PublishBatch has both shapes, and the page does not say which applies here; TopicArn is a request-level parameter, so every entry addresses the same topic and a per-entry rendering would report the identical failure on each of them behind a success status. NotFound is in the operation's top-level Errors list, and the batch-scoped codes it publishes — BatchEntryIdsNotDistinct, InvalidBatchEntryId, ParameterValueInvalid — are each about an individual message. That reading is substrate's.
Unsubscribe answers NotFound/404 for a subscription ARN naming no subscription, where substrate answered 200 behind an unsourced "idempotent" comment. API_Unsubscribe publishes the code and states no idempotence, and SubscriptionArn is the operation's only request parameter and only named resource, so the published code can only be about the subscription.
DeleteTopic is the one topic operation that goes the other way, and substrate had it backwards (#992). API_DeleteTopic's description states that "this action is idempotent, so deleting a topic that does not exist does not result in an error", and substrate answered NotFound/404 — so a teardown that deletes unconditionally, or a retried delete, failed here and succeeded in production. The same page also publishes NotFound/404 in its Errors list, alongside ConcurrentAccess, InvalidState, StaleTag and TagPolicy, so the page contradicts itself. The description sentence governs: it names the condition, names the outcome, and calls the property by name, while the error-list entry names no condition at all. Resolving it that way is substrate's reading, corroborated by API_Unsubscribe publishing the same code and carrying no such sentence — AWS states idempotence where it means it. A malformed ARN is still InvalidParameter/400: idempotence licenses a topic that does not exist, not a string that is not an ARN.
Which attributes GetTopicAttributes reports
API_GetTopicAttributes publishes seventeen attribute names, and substrate's answer splits in two: four are derived from state on every read, and the rest are reported only if CreateTopic or SetTopicAttributes stored them.
| Attribute | Substrate's answer |
|---|---|
TopicArn | Derived — the ARN the record is keyed by |
Owner | Derived — the account segment of that ARN, matching the page's sample |
SubscriptionsConfirmed | Derived — the length of the per-topic subscription index |
SubscriptionsPending | Derived — always 0; see below |
SubscriptionsDeleted | Not reported; see below |
Policy | Stored only; substrate mints no default topic policy |
DeliveryPolicy, EffectiveDeliveryPolicy, DisplayName, MaximumMessageSize, SignatureVersion, TracingConfig, KmsMasterKeyId | Stored only |
ArchivePolicy, BeginningArchiveTime, ContentBasedDeduplication, FifoTopic | Stored only (FIFO) |
Of those seventeen, eight are read-only — SetTopicAttributes publishes none of them, so substrate refuses a write to any: TopicArn, Owner, the three subscription counts, EffectiveDeliveryPolicy, BeginningArchiveTime and FifoTopic. The next section is the other side of that arithmetic.
Until #993 none of that was true. The handler reported a SubscriptionsCount member that appears nowhere on the page — not in the attribute list, not in the sample response, not in any SDK's shape — hardcoded to "0", and reported none of the three subscription counts AWS does publish. So a caller reading the attribute AWS documents got nothing back and a caller reading the invented one got a constant no writer ever touched.
SubscriptionsPending is always 0, and that is a fact rather than a placeholder. Substrate implements no ConfirmSubscription, and Subscribe hands back a real subscription ARN rather than the "pending confirmation" string API_Subscribe documents for a subscription awaiting confirmation. Every subscription this emulator mints is therefore confirmed the moment Subscribe returns, so the confirmed/pending split is decidable rather than guesswork.
SubscriptionsDeleted is omitted rather than reported as 0. Unsubscribe deletes the subscription record and filters both indexes rather than tombstoning it, and SNSSubscription carries no status, so nothing tracks a deletion. A monotonic counter incremented in Unsubscribe would be cheap, but the page publishes only "The number of deleted subscriptions for the topic" and says nothing about how long a deleted subscription stays counted — so a never-decaying counter would be substrate's invention rather than its reading of the page. Omitting the member claims nothing, which is the honest-empty rule. That choice is substrate's.
SubscriptionsConfirmed is keyed by the caller's account and Region, not by the ARN's. The topic record is keyed by the ARN's own account and Region — the rule #925 made structural — while every read and write of the per-topic subscription index is keyed by the caller's, which Subscribe records deliberately: a cross-account subscription is not modeled, and re-keying the indexes would leave Publish reading an index the subscriptions are not in. The count follows the index, because that makes GetTopicAttributes and ListSubscriptionsByTopic read the same entries under the same key and so unable to disagree about the same topic. Keying the count by the ARN's target instead would have made them disagree by construction: 0 from the count while the list returned the subscriptions.
A derived attribute cannot be shadowed by a stored one. SetTopicAttributes used to accept any AttributeName and store it unchecked, and the handler used to merge the stored map after its own literals — so a caller could set TopicArn and have GetTopicAttributes report it, and once the counts became derived could have set SubscriptionsConfirmed to any value it liked. The derived members are written last for that reason. Since #1067 the handler refuses all four derived names outright, so the ordering is defence in depth rather than the only guard — but it remains the only guard for a value that reached the record another way, which is why it stays and why its test seeds state directly rather than through the calls the allowlist now refuses.
Policy is unmodelled, not omitted by accident. The page publishes it and AWS's sample response carries a default policy document naming eight actions, but no SNS operation mints one, so substrate reports a Policy only when something stored it. This is the same shape as KMS's #983 — a default resource policy that no operation creates — and is recorded here rather than answered with an invented document. SignatureVersion is the one attribute whose absence the page gives a meaning: "If the API response does not include the SignatureVersion attribute, it means that the SignatureVersion for the topic has value 1." Not inventing it is what the page asks for.
Which attributes SetTopicAttributes accepts
API_SetTopicAttributes publishes twenty-five settable AttributeName values, and substrate accepts exactly those. Any other name — including the eight GetTopicAttributes publishes and this page does not — is InvalidParameter/400.
| Group | Names |
|---|---|
| General | DeliveryPolicy, DisplayName, MaximumMessageSize, Policy, TracingConfig |
| Delivery status, per endpoint family | HTTP, Firehose, Lambda, Application and SQS each × SuccessFeedbackRoleArn, SuccessFeedbackSampleRate, FailureFeedbackRoleArn — fifteen names |
| Server-side encryption | KmsMasterKeyId, SignatureVersion |
| FIFO topics | ArchivePolicy, ContentBasedDeduplication, FifoThroughputScope |
Until #1067 the handler wrote whatever AttributeName arrived into the topic record with no check of any kind, so AttributeName=Banana was stored and GetTopicAttributes reported it back as though SNS carried it — and so were the eight read-only names, of which the merge order described above stopped only four from being reported.
The guard is an allowlist, not a denylist of the derived names, because the two pages do not partition one vocabulary. Set publishes 25 names, Get publishes 17, only 9 appear on both, and the union is 33. Subtracting substrate's four derived names from anything would still have accepted EffectiveDeliveryPolicy, SubscriptionsDeleted, BeginningArchiveTime and FifoTopic — each a fact about the topic rather than something a caller sets — and no denylist of any length refuses a name neither page publishes.
The code is substrate's reading of a published gloss, not a published sentence. The page lists InvalidParameter/400 with "Indicates that a request parameter does not comply with the associated constraints", and AttributeName's constraint is its published value list; the page's only prose naming the code is about a MaximumMessageSize above 256 KiB on a topic that cannot carry it. The refusal messages are substrate's own, because the page publishes none. AttributeName is Required: Yes, so an absent one is the same code; AttributeValue is Required: No, so an empty value is accepted — CloudFormation's own AWS::SNS::TopicPolicy deletion depends on it, since there is no DeleteTopicPolicy and a policy is removed by setting it to the empty string.
Divergence: sixteen settable names are reported back where AWS would not report them. Those sixteen — the fifteen delivery-status names and FifoThroughputScope — are absent from GetTopicAttributes' seventeen, so on AWS they are write-only. Substrate stores them and GetTopicAttributes reports the whole stored map, so it reports them. That is recorded rather than filtered: the value the caller set is real, and hiding it would make SetTopicAttributes look like a no-op, which is #1067's failure mode in the other direction. A consumer asserting on the key set GetTopicAttributes publishes must take AWS's page as the authority here, not substrate's answer.
The name check runs before the topic is resolved, so an unpublished name is refused whether or not the topic exists. The page publishes both InvalidParameter/400 and NotFound/404 and orders them nowhere, so that is substrate's choice.
CreateTopic's Attributes map, and the one name it does not accept
API_CreateTopic publishes an Attributes map on the wire as Attributes.entry.N.key / Attributes.entry.N.value, which is what an SDK sends for create_topic(Name=…, Attributes={…}). Substrate decoded none of it until #1126: it seeded the record from a bare DisplayName query parameter instead, so the ordinary SDK call lost every attribute and GetTopicAttributes reported none of them, while a parameter the page does not publish at all was the only one honored.
The map is now decoded at every index, 1-based and dense — the first absent key ends it, and an empty value is stored, matching what SetTopicAttributes does with an empty AttributeValue. The bare DisplayName parameter is no longer read: the page publishes exactly four request parameters — Attributes, DataProtectionPolicy, Name and Tags.member.N — and honoring a fifth let a consumer write a call that round-tripped here and created a topic with no display name on AWS.
CreateTopic publishes 24 keys where SetTopicAttributes publishes 25. The lists are otherwise identical, name for name and group for group; the single difference is the server-side-encryption group, where SetTopicAttributes lists KmsMasterKeyId andSignatureVersion and CreateTopic lists KmsMasterKeyId alone. So SignatureVersion on a create is InvalidParameter/400 and on a set is accepted, and a topic that needs one gets it from the operation whose page publishes it. That asymmetry is AWS's, and substrate keeps two allowlists rather than one so that it cannot be collapsed by accident.
The refusal is the same code and the same shape as the set side's, and it is decoded before the topic index is read — so a refused create leaves no topic behind, and the answer does not depend on whether a topic of that name already exists.
AWS::SNS::Topic's DisplayName property travels through this map, as the wire form a real CreateTopic carries. The type's other attribute-valued properties are not forwarded; see the CloudFormation section for what it does send.
Subscription attributes: six settable, seven derived, one inert
SetSubscriptionAttributes was a stub until #1125 — it read SubscriptionArn into _, read neither AttributeName nor AttributeValue, touched no state and answered 200 — and GetSubscriptionAttributes answered a fixed four entries built from the record, so nothing a caller set could ever be read back. Both halves now go through one merge.
Settable (6). Exactly the AttributeName values API_SetSubscriptionAttributes publishes: DeliveryPolicy, FilterPolicy, FilterPolicyScope, RawMessageDelivery, RedrivePolicy and SubscriptionRoleArn. Anything else is InvalidParameter/400.
Six, not seven. ReplayPolicy and ReplayStatus appear on API_Subscribe, under a heading reading "The following attributes apply only to FIFO topics", and on neither of the two attribute pages — so substrate refuses both here. The ReplayLimitExceeded/403 this page does publish is an error shared with Subscribe and says nothing about which names the operation accepts.
The Firehose-only qualification on SubscriptionRoleArn is recorded and not enforced: the page states the attribute "applies only to" Firehose subscriptions but publishes no error for setting it elsewhere, so substrate accepts it on any protocol rather than inventing a refusal.
Derived (7). ConfirmationWasAuthenticated, Endpoint, Owner, PendingConfirmation, Protocol, SubscriptionArn and TopicArn are projected from the record and merged over the stored map, so a stored value cannot shadow one. PendingConfirmation is false and ConfirmationWasAuthenticated is true because substrate mints no unconfirmed subscription — there is no ConfirmSubscription operation, and Subscribe returns a real ARN rather than the "pending confirmation" string — so a subscription is confirmed by the authenticated Subscribe call itself and never by an out-of-band token. This is the same reasoning that makes a topic's SubscriptionsPending"0".
Protocol and Endpoint are reported although API_GetSubscriptionAttributes' list omits them. That list is explicitly open ("Attributes in this map include the following"), both are Subscribe parameters a caller has no other way to read back for a single subscription, and ListSubscriptions already reports both for the same record — dropping them would make two readers disagree about one subscription.
Not modelled. EffectiveDeliveryPolicy is omitted. The page defines it as the policy "that takes into account the topic delivery policy and account system defaults", and substrate models neither the defaults nor the merge; reporting the subscription's own DeliveryPolicy under the name would claim a computation that did not happen, so the member is absent rather than invented (#827).
A stored FilterPolicy does not filter delivery. It round-trips through GetSubscriptionAttributes as the JSON string the caller sent, and Publish delivers to the subscription regardless of whether a message would match. Delivering only matching messages is the subscription's runtime behaviour rather than an API observation, so it stays outside the boundary — the same reading that keeps a Lambda's handler from being executed. SNSSubscription carries a separate FilterPolicy field that Publish does consult, but no operation writes it: it is reachable only by a test writing the record directly. A consumer asserting "a non-matching message was not delivered" will therefore see it delivered here, which is the deliberate divergence.
ARN refusals. Both operations answer InvalidParameter/400 for a string that is not a subscription ARN and NotFound/404 for one that names no subscription, in that order. Before #1125 neither had a site for the first: a topic ARN — a perfectly good SNS ARN naming no subscription — was reported as a subscription that did not exist. Unsubscribe still answers NotFound/404 rather than InvalidParameter/400 for a malformed ARN; that remainder is #1259.
An empty result element is not the same as no result element
Eight SNS operations answer with a body carrying no members, and the query protocol spells that two different ways. Whether <{Operation}Response> holds an <{Operation}Result> element at all is decided by the operation's modeled output:
- an output of
smithy.api#Unithas no result element —DeleteTopic,SetTopicAttributes,Unsubscribe,SetSubscriptionAttributes,AddPermissionandRemovePermission; - an output that is an empty structure has the element, empty —
TagResourceandUntagResource.
AWS publishes both halves as sample responses: API_TagResource and API_UntagResource show <TagResourceResult/> and <UntagResourceResult/>, while API_DeleteTopic, API_Unsubscribe and API_AddPermission show <ResponseMetadata> as the response's only child. Substrate emitted the second shape for all eight, which is right for six of them (#1141).
The two tag operations therefore could not be called through an SDK at all, even though they did their work. aws-sdk-go-v2's generated deserializer looks the element up by name and fails the operation when it is absent rather than treating a missing empty element as an empty one:
operation error SNS: TagResource, https response error StatusCode: 200, RequestID: ,
deserialization failed, failed to decode response body, TagResourceResult node not foundThe tag was already written when that error was returned, so state was right and only the envelope was wrong — and every caller that checks its error treated a successful tag as a failure. A create → converge → tag deployment sequence could not get past its last step.
A hand-written client cannot see the difference, because the element carries nothing, which is why the omission survived until a real SDK reached it. The sweep for further instances found none: the SDK's SNS deserializer requires a result element for 31 operations and none of the six Unit ones is among them, and no other query-protocol service in the tree omits an element its own page publishes.
CloudFormation resource types
| Type | Ref | Notes |
|---|---|---|
| AWS::SNS::Topic | TopicArn | TopicName and DisplayName are sent; other attribute-valued properties are not |
| AWS::SNS::Subscription | SubscriptionArn |
DisplayName travels as CreateTopic's published Attributes.entry.1.key / .value — see CreateTopic's Attributes map above. It is the only one of the type's attribute-valued properties the deployer forwards. AWS::SNS::Topic publishes fourteen properties, and the deploy reads two: TopicName and DisplayName. So ArchivePolicy, ContentBasedDeduplication, FifoThroughputScope, KmsMasterKeyId, MaximumMessageSize, SignatureVersion and TracingConfig are accepted in a template and reach no CreateTopic parameter — GetTopicAttributes reports none of them for a topic CloudFormation created — and neither Tags, the inline Subscription array, DataProtectionPolicy nor DeliveryStatusLogging is sent. FifoTopic is likewise not read, so a .fifo topic declared in a template is a standard topic here.
Cost
SNS publish: $0.0000005 per message.
Secrets Manager
Endpoint: secretsmanager.{region}.amazonaws.comProtocol: JSON (X-Amz-Target: secretsmanager.{Op})
Supported operations
| Operation | Notes |
|---|---|
| CreateSecret | Tags are stored key-ordered, so two identical runs report them alike |
| GetSecretValue | Returns SecretString or SecretBinary; refuses a secret scheduled for deletion — see A deleted secret is scheduled, not removed |
| PutSecretValue | Creates a new version; refuses a secret scheduled for deletion — see A deleted secret is scheduled, not removed |
| UpdateSecret | Rewrites Description, KmsKeyId and the value; refuses a secret scheduled for deletion — see A deleted secret is scheduled, not removed |
| DeleteSecret | Opens a 7-to-30-day recovery window, defaulting to 30, rather than removing the secret; ForceDeleteWithoutRecovery removes it — see A deleted secret is scheduled, not removed |
| RestoreSecret | Clears the DeletionDate and answers ARN and Name only |
| ListSecrets | Base64 offset pagination; scoped to the caller's account and Region |
| DescribeSecret | The read path for a secret's tags; reports only the members it has a value for, plus DeletedDate while a recovery window is open |
| ListSecretVersionIds | Reports the current version only |
| TagResource | Appends to the existing list rather than replacing it; refuses a secret scheduled for deletion — see A deleted secret is scheduled, not removed |
| UntagResource | Idempotent — an absent key is not an error — but a secret scheduled for deletion is refused even then, see A deleted secret is scheduled, not removed |
| RotateSecret | Records the rotation function and schedule and echoes ClientRequestToken as VersionId; no rotation function is executed, and a secret scheduled for deletion is refused — see A rotation is configured, not run |
A SecretId addresses the secret its own ARN names
SecretId is documented as "the ARN or name of the secret", and the two halves have to be resolved differently. An ARN carries an account and a Region; a bare name carries neither, so for a name — and only for a name — the caller's own account and Region are the correct source.
Substrate read the caller's for both. The resolver split an identifier on : and returned the last segment, and every one of the ten SecretId-taking operations then keyed its load and its store by ctx.AccountID and ctx.Region. So arn:aws:secretsmanager:eu-west-1:999988887777:secret:db-password, presented by a us-east-1 caller in another account, addressed that caller's own db-password: GetSecretValue read its value, DeleteSecret deleted it, and UntagResource answered 200 while stripping its tags. That is the rule #826 settled for SQS and #928 applies here — the account and Region come from the ARN, never from the request context.
Three further checks were missing and compounded it. The service segment was never compared, so an ARN belonging to another service resolved to its own last segment as a secret name. The type keyword was never compared either, so arn:aws:secretsmanager:{region}:{account}:other:foo named a secret called foo; secret is now matched as a whole segment rather than as a prefix, per #910, so secretpolicy and any keyword AWS adds later are refused rather than accepted. And the malformed case fell through to returning its argument, so an ARN with too few segments became a name and was looked up as one. Anything beginning arn: that does not parse is now InvalidParameterException, because a caller who wrote an ARN prefix meant an ARN and a silent name lookup turns a typo into a 200 against the wrong resource.
The separation is structural rather than a convention each handler has to remember: the ARN parser takes no account, Region or request context as parameters at all, which is the arrangement Step Functions (#910), CloudFront (#918), KMS (#922) and SNS (#925) each settled on. The name is the whole remainder after the keyword, so a hierarchical prod/db/password round-trips through its own ARN where taking the last component would truncate it to password.
DeleteSecret had the same defect in its index: it removed the name from the caller's account and Region index while deleting the record the ARN named, so the owning Region went on listing a secret whose record had just been removed. Both now address the target.
Why the ARN-to-name derivation is exact, and what substrate deliberately does not model. AWS appends a hyphen and six random characters to a secret's name when it mints the ARN, and warns "do not end your secret name with a hyphen followed by six characters" — because a name may itself end that way, so trimming the suffix back off cannot be done in general. Substrate mints no suffix, which is precisely what makes the name recoverable from the ARN here. Adding one would make this resolver ambiguous by construction and would change a value CloudFormation records as a physical ID, so it is a separate decision rather than part of the fix.
An absent secret is a 400, and DescribeSecret reports only what it has a value for
ResourceNotFoundException is 400. Substrate answered 404 at all nine SecretId-taking operations until #930 — a status Secrets Manager publishes nowhere. API_DescribeSecret's error list is three codes long: InternalServiceError at 500, InvalidParameterException at 400, and ResourceNotFoundException at 400, glossed "Secrets Manager can't find the resource that you asked for". Every other operation that takes a SecretId publishes the same status. This is the defect ACM carried at one site (#921) and KMS at fifteen (#923), and the fix is the same shape: one constructor chooses the status, so a tenth operation added later cannot disagree with the reference page by accident.
DescribeSecret omits a member it has no value for — but AWS says two different things, and the difference is observable. The blanket rule is one sentence: "Secrets Manager only returns fields that have a value in the response". The per-member text does not distribute uniformly over it, so the members fall into three tiers, and substrate keeps them apart deliberately:
| Tier | Members | Treatment |
|---|---|---|
| AWS states "this field is omitted" | DeletedDate, KmsKeyId, LastAccessedDate, RotationRules | Absent — DeletedDate is emitted while a recovery window is open (#953), and RotationRules once RotateSecret has configured a schedule (#952) |
| AWS states "Secrets Manager returns null" | LastRotatedDate, NextRotationDate, RotationEnabled | Emitted as JSON null |
| AWS states nothing per member | ARN, CreatedDate, Description, LastChangedDate, Name, Tags, and the rest | Absent when empty — substrate's reading, on the blanket sentence alone |
KmsKeyId is omitted on AWS's own statement ("If the secret is encrypted with the AWS managed key aws/secretsmanager, this field is omitted"). Description is omitted on substrate's reading of the blanket sentence, which is a weaker basis and is recorded as such rather than presented as matching AWS — #930's own criterion named it as though AWS had stated it, and the page does not.
RotationEnabled needed no decision at all, because AWS published one: "If the secret has never been configured for rotation, Secrets Manager returns null." So it is emitted either way, as null before a RotateSecret and true after — never false, which is a claim AWS does not make. Substrate needs no extra state to answer that correctly: rotation is set by RotateSecret and by nothing else, and there is no CancelRotateSecret, so "false" and "never configured" are the same condition.
Seven of the twenty-one published response members are absent because substrate models no value for them, which the same sentence makes correct rather than a gap: LastAccessedDate, LastRotatedDate, NextRotationDate, OwningService, PrimaryRegion, ReplicationStatus and VersionIdsToStages. DeletedDate left that list in #953 and RotationLambdaARN and RotationRules in #952 — which is also what makes the count true, the list having named ten when it was nine. So are the three managed-external-secret members — Type, ExternalSecretRotationRoleArn and ExternalSecretRotationMetadata — which belong to a partner integration substrate models nothing of.
LastRotatedDate and NextRotationDate stay absent even now that a schedule is recorded, and the reason is the boundary rather than the effort: both report when a rotation happened, and no rotation happens here. AWS documents them as nulled rather than omitted, so if either is ever modelled it belongs beside RotationEnabled.
Tags. DescribeSecret is the read path, because Secrets Manager publishes no ListTagsForResource. Substrate answered one until #929 removed it: the API publishes twenty-three operations and that is not among them, so the name now falls to UnknownOperationException, which is what a caller reaching for it against real AWS gets. An untagged secret omits the Tags member entirely rather than sending null.
A deleted secret is scheduled, not removed
DeleteSecret does not delete a secret. AWS "attaches a DeletionDate stamp to the secret that specifies the end of the recovery window", and only "at the end of the recovery window" is the secret deleted permanently; the window is 7 to 30 days and defaults to 30, and "at any time before recovery window ends, you can use RestoreSecret to remove the DeletionDate and cancel the deletion of the secret".
Substrate removed the record, the version payload and the index entry on every call — the one behaviour AWS reserves for ForceDeleteWithoutRecovery: true — and decoded neither of the two parameters that choose between them. Three consequences followed (#953). The destructive variant was the only variant, so a consumer could not test the default path at all. RestoreSecret was not implemented, and had nothing to restore if it had been. And a secret scheduled for deletion and a secret that never existed were the same observation: both answered ResourceNotFoundException, so a caller's error handling could not tell "restore this" from "this was never here".
A recovery window is in scope under the boundary in doc.go: it is a state transition observable through an API call, stamped off the simulated clock, so a schedule-then-restore path is assertable with no dependence on wall-clock time.
| Call | Answer |
|---|---|
DeleteSecret with neither parameter | 200, DeletionDate 30 days out; the secret is still listed and still described |
DeleteSecret with RecoveryWindowInDays 7–30 | 200, DeletionDate that many days out |
DeleteSecret with a window outside 7–30 | InvalidParameterException/400, nothing written |
DeleteSecret with both parameters present | InvalidParameterException/400, nothing written |
DeleteSecret on an already-scheduled secret, unforced | InvalidRequestException/400 naming the scheduled-for-deletion cause |
DeleteSecret with ForceDeleteWithoutRecovery: true | 200; record, current version payload and index entry all removed |
DeleteSecret forced, on an absent or already-deleted secret | 200 — see below |
GetSecretValue on a scheduled secret | InvalidRequestException/400, distinguishable from ResourceNotFoundException |
PutSecretValue on a scheduled secret | InvalidRequestException/400; no version is stored and CurrentVersionID does not move |
UpdateSecret on a scheduled secret | InvalidRequestException/400; Description, KmsKeyId and the value are all unchanged |
TagResource on a scheduled secret | InvalidRequestException/400; the tag list is unchanged |
UntagResource on a scheduled secret | InvalidRequestException/400, even when no named key is attached; the tag list is unchanged |
RotateSecret on a scheduled secret | InvalidRequestException/400; no rotation function or schedule is recorded |
DescribeSecret on a scheduled secret | 200 with DeletedDate; the member is absent otherwise |
RestoreSecret | 200 with ARN and Name only; the stamp is cleared and all seven refusals lift at once |
Four points where the reasoning is not simply AWS's prose:
Refusing both parameters turns on their presence, which is substrate's reading. AWS states the exclusion twice, once under each parameter, and states it over use: "You can't use both this parameter and ForceDeleteWithoutRecovery in the same call." So ForceDeleteWithoutRecovery: false alongside a window is refused here too. The alternative — treating an explicit false as an omission — would silently open a 30-day window for a caller who asked for an immediate delete, which is the more damaging direction to guess in.
A forced delete of a secret that is not there answers 200, and the body is substrate's reading. The sentence is explicit — "if you forcibly delete an already deleted or nonexistent secret, the operation does not return ResourceNotFoundException" — but AWS publishes the suspension of the code without publishing the body answered instead, so substrate reports the ARN the identifier names and a DeletionDate at the request. This is the path a CloudFormation stack teardown takes twice: substrate's own deleter has always sent ForceDeleteWithoutRecovery: true for AWS::SecretsManager::Secret, citing "the default behavior of CloudFormation is to delete the secret with the ForceDeleteWithoutRecovery flag" — a parameter nothing decoded until #953, so that comment describes what the tree does only now.
The stamp has two names, and substrate follows AWS rather than tidying.DeleteSecret answers it as DeletionDate; DescribeSecret reports the same value as DeletedDate.
The permanent deletion at the end of the window is deliberately not modelled. That is a decision, not a gap: AWS publishes no guarantee to model — "there is no guarantee of a specific time after the recovery window for the permanent delete to occur" — so a secret whose DeletionDate has passed is still reported, still stamped, still withholding its value, and still restorable. A test asserting it had vanished at some simulated instant would assert something AWS explicitly declines to promise. What is modelled is the stamp and the refusals it causes.
RestoreSecret brings the SecretId-taking operations to ten, and it refuses an absent secret through the same 400 constructor as the other nine, so it cannot disagree with them. Restoring a secret that is not scheduled succeeds, which is substrate's reading: API_RestoreSecret publishes InvalidRequestException but its cause list names only the three conditions shared across the service, none of which is "not scheduled", so there is no published code to refuse with — and inventing one would make an idempotent restore fail here and succeed against AWS.
Seven operations refuse the stamp, and one shared constructor builds all seven refusals.GetSecretValue and the unforced DeleteSecret came with the recovery window itself, RotateSecret with #952, and PutSecretValue, UpdateSecret, TagResource and UntagResource with #956 — each of whose pages publishes "The secret is scheduled for deletion." as the first of InvalidRequestException's three possible causes. Until then a secret inside its recovery window could take a new version, have its description, KMS key and value rewritten, and be retagged, so a consumer's "is this secret usable?" check got a 200 here and a 400 from AWS at four operations, and a RestoreSecret afterwards returned a secret carrying writes AWS would have refused. RestoreSecret is the one operation publishing the cause that does not refuse on it, for the obvious reason: it is what clears the stamp.
One refusal reads two AWS sentences against each other, and the resolution is substrate's. UntagResource is documented idempotent — "if a requested tag is not attached to the secret, no error is returned and the secret metadata is unchanged" — but that sentence is about which keys are present, whereas InvalidRequestException reports "a parameter value is not valid for the current state of the resource", which the stamp decides. So the scheduled refusal outranks the idempotency, and an UntagResource naming no attached key is still refused on a scheduled secret.
A rotation is configured, not run
RotateSecret read one of its seven request parameters, omitted the response member all four of AWS's samples return, and refused nothing. That was worse than a thin response: the handler set rotation enabled on any secret it could load, so DescribeSecret reported rotation configured for a secret with no rotation function — the one state AWS refuses to create. A consumer's nominal path was substrate's nominal path, and its error path was unreachable.
Substrate records which function a rotation would run and which schedule it would run on, and runs neither. That is the boundary: a rotation function's execution is workload-internal and out of scope, while which function and schedule a caller configured is observable through DescribeSecret and so belongs here.
| Call | Answer |
|---|---|
RotateSecret with a ClientRequestToken and a RotationLambdaARN | 200 with ARN, Name and VersionId |
RotateSecret with no ClientRequestToken | InvalidParameterException/400, nothing written |
RotateSecret on a secret with no rotation function, naming none | InvalidRequestException/400 naming the cause, nothing written |
RotateSecret naming no function on a secret that already has one | 200 — the stored function is used and is not cleared |
RotationRules with both AutomaticallyAfterDays and ScheduleExpression | InvalidParameterException/400, nothing written |
AutomaticallyAfterDays outside 1–1000 | InvalidParameterException/400, nothing written |
RotateSecret on a secret in its recovery window | InvalidRequestException/400 — distinguishable from ResourceNotFoundException |
VersionId is derived from the request, not minted. All four of AWS's examples state it in the same words — "the ClientRequestToken field becomes the VersionId of the new version created during the rotation" — and all four sample responses return the token that was sent. So the value is reproducible on replay by construction rather than by a determinism mechanism. No version payload is written for it: producing the new secret value is the rotation function's job, and inventing one would be inventing a secret.
An omitted ClientRequestToken is refused, and that is substrate's reading. The parameter is "Required: No" because "the CLI or SDK generates a random UUID for you", but the page is explicit about the caller substrate is: "if you generate a raw HTTP request to the Secrets Manager service endpoint, then you must generate a ClientRequestToken and include it in the request." Minting one would put a nondeterministic value in a response body, and echoing an empty one would answer a VersionId of "" where AWS publishes a minimum length of 32.
Two of InvalidRequestException's three published causes are refused. The first, "the secret is scheduled for deletion", became decidable when #953 modelled the recovery window. The second, "you tried to enable rotation on a secret that doesn't already have a Lambda function ARN configured and you didn't include such an ARN as a parameter in this call", is decided from the record and the request together — which is why a bare call is refused on a fresh secret and succeeds on one already configured. Note RotationLambdaARN's published length minimum of 0: an explicit "" is not naming an ARN, so it is refused too. The third cause, "the secret is managed by another service", needs OwningService, which substrate models nothing of, so it is not refused — recorded here rather than left silent. The same absence is why the refusal is unconditional: a secret using AWS's managed rotation legitimately has no ARN, but such a secret is an OwningService secret and none can exist here.
The mutual exclusion inside RotationRules is AWS's rule — "in RotateSecret, you can set the rotation schedule in RotationRules with AutomaticallyAfterDays or ScheduleExpression, but not both" — while the code is substrate's reading, AWS publishing none for the violation. It is refused on the parameters' presence, following DeleteSecret's precedent above and for the same reason: the alternative silently picks one of two schedules the caller asked for.
Recorded rather than enforced: the length constraints on ClientRequestToken (32–64) and RotationLambdaARN (≤2048), and the patterns on Duration ([0-9]+h) and ScheduleExpression. Substrate does not validate parameter lengths or patterns as a rule, and enforcing them at this one operation would refuse a token every sibling operation accepts. RotateImmediately is recorded on the secret and reported by nothing, which is not an omission — the difference AWS describes between true and false is whether the function runs now or at the next window, and no function runs — but the state snapshot is itself an observable under replay and time-travel inspection, so the intent survives.
AWS::SecretsManager::RotationSchedule passes the template's rotation function and schedule through. It sent SecretId alone, which stopped being merely thin once the refusal above existed: a template naming a RotationLambdaARN would have been refused for naming none. The ClientRequestToken a deploy has no caller to supply is derived from the account, Region, stack name and logical ID, so a redeploy sends the same token rather than a fresh one. HostedRotationLambda's RotationLambdaName is composed into a function ARN in the deploying account and Region — substrate's reading, and the narrowest available: a hosted rotation lambda is a function CloudFormation creates through a nested stack, which substrate does not do, so nothing exists at that ARN. What the pass-through buys is a deployable template instead of a failed resource.
CloudFormation resource types
| Type | Ref | Notes |
|---|---|---|
| AWS::SecretsManager::Secret | SecretArn | |
| AWS::SecretsManager::RotationSchedule | the secret's ARN | Passes RotationLambdaARN, RotationRules and RotateImmediatelyOnUpdate through to RotateSecret with a derived ClientRequestToken |
Cost
Secrets Manager API calls: $0.05 per 10,000 API calls.
SSM Parameter Store
Endpoint: ssm.{region}.amazonaws.comProtocol: JSON (X-Amz-Target: AmazonSSM.{Op})
Supported operations
| Operation | Notes |
|---|---|
| PutParameter | Supports String, StringList, SecureString types; normalizes Name to a leading / |
| GetParameter | Supports WithDecryption; reports the parameter's ARN |
| GetParameters | Batch get |
| GetParametersByPath | Recursive path traversal; paginates on MaxResults/NextToken and refuses a NextToken substrate did not issue with InvalidNextToken — see A pagination token substrate never issued |
| GetParameterHistory | |
| DeleteParameter | |
| DeleteParameters | |
| DescribeParameters | Paginates on MaxResults/NextToken; refuses a NextToken substrate did not issue with InvalidNextToken — see A pagination token substrate never issued |
| AddTagsToResource | ResourceType is required and is resolved — see below |
| RemoveTagsFromResource | Removes only the named keys |
| ListTagsForResource | Reports TagList sorted by key; an empty list, never null |
| LabelParameterVersion | Accepted; always reports ParameterVersion: 1 |
| SendCommand | Run Command; records the intent — substrate does not execute the command |
| GetCommandInvocation | |
| DescribeInstanceInformation |
How a (ResourceType, ResourceId) pair resolves
ResourceType is Required: Yes at all three tag operations, over the ten-value enum AWS publishes. Substrate reads it (#932); it previously decoded the member and ignored it, so any type resolved to the same-named Parameter Store parameter:
| Request | Answer |
|---|---|
ResourceType: "Parameter", ResourceId: "/db/password" | The parameter /db/password |
ResourceType: "Parameter", ResourceId: "db/password" | The same parameter — the leading / is supplied |
ResourceType: "Parameter", ResourceId: "arn:aws:ssm:…" | InvalidResourceId/400, not normalized into a name (#928) |
ResourceType: "Document" and the eight other published types | InvalidResourceId/400 — the type is real, the resource is not modelled here |
ResourceType: "parameter", "Parameters", or anything outside the enum | InvalidResourceType/400, listing the ten valid values |
ResourceType absent | ValidationException/400 |
ResourceId is the parameter name, not an ARN: AWS states "For the Document and Parameter values, use the name of the resource". It therefore carries no account or Region, and the caller's own supply them — the one arm in the tagging set where that is correct rather than a defect. The Resource Groups Tagging API's arm for the same parameter takes an ARN and takes account and Region from it; both arms build the same state key, so the two cannot disagree about which parameter an identifier names. See Resource Groups Tagging.
The leading-slash tolerance is substrate's reading, not AWS's: AWS documents it only for OpsMetadata. Substrate needs it because PutParameter normalizes Name to a leading /, so a caller who created MyParam must be able to tag MyParam.
InvalidResourceId is answered at HTTP 400, the status all three reference pages give it: "The resource ID isn't valid. Verify that you entered the correct ID and try again. HTTP Status Code: 400". Systems Manager publishes no distinct not-found code for these operations — InvalidResourceId is how a nonexistent resource is reported — and no 404 at all, so the status is not a thing a caller can branch on and the code is the whole signal. Substrate models no tag-count cap here, where AWS caps most resources at 50 tags and Automations at 5.
A body that will not parse answers ValidationError/400, at all twelve operations that decode one. Two of the twelve — SendCommand and GetCommandInvocation — answered SerializationException with encoding/json's own error text as the message, so a caller was told about the emulator's decoder and the Go type it was unmarshalling into; the other ten answered InvalidRequest. Neither code appears anywhere in Systems Manager's documentation. All twelve operation pages were read and none publishes an error for a request that could not be read at all, so the code comes from the common-errors page — see A request body that will not parse above for why, and for the two published near misses that are not the answer (#950).
AWS's public AMI parameters are answered
A /aws/service/… path whose shape says "AMI" is answered without anyone having put it there, because that is how AWS documents AMI discovery — a template or a script reads /aws/service/ami-amazon-linux-latest/… rather than hardcoding an ID. The value is the AMI ID substrate resolves for that parameter in the request's region, and it is the same ID RunInstances accepts: the two answers come from one table, so GetParameter cannot hand out an AMI the launch then refuses. See Which AMIs resolve for the paths and the derivation.
A parameter the caller wrote wins over a managed one at the same path, so PutParameter can override any of these.
CloudFormation resource types
| Type | Ref | Notes |
|---|---|---|
| AWS::SSM::Parameter | ParameterName |
Cost
SSM standard parameters are free. Advanced parameters: $0.05 per 10,000 API interactions.
KMS
Endpoint: kms.{region}.amazonaws.comProtocol: JSON (X-Amz-Target: TrentService.{Op})
Supported operations
| Operation | Notes |
|---|---|
| CreateKey | Answers the same KeyMetadata shape DescribeKey does, from the same builder; validates KeySpec/KeyUsage and their pairing, and refuses an Origin, CustomKeyStoreId or XksKeyId substrate does not model rather than discarding it; records the Policy it was given, and refuses one outside 1–32768 bytes or not a JSON object — see below |
| DescribeKey | Accepts all four KeyId forms; answers 16 of KeyMetadata's 26 members, including AWSAccountId, KeyManager, Origin and the key's algorithm list; reports DeletionDate while a key is pending deletion, and reports no rotation flag — see below |
| ListKeys | |
| EnableKey | Refuses a key pending deletion, so recovery stays two calls — see below |
| DisableKey | Same refusal as EnableKey |
| ScheduleKeyDeletion | Waiting period range-checked at 7–30; refuses a key already pending deletion — see below |
| CancelKeyDeletion | Requires a key pending deletion, and leaves it Disabled — see below |
| GetKeyPolicy | Answers AWS's default key policy for a key that has never been given one, naming the key's own account root; refuses a KeyId naming no key and a PolicyName other than default — see below |
| PutKeyPolicy | Refuses a Policy outside 1–32768 bytes and one that is not a JSON object, both before the key is read; refuses a PolicyName other than default; accepts a key in every state, including pending deletion — see below |
| GetKeyRotationStatus | Reports the bare key ID and, while rotation is on, RotationPeriodInDays and NextRotationDate; answers false for a key pending deletion and answers in every key state substrate can produce — see below |
| EnableKeyRotation | Range-checks RotationPeriodInDays at 90–2560 and stores it with the date it ran; refuses a key whose spec is not SYMMETRIC_DEFAULT, and refuses a disabled key and a key pending deletion with a different code for each — see below |
| DisableKeyRotation | Same refusals as EnableKeyRotation; leaves the stored rotation period and enable date alone |
| TagResource | Tags are keyed TagKey/TagValue, not Key/Value |
| UntagResource | |
| ListResourceTags | |
| CreateAlias | Refuses a TargetKeyId outside the caller's account and Region, one naming no key, and one pending deletion; enforces its own published AliasName pattern and the reserved alias/aws/ prefix; refuses a name the account and Region already hold — see below |
| DeleteAlias | Accepts a name with or without the alias/ prefix, because its published pattern — unlike the other two — does not require it; answers 200 for an alias that does not exist — see below |
| UpdateAlias | Refuses an alias that does not exist, a TargetKeyId outside the caller's account and Region, one naming no key, and one pending deletion; refuses a move between two key types or two key usages. The alias's current key may be pending deletion — see below |
| ListAliases | Reports one entry per alias, so a repeated CreateAlias no longer duplicates a row |
| Encrypt | Returns ciphertext blob (base64-encoded stub); reports the EncryptionAlgorithm used and refuses one the key's spec does not admit; refuses a disabled key and a key pending deletion, with a different code for each; refuses a Plaintext outside the published 1–4096 bytes before the key is read, and one past the smaller per-spec maximum after — see below |
| Decrypt | Returns plaintext (stub pass-through); same algorithm handling and same refusals as Encrypt, and refuses a KeyId naming a key other than the ciphertext's — see below |
| GenerateDataKey | Same refusals as Encrypt, minus the algorithm members, plus a refusal of any key spec but SYMMETRIC_DEFAULT — see below |
| GenerateDataKeyWithoutPlaintext | Same refusals as GenerateDataKey, through the same helper |
| ReEncrypt | Checks the key state, the named key and the encryption algorithm of both keys independently, and reports the source key's ARN and both algorithms — see below |
A KeyId is resolved from its own ARN, not from the caller
AWS's KeyId parameter accepts four forms, and substrate accepts all four:
| Form | Example | Account and Region come from |
|---|---|---|
| Key ID | 1234abcd-12ab-34cd-56ef-1234567890ab | the request |
| Key ARN | arn:aws:kms:us-west-2:111122223333:key/1234abcd-… | the ARN |
| Alias name | alias/prod | the request |
| Alias ARN | arn:aws:kms:us-west-2:111122223333:alias/prod | the ARN |
The two ARN forms take their account and Region from the ARN, and the parser that reads them takes no request context at all, so that is structural rather than remembered at each of eighteen call sites. Before #922 every one of those sites keyed its load and its store by the caller's account and Region, so a key ARN naming another account addressed the caller's own key of that ID — and UntagResource stripped its tags while answering 200.
The type is the resource portion's first /-delimited segment, compared whole. That matters twice. An alias ARN's resource portion is alias/{name}, so taking the last component resolved …:alias/prod to the key ID prod; and an alias name may itself contain a slash — AWS's own example of an AWS managed key's alias is alias/aws/s3 — so a parser that refused a slashed identifier would be wrong in the other direction. The cut is on the first slash only, and a further slash is refused for a key and allowed for an alias.
A bare identifier is not treated as a malformed ARN. It is a key ID, and one that names nothing is not-found; answering InvalidArnException would tell a caller its ARN was wrong when it sent no ARN.
Two refusals follow from the resolution rather than preceding it. All three tagging operations publish "Cross-account use: No. You cannot perform this operation on a KMS key in a different AWS account", and the developer guide adds "You cannot tag aliases, custom key stores, AWS managed keys, AWS owned keys, or KMS keys in other AWS accounts" — so a key outside the caller's account or Region is refused with NotFoundException. While the account came from the request context that prohibition had nothing to refuse, because a foreign ARN could only ever reach a local record. CreateAlias and UpdateAlias refuse the same way, because an alias is scoped to one account and one Region and a written cross-Region pointer is a dangling one that ListAliases reports and every later resolution fails on. Mapping the prohibition onto NotFoundException is substrate's reading: AWS publishes the prohibition and the code but does not join them.
One thing this deliberately leaves alone, so the residue is recorded rather than discovered: the other fifteen KeyId operations do not enforce the Region, so DescribeKey on a foreign-Region key ARN answers with that key — strictly better than describing a local impostor, but real KMS would refuse, and AWS publishes no per-operation statement to cite for the other fifteen.
The three alias operations do not share one set of rules
Until #1085 the two alias writers verified nothing at all. UpdateAlias decoded two members, prepended alias/ if it was missing and wrote a state key — so an alias that did not exist was created by the operation whose first published sentence is "Associates an existing AWS KMS alias with a different KMS key", a TargetKeyId naming no key produced a dangling pointer that every later resolution of the alias failed on, a key scheduled for deletion could take the alias, and a symmetric key's alias could be moved to an RSA one. CreateAlias checked the same nothing, so all four gaps existed twice in one plugin.
Closing them meant reading all three pages, and the finding is that they publish three different AliasName rules and three different sets of codes:
| Operation | Pattern | Codes published for a name |
|---|---|---|
| CreateAlias | ^alias/[a-zA-Z0-9/_-]+$ | InvalidAliasNameException, LimitExceededException |
| UpdateAlias | ^alias/[a-zA-Z0-9/_-]+$ | LimitExceededException |
| DeleteAlias | ^[a-zA-Z0-9:/_-]+$ | none |
So the alias/ prepend is gone from the two writers whose pattern requires the prefix and stays at DeleteAlias, whose pattern does not require it and admits a colon besides. API_DeleteAlias's own prose still says the name "must begin with alias/", so that page contradicts itself; substrate takes the machine-readable half. All three publish the same 1–256 length bound.
InvalidAliasNameException is published on CreateAlias alone, and that is not a gap on UpdateAlias: the alias must already exist, and no alias failing CreateAlias's pattern can ever have been created, so a malformed name there is answered by the NotFoundException UpdateAlias does publish. Nothing is borrowed across pages. Over-length is LimitExceededException at both, by its own gloss — "a length constraint or quota was exceeded" — and it is checked before the alias is looked up, so the length code wins over the not-found one.
Only the new target's key state is checked, and the operation pages do not say so. Both say merely "The KMS key that you use for this operation must be in a compatible key state", which reads as one condition over one key. The developer guide's key-state table resolves it into two: CreateAlias carries the footnote "KMSInvalidStateException: <key ARN> is pending deletion", while UpdateAlias carries "If the source KMS key is pending deletion, the command succeeds. If the destination KMS key is pending deletion, the command fails." So an alias whose current key is scheduled for deletion may still be re-pointed — which is exactly what a caller does to rescue it — and a single "check the key state" guard would break that. A disabled key is accepted at both: the table gives both a checkmark for it and neither page publishes DisabledException.
The type-match refusal is substrate's reading. AWS publishes the restriction twice on API_UpdateAlias, verbatim in both places — "The current and new KMS key must be the same type (both symmetric or both asymmetric or both HMAC), and they must have the same key usage. This restriction prevents errors in code that uses aliases." — and none of the operation's five published errors describes it. Substrate answers ValidationError/400, following the two in-tree precedents for a malformed KMS request (kmsUnknownKeySpec, kmsUnknownKeyUsage) rather than borrowing a code from a sibling page. The message names which half failed, because the code cannot: a deploy that moved an alias across types needs to know whether it was the family or the usage. The three families are derived from the algorithm tables the rest of the plugin already reads, not listed a fourth time.
Two divergences recorded rather than fixed here. TargetKeyId is published as "Specify the key ID or key ARN", and substrate additionally accepts an alias there, because the shared resolver handles all four KeyId forms; a caller relying on that is relying on something AWS does not publish. And DeleteAlias answers 200 for an alias that does not exist where its page publishes NotFoundException/400. That one is #1107 rather than part of this change, because it turns an idempotent teardown into a failing one: CloudFormation stack deletion calls it for an alias that may already be gone, so the refusal needs a tolerance on the teardown path before it can land.
Every KMS refusal is a 400, because KMS publishes no 404
Across every KMS operation substrate models, AWS's reference publishes exactly two HTTP statuses: 500 for DependencyTimeoutException, KMSInternalException and KeyUnavailableException, and 400 for everything else. There is no 404 for a missing key, alias or destination key on any operation. The only 404 anywhere in KMS's documentation is UnknownOperationException on the common-errors page, which reports that the action name was not recognised — a different failure entirely.
So a KMS status carries nothing a caller can branch on, and the code is the whole signal. Substrate answered three codes at a status KMS does not publish, and one of the three was not a KMS code at all (#923):
| Code | Was | Is | Sites | Provenance |
|---|---|---|---|---|
NotFoundException | 404 | 400 | 15 | Every operation that can answer it — "The request was rejected because the specified entity or resource could not be found" |
DisabledException | 409 | 400 | 3 | API_Encrypt, API_Decrypt, API_GenerateDataKey — "The request was rejected because the specified KMS key is not enabled" |
InvalidRequest | 400 | ValidationError/400 | 21 | The string appears on no KMS page at all; ValidationError is the common error — "The input doesn't meet the required format or constraints" |
ValidationError rather than TagException for the unparseable-body guard: both are 400, but TagException is glossed "one or more tags are not valid" — the content of a Tags member — and eighteen of those twenty-one operations take no tags. MalformedHttpRequestException is the other near miss and is also declined: its published scope is the transport layer, "when the request body can't be decompressed using the specified content encoding algorithm", and a body that decompressed and then failed to parse is not that. Note that KMS spells the common error ValidationError, with no Exception suffix, unlike most JSON-protocol services; SerializationException and ValidationException appear nowhere in its documentation, so answering either would repeat the defect.
Every code is now constructed by one helper per code, so a status is chosen once rather than at each of the thirty-nine call sites that answered it — the arrangement Systems Manager arrived at in #933 from the other direction.
The full published surface, for a caller deciding what to branch on:
| Status | Codes |
|---|---|
| 400 | NotFoundException, DisabledException, InvalidArnException, KMSInvalidStateException, InvalidCiphertextException, IncorrectKeyException, InvalidKeyUsageException, InvalidGrantTokenException, LimitExceededException, TagException, AlreadyExistsException, InvalidAliasNameException, MalformedPolicyDocumentException, UnsupportedOperationException, DryRunOperationException, ValidationError, ThrottlingException |
| 500 | DependencyTimeoutException, KMSInternalException, KeyUnavailableException |
The adjacent gap #923 named rather than closed — API_EnableKeyRotation and API_DisableKeyRotation publish DisabledException too, and substrate's two handlers checked nothing — is closed by #949. It was a missing refusal rather than a wrong status, and it is described in the next section, because getting it right needed a second code rather than a second call to the same helper.
The same gap remains open on three cryptographic operations, and is named here for the same reason: Encrypt, Decrypt and GenerateDataKey answer DisabledException for a key pending deletion, where AWS publishes KMSInvalidStateException. That is #961.
A key state can forbid rotation, and the two forbidding states answer different codes
API_EnableKeyRotation and API_DisableKeyRotation both carry the sentence "the KMS key that you use for this operation must be in a compatible key state", and both publish the identical seven-error list — DisabledException and KMSInvalidStateException among them, each at 400. Substrate checked no state at all: it wrote RotationEnabled and answered 200 whatever the key was.
The developer guide's Key states of AWS KMS keys table is what decides which of the two codes a state gets, and for these two operations its row is:
| Key state | Substrate answers | Provenance |
|---|---|---|
Enabled | 200 | Permitted |
Disabled | DisabledException/400 | Footnote [1] — "DisabledException: <key ARN> is disabled" |
PendingDeletion | KMSInvalidStateException/400 | Footnote [3] — "KMSInvalidStateException: <key ARN> is pending deletion (or pending replica deletion)" |
PendingImport, Unavailable, Creating, Updating | — | Refused by AWS, unreachable in substrate: no operation writes any of the four, so nothing can be in one of these states to be answered |
Two of those rows are load-bearing.
PendingDeletion is reachable, so the naive check would answer the wrong code.ScheduleKeyDeletion writes the state and clears the enabled flag in one go, so a lone "is it enabled?" test would report DisabledException for a key pending deletion. The two codes carry different remedies — enable the key and retry, or cancel the deletion and retry — so collapsing them leaves a caller's error handler unable to tell which. PendingDeletion is therefore checked first.
GetKeyRotationStatus is deliberately not guarded. Its row in the same table permits Enabled, Disabled and PendingDeletion alike, so reading whether rotation is on succeeds in every state substrate can produce. A test pins that, so a later sweep guarding "every key-state-sensitive operation" cannot quietly introduce a refusal AWS does not have.
The reason first recorded here was that the page published neither code. That was wrong about half of it and is corrected rather than left standing: API_GetKeyRotationStatus publishes no DisabledException, but it does publish KMSInvalidStateException/400 among its six errors. The decision survives the correction on better grounds — the page documents an answer for a pending-deletion key, "while a KMS key is pending deletion, its key rotation status is false … If you cancel the deletion, the original key rotation status returns to true", and an operation that documents an answer for a state cannot also be refusing that state. So the published code belongs to the states substrate never writes, the same four the table's last row names. That documented false was itself a wrong value rather than a missing refusal, and it is now answered — see A pending deletion suspends the rotation schedule without forgetting it below.
The refusal is checked before the write, so a refused call leaves RotationEnabled exactly as it was — asserted by reading it back through GetKeyRotationStatus rather than by inspecting state, since a guard placed after the assignment would answer the right code while having already changed the value. For a key pending deletion that read-back now reports a derived false whatever was stored, so the test leaves the state before reading a second time; otherwise the strongest half of the assertion would be invisible in exactly the state where the guard is newest.
The four unreachable states are recorded rather than implemented because ScheduleKeyDeletion is substrate's only writer of a state other than Enabled or Disabled: there is no operation a caller could use to reach them, so a guard for them could only be exercised by fabricating a key record, which is the kind of test that proves nothing about the wire.
The same guard now serves the five cryptographic operations, which reached it by a different route and with one code chosen rather than published — see The five cryptographic operations refuse the same two key states below.
The rotation period is a value AWS expects a caller to read back
EnableKeyRotation decoded KeyId and nothing else, so RotationPeriodInDays was accepted and discarded (#964). Discarding it would have been defensible if the value were write-only — a plugin has no rotation to schedule and no key material to replace — but it is not: API_GetKeyRotationStatus publishes RotationPeriodInDays as a response element with the identical Valid Range of 90 to 2560. A range stated at both ends of a round trip is a value AWS expects a caller to write and read back, so substrate now stores it and reports it.
The published members, and what substrate answers on each:
| Member | Answered | Provenance |
|---|---|---|
KeyId | The bare key ID, whichever of the four forms the caller addressed the key by | Glossed only "identifies the specified symmetric encryption KMS key" — none of the "Amazon Resource Name (key ARN)" wording ScheduleKeyDeletion and ReEncrypt use for theirs — and the page's sample renders 1234abcd-…. Echoing the request would therefore be wrong for an alias or an ARN, not merely lazy |
KeyRotationEnabled | Always; the stored flag in every state but PendingDeletion, which answers false | Published unconditionally, with the pending-deletion false published separately — see below |
RotationPeriodInDays | Only while the reported rotation status is on | See below |
NextRotationDate | Only while the reported rotation status is on: the enable date plus the period, as Unix seconds | See below |
OnDemandRotationStartDate | Not answered | "Identifies the date and time that an in progress on-demand rotation was initiated"; RotateKeyOnDemand is not implemented, so nothing can start one. AWS's own sample response omits it too |
Three decisions here are substrate's rather than AWS's.
The refusal for an out-of-range period is ValidationError/400, reached exactly as the waiting-period range check reaches it and deliberately reused rather than re-argued: API_EnableKeyRotation's seven errors — DependencyTimeoutException, DisabledException, InvalidArnException, KMSInternalException, KMSInvalidStateException, NotFoundException and UnsupportedOperationException — contain none for a parameter value out of range, so it comes from CommonErrors. Two range violations in one plugin answering two different codes would be the divergence #923 exists to prevent. UnsupportedOperationException is the near miss and is declined: its gloss is "a specified parameter is not supported or a specified resource is not valid for this operation", which describes an inadmissible parameter or resource — an asymmetric key, which is where that code is answered, two sections below — not an admissible parameter carrying a number out of range. The message names 90 and 2560, because a caller that sent 30 by analogy from the deletion window cannot discover the range from a bare refusal. As with the waiting period, the range is checked before the key is resolved, so a bad period against an absent key answers the value that is wrong on the face of the request.
The period decodes into a *int, where PendingWindowInDays deliberately stays an int. The asymmetry is the point rather than an inconsistency: 0 is a value a caller can send, it is far below the published minimum of 90, and a plain int would make it indistinguishable from an absent member and silently accept it as 365 — #964's defect reintroduced one level down. The waiting period's note below explains why it does not need the same treatment.
An omitted period resets to 365 on every call, not only the first.API_EnableKeyRotation states "if no value is specified, the default value is 365 days" unconditionally, and documents the parameter as able to "modify the rotation period of a key that you previously enabled automatic key rotation on" — so a second call is a legitimate change rather than a conflict to refuse, and a second call that omits the member takes the default rather than preserving the value the first one set. AWS does not address the interaction directly, so the reading is recorded and pinned by a test: the intuitive opposite reading would otherwise be an easy "fix". A caller that wants to keep 180 has to send 180 again.
DisableKeyRotation leaves the stored period alone — AWS documents no clearing — but GetKeyRotationStatus stops reporting it, which is the honest-empty behaviour #827 established: a key that is not rotating has no rotation period to report. A re-enable with an explicit period therefore never surfaces a stale one, and a re-enable without one reports 365 per the rule above.
The first of the two restrictions the page publishes — automatic rotation is "supported only on symmetric encryption KMS keys" — is now enforced; see the next section. The second, "you cannot enable or disable automatic rotation of AWS managed KMS keys", is unreachable rather than unenforced: substrate mints no AWS managed key, so no request can name one. EnableKeyRotation is also documented Cross-account use: No while GetKeyRotationStatus is Yes, an asymmetry substrate does not model.
A key type can forbid rotation permanently, and that is answered before the key state
EnableKeyRotation and DisableKeyRotation checked the period's range (#964) and the key state (#949) and never checked what kind of key they were pointed at (#972). AWS states the restriction twice on API_EnableKeyRotation — once in the prose and once on the KeyId parameter — and repeats it verbatim on API_DisableKeyRotation and API_GetKeyRotationStatus: "automatic key rotation is supported only on symmetric encryption KMS keys. You cannot enable automatic rotation of asymmetric KMS keys, HMAC KMS keys, KMS keys with imported key material, or KMS keys in a custom key store." EnableKeyRotation on an RSA key answered 200 and wrote the flag, so GetKeyRotationStatus then reported a rotation schedule for a key AWS will never rotate.
Both operations now refuse it with UnsupportedOperationException/400, whose gloss — "a specified parameter is not supported or a specified resource is not valid for this operation" — is the inadmissible-resource case the out-of-range period above declined to borrow. DisableKeyRotation refuses it too, although turning rotation off on a key that will never rotate looks harmless; the same argument was rejected for the key state and it is rejected again here, because AWS publishes the identical seven-error list and the identical sentence on both pages.
The discriminator is KeySpec, not KeyUsage. A single equality test against SYMMETRIC_DEFAULT covers every family AWS names and the two it implies — asymmetric encryption, asymmetric signing, SM2, HMAC and ML-DSA — where a KeyUsage test would let an ENCRYPT_DECRYPT RSA key through. The refusal message names the spec it found, because UnsupportedOperationException/400 is otherwise indistinguishable from the AWS-managed-key refusal a caller might expect. Tests cover one spec per family rather than all fifteen: the plugin's check is one comparison, so a second RSA size exercises no new code, while five families guard against the check being rewritten as the family test the sentence's own wording invites.
The key type is answered before the key state, which AWS does not publish a precedence for. It is substrate's reading, and the argument is the remedy: a key state has one — EnableKey, or CancelKeyDeletion and then EnableKey — and a key type has none, so telling a caller with a disabled RSA key to enable the key sends it round a loop that cannot terminate. It is the same request-before-resource principle #964 used to check the period's range before resolving the key.
GetKeyRotationStatus is deliberately not refused for such a key, and a test pins that. The sentence forbids enabling rotation on the key, not asking whether it rotates; the operation's own key-state row permits the read; and false for a key that will never rotate is a true answer. A sweep that guarded "every rotation operation" for consistency would introduce a refusal AWS does not publish.
Three of AWS's five restrictions stay unenforced because they are unreachable rather than unguarded: imported key material, a custom key store and an AWS managed key each need a KMSKey member substrate does not model, so no request can produce one. Every non-symmetric key in the tests is now created with a key usage its spec admits, because #977 closed the gap they used to rely on — CreateKey validated KeySpec against nothing — and that refusal leaves this one unchanged: it fires on the key spec of a key that exists, not on a request member.
A pending deletion suspends the rotation schedule without forgetting it
GetKeyRotationStatus answered two of its five published members, and one of the two answers was wrong (#973).
NextRotationDate was missing entirely, and it is the member rotation-monitoring code reads — the reason to ask for a rotation status is usually to find out when the next rotation is. AWS defines it on API_EnableKeyRotation rather than where it is reported: "the rotation period defines the number of days after you enable automatic key rotation that AWS KMS will rotate your key material, and the number of days between each automatic rotation thereafter." So it is the enable date plus the period, and substrate held only the period — KMSKey gained the enable date for this and nothing else. It is reported as Unix seconds, matching what DescribeKey does with DeletionDate. AWS's own page disagrees with itself here: its Response Syntax types the member number while its sample response renders "2024-02-14T18:14:33.587000+00:00". Substrate keeps one KMS timestamp convention.
A second EnableKeyRotation moves the date, because it overwrites the enable date rather than keeping the first one. AWS documents no answer — the parameter is described as able to "modify the rotation period", with nothing said about the date it counts from — so this is substrate's reading, and the alternative is what argues for it: a key enabled with 2560 days and re-enabled with 90 would otherwise report a next rotation date roughly six years in the past. DisableKeyRotation leaves both the period and the enable date alone, so nothing is lost by turning rotation off.
A key pending deletion reports false, whatever the stored flag says. API_GetKeyRotationStatus publishes the state's answer in full: "while a KMS key is pending deletion, its key rotation status is false and AWS KMS does not rotate the key material. If you cancel the deletion, the original key rotation status returns to true." Substrate reported the stored flag, so a caller polling a key it had scheduled for deletion was told rotation was still on. That was a wrong value rather than a missing refusal — the distinction that kept it out of #949, which read the same two sentences as a reason not to guard the operation and stopped there.
The second sentence is what forces the shape of the fix. The reported status is derived — the stored flag and the key state, combined at read time — rather than written by ScheduleKeyDeletion, because there has to be something left for CancelKeyDeletion to restore. An implementation that cleared the flag on the way in would satisfy the first sentence and make the second unimplementable, and no state inspection would tell the two apart; the test walks enable → schedule → cancel and asserts the original date comes back, not a fresh one counted from the cancel.
The period and the date follow the reported status rather than the stored flag, so a key pending deletion reports neither. Answering a period beside a false status would describe a rotation AWS has just said will not happen, and the honest-empty reading #827 established says a key with no rotation schedule has no schedule to report. Every read in the test file also asserts OnDemandRotationStartDate's absence, so the one member substrate still does not answer cannot appear by accident: it reports an in-progress RotateKeyOnDemand, which is not implemented, so a zero timestamp there would report a rotation nobody started.
DescribeKey never published a rotation flag
Substrate rendered RotationEnabled inside DescribeKey's KeyMetadata. API_KeyMetadata publishes 26 members and that is not one of them; the string does not occur on the page at all (#971).
That is the failure mode #765 exists to catch, aimed at a response member rather than at state: a consumer could branch on DescribeKey().KeyMetadata.RotationEnabled against the emulator, pass, and get an absent field from AWS. The member is removed, and the test asserts its absence against raw JSON both before and after enabling rotation — a decoded struct cannot tell an omitted member from a false one, which is the whole distinction — and then asserts that rotation state is still readable through GetKeyRotationStatus, so the removal is shown to have removed a duplicate route rather than the only one.
Restoring it under another name would be worse than the original defect, because the rules the real operation carries — the symmetric-only restriction, the key-state refusals, the documented false while a key is pending deletion — belong to GetKeyRotationStatus and would all be bypassed. The opposite direction — the sixteen KeyMetadata members substrate did not answer, five of which are present on every real DescribeKey call for a plain customer symmetric key — was #974, and it is the next section.
One KeyMetadata builder, and the six members it stopped withholding
API_KeyMetadata publishes 26 members. DescribeKey answered 10 and CreateKey answered 9 from a second map of its own (#974). Five of the absent ones are sent by AWS on every call for a plain customer symmetric key — AWSAccountId, KeyManager, Origin, EncryptionAlgorithms and the deprecated CustomerMasterKeySpec — so a consumer reading any of them got a missing field from substrate where AWS always has a value. That is #765's failure mode aimed at a response shape, and it is #971 read from the other side: that issue removed the one member substrate emitted and AWS does not publish, and this one adds the members AWS publishes and substrate did not emit.
Nothing here is a simulation. Three of the five are constants for every key substrate can create and the other two are functions of KeySpec and KeyUsage, both of which the key already carries. Every member added is a value substrate already knew and withheld, which is what makes the two issues the same finding rather than two.
The builder is shared because AWS shares the shape. The type's own page says it "is used as a response element for the CreateKey, DescribeKey, and ReplicateKey operations", and substrate's two hand-built maps had already drifted apart: #963 added DeletionDate to DescribeKey's and left CreateKey's alone, so two operations disagreed about one documented type. That is the defect class #952 catalogues, and neither operation's own test could see it, because each asserted its own response against the page rather than against the other. Both now build from one function, and a test compares the two responses member by member on raw bytes. ReplicateKey is the third caller AWS names; substrate does not implement it, and when it arrives it must build from here rather than growing a third map.
Three constants, and what each one records
| Member | Value | Why it is a constant rather than a stored field |
|---|---|---|
AWSAccountId | The key's own account | Not the caller's. A cross-account DescribeKey is the only call that separates the two, so that is the call the test makes |
KeyManager | CUSTOMER | Substrate mints no AWS managed key; every key in state came from a CreateKey request in the caller's own account |
Origin | AWS_KMS | Substrate creates its own key material and models no custom key store, and since #984 CreateKey refuses every other published origin rather than discarding the member — so a stored key with another origin is unreachable by construction, which is what keeps this a constant |
KeyManager is also why EnableKeyRotation's "you cannot enable or disable automatic rotation of AWS managed KMS keys" is recorded as unreachable rather than unenforced, and Origin is why three further members are: ExpirationModel and ValidTo are published "only when Origin is EXTERNAL", and XksKeyConfiguration only for an external key store.
CreateKey's three key-material parameters
Until #984 CreateKey decoded none of Origin, CustomKeyStoreId or XksKeyId. All three were accepted and thrown away, so CreateKey with Origin: "EXTERNAL" — the first call of every key-import workflow — answered 200 with Origin: "AWS_KMS", KeyState: "Enabled" and Enabled: true. A consumer got a fully usable key where AWS answers one in PendingImport that no cryptographic operation will touch, so the workflow passed at step one and failed at step two, where GetParametersForImport turns out not to exist either.
Substrate models neither imported key material nor a custom key store, and per CLAUDE.md's boundary that is defensible: the key material itself and an HSM cluster's contents are resource-internal. The request parameters are not — they are observable through an API call — so the choice was between modelling them, refusing them, and the third thing substrate was doing. Accepting a parameter and discarding it is the worst of the three, because it is the only one a caller cannot detect. They are now decoded, and what substrate does not model is refused with a code API_CreateKey publishes.
Two refusals, two codes. The split is not cosmetic: the two answer different questions about whether the caller did anything wrong, and both are 400, so the code is the only thing that can say which.
| Request | Substrate answers | Whose refusal it is |
|---|---|---|
Origin outside the published four | ValidationError/400, naming the value and the four | AWS's. A malformed member, from CommonErrors.html — the reading #977 recorded for a misspelled KeySpec |
XksKeyId with any Origin but EXTERNAL_KEY_STORE | ValidationError/400, naming the origin sent and the one the member is for | AWS's. The page states it itself: "this parameter is required for a KMS key with an Origin value of EXTERNAL_KEY_STORE. It is not valid for KMS keys with any other Origin value" — so this refusal holds against real KMS. AWS attaches no code to the sentence; ValidationError is substrate's reading, the same one every other malformed-member refusal on this operation answers |
Origin published and not AWS_KMS | UnsupportedOperationException/400, naming both origins | Substrate's. Real KMS honours all four. The message says so, because a consumer whose import workflow stops here needs to know it has reached a boundary of the emulator rather than written a bad request |
CustomKeyStoreId, any value | UnsupportedOperationException/400, quoting the ID | Substrate's, for the same reason |
The order matters at one point and is asserted: XksKeyId is checked against the requested origin before the origin's own support, so a caller sending it with AWS_KMS reads AWS's own refusal, while EXTERNAL_KEY_STORE with an XksKeyId — the one combination AWS accepts — falls through and is told the truth about the store.
CustomKeyStoreNotFoundException is the near miss for the fourth row and is not used, although API_CreateKey publishes it. It says no store has this ID, which invites the caller to create one, and CreateCustomKeyStore does not exist either — so the caller would loop. The same reasoning keeps CustomKeyStoreInvalidStateException, CloudHsmClusterInvalidConfigurationException, XksKeyAlreadyInUseException, XksKeyNotFoundException and XksKeyInvalidConfigurationException unconstructed: each presupposes a modelled store or external key.
Three published constraints are unreachable by construction and are recorded rather than implemented, because a constraint no request can reach is not enforcement and writing one implies the parameter is modelled: CustomKeyStoreId's 1–64 length, XksKeyId's 1–128 length and ^[a-zA-Z0-9-_.]+$ pattern, and the spec-dependent conditions on Origin itself ("the EXTERNAL origin value is valid only for symmetric KMS keys", and the SYMMETRIC_DEFAULT requirement for AWS_CLOUDHSM and EXTERNAL_KEY_STORE). The resolver is nevertheless ordered after the key spec resolves, so that whichever of those is ever modelled has a resolved spec to read.
Modelling Origin: EXTERNAL as a PendingImport key state, and modelling a custom key store, both remain open and are each larger than this. A refusal now forecloses neither, and it converts five of the absent KeyMetadata members below from nothing reads the parameter into the request that would produce them is refused — a stronger statement, and the one the table's Why it would need column now records.
One algorithm list, selected by the key usage
Each of the four algorithm members names a KeyUsage as its presence condition, and a key has exactly one usage — so a key carries exactly one list, and the four are mutually exclusive rather than four independent members. The contents come from the developer guide's key spec reference, which tabulates them for the reason it states twice: "you cannot configure a KMS key to use a particular encryption algorithm" and "you cannot configure a KMS key to use particular signing algorithms."
KeyUsage | Member | Example |
|---|---|---|
ENCRYPT_DECRYPT | EncryptionAlgorithms | SYMMETRIC_DEFAULT → itself; RSA_2048 → the two RSAES_OAEP |
SIGN_VERIFY | SigningAlgorithms | every RSA spec → the same six; ECC_NIST_P384 → ECDSA_SHA_384; ECC_NIST_EDWARDS25519 → two; the three ML-DSA specs → ML_DSA_SHAKE_256 |
GENERATE_VERIFY_MAC | MacAlgorithms | HMAC_384 → HMAC_SHA_384, one per spec because "the length of the key determines the MAC algorithm" |
KEY_AGREEMENT | KeyAgreementAlgorithms | ECDH, for the NIST curves and SM2 |
KeyAgreementAlgorithms' presence condition is substrate's reading. The other three members publish one; this member publishes none at all — its entire description is "the key agreement algorithm used to derive a shared secret". KEY_AGREEMENT is a published KeyUsage and a key has one usage, so following the pattern of the three that do state a condition is the narrowest available reading. The alternative — reporting ECDH on a NIST-curve signing key, whose spec does admit it — would tell a caller DeriveSharedSecret was available on a key AWS reserves for Sign. A test pins exactly that case.
An empty list is omitted rather than sent as []. When this was written CreateKey validated neither KeySpec nor KeyUsage, so a caller could create an ECC key with KeyUsage ENCRYPT_DECRYPT — a pair AWS would refuse — and that key admitted no encryption algorithm. An empty array would say "this key supports no encryption algorithms", a claim AWS never makes about a key it accepted; the absent member says nothing, which is the honest-empty reading #827 established.
#977 removed the premise by refusing the pair, so no key substrate creates now reaches that branch. The branch stays, and so does the reading behind it: it is the guard on a fifth algorithm member being added without its pairing being thought through. The four maps it reads are also now the source of the pairing rule — see A key spec and a key usage must pair below.
CustomerMasterKeySpec is answered, and omitted outside its own enum
AWS still sends the deprecated member — "the KeySpec and CustomerMasterKeySpec fields have the same value. We recommend that you use the KeySpec field in your code. However, to avoid breaking changes, AWS KMS supports both fields" — so substrate answers it rather than dropping it as obsolete: a consumer written against an older SDK reads it, and omitting a member AWS sends is what this issue is about.
Its enum is narrower than KeySpec's: API_KeyMetadata gives it 13 valid values against KeySpec's 17. The four specs added since the deprecation — ML_DSA_44, ML_DSA_65, ML_DSA_87 and ECC_NIST_EDWARDS25519 — appear only under KeySpec, so a key with one of those has no admissible value for the older member. Substrate omits it there, which is a reading rather than a match: AWS documents no answer for the case, and the alternative puts a value outside the member's own published set on the wire.
The deprecated name is a request parameter too
CustomerMasterKeySpec appears in API_CreateKey's Request Parameters as well, and substrate decoded only the response half (#985). A caller on an SDK old enough to still send the deprecated name asked for RSA_4096 and got a symmetric key — the quietest failure available, a 200 with a complete KeyMetadata whose own CustomerMasterKeySpec read SYMMETRIC_DEFAULT, contradicting the value it was sent. It is now decoded and honored, and every rule of the section above applies to the spec it names, since those rules are keyed on the resolved spec rather than on which member carried it.
Two things AWS does not document, both substrate's reading and both refused rather than resolved:
| Request | Substrate answers | Why |
|---|---|---|
CustomerMasterKeySpec outside its own 13 — ML_DSA_44, say | ValidationError/400, naming the member and its set, and pointing at KeySpec | The request-side half of the omission above: neither direction puts a value outside the member's own published set on the wire. The message must name the set, because the value is a good key spec and a bare refusal would send the caller hunting a typo that is not there |
| Both members sent with different values | ValidationError/400, naming both values | AWS documents no answer for the conflict; what it documents is that the two members "have the same value", so a request in which they disagree is not one the page describes. Substrate cannot tell which was meant, which is why it refuses instead of choosing |
The two rejected alternatives are worth recording because both look reasonable. A precedence rule — the newer member wins — discards half of a contradictory request silently, which is the failure mode this issue exists to remove rather than relocate. Resolving by JSON member order makes the answer depend on something no caller controls meaningfully. Equal values are accepted: an SDK migrating between the two names may send both, and that is a caller agreeing with itself.
The code is ValidationError in both rows, the same one every other malformed-member refusal on this operation answers. That is deliberate rather than incidental — two codes for one class of defect on one operation is what #977's analysis warned against, and this member joins that decision rather than introducing a second.
The eight members that stay absent
| Member | What it would need |
|---|---|
CloudHsmClusterId, CustomKeyStoreId, XksKeyConfiguration | A custom or external key store, which substrate does not implement — and since #984 CreateKey refuses the request that would ask for one |
ExpirationModel, ValidTo | An EXTERNAL origin, which the Origin section above records as unreachable — substrate's key material is always AWS_KMS, and since #984 by refusal rather than by omission |
MultiRegionConfiguration | Published "only when the value of the MultiRegion field is True". Substrate stores the flag but models no replica, so it would have to report a primary with an empty ReplicaKeys list — a shape describing a multi-Region key nothing can replicate |
PendingDeletionWindowInDays | KeyState PendingReplicaDeletion, which only a multi-Region primary that still has replicas reaches. Its range is 1–365, not ScheduleKeyDeletion's 7–30, so reporting the waiting period under it would be wrong twice over |
RotationEnabled | Nothing — AWS does not publish it, per #971 above |
All eight are asserted absent by a test, RotationEnabled included. Adding sixteen members from a page is exactly the moment someone working from the struct rather than the page would put #971's member back, so the guard against that sits beside the guard for the other seven.
CurrentKeyMaterialId was the ninth until substrate gained a key material identity; the section below is what it needed.
A key has a material identity, and six members report it
API_KeyMetadata publishes CurrentKeyMaterialId — "identifies the current key material… AWS KMS uses the current key material for both encryption and decryption, and the non-current key material for decryption operations only" — and five operation responses carry the same identity under four other names. Substrate modelled no key material at all, so all six were absent (#978).
| Where | Member | Reported when |
|---|---|---|
CreateKey, DescribeKey | CurrentKeyMaterialId | "present for symmetric encryption keys with AWS_KMS or EXTERNAL origin" |
Decrypt | KeyMaterialId | "present only when the operation uses a symmetric encryption KMS key" |
ReEncrypt | SourceKeyMaterialId | "present only when the original encryption used a symmetric encryption KMS key" |
ReEncrypt | DestinationKeyMaterialId | "present only when data is reencrypted using a symmetric encryption KMS key" |
GenerateDataKey | KeyMaterialId | The page states no key-type condition, only that it "is omitted if the request includes the Recipient parameter" |
GenerateDataKeyWithoutPlaintext | KeyMaterialId | The page states no condition at all |
Encrypt publishes none, and that is recorded rather than left to be noticed: its Response Syntax is exactly CiphertextBlob, EncryptionAlgorithm and KeyId, and a test asserts those three exactly, so a later sweep over "the operations that report key material" cannot add a site AWS does not have. The asymmetry is AWS's and is not obviously deliberate — Encrypt names the material it used no more than Decrypt names the material that produced the ciphertext it was handed.
The value is stored on the key and read from it at all six sites, which is the point. AWS's member is named Current, and "current" is only meaningful against material that can change; more practically, a caller compares what Decrypt reports against what DescribeKey reports and learns that the material which decrypted its data is the material the key holds. A value each site computed for itself would satisfy that comparison by construction and prove nothing. ReEncrypt is where it shows: its two members are the only two material identities in one response, they differ when the keys differ, and each follows its own key — so a re-encryption out of an asymmetric key into a symmetric one reports the destination member alone.
The identifier is derived, not drawn from crypto/rand: SHA-256 over the key's own ARN with a domain-separation prefix. AWS constrains all six members to a fixed length of 64 with pattern ^[a-f0-9]+$, and a SHA-256 digest hex-encodes to exactly 64 lowercase hex characters, so no truncation or reshaping is involved. The ARN already carries the account, the Region and the key ID, so two keys never collide and the material ID inherits exactly the determinism the key ID has — becoming fully deterministic once #856 reaches the key ID itself.
Two readings are substrate's rather than AWS's:
- Rotation mints no new material. Nothing rotates:
ListKeyRotationsandRotateKeyOnDemandare unimplemented, and the rotation section above already records that a rotation schedule here is a value reported to a caller rather than an event that fires. A second material identity nothing can list or ask for would be a value no call could reach, soCurrentis true in the trivial sense — it is the only material the key has ever had. Minting on rotation means implementingListKeyRotationsalongside it, so that the non-current identitiesDecryptis documented to still accept are observable. - The two
GenerateDataKey*operations are held to the same symmetric-encryption-key condition as the other four, although their own pages state none. Both operations require a symmetric encryption key at AWS, which is why neither page needs a condition. Substrate did not enforce that until #988, because its check tested the key usage rather than the key spec, so anRSA_2048key withKeyUsageENCRYPT_DECRYPTreached both responses; reporting unconditionally would have put a material ID on a response AWS cannot produce, and omitting said nothing about a request that should not have succeeded — the honest-empty reading. #988 refuses that request, so at those two sites the condition is now a guard rather than a path: every key reaching either response satisfies it, and no observable behaviour there depends on it. It is kept because one condition in one place is the whole point of the shared helper, and because a key written directly into state by a test can still reach those sites.
Note that "symmetric encryption key" is narrower than "symmetric key": the four HMAC_* specs are symmetric, hold key material, and encrypt nothing, so they report no material identity. All seventeen key specs are walked by a test for exactly that reason — an implementation reading the condition as "not asymmetric" passes every other row and fails those four.
A key with no policy has AWS's default policy, not an empty one
Substrate answered {"Version":"2012-10-17","Statement":[]} from GetKeyPolicy for any key that had never had PutKeyPolicy called on it, and CreateKey decoded no Policy member at all (#983). So a caller that attached a policy at creation time read back a document it had never sent, and every other key reported a policy that grants nothing.
The second half is the one that matters beyond fidelity. A key policy is the only place a KMS key's own permissions live — an IAM policy cannot grant access to a key whose key policy does not delegate to IAM — so an empty-statement document is not a neutral placeholder. It states the opposite of what AWS attaches.
The document comes from API_GetKeyPolicy's own Example Response, which carries it in full, rather than from the developer guide's Default key policy page that describes it in prose. The reference page is the stronger source in one concrete way: it carries an Id member, key-default-1, that the prose does not mention.
| Member | Value |
|---|---|
Version | 2012-10-17 |
Id | key-default-1 |
Statement[0].Sid | Enable IAM User Permissions |
Statement[0].Effect | Allow |
Statement[0].Principal.AWS | arn:aws:iam::{account}:root |
Statement[0].Action | kms:* |
Statement[0].Resource | * |
The account is the key's own, where AWS's example shows its documentation placeholder 111122223333: the whole content of the statement is that this key's account controls it, so a document naming a foreign root would grant nothing to anyone who can reach the key. The document is synthesised when it is read rather than written when the key is created, which is observationally identical and also means a key created by an earlier release reports the default rather than the old stand-in. Whitespace is not preserved from the example — AWS formats the document with spaces around its colons — because a consumer parses the string.
Three refusals, each with the code its page publishes.
| Request | Answer |
|---|---|
Policy outside 1–32768 bytes, on CreateKey or PutKeyPolicy | LimitExceededException/400 |
Policy that is not JSON, or is JSON but not an object | MalformedPolicyDocumentException/400 |
PolicyName other than default, on GetKeyPolicy or PutKeyPolicy | NotFoundException/400 |
KeyId naming no key, on either operation | NotFoundException/400 |
LimitExceededException for a length violation is not an inference from that error's gloss — CreateKey's Policy member names the code itself: "if the key policy exceeds the length constraint, AWS KMS returns a LimitExceededException". The length is checked before the document is parsed, so a caller sending 40 KB of valid JSON hears about the size. On PutKeyPolicy, where Policy is Required: Yes, the range is also what refuses an empty member, since a member present and empty satisfies presence.
NotFoundException for a PolicyName is substrate's one-step reading: neither page publishes a code for that member, but both state that default is the only valid value and both publish NotFoundException glossed "the specified entity or resource could not be found" — which a name that names no policy is exactly. It keeps the refusal among the codes the page publishes rather than reaching for CommonErrors.
Both member checks run before the key is looked up, following the same reading Encrypt's Plaintext guard follows: a request whose document is unusable and whose KeyId names no key is told about the document, which is the half it can fix without an AWS account.
Four things AWS publishes here that substrate does not implement, recorded rather than half-built:
- The key policy lockout safety check, and therefore
BypassPolicyLockoutSafetyCheck. AWS requires that a supplied policy "allow the calling principal to make a subsequentPutKeyPolicyrequest" — a policy evaluation against the caller's principal, which is authorization machinery rather than this operation's business. With no lockout check, bypassing it is unobservable, so the member stays undecoded on both operations rather than becoming a parameter read by nobody. - Statement-level validation.
MalformedPolicyDocumentExceptionis glossed "not syntactically or semantically correct", but AWS's ownPolicydescription narrows the semantic half almost to nothing: a statement missingActionorResource"has no effect" while "theCreateKeyandPutKeyPolicyAPI requests succeed". There is no published statement-level refusal to implement, so substrate accepts such a document. - The
Policypattern, which admits tab, line feed, carriage return and the printable range through U+00FF. Neither page publishes a code for a pattern violation. InvalidArnException, glossed "a specified ARN, or an ARN in a key policy, is not valid". Nothing walks the principals, so it stays unreachable.
Neither operation refuses a key for its state, and that is deliberate. Both publish KMSInvalidStateException, which reads like a missing refusal — but the developer guide's key-state table gives GetKeyPolicy and PutKeyPolicy a checkmark in all seven state columns, footnote-free, where TagResource two rows below is refused at pending deletion under footnote [3]. A key pending deletion still takes a policy and still reports one, and a test pins that so a later sweep completing the key-state checks cannot read the published code as a gap.
Cancelling a deletion leaves the key disabled, not enabled
API_CancelKeyDeletion's first sentence is the whole of it: "Cancels the deletion of a KMS key. When this operation succeeds, the key state of the KMS key is Disabled. To enable the KMS key, use EnableKey." Recovery from a scheduled deletion is two calls, and substrate answered Enabled — collapsing it to one.
That is not a cosmetic difference in a reported string. A consumer's recovery path written against substrate would have passed with its EnableKey step missing, and then failed against AWS. Substrate now leaves the key Disabled with Enabled false, and the test asserts it by encrypting: Encrypt still refuses with DisabledException/400 after the cancel, and succeeds only once EnableKey has run. Reading KeyState back says the state changed; Encrypt refusing says the state means what it says.
API_KeyMetadata states the invariant the two fields have to satisfy — "Enabled: when KeyState is Enabled this value is true, otherwise it is false" — so the state and the boolean are not independent, and a test asserts they agree.
The deletion pair each permit one side of a single key state
CancelKeyDeletion and ScheduleKeyDeletion are each other's inverse, and the Key states of AWS KMS keys table treats them that way. Both carry the compatible-key-state sentence and both publish KMSInvalidStateException/400; substrate checked neither.
| Operation | Permitted from | Refused from |
|---|---|---|
CancelKeyDeletion | PendingDeletion only | Every other state — footnote [4], "KMSInvalidStateException: <key ARN> is not pending deletion" |
ScheduleKeyDeletion | Every state but the two at right | PendingDeletion — footnote [3]; Updating — footnote [15], unreachable in substrate |
CancelKeyDeletion is the only operation in the table whose permitted set is a single state, and the only one whose footnote is phrased as a negation. So it gets its own refusal helper: naming the offending state, as every other refusal in this package does, would read as though some other state were the problem when the problem is the absence of the one state the operation needs.
Without that check the operation enabled any key it was pointed at, which is EnableKey under another name — reachable by a caller holding kms:CancelKeyDeletion and not kms:EnableKey.
On ScheduleKeyDeletion the missing check was quieter and no less real: a second call against an already-pending key recomputed the deletion date and saved it, so a caller that had scheduled a 7-day deletion and then called again silently moved a deadline it believed was fixed. Updating, the row's other refused state, is recorded as unreachable for the same reason the four rotation states are: nothing in substrate writes it.
DescribeKey reports a deletion date, so a refused schedule can be shown to have changed nothing
Substrate computed the deletion date inside ScheduleKeyDeletion and discarded it. A caller could therefore learn when a key was due to be deleted exactly once — from the response to the call that set it — and any later observation had lost it.
API_KeyMetadata publishes the member and bounds when it appears: "the date and time after which AWS KMS deletes this KMS key. This value is present only when the KMS key is scheduled for deletion, that is, when its KeyState is PendingDeletion." Substrate now stores the date and renders it on that condition — on the key state, not on the field being non-zero, so a cancelled deletion leaves no date behind and the zero value is never emitted as an epoch timestamp.
Storing it is what makes the refusal above assertable. A re-stamped deadline is invisible if the date is computed and thrown away, so the test reads the date back through DescribeKey after the refused call rather than inspecting state.
PendingDeletionWindowInDays is deliberately absent from KeyMetadata. The page confines it to KeyState PendingReplicaDeletion, which only a multi-Region primary that still has replica keys reaches and substrate never writes — and its range is 1–365, not ScheduleKeyDeletion's 7–30. The two are different members measuring different things, so reporting the waiting period under it would be wrong twice over.
The waiting period is range-checked, at a code substrate chooses
API_ScheduleKeyDeletion states the range twice — "you can specify a waiting period of 7-30 days" in the prose, and "if you include a value, it must be between 7 and 30, inclusive" on the parameter, with a published Valid Range to match. Substrate's guard was if days <= 0 { days = 30 }, which got the default right and the range not at all: 1, 365 and -5 were all accepted, and the last silently became 30.
The response now echoes PendingWindowInDays, which AWS publishes and its own sample response carries, so the default is observable rather than inferable from date arithmetic. KeyId also became the key ARN, as the page says ("the Amazon Resource Name (key ARN)") and its sample shows; and CancelKeyDeletion, which answered {}, now answers the single element the page publishes — KeyId, again the key ARN — so a consumer reading response["KeyId"] gets something.
Two decisions here are substrate's, not AWS's:
The refusal code. AWS publishes no error for a range violation on this page: its list is DependencyTimeoutException, InvalidArnException, KMSInternalException, KMSInvalidStateException and NotFoundException, none of which describes a bad parameter value. ValidationError/400 comes from CommonErrors for the same reason an unparseable body does — a failure belonging to no operation-specific code has to come from the common set. The bound is stated in the message, because a caller that sent 1 has no way to discover 7–30 from a bare refusal.
Which check runs first. The range is checked before the key is resolved, so a bad window against a key that does not exist answers ValidationError rather than NotFoundException. AWS does not publish the precedence. A value wrong on the face of the request is refused without a lookup, which is how an unparseable body already behaves one line earlier.
One thing recorded rather than fixed: PendingWindowInDays decodes into an int, so a body that omits the member and a body sending an explicit 0 are indistinguishable. AWS refuses 0 and defaults an absent value. Substrate defaults both, because telling them apart needs a *int and defaulting is by far the commoner intent — worth changing only if a caller is ever shown to send an explicit zero.
RotationPeriodInDays is decoded into a *int, and the difference is not an inconsistency. Here the two readings agree on the sensible answer, since AWS's default of 30 coincides with the maximum a caller could plausibly have meant; there the default of 365 sits in the middle of the range, so collapsing an explicit 0 into it would accept a value AWS refuses and report a period the caller never asked for.
The five cryptographic operations refuse the same two key states
Encrypt, Decrypt, GenerateDataKey, GenerateDataKeyWithoutPlaintext and ReEncrypt share one row in the developer guide's Key states of AWS KMS keys table, and each of their reference pages carries the same "the KMS key that you use for this operation must be in a compatible key state" sentence. Substrate honored that unevenly. Three of the five checked whether the key was enabled and answered DisabledException; two checked nothing at all, so a disabled key generated a data key or re-encrypted a ciphertext and answered 200.
| Key state | Substrate answers | Provenance |
|---|---|---|
Enabled | 200 | Permitted |
Disabled | DisabledException/400 | Footnote [1] — "DisabledException: <key ARN> is disabled" |
PendingDeletion | KMSInvalidStateException/400 | Cell reads [2] or [3]; substrate's reading, below |
PendingImport, Unavailable, Creating, Updating | — | Refused by AWS, unreachable in substrate: nothing writes them |
The missing refusal was the larger half.GenerateDataKeyWithoutPlaintext and ReEncrypt had no key check of any kind between loading the record and returning a ciphertext. A caller that disabled a key to stop it being used could still use it through either operation, which is the defect class #923 catalogued rather than a question of which code to answer.
The pending-deletion code is a decision, not a correction. This is worth stating plainly, because the neighbouring rotation section looks like the same finding and is not. For the rotation pair the table's pending-deletion cell is footnote [3] alone, so DisabledException there was simply the wrong one of two published codes. For these five the cell reads [2] or [3], and the two footnotes are the same sentence under two different codes:
[2]— "DisabledException:<key ARN>is pending deletion (or pending replica deletion)"[3]— "KMSInvalidStateException:<key ARN>is pending deletion (or pending replica deletion)"
So AWS admits either code here, and the DisabledException substrate answered before was inside what the table publishes. KMSInvalidStateException is chosen anyway, and the reason is what an emulator is for: it is the only one of the two choices under which a consumer's error handler can tell the two states apart by code, and the two states carry different remedies — EnableKey for a disabled key, CancelKeyDeletionthen EnableKey for one pending deletion, which is a genuine two-step recovery. It also gives one key state one code across the whole plugin, matching what the rotation pair and the deletion pair already answer.
The message names the state either way, so the distinction survives even for a caller that matches on the code alone. Before this, both states answered "is not enabled", so neither the code nor the message told a caller which remedy applied — and that, rather than the code, was the observable defect. A test compares the two answers from one operation directly, so a later change cannot collapse them again by either route: one code for both states, or two codes with one message.
GetKeyRotationStatus remains deliberately unguarded, and EnableKey and DisableKey are guarded by neither this nor the rotation rule: their rows permit a disabled key, so the enabled-check would refuse a call AWS accepts. They take the pending-deletion arm alone — see EnableKey cannot skip the cancel below, which completes the class.
EnableKey cannot skip the cancel
EnableKey and DisableKey both went through one helper that resolved the key, refused only a missing one, and then assigned whatever state it was handed. So EnableKey against a key in PendingDeletion answered 200 and wrote Enabled — a one-call path from pending deletion to usable, which AWS does not have (#968).
That is worse than a missing refusal usually is, because it defeated a guarantee another operation in the same file had just established: CancelKeyDeletion leaves a key Disabled precisely so recovery is two calls, and an unguarded EnableKey skipped the first one. It also abandoned the deletion silently, since the helper cleared the deletion date on its way through, leaving no record that one had ever been scheduled.
Both operations have identical rows in the Key states of AWS KMS keys table:
| Key state | Answered | Provenance |
|---|---|---|
Enabled | 200 | Permitted — so neither operation refuses a redundant call |
Disabled | 200 | Permitted too, which is why the rotation rule cannot be reused here: an enabled-key check would refuse a call AWS accepts |
PendingDeletion | KMSInvalidStateException/400 | Footnote [3], which names the state — so the existing helper serves, with no sibling needed as footnote [4]'s negation required for CancelKeyDeletion |
Unavailable | — | Permitted, not refused. Footnote [12]: "the operation succeeds, but the key state of the KMS key does not change until it becomes available" — a success with a deferred effect, unreachable in substrate and recorded so a later sweep does not read it as a refusal |
PendingImport, Creating, Updating | — | Refused by AWS, unreachable: ScheduleKeyDeletion remains substrate's only writer of a state other than Enabled or Disabled |
The guard lives in the shared helper, and #963 is the counter-example that makes that worth stating. Both callers here refuse the same single state, so one condition serves both. CancelKeyDeletion was deliberately moved off that helper, because it requires the opposite state and nothing else, phrases the absence of it as a negation, and returns a body. Two callers wanting one refusal belong together; a third wanting the inverse does not.
The refusal precedes the write, so a refused call leaves the key state, the enabled flag and the deletion date exactly as they were — assertable only because #963 stores the date and DescribeKey reports it. The date-clearing the helper used to do is gone rather than kept as a safety net: the only state that carries a date can no longer reach that line, and CancelKeyDeletion clears it on the one exit AWS documents, so keeping it would be code no request can run — which this package records in a comment rather than guards, the same disposition the unreachable states have.
With this, every key-state-sensitive operation in the package is guarded: the rotation pair (#949), the five cryptographic operations (#961), the deletion pair (#963) and this one. GetKeyRotationStatus is the only deliberate exception, for the reason given above.
ReEncrypt has two keys, and both are checked
ReEncrypt is the only cryptographic operation with two keys, and substrate loaded one. The source key's identifier came out of the ciphertext and was discarded, so a source key that was disabled, pending deletion, or absent from state entirely still re-encrypted successfully.
Both keys are now resolved, loaded and state-checked. Nothing in AWS's table splits the two — ReEncrypt has a single row whose pending-deletion cell is a plain [2] or [3] — and three things point the same way: the page publishes DisabledException and KMSInvalidStateException without qualification, its Required permissions are split across the two keys (kms:ReEncryptFrom on the source, kms:ReEncryptTo on the destination), and cross-account use is documented for both. AWS therefore treats the source as a key the operation uses, not as a value inside a blob.
One footnote would have exempted a pending-deletion source — [10], "if the source KMS key is pending deletion, the command succeeds. If the destination KMS key is pending deletion, the command fails" — but it belongs to UpdateAlias's row, not to this one. Refusing both keys is therefore substrate's reading of an unsplit row, and it is the conservative direction: it cannot admit a call AWS refuses.
The source is checked first, following the operation's own description — "Decrypts ciphertext and then reencrypts it entirely within AWS KMS" — so a caller whose source and destination are both unusable hears about the decrypt half. AWS publishes no ordering, so this is recorded rather than matched, and a test pins it with a bad source and an absent destination, where the two candidate answers are visibly different.
SourceKeyId is the source key's ARN. It held the ciphertext blob before #961 — a value that identifies nothing, and one a caller round-tripping it into DescribeKey could only get NotFoundException from. API_ReEncrypt glosses the element "unique identifier of the KMS key used to originally encrypt the data" and its sample response renders a key ARN, so that is what substrate reports; the test asserts the round trip through DescribeKey rather than string equality, because a bare key ID would satisfy the weaker assertion while diverging from the sample.
The source key is loaded from the caller's account and Region, for the reason Decrypt records: the key ID came out of the ciphertext, so there is no ARN to take an account from. AWS permits a cross-account source here and substrate cannot address one, because its stub ciphertext carries no account and no Region — #979's envelope records what a refusal needs, not what a lookup needs. That is a limitation of the stub cipher, not of the resolver.
Two of ReEncrypt's nine request members are still unmodelled: DryRun and GrantTokens. SourceKeyId as a request parameter, and both encryption-algorithm members at both ends, arrived with #969; the two *KeyMaterialId response members with #978; and the source and destination EncryptionContext pair with #979 — see the sections below.
A named key that is not the ciphertext's is refused
Decrypt takes a KeyId and ReEncrypt takes a SourceKeyId, and neither names the key the operation will use — substrate finds that inside its own stub ciphertext. Both members are a constraint on which key the ciphertext may belong to, and both were decoded and then dropped, so naming the wrong key was indistinguishable from naming the right one: the operation used the ciphertext's key and answered 200 (#969).
AWS glosses the two members with one sentence and gives them one code, which is why one check now serves both:
Enter a key ID of the KMS key that was used to encrypt the ciphertext. If you identify a different KMS key, the
Decryptoperation throws anIncorrectKeyException.
| Request | Substrate answers | Provenance |
|---|---|---|
| The member is absent | 200, on the ciphertext's own key | Both members are published Required: No |
| The member names the ciphertext's key | 200 | The constraint is satisfied |
| The member names a different key KMS holds | IncorrectKeyException/400 | Published by API_Decrypt and by API_ReEncrypt, in the gloss above |
| The member names no key at all | NotFoundException/400 | Published; which of the two codes wins is substrate's reading, below |
The comparison is between key ARNs, not between strings. The member accepts all four forms a KeyId accepts — a bare key ID, a key ARN, an alias name and an alias ARN — so it is resolved to a key record first and the two records' ARNs are compared. Comparing the member against the bare key ID inside the ciphertext would have refused three of those four correct requests, and it would also have been wrong across accounts, where two keys can share neither ARN nor much else. A test sends all four forms plus the absent case.
A member naming nothing answers NotFoundException, not IncorrectKeyException. AWS publishes both codes for both operations and orders neither. Substrate reports the absence, because the two codes ask the caller for different things: IncorrectKeyException says go find the right key, which is useless advice when the identifier names no key — there is a typo to fix instead.
The wrong key is reported before the key state. A request whose member names another key and whose ciphertext key is disabled has two candidate answers, and substrate gives the wrong-key one. AWS publishes no ordering, so this is recorded rather than matched; it follows the direction #964 took, that a defect in the request precedes a condition of a resource, and it avoids sending a caller after the state of a key it did not ask about. A test pins it with the two answers visibly different.
Encrypt, GenerateDataKey and GenerateDataKeyWithoutPlaintext have no such member: their KeyId is the key they use, and an absent or unresolvable one is already NotFoundException.
An encryption algorithm belongs to the key spec, not to the key
Encrypt, Decrypt and both ends of ReEncrypt take an encryption algorithm and report one back. Substrate decoded none of the four members and reported none of them (#969), so a caller could not say which algorithm it wanted and could not read which one was used.
The absence was defensible while it lasted, because substrate performs no cryptography and echoing SYMMETRIC_DEFAULT unconditionally would have reported a value derived from nothing. What makes the value derivable is that AWS does not let a key choose its algorithm. The developer guide's key spec reference is explicit — "you cannot configure a KMS key to use a particular encryption algorithm" — and fixes the admissible set per key spec instead, so the algorithm a request may use is a function of the key it names and the member it sent, both of which substrate holds:
| Key spec | Admissible encryption algorithms |
|---|---|
SYMMETRIC_DEFAULT | SYMMETRIC_DEFAULT — "the only supported algorithm that is valid for symmetric encryption KMS keys" |
RSA_2048, RSA_3072, RSA_4096 | RSAES_OAEP_SHA_1, RSAES_OAEP_SHA_256 |
SM2 | SM2PKE |
| The ECC, HMAC and ML-DSA specs | None. They sign, generate MACs or derive shared secrets; no encryption algorithm applies |
Substrate still encrypts nothing. What it models is the refusal, which is the observation a consumer's error path is written against, and it comes in two codes:
| Request | Substrate answers | Provenance |
|---|---|---|
| The member is absent | 200, reporting SYMMETRIC_DEFAULT | Published: "the default value, SYMMETRIC_DEFAULT, is the algorithm used for symmetric encryption KMS keys" |
| The member is one of the published four and the key spec admits it | 200, reporting the caller's own value | The four are SYMMETRIC_DEFAULT, RSAES_OAEP_SHA_1, RSAES_OAEP_SHA_256, SM2PKE — one Valid Values line shared by all four members |
| The member is outside the published four | ValidationError/400, naming the member and listing the set | Substrate's reading: no operation page gives a code for a malformed enum member, so it comes from CommonErrors.html |
| The key spec does not admit the algorithm | InvalidKeyUsageException/400, naming the member, the key spec and what that spec does admit | Published — it is that code's own second gloss bullet, "the encryption algorithm or signing algorithm specified for the operation is incompatible with the type of key material in the KMS key (KeySpec)" |
The two codes are ordered, and they are checked at different points. A value outside the published four says nothing about any key, so it is refused before the key is resolved — a caller that misspells RSAES_OAEP_SHA_256 and names a nonexistent key hears about the misspelling, which is the ordering #964 established for a number out of range. The key-spec check necessarily runs after the key is loaded, and after the key-state check, so a caller holding a key pending deletion hears about the deletion rather than about an algorithm it would not get to use.
AWS's separate rule that the member is "required only for asymmetric KMS keys" falls out of this with no branch of its own. The default is SYMMETRIC_DEFAULT unconditionally, and an RSA key does not admit it — so omitting the member on an RSA key is refused for the ordinary reason, and the member is required exactly where AWS says it is. A test asserts that, because the alternative implementation — a required-ness check keyed on the key spec — is a second thing to keep in step with the table above.
ReEncrypt's two ends are independent. The operation exists to move data between keys, so the two algorithms need not agree: a symmetric source can re-encrypt to an RSA destination, and each member is checked against its own key. The source is checked first, matching the order #961 established for the two key states. A test re-encrypts across two key specs and then swaps each end for the other's value, because an implementation that validated both members against one key would pass every other assertion here.
The first bullet of InvalidKeyUsageException's gloss — a KeyUsage incompatible with the operation, such as an ENCRYPT_DECRYPT call against a SIGN_VERIFY key — is the next section's subject, along with CreateKey's former acceptance of any string as a KeySpec (#977).
Plaintext has two maximum sizes, and they are checked in two places
#991. Encrypt's Plaintext member carries two published constraints at two different levels, and substrate enforced neither while answering a code the page does not publish for the one thing it did check.
| Constraint | Where AWS states it | What it depends on |
|---|---|---|
| 1–4096 bytes | The member's own Length Constraints, repeated in the page's opening sentence: "encrypts plaintext of up to 4,096 bytes using a KMS key" | Nothing but the request |
| A smaller per-key maximum | The page's own list, under "the maximum size of the data that you can encrypt varies with the type of KMS key and the encryption algorithm that you choose" | The key spec and the encryption algorithm |
| Key spec | Algorithm | Maximum |
|---|---|---|
SYMMETRIC_DEFAULT | SYMMETRIC_DEFAULT | 4096 bytes |
RSA_2048 | RSAES_OAEP_SHA_1 / RSAES_OAEP_SHA_256 | 214 / 190 bytes |
RSA_3072 | RSAES_OAEP_SHA_1 / RSAES_OAEP_SHA_256 | 342 / 318 bytes |
RSA_4096 | RSAES_OAEP_SHA_1 / RSAES_OAEP_SHA_256 | 470 / 446 bytes |
SM2 | SM2PKE | 1024 bytes (China Regions only) |
The numbers are RSA's OAEP padding overhead made observable: the same key spec accepts 24 fewer bytes under RSAES_OAEP_SHA_256, because the padding carries a longer hash. Note that AWS's list heads its RSA entries with a key spec and its last entry with an algorithm; substrate keys the table on the pair, which loses nothing because SYMMETRIC_DEFAULT and SM2 each admit exactly one algorithm.
The two constraints belong at different points in the handler, and that is the finding that made this more than a code correction. The range and the base64 decode are facts about the request, so they are answered before the key is resolved — the same split the encryption algorithm already makes between its enum check and its key-spec check. The per-key maximum cannot be answered until the key is loaded and the algorithm resolved, so it is the last check the operation makes:
| Request | Substrate answers |
|---|---|
Plaintext is not base64 | ValidationError/400, before any key is loaded |
Plaintext decodes to 0 bytes, or to more than 4096 | ValidationError/400 naming the range, before any key is loaded |
| The pair is admissible and the data fits | 200 |
| The data exceeds the pair's maximum | ValidationError/400 naming the key spec, the algorithm, the size sent and the maximum |
So a caller that sends 5 KB to a key that does not exist now hears about the 5 KB, and a caller whose Plaintext is unusable hears about it whether the key is disabled, is a signing key, or is absent. Tests assert all three, because no assertion on a code alone can show an ordering.
Neither refusal has a published code, and ValidationError/400 from CommonErrors.html is substrate's reading for both — the same landing place as an out-of-range waiting period and an unpublished encryption algorithm. InvalidKeyUsageException is the near miss for the per-key maximum and is deliberately not used, although the condition does involve the key spec and the algorithm: nothing about the pairing is incompatible — the key admits the algorithm, and a shorter plaintext would succeed — so a caller reading that code would change its algorithm when what it has to change is how much data it sends per call.
The code this replaces was InvalidCiphertextException, which is not among API_Encrypt's nine errors and is published on Decrypt and ReEncrypt, whose CiphertextBlob decode is what it exists for. Those two sites are untouched: Encrypt produces a ciphertext, it does not consume one, and a sweep that unified the three would have taken a published code off the two operations that own it.
One consequence is worth naming because it looks like a gap. SYMMETRIC_DEFAULT's per-key maximum is the member's 4096, so the per-key branch is unobservable for a symmetric key — a symmetric request one byte over is refused by the range check before any key is read. The per-key refusal is therefore an asymmetric-key observation only, which is also the direction that matters: a consumer that encrypts a database password under a symmetric key and later switches to an RSA key crosses a 190-byte boundary without changing its request shape at all.
An encryption context is authenticated data, so the ciphertext has to carry it
Encrypt, Decrypt, both GenerateDataKey* operations and both ends of ReEncrypt take an encryption context. Substrate decoded none of the six members (#979), so a context sent on the way in was accepted and lost, and the refusal AWS publishes for a mismatch on the way out was unreachable. API_Encrypt states the rule as a consequence rather than a footnote:
If you specify an
EncryptionContextwhen encrypting data, you must specify the same encryption context (a case-sensitive exact match) when decrypting the data. Otherwise, the request to decrypt fails with anInvalidCiphertextException.
That is the whole reason the member is worth modelling. An application that encrypts under {"tenant": "acme"} and decrypts without it is broken in production and passed here, which is exactly the class of defect an emulator exists to catch before AWS does.
The stub ciphertext changed format to carry it, and that is a compatibility break: a blob written by an earlier release no longer decodes, and Decrypt answers InvalidCiphertextException/400 for it rather than reading its fields under the new meanings. The old format was a delimited string; the new one is a base64-wrapped JSON envelope recording the key ID, the encryption algorithm, the encryption context and the plaintext. JSON rather than a delimiter is a correctness requirement, not a preference — an encryption context is caller-supplied text, so a key or a value may contain any delimiter, and an escaping bug would produce a ciphertext that decrypts to the wrong context, which is the failure the feature exists to detect. json.Marshal sorts map keys, so one input produces one blob however the caller ordered its context; a test asserts that two Encrypt calls whose contexts differ only in JSON key order return byte-identical ciphertext, because a blob that varied with the caller's ordering could not be replayed.
Two refusals become reachable, and they are ordered:
| Request | Substrate answers | Provenance |
|---|---|---|
| The context matches exactly | 200, and the plaintext round-trips | Published: "an exact case-sensitive match" |
| No context recorded, none supplied | 200 | Both members are Required: No |
| An empty context against an absent one, either direction | 200 | Substrate's reading — AWS documents no way to tell {} from an omitted member |
| The recorded and supplied contexts differ in any key, value or case | InvalidCiphertextException/400, naming both | Published end to end: the code by API_Encrypt's sentence above, the condition by the code's own gloss — "the specified ciphertext, or additional authenticated data incorporated into the ciphertext, such as the encryption context, is corrupted, missing, or otherwise invalid" |
| The encryption algorithm is not the one that encrypted the data | InvalidCiphertextException/400, naming both | The behaviour is published — "if you specify a different algorithm, the Decrypt operation fails" — the code is substrate's reading, resting on that gloss's "or otherwise invalid" |
| The blob is not a substrate ciphertext at all | InvalidCiphertextException/400 | The envelope carries a format marker, so a foreign, truncated or previous-format blob is refused rather than misread |
The algorithm is checked before the context. AWS orders the two nowhere, so this is recorded rather than matched: the algorithm is what a real implementation needs in order to attempt a decryption at all, where the context is authenticated once the decryption has happened, so a caller wrong about both hears about the one that would have stopped it first. Both checks run after the named-key, key-usage, key-state and key-spec checks — an algorithm the key does not admit is a fact about the key, knowable without holding the ciphertext, where a mismatch is a fact about these particular bytes.
InvalidCiphertextException rather than IncorrectKeyException for both, and the distinction is the point: IncorrectKeyException is about the key the caller named, which the section above answers, while these are about the request disagreeing with the blob. Both messages render both contexts, which is licensed rather than assumed — API_GenerateDataKey says "do not include confidential or sensitive information in this field. This field may be displayed in plaintext in CloudTrail logs and other output", and a refusal a caller cannot act on is not worth answering.
A context is recorded only under a symmetric encryption key, and that split is AWS's own. API_ReEncrypt is decisive: "a destination encryption context is valid only when the destination KMS key is a symmetric encryption KMS key. The standard ciphertext format for asymmetric KMS keys does not include fields for metadata." So an asymmetric ciphertext has nowhere to hold a context, and AWS publishes no code for sending one — recording it would invent a refusal AWS cannot produce. Substrate accepts the member and ignores it, and a test asserts the opposite shape from everything above: two different contexts across an Encrypt and a Decrypt under an RSA key must succeed.
The encryption algorithm is recorded for every key regardless, although AWS's asymmetric format holds no metadata either. AWS reaches the same observable answer cryptographically — decrypting RSA ciphertext with the wrong OAEP hash fails — and substrate models the observation rather than the mechanism. The divergence is confined to bytes inside the blob, which no caller is entitled to read; the answer, which every caller is, agrees.
ReEncrypt is where the two members are visibly different things.SourceEncryptionContext is matched against the incoming blob — "enter the same encryption context that was used to encrypt the ciphertext" — while DestinationEncryptionContext is written into the outgoing one. So a ReEncrypt is the call that changes a ciphertext's context, and one consequence is worth stating because nothing in AWS's text suggests otherwise: re-encrypting with no destination context strips the context rather than inheriting the source's. A caller relying on inheritance would find its data readable without the context it believed it had set, so a test pins it.
Both GenerateDataKey* operations record the context and neither publishes InvalidCiphertextException, which is consistent — the recording happens there and the refusal happens at Decrypt. API_GenerateDataKey states the round trip in its own words and substrate's tests assert it through the only thing a caller can observe: the wrapped data key decrypts under the recorded context and is refused without it.
Still unmodelled, and recorded here rather than left to be discovered. The two IAM condition keys built on this member — kms:EncryptionContext:<key> and kms:EncryptionContextKeys — are not evaluated, so a key policy or grant constraining a context is accepted and has no effect on whether a request is authorized. AWS also publishes limits substrate does not enforce: the total size of an encryption context, and the reserved aws: key prefix. Neither is observable through a response substrate produces today.
A key spec and a key usage must pair
CreateKey accepted any string for KeySpec and KeyUsage and paired them however a caller asked (#977). Both members are immutable — "you can't change the KeySpec after the KMS key is created", and the same sentence for KeyUsage — so an unvalidated value is not a member a later call can correct. It is a key that will never behave as its metadata says, and every operation reading either member reads a value AWS would never have stored: KeySpec "rsa2048" reports an algorithm list for no spec at all, and KeyUsage "ENCRYPT" reports none.
Three defects, three answers:
| Request | Substrate answers | Provenance |
|---|---|---|
A KeySpec or KeyUsage outside its published enum | ValidationError/400, listing the enum | Substrate's reading: API_CreateKey publishes 13 errors and ValidationException is not among them, so a malformed enum member comes from CommonErrors.html, as it does for the encryption algorithm above |
KeyUsage absent for any spec but SYMMETRIC_DEFAULT | ValidationError/400, naming the spec and what it admits | Published as a requirement: "this parameter is optional when you are creating a symmetric encryption KMS key; otherwise, it is required" — the code is substrate's reading, for the same reason |
| A well-formed pair the spec does not admit | UnsupportedOperationException/400, naming both and what the spec admits | Published for CreateKey, glossed "a specified parameter is not supported or a specified resource is not valid for this operation" |
The two codes say different things, which is why they are not one. ValidationError says that is not a key spec; UnsupportedOperationException says that is a key spec, and not with that usage. A caller that misspelled a value and one that chose an impossible combination have different fixes, and the first is refused before the second is considered — a KeySpec of "ECC_NIST_P25" with a KeyUsage of ENCRYPT_DECRYPT hears about the spec.
The default fires for exactly one spec. AWS's HMAC guidance settles it: "you must set the key usage even though GENERATE_VERIFY_MAC is the only valid key usage value for HMAC KMS keys." So KeyUsage is not defaulted to the single admissible value wherever there is one — it is defaulted only for SYMMETRIC_DEFAULT, and required everywhere else.
The pairing table is derived, not written. A spec admits a usage exactly when its algorithm list for that usage is non-empty, read from the four maps #974 already built for DescribeKey's algorithm members. Writing AWS's seven pairing bullets out a second time would create the drift class this file records elsewhere: two tables that must agree, with nothing making them. Here there is one table, and the metadata builder and the validator read it through the same accessor — so a key cannot be accepted for a usage whose algorithm list it would then not carry. The test transcribes AWS's bullets by hand, all 17 specs × 4 usages, so the derivation and the page disagree unless both match.
A key usage is also checked at the five cryptographic operations, which is InvalidKeyUsageException's first gloss bullet — "the KeyUsage value of the KMS key is incompatible with the API operation" — where the encryption-algorithm section above implements the second. AWS states the requirement: "for encrypting, decrypting, re-encrypting, and generating data keys, the KeyUsage must be ENCRYPT_DECRYPT." So Encrypt, Decrypt, GenerateDataKey, GenerateDataKeyWithoutPlaintext and both ends of ReEncrypt refuse a key whose usage is anything else.
That check runs before the key-state check and before the encryption-algorithm check. Before the state check because a key usage is permanent and a key state is not — telling a caller to enable a SIGN_VERIFY key sends it round a loop that cannot terminate, which is the same argument the rotation section above makes for refusing a non-symmetric key spec ahead of that key's state. Before the algorithm check so that one condition yields one message: a SIGN_VERIFY RSA key addressed by Encrypt hears about its usage, not about SYMMETRIC_DEFAULT being inadmissible for RSA.
What this changes for a CloudFormation template. AWS::KMS::Key documents KeySpec's default as SYMMETRIC_DEFAULT and KeyUsage's as ENCRYPT_DECRYPT, and substrate's deployer sends both unconditionally — so a template naming an asymmetric or HMAC KeySpec without a KeyUsage is now refused where it previously created a key AWS would not have. The defaults stay, because they are the resource type's own and because that template is invalid at AWS too: the property "is required for asymmetric KMS keys and HMAC KMS keys".
A data key needs a symmetric encryption key, and the usage check does not say so
The key-usage check above is not the same condition as "this key can wrap a data key", and #988 is the gap between them. An RSA_2048 key created with KeyUsage ENCRYPT_DECRYPT is a pair AWS publishes and CreateKey must accept, so it passed the usage check — and GenerateDataKey handed such a caller a wrapped data key and a 200.
AWS states the restriction four times over between the two pages, twice in a description and twice on the KeyId parameter itself:
API_GenerateDataKey— "to generate a data key, specify the symmetric encryption KMS key that will be used to encrypt the data key. You cannot use an asymmetric KMS key to encrypt data keys."API_GenerateDataKeyWithoutPlaintext— "you cannot use an asymmetric KMS key or a key in a custom key store to generate a data key."- Both, on
KeyId— "specifies the symmetric encryption KMS key that encrypts the data key. You cannot specify an asymmetric KMS key or a KMS key in a custom key store."
The plural in "data keys" is why this is not a condition on Encrypt as well. The corresponding sentence there is about data, and an RSA encryption key encrypts data perfectly well — the algorithm section above is the only thing Encrypt owes such a key. The observable form of the same rule is that these two operations take no EncryptionAlgorithm member at all: AWS gives a caller no way to name an asymmetric algorithm here, because no asymmetric key belongs here.
| Request | Substrate answers | Provenance |
|---|---|---|
SYMMETRIC_DEFAULT | 200, wrapping the data key | The only spec either operation accepts |
RSA_2048, RSA_3072, RSA_4096, SM2 with KeyUsage ENCRYPT_DECRYPT | InvalidKeyUsageException/400, naming the key spec | The restriction is published; the code is substrate's reading — see below |
An HMAC_* spec, or any signing or key-agreement spec | InvalidKeyUsageException/400, naming the key usage | Unreachable at this check: the usage check refuses such a key first, and CreateKey will not pair those specs with ENCRYPT_DECRYPT at all |
| A key in a custom key store | — | Unreachable: no stored key can have an origin other than AWS_KMS since #984 |
The code is substrate's reading of an unsplit bullet. Neither of InvalidKeyUsageException's two published bullets describes this condition exactly: the key's KeyUsage is ENCRYPT_DECRYPT, which is what the operation wants, and the operation specifies no encryption algorithm for the second bullet to find incompatible. What is wrong is the KeySpec alone — the second bullet's subject, reached by a route it does not describe. It is still the right answer on three grounds: it is the only code either page publishes about a key being the wrong kind for the operation (the other eight are two 500s, a grant token, a dry run, a key state, a disabled key, a missing key and an internal error); the restriction it enforces is published four times over; and the wrap has a fixed algorithm, so the second bullet does fit on the reading that the operation specifies SYMMETRIC_DEFAULT implicitly.
The message names the key spec, where the usage refusal names the key usage. One code carries three conditions across these operations, so the message is the only thing that tells them apart — and naming the usage here would be actively wrong, since the usage is the one thing about such a key that is correct. It also names GenerateDataKeyPair, because AWS sends an asymmetric caller there ("to generate an asymmetric data key pair, use the GenerateDataKeyPair or GenerateDataKeyPairWithoutPlaintext operation") and substrate implements neither. Saying so in the refusal rather than only here is deliberate: this refusal is where a caller is standing when it needs to know, and being redirected to an operation that answers an unknown-action error would be worse than being told the truth.
Ordered after the usage check and before the key-state check, and both halves are substrate's reading of conditions AWS states without precedence. Usage first, because a request naming a signing key is wrong about the operation rather than about the key material, and that refusal is uniform across all five cryptographic operations where this one covers two. Key state after, following the rotation section's argument exactly: a key spec is permanent — "you can't change the KeySpec after the KMS key is created" — while a key state is transient and has a remedy, so answering the state first would tell a caller that enabling the key makes the call succeed, which for an RSA key is false however many times it retries. Tests assert each side through the message, since the neighbouring refusals are one shared code and one shared 400.
One helper serves both operations, and that is a claim about this service's history rather than a preference: #961 found GenerateDataKeyWithoutPlaintext refusing nothing while its four siblings each refused something. A test compares the two refusals to each other, so two sites cannot answer one code with two explanations.
A key is reachable through the tagging API
TagResources, UntagResources and GetResources all reach a KMS key. The resolver and KMS's own three tagging operations share one key builder, so a key's tags are at one address whichever arm writes them, and a tag written through either is readable through the other.
An alias ARN is refused by the tagging API rather than followed to its key. The developer guide's list of what cannot be tagged — aliases, custom key stores, AWS managed keys, AWS owned keys, and keys in other accounts — is the boundary, and an alias is the one of those five substrate stores in the same namespace.
The namespace holds five kinds of key and only the key record stores tags, so the merge is guarded on a colon-terminated prefix. Without the colon, key: would match key_ids: and key_policy: and alias: would match alias_names: — and key_ids is a JSON array of identifier strings, which a tags merge would leave looking like a record. This is the third namespace to need that guard; cert/cert_arns and cfdist/cfdist_ids were the first two.
KMS is the first row whose tags are an array rather than a map, so the merge goes through a separate helper that takes the element's two field names as parameters. KMS spells them TagKey and TagValue — alone among the four services #835's remaining rows cover, the other three spelling them Key and Value. Both merge paths sort the result by key, because a slice built by ranging a Go map comes out in the map's hash order and one recorded run would not replay byte-identically.
A key pending deletion is still reported by GetResources if it has been tagged, or once was (#938). AWS says you may not tag such a key, but a listing is a read and the key exists until its waiting period elapses; refusing the write is a separate unmodelled behaviour recorded on #922.
CloudFormation resource types
| Type | Ref | Notes |
|---|---|---|
| AWS::KMS::Key | KeyId | |
| AWS::KMS::Alias | — |
Cost
KMS API requests: $0.03 per 10,000 requests.
CloudWatch Logs
Endpoint: logs.{region}.amazonaws.comProtocol: JSON (application/x-amz-json-1.1, X-Amz-Target: Logs_20140328.{Op})
Supported operations
| Operation | Notes |
|---|---|
| CreateLogGroup | |
| DeleteLogGroup | Also removes the group's streams and their events |
| DescribeLogGroups | Reports both ARN forms — see below; refuses a nextToken it did not issue |
| PutRetentionPolicy | retentionInDays must be one of the API's 22 enumerated values |
| DeleteRetentionPolicy | The documented way to make a group's events never expire |
| CreateLogStream | |
| DeleteLogStream | |
| DescribeLogStreams | Refuses a nextToken it did not issue |
| PutLogEvents | Accepts up to 10,000 events per call |
| GetLogEvents | Issues both nextForwardToken and nextBackwardToken; reads startFromHead; refuses a nextToken it did not issue |
| FilterLogEvents | Substring match on filterPattern; reports searchedLogStreams; refuses a nextToken it did not issue |
Lambda auto-creates /aws/lambda/{name} log groups.
Response members are camelCase
CloudWatch Logs is a JSON-1.1 service whose members are camelCase, and an SDK matches response members against the service model case-sensitively — a PascalCase member does not fail to parse, it parses to nothing, so a caller receives one empty object per resource with an HTTP 200 and no error. Earlier releases had DescribeLogGroups, DescribeLogStreams and GetLogEvents doing exactly that (FilterLogEvents did not), so a len() assertion passed while every field read raised KeyError. All four now emit the API's member names.
DescribeLogGroups reports both ARN forms the reference documents as distinct members: logGroupArn without a trailing :*, which is what a logGroupIdentifier input or a tagging API wants, and arn with it, which is what an IAM policy wants for most actions. They differ only in that suffix.
A group with no retention policy omits retentionInDays entirely rather than reporting 0, because the API has no value meaning "never" — the member's absence is the signal, which is why DeleteRetentionPolicy exists.
PutRetentionPolicy accepts only the enumerated day counts (1, 3, 5, 7, 14, 30, 60, 90, 120, 150, 180, 365, 400, 545, 731, 1096, 1827, 2192, 2557, 2922, 3288, 3653); anything else — including a plausible 45 or 100 — is an InvalidParameterException. Note that this service returns ResourceNotFoundException at HTTP 400, not 404, as its reference documents: the error code travels in the body's __type, not the status line. Every group- and stream-level not-found in the plugin answers that 400, and the three reads that answered an empty 200 for an absent log group now refuse it too — see a log group that does not exist is not an empty log group, which also records the one status deliberately left as it was (#1251).
All four paginating operations refuse a nextToken substrate could not have issued with InvalidParameterException / 400, rather than answering a well-formed page one — see CloudWatch Logs carried one block four times for the provenance, the one divergence the change deliberately leaves in place (the published 24-hour token expiry, which is declined rather than deferred), and the argument that the token is validated before any listing is read.
GetLogEvents pages by a pair of tokens
GetLogEvents is the one Logs paginator whose response carries two tokens, and whose termination rule is stated in terms of both. The reference's overview says "As long as the nextBackwardToken or nextForwardToken returned is NOT equal to the nextToken that you passed into the API call, there might be more log events available", and each member's own description adds "If you have reached the end of the stream, it returns the same token you passed in." Both are published with Length Constraints minimum 1, and the overview states flatly that "The returned tokens are never null."
Substrate emitted nextForwardToken only when a further page existed, and nextBackwardToken never. A caller written from the page therefore could not terminate: it sent an empty token, was returned an empty one, and either stopped on its first page believing the comparison had been met or spun. Both tokens are now present on every answer, including for an empty stream, and each names a position such that presenting it back yields the same token — so the published rule works as written in both directions (#1223).
The forward token names the position after the page; the backward token the position before it. That is what makes equality fall out arithmetically at each end rather than needing a special case: walking forward past the tail clamps to the same offset and returns the same token, and walking backward past the head clamps to zero and does the same. It is also why GetLogEvents does not share pageByOffsetToken with the other three — that helper omits its token on a final page, which is correct where a token's absence means done, and wrong here where equality means done. A full final page therefore still carries a forward token, and presenting it costs one round trip to an empty page. AWS describes exactly that: "Partially full or empty pages don't necessarily mean that pagination is finished."
The direction is on the wire, using the prefixes the reference's own examples publish. The page's example responses show "nextBackwardToken": "b/31132629274945519779805322857203735586714454643391594505" and "nextForwardToken": "f/31132629323784151764587387538205132201699397759403884544", so substrate prefixes b/ and f/ ahead of its offset encoding. The prefix is load-bearing rather than cosmetic: without it a backward token is a bare offset indistinguishable from a forward one, and would be read as a forward position — the same class of undetectably wrong page the token refusal above exists to prevent. One consequence is a wire-shape change: GetLogEvents no longer accepts the bare offset token the other three issue, and its own tokens no longer decode under them. A token presented without a valid direction prefix is refused with the same InvalidParameterException / 400.
startFromHead is read, and decides only the first page. The reference gives it a default of false — "If the value is true, the earliest log events are returned first. If the value is false, the latest log events are returned first" — so a tokenless call now answers the tail of the stream, where substrate previously always started at the head. Once a token is present the token's own direction decides. The page also says "If you are using a previous nextForwardToken value as the nextToken in this operation, you must specify true for startFromHead"; substrate does not enforce that, because no Logs page publishes a code for violating it and the token already carries its direction — inventing a refusal AWS does not document refusing is the thing #671's scope decision rules out.
The 24-hour expiry is declined here too. Both members publish "The token expires after 24 hours." and neither publishes a code for presenting an expired one; the argument is the same as for the other three and is set out above. A token past the end of a stream that has since been trimmed is clamped to a final empty page rather than refused, so a walk whose events were deleted mid-loop terminates instead of erroring.
CloudFormation resource types
| Type | Ref | Notes |
|---|---|---|
| AWS::Logs::LogGroup | LogGroupName | |
| AWS::Logs::LogStream | LogStreamName |
Cost
CloudWatch Logs ingestion: $0.50 per GB. Storage: $0.03 per GB-month.
EventBridge
Endpoint: events.{region}.amazonaws.comProtocol: JSON (X-Amz-Target: AWSEvents.{Op})
Supported operations
| Operation | Notes |
|---|---|
| CreateEventBus | |
| DescribeEventBus | |
| DeleteEventBus | |
| ListEventBuses | |
| PutRule | |
| DescribeRule | |
| DeleteRule | |
| ListRules | |
| PutEvents | Stores last 100 events in ring buffer |
| ListTargetsByRule |
CloudFormation resource types
| Type | Ref | Notes |
|---|---|---|
| AWS::Events::Rule | RuleArn |
Cost
EventBridge custom events: $1.00 per million events.
EventBridge Scheduler
Endpoint: scheduler.{region}.amazonaws.comProtocol: REST/JSON (path and HTTP method, no X-Amz-Target)
Supported operations
| Operation | Route | Notes |
|---|---|---|
| CreateSchedule | POST /schedules/{Name} | Answers 200, not 201 — see below |
| GetSchedule | GET /schedules/{Name} | |
| UpdateSchedule | PUT /schedules/{Name} | Replaces the whole configuration, as the page publishes — see below |
| DeleteSchedule | DELETE /schedules/{Name} | |
| ListSchedules | GET /schedules | Filters and cursor read from the published ScheduleGroup, NamePrefix, State, MaxResults and NextToken keys — not the lowerCamel ones the sibling operations use. A NextToken no previous call returned answers ValidationException / 400 rather than page one. All three filters are applied before the page is cut, so a page carries min(MaxResults, remaining matches) and never an empty array alongside a cursor |
What a create or update is refused for
API_CreateSchedule and API_UpdateSchedule publish exactly three Required: Yes body members, plus a required Name in the URI, and substrate checked none of them before #1008: a POST with an empty body created a schedule with no expression and no target, which GetSchedule then reported as a live resource. Every constraint below is checked before any state is read, and every failure is ValidationException/400 — the only refusal either page publishes for an input that fails a constraint, which is why the message rather than the code carries which constraint failed.
| Member | Published constraint | Refusal |
|---|---|---|
Name (URI) | Required: Yes, length 1–64, pattern [0-9a-zA-Z-_.]+ | names 'name' |
ScheduleExpression | Required: Yes, length 1–256 | names 'scheduleExpression' |
Target | Required: Yes | names 'target' |
Target.Arn | Required: Yes, length 1–1600 | names 'target.arn' |
Target.RoleArn | Required: Yes, length 1–1600 | names 'target.roleArn' |
FlexibleTimeWindow | Required: Yes | names 'flexibleTimeWindow' |
FlexibleTimeWindow.Mode | Required: Yes, Valid Values OFF | FLEXIBLE | names 'flexibleTimeWindow.mode' |
FlexibleTimeWindow.MaximumWindowInMinutes | Required: No, Valid Range 1–1440 | 0 and 1441 refused; absent accepted |
GroupName | Required: No, length 1–64, pattern [0-9a-zA-Z-_.]+ | names 'groupName' |
Description | length 0–512 | names 'description' |
State | Valid Values ENABLED | DISABLED | names 'state' |
MaximumWindowInMinutes is the one member whose absence is observably different from a zero, which is why the request type holds it as a pointer: an explicit 0 is outside the published range and is refused, where an absent member is accepted. AWS does not publish that a FLEXIBLE window requires the bound, so substrate does not invent that rule — a FLEXIBLE window with no maximum is accepted.
Three published constraints are deliberately not checked, and the omissions are recorded rather than silently taken:
Target.RoleArn's IAM-role ARN pattern. Substrate does not model the role, so refusing a string that is not an ARN would refuse a call this emulator otherwise serves without ever needing the value to resolve. ItsRequired: Yesand its length are checked.StartDate,EndDate,KmsKeyArnandActionAfterCompletionare published and unmodelled: substrate decodes none of them, so it validates none of them, and #1013's rule keeps them out of the response as well.- The templated-target objects (
EcsParameters,EventBridgeParameters,KinesisParameters,SageMakerPipelineParameters,SqsParameters,DeadLetterConfig) are likewise unmodelled, so they are neither decoded nor reported.
UpdateSchedule replaces the schedule, it does not merge into it
API_UpdateSchedule opens with the property verbatim:
Updates the specified schedule. When you call
UpdateSchedule, EventBridge Scheduler uses all the information that you have provided and replaces your schedule. You will lose any information that you haven't provided, such as a description.
Substrate assigned each optional member only when the request supplied a non-empty one, so an omitted Description, ScheduleExpressionTimezone, State, Target.Input or Target.RetryPolicy survived the update. A caller following AWS's own advice — send the whole configuration, or accept losing what you omit — saw a stale value instead of a reset. Since #1089 the create and the update resolve a body through one function, so the two doors cannot disagree about what an omitted member means.
State's default is the one part of this with no API Reference citation. The State entry is byte-identical on all four pages that carry it (API_UpdateSchedule, API_CreateSchedule, API_GetSchedule, API_ScheduleSummary) and none publishes a Default: line; the CLI and CloudFormation references are equally silent, and API_GetSchedule has no Examples section. ENABLED is documented in the User Guide instead — "By default, the EventBridge Scheduler enables your schedule" (scheduler/latest/UserGuide/getting-started.html) — and that is the citation substrate applies at both doors.
ClientToken came off the GetSchedule response with the same change. It is not among that page's fifteen published response elements — it is a request-only idempotency token, published on the create and the update and on no read. It was removed with the full-replace fix rather than on its own because an omitted member now reverts: leaving it would have made an unpublished field start changing under callers who never named it. The record keeps it as recorded intent.
CreateSchedule answers 200, not 201
API_CreateSchedule's Response Syntax opens HTTP/1.1 200. Substrate answered 201, on the reasonable but unpublished reading that a create is a creation; the page is the contract and it says 200.
The request body is decoded in the published spelling
Both handlers used to unmarshal a caller's body straight into substrate's storage types, whose JSON tags are snake_case (role_arn, retry_policy, maximum_window_in_minutes). A caller's Target.RoleArn, Target.RetryPolicy and FlexibleTimeWindow.MaximumWindowInMinutes therefore never survived the request — GetSchedule reported them empty however they were sent. The handlers now decode into request types carrying the published names and fold those into the stored shape, which is also what makes RoleArn's Required: Yes checkable at all: a member the decode drops cannot be found missing.
The query string is read in the published spelling, which differs per operation
The body's spelling was only half of it. ListSchedules read all five of its query parameters in the lowerCamel form — groupName, namePrefix, state, nextToken, maxResults — and no SDK sends those names, so every call arrived with none of the five set. The observable result was the worst available: the operation answered the default group's first twenty schedules to every request, attached a NextToken the next request then ignored, and applied no filter. A paginating loop either spun or reread the same page, and nothing in any response said so. Every test in the tree spoke substrate's dialect rather than AWS's, which is why it survived (#1226).
The split is the API Reference's own, not substrate's, which is why the fix is confined to one operation. API_ListSchedules publishes
GET /schedules?MaxResults={MaxResults}&NamePrefix={NamePrefix}&NextToken={NextToken}&ScheduleGroup={GroupName}&State={State}— PascalCase throughout, and the group is bound to ScheduleGroup even though the parameter list calls it GroupName, so GroupName never appears on the wire. API_GetSchedule publishes GET /schedules/{Name}?groupName={GroupName} and API_DeleteSchedule publishes DELETE /schedules/{Name}?clientToken={ClientToken}&groupName={GroupName} — lowerCamel, and a different key for the same concept. Those two handlers were already right and were deliberately left alone; emulator/scheduler_query_keys.go names both spellings in one place so neither can later be "corrected" into the other.
The lowerCamel names are not accepted as aliases for ListSchedules. AWS ignores a query parameter its model does not carry, so honoring one would be the same defect facing the other way: a call that filters against substrate and silently does not against AWS. ?maxResults=1 now reads the default page, which is what AWS answers.
Two readings of substrate's own remain, and are recorded rather than fixed here. MaxResults has no published default — the page publishes only a Valid Range of 1–100 — so the page size of 20 an absent MaxResults gets is substrate's choice. And a MaxResults above the published maximum is clamped to 100 rather than refused, as is a value of zero or below, which is silently ignored; refusing an out-of-range page size is a class of its own and is not this operation's alone.
An empty Name reaches the operation the caller named
parseSchedulerOperation used to normalise /schedules/ to /schedules, so a caller that built the path from an empty variable got ListSchedules — every schedule in the group, answered 200, to a request for one schedule. All four empty-name guards below the router were therefore dead code. Since #1009 only /schedules is the collection route, and /schedules/ reaches GetSchedule, CreateSchedule, UpdateSchedule or DeleteSchedule on its own verb and is refused there naming 'name'. See A request body that will not parse above for the whole-tree inventory of that fold.
Cost
Not modelled.
CloudWatch
Endpoint: monitoring.{region}.amazonaws.comProtocol: Smithy RPC v2 CBOR, awsJson1_0 and AWS Query — see below
Protocols
CloudWatch is the one service whose model declares three wire protocols at once. Its service shape, com.amazonaws.cloudwatch#GraniteServiceVersion20100801, carries aws.protocols#awsQuery, aws.protocols#awsJson1_0, smithy.protocols#rpcv2Cbor andaws.protocols#awsQueryCompatible, and its clients pick differently: aws-sdk-go-v2 sends CBOR, the AWS CLI and boto3 send awsJson1_0, and a hand-rolled client sends a query form. All three are served, so which one you use is your choice and not a constraint (#785).
| Client | Request | Response |
|---|---|---|
aws-sdk-go-v2 | POST /service/GraniteServiceVersion20100801/operation/{Op}, Content-Type: application/cbor, Smithy-Protocol: rpc-v2-cbor | CBOR, Smithy-Protocol: rpc-v2-cbor |
| AWS CLI, boto3 | POST /, X-Amz-Target: GraniteServiceVersion20100801.{Op}, Content-Type: application/x-amz-json-1.0 | JSON, application/x-amz-json-1.0 |
| query client | POST / with Action={Op} in a form body | <{Op}Response><{Op}Result>…, text/xml |
The Query path is byte-for-byte what earlier releases served, with two deliberate exceptions. DescribeAlarmsForMetric now answers <DescribeAlarmsForMetricResponse> / <DescribeAlarmsForMetricResult>; it previously borrowed DescribeAlarms' element names, which the query protocol does not permit. EnableAlarmActions and DisableAlarmActions previously emitted a document whose opening tag was literally <placeholder> and whose closing tag was the operation's — not well-formed XML at all. MetricAlarms and GetMetricData's Messages are also now always present, where an empty one used to be omitted.
An operation with a smithy.api#Unit output answers with no body and no Content-Type. Six of the ten do: PutMetricAlarm, DeleteAlarms, SetAlarmState, EnableAlarmActions, DisableAlarmActions and PutMetricData. On the CBOR path they return HTTP 200 carrying only Smithy-Protocol: rpc-v2-cbor — the rule comes from the no_output protocol test in smithy-protocol-tests/model/rpcv2Cbor/empty-input-output.smithy, which lists Content-Type in its forbidHeaders, rather than from the protocol spec page. An empty CBOR map is merely tolerated by the companion NoOutputClientAllowsEmptyCbor test, and GetMetricData used to send exactly that one byte for any caller whose Content-Type mentioned CBOR — a response no client could tell from "not implemented". On the JSON path the same operations answer {}, because awsJson1_0 has no equivalent rule and botocore reads a zero-length JSON body as a parse failure. On the Query path they answer a response wrapper with no result element.
Absent and present-but-empty are different answers. A member substrate does not model is omitted, so a typed client reads it as nil and can tell it was never set: a MetricAlarm carries no timestamps, Unit, ExtendedStatistic, DatapointsToAlarm or TreatMissingData, and an alarm created without OK actions omits OKActions rather than returning an empty list. A member substrate models as empty is present: DescribeAlarms always returns MetricAlarms, a metric published without dimensions returns an empty Dimensions, and GetMetricData returns empty MetricDataResults and Messages because substrate records a metric's identity but not its time series (running the workload behind the API is outside what substrate models).
Errors name the modeled shape, not the query code. A CBOR or JSON refusal sets __type to the absolute shape ID — com.amazonaws.cloudwatch#InvalidParameterValueException, not InvalidParameterValue — with the HTTP status from the shape's @httpError, and per the protocol the Code body member must not be what a client keys on. A caller that sent X-Amzn-Query-Mode: true (both reference clients do) additionally gets x-amzn-query-error: <QueryCode>;Sender|Receiver, which is how a query-compatible client recovers the code its older error handling branches on. A body substrate cannot decode is refused with SerializationException and HTTP 400; that choice is substrate's, since neither the protocol nor the CloudWatch model names a shape for it.
Substrate's CBOR writer is definite-length and minimal; its reader is not. The protocol spec never mentions indefinite-length encoding, yet the normative test vectors use it almost everywhere and the reference implementation emits it, so a reader must accept indefinite-length maps, arrays and strings, non-minimal length arguments, a double arriving as a float16, float32, float64 or an integer, tag 1 as either an integer or a float, and 0xf7 as null — substrate's does, and skips an unrecognized member's value recursively. Its writer chooses definite lengths with minimal arguments so that the same response always produces the same bytes, which is what makes a recorded event replayable byte-for-byte. That is a documented divergence from the reference writer's output and conformant either way.
Supported operations
| Operation | Notes |
|---|---|
| PutMetricData | Records each datum's name and namespace for ListMetrics; values are discarded |
| ListMetrics | Filters on Namespace and MetricName; Dimensions is always empty |
| GetMetricData | Always empty MetricDataResults — no time series is modeled |
| PutMetricAlarm | Preserves an existing alarm's state on re-put |
| DescribeAlarms | Filters on AlarmNames and StateValue; paginates on MaxRecords/NextToken; refuses a NextToken substrate did not issue with InvalidNextToken — see A pagination token substrate never issued |
| DescribeAlarmsForMetric | Filters on MetricName and Namespace; does not paginate |
| DeleteAlarms | |
| SetAlarmState | ResourceNotFoundException for an unknown alarm |
| EnableAlarmActions | |
| DisableAlarmActions |
CloudFormation resource types
| Type | Ref | Notes |
|---|---|---|
| AWS::CloudWatch::Alarm | AlarmName |
Cost
CloudWatch metrics: $0.30 per metric per month. Alarms: $0.10 per alarm per month.
ACM
Endpoint: acm.{region}.amazonaws.comProtocol: JSON (X-Amz-Target: CertificateManager.{Op})
Supported operations
| Operation | Notes |
|---|---|
| RequestCertificate | Certificate auto-transitions to ISSUED status |
| DescribeCertificate | |
| DeleteCertificate | |
| ListCertificates | |
| AddTagsToCertificate | |
| RemoveTagsFromCertificate | |
| ListTagsForCertificate | Reports Tags sorted by key — see A tag set read back out of a map |
| RenewCertificate |
A certificate is reachable through the tagging API
The Resource Groups Tagging API reaches an ACM certificate as of #835: TagResources, UntagResources and GetResources all address it, and a tag written through any of them is readable through ListTagsForCertificate. All four resolve the certificate's state key through one builder, so none can address a certificate another would not.
An ARN naming any other ACM resource type is refused rather than resolved to a certificate. AWS scopes these three operations to one type in prose — "This action applies only to the certificate resource type. For all other ACM resource types, use TagResource instead" — and publishes at least one other type with its own ARN shape, the ACME endpoint. The published CertificateArn pattern does not itself restrict the resource portion, so the restriction is the prose's and the type match is anchored on the ARN's own first path segment (#910).
Only the certificate record stores tags. The cert_arns: index lives in the same namespace and is a JSON array of ARN strings, so the merge sits behind a guard whose prefix is tested colon-terminated — cert alone matches cert_arns too.
A CertificateArn is refused in three tiers, all at HTTP 400
Every code ACM publishes on the five operations that take a CertificateArn — DescribeCertificate, DeleteCertificate, AddTagsToCertificate, RemoveTagsFromCertificate and ListTagsForCertificate — carries HTTP 400. Substrate answered ResourceNotFoundException at 404, a status ACM publishes nowhere, and sent a malformed ARN through the state lookup so that the answer was "not found" for a string that could not name a certificate at all (#921).
CertificateArn | Answer | Whose reading |
|---|---|---|
| Absent, length outside 20–2048, or failing the published pattern | ValidationException/400 | AWS's — "The supplied input failed to satisfy constraints of an AWS service", and CertificateArn publishes both constraints |
| Pattern satisfied but naming no certificate: empty Region, another ACM resource type, a type with no identifier, or something nested under a certificate | InvalidArnException/400 | Substrate's — AWS publishes the code but describes it as "does not refer to an existing resource", which is about non-existence, not syntax |
| A well-formed certificate ARN with no record | ResourceNotFoundException/400 | AWS's — "The specified certificate cannot be found in the caller's account or the caller's account cannot be found" |
The absent-CertificateArn case is refused on the published minimum length rather than by a separate required-member check, which is why it no longer answers InvalidParameterException — a code ListTagsForCertificate and DescribeCertificate do not publish at all, their error lists being three codes long.
ACM's own operations and the tagging API's resolver run the same validation, so neither can accept an ARN the other refuses. The tagging API renders its refusal as a FailedResourcesMap entry rather than an error response, so the shapes differ and the decision does not.
The unparseable-body case was left open by #921 and settled by #950, which confirmed that deferral's guess: it is a protocol-level failure whose code belongs to ACM's common errors rather than to any one operation's published list, so it answers ValidationError/400 from that page. All six sites previously answered InvalidParameterException, which ACM publishes on only three of the six. See A request body that will not parse above for why ValidationException is not the answer either, near as the name is, and why the two codes now sit side by side in this plugin on purpose.
One answer is deliberately left as it is. ACM publishes AccessDeniedException at 400 while substrate answers it at 403, from the central authorization check every service shares — moving it for one service would have the emulator answer two statuses for one decision, so it is recorded here rather than changed.
CloudFormation resource types
| Type | Ref | Notes |
|---|---|---|
| AWS::CertificateManager::Certificate | CertificateArn |
Cost
ACM certificates are free.
API Gateway (REST)
Endpoint: apigateway.{region}.amazonaws.comProtocol: REST/JSON
Response shape: every member is lowerCamel as the service model spells it (id, name, rootResourceId, resourceMethods, methodIntegration, …), and collection responses nest their elements under item — singular, because that is the locationName of the items member. GetUsage uses a third spelling, values, and is not routed.
Every collection whose URI publishes limit and position now reads the pair — GetBasePathMappings, GetRestApis, GetResources, GetDeployments, GetAuthorizers, GetApiKeys and GetUsagePlans, seven of the eight — see Two more cursors published and unread for the first and Six more v1 collections read the pair for the other six (#1025). A collection that fits in one page leaves the member unset, so it is omitted rather than sent empty: a caller must not be handed a token for a page that does not exist. The eighth collection, GetStages, publishes neither parameter and lists no position response member, so its single page is what AWS describes rather than a gap. Seven of the eight handlers also could not read a query parameter as written — their signatures took no request — which is why GetBasePathMappings converted without a signature change and each of the six needed one; GetStages has nothing to read, so its signature stays as it is. Earlier releases sent PascalCase members under an items envelope, which an AWS SDK parsed to an empty result with no error (#529).
Supported operations
| Operation | Notes |
|---|---|
| CreateRestApi | Auto-creates root / resource |
| GetRestApi | |
| DeleteRestApi | |
| GetRestApis | Pages on limit/position; ascending API ID (#1025) |
| CreateResource | |
| GetResource | |
| DeleteResource | |
| GetResources | Pages on limit/position; ascending resource ID. embed unread — methods always reported (#1025) |
| PutMethod | |
| GetMethod | |
| DeleteMethod | |
| PutIntegration | |
| GetIntegration | |
| CreateDeployment | |
| GetDeployment | |
| GetDeployments | Pages on limit/position; ascending deployment ID, which is unrelated to createdDate (#1025) |
| CreateStage | |
| GetStage | |
| CreateAuthorizer | |
| GetAuthorizer | |
| GetAuthorizers | Pages on limit/position; ascending authorizer ID (#1025) |
| CreateApiKey | |
| GetApiKey | |
| GetApiKeys | Pages on limit/position; ascending key ID. customerId, includeValues and name unread; the published warnings member is unmodelled and omitted (#1025) |
| DeleteApiKey | |
| CreateUsagePlan | |
| GetUsagePlan | |
| GetUsagePlans | Pages on limit/position; ascending plan ID. keyId unread (#1025) |
| DeleteUsagePlan |
CloudFormation resource types
| Type | Ref | Notes |
|---|---|---|
| AWS::ApiGateway::RestApi | RestApiId | |
| AWS::ApiGateway::Resource | ResourceId | |
| AWS::ApiGateway::Method | method ID | Generated {stack}-{logical}-{suffix}, matching AWS's documented example (#843) |
| AWS::ApiGateway::Deployment | DeploymentId | |
| AWS::ApiGateway::Stage | StageName |
Cost
API Gateway REST API calls: $3.50 per million calls.
API Gateway v2 (HTTP)
Endpoint: apigateway.{region}.amazonaws.comProtocol: REST/JSON (/v2/ prefix)
Routing: v1 and v2 are one endpoint and one SigV4 signing name — an apigatewayv2 client signs as apigateway, uses the same hostname, and sends no X-Amz-Target — so Substrate discriminates them by path: a request under /v2/ that would otherwise resolve to apigateway is routed to the v2 plugin. Every requestUri in the apigatewayv2 API is under /v2/ and none of v1's is, so the split is exact. A consumer pointing an apigatewayv2 client at Substrate needs no special configuration.
Response shape: every member is lowerCamel as the service model spells it (apiId, routeId, integrationId, apiEndpoint, protocolType, …), and collection responses nest their elements under items — lowercase, unlike v1's singular item. Substrate returns every element in one page and honours no pagination token, so no response carries a nextToken. Two members v1 has are absent here because the v2 model does not declare them: a route, integration, stage or API mapping reports no apiId (the API is a path parameter of the request), and a domain name reports no regionalDomainName — v2 nests that hostname as domainNameConfigurations[].apiGatewayDomainName. Earlier releases sent PascalCase members under an Items envelope, which an AWS SDK parsed to an empty result with no error (#529).
Supported operations
| Operation | Notes |
|---|---|
| CreateApi | |
| GetApi | |
| DeleteApi | |
| GetApis | |
| CreateRoute | |
| GetRoute | |
| DeleteRoute | |
| CreateIntegration | |
| GetIntegration | |
| CreateStage | |
| GetStage | |
| CreateAuthorizer |
CloudFormation resource types
| Type | Ref | Notes |
|---|---|---|
| AWS::ApiGatewayV2::Api | ApiId | |
| AWS::ApiGatewayV2::Route | RouteId | |
| AWS::ApiGatewayV2::Integration | IntegrationId | |
| AWS::ApiGatewayV2::Stage | StageName |
Cost
API Gateway HTTP API calls: $1.00 per million calls.
AppSync
Endpoint: appsync.{region}.amazonaws.comProtocol: REST/JSON (path and HTTP method, no X-Amz-Target)
Routing: every operation is identified by its verb and its path, and Substrate matches the path segment by segment in the spelling the operation's own Request Syntax publishes. That is worth stating because it was not true until #1065: the data-source segment was matched as DataSources and the api-key segment as ApiKeys, where AWS publishes datasources and apikeys. Path matching is case-sensitive, so seven implemented operations — the five data-source ones and the two api-key ones — were unreachable through the URIs every AWS SDK builds, and answered UnknownOperationException/404 instead.
Supported operations
Twenty-four operations are routed. The Route column is the published requestUri; nothing in the table is a Substrate spelling.
| Operation | Route | Notes |
|---|---|---|
| CreateGraphqlApi | POST /v1/apis | name is required; authenticationType defaults to API_KEY |
| ListGraphqlApis | GET /v1/apis | one page, no cursor |
| GetGraphqlApi | GET /v1/apis/{apiId} | |
| UpdateGraphqlApi | POST /v1/apis/{apiId} | |
| DeleteGraphqlApi | DELETE /v1/apis/{apiId} | 200 with an empty body — see below |
| CreateDataSource | POST /v1/apis/{apiId}/datasources | name and type are required |
| ListDataSources | GET /v1/apis/{apiId}/datasources | one page, no cursor |
| GetDataSource | GET /v1/apis/{apiId}/datasources/{name} | |
| UpdateDataSource | POST /v1/apis/{apiId}/datasources/{name} | merges the members it decodes |
| DeleteDataSource | DELETE /v1/apis/{apiId}/datasources/{name} | 200 with an empty body |
| CreateResolver | POST /v1/apis/{apiId}/types/{typeName}/resolvers | fieldName is required; kind defaults to UNIT |
| ListResolvers | GET /v1/apis/{apiId}/types/{typeName}/resolvers | filtered to the type in the path |
| GetResolver | GET /v1/apis/{apiId}/types/{typeName}/resolvers/{fieldName} | |
| UpdateResolver | POST /v1/apis/{apiId}/types/{typeName}/resolvers/{fieldName} | |
| DeleteResolver | DELETE /v1/apis/{apiId}/types/{typeName}/resolvers/{fieldName} | 200 with an empty body |
| CreateFunction | POST /v1/apis/{apiId}/functions | |
| ListFunctions | GET /v1/apis/{apiId}/functions | one page, no cursor |
| GetFunction | GET /v1/apis/{apiId}/functions/{functionId} | |
| DeleteFunction | DELETE /v1/apis/{apiId}/functions/{functionId} | 200 with an empty body |
| CreateApiKey | POST /v1/apis/{apiId}/apikeys | expires is read and bounded — see An api key's expiry |
| ListApiKeys | GET /v1/apis/{apiId}/apikeys | one page, no cursor |
| StartSchemaCreation | POST /v1/apis/{apiId}/schemacreation | stores the definition, answers PROCESSING |
| GetIntrospectionSchema | GET /v1/apis/{apiId}/schema | a fixed placeholder schema |
| ExecuteGraphQL | POST /graphql | a stub; see The execution endpoint answers a stub below |
UpdateFunction is the one operation with a routed sibling under the same segment that Substrate does not implement: POST /v1/apis/{apiId}/functions/{functionId} resolves to nothing and is refused. The same is true of UpdateApiKey and DeleteApiKey under apikeys/{id}, and of every operation AppSync publishes outside these five segments — the merged-API operations, AssociateApi, the SourceApiAssociation family, the tagging trio, EvaluateMappingTemplate, and the Event API operations. All of them answer UnknownOperationException/404, per An operation substrate does not implement above.
ExecuteGraphQL is reachable two ways, because AWS gives it its own host. A request to POST /graphql on the control-plane endpoint resolves to it, and so does any request to a host containing .appsync-api. — the per-API hostname CreateGraphqlApi reports back under uris.GRAPHQL. A consumer that takes the URI from the create response and posts to it therefore reaches the same handler without configuring anything.
A segment carries its verb and its tail, not just its name
Three arms of the router used to match on the segment name alone, and each of the three was a hazard of a different size:
apikeysignored its tail, so anyPOSTunder the segment resolved toCreateApiKey. That was invisible while the segment was misspelled, and lowercasing it without a gate would have been worse than the defect being fixed:UpdateApiKeyisPOST /v1/apis/{apiId}/apikeys/{id}, so a consumer extending a key's expiry would have been answered 200 carrying a second, newly minted credential instead of the 404 an unimplemented operation owes. Only the bare segment reachesCreateApiKeyandListApiKeys.schemacreationignored its verb, so aGETperformed a write — it stored the request'sdefinitionand reportedPROCESSING. AWS publishesPOSTonly, and onlyPOSTreaches it now.schemaignored its verb the same way; AWS publishesGET.
The datasources, types and functions arms already checked both, which is why only these three moved.
A delete answers 200, not 204
All four delete operations — DeleteGraphqlApi, DeleteDataSource, DeleteResolver, DeleteFunction — open their Response Syntax HTTP/1.1 200 and state it in words: "If the action is successful, the service sends back an HTTP 200 response with an empty HTTP body." Substrate answered 204 at all four, on the reasonable but unpublished reading that an empty body is a No Content. The body stays empty rather than becoming {}, because an empty body is what the pages promise and a member-less object is not the same thing.
Each of those four operations had a test, and each test asserted the 204 — so the tests that existed to check the operations were pinning the divergence. That is the same shape as CloudFront's Id leak (#1091) and Kinesis's shared describe builder (#1076): a defect survives precisely where a test records it.
What a refusal reports
Every AppSync page publishes the same small error set, and all of Substrate's refusals come from it:
| Condition | Code | Status |
|---|---|---|
| a body that will not parse | BadRequestException | 400 |
| a required member absent | BadRequestException | 400 |
| an API, data source, resolver or function that does not exist | NotFoundException | 404 |
| a verb and path that resolve to no operation | UnknownOperationException | 404 |
BadRequestException is checked after the parent API is resolved on every child operation, so a malformed CreateDataSource against an absent API reports NotFoundException. AppSync's ConcurrentModificationException/409, UnauthorizedException/401 and InternalFailureException/500 are published and have no site: Substrate serialises requests, so no modification is concurrent, and it has no fault to report where AWS would report an internal failure. UnauthorizedException is the one of the three with a condition that could arise, and an AppSync request does pass through the cross-service IAM gate like every other service's — but that gate answers its own AccessDeniedException/403, the code Substrate uses for every JSON-protocol service, not AppSync's published 401. So the 401 has no site either.
The execution endpoint answers a stub
POST /graphql answers {"data": {}, "errors": null} for any query. Executing a GraphQL document against a schema and a resolver chain is running the workload behind the API, not observing the API, so it sits on the far side of the boundary doc.go draws — the same reading that keeps a Lambda's handler from being executed. The schema a caller uploads through StartSchemaCreation is stored as recorded intent and is not parsed; GetIntrospectionSchema answers a fixed placeholder rather than an introspection of it.
ARNs are deterministic
| Resource | ARN |
|---|---|
| GraphQL API | arn:aws:appsync:{region}:{account}:apis/{apiId} |
| Data source | arn:aws:appsync:{region}:{account}:apis/{apiId}/datasources/{name} |
| Resolver | arn:aws:appsync:{region}:{account}:apis/{apiId}/types/{typeName}/resolvers/{fieldName} |
| Function | arn:aws:appsync:{region}:{account}:apis/{apiId}/functions/{functionId} |
An API ID is 13 hex characters and a function or api-key ID is 26, both from the shared random source, so they differ between runs but are recorded in the event log and reproduce on replay.
The wire is projected from the state, not handed over
Four AppSync records used to be answered straight out of state, and the persisted shape is not the published one (#1121):
- The
graphqlApiobject spelled its ARNapiArn, whereAPI_GraphqlApipublishesarn— "The Amazon Resource Name (ARN)".aws.ToString(out.GraphqlApi.Arn)was therefore""with no error atCreateGraphqlApi,GetGraphqlApi,UpdateGraphqlApiandListGraphqlApis: a silent wrong answer rather than a refusal. The ARN substrate computes was always right; only the member name was wrong, and the value is unchanged. - The same object carried
regionandaccountId, which the page publishes nowhere. That is #756's class, whose worked instance is ECR. - A data source, resolver and function each carried an
apiId.API_DataSource,API_ResolverandAPI_FunctionConfigurationpublish no such member: the API is the path segment the request was addressed to, not data the shape carries — the same reading API Gateway v2'sRouteshape records.
Each response is now rendered from a type tagged from the API model and projected from the record (appsync_wire.go), which is the pattern API Gateway v1 (#529), DynamoDB (#1013) and ECR (#1090) already follow. The records themselves are unchanged, deliberately: they are what state.Put writes and what a replay reads back, so retagging a persisted field in place would make an already-recorded run decode differently. Members AppSync publishes but substrate does not model are absent from the projection rather than present and empty.
Because AWS::AppSync::GraphQLApi's Ref and Fn::GetAtt Arn are read out of the plugin's own response rather than rebuilt in the deployer, the CloudFormation reader moved with the rename.
An api key's expiry
API_CreateApiKey publishes expires as an optional request member and states the default in words: "From the creation time, the time after which the API key expires. The date is represented as seconds since the epoch, rounded down to the nearest hour. The default value for this parameter is 7 days from creation time."
| Request | Answer |
|---|---|
No expires | 7 days from the simulated clock, rounded down to the hour |
expires between 1 and 365 days out | that instant, rounded down to the hour |
expires under 1 day or over 365 days out | ApiKeyValidityOutOfBoundsException/400 |
The bound is the one the exception's own message states: "The API key expiration must be set to a value between 1 and 365 days from creation (for CreateApiKey) or from update (for UpdateApiKey)." It is checked against the value the caller sent, with the hour rounding applied afterwards — rounding first would refuse an expires exactly one day out, which the sentence admits, because the rounding is how the timestamp is represented rather than part of the constraint. AWS publishes no order for the two.
deletes is answered on every key, derived as 60 days past expires and rounded the same way, from API_ApiKey's "Expired API keys are kept for 60 days after the expiration time." It is derived in the projection rather than persisted, so a key recorded before this behaviour existed still reports the published value instead of a zero.
Until #1122, every key expired 365 days out and a caller's expires was decoded nowhere — so a request for a 30-day key was answered with a year-long one and told it succeeded, and the published refusal had no site to fire from. The from update half of the bound arrives with UpdateApiKey, which is unrouted: a POST under apikeys/{id} answers UnknownOperationException/404 rather than minting a second credential.
Known divergences in the wire shape
These are recorded rather than fixed, each with the issue that owns it, so that a consumer reading this page is not surprised by a member:
- Tagging is not modelled.
CreateGraphqlApistores atagsmap and reports it back, butTagResource,UntagResourceandListTagsForResourceare unrouted and an AppSync ARN is not a Resource Groups Tagging resource here — see Resource Groups Tagging for the services that are. - No listing pages. Every collection above answers all of its members and no
nextToken, so a caller's pagination loop terminates on the first response. See The order a listing returns its members in for the tiering that governs which listings are ordered.
CloudFormation resource types
| Type | Ref | Notes |
|---|---|---|
| AWS::AppSync::GraphQLApi | the API ARN | Fn::GetAtt ApiId is the ID; GraphQLEndpointArn is empty |
| AWS::AppSync::DataSource | the data source ARN | |
| AWS::AppSync::Resolver | the resolver ARN | physical ID is TypeName.FieldName |
| AWS::AppSync::FunctionConfiguration | the function ARN |
All four Refs are the resource's ARN, which is what each type's Return values section publishes, so a child resource must name its API by Fn::GetAtt ["Api", "ApiId"] and not by Ref — the Ref form builds /v1/apis/arn:aws:appsync:…/datasources and reaches no operation. Substrate answers both correctly, and its own CloudFormation tests now use the Fn::GetAtt form and assert every deployed resource's error, physical ID and ARN (#1123 — they previously used Ref, deployed three resources that all failed, and passed, because Deploy reports a refusal on the resource rather than as a returned error).
Cost
AppSync query and mutation operations: $4.00 per million, charged on ExecuteGraphQL and on CreateGraphqlApi. No other AppSync operation is priced.
Step Functions
Endpoint: states.{region}.amazonaws.comProtocol: JSON (X-Amz-Target: AWSStepFunctions.{Op})
AmazonStates was documented here previously and is not what any client sends; the Step Functions model's target prefix is AWSStepFunctions (#739). Substrate routes both.
Supported operations
| Operation | Notes |
|---|---|
| CreateStateMachine | tags is an array of {key, value} objects; the ARN is minted from the caller's account and Region; the definition must be a JSON object and a structurally valid state machine — see below (#996, #1073); name, roleArn and type are checked against their published constraints, and a repeat is idempotent — see below (#1072) |
| DescribeStateMachine | Addressed by ARN — see below |
| UpdateStateMachine | Addressed by ARN; a supplied definition is checked the same way CreateStateMachine checks one (#996, #1073) |
| DeleteStateMachine | Addressed by ARN; idempotent — an ARN naming nothing is a 200; synchronous, so no DELETING status is observable (#995) |
| ListStateMachines | Scoped to the caller's own account and Region |
| StartExecution | Returns RUNNING status immediately; the execution ARN is minted in the state machine's account and Region |
| StartSyncExecution | EXPRESS only — a STANDARD state machine is StateMachineTypeNotSupported/400 (#996); the express execution ARN is minted in the state machine's account and Region, and no record is stored for it; twelve of the fourteen published response members are answered — see below (#1071) |
| DescribeExecution | Addressed by ARN. The execution runs to a terminal status at StartExecution, so this reports rather than advances it; error and cause are answered on a failed execution — see below (#1071) |
| StopExecution | Addressed by ARN |
| ListExecutions | Exactly one of stateMachineArn or mapRunArn — see below |
| GetExecutionHistory | Addressed by ARN |
| CreateActivity | tags is an array of {key, value} objects; the ARN is minted from the caller's account and Region; name is checked against the same published constraints as CreateStateMachine's, and a repeat is idempotent on the name alone — see below (#1072) |
| DescribeActivity | Addressed by ARN — see below |
| ListActivities | Scoped to the caller's own account and Region |
| DeleteActivity | Addressed by ARN; idempotent — an ARN naming nothing is a 200 (#995) |
| TagResource | State machine or activity — see below |
| UntagResource | State machine or activity — see below |
| ListTagsForResource | tags sorted by key — see below |
An ARN addresses the resource it names, at every operation
The account, the Region, the resource type and the name all come from the ARN, never from the calling request. Every operation that takes an ARN resolves it through one parser, which takes no request context at all — so the guarantee is structural rather than something each of the fourteen call sites has to remember. That is the arrangement ECS has had since #826, and it is the rule #826 established for SQS and DynamoDB and #845 carried across the tagging API's resolver.
| Resource | ARN | State key |
|---|---|---|
| State machine | arn:aws:states:{region}:{account}:stateMachine:{name} | statemachine:{account}/{region}/{name} |
| Activity | arn:aws:states:{region}:{account}:activity:{name} | activity:{account}/{region}/{name} |
| Execution | arn:aws:states:{region}:{account}:execution:{stateMachine}:{execution} | execution:{account}/{region}/{stateMachine}/{execution} |
| Express execution | arn:aws:states:{region}:{account}:express:{stateMachine}:{execution} | none — no record is kept |
The tagging operations were audited against the rule in #910 and the other eleven in #912. TagResource, UntagResource and ListTagsForResource each describe resourceArn as "the Amazon Resource Name (ARN) for the Step Functions state machine or activity", so those two are the whole taggable set; the execution rows above are addressable by the execution operations only.
Before #910 and #912 every one of the fourteen took the resource name from the ARN's last colon-separated segment and the account and Region from the caller's own request context. Three separate things followed:
- An ARN naming another account's or another Region's resource reached the caller's own same-named one.
DescribeStateMachinedisclosed it,UpdateStateMachinerewrote it,DeleteStateMachineandDeleteActivityremoved it,StopExecutionaborted it,UntagResourcestripped its tags — every one answering200.UntagResourceandStopExecutionare the damaging directions: stripping a tag can turn anaws:ResourceTagDenyinto an allow, and aborting the wrong execution destroys work. - The resource-type segment was never read. The tagging check was
strings.Contains(arn, ":stateMachine:"), a substring test over the whole ARN, so a name carrying that text satisfied it as readily as a type segment did:arn:aws:states:{region}:{account}:activity:x:stateMachine:ytook the state-machine branch. The other eleven did not check the type at all, so an activity ARN atDescribeStateMachinelooked for a state machine named after the activity, and an execution ARN there looked for one named after the execution. - An execution ARN's two names were reconstructed by stripping one segment, which is right only for an ARN of exactly that arity.
The type comparison is exact and case-sensitive, because AWS distinguishes these resources by the literal segment alone — stateMachine with a capital M against activity — so statemachine:orders is refused rather than treated as the same resource.
A well-formed ARN of the wrong type answers InvalidArn. An execution ARN at a tagging or state-machine operation, an activity ARN at a state-machine operation, a state-machine ARN at an execution operation: the resource may well exist, and it is the ARN that does not belong at this operation. An execution ARN answered InvalidArn at the tagging operations before #910 too, but by falling off the end of the strings.Contains chain rather than by a decision.
A version or alias ARN is refused. arn:aws:states:{region}:{account}:stateMachine:orders:1 and …:stateMachine:orders:live are both well-formed at AWS and both name something substrate keeps no record of — state machine versions and aliases are not modelled. Resolving either to the unqualified state machine would hand a caller a different resource from the one it asked for, which is the whole defect this rule exists to end, so both answer InvalidArn. This is substrate's reading: AWS refuses neither shape.
An express execution ARN resolves to nothing rather than being refused for its shape. StartSyncExecution mints one and stores no record, since an express execution completes within the call, so such an ARN answers ExecutionDoesNotExist at the execution operations. Refusing it for its shape would claim AWS rejects an ARN it mints. A trailing extra segment is tolerated in an express ARN for the same reason.
A Task state's Resource is not a Step Functions ARN and is not parsed as one. It is Lambda's ARN, and the segment wanted is the one after function:, which is a different job — so it has its own reader (#912). The same last-segment extraction used to run here: a qualified ARN such as arn:aws:lambda:{region}:{account}:function:score:PROD invoked a function named after the alias, and arn:aws:states:::lambda:invoke — the optimized integration, which also contains :lambda: and so passed the old dispatch test — invoked one named invoke. A qualifier is now dropped rather than honored, because the executor invokes through Lambda's unqualified path; invoking a specific version or alias from a Task state is not modelled. An optimized integration is not dispatched to Lambda at all and returns the empty-object stub.
Every refusal answers its own code at 400
| Code | Status | When |
|---|---|---|
| InvalidArn | 400 | The ARN is malformed, names another service, names a type the operation does not accept, or carries a version or alias qualifier |
| StateMachineDoesNotExist | 400 | A well-formed state-machine ARN names a state machine that does not exist |
| ActivityDoesNotExist | 400 | A well-formed activity ARN names an activity that does not exist |
| ExecutionDoesNotExist | 400 | A well-formed execution ARN names an execution that does not exist, including any express execution ARN |
| ResourceNotFound | 400 | The tagging operations' code for a state machine or activity that does not exist, and ListExecutions' answer for a mapRunArn |
| StateMachineTypeNotSupported | 400 | StartSyncExecution against a STANDARD state machine (#996), or a CreateStateMachine type outside the published STANDARD/EXPRESS (#1072) |
| InvalidDefinition | 400 | CreateStateMachine or UpdateStateMachine was given a definition substrate could not read back (#996), or one that reads back and is not a structurally valid state machine (#1073) — see below |
| InvalidName | 400 | A CreateStateMachine or CreateActivity name that is absent or breaks the published constraints — see below (#1072) |
| StateMachineAlreadyExists | 400 | CreateStateMachine against a name held by a state machine with a different definition or type — see below (#1072) |
| ValidationError | 400 | The request body is not valid JSON, at all fifteen operations that decode one — the common error, for the reasons in A request body that will not parse above (#950) |
Every one of these is 400, not 404. All eleven Step Functions API reference pages consulted for #910 and #912 publish every error at "HTTP Status Code: 400", including the three *DoesNotExist codes — unusual enough to be worth stating, because substrate answered 404 for all four before #910 and #912, which no Step Functions endpoint returns. A consumer branching on the status rather than the code saw something AWS never sends.
And not 409 either. Two create handlers survived those sweeps at 409 rather than 404 and were corrected in #1072: CreateStateMachine's StateMachineAlreadyExists, which API_CreateStateMachine publishes at 400, and CreateActivity's ActivityAlreadyExists, which is no longer answered at all (see below). Both handlers also answered InvalidParameterException for an absent name — a code on neither page's Errors list, where API_CreateStateMachine publishes fifteen and API_CreateActivity seven.
Two codes those pages publish at a status other than 400 are not in the table above and are not answered anywhere, which is deliberate rather than an omission: API_CreateStateMachine publishes ConflictException at 409 and at 400 — it is listed twice, at two statuses, on the same page — and API_UpdateStateMachine publishes ServiceQuotaExceededException at 402. Both describe concurrency and quota conditions substrate does not model, so neither has a site to be answered from.
The code a resource's absence carries is the one its own operation's page publishes, which is why there are four rather than one: StateMachineDoesNotExist at DescribeStateMachine, UpdateStateMachine, StartExecution, StartSyncExecution and ListExecutions; ActivityDoesNotExist at DescribeActivity; ExecutionDoesNotExist at DescribeExecution, StopExecution and GetExecutionHistory; and ResourceNotFound at the three tagging operations. The two deletes used to answer a code their own pages do not publish, and no longer do — see The two deletes are idempotent below. StartSyncExecution's refusal of a STANDARD state machine used to answer an unpublished code too; see StartSyncExecution refuses a workflow type, not a definition (#996).
The two creates are idempotent, and check what their pages constrain
Both create operations are published as idempotent, and substrate refused a repeat unconditionally until #1072. The published Notes differ in what they key on, and substrate follows each page rather than generalising one to both.
CreateStateMachine:
CreateStateMachineis an idempotent API. Subsequent requests won't create a duplicate resource if it was already created.CreateStateMachine's idempotency check is based on the state machinename,definition,type,LoggingConfiguration,TracingConfiguration, andEncryptionConfigurationThe check is also based on thepublishandversionDescriptionparameters. If a following request has a differentroleArnortags, Step Functions will ignore these differences and treat it as an idempotent request of the previous. In this case,roleArnandtagswill not be updated, even if they are different.
CreateActivity:
CreateActivityis an idempotent API. Subsequent requests won't create a duplicate resource if it was already created.CreateActivity's idempotency check is based on the activityname. If a following request has differenttagsvalues, Step Functions will ignore these differences and treat it as an idempotent request of the previous. In this case,tagswill not be updated, even if they are different.
So a repeat answers the stored record's own ARN and creationDate, byte for byte with the first call's response, and leaves roleArn and tags alone. Of the eight inputs to CreateStateMachine's check, substrate compares the three it models — name, definition and type. The other five are request members no handler in this plugin decodes, so they cannot differ between two requests substrate has seen.
Where the page contradicts itself, the Note governs.StateMachineAlreadyExists is glossed "A state machine with the same name but a different definition or role ARN already exists", which would make a differing roleArn a refusal — while the Note excludes roleArn from the check twice and says outright that it "will not be updated, even if [it is] different". The Note is the more specific statement, so substrate treats a repeat differing only in roleArn or tags as the idempotent success and refuses only a differing definition or type. That choice is substrate's reading of a page that states both things.
ActivityAlreadyExists is consequently unreachable, and that is the page's doing rather than a gap. Its only published condition is its own gloss — "Activity already exists. EncryptionConfiguration may not be updated." — and encryptionConfiguration is a request member substrate does not decode and ActivityState does not hold. With the name-keyed idempotency modelled, no input reaches the refusal. Substrate previously answered it at 409 for a plain duplicate name, which was neither the published status nor the published condition.
Three published constraints are now checked, each answering a code its own page publishes:
| Member | Published constraint | Refusal |
|---|---|---|
name (both operations) | Required: Yes; 1–80 characters; no white space; none of the brackets, wildcards and special characters the page lists — <>{}[], ?*, and "#%\^~$&,;:/ together with the pipe and the backtick; no control characters (U+0000-001F, U+007F-009F, U+FFFE-FFFF); no surrogates (U+D800-DFFF); not U+10FFFF | InvalidName/400 |
roleArn (CreateStateMachine) | Required: Yes; 1–256 characters | InvalidArn/400 |
type (CreateStateMachine) | Valid Values STANDARD or EXPRESS, defaulting to STANDARD | StateMachineTypeNotSupported/400 |
InvalidName rather than ValidationException for a bad name, because it is the only one of the two published on both pages: CreateStateMachine publishes ValidationException and CreateActivity does not, so answering that would report a code CreateActivity's page does not publish and would make one plugin answer two codes for one failure. InvalidName is also the narrower fit — every constraint checked is a name constraint.
Two clauses of the name list are recorded rather than enforced, both for stated reasons. The surrogate range U+D800-DFFF cannot be reached: Go's JSON decoder substitutes U+FFFD for an unpaired surrogate escape and for any byte sequence that is not valid UTF-8, so no request can carry one as a surrogate. And the page's further sentence — "To enable logging with CloudWatch Logs, the name should only contain 0-9, A-Z, a-z, - and _" — is a condition on logging stated with "should", not a constraint on the name, and substrate models no logging configuration for it to interact with; enforcing it would refuse names AWS accepts.
roleArn is checked for presence, for the published length, and for an arn: prefix — and no further. The page publishes no Pattern for the member, so splitting the ARN into its six fields and refusing a value that does not name an IAM role would invent a validation AWS does not document; that is the same line CloudFormation's own RoleARN draws. A role that does not exist is deliberately not an error either: no code is published for it and substrate does not resolve the role at create time.
The two deletes are idempotent
DeleteStateMachine on a state machine that is not there, and DeleteActivity on an activity that is not there, both answer 200 with an empty body. Neither refuses (#995).
Until then both answered a *DoesNotExist code, because both went through the same lookup helper as their siblings and the code came along with the lookup. The codes are real — StateMachineDoesNotExist is published at DescribeStateMachine, UpdateStateMachine, StartExecution, StartSyncExecution and ListExecutions, and ActivityDoesNotExist at DescribeActivity — just not at these two operations. API_DeleteStateMachine publishes exactly two errors, InvalidArn/400 and ValidationException/400. API_DeleteActivity publishes exactly one, InvalidArn/400.
Reading that omission as idempotence is substrate's reading. It is weaker evidence than SNS's DeleteTopic, whose page states the property outright — here neither page says anything in either direction. Two things carry it. The error list is the only thing either page says on the matter, and CommonErrors frames a page's list as the errors the operation returns. And the page's own description makes an idempotent delete the behaviour a caller needs:
Deletes a state machine. This is an asynchronous operation. It sets the state machine's status to
DELETINGand begins the deletion process. A state machine is deleted only when all its executions are completed.
A caller that has issued a delete and retries cannot distinguish already gone from still DELETING, which is the situation an idempotent delete exists for. The contrary reading — that the list is incomplete and AWS does refuse — rests on nothing either page says, only on the code existing elsewhere in the service. The two operations answer alike because an absent state machine and an absent activity cannot sensibly disagree about whether a delete is idempotent.
Only the deletes changed. DescribeStateMachine and DescribeActivity still refuse an absent resource with the code their own pages publish, so the change is about which operations may be idempotent, not about whether absence is observable.
Idempotence licenses an ARN that names nothing, not a string that is not the right kind of ARN. The parse stays ahead of the load in both handlers, so a malformed ARN, a non-states ARN, an ARN of the wrong resource type, and a version- or alias-qualified ARN are all still InvalidArn/400 at both deletes.
The cost of the 200 is that the status no longer proves a delete declined to act: a cross-account or cross-Region ARN now answers 200 whether or not it touched anything. So the tests assert the caller's own same-named resource survives, which is the #912 guarantee restated where it is no longer implied by a refusal.
The DELETING status is not modelled
API_DescribeStateMachine publishes two status values, ACTIVE and DELETING. Substrate reports ACTIVE and nothing else, and StateMachineState.Status claimed both until #995 looked for the writer that set DELETING and found none.
Nothing can set it: the delete removes the record synchronously, so there is no observation between ACTIVE and gone for a DELETING to occupy. Two consequences follow, and both are stated rather than hidden. A consumer cannot exercise a poll loop that waits for a delete to finish — the second DescribeStateMachine answers StateMachineDoesNotExist rather than reporting DELETING. And StateMachineDeleting/400 — "The specified state machine is being deleted. Execution will not be started or updated.", published at UpdateStateMachine, StartExecution and StartSyncExecution — is unreachable for the same reason.
Modelling the transition is a separate piece of work, and per substrate's scope it would be driven by the simulated clock or by a countdown of observations rather than by wall-clock time. A test pins the current answer, so whoever models it finds a failing assertion naming this decision rather than a silent widening.
StartSyncExecution refuses a workflow type, not a definition
StartSyncExecution against a STANDARD state machine answers StateMachineTypeNotSupported/400, "State machine type is not supported.", with the rejected type appended. It answered InvalidDefinition before #996 — a real Step Functions code, published at CreateStateMachine and UpdateStateMachine where a definition arrives in the request, but not on this page and not about this fact. API_StartSyncExecution publishes nine errors, all 400, and states the restriction outright: "StartSyncExecution is not available for STANDARD workflows." A consumer branching on the code was told the ASL document was wrong when what was wrong was the workflow type.
The page says nothing about an endpoint host. At AWS this operation is served on a sync- prefixed host, and that fact comes from the endpoints reference rather than from this page; substrate does not model it and serves StartSyncExecution on the same states.{region}.amazonaws.com endpoint as every other operation. A consumer whose client is configured against the AWS sync- host will not reach substrate.
A failed execution reports why it failed
StartSyncExecution answered five of the fourteen members its Response Syntax publishes, and DescribeExecution seven of its twenty-one. Four of the gaps were the same four on both pages — error, cause, inputDetails and outputDetails — and the first two are the ones that mattered, because a failed execution's reason was recorded and then reported nowhere (#1071).
On StartSyncExecution that is the whole of the observable failure, since AWS publishes the operation's contract as "StartSyncExecution will return a 200 OK response, even if your execution fails, because the status code in the API response doesn't reflect function errors." A consumer testing a failure path saw "status":"FAILED" and nothing else.
| Member | When it is answered |
|---|---|
error | Only on a failed execution — the ASL error code, from a Fail state's Error or from the runtime |
cause | Only on a failed execution — the explanation, from a Fail state's Cause |
input / inputDetails | Whenever the execution carries input |
output / outputDetails | Only on a succeeded execution, per the published rule that output "is set only if the execution succeeds" |
name, stateMachineArn | Always, on StartSyncExecution, which omitted both |
error and cause are absent rather than empty on a succeeded execution: the page gives no meaning to an empty error. inputDetails and outputDetails are CloudWatchEventsExecutionDataDetails objects carrying included: true, which is the page's own value — "Always true for API calls." — and not a derivation. Pairing each details member with the payload member it describes is substrate's reading: neither page states when they are present, and details about a payload the body does not carry would describe nothing.
status on StartSyncExecution is one of the narrower three the operation publishes, SUCCEEDED | FAILED | TIMED_OUT, where DescribeExecution publishes six.
Two published members stay unreported, deliberately. billingDetails reports the metering of a workload substrate does not run: billedMemoryUsedInMB is memory consumed inside the execution, which is resource-internal, and billedDurationInMilliseconds measured on the simulated clock would be 0 for a sync execution that completes inside one handler — a duration AWS would never return. traceHeader echoes a request member no path decodes, and the page publishes a precedence rule for it (the X-Amzn-Trace-Id header wins over the body), so answering it means modeling X-Ray's header rather than adding a field.
A definition that cannot be read back, or cannot run, is refused when it is stored
CreateStateMachine and UpdateStateMachine answer InvalidDefinition/400, "The provided Amazon States Language definition is not valid.", for a definition that is empty, is not valid JSON, or does not describe a JSON object — and, since #1073, for one that reads back perfectly and still does not name a runnable state machine. Every message names the offending state and field, because a caller fixing a generated document cannot act on "the definition is not valid".
Neither operation checked the definition at all before #996: both stored whatever string arrived. So substrate could accept a definition, report 200, and then be unable to execute it — and both execution operations reported that as a caller error. StartSyncExecution answered InvalidDefinition for it, which is the same unpublished code as above at the same operation, and StartExecution failed the execution with the error name InvalidDefinition, which is an API error code and not an Amazon States Language error name at all.
The CloudFormation deployer was a concrete producer of exactly that, not a hypothetical one. DefinitionString is a CloudFormation string property, and the deployer marshalled it to JSON, which quotes and escapes a string that is already the document — so every AWS::StepFunctions::StateMachine deployed from a DefinitionString stored a JSON string literal rather than an ASL object, and its executions failed for a reason the template author could do nothing about. Fixed in the same change; the sibling object-valued Definition property was still not read at all until #1074, which is below.
Both residual paths are now unreachable, and both are stated rather than removed. In StartSyncExecution an unreadable stored definition is a 500 through Go's error return rather than an AWSError, because it means substrate wrote something it cannot read — or replayed an event log written before the validation — and that is not the caller's fault. In StartExecution it stays an execution-level failure, which is the shape an asynchronous start has to use, but the error name is now States.Runtime, the published ASL name for an execution that failed due to an exception that could not be processed.
The structural rules: what a definition has to name before it is stored. #996 asked only whether substrate could read the document back, so {} created a state machine and answered 200. #1073 added the rules below, each one a sentence AWS publishes on amazon-states-language-state-machine-structure.html, the Choice page, the Parallel page or the inline-Map page:
| Rule | AWS's words |
|---|---|
States is present, and is not an empty object | "States (Required) An object containing a comma-delimited set of states" — absent and {} answer different messages |
StartAt is present and names a member of States | "StartAt (Required) A string that must exactly match (is case sensitive) the name of one of the state objects" |
Every state has a Type, and it is one of the published eight | Pass, Task, Choice, Wait, Succeed, Fail, Parallel, Map |
End is refused on Choice, Succeed and Fail | "Some state types, such as Choice, or terminal states, such as Succeed workflow state and Fail workflow state, don't support or use the End field" |
Every other type carries exactly one of Next or End | "Only one of Next or End can be used in a state" |
A Choice state carries no Next of its own | "Choice states do not support the End field. In addition, they use Next only inside their Choices field" |
A Choice state has at least one Choices rule | "Choices (Required) … You must define at least one rule in the Choice state" |
Every top-level Choice Rule has a Next, and Default resolves when present | "Default (Optional, Recommended) The name of the state to transition to if no Choice Rule evaluates to true" |
A Next nested inside And, Or or Not is refused where it stands | "the Next field can appear only in a top-level Choice Rule" — not resolved and accepted; refused for being nested |
"And": [] and "Or": [] are refused, while an absent operator is fine | "The values of the And and Or operators must be non-empty arrays of Choice Rules" |
Every Catch entry has a Next | a Catcher with no Next has nowhere to send the error |
A Parallel state has Branches, and each branch is checked by these same rules | "Each such state machine object must have fields named States and StartAt, whose meanings are exactly like those in the top level of a state machine" |
A Map state carries exactly one of ItemProcessor or Iterator | ItemProcessor is marked "(Required)"; Iterator is under "Deprecated fields" and still accepted |
Every Next, Catch[].Next, Choice Rule Next and Default names a state in its own States object | "Each branch must be self-contained" (Parallel); "States within the ItemProcessor field can only transition to each other" (Map) |
A definition with two faults reports the same one on every run: state names are walked in sorted order, because a map range would make the refusal a coin flip and the event log unreplayable.
Both spellings of a Map state's sub-state-machine are accepted, and both now run. AWS says "The ItemProcessor field replaces the now deprecated Iterator field" and, on the same page, "Step Functions Local doesn't currently support the ItemProcessor field. We recommend that you use the Iterator field with Step Functions Local." Refusing either spelling would reject a document AWS accepts from exactly the class of tool substrate is, so the rule is exactly one of the two. Carrying both is refused: they name one field and nothing publishes a precedence. Before #1073 the executor read Iterator alone, so a Map written with ItemProcessor — the spelling AWS marks Required — iterated zero times and returned an empty array instead of failing.
What is still not checked, and a 200 here is still not ASL approval. The rules above are structural: a rule exists only where a published AWS sentence makes the document malformed regardless of any input. Left unvalidated, deliberately:
- A field a state type does not support.
ASLStateis one flat struct carrying every type's members at once, so anInputPathon aSucceedstate, or aSecondson aPassstate, is invisible to the validator. The three cases the pages name outright —EndonChoice,SucceedandFail— are checked, because those are the ones a generator actually emits. - A
Nexton aSucceedorFailstate. Both are terminal, so the transition can never be taken, but the published sentence covers onlyEnd. Substrate accepts it rather than borrowing a rule AWS does not state (#671). - Everything that depends on the execution. Whether a Choice Rule's comparison can ever be true, whether a
Task'sResourceARN names anything, whether a JSONPath resolves against the state's input, whether aRetryinterval is sensible — all of those are answers about a run, not about the document, and sit on the workload-internal side of substrate's scope boundary. - Field-level rules inside a state. Each state type's own page carries requirements about that type's own members — a
Waitstate's alternative timing fields, aFailstate'sError/Causepair, aMapstate'sMaxConcurrency. Those are validation of a field's value rather than of the state machine's shape, and none of them is enforced. - The definition-size quota. The service-quotas page publishes "Maximum size of state machine definition — 1 MB — Hard quota"; substrate does not measure it, and neither page publishes an error code for exceeding it.
This is also where API_StartSyncExecution itself draws the line, which is worth recording because it is the only guidance either page gives: "Error codes are reserved for errors that prevent your execution from running, such as permissions errors, limit errors, or issues with your state machine code and configuration." An unreadable definition is an issue with the state machine's code, so AWS puts it on the error-code side — but publishes no code for it, because AWS would never have stored such a definition in the first place. Checking on the way in is the only reading that leaves both sides consistent.
ListTagsForResource returns tags sorted by key. AWS documents no order for it; lexicographic is substrate's reading, justified by the replay promise — a member order that followed Go's map iteration would differ between two identical calls in one run and could not replay from the event log (#862).
ListExecutions takes exactly one of its two ARNs
stateMachineArn is "Required: No" at ListExecutions, and the page states: "You can specify either a mapRunArn or a stateMachineArn, but not both." Substrate answers all four cases:
| Input | Answer |
|---|---|
stateMachineArn alone | The state machine's executions |
mapRunArn alone | ResourceNotFound / 400 |
| Both | ValidationException / 400 |
| Neither | ValidationException / 400 |
The mapRunArn row is substrate's reading: a Map Run is not modelled — no operation mints one — so the ARN names a resource substrate keeps no record of, and ResourceNotFound is published on this page. The alternative, refusing it as unsupported, would need a code the page does not carry.
An absent state machine is a refusal, not an empty list. ListExecutions had no existence check before #912, so a stateMachineArn naming nothing answered 200 with executions: [] — indistinguishable from a state machine that exists and has never run, which is the one pair a consumer polling for executions cannot tell apart. It now answers StateMachineDoesNotExist / 400, which the page publishes. A state machine that exists with no executions still answers 200 with an empty list.
Tags are an array of objects, not an object
AWS's Tag shape is {"key": …, "value": …}, and both TagResource's request and ListTagsForResource's response carry an array of them:
{"tags": [{"key": "env", "value": "test"}]}Substrate rendered and accepted an object at those two operations while CreateStateMachine and CreateActivity in the same plugin already took the array — so the plugin disagreed with itself about the wire shape of its own tags: a tag set at create time could not be read back in a shape any SDK decodes, and TagResource could not be called by one at all. Both now use AWS's array (#910).
CloudFormation resource types
| Type | Ref | Notes |
|---|---|---|
| AWS::StepFunctions::StateMachine | StateMachineArn | DefinitionString, Definition and DefinitionSubstitutions are all read and all resolved through the intrinsic context; DefinitionS3Location is declined — see below (#1074) |
| AWS::StepFunctions::Activity | ActivityArn | Name only |
The definition resolves through the intrinsic context
Every property of AWS::StepFunctions::StateMachine was resolved through the intrinsic context except the one carrying the definition, which is close to the only place a real template has to put an intrinsic: an ASL Task state's Resource is a Lambda function ARN, and a template that creates the function cannot know the ARN at authoring time. An Fn::Sub arrived at the deployer as a map and was stored as {"Fn::Sub":"…"} — a document that parses, describes an object, and has neither StartAt nor States (#1074).
The three properties substrate reads, and the order it reads them in:
| Property | Type | How it resolves |
|---|---|---|
DefinitionString | String | Resolved through the intrinsic context, so an Fn::Join or Fn::Sub becomes the document. A literal string passes through untouched and is not re-marshalled — doing that once stored a JSON string literal rather than an ASL object (#996) |
Definition | Json | The object form, resolved at every depth and marshalled once. A literal number stays a number; only a resolved intrinsic becomes a string |
DefinitionSubstitutions | Object | Applied last to whichever of the two supplied the document. Each value is resolved first, so HelloFunction: !GetAtt Hello.Arn injects the deployed ARN |
DefinitionString wins when both it and Definition are present. This is substrate's reading: both are Required: No and no sentence on the resource page covers supplying both, and this is the one order that changes nothing for a template that already deployed.
An undeclared ${key} in the document is left exactly as written, which is why substitution does not reuse Fn::Sub's resolver: that one falls back to resolving an unknown name as a Ref, which returns the bare name, so an unrelated ${…} would lose its braces instead of being left alone. AWS's second published substitution form, ${variable_1,variable_2,…}, addresses a key-value map variable rather than naming a substitution key, so no key can match it and it falls through that same untouched case.
Two things substrate does not do here. DefinitionS3Location is declined: it names an S3 object holding the document, and fetching it would make a deploy depend on a bucket's contents, so a template using it gets the stub definition instead. And Fn::Sub's ${LogicalId.Attribute} form is unimplemented in the shared resolver — ${MyFunction.Arn} resolves to the literal string MyFunction.Arn — which affects every resource type's properties, not just this one, and is tracked separately. A template needing an attribute inside a definition should use DefinitionSubstitutions, which is AWS's own documented mechanism for it, or an explicit Fn::GetAtt.
A template that supplies none of the three gets a stub definition, and the stub is a runnable state machine rather than a placeholder — which is what lets it survive the structural validation above.
Cost
Step Functions state transitions: $0.025 per 1,000 transitions.
ECR
Endpoint: ecr.{region}.amazonaws.comProtocol: JSON (X-Amz-Target: AmazonEC2ContainerRegistry_V20150921.{Op})
_V1_1_0 was documented here previously; _V20150921 is what the ECR model declares (#739). Both reduce to ec2containerregistry, so substrate routes either.
Supported operations
| Operation | Notes |
|---|---|
| CreateRepository | tags is an array of Tag objects; imageTagMutability defaults to MUTABLE and a value outside the published set is refused |
| DescribeRepositories | Reports the nine published Repository members and no others; a repositoryNames entry that names nothing is refused, not skipped; the registry-wide form pages, and the named form cannot |
| DeleteRepository | Reports the deleted repository in the same shape; a repository holding images needs force, and its images go with it |
| GetAuthorizationToken | Returns base64("AWS:password") |
| PutImage | |
| BatchGetImage | Refuses an unknown repository, as do BatchDeleteImage, DescribeImages and ListImages |
| BatchDeleteImage | Removes tags from the repository's tag index; an entry that matches nothing is reported in failures |
| DescribeImages | Pages unless imageIds is given, which excludes both members; an imageIds entry that names nothing is refused with ImageNotFoundException |
| ListImages | Reports one entry per digest-and-tag pair, sorted, and pages with no exclusion on either member |
| TagResource | tags is an array of Tag objects |
| UntagResource | tagKeys is an array of strings, as published |
| ListTagsForResource | Reports the published array, ordered by key; an untagged repository reports [] |
ECR's tags is an array with capitalized members
tags is an array of Tag objects on CreateRepository's and TagResource's requests and on ListTagsForResource's response, and each entry's members are the capitalized Key and Value:
{"tags": [{"Key": "env", "Value": "prod"}]}That casing contradicts every other member in the service — repositoryName, resourceArn, tagKeys are all lowerCamelCase — so it reads like a defect and is not one. API_Tag publishes Key and Value with a capital, both Required: Yes, and the Request Syntax of API_CreateRepository and API_TagResource and the Response Syntax and sample response of API_ListTagsForResource all spell them that way. It is recorded here because a future reader will otherwise "fix" it.
Substrate decoded and rendered a JSON object at all three sites until #1017. Since an array does not unmarshal into a map[string]string, and each of these handlers refuses a body it cannot unmarshal, the observable result was not a dropped tag: aws ecr create-repository --tags Key=env,Value=prod answered InvalidParameterException/400, and ECR tagging was unusable from any SDK. The round-trip rule #765 established could not catch it, because both halves of the round trip shared the wrong shape — only the published Request and Response Syntax settles a shape question, which is why the tests assert raw JSON.
Three readings are substrate's rather than AWS's:
- The order is lexicographic by key.
API_ListTagsForResourcepublishes no order and its sample response carries one entry; sorting is what makes a recorded run replay byte-identically (#862), and ranging a Go map put map order on the wire. - An empty set is
[], notnulland not an absent member, per the rule #938 established for the tagging API: an SDK decodingnullinto a list cannot tell "no tags" from "the service did not answer". - An entry with no
Keyis refused withInvalidParameterException/400, the code all three operations publish. An empty value is accepted, becauseAPI_CreateRepositorydescribes a tag as "a key and an optional value" whereAPI_TagmarksValueRequired: Yes— the page contradicts itself, and the narrower reading refuses only what both sentences agree is required.
Storage is unchanged: the record holds a map[string]string, which is what the Resource Groups Tagging API's mergeResourceTags arm and the CloudFormation tag stamp operate on, so those paths needed no edit. A tag written through TagResources is reported by ListTagsForResource and one written through ECR's own TagResource is reported by GetResources — #765 in both directions, which is what proves the two halves now agree about the wire as well as about the record.
A repository response carries the nine published members
API_Repository publishes nine members, all Required: No, and substrate answers eight of them — repositoryName, repositoryArn, registryId, repositoryUri, createdAt, imageTagMutability, imageScanningConfiguration and encryptionConfiguration. The ninth, imageTagMutabilityExclusionFilters, is unmodelled and therefore absent rather than present and empty, per #1013's rule; AWS's own CreateRepository sample response omits it and encryptionConfiguration.kmsKey as well, so the absences are shapes the page publishes.
Until #1090 the persisted record was marshalled straight onto the wire at all three sites that answer a repository, so each of them also reported fields that are substrate's own:
| Member answered | Published by ECR | Where AWS puts it |
|---|---|---|
AccountID, Region | No — on every response, since neither was optional | registryId and the Region embedded in repositoryArn |
Tags | No | ListTagsForResource |
LifecyclePolicy | No | GetLifecyclePolicy |
RepositoryPolicy | No | GetRepositoryPolicy |
ever_tagged | No — substrate's #938 bookkeeping flag | nowhere; it is not an AWS concept |
The two policies were the worst of the set, because a DescribeRepositories response publishes no policy member at all — a caller inspecting a repository saw a field name that matches no page. The repository shape is now a projection (ecr_wire.go) rather than the record itself, which is the pattern #529 established for API Gateway v1 and #1013 repeated for DynamoDB: a state record grows fields for substrate's own bookkeeping, and a projection is what stops the next one from reaching a response. The stored record is unchanged, so a recorded run still replays.
createdAt is a JSON number, not an RFC3339 string. ECR speaks application/x-amz-json-1.1, whose timestamps are epoch seconds — AWS's sample response answers 1.563223656E9 — and the SDK v2 decoder calls ParseEpochSeconds on a timestamp member, which a quoted string does not satisfy. The persisted time.Time rendered as a string until #1090, so an SDK caller could not decode the response at all.
imageTagMutability is now decoded on CreateRepository, stored, and reported from all three sites. An omitted member takes MUTABLE, which API_CreateRepository publishes in as many words ("If this parameter is omitted, the default setting of MUTABLE will be used"), and a record written before #1090 is read the same way, because the member did not exist then. A value outside the published set MUTABLE | IMMUTABLE | IMMUTABLE_WITH_EXCLUSION | MUTABLE_WITH_EXCLUSION is refused with InvalidParameterException/400 — the code the page publishes — because the member is reported back, so accepting one would put an unpublished string on the wire under a name whose Valid Values are published. The two _WITH_EXCLUSION forms are accepted even though the filters they accompany are unmodelled: those filters are Required: No, so a request naming one without them is a request AWS accepts, and refusing it would be substrate inventing a bound.
Substrate does not act on the setting — an IMMUTABLE repository still accepts a PutImage that reuses a tag. That is a separate question from whether the response reports what the request set, and the setting is recorded intent until an issue models the refusal.
Every ECR refusal is a 400
Every ECR operation page publishes exactly one status above 400 — ServerException at 500, which substrate never answers — so every refusal an ECR handler can reach is a 400. That was read off the pages one at a time rather than generalised from one of them: API_CreateRepository, API_DescribeRepositories, API_DeleteRepository, API_GetLifecyclePolicy, API_GetRepositoryPolicy, API_ListImages, API_DescribeImages, API_BatchGetImage and API_BatchDeleteImage each publish their errors at 400 and nothing else below 500.
Until #1090 four of substrate's five ECR codes carried a status no page publishes, at twelve sites:
| Code | Was | Published | Sites |
|---|---|---|---|
RepositoryAlreadyExistsException | 409 | 400 | 1 |
RepositoryNotFoundException | 404 | 400 | 8 |
RepositoryPolicyNotFoundException | 404 | 400 | 2 |
LifecyclePolicyNotFoundException | 404 | 400 | 1 |
The codes were right, which is why no test noticed: ecr_plugin_test.go asserted that a refusal happened and which code it carried, never its status. But the status is the part a consumer branches on before it has parsed a body — an SDK's retry classifier reads it, and 409 is what CloudFormation's own create-exists probe looks for — so every caller keying on the status was told something the service never says. InvalidParameterException/400 was already right at the twenty-seven sites that answer it.
Two published refusals also had no site they could fire from:
RepositoryNotFoundExceptionon the four image operations.ListImages,DescribeImages,BatchGetImageandBatchDeleteImageeach read the repository's tag index and never the repository record, so a name that addresses nothing read as an empty index and each answered 200 with an empty result — indistinguishable from a repository that exists and holds no images.RepositoryNotEmptyExceptiononDeleteRepository.forcewas decoded into a field nothing read, so a repository full of images was deleted silently. A repository's contents are measured by its tag index, the same way every operation that reports contents measures them: an image pushed without a tag is written under its digest and entered in no index, so nothing in this plugin can enumerate it. A forced delete now removes the images too — the index used to outlive the repository, so a name re-created after a delete reported the previous repository's images.
DescribeRepositories is the third site where the refusal could not fire, and the reading there is narrower: a name the caller supplied is answered for or refused, while a name read out of substrate's own index is skipped, because a missing record there is an internal inconsistency rather than a caller's mistake. Before #1090 every miss was dropped from the list, so a request naming one real and one imaginary repository answered 200 with a single entry.
The three ECR listings page three different ways
DescribeRepositories, DescribeImages and ListImages each publish maxResults (Valid Range 1 to 1000, and "If this parameter is not used, then … returns up to 100 results") and an opaque nextToken. Substrate decoded neither on any of the three, so every request answered the whole listing in one page. A caller written against the published contract — send maxResults, follow nextToken until it is absent — got everything at once and could not tell, because one full page is a well-formed answer.
The three pages were read one at a time rather than the rule being taken from the first, and they do not agree about the mutual exclusion. That disagreement is why substrate declares the exclusion per operation instead of sharing one guard:
| Operation | Published exclusion |
|---|---|
DescribeRepositories | Both members: "This option cannot be used when you specify repositories with repositoryNames." |
DescribeImages | Both members: "This option cannot be used when you specify images with imageIds." |
ListImages | None. Its filter member selects rather than enumerates, so a filtered listing still pages. |
maxResults outside 1 to 1000 is refused, not clamped: a page of 1000 does not tell a caller who asked for 5000 that they misread the contract. The cursor is the base64 offset of offset_pagination_token.go, so a nextToken substrate never issued is refused rather than silently answering page one (#915), while an offset past the end of a listing that has since shrunk clamps to a final empty page. InvalidParameterException/400 is the code for all three of those, because it is the only parameter-fault code any of the three pages publishes — there is no ECR equivalent of KMS's InvalidMarkerException.
Two further divergences were found and fixed in the same change, because an offset cursor is only meaningful over a listing with settled membership and a stable order, and neither held:
ListImagesanswers one entry per image ID, not per image. Substrate de-duplicated by digest and kept whichever tag Go's map iteration yielded first, so an image carrying two tags was reported under a randomly chosen one of them and two identical calls could answer differently. AWS's own published sample for the operation answers two entries with the same digest and different tags, and the page says aTAGGEDfilter lists "all of the tags in your repository".- All three listings now sort before they cut.
DescribeRepositorieswalked its names index in creation order, so a repository created mid-walk shifted every later one by a page position; both image operations built their result by ranging over the tag map. Repositories sort by name, image details by digest, image IDs by digest then tag.
DescribeImages also publishes ImageNotFoundException/400, which had no site: an imageIds entry naming an unknown tag was dropped from the request and one naming an unknown digest was dropped from the answer, so a caller naming one real and one imaginary image was answered 200 with a short list. As with DescribeRepositories, only an image the caller named is refused; a digest derived from substrate's own tag index with no record behind it is skipped as an internal inconsistency.
CloudFormation resource types
| Type | Ref | Notes |
|---|---|---|
| AWS::ECR::Repository | RepositoryName |
Cost
ECR storage: $0.10 per GB-month. Data transfer is free within the same region.
ECS
Endpoint: ecs.{region}.amazonaws.comProtocol: JSON (X-Amz-Target: AmazonEC2ContainerServiceV20141113.{Op})
Supported operations
| Operation | Notes |
|---|---|
| CreateCluster | |
| DescribeClusters | |
| DeleteCluster | |
| ListClusters | |
| RegisterTaskDefinition | |
| DescribeTaskDefinition | |
| ListTaskDefinitions | |
| CreateService | |
| DescribeServices | |
| UpdateService | |
| DeleteService | |
| RunTask | |
| DescribeTasks | |
| ListTasks | |
| StopTask |
CloudFormation resource types
| Type | Ref | Notes |
|---|---|---|
| AWS::ECS::Cluster | ClusterName | |
| AWS::ECS::TaskDefinition | TaskDefinitionArn | |
| AWS::ECS::Service | ServiceName |
Cost
ECS Fargate vCPU: $0.04048 per vCPU-hour. Memory: $0.004445 per GB-hour.
Cognito User Pools
Endpoint: cognito-idp.{region}.amazonaws.comProtocol: JSON (X-Amz-Target: AWSCognitoIdentityProviderService.{Op})
Supported operations
| Operation | Notes |
|---|---|
| CreateUserPool | Pool ID format: {region}_{12-char alphanum} |
| DescribeUserPool | |
| UpdateUserPool | Replaces the published configuration and answers an empty body — see below |
| DeleteUserPool | |
| ListUserPools | |
| CreateUserPoolClient | |
| DescribeUserPoolClient | |
| UpdateUserPoolClient | Replaces the published configuration — see below |
| DeleteUserPoolClient | |
| AdminCreateUser | |
| AdminGetUser | |
| AdminDeleteUser | |
| InitiateAuth | Returns stub JWT tokens |
The table above is a subset; 31 operations are routed. Completing it is #1093's scope.
UpdateUserPool and UpdateUserPoolClient replace, they do not merge
API_UpdateUserPool and API_UpdateUserPoolClient carry the same Important box, word for word:
If you don't provide a value for an attribute, Amazon Cognito sets it to its default value.
and the same recommendation above it — build the request from the current configuration, which both pages point at DescribeUserPool / DescribeUserPoolClient to obtain. Substrate assigned each member only when the request supplied a non-empty one, so a caller following that advice and omitting a member it did not want to change saw the stored value survive where AWS resets it. Since #1089 both handlers assign every member their page publishes, and the create shares the same resolver so the two doors cannot drift apart again.
Full replacement governs only the members an operation publishes. Schema is absent from API_UpdateUserPool's Request Syntax, so SchemaAttributes is preserved across an update rather than cleared — an operation cannot reset a member it does not accept. ProviderName, Status, Arn and CreationDate are preserved for the same reason.
Two defaults are applied on the reset:
| Member | Default | Citation |
|---|---|---|
ExplicitAuthFlows | ALLOW_REFRESH_TOKEN_AUTH, ALLOW_USER_SRP_AUTH, ALLOW_CUSTOM_AUTH | Published on API_UpdateUserPoolClient and API_CreateUserPoolClient: "If you don't specify a value for ExplicitAuthFlows, your app client supports ALLOW_REFRESH_TOKEN_AUTH, ALLOW_USER_SRP_AUTH, and ALLOW_CUSTOM_AUTH." An explicit [] is a value the caller specified and keeps the empty set. |
MfaConfiguration | OFF | Unpublished. Neither page carries a Default: line; both publish Valid Values: OFF | ON | OPTIONAL. OFF is substrate's reading, and it predates this change — the create has always applied it. It is applied at the update so an omitted member cannot leave the pool reporting the empty string, which is not a value the page publishes (#1013). |
UpdateUserPool answers 200 with a byte-empty body. Its Response Syntax is HTTP/1.1 200 followed by nothing, where UpdateUserPoolClient's publishes a UserPoolClient object — so the two updates differ, and substrate answers each as its own page publishes rather than making them symmetrical. Empty rather than {} follows AppSync's in-tree precedent: {} is a member-less object where the page promises no object at all.
Other Update* handlers are not flipped by analogy
41 update* handlers live in emulator/, and 29 of them guard an assignment on a non-empty request member. Exactly three pages publish that an update is a full replacement — API_UpdateSchedule, API_UpdateUserPool and API_UpdateUserPoolClient — and only those three lost their guards. The other 26 keep them, because #671's binding scope decision is that substrate models only what an operation's own page states: a published sentence is not extended to a sibling by analogy, however tempting the symmetry.
Step Functions is the sharpest case, because its page argues the other way rather than merely staying silent. API_UpdateStateMachine publishes both definition and roleArn as Required: No and then publishes MissingRequiredParameter — "This error occurs if both definition and roleArn are not specified." A request naming only roleArn is therefore explicitly legal, which full replacement would turn into a request that blanks the definition and leaves a state machine the service could not execute. So the merge there is what the page describes, and substrate keeps it.
Tagging is published and unrouted
AWS publishes ListTagsForResource, TagResource and UntagResource for cognito-idp. Substrate routes none of the three (#1135), so a user pool's tag set is readable only through DescribeUserPool, where UserPoolType publishes it. UpdateUserPool replaces the tag set outright like every other published member. Note that DescribeUserPool currently reports the set as Tags rather than the published UserPoolTags (#1136).
CloudFormation resource types
| Type | Ref | Notes |
|---|---|---|
| AWS::Cognito::UserPool | UserPoolId | |
| AWS::Cognito::UserPoolClient | ClientId |
Cost
Cognito MAUs: first 50,000 free, then $0.0055 per MAU.
Cognito Identity
Endpoint: cognito-identity.{region}.amazonaws.comProtocol: JSON (X-Amz-Target: AWSCognitoIdentityService.{Op})
Supported operations
| Operation | Notes |
|---|---|
| CreateIdentityPool | |
| DescribeIdentityPool | |
| DeleteIdentityPool | |
| GetCredentialsForIdentity | Returns stub temporary credentials |
CloudFormation resource types
| Type | Ref | Notes |
|---|---|---|
| AWS::Cognito::IdentityPool | IdentityPoolId |
Cost
Cognito Identity operations are free.
Kinesis Data Streams
Endpoint: kinesis.{region}.amazonaws.comProtocol: JSON (X-Amz-Target: Kinesis_20131202.{Op})
Supported operations
All seventeen operations accept StreamARN, StreamName or both, except the three noted below — see Naming a stream: by name or by ARN.
| Operation | Notes |
|---|---|
| CreateStream | Names the stream by StreamName only — the service's one operation-wide Required: Yes, and the one operation minting an ARN rather than resolving one; stores a create-time Tags map and refuses it under the same two bounds AddTagsToStream enforces, before the stream is written |
| DescribeStream | Answers an API_StreamDescription — which carries Shards and HasMoreShards and no OpenShardCount; see The two describe shapes, and the bounds on a reshard |
| DescribeStreamSummary | Answers an API_StreamDescriptionSummary — which carries OpenShardCount and neither Shards nor HasMoreShards |
| DeleteStream | |
| ListStreams | Names no single stream, so it publishes neither member and lists the caller's own account and Region |
| UpdateShardCount | ScalingType and TargetShardCount both required and both checked: the enum, the published minimum of 1, the 10 000 ceiling and the double/half pair, all InvalidArgumentException/400. Reports all four published members including StreamARN. The stream still never reports UPDATING — see the section below |
| MergeShards | |
| SplitShard | |
| PutRecord | |
| PutRecords | Batch put |
| GetShardIterator | Returns base64-encoded cursor |
| GetRecords | Names its stream by ShardIterator; a StreamARN is optional and is checked against it. This is the one page publishing StreamARN and no StreamName |
| EnableEnhancedMonitoring | ShardLevelMetrics is checked against its published 1–7 range and enum; ALL is expanded — see Shard-level metrics, and the ALL wildcard |
| DisableEnhancedMonitoring | Same shape and the same checks as its sibling |
| AddTagsToStream | Tags is a JSON object of key/value pairs, not a list |
| RemoveTagsFromStream | |
| ListTagsForStream | Reports Tags sorted by key — see A tag set read back out of a map — and pages them with Limit and ExclusiveStartTagKey; see Paging the tags on a stream |
Naming a stream: by name or by ARN
Fifteen of the seventeen operations publish StreamARN beside StreamName, both Required: No, under a Note that is byte-identical on every one of their pages: "you must use either the StreamARN or the StreamName parameter, or both. It is recommended that you use the StreamARN input parameter when you invoke this API." Until #966 substrate decoded only StreamName, so the recommended form was the one form that could not work — a caller sending only an ARN, which is what an SDK client built from one sends, and what a CloudFormation Ref, a Lambda event-source mapping and an IAM policy all carry, reached an empty-StreamName guard and was refused.
| Request | Answer |
|---|---|
StreamARN only | The stream the ARN names, in the ARN's own account and Region |
StreamName only | The stream of that name in the caller's account and Region — the only reading available, since a name carries neither |
| Both, naming one stream | Accepted, per "or both" |
| Both, naming different streams | InvalidArgumentException/400 — substrate's reading, below |
| Neither | InvalidArgumentException/400, since the Note makes such a request invalid |
A StreamARN not matching the published pattern | InvalidArgumentException/400, whose description — "a specified parameter exceeds its restrictions, is not supported, or can't be used" — is this case |
StreamId | Not decoded, and naming a stream by it alone names it not at all: AWS publishes it as "Not Implemented. Reserved for future use." on all fifteen pages |
The account and Region come from the ARN, not from the request context. An ARN naming another account's or another Region's stream addresses that stream, and one naming nothing there reports the stream absent rather than quietly serving the caller's own same-named one. Every key a request touches — the stream record, its records, and its entry in the ListStreams index — is derived from that one resolution, so a cross-account UpdateShardCount, MergeShards or AddTagsToStream writes to the target and a cross-account DeleteStream removes the target's index entry rather than the caller's. The parse takes no request context at all, which is what makes the rule structural rather than a thing fifteen call sites have to remember, following #826 for SQS and DynamoDB and #912 for Step Functions. The Resource Groups Tagging API resolves a stream ARN through the same parser, so the two cannot disagree about which stream an ARN names or where its tags live.
What enforcement the pattern states, and no more. The published pattern is arn:aws.*:kinesis:.*:\d{12}:stream/\S+. The partition must begin aws, the service segment must be kinesis, and the account must be exactly twelve digits. A remaining / in the resource portion means the ARN names a consumer — stream/{name}/consumer/{name}:{timestamp} — which substrate does not model, so it is refused. StreamName's own [a-zA-Z0-9_.-]+ class is deliberately not applied to the name inside an ARN, because nothing applies it on CreateStream either: applying it only here would make a stream substrate itself lets a caller create unaddressable by its own ARN.
A StreamARN with an empty Region resolves rather than being refused. The pattern's Region segment is .*, which matches the empty string, so such an ARN is well-formed; it resolves to a key nothing is written at and reports the stream absent. Refusing it for its shape would claim AWS rejects an ARN its own pattern accepts — the decision #912 recorded for an express execution ARN.
Two members that disagree are refused, and that is substrate's reading: no Kinesis page says what happens when StreamARN and StreamName name different streams. Serving either of them is how a caller's bug stays hidden, so the request is refused instead. Only the name segments are compared — an ARN whose account or Region differ from the caller's is not a disagreement but the whole point of the change, and it wins.
Two error statuses were corrected in the same pass. Every error on every one of the seventeen Kinesis reference pages is published at HTTP 400 — the only 500 in the service is InternalFailureException, which substrate does not raise — and substrate answered ResourceNotFoundException at 404 and ResourceInUseException at 409. Both are now 400, as #910 and #912 established for Step Functions. The InvalidParameterException each handler's body-decode guard answered is published by Kinesis nowhere at all — not on an operation page, not on the common-errors page, not even in prose — and #950 corrected all nineteen sites to InvalidArgumentException/400, which all sixteen guarded operation pages publish: "A specified parameter exceeds its restrictions, is not supported, or can't be used. For more information, see the returned message." ValidationException, which four of those pages also carry, is not the answer: its gloss is specific to capacity mode. So nothing in the plugin answers the old code any longer.
GetRecords' page contradicts itself, and the contradiction is recorded rather than resolved. It publishes StreamARN and no StreamName, because its stream is implied by the required ShardIterator — and it carries the same boilerplate Note anyway, naming a parameter the same page does not document. Substrate follows the shape: an iterator already carries the account and Region its stream was resolved in, so a supplied StreamARN is redundant and is checked against it. One naming a different stream is InvalidArgumentException/400 rather than ignored, since ignoring it would serve records from a stream the request did not name.
Paging the tags on a stream
ListTagsForStream publishes both halves of a cursor over the tag key, and until #954 substrate read neither: every call returned every tag and HasMoreTags was the literal false, so a caller's paging loop was told there was nothing more by a response that had not looked. Now Limit caps a page, ExclusiveStartTagKey positions it, and HasMoreTags says whether anything remains.
| Call | Answer |
|---|---|
No Limit | Every tag, HasMoreTags: false — Limit is optional and the page states no default |
Limit of 1–50 | At most that many tags, taken from the front of the remaining set |
Limit of 0, 51 or any other value outside 1–50 | InvalidArgumentException/400 — the operation's own published error, whose description is "a specified parameter exceeds its restrictions" |
ExclusiveStartTagKey | The tags whose keys sort strictly after it, per "gets all tags that occur after ExclusiveStartTagKey" — so a caller that passes back the last key it received advances instead of repeating it |
ExclusiveStartTagKey naming no tag | Positions the walk anyway: the cursor is over the key space, so a value between two keys starts at the later one |
ExclusiveStartTagKey of "" | Read as absent rather than as a violation of the published minimum length of 1 — it is what an omitted member decodes to and what a caller starting a walk sends |
ExclusiveStartTagKey longer than 128 characters | InvalidArgumentException/400, the published maximum length, which is also the maximum length of a tag key |
The walk order is substrate's reading, as the tag-order section records: AWS names no order for these tags anywhere, and its own sample response is unsorted. The cursor requires one — a tag cannot occur "after" a key otherwise — so substrate walks keys lexicographically, and that is what makes a page's contents predictable rather than a function of Go's map seed.
HasMoreTags is true exactly when tags were withheld, which resolves two AWS sentences that do not agree. Under Limit, the page says HasMoreTags is set "if this number is less than the total number of tags associated with the stream"; read literally, a walk at Limit 2 over six tags would report true on the last page too, since 2 is still less than 6, and the loop AWS itself describes — "to list additional tags, set ExclusiveStartTagKey to the last key in the response" — would never terminate. HasMoreTags' own description is the coherent one, "if set to true, more tags are available", so substrate reports whether anything remains after this page. A Limit equal to or larger than the remaining set is therefore not a truncation.
AWS publishes two different limits on how large a stream's tag set can get, and both are now enforced — see How many tags a stream may carry. Because 50 is the one that binds, a single maximum-Limit page holds every tag a stream may legally hold through Kinesis's own operations, and the cursor matters only for a smaller Limit.
How many tags a stream may carry
API_AddTagsToStream states both numbers inside one parameter entry. The Tags member's description reads "A set of up to 50 key-value pairs to use to create the tags. A tag consists of a required key and an optional value. You can add up to 50 tags per resource.", and the constraint lines directly beneath it read "Map Entries: Maximum number of 200 items." The operation's lede says 50 again, and ListTagsForStream's response Tags array publishes 0–200 while its Limit maxes out at exactly 50. Until #965 substrate enforced neither, so a stream could hold 300 tags and ListTagsForStream would answer an array longer than its own published maximum — substrate emitting a response its own reference says cannot exist.
Both figures are real, they are limits on different things, and both are enforced under different codes. 200 bounds one request's shape; 50 is the resource's quota.
| Request | Answer |
|---|---|
More than 200 entries in one Tags map | InvalidArgumentException/400, whose description is "a specified parameter exceeds its restrictions". Checked first, against the request alone: a request that does not satisfy its own shape is not evaluated against account state |
| A merged tag set of more than 50 | LimitExceededException/400 — "The requested resource exceeds the maximum number allowed" |
| A tag key outside 1–128 characters, or a value longer than 256 | InvalidArgumentException/400. An empty value is valid where an empty key is not, per "a tag consists of a required key and an optional value" and the published minimums of 0 and 1 |
An absent Tags member | InvalidArgumentException/400 — it is the operation's one Required: Yes member besides the stream reference |
An empty Tags map | Accepted as a no-op. Substrate's reading: the map publishes a maximum entry count and no minimum, where RemoveTagsFromStream's TagKeys array publishes "Minimum number of 1 item" — AWS states a minimum where it means one |
The quota is a property of the stream, so it is counted against the merged result, not against the incoming map: two accepted requests of thirty tags each are refused on the second. A key already on the stream does not count twice, because "AddTagsToStream overwrites any existing tags that correspond to the specified tag keys" — so re-tagging a stream that is already at the quota with a key it already carries is a rewrite and succeeds, and RemoveTagsFromStream frees slots for a later add. A refused request writes nothing, not even the tags that would have fit.
The Resource Groups Tagging API enforces this same quota, under this same code, since #1000. It did not until then: TagResources merges tags for twenty-three services through one helper that consulted no quota at all, so a caller could push a stream past fifty tags through it and AddTagsToStream would then refuse every further add — the right answer for a stream over quota however it got there, but reached through substrate's own API. That issue also corrects two claims first published here: Kinesis was not substrate's first per-resource tag quota (EC2, ELBv2 and IAM each enforced one already, and all three are reachable through the tagging API), and the merge covers twenty-three arms rather than sixteen. See A tag quota belongs to the service that owns the resource for the four services' codes, which differ.
CreateStream reaches the same two checks, since #1087. API_CreateStream publishes Tags with the identical pair of numbers — prose "A set of up to 50 key-value pairs" over "Map Entries: Maximum number of 200 items" — and the operation's lede states the interaction: "You can add tags to the stream when making a CreateStream request by setting the Tags parameter." Substrate decoded StreamName and ShardCount only, so a create-time tag set was silently dropped.
Both checks run before any state is read, which is what leaves no stream behind on a refusal: the 200-entry bound is a property of the request alone, and a stream that does not exist yet holds no tags for the quota to merge against. A create that refused after writing its record would be the worse failure, because the caller's retry would then hit ResourceInUseException for a stream it was told it had not created.
Two differences from AddTagsToStream are worth stating.
TagsisRequired: Nohere, so an absent member is not the missing-member refusal.kinesisValidateTagMap's nil branch belongs toAddTagsToStream, where the member isRequired: Yes, and is gated at the create call site. An explicitnulland an empty map are both accepted, the latter as the same no-op the table above records.LimitExceededException's attribution on this page is substrate's reading, not a citation. The code is inCreateStream's Errors list at 400, but the page attributes it to "more than five streams in theCREATINGstate" and to requesting "more shards than are authorized" — it says nothing about tag count. Using it for a fifty-first tag at create time carries the attribution over from the sibling door where it is published. That is not the borrowing #671 forbids, which is taking a code the operation's own page does not carry at all.InvalidArgumentExceptionneeds no such reading: its description covers the over-200 shape on this page exactly as it does on the other.
EverTagged is deliberately not stamped by the create path, for the reason recorded under GetResources reports what has been tagged.
Shard-level metrics, and the ALL wildcard
API_EnableEnhancedMonitoring and API_DisableEnhancedMonitoring publish a byte-identical ShardLevelMetrics shape — Required: Yes, "Array Members: Minimum number of 1 item. Maximum number of 7 items", and an eight-entry Valid Values enum: the seven metric names plus ALL. Until #999 substrate decoded the member and checked none of it, so an empty array, a fifty-item array and a misspelled metric name were all accepted and written to the stream.
| Request | Answer |
|---|---|
An absent ShardLevelMetrics | InvalidArgumentException/400 — it is the operation's one Required: Yes member besides the stream reference |
| An empty array | InvalidArgumentException/400, per the published "Minimum number of 1 item". The opposite reading from AddTagsToStream's Tags, deliberately: that map publishes a maximum and no minimum, this array publishes both |
| More than 7 items | InvalidArgumentException/400 |
| A value outside the enum, including a correct name in the wrong case | InvalidArgumentException/400, naming the offending value and the enum |
ALL is expanded into the seven metrics and never appears in a response. The enum's eighth entry is listed on the two response arrays as well, which would let AWS report the literal ALL; substrate reads it as an artifact of one MetricsName enum shape reused in both directions, because "The value ALL enables every metric" composed with DesiredShardLevelMetrics' own description — "the list of all the metrics that would be in the enhanced state after the operation" — names seven metrics, not one wildcard. Expanding is also the only reading under which DisableEnhancedMonitoring works: against a stored ["ALL"], disabling IncomingBytes would remove nothing.
That expansion is what makes the page's eight-value enum consistent with its seven-item maximum. A caller naming every metric and ALL sends eight items and cannot satisfy both bounds, so substrate enforces the maximum AWS states — and such a caller has no reason to ask, since ALL alone is one item and already means all seven.
CurrentShardLevelMetrics is the set before the operation and DesiredShardLevelMetrics the set after, per their own descriptions. Substrate rendered the same slice for both, read after the write, so Current reported the after-state on every call — and DisableEnhancedMonitoring filtered its stored slice in place, aliasing the backing array, so the before-state was destroyed as the filter ran. The second defect is why the first could not be fixed by swapping two renders.
An empty set renders as [], never null. Both arrays publish "Minimum number of 1 item", yet Enable's own Sample Response carries "CurrentShardLevelMetrics": [] and Disable's carries "DesiredShardLevelMetrics": []. The samples are the authority on the shape a caller has to handle, so the minimum is not a response guarantee — the same rule as #938.
The response orders the metrics as the pages bullet them, whatever order the caller sent. Neither page states a response ordering and a set has none, so substrate picks a canonical one, following ListTagsForStream's choice to sort an unordered map by key rather than report Go's iteration order. Two replays of one recorded request therefore render byte-identical bodies.
Both Sample Responses also omit the published StreamARN. It is reported regardless, from the stream the request resolved to rather than from the caller's own account and Region — so an ARN-only request naming another account's stream reports that account's ARN. UpdateShardCount omitted the same member and now reports it too. #966 was request-side only: it taught fifteen operations to read a StreamARN and gave no response one.
The two describe shapes, and the bounds on a reshard
DescribeStream answers an API_StreamDescription and DescribeStreamSummary an API_StreamDescriptionSummary. Until #1076 one builder served both and emitted the union of their members, so each operation answered members its own page does not publish:
| Member | StreamDescription | StreamDescriptionSummary |
|---|---|---|
Shards | Required: Yes | not a member |
HasMoreShards | Required: Yes | not a member |
OpenShardCount | not a member | Required: Yes |
The other six Required: Yes members — StreamName, StreamARN, StreamStatus, RetentionPeriodHours, StreamCreationTimestamp and EnhancedMonitoring — are common to both and substrate answers all of them. Substrate closes no shard, so every shard it holds is open and OpenShardCount equals the shard count; that the two coincide is a property of substrate's model, not of the API.
EnhancedMonitoring is an array of EnhancedMetrics objects, not an array of names. Both pages publish it Required: Yes and typed "Array of EnhancedMetrics objects", each object carrying one ShardLevelMetrics array. Substrate rendered the stored []string straight through, so a response read ["IncomingBytes"] where AWS answers [{"ShardLevelMetrics": ["IncomingBytes"]}] — an SDK decoding into the generated type gets an unmarshal error, so this was a hard failure for a real client rather than a cosmetic difference, the same class as #1017's ECR tags.
A stream with nothing enhanced answers "EnhancedMonitoring": []. Both readings were open — [] or [{"ShardLevelMetrics": []}] — and API_EnhancedMetrics settles it: ShardLevelMetrics publishes "Array Members: Minimum number of 1 item", so an object holding an empty list is a shape the model does not permit, while EnhancedMonitoring itself publishes no array minimum. The member is present either way, as Required: Yes demands, and [] invents no impossible inner object. ALL is expanded here as it is in the two monitoring responses, so a record written before #999 holding the literal wildcard reads back as the seven metrics it means.
UpdateShardCount checks four of its published bounds and refuses each with InvalidArgumentException/400.
| Request | Answer |
|---|---|
An absent ScalingType | InvalidArgumentException/400 — Required: Yes |
A ScalingType outside the enum, including the right word in the wrong case | InvalidArgumentException/400; UNIFORM_SCALING is the only published value |
An absent TargetShardCount | InvalidArgumentException/400 — Required: Yes, and reported as absent rather than as a zero, so a caller who omitted the member is not told it was too small |
TargetShardCount below 1 | InvalidArgumentException/400, per the published Valid Range: Minimum value of 1 |
TargetShardCount above 10 000 | InvalidArgumentException/400 |
| More than double, or less than half, the stream's current shard count | InvalidArgumentException/400. The bounds are inclusive: against 4 shards, 8 and 2 are accepted and 9 and 1 are not |
| A bad shape on a stream that does not exist | ResourceNotFoundException/400. Forced rather than chosen — the double and half bounds are stated against "your current shard count", so they cannot be evaluated before the record is loaded |
The code is InvalidArgumentException and not ValidationException, although the page publishes both. ValidationException's gloss there is capacity-mode-specific — "Specifies that you tried to invoke this API for a data stream with the on-demand capacity mode" — so it is not a general validation code despite the name, the reading substrate has carried since #950. LimitExceededException is the other candidate and is declined: its gloss is about a resource exceeding a maximum, and the page attributes it explicitly to one rule only, the 10 TPS call rate. The double, half and ceiling rules are stated inside the TargetShardCount parameter entry, and "a specified parameter exceeds its restrictions" is InvalidArgumentException's own sentence.
Three published restrictions are deliberately unmodelled, because each needs state substrate does not hold rather than a value in the request:
- "Scale more than ten times per rolling 24-hour period per stream" — needs a request history.
- "Scale up to more than the shard limit for your account" — needs an account quota.
- "Scale a stream with more than 10000 shards down unless the result is less than 10000 shards" — unreachable while the 10 000 ceiling above is enforced.
The seventh, "Make over 10 TPS", is a call-rate limit rather than a property of any one request; it is the one rule the page attributes to LimitExceededException by name.
The stream still reports ACTIVE immediately after a reshard, where the page says it reports UPDATING until the split or merge completes. Making that observable means a seeded count of observations — the shape CLAUDE.md requires and that ec2SnapshotProgression established — and it is tracked as #1119 so that it and EC2's instance states share one mechanism rather than inventing a second. Capacity mode is unmodelled altogether, which is why ValidationException has no site at all today; #1118 carries it, together with the StreamModeDetails member both describe shapes publish Required: No.
CloudFormation resource types
| Type | Ref | Notes |
|---|---|---|
| AWS::Kinesis::Stream | StreamName |
Cost
Kinesis shard: $0.015 per shard-hour. PUT payload: $0.014 per million 25KB units.
CloudFront
Endpoint: cloudfront.amazonaws.com (global) Protocol: REST/XML
Supported operations
| Operation | Notes |
|---|---|
| CreateDistribution | Distribution IDs: E{13-char upper alphanum} |
| GetDistribution | |
| GetDistributionConfig | Answers DistributionConfig members only, and two of its five required ones — see A configuration is not a distribution |
| UpdateDistribution | Shares the /config path with GetDistributionConfig, told apart by the verb |
| DeleteDistribution | |
| ListDistributions | |
| CreateInvalidation | |
| GetInvalidation | NoSuchDistribution and NoSuchInvalidation are both published and name different absences (#1091) |
| ListInvalidations | Refuses a distribution that does not exist rather than answering an empty list (#1091) |
| TagResource | Body is a <Tags> document; a body of another shape is refused rather than read as an empty tag set (#883) |
| UntagResource | Body is a <TagKeys><Items><Key> document. Removing a key the distribution does not carry succeeds — AWS documents no error for it, so that reading is substrate's (#883) |
| ListTagsForResource | Reports the <Tags><Items> members sorted by key — see A tag set read back out of a map |
All three tagging operations share the POST/GET /2020-05-31/tagging path and are told apart by the query string: Operation=Tag, Operation=Untag, and a GET carrying only Resource. A POST whose Operation is neither is refused with InvalidAction, never treated as a tag write (#883).
The Resource Groups Tagging API reaches a CloudFront distribution as of #835: TagResources and UntagResources resolve a distribution ARN through the same parser these three operations use, so a tag written either way is readable through the other. Until #835 both answered an InternalServiceException FailedResourcesMap entry and tags here could be changed through CloudFront's own operations only. No other CloudFront resource type is reachable — see the tagging section for the kind guard and for why GetResources reports a distribution in us-east-1 alone.
All CloudFront resources are stored under us-east-1 (global service).
A configuration is not a distribution
GetDistribution returns a Distribution and GetDistributionConfig returns a DistributionConfig. They are two published types, one nested in the other, and substrate answered the same fields for both until #1091: the configuration carried Id and ARN, which API_DistributionConfig publishes nowhere — they are Distribution members, one level up. A caller reading a distribution's identity out of a configuration found it here and finds nothing there against CloudFront itself.
The configuration now carries Comment and Enabled and nothing else, and Comment is answered even when empty because API_DistributionConfig marks it Required: Yes and the Response Syntax renders it unconditionally.
Two divergences remain, and are deliberate.
DistributionConfig marks five members Required: Yes — CallerReference, Comment, DefaultCacheBehavior, Enabled and Origins — and substrate can answer two. CreateDistribution decodes only Comment and Enabled from its body, so there is no recorded value for the other three, and neither page publishes an example of a configuration to copy a shape from. An Origins needs Items and a Quantity; a DefaultCacheBehavior needs a whole subtree. Omitting a member substrate holds no value for is the honest answer; inventing one would assert a shape AWS has not published.
API_GetDistributionConfig publishes, on its Id parameter: "The distribution's ID. If the ID is empty, an empty distribution configuration is returned." An empty ID is reachable — the path /2020-05-31/distribution//config routes to the operation with an empty ID — and substrate answers NoSuchDistribution/404 instead, for the same reason: the "empty distribution configuration" is the document with no published example whose five required members would have to be invented. The refusal is the code the page publishes at the status it publishes, so a caller is told something true; it is simply not what AWS says for this one input.
A path ending in a bare slash is not that case. /2020-05-31/distribution/ has its trailing slash trimmed before routing, so it is ListDistributions — not a GetDistribution with an empty ID, which is the natural reading of the routing arithmetic and is wrong.
Both invalidation codes name something
API_GetInvalidation publishes NoSuchDistribution/404 and NoSuchInvalidation/404, and API_ListInvalidations publishes NoSuchDistribution/404. Neither handler looked at the distribution record until #1091: ListInvalidations read the invalidation index straight out of state and answered 200 with an empty list for any ID at all, and GetInvalidation answered NoSuchInvalidation — telling a caller a batch was missing from a distribution that does not exist. Both load the distribution first, so each published code reports the thing that is actually absent.
A tagging ARN addresses the distribution it names
The three tagging operations take a Resource ARN in the query string, and one function resolves it for all three, so they cannot drift on which resource an ARN addresses. Two things about that ARN are load-bearing, and until #918 both were wrong.
The account comes from the ARN, not from the calling request. ListTagsForResource publishes the parameter's pattern — arn:aws(-cn)?:cloudfront::[0-9]+:.*, required — in which [0-9]+ is the account and the empty segment before it is the Region, absent because CloudFront is global. The resolver read the distribution ID out of the ARN and then keyed the load and the store by the caller's own account, so arn:aws:cloudfront::{other}:distribution/E1EXAMPLE addressed the caller's E1EXAMPLE. UntagResource is the damaging direction, and it answered the documented 204 while doing it: stripping a tag can turn an aws:ResourceTag Deny into an allow. This is the rule #826 established for SQS and DynamoDB, #845 carried through the tagging API's resolver and #910 applied to Step Functions — CloudFront was the last plugin holding out against it, and the resolver now takes no request context at all, so the caller's account is not in scope to reach for.
The resource type is matched against the ARN's own segment. It was strings.LastIndex(arn, "distribution/"), which is unanchored, so arn:aws:cloudfront::{account}:streaming-distribution/E1EXAMPLE resolved to the web distribution E1EXAMPLE — a different resource type reaching a record it does not name. That is the same anchoring mistake #910 found one layer up. The type is now the first /-delimited segment of the resource portion and is compared whole.
Only distribution resolves, and that is the reference's own boundary rather than substrate's convenience: the developer guide's tagging page states "You can tag distributions, but you can't tag origin access identities or invalidations". Substrate stores invalidations in the same namespace and CloudFormation mints origin access identities (#859), so both are reachable-looking targets that have to be refused rather than left to a substring match.
The two refusals answer two different published codes, because they are two different failures:
| Case | Code | Status |
|---|---|---|
| The ARN is malformed — not an ARN, not CloudFront, carrying a Region, naming no account or no resource, or naming something nested under a distribution such as an invalidation | InvalidArgument | 400 |
| The ARN is well formed and names a CloudFront resource type substrate does not model — a streaming distribution, a function, a cache policy, an origin access identity | NoSuchResource | 404 |
| The ARN names a distribution that does not exist in the account it names | NoSuchDistribution | 404 |
Both InvalidArgument and NoSuchResource are among the four codes all three tagging operations publish (AccessDenied 403, InvalidArgument 400, InvalidTagging 400, NoSuchResource 404); which of the two each case gets is substrate's reading, since AWS publishes the codes and not the conditions. The third row is not one of the four, and that is deliberate and older than #918: it is the code every other arm of this plugin answers for an absent distribution, and one plugin should not report a missing distribution two ways. An ARN naming an unmodelled type is a different case — there is no distribution for it to be missing — which is why it takes the published code instead.
CloudFormation resource types
| Type | Ref | Notes |
|---|---|---|
| AWS::CloudFront::Distribution | DistributionId |
Cost
CloudFront HTTPS requests: $0.0100 per 10,000 requests (approximate).
RDS
Endpoint: rds.{region}.amazonaws.comProtocol: AWS Query (form-encoded, Action= parameter)
Supported operations
| Operation | Notes |
|---|---|
| CreateDBInstance | |
| DescribeDBInstances | Paginates on Marker/MaxRecords; refuses a MaxRecords outside the published 20–100 with InvalidParameterValue — see A page size outside the documented range |
| DeleteDBInstance | |
| ModifyDBInstance | |
| StartDBInstance | |
| StopDBInstance | |
| RebootDBInstance | |
| CreateDBSnapshot | |
| DescribeDBSnapshots | Paginates on Marker/MaxRecords; a Marker it did not issue and a MaxRecords outside the published 20–100 are refused with InvalidParameterValue, which this page does not publish — see Six describes published a cursor. Filtering by DBSnapshotIdentifier for a snapshot that does not exist answers DBSnapshotNotFound / 404; a DBInstanceIdentifier that names no instance stays an empty 200, because only the snapshot filter has a published fault — see A single-resource filter that names nothing |
| DeleteDBSnapshot | |
| RestoreDBInstanceFromDBSnapshot | |
| CreateDBCluster | |
| DescribeDBClusters | Paginates on Marker/MaxRecords; refuses a MaxRecords outside the published 20–100 with InvalidParameterValue — see A page size outside the documented range |
| DeleteDBCluster | |
| CreateDBSubnetGroup | |
| DescribeDBSubnetGroups | Paginates on Marker/MaxRecords; a Marker it did not issue and a MaxRecords outside the published 20–100 are refused with InvalidParameterValue, which this page does not publish — see Six describes published a cursor. Filtering by DBSubnetGroupName for a group that does not exist answers DBSubnetGroupNotFoundFault / 404 — see A single-resource filter that names nothing |
| DeleteDBSubnetGroup | |
| CreateDBParameterGroup | |
| DescribeDBParameterGroups | Paginates on Marker/MaxRecords; a Marker it did not issue and a MaxRecords outside the published 20–100 are refused with InvalidParameterValue, which this page does not publish — see Six describes published a cursor. Filtering by DBParameterGroupName for a group that does not exist answers DBParameterGroupNotFound / 404 — see A single-resource filter that names nothing |
| DeleteDBParameterGroup | |
| ListTagsForResource | TagList sorted by key — see below |
| AddTagsToResource | |
| RemoveTagsFromResource |
An RDS ARN addresses the resource it names
The three tag operations take a ResourceName ARN, and so does the Resource Groups Tagging API. Both resolve it through one function, for the reason #826 established: two derivations of one key drift, and where they drift a tag is written to a record the other side does not read.
Four of AWS's RDS resource-type segments resolve, and they are the four whose records substrate stores tags on:
| Segment | Example | Resource |
|---|---|---|
db | arn:aws:rds:{region}:{account}:db:{name} | DB instance |
cluster | arn:aws:rds:{region}:{account}:cluster:{name} | DB cluster |
snapshot | arn:aws:rds:{region}:{account}:snapshot:{name} | DB snapshot |
subgrp | arn:aws:rds:{region}:{account}:subgrp:{name} | DB subnet group |
cluster: and subgrp: were absent until #835, and their absence was a self-contradiction rather than a gap: CreateDBCluster and CreateDBSubnetGroup report those ARNs, and substrate's own tag operations then refused them as an unsupported resource type. That breaks #765's rule — a value substrate reports has to be usable against the API that reported it — and it also put the tagging API's rds arm out of reach of both resources entirely.
An automated snapshot needs no separate handling. AWS writes its ARN as snapshot:rds:{name}, and the extra segment belongs to the identifier: such a snapshot really is named rds:mydb-2019-07-22-07-23. The parse keeps the whole remainder, so the ARN addresses that identifier rather than a truncated one. For the other three types an identifier containing / or : is refused, because RDS accepts neither in a name and a key built from one addresses nothing — reported as a malformed ARN rather than as an absent resource, which is the difference between a caller fixing its ARN and a caller waiting for a resource to appear.
The account and Region come from the ARN and never from the calling request, so an ARN naming another account's cluster resolves that account's cluster or none at all. It cannot reach the caller's own same-named one.
cluster-pg and cluster-snapshot are their own segments in AWS's ARN table, not prefixes of cluster, and substrate stores neither — so both are refused, as are pg and es.
A missing resource names its own kind
Each of the three tag operations answers the 404 AWS publishes for the kind of resource the ARN named, rather than one code for all four. Reporting DBInstanceNotFound for a cluster tells a caller polling for a cluster that it asked about the wrong sort of thing, and a consumer branching on the code to decide whether to keep waiting branches wrong.
| Resource | Code | Status |
|---|---|---|
| DB instance | DBInstanceNotFound | 404 |
| DB cluster | DBClusterNotFoundFault | 404 |
| DB snapshot | DBSnapshotNotFound | 404 |
| DB subnet group | DBSubnetGroupNotFoundFault | 404 |
The first three are on AddTagsToResource' and ListTagsForResource' own published error lists verbatim. The fourth is substrate's reading in one respect only: a DB subnet group is a taggable type in AWS's ARN table, but neither tagging operation's published error list names a subnet-group fault. The code and the status are still AWS's — DescribeDBSubnetGroups publishes DBSubnetGroupNotFoundFault/404, "DBSubnetGroupName doesn't refer to an existing DB subnet group." — so what substrate decides is where to answer it, not what it is. Keeping DBInstanceNotFound for a subnet group is wrong under any reading.
TagList is sorted by key. AWS documents no order for it — its own sample response renders owner before environment — so this is substrate's reading, taken for the reason #862 records: the list was built by ranging a Go map, so two identical calls answered in different orders and a caller asserting on the body could not replay a recorded run.
CloudFormation resource types
| Type | Ref | Notes |
|---|---|---|
| AWS::RDS::DBInstance | DBInstanceIdentifier |
Cost
RDS db.t3.micro on-demand: $0.017 per hour (approximate for testing purposes).
ElastiCache
Endpoint: elasticache.{region}.amazonaws.comProtocol: AWS Query (form-encoded, Action= parameter)
Supported operations
| Operation | Notes |
|---|---|
| CreateCacheCluster | |
| DescribeCacheClusters | Paginates on Marker/MaxRecords; refuses a MaxRecords outside the published 20–100 with InvalidParameterValue, which this service's page publishes — see A page size outside the documented range |
| ModifyCacheCluster | |
| DeleteCacheCluster | |
| CreateReplicationGroup | |
| DescribeReplicationGroups | Paginates on Marker/MaxRecords; a Marker it did not issue and a MaxRecords outside the published 20–100 are refused with InvalidParameterValue, which this page publishes — see Six describes published a cursor. Filtering by ReplicationGroupId for a group that does not exist answers ReplicationGroupNotFoundFault / 404, now through the same helper as the other five — see A single-resource filter that names nothing |
| ModifyReplicationGroup | |
| DeleteReplicationGroup | |
| CreateCacheSubnetGroup | |
| DescribeCacheSubnetGroups | Paginates on Marker/MaxRecords; a Marker it did not issue and a MaxRecords outside the published 20–100 are refused with InvalidParameterValue, which this page does not publish although its two ElastiCache siblings do — see Six describes published a cursor. Filtering by CacheSubnetGroupName for a group that does not exist answers CacheSubnetGroupNotFoundFault / 400, the one status outlier among the six — see A single-resource filter that names nothing |
| DeleteCacheSubnetGroup | |
| CreateCacheParameterGroup | |
| DescribeCacheParameterGroups | Paginates on Marker/MaxRecords; a Marker it did not issue and a MaxRecords outside the published 20–100 are refused with InvalidParameterValue, which this page publishes — see Six describes published a cursor. Filtering by CacheParameterGroupName for a group that does not exist answers CacheParameterGroupNotFound / 404 — see A single-resource filter that names nothing |
| DeleteCacheParameterGroup | |
| ListTagsForResource | |
| AddTagsToResource | |
| RemoveTagsFromResource |
CloudFormation resource types
| Type | Ref | Notes |
|---|---|---|
| AWS::ElastiCache::CacheCluster | CacheClusterId | |
| AWS::ElastiCache::ReplicationGroup | ReplicationGroupId |
Cost
ElastiCache cache.t3.micro: $0.017 per node-hour (approximate).
EFS
Endpoint: elasticfilesystem.{region}.amazonaws.comProtocol: REST/JSON
Supported operations
| Operation | Notes |
|---|---|
| CreateFileSystem | |
| DescribeFileSystems | |
| DeleteFileSystem | |
| CreateMountTarget | |
| DescribeMountTargets | |
| DeleteMountTarget | |
| CreateAccessPoint | |
| DescribeAccessPoints | |
| DeleteAccessPoint |
CloudFormation resource types
| Type | Ref | Notes |
|---|---|---|
| AWS::EFS::FileSystem | FileSystemId | |
| AWS::EFS::MountTarget | MountTargetId | |
| AWS::EFS::AccessPoint | AccessPointId |
Cost
EFS standard storage: $0.30 per GB-month.
Glue
Endpoint: glue.{region}.amazonaws.comProtocol: JSON (X-Amz-Target: AWSGlue.{Op})
Supported operations
| Operation | Notes |
|---|---|
| CreateDatabase | |
| GetDatabase | |
| DeleteDatabase | |
| GetDatabases | |
| CreateTable | |
| GetTable | |
| DeleteTable | |
| GetTables | |
| CreateJob | |
| GetJob | |
| DeleteJob | |
| GetJobs | |
| StartJobRun | Returns JobRunId |
| GetJobRun | Transitions to SUCCEEDED after describe |
| GetJobRuns |
What a refusal reports
| Condition | Code | Status |
|---|---|---|
| a database, table, connection, crawler, job or job run that does not exist | EntityNotFoundException | 400 |
All twelve of those sites answered 404 until #1098. Glue publishes EntityNotFoundException at 400 on every page that lists it — GetTable's Errors section gives the gloss, "A specified entity does not exist" — and publishes no 404 anywhere, so a consumer branching on the status rather than on the code saw a shape AWS never sends. The correction follows #910, which made the same argument for the statuses it moved; #1063 had already corrected these codes and left their statuses behind.
The messages are substrate's own — "<Entity> <name> not found.", naming which entity and which name — because the published gloss names neither, and a caller reading a message rather than a code needs to know which lookup failed.
CloudFormation resource types
| Type | Ref | Notes |
|---|---|---|
| AWS::Glue::Database | DatabaseName | |
| AWS::Glue::Table | TableName | |
| AWS::Glue::Job | JobName |
Cost
Glue ETL jobs: $0.44 per DPU-hour. Crawlers: $0.44 per DPU-hour.
Cost Explorer
Endpoint: ce.us-east-1.amazonaws.comProtocol: JSON (X-Amz-Target: AWSInsightsIndexService.{Op})
Cost Explorer reads from the Substrate EventStore to return real usage data from your test runs.
Supported operations
| Operation | Notes |
|---|---|
| GetCostAndUsage | Aggregates event costs by service/operation |
| GetCostForecast | Returns stub forecast based on recent usage |
Cost
Cost Explorer API calls: $0.01 per request.
Budgets
Endpoint: budgets.amazonaws.comProtocol: JSON (X-Amz-Target: AWSBudgetServiceGateway.{Op})
Supported operations
| Operation | Notes |
|---|---|
| CreateBudget | DuplicateRecordException if name already exists |
| DescribeBudget | NotFoundException if missing |
| UpdateBudget | |
| DeleteBudget | |
| DescribeBudgets | Lists all budgets for account |
| DescribeBudgetActionsForBudget |
CloudFormation resource types
| Type | Ref | Notes |
|---|---|---|
| AWS::Budgets::Budget | BudgetName |
Cost
Budgets: first two budgets free, then $0.02 per budget per day.
Health
Endpoint: health.us-east-1.amazonaws.comProtocol: JSON (X-Amz-Target: AWSHealth_20160804.{Op})
The Health plugin is a stub that returns empty valid responses. It exists to allow infrastructure code that calls the Health API to run without errors.
Supported operations
| Operation | Notes |
|---|---|
| DescribeEvents | Returns empty events list |
| DescribeEventDetails | Returns empty details |
| DescribeAffectedEntities | Returns empty entities |
Cost
Health API calls are free.
Price List Query API
Endpoints: api.pricing.us-east-1.amazonaws.com, api.pricing.ap-south-1.amazonaws.com, api.pricing.eu-central-1.amazonaws.comProtocol: JSON (X-Amz-Target: AWSPriceListService.{Op})
This is the server side of pricing — for code that queries AWS rates at runtime. It is the inverse of Substrate's own cost-tracking pricing provider, which consumes the public offer index to cost simulated usage (the /v1/pricing/refresh, /v1/pricing/lookup, /v1/pricing/discounts and /v1/pricing/credits control endpoints, and the substrate pricing command).
The offer corpus is 39 SKUs copied verbatim from the live offer files: seven Amazon S3 SKUs from AmazonS3/current/us-east-1/index.json (version 20260728131000) and 32 Amazon EC2 SKUs from AmazonEC2/current/{us-east-1,us-west-2,eu-west-1}/index.json (version 20260910195514). It is small on purpose: each SKU exists to reproduce a response shape that callers get wrong, so a consumer's parser is tested against real awkwardness rather than a tidied-up fixture.
Each document reports its own service's offer-file revision in version and publicationDate, so an EC2 document and an S3 document from one Substrate build disagree on both — as the real API's do, because the two services publish on their own schedules.
Supported operations
| Operation | Notes |
|---|---|
| GetProducts | ServiceCode required; Filters 0–50; MaxResults 1–100 |
| DescribeServices | All fields optional; MaxResults 1–100 |
| GetAttributeValues | AttributeName and ServiceCode required; MaxResults 1–10000 |
FormatVersion accepts only aws_v1, the sole documented value. Pagination uses an opaque NextToken; a token that does not decode, or that points past the end of the result set, is an InvalidNextTokenException.
Filter.Type supports the full documented enum — TERM_MATCH, EQUALS, CONTAINS, ANY_OF, NONE_OF — not just TERM_MATCH. ANY_OF and NONE_OF take a comma-separated Value. Filters are conjunctive, and a filter naming a field a product does not carry never matches it, including NONE_OF.
Response shapes worth knowing about
These are the traps the corpus deliberately preserves. Each is verified against the live offer file.
PriceListelements are JSON documents encoded as strings, not objects. Decoding requires a second unmarshal per element.pricePerUnitvalues are strings with trailing zeros ("0.0230000000"), never numbers. So arebeginRangeandendRange.productFamilyis absent from most products — 315 of the 381 in the real S3 offer file omit it. A filter onproductFamilytherefore misses the majority of SKUs.usagetypeis the attribute that is reliably present and 1:1 with a SKU in the S3 offer file; it is neither in EC2's, where four of the corpus SKUs shareBoxUsage:m5.xlarge. Keying onusagetyperelies on an S3 accident rather than a Price List rule.TimedStorage-ByteHrscarries threepriceDimensions, the last with"endRange": "Inf". Reading only the first reports the first-50 TB rate as if it were the only rate.Requests-Tier1is"0.0000050000"per request, and itsunitisRequests— that is $0.005 per 1,000. Dividing by 1,000 again is a 1,000× error.- Filtering
productFamily=StoragewithvolumeType="Glacier Deep Archive"returns onlyTimedStorage-GDA-Stagingat $0.021/GB-Mo — the staging rate, 21× the $0.00099 archive rate. NoTimedStorage-GDA-ByteHrsSKU exists in the S3 offer file at all, so that filter cannot return the rate a caller expects; the nearest $0.00099 SKU is Intelligent-Tiering'sTimedStorage-INT-DAA-ByteHrs.
An unknown ServiceCode is a NotFoundException rather than an empty PriceList. Substrate's corpus is far smaller than AWS's catalog, and a loud error is better than an empty result that reads as "AWS has no such price".
The AmazonEC2 corpus
Nine instance types (t3.micro, m5.xlarge, g4dn.xlarge, g5.2xlarge, g6.xlarge, inf2.xlarge, p4d.24xlarge, p5.48xlarge, trn1.32xlarge) in three regions, plus seven m5.xlarge variants that differ from the nominal row in exactly one attribute, plus the free-tier pseudo-product. Every rate, SKU, rate code, description and attribute value is the offer file's own; none is derived from another rate, because a plausible-looking rate is worse than no rate — a consumer computing a cost from it would be wrong with no way to notice.
The traps, each measured in the live files rather than reasoned about:
- The documented seven-filter recipe does not isolate one rate. Filtering
regionCode,instanceType,operatingSystem,tenancy,preInstalledSw,capacitystatusandmarketoptionreturns three SKUs for a Windowsm5.xlargeinus-east-1. All three share oneusagetypeand differ only inlicenseModelandoperation: $0.376 with a license, $0.192 without one and $0.192 BYOL. A caller that takesPriceList[0]picks one of the three arbitrarily.licenseModelis the eighth discriminator. marketoptionmatters at a single instance type.p5.48xlargepublishes an on-demand rate of $55.04 and a Capacity Block rate of $0.00. Omittingmarketoptioncan report a free p5.capacitystatusandtenancychange theusagetypetoken, not only the price:UnusedBox:,DedicatedUsage:. A caller matching on the prefixBoxUsage:silently misses both.- The
usagetypeRegion prefix is not derivable from the Region code.us-east-1has none,us-west-2usesUSW2-, andeu-west-1uses the legacyEU-, notEUW1-. - Two Regions can agree on nine rates and disagree on the tenth.
us-west-2is byte-identical tous-east-1for every type here exceptp4d.24xlarge, which is21.9576420000in one and21.9576400000in the other. eu-west-1is a per-family premium, not one Region multiplier:p4d≈1.080×,inf2exactly 1.250×,m5≈1.115×,t3≈1.096×. Deriving one Region's price from another's would be wrong by up to 15%.- A Region can publish no rate at all.
eu-west-1publishes nog6,p5ortrn1compute product, so an emptyPriceListthere is a real observation about the Region. - The free-tier pseudo-product falsifies five invariants. It carries no
instanceType, itsendRangeis"750"rather than"Inf", itsofferTermCodeisA429C66SYZrather than the global on-demand code, itstermAttributesis non-empty ("Restriction": "Limited SKU Usage") and itsappliesTolists 170 SKUs where every other on-demand dimension's is empty. Itslocationis"Any"and itsregionCodeis the empty string, so aregionCodefilter never selects it. Compute Instanceexcludes bare metal. Bare-metal instances are a separateproductFamily,Compute Instance (bare metal), so aproductFamily=Compute Instancefilter does not see a.metaltype.
Two things the corpus is deliberately not:
- It is not EC2's catalog.
GetProductsserves this corpus; EC2'sDescribeInstanceTypesserves its own instance-type data (#896). A type present in one is not thereby present in the other, and the two are not generated from a shared source — so a rate here is not a claim that the type can be launched. - It is not exhaustive, and an absence is not a statement.
us-east-1alone publishes 107,022 compute-instance products. An instance type absent from the corpus returns an emptyPriceList, which reads the same as a Region that genuinely does not publish it. Where the absence is the observation — the threeeu-west-1types above — it is because the real file omits them too.trn2appears nowhere at any rate for the same reason: a real query returns nothing for it in all three Regions.
Endpoint regions — a deliberate divergence
AWS hosts the Price List Query API in exactly three regions: us-east-1, ap-south-1 and eu-central-1. There is no api.pricing.eu-west-1.amazonaws.com to resolve.
Substrate serves every region from one endpoint, so it cannot reproduce a name that fails to resolve. Instead, a request signed for any other region is rejected with SubstrateInvalidPricingEndpoint (HTTP 400). The code is deliberately not an AWS code — this is Substrate reporting a condition AWS surfaces at the transport layer, and naming it as such is better than silently pricing a request against an endpoint that does not exist.
Seeding failures
Pricing is the kind of dependency whose failure should degrade a caller, not stop it. That property is only testable if the failure can be produced on demand:
# Fail one operation.
curl -X POST http://localhost:4566/v1/pricing/query-failures \
-d '{"operation":"GetProducts","code":"ThrottlingException","message":"Rate exceeded"}'
# Fail every operation (wildcard).
curl -X POST http://localhost:4566/v1/pricing/query-failures \
-d '{"code":"InternalErrorException"}'
# Clear one, or all.
curl -X DELETE 'http://localhost:4566/v1/pricing/query-failures?operation=GetProducts'
curl -X DELETE http://localhost:4566/v1/pricing/query-failuresAn operation-specific seed takes precedence over the wildcard. statusCode defaults to the status the Price List API documents for the code — 400 for every documented code except InternalErrorException, which is 500 — and may be overridden explicitly.
code must be one of the seven codes the Price List API documents: AccessDeniedException, ExpiredNextTokenException, InvalidNextTokenException, InvalidParameterException, NotFoundException, ThrottlingException, InternalErrorException. Anything else is rejected with a 400, because a typo'd code would seed an error no SDK catch branch matches — the fallback path would go untested while the seed itself appeared to work.
Seeding an offer document
The bundled corpus answers what does AWS charge. Every SKU in it is copied from a real offer file, and it can never be grown to answer the other question a consumer has — what does my code do when the rate is X — because a fixture carrying an invented rate is worse than no rate: a caller computing a cost from it is wrong with no way to notice. us-east-1 alone publishes 107,022 compute-instance products, so a consumer whose cost path depends on a rate outside the bundled 39 SKUs had no offline test at all.
An invented rate therefore arrives the way a deterministic emulator is allowed to produce a different answer: as a seed the caller wrote, in the request that caused it.
# Seed one offer document, in the shape GetProducts serves it.
curl -X POST http://localhost:4566/v1/pricing/offers -d @offer.json
# Override a SKU the bundled corpus measures — deliberately.
curl -X POST 'http://localhost:4566/v1/pricing/offers?replace=true' -d @offer.json
# Remove one, or every seed.
curl -X DELETE 'http://localhost:4566/v1/pricing/offers?sku=SEEDEDRDSSKU0001'
curl -X DELETE http://localhost:4566/v1/pricing/offersThe body is one PriceList element — product, serviceCode, terms, and optionally version and publicationDate — so a seed can be pasted straight out of a real offer file or out of a recorded response. replace is a query parameter rather than a body member for exactly that reason: a body member would make the document no longer a document.
A seeded SKU is not a second code path. It becomes a corpus entry like any other, so GetProducts filtering and paging, DescribeServices and GetAttributeValues all see it through the same functions that serve the bundled corpus. That matters because the discovery path AWS documents runs DescribeServices → GetAttributeValues → a GetProducts filter, and those two refuse an unknown ServiceCode with a different code from GetProducts (InvalidParameterException against NotFoundException) — an overlay that reached only the last of the three would let a caller query a SKU the first two deny exists.
Four decisions the endpoint makes, each because the alternative would let a test pass against something AWS never serves:
| Question | Answer |
|---|---|
Does a seeded service appear in DescribeServices? | Yes, and its AttributeNames is computed — the union of its seeded products' attribute keys, plus productFamily when one is carried, unioned with the bundled list for a service that is also bundled. A declared list could not hold the property the bundled lists hold, that every name reported filters to at least one product. |
| May a seed replace a bundled SKU? | Only with ?replace=true. Overriding a measured rate is useful; overriding it silently is the one thing the corpus exists to prevent. The flag does not prevent the override, it puts it in the request that caused it. A seed always replaces an earlier seed, which is what makes the endpoint re-runnable. |
| Which revision does a seeded document report? | Its own version/publicationDate when it carries them, otherwise substrate-seeded / 1970-01-01T00:00:00Z. Those are deliberately not plausible — a real offer file reports a 14-digit version — and a seeded SKU for a bundled service does not inherit that service's real revision, because the measured file does not contain it. |
Where does a seed appear in PriceList order? | After the bundled entries, sorted by SKU among themselves. The bundled order is fixed so that page boundaries are stable; a map-ordered overlay would undo that for every seeded query. |
A seed that would emit a shape the real API does not is refused with a 400 naming the member, because such a seed would let a consumer's parser pass here and fail against AWS — the exact class of bug the corpus was assembled to expose. Refused: an empty product.sku or serviceCode; a product with no usagetype (the one attribute present on every product in both bundled offer files); a terms key other than OnDemand, or more than one term, since a corpus entry carries exactly one and a dropped Reserved term would serve half of what was seeded; an on-demand key that is not <sku>.<offerTermCode>; a price-dimension key that is not <sku>.<offerTermCode>.<dimensionCode> or whose rateCode disagrees with it; an empty unit, effectiveDate or pricePerUnit; and a pricePerUnit value that is not a decimal string — including the JSON number a hand-written seed most often carries, where AWS emits every rate as a string.
Seeded offers live under their own state prefix, so clearing seeded failures (DELETE /v1/pricing/query-failures) leaves them in place and vice versa.
Cost
Price List API calls are free.
Organizations
Endpoint: organizations.us-east-1.amazonaws.comProtocol: JSON (X-Amz-Target: AWSOrganizationsV20161128.{Op})
Organizations_20161128 was documented here previously and reduces to organizations by the generic version strip; the prefix every SDK actually sends is AWSOrganizationsV20161128, which carries its version inline and needed an explicit alias (#739).
On the first call, the plugin auto-creates an organization, its management account, and its root. All three are persisted, so the root's ID and ARN are stable for the life of the state store — an organization whose root ID moved between calls could not be governed at all, since nothing could reference the thing policies attach to.
Every Organizations exception is HTTP 400. The API model declares no 404 for any of them, AccountNotFoundException included, so a consumer that branches on the status rather than the code takes a path AWS never sends it down. The InvalidInputException and ConstraintViolationException reasons ride at the front of the message ("OU_DEPTH_LIMIT_EXCEEDED: …"), because the JSON-RPC error document has no Reason member to put them in.
Supported operations
| Operation | Notes |
|---|---|
| DescribeOrganization | Auto-creates the organization, its management account and its root on first call |
| ListRoots | The same root ID on every call; PolicyTypes is empty under CONSOLIDATED_BILLING |
| ListAccounts | Reports a vending account immediately, before its status resolves |
| DescribeAccount | AccountNotFoundException |
| CreateAccount | Asynchronous — returns IN_PROGRESS and a car- request ID |
| DescribeCreateAccountStatus | Resolves the request on first observation; CreateAccountStatusNotFoundException |
| ListCreateAccountStatus | Filterable by States |
| MoveAccount | The only way an account leaves the root |
| CloseAccount | Asynchronous, no output shape; management-only; the account stays in the organization |
| CreateOrganizationalUnit | Name 1–128 characters; accepts inline Tags |
| DescribeOrganizationalUnit | OrganizationalUnitNotFoundException |
| UpdateOrganizationalUnit | Renames in place — ID, ARN, children and attachments all survive |
| DeleteOrganizationalUnit | OrganizationalUnitNotEmptyException while it holds anything; deletes its tags |
| ListOrganizationalUnitsForParent | |
| ListChildren | ChildType is required |
| ListParents | Walks up to the root ListRoots reports |
| ListAccountsForParent | |
| CreatePolicy | Content, Description, Name and Type are all required; accepts inline Tags |
| UpdatePolicy | Any of Name/Description/Content; IMMUTABLE_POLICY on p-FullAWSAccess |
| DeletePolicy | PolicyInUseException while attached; deletes its tags |
| DescribePolicy | PolicyNotFoundException |
| ListPolicies | Filter is required; includes p-FullAWSAccess |
| AttachPolicy | DuplicatePolicyAttachmentException; refused while the type is disabled |
| DetachPolicy | PolicyNotAttachedException; the last SCP on a target cannot be detached |
| ListPoliciesForTarget | Filter is required |
| ListTargetsForPolicy | Reports the root, OU and account targets of one policy |
| EnablePolicyType | PolicyTypeAlreadyEnabledException; restores only p-FullAWSAccess |
| DisablePolicyType | Detaches every SCP from every entity in the root |
| PutResourcePolicy | Content is required, 1–40,000 characters; Tags only on the first call |
| DescribeResourcePolicy | ResourcePolicyNotFoundException when none is set — the usual case |
| DeleteResourcePolicy | ResourcePolicyNotFoundException when none is set, so a re-run is a refusal |
| TagResource | Roots, OUs, accounts, policies and the resource policy |
| UntagResource | Validates key shape on the removal path too |
| ListTagsForResource | Paginated by NextToken; no MaxResults |
CreateOrganization is not implemented: the organization already exists on first contact, so there is nothing for it to create.
The resource policy
An organization holds exactly one resource policy — the delegation document that lets a member account make a read management would otherwise have to make. That shape is unlike everything else here: there is no list, no per-statement update, and PutResourcePolicy replaces the document wholesale.
The rp- ID and its ARN are minted once and survive every replacement. A re-mint would tell a caller holding the ARN that its policy had been replaced by a different one, when the same single policy was updated — and with only one per organization, there is nothing a new ID could distinguish it from.
DescribeResourcePolicy's refusal is the normal answer, not an edge case: most organizations have no resource policy. A caller checking whether anything was delegated to it has to tell ResourcePolicyNotFoundException apart from AccessDeniedException, so answering an empty policy would collapse "no delegation" and "delegation I cannot read" into one observation.
Tags apply only to the initial creation, per the API model; a Put carrying tags against an existing policy is refused rather than silently dropping them, which would leave a tag-gated decision reading a tag set the caller believes it just wrote. Deleting the policy deletes its tags.
Content must parse as JSON, refused as InvalidInputException/INVALID_RESOURCE_POLICY_JSON. The evidence is that enum member in the API model, not the shape's own pattern — which is [\s\S]*, any text at all. Only parseability is checked: the enum's INVALID_PRINCIPAL, UNSUPPORTED_ACTION_IN_RESOURCE_POLICY and the two like them are refusals about the document's meaning, and the sets AWS accepts are not in the model, so emitting them would mean guessing at their boundaries. A guessed refusal is worse than a missing one: it fails a document AWS would have accepted.
A member account sees its management account's organization
Organizations state is keyed by the management account, and every account substrate knows is indexed to the organization it belongs to. So a member account calling any Organizations operation reads management's organization — the same organization ID, the same root, the same accounts — rather than a private one of its own. A vended account signing its own requests is a real member, which is what makes a two-account governance flow testable at all.
An account substrate has never seen still gets an organization auto-created for it on first contact. That no-setup path is what makes a fresh emulator usable without a CreateOrganization call, and it is only reachable for an account that is not a known member of anything.
The reads are not uniform across the three resource-policy operations, because AWS's own permissions are not:
| Caller | DescribeResourcePolicy | Put/DeleteResourcePolicy |
|---|---|---|
| The management account | The policy, or ResourcePolicyNotFoundException | Allowed |
| A member the policy names | The same policy management set | AccessDeniedException (403) |
| A member the policy does not name | AccessDeniedException (403) | AccessDeniedException (403) |
DescribeOrganization stays readable by every account in the organization, per "can be called from any account in the organization"; DescribeResourcePolicy is documented as callable from the management account or a member account that is a delegated administrator, and for Organizations itself that delegation comes from the resource policy substrate already stores. Making the two uniform would erase a distinction a governance tool depends on.
AccessDeniedException is HTTP 403 here, not the 400 every declared Organizations exception uses. It is a common error the service model does not declare at all, and the API Reference's Common Errors page gives it 403 — which is what an SDK's retry classifier reads.
A caller in no relationship to the organization is not reachable through this API: none of the three operations takes an input naming an organization, so there is nowhere to put another organization's ID. The reachable third case is a member the policy does not name.
p-FullAWSAccess
AWS attaches the managed allow-everything SCP to the root, every OU and every account while the SCP type is enabled, and substrate does the same. It is synthesized rather than stored, so it cannot be updated or deleted, and its ARN is owned by aws (arn:aws:organizations::aws:policy/service_control_policy/p-FullAWSAccess) rather than by the organization — which is also why it cannot be tagged, though reading its tags answers empty.
Without it a fresh organization reports no attached policies, which is wrong, and the minimum-one-SCP rule below has nothing to hold.
Account vending is asynchronous
CreateAccount returns HTTP 200 with State: IN_PROGRESS and a car- request ID, as AWS does. The status resolves — to SUCCEEDED with an AccountId and a CompletedTimestamp, or to the seeded FAILED — on the firstDescribeCreateAccountStatus, so a waiter converges in one poll with no wall-clock dependence. ListAccounts reports the account immediately, before the status resolves, matching AWS.
This is advance-on-observation rather than clock-driven on purpose, and #514 has since settled that question the same way for EC2 instance states — a count of observations rather than a duration over the simulated clock, because the simulated clock advances with wall time. See Seeding an instance-state progression.
New accounts land in the root. MoveAccount is the only way into an OU, and a move to the account's current parent is DuplicateAccountException, not a no-op — which is what makes a vending script's re-run testable: the second run hits the refusal rather than silently duplicating.
Organization-wide email uniqueness is reachable only through the seed. AWS enforces it and surfaces a collision asynchronously — CreateAccount answers 200 and DescribeCreateAccountStatus later reports FAILED / EMAIL_ALREADY_EXISTS — and substrate models that shape without inferring the collision from the accounts it holds. Inferring it would remove a path rather than add one: every fixture that vends two accounts with one email would start failing without having asked to. The cost is that a consumer wanting the collision must seed it, which is the trade taken deliberately.
Closing an account does not remove it
CloseAccount has no output shape — a success is an empty 200 — and the closure is read through DescribeAccount, which is how AWS documents watching it: PENDING_CLOSURE while the request is in flight, SUSPENDED when it completes. There is no CLOSED status; the model's AccountStatus enum is exactly those three values.
The closed account stays in the organization. It keeps its place in the hierarchy, still appears in ListAccounts and ListAccountsForParent, and keeps counting against the accounts-per-organization quota — "when an account is closed it does not stop counting against this quota until it is permanently closed". So a cleanup path that closes accounts to make room for new ones gets no room, and CreateAccount still answers ACCOUNT_NUMBER_LIMIT_EXCEEDED. Removing the account instead would make that broken script look correct, which is the reason this operation is modelled at all.
The status advances on observation, like DescribeCreateAccountStatus, with one difference: the in-flight status is reported on the first observation and the terminal one from the second. CloseAccount returns no body, so a poll is the only place PENDING_CLOSURE is ever visible — resolving on the first read would leave a consumer's in-flight branch unexecutable. Only the operations that put an account's Status on the wire advance it; the concurrency count below deliberately does not, since counting through an observation would let closing a fourth account converge the first three.
The operation is management-only (AccessDeniedException, 403), checked before any state is read so a member cannot use the other refusals to probe the organization it belongs to — and a member cannot close itself either, since the guard is on the caller rather than on the caller's relationship to the target. It also requires the ALL feature set, per "you can close an account when all features are enabled"; under CONSOLIDATED_BILLING the account is left untouched, so a retry after enabling all features does not start from half-applied state.
A closure already in flight and one already finished are different refusals. The model declares both ConflictException and AccountAlreadyClosedException without saying which applies to a PENDING_CLOSURE target; substrate reads "already closed" as the terminal state and answers the conflict for the in-flight one, so a re-run of a teardown script can tell "this is finishing" from "this was done".
Of the three published closure quotas, only 3 concurrent closures is enforced — it is a count of accounts currently in PENDING_CLOSURE, so it is exact. Observing all three to SUSPENDED frees the slots, which is the "as soon as one finishes, you can close another" half of the quota. The rolling-30-day allowance (250 or 20% of member accounts, capped at 1,000) and the four-day minimum age before a created account can be removed are not modelled: both are bounded by a wall-clock window, and substrate's clock is simulated and freely advanced, so such a refusal would fire or not depending on unrelated AdvanceTime calls elsewhere in a test. A limit a test can skip past is not a limit.
The disabled-SCP state
An all-features organization whose root has had DisablePolicyType called on it is the state a governance tool is most likely to get wrong, because it looks nothing like a failure:
CreatePolicysucceeds.AttachPolicyis refused withPolicyTypeNotEnabledException.EnablePolicyTyperestores onlyp-FullAWSAccess. Attachments from before the disable are lost, per the User Guide.
That is different from SCPs not being available at all, which is what a CONSOLIDATED_BILLING organization has: there no SCP exists, no policy is visible, and every operation naming one answers with its own documented not-found code. Only CreatePolicy and EnablePolicyType name the feature set as the reason (PolicyTypeNotAvailableForOrganizationException), because those are the two operations whose model error list declares it — emitting it elsewhere would hand a caller an exception its SDK cannot catch by type.
Tags reach the authorization decision
Tags written through TagResource resolve as aws:ResourceTag/* on the tagged entity, and a request's inline Tags as aws:RequestTag/*, so a tag-gated privilege boundary — CreatePolicy only when aws:RequestTag/Owner matches, UpdatePolicy on that policy only when aws:ResourceTag/Owner does — is actually enforced rather than silently open.
The inline Tags of CreateOrganizationalUnit, CreatePolicy and CreateAccount go through the same validation TagResource applies, so a key that operation refuses cannot be planted through a create instead; an aws:-prefixed one would otherwise be readable as aws:ResourceTag by a policy condition. An invalid tag fails the whole create and leaves nothing behind, and CreateAccount's refusal is synchronous even though its success is not — the request is malformed, so there is nothing to vend. Deleting an OU or a policy deletes its tags, so an entity that reused the ID cannot inherit them.
A request naming several resources is authorized against every one
MoveAccount names three resources — the account, the source parent and the destination parent — and the Service Authorization Reference marks all three required. It is the only Organizations operation that does; every other one names a single resource, and AttachPolicy/DetachPolicy mark only the policy required, so those authorize against the policy alone.
The caller's policies must therefore allow the action against all three ARNs. A policy that names the account and one OU but not the root cannot move that account out of the root or into it, which is what a delegated-admin confinement policy is written to guarantee. A permission boundary is applied to every one of the three as well, since a boundary checked against a subset is not a boundary.
Each ARN is matched against the tags of the resource it names, so a condition on aws:ResourceTag written about the destination cannot be satisfied by a tag on the account. The denial names the first resource the policies do not allow — resolved in a fixed order of account, source parent, destination parent — which is the only place the missing ARN surfaces, and the ARN a caller has to add.
Refusals
| Condition | Answer |
|---|---|
| Unknown account | AccountNotFoundException |
| Unknown OU | OrganizationalUnitNotFoundException |
| Unknown parent / child / root | ParentNotFoundException / ChildNotFoundException / RootNotFoundException |
| Unknown policy / attachment target | PolicyNotFoundException / TargetNotFoundException |
| Describing or deleting an unset resource policy | ResourcePolicyNotFoundException |
| Resource-policy content outside 1–40,000 characters | InvalidInputException/MIN_LENGTH_EXCEEDED or MAX_LENGTH_EXCEEDED |
| Unparseable resource-policy content | InvalidInputException/INVALID_RESOURCE_POLICY_JSON |
PutResourcePolicy with Tags on an existing policy | InvalidInputException, with no reason prefix — the model's enum has no member for it |
| Duplicate OU name under one parent | DuplicateOrganizationalUnitException |
| Duplicate policy name | DuplicatePolicyException |
| Already attached | DuplicatePolicyAttachmentException |
| Detaching something not attached | PolicyNotAttachedException |
| Deleting an attached policy | PolicyInUseException |
| Deleting a non-empty OU | OrganizationalUnitNotEmptyException |
| Enabling an enabled policy type | PolicyTypeAlreadyEnabledException |
| Attaching while the type is disabled | PolicyTypeNotEnabledException |
CreatePolicy/EnablePolicyType under CONSOLIDATED_BILLING | PolicyTypeNotAvailableForOrganizationException |
| Unparseable policy content | MalformedPolicyDocumentException |
Modifying p-FullAWSAccess | InvalidInputException/IMMUTABLE_POLICY |
| Moving to the current parent | DuplicateAccountException |
| Closing the management account | ConstraintViolationException/CANNOT_CLOSE_MANAGEMENT_ACCOUNT |
Closing an account already SUSPENDED | AccountAlreadyClosedException |
Closing an account already PENDING_CLOSURE | ConflictException |
| Closing a malformed account ID | InvalidInputException/INVALID_PATTERN — shape is checked before existence |
CloseAccount under CONSOLIDATED_BILLING | ConstraintViolationException/ORGANIZATION_NOT_IN_ALL_FEATURES_MODE |
A member account calling CloseAccount | AccessDeniedException, 403 |
A member account calling Put/DeleteResourcePolicy | AccessDeniedException, 403 |
A member the resource policy does not name calling DescribeResourcePolicy | AccessDeniedException, 403 |
| A source that is not the account's parent | SourceParentNotFoundException |
| Unknown move destination | DestinationParentNotFoundException |
| A move across roots | InvalidInputException/MOVING_ACCOUNT_BETWEEN_DIFFERENT_ROOTS |
| A repeated tag key in one request | InvalidInputException/DUPLICATE_TAG_KEY |
An aws:-prefixed tag key | InvalidInputException/INVALID_SYSTEM_TAGS_PARAMETER |
| An unreadable pagination token | InvalidInputException/INVALID_NEXT_TOKEN |
| An unparseable request body | InvalidInputException |
| An unimplemented operation | UnknownOperationException, 404 — Organizations is JSON; see An operation substrate does not implement |
Quotas
Each is the value in Quotas for AWS Organizations, and each is enforced rather than merely documented.
| Quota | Value | Refusal reason |
|---|---|---|
| Accounts in an organization | 10 | ACCOUNT_NUMBER_LIMIT_EXCEEDED |
| OU nesting levels below the root | 5 | OU_DEPTH_LIMIT_EXCEEDED |
| OUs in an organization | 2,000 | OU_NUMBER_LIMIT_EXCEEDED |
| SCPs in an organization | 10,000 | POLICY_NUMBER_LIMIT_EXCEEDED |
| SCPs attached to one root, OU or account | 10 max, 1 min | MAX_POLICY_TYPE_ATTACHMENT_LIMIT_EXCEEDED / MIN_POLICY_TYPE_ATTACHMENT_LIMIT_EXCEEDED |
| Characters in an SCP | 10,240 | POLICY_CONTENT_LIMIT_EXCEEDED |
| Characters in the resource policy | 40,000 | MAX_LENGTH_EXCEEDED |
| Tags on one resource | 50 | MAX_TAG_LIMIT_EXCEEDED |
| Member-account closures in progress at once | 3 | CLOSE_ACCOUNT_REQUESTS_LIMIT_EXCEEDED |
The 5-per-target and 5,120-character figures often quoted are the RCP values, not the SCP ones.
Two published closure quotas are deliberately not enforced — the rolling 30-day allowance (250 or 20% of member accounts, capped at 1,000) and the four-day minimum age before a created account can be removed. Both require a wall-clock window, which a freely advanced simulated clock makes meaningless; see Closing an account does not remove it.
Three of these are also readable through Service Quotas — accounts, OUs and SCPs per organization — at the same values, so a consumer that reads a ceiling and a consumer that runs into one get the same number. See Service Quotas.
Pagination
Paginated listings honor MaxResults — clamped to the model's 1–20, so a caller asking for more gets a truncated page and a token rather than everything — and NextToken. An unreadable token is InvalidInputException/INVALID_NEXT_TOKEN rather than a silent restart from the beginning: a paginating caller that restarts sees duplicates instead of an error, which is the harder failure to notice.
Seeding
# An organization in which no service control policy can exist at all.
curl -X POST http://localhost:4566/v1/organizations/feature-set \
-d '{"featureSet":"CONSOLIDATED_BILLING"}'
curl -X DELETE http://localhost:4566/v1/organizations/feature-set
# The asynchronous outcome of CreateAccount, by account name or "*".
curl -X POST http://localhost:4566/v1/organizations/create-account-failure \
-d '{"accountName":"dev","failureReason":"EMAIL_ALREADY_EXISTS"}'
curl -X DELETE 'http://localhost:4566/v1/organizations/create-account-failure?accountName=dev'
curl -X DELETE http://localhost:4566/v1/organizations/create-account-failureA name-scoped seed takes precedence over the wildcard. The feature-set seed wins over the stored value, so an already observed organization can be flipped without recreating it.
failureReason must be a member of the model's CreateAccountFailureReason enum; anything else is a 400. A typo'd reason would seed a FAILED status carrying a value no SDK catch branch matches, so the caller's fallback path would go untested while the seed appeared to work. The seeded failure is the case worth testing: CreateAccount still returns 200, and only DescribeCreateAccountStatus reveals FAILED, the reason, and the absence of an AccountId.
Cost
Organizations API calls are free.
Config
Endpoint: config.{region}.amazonaws.comProtocol: JSON (X-Amz-Target: StarlingDoveService.{Op})
Note the target prefix. AWS Config's targetPrefix is StarlingDoveService, an internal code name bearing no resemblance to the config endpoint prefix — and every aws-sdk-go-v2, boto3 and CLI Config call routes by that target. A plugin registered without the alias would be fully unit-tested and unreachable from every SDK, which is what issues #561, #610 and #636 each turned out to be. The end-to-end journey exists to keep that from recurring.
Every Config exception is HTTP 400. Every exception shape in the API model carries exception: True with no error member, and every operation's reference page states "HTTP Status Code: 400" — NoSuchBucketException and NoSuchConfigRuleException included. A consumer branching on the status rather than the code takes a path AWS never sends it down.
One recorder and one delivery channel per account per Region. Both Puts are idempotent per the reference: a second call with the same name updates the role and recording group but does not replace creation-time tags. A second distinct name is MaxNumberOf{ConfigurationRecorders,DeliveryChannels}ExceededException. Both names default to default, and changing either requires a delete followed by a put. Every state key carries the Region, so a recorder put in us-east-1 is absent in eu-west-1 — "recording in one Region only" being a misconfiguration that looks like success from the Region you check.
Supported operations
| Operation | Notes |
|---|---|
| PutConfigurationRecorder | Idempotent; leaves recording: false; empty roleARN is InvalidRoleException |
| DescribeConfigurationRecorders | Reports the recorder whether or not it is recording — see below |
| DescribeConfigurationRecorderStatus | The only operation that answers "is it recording?"; ValidationException on more than one name |
| StartConfigurationRecorder | NoAvailableDeliveryChannelException with no channel; a no-op at 200 when already recording |
| StopConfigurationRecorder | A no-op at 200 when already stopped |
| DeleteConfigurationRecorder | NoSuchConfigurationRecorderException; no ordering precondition is documented |
| PutDeliveryChannel | NoAvailableConfigurationRecorderException before the bucket is looked at; then NoSuchBucketException, then InsufficientDeliveryPolicyException |
| DescribeDeliveryChannels | |
| DescribeDeliveryChannelStatus | Not_Applicable until the recorder first starts, then Success; seedable to Failure |
| DeleteDeliveryChannel | LastDeliveryChannelDeleteFailedException while the recorder records |
| PutConfigRule | Mints ConfigRuleId/ConfigRuleArn and refuses a create supplying either; an update may name the rule by Name, Id or Arn; 1000 rules per Region |
| DescribeConfigRules | Paginated, cap 100; honours the EvaluationMode filter; ConfigRuleNames 0–25 |
| DeleteConfigRule | Removes the rule's compliance seed and recorded evaluations with it |
| DescribeComplianceByConfigRule | INSUFFICIENT_DATA unless seeded — never computed |
| GetComplianceDetailsByConfigRule | Cap 100; ComplianceTypes 0–3; recorded PutEvaluations results outrank the seed |
| PutEvaluations | ResultToken required; TestMode stores nothing; Evaluations 0–100 and optional |
| PutConformancePack | Exactly one of TemplateS3Uri/TemplateBody/TemplateSSMDocumentDetails; 60 input parameters; 50 packs per Region |
| DescribeConformancePacks | Page size cap 20 |
| DescribeConformancePackStatus | CREATE_IN_PROGRESS → CREATE_COMPLETE on first observation; cap 20 |
| DescribeConformancePackCompliance | Cap 1000; seeded per rule |
| GetConformancePackComplianceSummary | Cap 20; ConformancePackNames 1–5 |
| DeleteConformancePack | ResourceInUseException while a create or delete is in flight |
| TagResource | Recorders, rules and packs; 50 tags; an aws:-prefixed key is refused |
| UntagResource | An absent key is a no-op, not a refusal |
| ListTagsForResource | Paginated; Limit capped at 100 — see Provenance below |
A recorder that exists is not a recorder that records
This is the behaviour the release exists for. DescribeConfigurationRecorders reporting a recorder says nothing about whether anything is being recorded; that is DescribeConfigurationRecorderStatus.recording. A recorder created and never started is the single most common real Config misconfiguration, and a consumer that checks only the first call reports an account as covered while nothing is recorded.
So PutConfigurationRecorder leaves recording: false and only StartConfigurationRecorder flips it. The two states are indistinguishable through the operation most consumers reach for first, which is precisely why substrate models them separately.
The ordering refusals are the other half, and they make a consumer's sequencing bug observable instead of silently tolerated:
StartConfigurationRecorder, no channel → NoAvailableDeliveryChannelException
PutDeliveryChannel, no recorder → NoAvailableConfigurationRecorderException
PutConfigRule, no recorder → NoAvailableConfigurationRecorderException
DeleteDeliveryChannel, recorder running → LastDeliveryChannelDeleteFailedExceptionThe last one is what makes a teardown-and-rebuild fixture — the same test run twice — express an ordering requirement rather than pass on a sequence AWS rejects:
Put recorder → Put channel → Start → Delete channel LastDeliveryChannelDeleteFailed
Stop → Delete channel → Delete recorder → Put recorder → Put channel → Start all 200The delivery-policy check reads real S3 state, permissively
PutDeliveryChannel is the one Config operation whose success depends on another service. A missing bucket is NoSuchBucketException; a bucket with no policy at all is InsufficientDeliveryPolicyException, which is certain rather than a guess about a policy's contents.
Where a policy does exist, the matcher passes if any Allow statement's principal covers config.amazonaws.com or * and its action covers s3:PutObject — including s3:Put*, s3:* and *. The resource ARN is not matched, and a policy shape substrate's parser cannot decode passes, because refusing there would be substrate blaming the consumer for its own limitation.
That asymmetry is deliberate. The two failure directions are not equivalent: always accepting would make a bucket-policy bug invisible here and fatal at AWS, while demanding the exact documented policy would refuse policies AWS accepts — and a wrong refusal breaks working code, which is the worse failure. The seed below exists for both edges of that choice.
Compliance is seeded, never computed
Substrate does not evaluate Config rules, and will not. Evaluating a rule against resource state is workload-internal rather than an API observation, so it falls outside what substrate models. Computing it would mean reimplementing hundreds of AWS-managed rules, and — worse — would make a consumer's compliance assertion silently change meaning as unrelated plugins gained fidelity.
An unevaluated rule therefore reports INSUFFICIENT_DATA, which is what AWS reports for a rule that has not evaluated. A default of COMPLIANT would make every consumer's compliance assertion pass for free, which is worse than no answer. Anything else comes from a seed.
PutEvaluations is the exception that proves the rule: a custom rule reporting its own result is an API observation, so what a caller submits is recorded — and where a rule has recorded evaluations, GetComplianceDetailsByConfigRule reports those in preference to the seed. A custom rule's own report outranks a fixture default.
Control plane
# A recorder reporting a failure. lastStatus must be a RecorderStatus member;
# lastErrorCode/lastErrorMessage apply only to Failure.
curl -X POST localhost:8080/v1/config/recorder-status \
-d '{"lastStatus":"Failure","lastErrorCode":"InsufficientDeliveryPolicy",
"lastErrorMessage":"Cannot write to the bucket"}'
curl -X DELETE localhost:8080/v1/config/recorder-status # ?accountId=®ion= to narrow
# A delivery stream that cannot write. Note Not_Applicable carries an underscore —
# the DeliveryStatus enum spells it differently from RecorderStatus's NotApplicable.
curl -X POST localhost:8080/v1/config/delivery-status \
-d '{"status":"Failure","lastErrorCode":"AccessDenied"}'
curl -X DELETE localhost:8080/v1/config/delivery-status
# Force or suppress the bucket-policy refusal regardless of real S3 state:
# "insufficient" for a consumer with no S3 fixture, "ok" for one whose valid policy
# substrate's permissive matcher still cannot read.
curl -X POST localhost:8080/v1/config/delivery-policy \
-d '{"bucket":"cfg-logs","outcome":"insufficient"}'
curl -X DELETE 'localhost:8080/v1/config/delivery-policy?bucket=cfg-logs'
# A rule's verdict. A {name} of "*" seeds every rule.
curl -X POST localhost:8080/v1/config/rule-compliance/s3-encrypted \
-d '{"complianceType":"NON_COMPLIANT","annotation":"Bucket b1 is unencrypted",
"resources":[{"resourceType":"AWS::S3::Bucket","resourceId":"b1"}]}'
curl -X DELETE localhost:8080/v1/config/rule-compliance/s3-encrypted
# A conformance pack's state, and its per-rule verdicts.
curl -X POST localhost:8080/v1/config/pack-status/ops -d '{"state":"CREATE_FAILED",
"statusReason":"The template could not be read"}'
curl -X POST localhost:8080/v1/config/pack-compliance/ops \
-d '{"rules":[{"configRuleName":"iam-password-policy",
"complianceType":"NON_COMPLIANT","controls":["CIS 1.5"]}]}'
curl -X DELETE localhost:8080/v1/config/pack-status/ops
curl -X DELETE localhost:8080/v1/config/pack-compliance/opsEvery seed is applied at read time rather than written into the resource, so clearing one restores the real state instead of leaving the seeded value behind. Seeds live in their own config-ctrl namespace so a seeded status is never mistaken for a real one in a state dump or during replay.
A seed that would be silently ignored is refused, which is the rule the whole family follows: a lastErrorCode on a non-Failure status, a statusReason on a pack state that is not one of the two failures, resources alongside INSUFFICIENT_DATA (which the EvaluationResult shape does not support), a body naming a different rule than the path, the same rule name twice in one pack-compliance seed, and any value outside its enum are all a 400. In particular NOT_APPLICABLE is refused for both a rule's verdict and a pack's: it is in the rule-level ComplianceType enum but not in the Compliance shape's subset, and ConformancePackComplianceType has no such member at all. Storing one would make substrate report a value no SDK enum member matches, so a consumer's switch would fall through to its default while the test passed asserting nothing.
Conformance pack status advances on observation
PutConformancePack returns CREATE_IN_PROGRESS, and the first DescribeConformancePackStatus resolves it to CREATE_COMPLETE and persists that, so every later observation reports the same state and the same timestamp. A waiter converges in one poll with no wall-clock dependence, and a status that re-resolved on each read would make a poll-comparing waiter loop forever. A seeded state does not advance — that is the point of seeding it.
ConformancePackStatusDetail carries all six required members, including a synthesized StackArn of the form arn:aws:cloudformation:{region}:{account}:stack/awsconfigconforms-{name}-{id}/{uuid}, matching the awsconfigconforms stack-name convention Config uses for the CloudFormation stack it deploys a pack through.
CloudFormation
AWS::Config::ConfigurationRecorder, AWS::Config::ConfigRule and AWS::Config::DeliveryChannel dispatch the real API operations. Earlier releases had all three reporting CREATE_COMPLETE while creating nothing.
A template carrying both a recorder and a channel ends with recording: true, per "AWS CloudFormation starts the recorder as soon as the delivery channel is available", and it does so no matter which order the two are declared in — ordering is carried by the deployer's type priority rather than by DependsOn. A template carrying only a recorder leaves it stopped, which is what a consumer's stack actually depends on.
Ref on the recorder and the channel returns the name (default, in the CFN page's own example); neither exposes any Fn::GetAtt attribute, because that section of each page is empty and exposing one would be an invention. AWS::Config::ConfigRule does expose Arn, ConfigRuleId and Compliance.Type, which its page documents.
CloudFormation marks RoleARN Required: Yes while the API model says No, so that refusal lives at the CloudFormation layer. Note also that CloudFormation spells the recorder's and channel's nested members UpperCamel where the API spells them lowerCamel (RecordingGroup.AllSupported vs. recordingGroup.allSupported); the deployer translates them, because real Config is case-sensitive and would silently ignore the UpperCamel form.
AWS::Config::ConformancePack remains unsupported in CloudFormation even though its API operations exist.
Provenance
ListTagsForResource'sLimitis documented two ways. The prose says "The limit maximum is 50. You cannot specify a number greater than 50"; the Valid Range says 0–100. Substrate takes 100, the API model's bound, so it never refuses a request the model permits.- The ARN templates come from the Service Authorization Reference, which the API reference does not give:
config-rule/${ConfigRuleId},conformance-pack/${Name}/${Id},delivery-channel/${Name}. The recorder's is two-segment —configuration-recorder/{name}/{id}— which corrected substrate's own pre-existing CloudFormation stub, that had mintedrecorder/{name}. roleARNis required though the model does not say so: "While the API model does not require this field, the server will reject a request without a definedroleARN." The exception is a null-or-empty check, not an assumability check, so a role that was never created is accepted — as AWS accepts it. Verifying assumability would refuse requests AWS accepts.- Three documented members are absent from the vendored botocore model —
ConfigurationRecorder.connectorArn,ConfigurationRecorder.scopeConfigurationandConfigRule.RuleEvaluationVisibility. Substrate models the vendored shape and does not emit them. - Recorder and delivery-channel maxima are not on the service-limits page. The "one per account per Region" note on each operation is what makes
MaxNumberOf…ExceededExceptionreachable at a count of two. - There is no delivery-channel resource type in the Service Authorization Reference's list of Config resource types, so
TagResourceaccepts recorders, rules and packs only.
Cost
Config API calls are free. Substrate does not model Config's per-configuration-item or per-rule-evaluation charges, because it records no configuration items and evaluates no rules.
Account Management
Endpoint: account.{region}.amazonaws.comProtocol: REST/JSON (POST /listRegions, /enableRegion, /disableRegion, /getRegionOptStatus — the operation is in the URL; there is no X-Amz-Target)
Substrate emulates the Region opt-in half of the Account Management API: which Regions an account may use, and the asynchronous opt in and out of the ones that are off by default. The other eleven operations in the model — alternate contacts, the primary contact, the account name and the primary email — are not emulated.
Supported operations
| Operation | Notes |
|---|---|
| ListRegions | All 34 Regions with their opt status; RegionOptStatusContains filters, MaxResults 1–50 |
| GetRegionOptStatus | The poll target; reports the Region name back alongside the status |
| EnableRegion | Asynchronous, empty 200, no output shape; idempotent against the target state |
| DisableRegion | The same, and refused for a Region that is enabled by default |
The two Region tables
The 17 default Regions — those launched before 2019-03-20 — report ENABLED_BY_DEFAULT and can be neither enabled nor disabled. The 17 opt-in Regions report DISABLED until an account enables them.
| Regions | |
|---|---|
Default (ENABLED_BY_DEFAULT) | ap-northeast-1, ap-northeast-2, ap-northeast-3, ap-south-1, ap-southeast-1, ap-southeast-2, ca-central-1, eu-central-1, eu-north-1, eu-west-1, eu-west-2, eu-west-3, sa-east-1, us-east-1, us-east-2, us-west-1, us-west-2 |
Opt-in (DISABLED until enabled) | af-south-1, ap-east-1, ap-east-2, ap-south-2, ap-southeast-3, ap-southeast-4, ap-southeast-5, ap-southeast-6, ap-southeast-7, ca-west-1, eu-central-2, eu-south-1, eu-south-2, il-central-1, me-central-1, me-south-1, mx-central-1 |
Both tables come from the Account Management Reference Guide rather than from the API model, which publishes the RegionOptStatus enum but no Region list.
ec2SeededRegions is a separate, smaller list and stays that way. EC2's DescribeRegions seeds three Regions and answers a different question — which Regions EC2 reports, not which ones an account has opted into. Unifying them would make every EC2 fixture's Region list depend on this table.
An opt resolves on observation
EnableRegion answers an empty 200 and moves the Region to ENABLING. The firstGetRegionOptStatus reports ENABLING; the next reports ENABLED, and it never moves again. DisableRegion behaves the same way through DISABLING to DISABLED. ListRegions resolves identically, so a caller polling through the listing and one polling a single Region cannot contradict each other.
Advancing on observation rather than after an interval of the simulated clock is what makes the wait testable: enabling a Region takes "a few minutes to several hours" in AWS, and a waiter here converges in two polls with no dependence on wall-clock or simulated time. The first observation reports the in-flight status deliberately — EnableRegion has no output shape, so a poll is the only place ENABLING is ever visible, and resolving before the first report would leave a consumer's in-flight branch unexecutable.
An opt is not an observation: a redundant EnableRegion neither advances the record nor rewrites it. Enabling a Region that is already ENABLED or ENABLING succeeds silently, which is what makes an "ensure these Regions are on" routine safe to re-run.
Refusals
Every 400 is ValidationException, the only one these operations declare, with the reason at the front of the message ("invalidRegionOptTarget: …") — the REST-JSON error document has no reason member to put it in. ValidationExceptionReason has exactly two members, and both are used:
| Case | Code | Reason |
|---|---|---|
| Enabling or disabling a default Region | ValidationException (400) | invalidRegionOptTarget |
| A Region code in neither table | ValidationException (400) | invalidRegionOptTarget |
An AccountId that is not a member of the caller's organization, or is the caller itself | ValidationException (400) | invalidRegionOptTarget / fieldValidationFailed |
MaxResults outside 1–50, a bad RegionOptStatusContains member, a missing RegionName, an unreadable NextToken | ValidationException (400) | fieldValidationFailed |
| The opposite opt is still in flight | ConflictException (409) | — |
Disabling a default Region is ValidationException, not ConstraintViolationException — the account/2021-02-01 model declares no such error for any operation, so a consumer catching one would never match.
Targeting a member account
AccountId names a member account of the caller's organization, resolved through the same member→management index Organizations uses; there is no second copy of it. The management account cannot specify its own AccountId — omit the parameter to operate in standalone context — and an account outside the caller's organization is refused. Trusted access and a delegated administrator for Account Management are not modelled, so the caller must be the management account itself.
Two limits are deliberately not modelled
AWS documents a limit of 6 region-opt requests in progress per account, and a per-organization limit that the same guide gives as 50 in one section and 20 in another. Neither is enforced here. A guessed TooManyRequestsException boundary would refuse requests AWS accepts, and there is no way to pick between two published numbers without making one of them wrong.
Seeding
# Pin what every observation of a Region reports, by Region code or "*".
curl -X POST http://localhost:4566/v1/account/region-opt-status \
-d '{"regionName":"af-south-1","status":"ENABLING"}'
curl -X DELETE 'http://localhost:4566/v1/account/region-opt-status?regionName=af-south-1'
curl -X DELETE http://localhost:4566/v1/account/region-opt-statusA Region-scoped seed takes precedence over the wildcard, and a seeded status does not resolve — that is the point of it. Because an in-flight opt otherwise advances on the first observation, the seed is the only way to hold a Region stuck in ENABLING, which is what a waiter's timeout branch and the ConflictException an opposite opt gets mid-flight both need.
status must be a member of the RegionOptStatus enum, and a default Region cannot be seeded: its status is fixed before a seed is ever consulted, so the seed would be silently ignored and the test using it would pass while asserting nothing. Both are a 400.
Cost
Account Management API calls are free.
Service Quotas
Endpoint: servicequotas.{region}.amazonaws.comProtocol: JSON (X-Amz-Target: ServiceQuotasV20190624.{Op})
Service Quotas answers from a built-in table of representative default quotas, not from the plugins that enforce them. It covers eleven services rather than AWS's full catalog, which is why an unrecognized service code is an error rather than an empty result — see below.
Supported operations
| Operation | Notes |
|---|---|
| ListServices | The service codes substrate publishes quotas for |
| ListServiceQuotas | ServiceCode required; NoSuchResourceException for a service not in the table |
| GetServiceQuota | ServiceCode and QuotaCode required |
| GetAWSDefaultServiceQuota | The same answer as GetServiceQuota — nothing here mutates a quota, so the applied value and the AWS default never diverge |
| RequestServiceQuotaIncrease | Records a PENDING request; the published value does not move |
| ListRequestedServiceQuotaChangeHistory | The caller's own requests; filterable by ServiceCode and Status |
| GetRequestedServiceQuotaChange | NoSuchResourceException for an unknown request ID |
The history operation also answers to ListRequestedServiceQuotaChangesByService, which is the name it shipped under and which the Service Quotas API does not have — the 2019-06-24 model declares ListRequestedServiceQuotaChangeHistory and ListRequestedServiceQuotaChangeHistoryByQuota and nothing else of that shape. So for one release the handler was reachable only by a hand-built X-Amz-Target, and every SDK or CLI call answered InvalidAction. The invented name is kept as an alias so a fixture that already drives it keeps working; new code should use the real one. …ChangeHistoryByQuota is not modelled.
An unknown service is a refusal, not an empty list
ListServiceQuotas for a service code that is not in the table answers NoSuchResourceException, which the API model declares for this operation. It previously answered HTTP 200 with {"Quotas": []}, and that is a different claim: this service exists and publishes no quotas, rather than there is no such service. Only the second is true of a code substrate does not carry, and the first sends a caller looking for a missing quota rather than a wrong service name.
GetServiceQuota distinguishes the two cases it can refuse for. Both are NoSuchResourceException — the only code the model declares — so the message is the only place they differ: an unknown service names the service and points at ListServices, while a known service with an unknown quota code names the code and points at ListServiceQuotas. Conflating them is what makes a missing service look like a bad quota code.
A missing required member is IllegalArgumentException rather than NoSuchResourceException: the request never named a resource, so reporting one as absent would be misleading.
An increase request does not grant anything
RequestServiceQuotaIncrease stores a PENDING record and returns it. The quota keeps reading its old value, because AWS grants nothing synchronously — a consumer that read the quota back expecting its DesiredValue would be asserting on a state real Service Quotas never reaches on that call.
A request is filed under the account that made it, and the two history operations report only that account's requests. Increase requests were previously filed under the literal 000000000000 regardless of the caller, so two accounts sharing one emulator shared one pile of requests — and a consumer reading the history to decide whether it had already asked would see a sibling's request as its own. A fixture that asserted on 000000000000 will now see the caller's real account.
Organizations quotas
The three ceilings the Organizations plugin enforces are readable here at the values it enforces them at. A quota table that disagreed with its own emulator would be worse than a missing one: a test written against the published number would fail for a reason that has nothing to do with the code under test.
| Quota code | Name | Value | Adjustable |
|---|---|---|---|
L-E619E033 | Maximum number of accounts | 10 | yes |
L-29A0C5DF | Service control policies in an organization | 10,000 | no |
L-0F0F51F4 | Organizational units in an organization | 2,000 | no |
All three are GlobalQuota: true — Organizations is a global service hosted in us-east-1, so its quotas are not per-region.
Only L-E619E033's code is confirmable from AWS documentation: Endpoints and quotas publishes a quota code only for the adjustable quotas, and that is the adjustable one. The other two codes are best-effort, and the User Guide notes that quota codes may change — use ListServiceQuotas to discover them rather than hard-coding. The values are documented in Quotas for AWS Organizations and match the Organizations quotas above.
Cost
Service Quotas API calls are free.
SES v2
Endpoint: email.{region}.amazonaws.comProtocol: REST/JSON
Supported operations
| Operation | Notes |
|---|---|
| CreateEmailIdentity | |
| GetEmailIdentity | |
| DeleteEmailIdentity | |
| ListEmailIdentities | |
| SendEmail | Returns stub MessageId; does not deliver |
CloudFormation resource types
| Type | Ref | Notes |
|---|---|---|
| AWS::SES::EmailIdentity | EmailIdentityName |
Cost
SES outbound email: $0.10 per 1,000 emails.
Kinesis Data Firehose
Endpoint: firehose.{region}.amazonaws.comProtocol: JSON (X-Amz-Target: Firehose_20150804.{Op})
Supported operations
| Operation | Notes |
|---|---|
| CreateDeliveryStream | |
| DescribeDeliveryStream | |
| DeleteDeliveryStream | |
| ListDeliveryStreams | |
| PutRecord | |
| PutRecordBatch |
CloudFormation resource types
| Type | Ref | Notes |
|---|---|---|
| AWS::KinesisFirehose::DeliveryStream | DeliveryStreamName |
Cost
Firehose data ingestion: $0.029 per GB.
Batch
Endpoint: batch.{region}.amazonaws.comProtocol: REST/JSON (POST /v1/{operation})
Supported operations
| Operation | Notes |
|---|---|
| CreateComputeEnvironment | computeEnvironmentName and type required; an omitted state is ENABLED |
| DescribeComputeEnvironments | computeEnvironments filter takes names or full ARNs; reports ecsClusterArn; a nextToken no previous call returned answers ClientException / 400 rather than page one |
| CreateJobQueue | jobQueueName required; an omitted state is ENABLED |
| DescribeJobQueues | jobQueues filter takes names or full ARNs; a nextToken no previous call returned answers ClientException / 400 rather than page one |
| RegisterJobDefinition | jobDefinitionName and type required; each registration is the next revision |
| DescribeJobDefinitions | jobDefinitions (${name}:${revision} or full ARN), jobDefinitionName (every revision), and status; a nextToken no previous call returned answers ClientException / 400 rather than page one |
| SubmitJob | Returns jobId; the job is immediately SUCCEEDED |
| DescribeJobs | |
| ListJobs | POST /v1/listjobs; RUNNING by default, jobQueue scopes, all five filters match by their published rules, maxResults/nextToken paginate, and arrayJobId/multiNodeJobId are empty listings |
| TerminateJob | Reports the job FAILED with the supplied reason |
Every operation reports a bad request as ClientException at HTTP 400. The API reference declares exactly two errors for each Batch operation, ClientException and ServerException, so substrate does not use the MissingParameter or InvalidParameterValue codes other services return for the same shape of complaint.
A describe filter takes a name or an ARN
All three resource describes document their filter as "a list of up to 100 … names or full Amazon Resource Name (ARN) entries", and both forms resolve. An absent filter reports every resource of that type in the caller's account and Region.
A filter entry naming a resource that does not exist is skipped, not refused: the operations describe "one or more of your compute environments" and document no not-found error, so an absent name yields an absent result rather than an error. A filter in which nothing matches is an empty list at HTTP 200.
maxResults and nextToken paginate. An absent or out-of-range maxResults reports up to 100 results, per the reference's "if this parameter isn't used, then Describe… returns up to 100 results". nextToken is omitted once the results are exhausted — "this value is null when there are no more results to return" — including when the last page is exactly full.
A job definition is versioned
Each RegisterJobDefinition of a name is a distinct revision, numbered from 1, and each revision is its own record. DescribeJobDefinitions addresses one revision through jobDefinitions (${name}:${revision} or the full ARN) and every revision of a definition through jobDefinitionName.
jobDefinitions wins outright when both are sent, rather than being intersected or unioned: the reference states it "can't be used with other parameters".
A newly registered definition is ACTIVE, and the status filter selects on that. Nothing yet reports a definition INACTIVE — DeregisterJobDefinition is not implemented (tracked as issue #555) — but the status is recorded on the resource rather than synthesised at read time, so a deregistration can set it.
ListJobs reads its request
#1236. The handler took its request as _ *AWSRequest, so none of the seven published members was read: it answered every job in the account and Region, in insertion order, with no cursor. The pagination gap it was filed under was the smallest of the four things that were wrong.
The published default is RUNNING only. "If you don't specify a status, only RUNNING jobs are returned." A SUCCEEDED job in a list a consumer reads as "still running" inverts the meaning of the call, and it was the default path — both of the page's own examples take it, and there is no parameter to blame it on. jobStatus selects any of the seven published values and a value outside them is refused; that a value outside a published Valid Values list is one of the "identifier[s] that's not valid" the ClientException gloss covers is substrate's reading, on the same footing as the token refusal above.
Because SubmitJob records a job SUCCEEDED at submission, substrate's default listing is always empty — no job is ever RUNNING. Implementing the default faithfully is what makes that visible; it is tracked as #1248 rather than papered over by keeping the every-status listing, because a listing AWS would not have answered is not a substitute for a job that reaches RUNNING.
One selector, or none. "You must specify only one of the following items: A job queue ID … A multi-node parallel job ID … An array job ID" — all three are Required: No individually, so the rule lives in that prose. More than one is refused. None of the three stays the account-wide listing, because the sentence forbids naming two rather than naming none and the page publishes no error for an empty request; requiring exactly one would be substrate's reading. jobQueue matches a name or a full ARN, so it reaches a job whichever form SubmitJob recorded.
arrayJobId and multiNodeJobId answer an empty list: SubmitJob records no arrayProperties and no nodeProperties, so substrate mints no children and no nodes and there is nothing for either listing to contain. Ignoring the member answered the account-wide listing instead, reporting a parent's children as though they existed — the worse of the two wrong answers, because it is not empty.
All five filters, by their own rules. JOB_NAME is a case-insensitive match with a trailing-asterisk prefix form; JOB_DEFINITION is case-sensitive, matches every revision of a bare name, supports the same asterisk on a name but not on an ARN; BEFORE_CREATED_AT and AFTER_CREATED_AT take milliseconds since the epoch, and a value that is not a number is refused rather than matching nothing. SHARE_IDENTIFIER is implemented and matches nothing, because no recorded job carries a share identifier — a real empty answer rather than an ignored filter. More than one filter is refused ("Only one filter can be used at a time"), a filter switches jobStatus selection off except for SHARE_IDENTIFIER, and the filter path sorts by createdAt with the most recent first, as the page publishes. The unfiltered path keeps insertion order, because the page states no order for it.
Two published properties of the filter path are not modeled: JOB_NAME's "the results are grouped by the job name and version", because a group is a property of the listing's shape rather than of any job and the page does not say what the grouping does to the order it publishes in the same paragraph; and "The filter doesn't apply to child jobs in an array or multi-node parallel (MNP) jobs", which is vacuous while both of those listings are empty.
The summary carries seven of the eighteen published JobSummary members — jobArn (derived as SubmitJob derives the one it returns), jobId, jobName, jobDefinition, createdAt, status and statusReason. It used to carry three. The other eleven describe the workload running inside the job rather than an API observation of it and have no record behind them, so they are declined rather than half-filled: a caller cannot tell a zero container.exitCode from a job that exited 0. The page's own two sample responses emit only jobId and jobName, so a reader of the examples alone would conclude nothing was missing; the Response Elements section is the authority.
maxResults clamps to the applicable published cap — 100 with filters, 1000 otherwise — and an absent, zero or negative value takes the cap too, which is the reading the three describes already record. nextToken is the same offset cursor, refused when no previous ListJobs returned it.
ListJobs also gained its published route, POST /v1/listjobs. Its members live in a body, which the legacy GET /v1/jobs route cannot carry, so that is the path an SDK call arrives on; the legacy route still answers, with every member absent.
Resources are scoped to the caller
A compute environment, job queue and job definition is recorded against the account and Region of the request that created it, and reported only to a caller in that scope, as every other partitioned plugin does. The ARN a create returns therefore names the caller's own account and Region, and is an identifier that caller's SubmitJob and computeEnvironmentOrder can use. A job definition's revision counter is scoped the same way, so two accounts each registering one definition of the same name both see revision 1.
Cost
Batch itself is free; the compute it launches is not, and substrate launches none.
SSO / Identity Store
Endpoint: sso.{region}.amazonaws.comProtocol: JSON (X-Amz-Target: SWBExternalService.{Op})
SWBExternalService is the sso-admin API's target prefix — not a name that appears in any published SDK surface, but what every client sends. Substrate accepts the plausible-looking AWSSSOAdminService as well, so a caller that constructs the target header by hand from the service name also reaches this plugin.
Responses carry Content-Type: application/x-amz-json-1.1 and errors are shaped as AWS JSON RPC, with the code in the body's __type member. Both were wrong until #758: substrate sent the unversioned application/json and shaped errors as REST-JSON, which puts the code in an x-amzn-errortype header that botocore's JSON parser never reads — so a refused call reported the stringified HTTP status instead of the error code. The cause of both was reading this plugin as the sso service (the OIDC token and account-list API, which really is REST-JSON) rather than as sso-admin, whose model is "protocol": "json" with "jsonVersion": "1.1".
Supported operations
| Operation | Notes |
|---|---|
| ListInstances | One instance per account, created on first read |
| CreatePermissionSet | |
| DescribePermissionSet | |
| UpdatePermissionSet | |
| DeletePermissionSet | |
| ListPermissionSets | |
| AttachManagedPolicyToPermissionSet | |
| DetachManagedPolicyFromPermissionSet | |
| ListManagedPoliciesInPermissionSet | |
| CreateAccountAssignment | |
| DeleteAccountAssignment | |
| ListAccountAssignments |
Athena
Endpoint: athena.{region}.amazonaws.comProtocol: JSON (X-Amz-Target: AmazonAthena.{Op})
Routing: Athena's target prefix carries no API version date — AmazonAthena is what every SDK sends, and parser.go aliases amazonathena to the athena plugin. The service model's API version is 2017-05-18 and appears in no wire field.
Supported operations
| Operation | Notes |
|---|---|
| StartQueryExecution | QueryString required; the query is already SUCCEEDED when the call returns |
| GetQueryExecution | Reports QueryExecutionId, Query, WorkGroup, Status and ResultConfiguration.OutputLocation, and nothing else |
| GetQueryResults | Returns the result set seeded for the execution's SQL; MaxResults and NextToken are not read |
| StopQueryExecution | Accepted for any stored execution, including one already SUCCEEDED |
| ListQueryExecutions | MaxResults defaults to 50 and is not clamped |
| CreateWorkGroup | Name and Description are read; Configuration and Tags are discarded |
| GetWorkGroup | Reports Name, State and Description, and nothing else; synthesises the primary workgroup when no record was written for it |
| DeleteWorkGroup | Refuses primary with InvalidRequestException/400 and "The primary workgroup cannot be deleted", which API_DeleteWorkGroup itself states; RecursiveDeleteOption is not read |
| ListWorkGroups | MaxResults defaults to 50 and is not clamped; the primary workgroup is prepended and pages like any other entry |
A query has already succeeded when StartQueryExecution returns
StartQueryExecution stores the execution with State: "SUCCEEDED" and SubmissionDateTime equal to CompletionDateTime, both read from the simulated clock. So QUEUED, RUNNING and FAILED — three of the five values QueryExecutionStatus.State publishes — cannot be produced by any code path, and there is no seed that changes that. CANCELLED is reachable, through StopQueryExecution.
The consequence for a consumer is that a GetQueryExecution poll loop terminates on its first observation on every run. That is a useful property for a fast test and a useless one for testing the loop: the waiter, the backoff and the failure branch are never entered, so a test cannot distinguish "my waiter works" from "my waiter never ran". A failure branch keyed on State == "FAILED" is dead code against Substrate.
#1155 covers this across the five services that share it, in the shape #514 shipped for EC2 instance state: a seeded count of observations in the transient state, defaulting to zero so no existing fixture changes.
StopQueryExecution writes CANCELED with one L, where the published enum spells it CANCELLED — #1154. A consumer comparing against its SDK's generated constant matches neither the stored value nor anything else.
QueryExecutionStatus' StateChangeReason and AthenaError members are not emitted at all, so a query has no reason to report even once a failure state can be reached.
The result set is seeded by SQL text
GetQueryResults returns rows from a seeded result set rather than executing anything — executing the SQL is workload-internal and out of scope. Seeds are written over the control plane:
POST /v1/athena/results {"sql": "SELECT 1", "columnMetadata": [...], "rows": [...]}
DELETE /v1/athena/results (all seeds; ?sql=… for one)Lookup order is exact SQL, then the "*" wildcard, then an empty result set. The SQL match is exact string equality, so a difference in whitespace or case misses the seed and reports zero rows rather than refusing. Seeds live in the athena-ctrl namespace keyed result:{sql} and are not scoped by account or Region: one seed serves every caller of the emulator.
The primary workgroup, and what is synthesised about it
Every AWS account has a primary workgroup. API_DeleteWorkGroup's own description states "The primary workgroup cannot be deleted", and the user guide's Manage workgroups page repeats the sentence verbatim, so both the existence and the refusal are published by the API reference rather than inferred from the guide.
Substrate still writes no record for it — there is nothing to write it in response to — and instead synthesises one on read. One producer and one reader, which is the whole of #1222: until it was fixed, GetWorkGroup synthesised the record while ListWorkGroups read only the workgroup-names index that CreateWorkGroup alone appends to, so GetWorkGroup("primary") answered 200, ListWorkGroups answered [], and ListQueryExecutions reported every unqualified query under a workgroup the listing said did not exist — three answers that cannot all be true of one account. DeleteWorkGroup compounded it by answering WorkGroup primary not found: the right code for the wrong reason.
What is read and what is substrate's own:
| Member | Where it comes from |
|---|---|
Name | primary, published by API_DeleteWorkGroup and by the user guide |
State | ENABLED — the workgroup is usable (an unqualified StartQueryExecution is attributed to it and runs), and API_WorkGroupSummary publishes only ENABLED and DISABLED, so a usable workgroup has exactly one available value |
Description | Primary workgroup — substrate's own placeholder. AWS publishes no description for it, and a real primary workgroup has none. Assert on Name and State; treat this string as arbitrary |
The synthesised entry is prepended to the listing, not appended, and the position is part of the contract rather than cosmetic. ListWorkGroups pages by an offset into this order (#1086); the primary workgroup exists before any workgroup a caller creates, so creation order puts it first, and prepending is the only position that leaves every other entry's offset unchanged — appending would move it on each CreateWorkGroup and leave a token issued mid-walk pointing at a different element.
A caller may create its own workgroup named primary. Then the stored record wins both readers, the listing reports it once (the duplicate guard is against the names index, which is what the walk orders), and DeleteWorkGroup still refuses it — the refusal is on the name, not on the absence of a record, because AWS's statement is flat.
Description is omitted from both readers' answers when it is empty, rather than sent as "": both API_WorkGroup and API_WorkGroupSummary give it Required: No with a minimum length of 0, and a workgroup created without a description has none rather than an empty one. Before #1222 GetWorkGroup sent the empty string while ListWorkGroups omitted the member, so the two readers differed over a workgroup neither was wrong about. EngineVersion and IdentityCenterApplicationArn are published WorkGroupSummary members that substrate does not carry, so neither reader reports them.
What a refusal reports
Athena has one refusal code, and it covers every condition:
| Condition | Code | Status |
|---|---|---|
| a body that will not parse, or is absent where a member is required | InvalidRequestException | 400 |
| a required member absent or empty | InvalidRequestException | 400 |
| a query execution or workgroup that does not exist | InvalidRequestException | 400 |
| a workgroup name already in use | InvalidRequestException | 400 |
DeleteWorkGroup naming the primary workgroup | InvalidRequestException | 400 |
The code is published — GetQueryResults declares InternalServerException/500, InvalidRequestException/400 and TooManyRequestsException/400, and Athena's consolidated Common Error Types list adds fifteen more — but the conditions Substrate attaches to it are broader than any page's, and in particular a not-found reports the same code as a missing parameter. Nothing in the plugin answers 404 or 500. MetadataException, ResourceNotFoundException and TooManyRequestsException are published and have no site.
Both paginators now refuse a NextToken neither of them issued, with InvalidRequestException/400 and a message naming the operation, rather than reading it as page one (#1086). What each page publishes about the token and what the refusal takes as its own reading are set out under Athena's two listings refuse a token above. Neither operation enforces the published MaxResults range, which is a separate defect: a value above the maximum of 50 is honored and one at or below zero is silently rewritten to 50.
CloudFormation resource types
| Type | Ref | Notes |
|---|---|---|
| AWS::Athena::WorkGroup | Name | A stub: properties are recorded in the CloudFormation stub store, which the Athena plugin does not read, so a workgroup deployed from a template is invisible to GetWorkGroup and ListWorkGroups |
Cost
StartQueryExecution is attributed $0.000005 per call, standing in for Athena's $5.00 per TB scanned. Substrate models no scan volume, so every query costs the same regardless of its SQL or its seeded result set.
CloudTrail
Endpoint: cloudtrail.{region}.amazonaws.comProtocol: JSON (X-Amz-Target: CloudTrail_20131101.{Op})
Routing: two target spellings are accepted — the short CloudTrail_20131101.{Op} and the fully qualified com.amazonaws.cloudtrail.v20131101.CloudTrail_20131101.{Op}, both of which SDKs have been observed to send. Neither is reached through a service alias, because the long form's first label is com; the router matches the prefix itself.
Supported operations
| Operation | Notes |
|---|---|
| CreateTrail | Name required; S3BucketName is Required: Yes and unchecked; the trail is logging from birth |
| GetTrail | |
| GetTrailStatus | Reports IsLogging: true unconditionally |
| UpdateTrail | Merges the supplied members into the stored trail |
| DeleteTrail | |
| DescribeTrails | trailNameList filters; a name that matches nothing is skipped rather than refused. IncludeShadowTrails is parsed and not read |
| StartLogging | Writes the flag; no endpoint reports it |
| StopLogging | Writes the flag; no endpoint reports it |
A trail is born logging and GetTrailStatus never looks
CreateTrail stores IsLogging: true, where AWS creates a trail that delivers nothing until an explicit StartLogging. And GetTrailStatus builds its response with IsLogging: true hardcoded rather than reading the stored flag, so StopLogging succeeds, changes state, and is invisible to the only operation that could report it.
The two compound: because a trail is born logging, a consumer that never calls StartLogging also sees true, so nothing distinguishes the hardcode from a working implementation. A test asserting that its own StopLogging took effect passes on a no-op. #1157.
A trail's ARN is always in the aws partition
CreateTrail builds the trail ARN with a literal aws partition, so a Region in aws-us-gov or aws-cn reports an ARN AWS would not issue. Substrate's Region handling is otherwise partition-agnostic.
What a refusal reports
| Condition | Code | Status |
|---|---|---|
| a body that will not parse | InvalidTrailNameException | 400 |
Name absent or empty | InvalidTrailNameException | 400 |
| a trail name already in use | TrailAlreadyExistsException | 400 |
| a trail that does not exist | TrailNotFoundException | 404 |
The 404 is a divergence: CloudTrail publishes every error at 400, TrailNotFoundException included, and no CloudTrail page publishes a 404 anywhere. Six operations propagate it — GetTrail, GetTrailStatus, UpdateTrail, DeleteTrail, StartLogging and StopLogging; DescribeTrails swallows it and reports a short list, which is what its page publishes. #1156 covers it together with CodePipeline, which has the same defect at the same scale.
InvalidTrailNameException is published, and its gloss covers a name that violates the published pattern — which Substrate does not check, so the code fires only for an absent name and an unparseable body. S3BucketDoesNotExistException, InsufficientS3BucketPolicyException, TrailNotProvidedException and the rest of the page's twenty-odd errors have no site: Substrate validates nothing about the destination bucket.
CloudFormation resource types
| Type | Ref | Notes |
|---|---|---|
| AWS::CloudTrail::Trail | TrailName | Ref returns the resource name where Substrate's physical ID is the ARN, so the name is recorded in the resource's metadata and read back from there (#827). A stub: the trail is invisible to GetTrail and DescribeTrails |
Cost
CreateTrail is attributed $0.000002 per call. Real CloudTrail charges nothing for the first copy of management events and $2.00 per 100,000 events for additional copies; Substrate counts no events.
CodeBuild
Endpoint: codebuild.{region}.amazonaws.comProtocol: JSON (X-Amz-Target: CodeBuild_20161006.{Op})
Supported operations
| Operation | Notes |
|---|---|
| CreateProject | name required; source, artifacts and environment are stored as opaque objects and never inspected |
| BatchGetProjects | An empty names array reports an empty list; an empty-string member is reported in projectsNotFound rather than refused |
| UpdateProject | Reads a project wrapper AWS does not send |
| DeleteProject | Refuses an absent project |
| ListProjects | Reports names only; sortBy, sortOrder and nextToken are not read |
| StartBuild | Only projectName is read; the build is SUCCEEDED before the call returns |
| BatchGetBuilds | An unreadable stored record is reported in buildsNotFound, so a store failure is indistinguishable from an absent build |
UpdateProject cannot be reached from an SDK
updateProject decodes its request into a struct whose only member is a "project" wrapper, where UpdateProjectInput is flat — AWS sends {"name": "…", "description": "…"}. So a request from any SDK or the CLI leaves the name empty and is refused with InvalidInputException / "name is required", naming a member the request did contain. There is no payload a real client can produce that reaches the handler's body.
The operation's response is correctly wrapped, which is presumably where the input shape came from: UpdateProjectOutput publishes a single project member. #1158, which also covers the second half — the update merges member by member, so an optional member such as description can never be cleared, where AWS replaces the project configuration.
DeleteProject is not idempotent
DeleteProject loads the project first and propagates a ResourceNotFoundException, so deleting something that is not there is refused. AWS publishes exactly one error on that page, InvalidInputException/400 — no not-found at all — and an empty successful response, which is the shape of an idempotent delete. A teardown path that runs twice succeeds against AWS and raises here, under a code the SDK's own model does not associate with the operation. #1159.
A build has already succeeded when StartBuild returns
StartBuild stores the build with buildStatus: "SUCCEEDED", currentPhase: "COMPLETED" and startTime equal to endTime. IN_PROGRESS, FAILED, FAULT, TIMED_OUT and STOPPED cannot be produced, and Build's phases, logs, artifacts and buildComplete members are not emitted at all. Everything the project carried about how to build is recorded and ignored — running the build is workload-internal and out of scope — but a consumer's wait loop has nothing to wait for. #1155.
StartBuild also reads only projectName: the twenty-odd *Override members AWS publishes, and idempotencyToken, are neither stored nor refused, so two identical calls mint two builds.
What a refusal reports
| Condition | Code | Status |
|---|---|---|
| a body that will not parse | InvalidInputException | 400 |
| a required member absent or empty | InvalidInputException | 400 |
| a project name already in use | ResourceAlreadyExistsException | 400 |
| a project that does not exist | ResourceNotFoundException | 400 |
All four are 400, which is what every CodeBuild page publishes — StartBuild's ResourceNotFoundException included, so CodeBuild is not part of the 404 divergence CloudTrail and CodePipeline share. AccountLimitExceededException and OAuthProviderException are published and have no site.
Two bookkeeping members reach the wire: CodeBuildProject and CodeBuildBuild are marshalled whole into their responses, so every project and build carries accountID and region, which are Substrate's own and appear on neither published shape (#756).
CloudFormation resource types
| Type | Ref | Notes |
|---|---|---|
| AWS::CodeBuild::Project | Name | A stub: properties are recorded in the CloudFormation stub store, which the CodeBuild plugin does not read, so a project deployed from a template is invisible to BatchGetProjects and ListProjects and cannot be built |
Cost
StartBuild is attributed $0.0001 per call, standing in for CodeBuild's per-build-minute charge ($0.005/minute for general1.small on Linux). Substrate's builds take no time, so the duration term has nothing to multiply.
CodePipeline
Endpoint: codepipeline.{region}.amazonaws.comProtocol: JSON (X-Amz-Target: CodePipeline_20150709.{Op})
Supported operations
| Operation | Notes |
|---|---|
| CreatePipeline | pipeline.name required; stages are stored as opaque objects and never validated |
| GetPipeline | version is decoded and ignored |
| UpdatePipeline | Merges roleArn and stages, increments version; reports no metadata |
| DeletePipeline | |
| ListPipelines | Reports name, version and timestamps; maxResults and nextToken are not read. A pipeline whose record cannot be loaded is skipped |
| StartPipelineExecution | The execution is Succeeded before the call returns; clientRequestToken and variables are not read |
| GetPipelineState | Reports every stage as Succeeded with an empty pipelineExecutionId |
| GetPipelineExecution | pipelineName is decoded and ignored |
An execution has already succeeded when StartPipelineExecution returns
StartPipelineExecution stores the execution with Status: "Succeeded", so InProgress, Stopping, Stopped, Superseded, Failed and Cancelled cannot be produced. No stage action runs — that is workload-internal — but neither does any stage state progress, so a consumer polling GetPipelineExecution for completion is answered on its first observation every time. #1155.
clientRequestToken is the published idempotency member and is not read, so a retried start mints a second execution where AWS would return the first.
GetPipelineExecution and GetPipeline answer for the wrong resource
getPipelineExecution keys state on the execution ID alone and never reads pipelineName, which is Required: Yes — so a request that omits it succeeds, and an execution ID belonging to pipeline A is reported successfully when asked for under pipeline B. AWS's own error text states the cross-check as part of the contract: "…or an execution ID does not belong to the specified pipeline."
getPipeline decodes version and always reports the current one, so a request for version 1 of a pipeline updated three times answers version 4 at HTTP 200 rather than the published PipelineVersionNotFoundException. #1160.
GetPipelineExecution's response is the persisted record marshalled whole, so it carries accountID and region, which are Substrate's own bookkeeping and appear on no published shape (#756).
GetPipelineState reports a shape no execution produced
GetPipelineState derives one stage state per stored stage definition, each with latestExecution.status: "Succeeded" and latestExecution.pipelineExecutionId: "" — an empty string where the member is published as an execution ID, and a success regardless of whether any execution has ever run. A pipeline created and never started reports every stage succeeded. actionStates, inboundTransitionState and beforeEntryConditionState are not emitted.
What a refusal reports
| Condition | Code | Status |
|---|---|---|
| a body that will not parse | InvalidStructureException | 400 |
| a required name absent or empty | InvalidStructureException | 400 |
| a pipeline name already in use | PipelineNameInUseException | 400 |
| a pipeline that does not exist | PipelineNotFoundException | 404 |
| a pipeline execution that does not exist | PipelineExecutionNotFoundException | 404 |
Both 404s are divergences — CodePipeline publishes every error at 400 — and six operations propagate one of them: GetPipeline, UpdatePipeline, DeletePipeline, StartPipelineExecution, GetPipelineState and GetPipelineExecution. ListPipelines swallows the refusal and reports a short list. #1156 covers it together with CloudTrail.
InvalidStructureException is published, glossed "The structure was specified in an invalid format" — but GetPipeline does not publish it at all (its three errors are PipelineNotFoundException, PipelineVersionNotFoundException and ValidationException), so on the four operations that reach it through the shared name check it is Substrate's reading rather than that page's vocabulary. ValidationException, PipelineVersionNotFoundException, ConcurrentModificationException and LimitExceededException are published and have no site.
CloudFormation resource types
| Type | Ref | Notes |
|---|---|---|
| AWS::CodePipeline::Pipeline | Name | A stub: properties are recorded in the CloudFormation stub store, which the CodePipeline plugin does not read, so a pipeline deployed from a template is invisible to GetPipeline and ListPipelines and cannot be started. The ARN carries no resource-type prefix (arn:aws:codepipeline:{region}:{account}:{name}), which is the form AWS publishes |
Cost
StartPipelineExecution is attributed $0.000001 per call. Real CodePipeline charges $1.00 per active pipeline per month rather than per execution, so the attribution is a proxy: Substrate has no month.
Redshift Data API
Endpoint: redshift-data.{region}.amazonaws.comProtocol: JSON (X-Amz-Target: RedshiftData.{Op})
Supported operations
| Operation | Notes |
|---|---|
| ExecuteStatement | Sql required; the statement is FINISHED before the call returns. SecretArn is decoded and not stored; Parameters, StatementName, WithEvent and ClientToken are not read |
| DescribeStatement | Reports Id, Status, QueryString, CreatedAt, UpdatedAt and Error, and nothing else |
| GetStatementResult | Returns the result set seeded for the statement's SQL; NextToken is not read and no NextToken is emitted |
BatchExecuteStatement, CancelStatement, DescribeTable, ListDatabases, ListSchemas, ListStatements, ListTables and GetStatementResultV2 are not routed.
The statement status is seeded at execute time and frozen
A statement's status is read from the control plane when ExecuteStatement runs and stored on the record; DescribeStatement reports what was stored. Four consequences, all of them #1163:
- Seeding after
ExecuteStatementhas no effect on that statement. - No progression is expressible: a statement cannot be
STARTEDfor two observations and thenFINISHED, which is the shape of every Redshift Data wait loop. - The seed is one global value under the literal key
status— no statement ID, no"*"wildcard, no account or Region qualification — so one seed governs every statement in every account, and there is noDELETE /v1/redshift-data/status, so a seededFAILEDpersists for the life of the process. - Both control-plane reads discard their error, so a store failure is indistinguishable from an absent seed.
POST /v1/redshift-data/status {"status": "FAILED", "errorMessage": "query timed out"}The endpoint accepts FINISHED, FAILED, ABORTED and STARTED, and refuses anything else with HTTP 400 — so SUBMITTED and PICKED, two of the six values the published enum carries, cannot be seeded. errorMessage is reported as Error only when the status is FAILED.
GetStatementResult does not consult the status at all: a statement seeded FAILED still returns its seeded rows at HTTP 200.
The result set is seeded by the statement's SQL
POST /v1/redshift-data/results {"sql": "SELECT 1", "columnMetadata": [...], "records": [...]}
DELETE /v1/redshift-data/results (all seeds; ?sql=… for one)Lookup order is a Go-level in-memory map (exact SQL, then "*"), then the control-plane state (exact SQL, then "*"), then an empty result set. The match is exact string equality on the SQL the statement was created with. TotalNumRows is the number of seeded records.
DescribeStatement reports six members
Id, Status, QueryString, CreatedAt, UpdatedAt and — for a failed statement — Error. UpdatedAt is always exactly CreatedAt. Absent are HasResultSet, which is the member a consumer checks before calling GetStatementResult, along with Duration, ResultRows, ResultSize, RedshiftPid, RedshiftQueryId, WorkgroupName, ClusterIdentifier, Database, DbUser, SecretArn, SessionId, SubStatements and QueryParameters. The two timestamps are emitted as epoch seconds, which is what the protocol's JSON version specifies.
Responses carry Content-Type: application/json where the service's protocol is JSON 1.1 and application/x-amz-json-1.1 is what the rest of the tree emits — also #1163.
What a refusal reports
| Condition | Code | Status |
|---|---|---|
| a body that will not parse | ValidationException | 400 |
Sql or Id absent or empty | ValidationException | 400 |
| a statement that does not exist | ResourceNotFoundException | 400 |
Both codes and both statuses are what API_DescribeStatement publishes. ActiveStatementsExceededException, ActiveWaitingRequestsExceededException, BatchExecuteStatementException, ExecuteStatementException, DatabaseConnectionException and InternalServerException are published and have no site: Substrate holds no connection, enforces no concurrency ceiling, and has no internal failure to report.
CloudFormation resource types
None. AWS publishes no CloudFormation resource type for the Redshift Data API — a statement is an action, not a resource.
Cost
Nothing is attributed. The Redshift Data API itself is free; a query's cost falls on the cluster or Serverless workgroup that runs it, which Substrate does not model.
SageMaker
Endpoint: api.sagemaker.{region}.amazonaws.comProtocol: JSON (X-Amz-Target: SageMaker.{Op})
Two unrelated slices of SageMaker are modelled: the Studio app lifecycle and training jobs.
Supported operations
| Operation | Notes |
|---|---|
| ListDomains | Always an empty list — Substrate has no domain records |
| ListApps | DomainIdEquals and UserProfileNameEquals filter; MaxResults, NextToken, SortBy and SortOrder are not read |
| CreateApp | Only AppName is required; AppType and DomainId are Required: Yes and unchecked |
| DeleteApp | Answers 200 for an app that does not exist |
| DescribeApp | Reports the stored record whole |
| CreatePresignedDomainUrl | A fixed stub URL; no domain, user profile or expiry is read |
| CreateTrainingJob | Only TrainingJobName is read; the job is Completed before the call returns |
| DescribeTrainingJob | Applies the seeded status |
| StopTrainingJob | Writes Stopped directly; Stopping is never observable, and a Completed job is stopped without complaint |
| ListTrainingJobs | Does not apply the seed; reads no request member at all, so StatusEquals, NameContains, the four time filters, SortBy, SortOrder, MaxResults and NextToken are all ignored |
A training job is Completed at birth, and the seed drives one endpoint
CreateTrainingJob stores TrainingJobStatus: "Completed", so InProgress and Stopping are unreachable without a seed. Nothing in CreateTrainingJob's large published input is read beyond the name: the algorithm, the resource configuration, the hyper-parameters and the input data are neither stored nor validated. Running the training is out of scope; the seed is what makes the outcome assertable:
POST /v1/sagemaker/training-job-status {"trainingJobName": "job-1", "status": "Failed",
"failureReason": "CapacityError: …"}
DELETE /v1/sagemaker/training-job-status (all seeds; ?trainingJobName=… for one)trainingJobName defaults to the "*" wildcard, and lookup is exact name first, then "*". The seed overrides what an observation reports; it never rewrites the stored record.
Two gaps, both #1162:
ListTrainingJobsbuilds its summaries straight from state and does not apply the seed, so a job seededFailedis reportedCompletedby one endpoint andFailedby the other in the same instant — two API observations of one resource that contradict each other.- The control plane checks only that
statusis non-empty, so a misspelling is accepted and reported as a status outside the published enum. Redshift Data's status endpoint, which validates against its four accepted values, is the in-tree counterexample.
DeleteApp does not look before deleting
DeleteApp deletes the state key unconditionally and answers 200 with an empty body, so deleting an app that never existed succeeds. API_DeleteApp publishes ResourceNotFound, which DescribeApp already answers for the same condition. #1162.
An app's state key is the account, Region, domain ID, user profile name, app type and app name joined — all six, so two apps differing only in type are distinct records, which is what AWS's four-part identity implies.
What a refusal reports
| Condition | Code | Status |
|---|---|---|
| a body that will not parse | ValidationException | 400 |
AppName or TrainingJobName absent or empty | ValidationException | 400 |
| an app or training job that does not exist | ResourceNotFound | 400 |
ResourceNotFound is spelled without the Exception suffix because that is how SageMaker publishes it, and at 400, which is the status its pages publish. SageMaker's ResourceInUse, ResourceLimitExceeded and ConflictException are published and have no site.
DescribeApp and DescribeTrainingJob marshal the persisted record whole, so both carry AccountID and Region — Substrate's own bookkeeping, on neither published shape (#756).
CloudFormation resource types
None are handled specifically. A AWS::SageMaker::* resource in a template falls to the generic stub, whose Ref is the logical ID, and is invisible to the SageMaker plugin.
Cost
CreateTrainingJob is attributed $0.001 per call and CreateApp $0.0001. Real SageMaker bills training by instance-second and a Studio app by the instance behind it; Substrate's jobs and apps consume no time, so the attribution is a per-call proxy rather than a rate.
WAFv2
Endpoint: wafv2.{region}.amazonaws.com (and wafv2.us-east-1.amazonaws.com for Scope: CLOUDFRONT) Protocol: JSON (X-Amz-Target: AWSWAF_20190729.{Op})
Routing: parser.go aliases awswaf to the wafv2 plugin. Classic WAF (AWSWAF_20150824) is not routed.
Scope is part of a resource's identity. A Web ACL's and an IP set's state keys carry the account, the Region and the scope, so the same name in REGIONAL and CLOUDFRONT is two resources, which is what AWS's own model implies. An invalid Scope is refused everywhere except GetWebACL, where AWS marks the member Required: No and Substrate defaults it to REGIONAL deliberately (#1062). The assoc: keys that record AssociateWebACL omit the scope, since a regional resource ARN can only be associated with a regional Web ACL.
Supported operations
| Operation | Notes |
|---|---|
| CreateWebACL | Name, Scope and DefaultAction are read; Rules and VisibilityConfig are stored as opaque objects |
| GetWebACL | Scope defaults to REGIONAL; resolvable by ARN or by the Name+Id+Scope triple |
| UpdateWebACL | Requires a matching LockToken; merges rather than replaces |
| DeleteWebACL | Requires a matching LockToken |
| ListWebACLs | Does not paginate |
| AssociateWebACL | Records the association; the resource ARN is not checked against any service's state |
| DisassociateWebACL | |
| GetWebACLForResource | Reports an ARN where a WebACL is published |
| CreateIPSet | Name, Scope, IPAddressVersion and Addresses are read; a CIDR is not validated |
| GetIPSet | |
| UpdateIPSet | Requires a matching LockToken; merges |
| DeleteIPSet | Requires a matching LockToken |
| ListIPSets | Does not paginate |
Neither list operation paginates
ListWebACLs and ListIPSets declare a Limit int member on their shared input struct and read neither it nor any cursor: every call returns the whole set and no NextMarker is ever emitted. Both pages publish Limit with a Valid Range of 1–100 and NextMarker on request and response.
A consumer that walks NextMarker until it is absent works here by accident — one page, no marker, loop exits — so a paging bug in the consumer cannot be caught, and a caller that sends Limit: 1 is silently given everything. #1161.
GetWebACLForResource reports an ARN, not a Web ACL
The response is {"WebACL": {"ARN": "…"}}, a one-member object where AWS publishes the full WebACL shape. DefaultAction, Id, Name and VisibilityConfig are all Required: Yes on that shape, so the body is not a valid WebACL at all, and a caller reading the returned ACL's rules or capacity gets a zero value rather than a refusal. Substrate holds the whole record — GetWebACL reports it — so this is a lookup that was not done. #1161.
UpdateWebACL merges, so a member can never be cleared
UpdateWebACL and UpdateIPSet copy each supplied member over the stored record and leave the others alone, where AWS's update operations replace the whole configuration — their inputs mark DefaultAction, VisibilityConfig and Addresses Required: Yes precisely because the call is a replacement. A Description set once cannot be removed, and a rule list cannot be emptied.
Both operations do enforce the LockToken, which is the part that matters for a consumer's optimistic-concurrency handling: a stale token is refused with WAFOptimisticLockException and a new token is minted on every successful write.
What a refusal reports
| Condition | Code | Status |
|---|---|---|
| a body that will not parse | WAFInvalidParameterException | 400 |
an invalid Scope | WAFInvalidParameterException | 400 |
| a Web ACL or IP set that does not exist | WAFNonexistentItemException | 400 |
a LockToken that does not match | WAFOptimisticLockException | 400 |
Those three answered 404 until #1098. Every WAFv2 page that lists WAFNonexistentItemException publishes it at 400 — GetIPSet's Errors section gives the gloss, "AWS WAF couldn't perform the operation because your resource doesn't exist. If you've just created a resource that you're using in this operation, you might just need to wait a few minutes." — and no WAFv2 page publishes a 404 at all, so a consumer branching on the status rather than on the code saw a shape AWS never sends. The messages are substrate's own, naming which Web ACL or IP set was not found, because the published gloss names neither.
The one 404 that remains is not WAFv2's: an operation the plugin does not route answers UnknownOperationException, which the JSON protocol's own Common Errors page publishes at 404.
WAFDuplicateItemException, WAFLimitsExceededException, WAFInvalidResourceException, WAFUnavailableEntityException and WAFInternalErrorException are published and have no site: Substrate enforces no capacity ceiling, validates no rule statement, and checks no association target.
CloudFormation resource types
| Type | Ref | Notes |
|---|---|---|
| AWS::WAFv2::WebACL | name|id|scope | Ref is the composite AWS publishes, with the scope spelled as the template spells it (REGIONAL/CLOUDFRONT) rather than lowercased as the ARN segment is. The ARN comes from wafv2ARN, the same builder the plugin uses, so one logical Web ACL cannot report two different ARNs depending on which path created it. A stub otherwise: the Web ACL is invisible to GetWebACL and ListWebACLs |
Cost
CreateWebACL is attributed $5.00 per call — WAF's real charge is $5.00 per Web ACL per month, taken here once at creation — and AssociateWebACL $0.000001. Per-request WAF charges ($0.60 per million) are not modelled, because Substrate sees no traffic through a Web ACL.
AWS Backup
Endpoint: backup.{region}.amazonaws.com
Protocol: REST-JSON — the operation is the HTTP method plus the URL path, not an X-Amz-Target header. API version 2018-11-15.
Twelve operations over three resources: backup vaults, backup plans, and a plan's resource selections. Every record is keyed by account and Region, so two accounts, or one account in two Regions, never see each other's vaults or plans.
Nothing is ever backed up. A plan's Rules are stored verbatim as recorded intent and no schedule is ever evaluated, so no backup job, recovery point or restore job exists and a vault's NumberOfRecoveryPoints is 0 for its whole life. That is the boundary in doc.go: the API observation is modelled, the work behind it is not.
The published path is given for every operation because one of them cannot be reached over it.
Supported operations
| Operation | Published path | Notes |
|---|---|---|
| CreateBackupVault | PUT /backup-vaults/{backupVaultName} | Routed on the published verb. Answers exactly the three published members. BackupVaultTags and CreatorRequestId are not read, so a create-time tag set is dropped, and EncryptionKeyArn is echoed without the KMS key having to exist |
| DescribeBackupVault | GET /backup-vaults/{backupVaultName} | Five of the seventeen published members, plus two of Substrate's own |
| DeleteBackupVault | DELETE /backup-vaults/{backupVaultName} | Answers {}, which is the published empty body. Its published precondition cannot fail here |
| ListBackupVaults | GET /backup-vaults/ | BackupVaultList of whole vault records; maxResults, nextToken, shared and vaultType are all ignored and no NextToken is emitted |
| CreateBackupPlan | PUT /backup/plans/ | Routed on POST instead. BackupPlanName is required; Rules are stored unvalidated, AdvancedBackupSettings is not read, and CreatorRequestId is ignored, so the published idempotency — "If the request includes a CreatorRequestId that matches an existing backup plan, that plan is returned" — does not hold. The plan ARN uses the wrong resource segment |
| GetBackupPlan | GET /backup/plans/{backupPlanId}/ | Unreachable over that path; versionId and MaxScheduledRunsPreview are not read |
| UpdateBackupPlan | POST /backup/plans/{backupPlanId} | Routed on the published verb, but merges where AWS replaces and answers members no page publishes |
| DeleteBackupPlan | DELETE /backup/plans/{backupPlanId} | Answers {} where four members are published, and ignores the plan's selections |
| ListBackupPlans | GET /backup/plans/ | Five of the nine published BackupPlansListMember members per plan; includeDeleted, maxResults and nextToken are ignored |
| CreateBackupSelection | PUT /backup/plans/{backupPlanId}/selections/ | Routed on POST instead; refuses an unknown plan. SelectionName is required; Conditions, ListOfTags and NotResources are not read |
| GetBackupSelection | GET /backup/plans/{backupPlanId}/selections/{selectionId} | Answers BackupPlanId, SelectionId, CreationDate and a three-member BackupSelection; CreatorRequestId is not recorded |
| DeleteBackupSelection | DELETE /backup/plans/{backupPlanId}/selections/{selectionId} | Answers {}, which is the published empty body |
Every other AWS Backup operation is unrouted, including the whole job surface — StartBackupJob, DescribeBackupJob, ListBackupJobs, StartRestoreJob, ListRecoveryPointsByBackupVault — as well as ListBackupSelections, ListBackupPlanVersions, PutBackupVaultAccessPolicy, PutBackupVaultLockConfiguration, GetBackupPlanFromJSON and the three tag operations. No Backup resource is scanned by the Resource Groups Tagging API either, so a vault or plan cannot be found by tag.
A create writes its resource and then adds it to a name or ID index with a helper that discards the index write's error, so a create can report success while the resource is missing from ListBackupVaults or ListBackupPlans (#1175).
GetBackupPlan is unreachable over its published path
API_GetBackupPlan publishes GET /backup/plans/{backupPlanId}/?… — with a trailing slash before the query string. The router treats everything after /backup/plans/ as the plan ID, so an SDK built from the model asks for the ID abc/ and the lookup misses: a plan that exists, and that ListBackupPlans reports, answers ResourceNotFoundException. Through an SDK the operation does not work at all. It is the only routed Backup operation whose published path puts a trailing slash after a path parameter; the two creates publish one too, but there the remainder is empty and only the verb is wrong. #1176.
versionId is unread for a structural reason rather than an oversight: one record is kept per plan and UpdateBackupPlan overwrites it, so no previous version exists to fetch.
The two backup creates are routed on the wrong verb
API_CreateBackupPlan publishes PUT /backup/plans/ and API_CreateBackupSelection publishes PUT /backup/plans/{backupPlanId}/selections/. Both are routed on POST, and nothing routes the published PUT, so an SDK call falls through to the router's fallback and is refused as an unknown route. CreateBackupVault is on its published PUT, so the plugin's three creates do not agree with each other. #1172.
Two backup plan responses carry the wrong members
UpdateBackupPlan answers BackupPlanId, BackupPlanArn, VersionId and an UpdatedAt that is on no AWS Backup page, while CreationDate — published, and already held on the stored record — is absent. DeleteBackupPlan answers {} where the page publishes BackupPlanArn, BackupPlanId, DeletionDate and VersionId; VersionId is the only handle on the version that was deleted, so the member identifying what happened is the one missing. DeleteBackupVault and DeleteBackupSelection publish "an HTTP 200 response with an empty HTTP body", so their {} is faithful. #1177.
UpdateBackupPlan also merges where AWS replaces. BackupPlan is Required: Yes and describes the plan in full, but an omitted BackupPlanName or Rules leaves the stored value in place, and an empty body updates nothing while still minting a new VersionId — so an update that drops a rule does not drop it here.
Which backup preconditions are enforced
API_DeleteBackupPlan opens with "A backup plan can only be deleted after all associated selections of resources have been deleted." That is not enforced: a plan with selections is deleted, and GetBackupSelection then answers HTTP 200 for a selection of a plan that no longer exists, reporting the deleted plan's ID. CreateBackupSelection does check the plan, so the selection namespace accepts reads for a parent it will not accept writes for. #1178.
API_DeleteBackupVault's mirror precondition — "A vault can be deleted only if it is empty" — is vacuous rather than unenforced. No operation creates a recovery point, so NumberOfRecoveryPoints is 0 for a vault's whole life and the condition cannot fail.
The backup vault record goes out whole
DescribeBackupVault and ListBackupVaults marshal the persisted vault straight onto the wire, so AccountID and Region — Substrate's own bookkeeping — appear as response members (#756). Five published members are present (BackupVaultName, BackupVaultArn, EncryptionKeyArn, CreationDate, NumberOfRecoveryPoints) and twelve are absent, VaultState, Locked, MinRetentionDays and CreatorRequestId among them. The plan and selection handlers build their responses member by member, so the vault is the only Backup record that leaks.
What a refusal reports
| Condition | Code | Status |
|---|---|---|
| a body that will not parse | InvalidRequestException | 400 |
BackupVaultName, BackupPlanName or SelectionName absent | InvalidRequestException | 400 |
| a vault name already in use | AlreadyExistsException | 400 |
| a vault, plan or selection that does not exist | ResourceNotFoundException | 404 |
AlreadyExistsException/400 is what API_CreateBackupVault publishes. The other two diverge, and both are #1173: every Backup page publishes ResourceNotFoundException at 400, not 404, and the published code for an absent required member is MissingParameterValueException. InvalidRequestException is published on the delete pages, for input that is wrong rather than missing, and on the create pages not at all.
InvalidParameterValueException, LimitExceededException and ServiceUnavailableException/500 are published across these pages and have no site here: Substrate enforces no vault or plan quota and has no transient failure to report.
CloudFormation resource types
| Type | Ref | Notes |
|---|---|---|
AWS::Backup::BackupPlan | the logical ID | A stub. No property is read — including BackupPlan, which is Required: Yes — and the plan is written to the CloudFormation stub namespace rather than to Backup's own, so it is invisible to GetBackupPlan and ListBackupPlans. AWS publishes that Ref returns BackupPlanId, and BackupPlanArn, BackupPlanId and VersionId as Fn::GetAtt attributes; Substrate returns the logical ID and supports no attribute, and the deploy function's own doc comment claims the Ref is the plan ID (#1182) |
AWS::Backup::BackupVault and AWS::Backup::BackupSelection are not deployed.
ARN shapes
| Resource | Substrate | Published |
|---|---|---|
| vault | arn:aws:backup:{region}:{account}:backup-vault:{name} | the same |
| plan | arn:aws:backup:{region}:{account}:backup-plan:{planId} | …:plan:{planId} |
| selection | none — a selection carries no ARN | AWS publishes none either |
The two segments really are spelled differently by the same service: backup-vault for a vault and plan for a plan, each published as a worked example rather than as a format string. Substrate's vault matches; its plan does not, in the API handler and in the CloudFormation deployer alike, so an IAM policy or an ARN parser written against Substrate's plan ARN matches nothing on AWS (#1181).
Cost
CreateBackupPlan is attributed $0.000001 per call. Real AWS Backup charges for protected storage and for restores, not for creating a plan; Substrate stores no backups, so the attribution stands in for a plan's existence rather than for anything AWS would bill.
Bedrock Runtime
Endpoint: bedrock-runtime.{region}.amazonaws.com
Protocol: REST-JSON, path-routed. Two API versions, because two services are served here: the bedrock-runtime data plane is 2023-09-30 and the bedrock control plane is 2023-04-20.
Routing: bedrock is aliased to bedrock-runtime, because boto3's bedrock-runtime client signs with bedrock as the SigV4 signing name in the credential scope when AWS_ENDPOINT_URL is set. One plugin therefore serves two AWS services: the bedrock-runtime data plane (InvokeModel, ApplyGuardrail) and the four *ModelInvocationJob batch-inference operations, which belong to the bedrock control plane.
No inference is performed. InvokeModel answers a seeded body or a canned one, ApplyGuardrail answers a deterministic verdict, and a batch job's status is a value a seed sets — all of which is the point: a consumer's polling and guardrail-handling paths become testable without a model ever running.
Supported operations
| Operation | Notes |
|---|---|
| InvokeModel | POST /model/{modelId}/invoke. Answers a seeded response body verbatim, or a canned Claude Messages body naming the requested model. Nothing but the model ID is read — not the body, not accept or contentType, and not the guardrail headers |
| ApplyGuardrail | POST /guardrail/{guardrailIdentifier}/version/{guardrailVersion}/apply. NONE or GUARDRAIL_INTERVENED, decided by a blocklist; the version is discarded |
| CreateModelInvocationJob | POST /model-invocation-job. Answers {"jobArn"}, exactly the published shape, and records the job as Submitted — the first state the page documents, so a batch job is deliberately not terminal at birth. None of the five members marked Required: Yes is checked |
| GetModelInvocationJob | GET /model-invocation-job/{jobIdentifier}. Returns the stored record whole, so accountID and region reach the wire (#756), and reports a seeded status if one is set |
| ListModelInvocationJobs | GET /model-invocation-jobs. invocationJobSummaries of five members each; the seeded status is applied here too, so a poll on either operation agrees. Every published query filter is ignored and no nextToken is emitted |
| StopModelInvocationJob | POST /model-invocation-job/{jobIdentifier}/stop. Stops a job in any state and skips Stopping |
InvokeModelWithResponseStream, Converse and ConverseStream are not routed, so no streaming or Converse-shaped call is served. Nor is the rest of the bedrock control plane — ListFoundationModels, GetFoundationModel, CreateGuardrail, GetGuardrail, CreateModelCustomizationJob and the provisioned-throughput operations among them.
InvokeModel reads nothing but the model ID
The handler takes its request as _ *AWSRequest, so every part of the call except the path's model ID is discarded. Three consequences are worth knowing before writing a test.
X-Amzn-Bedrock-GuardrailIdentifier and X-Amzn-Bedrock-GuardrailVersion are published request headers and are unread, so an invocation that attaches a guardrail is never filtered. The published "amazon-bedrock-guardrailAction": "INTERVENED | NONE" member therefore never appears in a canned body — the only way to observe an intervention is to call ApplyGuardrail directly, which is a different operation most consumers do not make. The decision itself already exists next door (see above); what is missing is the header read that would reach it.
The page publishes three conditions under which the request is an error, and none is checked: a body naming amazon-bedrock-guardrailConfig with no guardrail identifier, a guardrail enabled with a contentType other than application/json, and a guardrail identifier with no guardrailVersion.
And because the body is never parsed, a malformed or entirely absent one is accepted — the canned Claude Messages body comes back regardless. #1183.
How a guardrail decides
A guardrail's blocklist is a list of substrings held in state. If any of them occurs in the request's first text content item, the response is action: GUARDRAIL_INTERVENED with a fixed output ("Sorry, I can't help with that.") and a single fabricated topicPolicy assessment naming blocked-topic; otherwise it is action: NONE with the input echoed back and an empty assessment list. usage is a fixed set of counters in both cases.
Two consequences worth knowing before writing a test. There is no control-plane endpoint for the blocklist, so GUARDRAIL_INTERVENED is reachable only by writing the blocklist key into state directly — every ordinary call gets NONE. And the blocklist key is auto-created empty on first use and omits both the Region and the guardrail version, so ApplyGuardrail can never answer ResourceNotFoundException: any guardrail identifier, for any version, is valid.
Seeding a model response
POST /v1/bedrock-runtime/responses {"modelId": "anthropic.claude-v2", "body": {…}}
DELETE /v1/bedrock-runtime/responses (all, or ?modelId=… for one)modelId defaults to "*", which matches any model; an exact model ID wins over the wildcard. body is required and is returned verbatim as the response payload, which is what InvokeModel publishes — the member named body is the HTTP body — so a seeded response is byte-exact.
Seeding a batch job status
POST /v1/bedrock/model-invocation-job-status {"jobId": "…", "status": "Failed", "message": "…"}
DELETE /v1/bedrock/model-invocation-job-status (all, or ?jobId=… for one)jobId defaults to "*". status is required and is not validated against the ten published values, so a misspelled status is reported back as the job's status rather than refused. A seed governs what an observation reports; it does not rewrite the stored record, so clearing the seed returns the job to the state its own history gave it.
Stopping a batch job is immediate
API_GetModelInvocationJob glosses Stopping as the state a job is in while it stops and Stopped as the state after. StopModelInvocationJob writes Stopped directly, so Stopping is never observable, and it accepts a job in any state — including Completed and Failed — where AWS refuses with ConflictException. It answers {}, which is the published empty body. #1174.
What a refusal reports
| Condition | Code | Status |
|---|---|---|
a body that will not parse, on ApplyGuardrail or CreateModelInvocationJob | ValidationException | 400 |
| a batch job that does not exist | ResourceNotFoundException | 404 |
Both match what the pages publish. Those two are the only operations that parse a body at all: InvokeModel reads nothing but the model ID, so it has no body-parse refusal to answer.
The rest of what the pages publish has no site, because none of the conditions is modelled. InvokeModel alone publishes ten errors — AccessDeniedException/403, InternalServerException/500, ModelErrorException/424, ModelNotReadyException/429, ModelTimeoutException/408, ResourceNotFoundException/404, ServiceQuotaExceededException/400, ServiceUnavailableException/503, ThrottlingException/429 and ValidationException/400 — of which Substrate answers none: there is no quota, no model readiness, no timeout and no unknown model, so every invocation succeeds. ConflictException/400 is published on the batch create and the batch stop and is answered by neither — a duplicate jobName is accepted, and so is stopping a finished job. ModelStreamErrorException belongs to InvokeModelWithResponseStream, which is not routed.
CloudFormation resource types
None. AWS::Bedrock::Guardrail and the other AWS::Bedrock::* types are not deployed, so a guardrail or batch job exists only if an API call creates it.
Cost
InvokeModel and CreateModelInvocationJob are attributed $0.000015 per call and ApplyGuardrail $0.000075. Real Bedrock bills per input and output token, and batch inference at half the on-demand token rate; Substrate counts no tokens, so these are flat per-call proxies that make a cost report respond to call volume rather than to model size.
HealthOmics
Endpoint: omics.{region}.amazonaws.com
Protocol: REST-JSON, path-routed. API version 2022-11-28.
Four operations, all on workflow runs. A run's state is keyed by account and Region.
No workflow is executed. StartRun records the workflow ID, role and output URI as intent and the run is COMPLETED the moment it is created, so a consumer's wait loop observes a finished run on its first poll.
Supported operations
| Operation | Notes |
|---|---|
| StartRun | POST /run. Answers HTTP 201 with one of the eight published members and checks none of the three required ones |
| GetRun | GET /run/{id}. Returns the stored record whole, so accountID and region reach the wire (#756) and eight members stand in for the roughly forty-four published ones — arn, uuid, creationTime, startTime, stopTime, runOutputUri and the whole resource-usage set are absent |
| ListRuns | GET /run. items of id, status and name only, where RunListItem publishes ten members. maxResults, startingToken, name, runGroupId and status are ignored and no nextToken is emitted |
| CancelRun | POST /run/{id}/cancel, and DELETE /run/{id} as well. Answers 204 where the page publishes 202 |
Nothing else is routed: DeleteRun, ListRunTasks, GetRunTask, the workflow surface (CreateWorkflow, GetWorkflow, ListWorkflows), run groups, sequence and reference stores, the read-set and annotation-store import jobs, and the three tag operations are all absent. HealthOmics resources are not scanned by the Resource Groups Tagging API either.
StartRun answers an id and nothing else
API_StartRun publishes eight response members — arn, configuration, id, networkingMode, runOutputUri, status, tags and uuid — and Substrate answers {"id": …}. status is the absence that matters: a consumer that reads it off the create response, rather than polling GetRun, reads nothing.
The same operation marks outputUri, requestId and roleArn Required: Yes and checks none of them, so a run starts with no role and no destination. requestId is the idempotency token and is not read at all, so the same request twice creates two runs. #1166.
No HealthOmics response carries an ARN anywhere in the plugin, though API_StartRun and API_GetRun both publish arn.
CancelRun answers the wrong status and spells the state with one L
API_CancelRun publishes HTTP 202 with an empty body; Substrate answers 204. An SDK treats both as success, so the divergence is invisible through a client and visible in a recorded event log or a fixture diff.
The state written is CANCELED. The published RunStatus enum is PENDING | STARTING | RUNNING | STOPPING | COMPLETED | DELETED | CANCELLED | FAILED — two L's — so a consumer matching the published spelling never sees a cancelled run, and STOPPING is never observable because the cancel is immediate. #1165.
DELETE /run/{id} is also accepted for CancelRun. AWS publishes that path for DeleteRun, which is a different operation; the arm exists because an older SDK generation used it.
What a refusal reports
| Condition | Code | Status |
|---|---|---|
| a body that will not parse | ValidationException | 400 |
| a run that does not exist | ResourceNotFoundException | 404 |
Both match the published code and status on all three pages that carry them. AccessDeniedException, ConflictException, InternalServerException, RequestTimeoutException, ServiceQuotaExceededException and ThrottlingException are published and have no site: no quota, concurrency conflict or transient failure is modelled.
Run IDs
A run ID is a ten-digit number drawn from a per-process pseudo-random source that ResetForRun rewinds, so a recorded run replays with the same run IDs it was recorded with. Nothing checks that a freshly minted ID is unused; the sequence makes a collision vanishingly unlikely rather than impossible.
CloudFormation resource types
None. AWS publishes AWS::Omics::* types for workflows, run groups and stores, and Substrate deploys none of them, so a run exists only if StartRun creates it.
Cost
StartRun is attributed $0.001 per call. Real HealthOmics bills a run by the compute and storage it consumes for as long as it runs; Substrate runs nothing, so the attribution is a flat per-run proxy and no run is more expensive than another.
QuickSight
Endpoint: quicksight.{region}.amazonaws.com
Protocol: REST-JSON, path-routed. API version 2018-04-01.
Four operations over two resources: data sources and data sets, plus a data set's ingestion.
No data is ever read from a source and no ingestion runs. A data source is CREATION_SUCCESSFUL the moment it is created, and an ingestion is COMPLETED with a fixed row count the moment it is asked about.
Supported operations
| Operation | Notes |
|---|---|
| CreateDataSource | POST /accounts/{AwsAccountId}/data-sources. Answers HTTP 201 with the four published members and CreationStatus: CREATION_SUCCESSFUL, so CREATION_IN_PROGRESS is never observable. Name and Type are Required: Yes and unchecked, so a data source can have neither |
| DescribeDataSource | GET /accounts/{AwsAccountId}/data-sources/{DataSourceId}. Returns the stored record whole, so AccountID and Region reach the wire (#756), and adds a Status body member the API binds to the status line |
| CreateDataSet | POST /accounts/{AwsAccountId}/data-sets. Answers HTTP 201 with DataSetId, Arn, IngestionId and RequestId; PhysicalTableMap, ImportMode and the rest of the definition are not read |
| DescribeIngestion | GET /accounts/{AwsAccountId}/data-sets/{DataSetId}/ingestions/{IngestionId}. Reports any ingestion ID as COMPLETED |
Every other QuickSight operation is unrouted: the updates and deletes (UpdateDataSource, DeleteDataSource, UpdateDataSet, DeleteDataSet), the lists (ListDataSources, ListDataSets, ListIngestions), CreateIngestion and CancelIngestion, and the whole analysis, dashboard, template, namespace, user and group surface. QuickSight resources are not scanned by the Resource Groups Tagging API either, so Tags on a create is dropped.
The account in the path is discarded, and the Region is not in the key
AwsAccountId is Required: Yes on every QuickSight operation, is extracted from the path, and is then discarded by every handler — the state key is built from the caller's own account instead. So a call naming account B reads and writes account A's data sources, and a data source created in one Region is visible in every other, because the key omits the Region. The rest of the emulator keys a regional resource by account and Region; QuickSight is the exception. #1167.
Any ingestion ID is reported COMPLETED
DescribeIngestion loads the data set key, ignores the ingestion ID entirely, and fabricates the response: IngestionStatus: COMPLETED with RowsIngested: 1000 and RowsDropped: 0, plus an ARN built from the ID it was given. So any ingestion ID whatsoever answers HTTP 200 as long as the data set exists, a data set that was never ingested reports a thousand rows, and INITIALIZED, QUEUED, RUNNING, FAILED and CANCELLED are unobservable — the poll loop the operation exists for finishes on its first call. #1168.
Status is bound to the status line, not the body
Every QuickSight Response Syntax opens with HTTP/1.1 {Status} rather than a literal code, because Status is bound to the status line: an SDK populates the field from the HTTP status it already received. Both describes emit Status as a JSON body member as well, which is invisible through an SDK and visible as an unpublished extra member to anything reading the raw body. #1179. The two creates emit no Status.
What a refusal reports
| Condition | Code | Status |
|---|---|---|
a body that will not parse, or DataSourceId/DataSetId absent | InvalidParameterValue | 400 |
| a data source, data set or data set's ingestion that does not exist | ResourceNotFoundException | 404 |
ResourceNotFoundException/404 is what the pages publish. InvalidParameterValue is not: QuickSight publishes InvalidParameterValueException, and one code with the message "DataSourceId is required" also serves an unparseable body, which is a different failure (#1169).
AccessDeniedException/401, ConflictException/409, LimitExceededException/409, ResourceExistsException/409 and ThrottlingException/429 are published and have no site, so creating the same data source twice succeeds. QuickSight publishes no ValidationException anywhere.
CloudFormation resource types
None. AWS publishes AWS::QuickSight::DataSource, AWS::QuickSight::DataSet and the analysis, dashboard and template types; Substrate deploys none of them.
Cost
CreateDataSource and CreateDataSet are each attributed $0.000025 per call. Real QuickSight bills per user per month, and SPICE capacity by the gigabyte; neither has a per-call analogue, so these are flat proxies for authoring activity.
RAM
Endpoint: ram.{region}.amazonaws.com
Protocol: REST-JSON over lowercase POST paths — POST /createresourceshare rather than an X-Amz-Target header. API version 2018-01-04.
Routing: the path is lowercased before matching, so a mixed-case path still routes; a path that matches no operation falls through to the bare HTTP method, which matches nothing and is refused as an unknown route. DeleteResourceShare is routed on DELETE, which is what the page publishes, and on POST as well.
Eight operations over one resource: a resource share, and the principals and resources associated with it. Nothing is actually shared — an association is a record, not access — so a principal that RAM reports as ASSOCIATED gains no permission on the resource anywhere else in the emulator.
Supported operations
| Operation | Notes |
|---|---|
| CreateResourceShare | Answers HTTP 200 with {"resourceShare"}; name is the one Required: Yes member and is checked. Two members AWS does not publish are included, and clientToken is never echoed |
| GetResourceShares | Filters by name and resourceShareArns; resourceOwner is Required: Yes and ignored. maxResults, nextToken, resourceShareStatus, tagFilters and permissionArn are ignored and no nextToken is emitted |
| UpdateResourceShare | Replaces name and allowExternalPrincipals and refreshes lastUpdatedTime; clientToken is not read |
| DeleteResourceShare | A hard delete, answering {"returnValue": true} |
| AssociateResourceShare | Records each principal and resource ARN and answers resourceShareAssociations of five members, each ASSOCIATED. clientToken and sources are not read |
| DisassociateResourceShare | Also reports ASSOCIATED |
| ListPrincipals | principals of id, resourceShareArn and status, all ASSOCIATED, where Principal publishes five members. The published resourceOwner is ignored here too |
| ListResources | resources of arn, resourceShareArn and status, all AVAILABLE, where Resource publishes eight members |
The invitation surface is unrouted — GetResourceShareInvitations, AcceptResourceShareInvitation, RejectResourceShareInvitation — as is the permission surface (ListPermissions, GetPermission, AssociateResourceSharePermission, ListResourceSharePermissions), GetResourceShareAssociations, ListResourceTypes, GetResourcePolicies, EnableSharingWithAwsOrganization and the three tag operations. A share's tags are stored but RAM is not scanned by the Resource Groups Tagging API, so they cannot be searched for or read back except inside a share.
The resource share record diverges from the published shape
API_ResourceShare publishes exactly eleven members. Substrate's record carries eight of them — allowExternalPrincipals, creationTime, lastUpdatedTime, name, owningAccountId, resourceShareArn, status, tags — and omits featureSet, resourceShareConfiguration and statusMessage. It adds principals and resourceArns, which are on no RAM page: they are the create request's own inputs kept on the share for convenience, and a consumer that reads them is writing code that reads nothing against AWS. The record also carries accountID and region, Substrate's own bookkeeping (#756).
CreateResourceShare publishes clientToken alongside resourceShare and Substrate emits only the latter. clientToken is not read either, so the published idempotency contract — a retry with the same token returning the same share, and the same token with different parameters failing with IdempotentParameterMismatch — does not hold: the same request twice creates two shares with different ARNs. #1170.
resourceOwner is required and ignored
resourceOwner is Required: Yes on GetResourceShares, ListPrincipals and ListResources, with the values SELF and OTHER-ACCOUNTS. It is decoded and discarded, so every call behaves as SELF and a request for shares owned by other accounts answers the caller's own. #1171.
A disassociation still reports ASSOCIATED
DisassociateResourceShare builds its response through the same helper as the association, which hard-codes status: "ASSOCIATED". The published ResourceShareAssociationStatus enum is ASSOCIATING | ASSOCIATED | FAILED | DISASSOCIATING | DISASSOCIATED, so the one state that says the call did what it was asked is never reported. The association records themselves are not removed either, so ListPrincipals still lists a disassociated principal. #1171.
A delete removes the record rather than marking it deleted
RAM publishes a DELETED share status, and DeleteResourceShare here deletes the state entry and removes it from the share index, so the share does not appear in GetResourceShares in any status and a subsequent read answers not-found rather than a deleted share. #1171.
What a refusal reports
| Condition | Code | Status |
|---|---|---|
| a body that will not parse | ValidationError | 400 |
name or resourceShareArn absent | MissingRequiredParameter | 400 |
| a resource share that does not exist | UnknownResourceException | 400 |
ValidationError/400 is the spelling RAM's consolidated common-errors list publishes, and UnknownResourceException/400 matches its own page. MissingRequiredParameter is invented: no MissingParameter-anything appears anywhere in RAM's documentation, and the published code for the condition is ValidationError (#1169).
IdempotentParameterMismatch, InvalidClientTokenException, MalformedArnException, OperationNotPermittedException, ResourceShareLimitExceededException and ServerInternalException/500 are published and have no site: no ARN is validated for shape, no token is tracked, and no share quota is enforced.
CloudFormation resource types
None. AWS publishes AWS::RAM::ResourceShare and Substrate does not deploy it, so a share exists only if CreateResourceShare creates it.
Cost
Nothing is attributed. AWS RAM is free of charge; what a share costs is whatever the shared resources cost in the accounts that use them, which Substrate does not model.
CodeDeploy
Endpoint: codedeploy.{region}.amazonaws.comProtocol: JSON (X-Amz-Target: CodeDeploy_20141006.{Op}), API version 2014-10-06
Nine of the forty-eight published operations, over three resources: an application, its deployment groups, and a deployment. Every record is keyed by account and Region, so two accounts, or one account in two Regions, never see each other's applications. Nothing is ever deployed — the revision, the lifecycle hooks, the traffic-shifting configuration and the alarms are not read at all, and a deployment is Succeeded before CreateDeployment returns. Before writing any test against this service, know that GetApplication and GetDeployment cannot be deserialized by an AWS SDK.
Supported operations
| Operation | Notes |
|---|---|
| CreateApplication | applicationName is the one checked member; computePlatform defaults to Server and is otherwise unvalidated. tags are not read. Answers the published applicationId |
| GetApplication | Four of the six published ApplicationInfo members, plus two of Substrate's own; createTime is the wrong JSON type |
| DeleteApplication | Answers {} where the page publishes an empty body, and refuses an absent application under an unpublished code |
| ListApplications | Names only. nextToken is neither read nor emitted |
| CreateDeploymentGroup | Verifies the application exists; serviceRoleArn is Required: Yes and stored without a check. The other nineteen published members — ec2TagFilters, deploymentStyle, blueGreenDeploymentConfiguration, alarmConfiguration, triggerConfigurations and the rest — are not read |
| GetDeploymentGroup | Four of the twenty-three published deploymentGroupInfo members, plus two of Substrate's own |
| DeleteDeploymentGroup | Answers the published hooksNotCleanedUp as an empty array, which is what AWS's own sample response shows, and refuses an absent group under an unpublished code |
| CreateDeployment | Verifies the application, and the deployment group when one is named. revision is Required: No and unread, so a deployment with no artifact at all succeeds. Answers the published deploymentId in the published d-XXXXXXXXX shape |
| GetDeployment | Six of the thirty-one published deploymentInfo members. An absent deploymentId is reported as an absent deployment |
The thirty-nine unrouted operations include everything that would let a consumer observe a deployment in progress or intervene in one: ListDeployments, StopDeployment, ContinueDeployment, GetDeploymentTarget, ListDeploymentTargets, GetDeploymentInstance, PutLifecycleEventHookExecutionStatus and SkipWaitTimeForInstanceTermination. Also unrouted are UpdateApplication and UpdateDeploymentGroup — nothing created here can be modified — the five BatchGet* reads, ListDeploymentGroups, the revision surface (RegisterApplicationRevision, GetApplicationRevision, ListApplicationRevisions), the whole deployment-configuration surface (CreateDeploymentConfig, GetDeploymentConfig, ListDeploymentConfigs, DeleteDeploymentConfig), the on-premises-instance surface, the GitHub-token operations and the three tag operations. Each answers UnknownOperationException / 404.
CodeDeploy timestamps are RFC3339 strings where the pages publish numbers
CodeDeployApp.CreateTime and CodeDeployDeployment.CreateTime and CompleteTime are Go time.Time values marshalled by encoding/json, which emits RFC3339: "createTime": "2024-01-01T00:00:00Z". API_GetApplication publishes "createTime": number, API_GetDeployment publishes "completeTime": number and "createTime": number, and API_DeploymentInfo types both as Timestamp; AWS's own sample responses show "createTime": 1446229001.211 and "completeTime": 1446232681.319. An awsJson1_1 timestamp deserializer expects a JSON number, so this is not a wrong value but a refusal to decode: GetApplication and GetDeployment fail in the SDK before a consumer's assertion runs. The three records are marshalled whole, which is also how accountID and region reach the wire (#756). #1207.
A deployment is Succeeded before CreateDeployment returns
CreateDeployment stores the deployment with status: "Succeeded" and completeTime equal to createTime. DeploymentInfo publishes Valid Values: Created | Queued | InProgress | Baking | Succeeded | Failed | Stopped | Ready, and seven of those eight cannot be produced by any input or seed. Running the deployment is workload-internal and out of scope, but the observable progression is not: a consumer's wait loop over GetDeployment passes on its first poll, errorInformation and rollbackInfo are never populated, and the autoRollbackConfiguration a template supplies has no failure to react to. startTime and deploymentOverview are not emitted either, so the idiomatic assertion — deploymentOverview.Succeeded — reads nil on a deployment that reports success. #1196.
Three CodeDeploy refusals answer codes their own page does not publish
DeleteApplication loads the application first and propagates ApplicationDoesNotExistException. That page publishes three errors — ApplicationNameRequiredException, InvalidApplicationNameException and InvalidRoleException, all 400 — and an empty 200 body, which is the shape of an idempotent delete; the not-found code belongs to GetApplication, CreateDeploymentGroup, GetDeploymentGroup and CreateDeployment. DeleteDeploymentGroup does the same with DeploymentGroupDoesNotExistException, which its page also does not publish. A teardown that runs twice succeeds against AWS and raises here, both times.
The third is InvalidInputException, answered for a missing name by CreateApplication and by the two loaders every get and delete goes through. CodeDeploy publishes that code on exactly two pages, CreateDeploymentGroup and CreateDeployment, and it is absent from the service's consolidated common-errors list; the five other operations publish ApplicationNameRequiredException — "The minimum number of required application names was not specified." — and DeploymentGroupNameRequiredException — "The deployment group name was not specified." — at 400 instead. Both of those, and the format codes beside them, have no site. #1198.
An absent deploymentId is reported as an absent deployment
GetDeployment does not check deploymentId for emptiness: the empty string is concatenated into the state key, the lookup misses, and the caller is told DeploymentDoesNotExistException. The page publishes DeploymentIdRequiredException — "At least one deployment ID must be specified." — and InvalidDeploymentIdException at 400 for precisely this, and neither has a site, so a validation bug in a consumer's own code arrives dressed as a missing resource. #1198.
The three CodeDeploy record shapes are truncated
deploymentInfo carries six of the thirty-one members DeploymentInfo publishes: deploymentId, applicationName, deploymentGroupName, status, createTime and completeTime. startTime, creator, deploymentOverview, revision, previousRevision, deploymentConfigName, errorInformation, rollbackInfo, externalId and the rest are absent. deploymentGroupInfo carries four of twenty-three — deploymentGroupId, deploymentGroupName, applicationName and serviceRoleArn — so computePlatform, deploymentConfigName, targetRevision, ec2TagFilters, autoScalingGroups and lastSuccessfulDeployment are never reported, and a group created with tag filters reads back with none. application carries four of the six published ApplicationInfo members, omitting gitHubAccountName and linkedToGitHub; the latter appears in AWS's sample response for every application, including ones with no GitHub connection. #1199.
No CodeDeploy name, role or compute platform is checked
serviceRoleArn is Required: Yes on CreateDeploymentGroup and is decoded and stored without validation, so RoleRequiredException — "The role ID was not specified." — and InvalidRoleException have no site and a group can exist with no role at all. computePlatform is defaulted to Server when absent and stored verbatim when present, so InvalidComputePlatformException — "The computePlatform is invalid. The computePlatform should be Lambda, Server, or ECS." — cannot fire, even though the member's published Valid Values are Server | Lambda | ECS | Kubernetes. The published Length Constraints: Minimum length of 1. Maximum length of 100 and Pattern: [A-Za-z0-9+=,.@_-]* on applicationName and deploymentGroupName are unenforced, retiring InvalidApplicationNameException and InvalidDeploymentGroupNameException. A template that AWS would reject on any of these deploys clean here. #1197.
ListApplications never paginates
The handler discards its request entirely and answers the name index. nextToken is published in both the request and the response — "If a large amount of information is returned, an identifier is also returned. It can be used in a subsequent list applications call to return the next set of applications" — and neither half exists here, so a paginator loop terminates after one page however many applications were created. InvalidNextTokenException/400 is the operation's only published error and has no site. #1195.
What a refusal reports
| Condition | Code | Status |
|---|---|---|
| a body that will not parse | ValidationError | 400 |
applicationName or deploymentGroupName absent | InvalidInputException | 400 |
| an application that does not exist | ApplicationDoesNotExistException | 400 |
| an application name already in use | ApplicationAlreadyExistsException | 400 |
| a deployment group that does not exist | DeploymentGroupDoesNotExistException | 400 |
| a deployment group name already in use | DeploymentGroupAlreadyExistsException | 400 |
a deployment that does not exist, or no deploymentId at all | DeploymentDoesNotExistException | 400 |
an unrecognised X-Amz-Target suffix | UnknownOperationException | 404 |
ValidationError/400 is the spelling CodeDeploy's consolidated common-errors list publishes, and it is the only code that covers all eight body-decode sites because the service publishes narrow per-field exceptions almost everywhere and adds a generic code only on its two create surfaces; the reasoning is recorded on codedeployInvalidBody. The four *AlreadyExists* and *DoesNotExist* codes are at their published status of 400, and two of them are answered on operations that do not publish them.
Beyond the codes named above, CodeDeploy publishes and Substrate never answers: ApplicationNameRequiredException, DeploymentGroupNameRequiredException, DeploymentIdRequiredException, InvalidApplicationNameException, InvalidDeploymentGroupNameException, InvalidDeploymentIdException, InvalidNextTokenException, RoleRequiredException, InvalidRoleException, InvalidComputePlatformException, InvalidTagsToAddException, RevisionRequiredException, RevisionDoesNotExistException, InvalidRevisionException, DeploymentConfigDoesNotExistException, InvalidDeploymentConfigNameException, InvalidAlarmConfigException, InvalidAutoRollbackConfigException, InvalidDeploymentStyleException, InvalidLoadBalancerInfoException, InvalidTriggerConfigException, InvalidTargetInstancesException and ThrottlingException, all at 400, plus the five limit codes (ApplicationLimitExceededException, DeploymentGroupLimitExceededException, DeploymentLimitExceededException, AlarmsLimitExceededException, TriggerTargetsLimitExceededException) at 400. No quota is enforced and no member is validated for shape, so none of them has a site.
CloudFormation resource types
| Type | Ref | Notes |
|---|---|---|
| AWS::CodeDeploy::DeploymentGroup | DeploymentGroupName | A stub: properties are recorded in the CloudFormation stub store, which the CodeDeploy plugin does not read, so GetDeploymentGroup on a group a template just created answers DeploymentGroupDoesNotExistException (#1203) |
AWS publishes three types in the namespace. AWS::CodeDeploy::Application and AWS::CodeDeploy::DeploymentConfig are not deployed, so an application exists only if CreateApplication creates it, and a template whose deployment group names a custom deployment configuration deploys without the configuration existing anywhere.
Cost
CreateDeployment is attributed $0.000001 per call. The table's own comment records the rationale as "free for EC2/on-premises, approximated per deployment"; AWS's published price structure for CodeDeploy could not be read off its pricing page during this pass, so treat the figure as a placeholder rather than a derivation. Substrate does not model on-premises instances at all — none of the four *OnPremisesInstance* operations is routed — so the entry fires identically for every deployment whatever platform it names.
EMR Serverless
Endpoint: emr-serverless.{region}.amazonaws.comProtocol: REST/JSON, API version 2021-07-13. The SigV4 signing name is emr-serverless, which parser.go maps to the emrserverless namespace Routing: method and path, resolved by parseEMRServerlessOperation. The path is matched with a strings.Index for /jobruns rather than by segment, so two path shapes AWS would not match are routed
Seven of the twenty-three published operations, over two resources: an application and its job runs. Every record is keyed by account and Region. No Spark or Hive work is executed — that is the boundary in doc.go — and the jobDriver that says what would have run is not even recorded, so a job run reports SUCCESS from the instant it is submitted. Before writing a test, know that StartJobRun does not check that the application exists and that ListJobRunsreports every job run id as blank.
Supported operations
| Operation | Published path | Notes |
|---|---|---|
| CreateApplication | POST /applications | Routed on the published verb. clientToken, releaseLabel and type are all Required: Yes and none is checked; the application is CREATED and stays there. Answers applicationId and arn where three members are published |
| GetApplication | GET /applications/{applicationId} | Five of the seven published Required: Yes Application members, plus two of Substrate's own |
| DeleteApplication | DELETE /applications/{applicationId} | Answers {} where the page publishes an empty body, and cannot fail |
| StartJobRun | POST /applications/{applicationId}/jobruns | Only name is read: clientToken and executionRoleArn are Required: Yes and unread, as are jobDriver, configurationOverrides, retryPolicy, mode and executionTimeoutMinutes. The application is not verified and the run is SUCCESS immediately. The jobRunId it mints violates its published pattern |
| GetJobRun | GET /applications/{applicationId}/jobruns/{jobRunId} | Four of the eleven published Required: Yes JobRun members; attempt is not read |
| CancelJobRun | DELETE /applications/{applicationId}/jobruns/{jobRunId} | Answers the two published members. Writes a state the published enum does not carry; shutdownGracePeriodInSeconds is not read |
| ListJobRuns | GET /applications/{applicationId}/jobruns | Three members per run, one of them under the wrong name. Every filter and both pagination members are discarded |
The sixteen unrouted operations include the whole application lifecycle beyond create and delete — StartApplication, StopApplication, UpdateApplication and ListApplications, so an application cannot be started, stopped, modified or enumerated — the entire interactive-session surface (StartSession, GetSession, GetSessionEndpoint, ListSessions, TerminateSession), the two dashboard reads (GetDashboardForJobRun, GetResourceDashboard), ListJobRunAttempts, and the three tag operations. Each answers UnknownOperationException / 404. An application's tags are not stored at all, so they cannot be read back by any route.
A job run is SUCCESS the moment it is started
StartJobRun stores the run with state: "SUCCESS". JobRun publishes state as Required: Yes with Valid Values: SUBMITTED | PENDING | SCHEDULED | RUNNING | SUCCESS | FAILED | CANCELLING | CANCELLED | QUEUED, and eight of those nine cannot be produced by any input — CANCELLED not even by CancelJobRun. Executing the Spark or Hive work is out of scope, but the progression a consumer polls for is not: a wait loop over GetJobRun passes on its first observation, the retryPolicy the request carried has no failure to retry, stateDetails is never emitted, and neither is billedResourceUtilization or totalResourceUtilization, so nothing about what the job consumed can be asserted. queuedDurationMilliseconds, startedAt and endedAt are absent for the same reason. #1196.
A cancelled job run reports CANCELED, which is not a published state
CancelJobRun sets the stored state to CANCELED. Both places EMR Serverless publishes the enum — JobRun's state member and ListJobRuns's states filter — spell it CANCELLED, with two Ls, and publish CANCELLING beside it as the transitional state. A typed-enum SDK resolves CANCELED to an unknown value, so a consumer switching on the cancelled state, or filtering ListJobRuns by it, never matches — on the one operation whose only observable effect is producing that state. CANCELLING is never reported either, so the cancellation is instantaneous as well as misspelled. #1198.
ListJobRuns names the job run id jobRunId
The list builds a three-member summary of applicationId, jobRunId and state. ListJobRuns publishes its jobRuns elements with the id member named id — jobRunId is a member of JobRun, which GetJobRun returns, not of the summary — so an SDK deserializing the list finds no id and leaves it empty on every element. The failure is silent and the shape is plausible: the count is right, the states are right, and every id is blank, which reads as a list of anonymous runs rather than as a bug. Thirteen further published summary members are absent, among them arn, createdAt, updatedAt, createdBy, executionRole, releaseLabel, type, mode, name and stateDetails. #1204.
StartJobRun does not check that the application exists
The handler reads name from the body and writes the job run under a key built from the path's application id, without loading the application first. StartJobRun publishes ResourceNotFoundException — "The specified resource was not found." — at 404, and it has no site, so a typo'd or already-deleted application id yields a job run that GetJobRun then reports as SUCCESS. A consumer's error path for a missing application is unreachable, and so is the check that would have caught the wrong id in the first place. ListJobRuns does not verify the application either, but that operation publishes no not-found error, so an empty list for an unknown application is within its published vocabulary. #1197.
DeleteApplication cannot fail
The handler deletes the state key without reading it first, removes the id from the index and answers {}. ResourceNotFoundException/404 is published on that page and has no site, so deleting an application that was never created succeeds. The page also states the precondition — "An application has to be in a stopped or created state in order to be deleted." — which cannot be violated here because an application never leaves CREATED; and there is no published code on that page for a precondition failure to land on, since it publishes only InternalServerException/500, ResourceNotFoundException/404 and ValidationException/400, with no ConflictException. The {} body is a third small divergence: the page publishes "an HTTP 200 response with an empty HTTP body". #1196.
An EMR Serverless job run id is a dashed UUID
generateEMRServerlessRunID formats sixteen random bytes as %x-%x-%x-%x-%x. JobRun's jobRunId publishes Pattern: [0-9a-z]+ with a maximum length of 64, the URI parameters on GetJobRun and CancelJobRun publish the same, and StartJobRun's arn publishes Pattern: arn:(aws[a-zA-Z0-9-]*):emr-serverless:.+:(\d{12}):\/applications\/[0-9a-zA-Z]+\/jobruns\/[0-9a-zA-Z]+. A dashed id satisfies none of the three, so code that validates an id or parses one out of an ARN rejects every job run Substrate mints. The application id, formatted 00%08x, does satisfy its published [0-9a-z]+, and the application ARN satisfies its pattern and its 60-character minimum. #1204.
Every ListJobRuns filter is accepted and discarded
The handler discards its request, so maxResults — published Valid Range: Minimum value of 1. Maximum value of 50 — nextToken, states, mode, createdAtAfter and createdAtBefore are all unread, and the response carries jobRuns with no nextToken, which the page calls "the token for the next set of job run results. This is required for pagination". A states=["FAILED"] filter therefore returns every job run the application has, all of them SUCCESS, which reads as a passing assertion rather than as an ignored filter. GetJobRun's attempt and CancelJobRun's shutdownGracePeriodInSeconds are discarded the same way, both handlers taking _ *AWSRequest. #1195.
The EMR Serverless records drop most of their required members
Application publishes seven members as Required: Yes; the stored record supplies five and omits createdAt and updatedAt, both Type: Timestamp. JobRun publishes eleven as Required: Yes; the stored record supplies four — applicationId, arn, jobRunId and state — and omits createdAt, updatedAt, createdBy, executionRole, jobDriver, releaseLabel and stateDetails. jobDriver is the member that says what was submitted, so a consumer cannot confirm from any response that the right entry point, jar or SQL was even sent. Both records also carry accountID and region, which are Substrate's own and appear on neither published shape (#756). #1199.
The EMR Serverless required inputs are neither read nor refused
CreateApplication publishes clientToken, releaseLabel and type as Required: Yes. Substrate decodes two of them, never reads clientToken, and checks none, so a request missing all three succeeds and ValidationException/400 has no missing-member site. clientToken is documented as "The client idempotency token of the application to create. Its value must be unique for each request", and because it is not tracked the published idempotency contract does not hold: the same request twice creates two applications with different ids, and ConflictException/409 has no site. StartJobRun repeats the shape with clientToken and executionRoleArn, both Required: Yes and both unread, so a job run can exist with no execution role. The create response also omits the published name, answering applicationId and arn where the Response Syntax publishes all three. #1197.
An EMR Serverless application never leaves CREATED
CreateApplication stores state: "CREATED" and nothing routed changes it. Application publishes Valid Values: CREATING | CREATED | STARTING | STARTED | STOPPING | STOPPED | TERMINATED, and six of the seven are unreachable because StartApplication, StopApplication and UpdateApplication are not routed. autoStartConfiguration and autoStopConfiguration are stored nowhere, so neither the start-on-submission path nor the idle-timeout path can be observed, and StartJobRun succeeds against a CREATED application regardless. #1196.
The jobruns route is matched as a substring
parseEMRServerlessOperation locates the job run surface with strings.Index(after, "/jobruns") rather than by splitting the path into segments. GET /applications/ab/jobrunsbad therefore routes to GetJobRun with application id ab and job run id bad, and a trailing slash on GET /applications/ab/ makes the application id ab/, which matches no state key. AWS matches neither path to an operation. Both cases end in a refusal rather than a wrong success — the first as ResourceNotFoundException/404 for a job run that does not exist, the second as the same for an application — so the practical cost is a misleading code rather than corrupt state, and the published landing for a path that matches nothing is the common list's UnknownOperationException/404, which unknownRouteError already answers for genuinely unmatched paths. #1205.
What a refusal reports
| Condition | Code | Status |
|---|---|---|
a CreateApplication body that will not parse, or no body at all | ValidationException | 400 |
a StartJobRun body that will not parse | ValidationException | 400 |
an application that does not exist, on GetApplication | ResourceNotFoundException | 404 |
a job run that does not exist, on GetJobRun or CancelJobRun | ResourceNotFoundException | 404 |
| a method and path that match no operation | UnknownOperationException | 404 |
Both codes are published at those statuses on every page that carries them: ValidationException at 400 and ResourceNotFoundException at 404, which is the REST-JSON shape and not the 400 the JSON-RPC services in this document use. CreateApplication is the only handler with no len(req.Body) > 0 guard, so a bodyless create is refused rather than defaulted — which is right, given three of its members are Required: Yes. The two body-decode refusals answer the same code, status and message; one reaches it through the shared emrInvalidBody constructor and the other through a literal, which is a house-consistency gap rather than a wire divergence.
ConflictException/409, published on CreateApplication and StartJobRun, has no site: no client token is tracked and no state precondition is enforced. ResourceNotFoundException/404 has no site on DeleteApplication or StartJobRun, both of which publish it. ValidationException/400 has no missing-member or constraint site anywhere: no Required: Yes member is checked, and no published Pattern, Length Constraints or Valid Range — including maxResults' 1-to-50 range and the states filter's enum — is enforced. InternalServerException/500 is published on all seven routed operations and has no site, which is correct: an internal failure is not something a request can ask for.
Cost
StartJobRun is attributed $0.0001 per call and CreateApplication $0.00001. Both keys can match: the cost table is keyed on the lowercased service name and the resolved operation, and the emrserverless operation resolver derives the operation from the method and path. The figures stand in for nothing AWS actually meters — EMR Serverless bills worker vCPU-hours, memory-GB-hours and storage, and Substrate runs no workers, which is also why billedResourceUtilization and totalResourceUtilization are never emitted on a job run. Creating an application is free at AWS; the $0.00001 exists so that an application appears in a cost summary at all.
FSx
Endpoint: fsx.{region}.amazonaws.com
Protocol: JSON 1.1 with an X-Amz-Target header. The target namespace is AWSSimbaAPIService_v20180301, the internal name the FSx model carries rather than anything the public reference prints, and Substrate's parser maps that namespace onto the fsx service. API version 2018-03-01.
Routing: on the target's operation name alone; a target Substrate does not route, and an absent target, are both refused as an unrecognised action.
Three operations over one resource: a file system. Nothing is mounted and no storage exists — a file system is a record with a DNS name — and the Lustre mount name a SCRATCH_2 file system reports is the literal fsx, which is what real FSx uses for that deployment type. A file system's VPC is derived by looking its first subnet up in EC2 state, so a file system created against a subnet Substrate has never seen reports an empty VpcId.
Supported operations
| Operation | Notes |
|---|---|
| CreateFileSystem | Answers {"FileSystem"} as published. Neither Required: Yes member is checked and no member is validated; the file system is AVAILABLE immediately; ClientRequestToken is not read |
| DescribeFileSystems | Describes the IDs given, or every non-deleted file system when FileSystemIds is absent, which is what the page publishes. MaxResults and NextToken are ignored and no token is emitted |
| DeleteFileSystem | A soft delete. The response body is the wrong shape and the lifecycle it records is not a published value |
Everything else on the FSx API is unrouted: CreateFileSystemFromBackup and UpdateFileSystem, the backup surface (CreateBackup, CopyBackup, DeleteBackup, DescribeBackups), the volume and storage-virtual-machine surfaces used by ONTAP and OpenZFS, the snapshot surface, the data-repository association and task surfaces used by Lustre, the alias operations, and the three tag operations. A call to any of them is refused as an unrecognised action, so an FSx file system here cannot be backed up, restored, resized, or read through a data repository — and because ListTagsForResource is unrouted, a file system's tags can only be read back inside the file system record itself.
DeleteFileSystem answers a file system object where the published response is flat
API_DeleteFileSystem publishes a response of five top-level members — FileSystemId, Lifecycle, LustreResponse, OpenZFSResponse and WindowsResponse — and no FileSystem member. Substrate answers the whole file-system record under a FileSystem key, the shape CreateFileSystem uses. An SDK's DeleteFileSystemOutput unmarshals that as an empty struct: the ID is nil and the lifecycle is the empty string, so a caller cannot read back which file system it deleted or what state the delete left it in. #1210.
A deleted file system carries a Lifecycle the API does not publish
The delete marks the record DELETED. The published Lifecycle values are AVAILABLE | CREATING | FAILED | DELETING | MISCONFIGURED | UPDATING | MISCONFIGURED_UNAVAILABLE, and the page states that "If the DeleteFileSystem operation is successful, this status is DELETING." DELETED is on no FSx page, so a consumer switching on the published enum falls through every arm. The value is also load-bearing inside the plugin, which treats it as not-found on DescribeFileSystems and filters it out of the list, so the published DELETING window is never observable: a file system is available and then absent. Reporting DELETING is the published behaviour and would keep the SDK's delete waiter working, because the page also publishes that describing a deleted file system answers FileSystemNotFound. #1210.
A new file system is AVAILABLE and was never CREATING
API_CreateFileSystem publishes that it "Creates a new, empty Amazon FSx file system with an assigned ID, and an initial lifecycle state of CREATING", and notes that "The CreateFileSystem call returns while the file system's lifecycle state is still CREATING. You can check the file-system creation status by calling the DescribeFileSystems operation." Substrate records AVAILABLE at creation, so the SDK's file-system-available waiter succeeds on its first poll and the polling code a consumer wrote for a real create is never exercised. A seedable observation count, the pattern the snapshot and job-status surfaces already use, would let a test assert the CREATING path without depending on wall-clock time. #1196.
CreateFileSystem validates none of its members
FileSystemType is Required: Yes with Valid Values: WINDOWS | LUSTRE | ONTAP | OPENZFS, and SubnetIds is Required: Yes. Substrate defaults the first to LUSTRE and accepts an absent second, so a template or SDK call missing a required member deploys clean here and is refused by AWS. StorageType publishes Valid Values: SSD | HDD | INTELLIGENT_TIERING, and only FileSystemType is upper-cased before storage, so a lowercase ssd is stored and echoed verbatim — an off-enum value in a response, which is worse than a refusal because it looks like a real observation. StorageCapacity is accepted unchecked and reported as 0 when absent, where the page publishes per-deployment-type values such as "1200 GiB, 2400 GiB, and increments of 2400 GiB" for SCRATCH_2. Each of these lands on BadRequest/400, "A generic error indicating a failure with a client request.", which is the first entry in the operation's own Errors section and already has a constructor in the plugin. #1197.
ClientRequestToken is not read so creating a file system twice creates two
The page publishes the whole idempotency contract: "If a file system with the specified client request token exists and the parameters match, CreateFileSystem returns the description of the existing file system. If a file system with the specified client request token exists and the parameters don't match, this call returns IncompatibleParameterError." Substrate does not decode the token, so the same request sent twice creates two file systems with different IDs and IncompatibleParameterError, published at 400, has no site in the plugin. A consumer testing its own retry-on-timeout path — the case the token exists for — observes a duplicate resource instead of the published replay. #1210.
DescribeFileSystems answers every file system in one page
MaxResults and NextToken are both published request members, NextToken is a published response member, and the page describes the loop in full: "DescribeFileSystems is called first without a NextToken value. Then the operation continues to be called with the NextToken parameter set to the value of the last NextToken value until a response has no NextToken." Substrate decodes only FileSystemIds and emits only FileSystems, so a paginator stops after one page and a consumer's paging code is never exercised. The page also warns that an implementation "might return fewer than MaxResults file system descriptions while still including a NextToken value", which is exactly the case a test wants to reach and cannot. #1195.
The file system record carries twelve of the published members
API_FileSystem is a large shape and Substrate reports FileSystemId, FileSystemType, StorageCapacity, StorageType, VpcId, SubnetIds, DNSName, ResourceARN, Lifecycle, CreationTime, Tags and OwnerId, plus a LustreConfiguration of MountName and DeploymentType for Lustre file systems so that an SDK consumer can dereference the mount name without a nil check. Published and never populated: AdministrativeActions, FailureDetails, FileSystemTypeVersion, KmsKeyId, NetworkInterfaceIds, NetworkType, and the ONTAP, OpenZFS and Windows configuration blocks. Every name Substrate does emit is a published one, and CreationTime is a JSON number as the model declares. The absences bite hardest through CloudFormation, where two of the four published Fn::GetAtt attributes have no stored value. #1199.
What a refusal reports
| Condition | Code | Status |
|---|---|---|
| a body that will not parse | BadRequest | 400 |
FileSystemId absent on DeleteFileSystem | BadRequest | 400 |
| a file system ID that does not exist | FileSystemNotFound | 400 |
a file system already deleted, on DescribeFileSystems | FileSystemNotFound | 400 |
| a target Substrate does not route | UnknownOperationException | 404 |
All four FSx codes are published spellings at published statuses. BadRequest/400 and FileSystemNotFound/400 are both in API_DeleteFileSystem's own Errors section, and FileSystemNotFound/400 is in API_DescribeFileSystems' as well — the 400 is worth stating plainly, because a not-found at 400 rather than 404 is unusual enough to look like a bug and is not one here. Refusing an absent FileSystemId is correct on the delete, where it is Required: Yes, and the absence of the same check on DescribeFileSystems is also correct, where FileSystemIds is Required: No and an absent list means describe them all.
Published and with no site: ActiveDirectoryError/400, IncompatibleParameterError/400, InvalidExportPath/400, InvalidImportPath/400, InvalidNetworkSettings/400, InvalidPerUnitStorageThroughput/400, MissingFileSystemConfiguration/400 and ServiceLimitExceeded/400 on CreateFileSystem; IncompatibleParameterError/400 and ServiceLimitExceeded/400 on DeleteFileSystem; and InternalServerError/500 on all three. No client token is tracked, no network setting is validated, no configuration block is required and no file-system quota is enforced, so none of these conditions can arise.
CloudFormation resource types
AWS::FSx::FileSystem deploys through CreateFileSystem and deletes through DeleteFileSystem. Five template properties reach the plugin — FileSystemType (defaulting to LUSTRE), StorageCapacity (defaulting to 1200), StorageType (defaulting to SSD), SubnetIds and Tags — with !Ref and !Sub resolved in the subnet list and in both halves of every tag. Everything else the resource publishes is dropped, including FileSystemTypeVersion, KmsKeyId, SecurityGroupIds, NetworkType, BackupId and all four per-type configuration blocks. Dropping LustreConfiguration is the consequential one: a template asking for PERSISTENT_2 gets SCRATCH_2 and a mount name of fsx, which is the one Lustre observable a mount script actually reads.
Ref returns the file system ID, as published. Of the four published Fn::GetAtt attributes, DNSName resolves from stored metadata and ResourceARN resolves because its name ends in ARN; LustreMountName and RootVolumeId have no stored value, so a template that reads either gets nothing. #1203.
Cost
CreateFileSystem is attributed $0.00013, a file-system hour prorated across a single API call. DescribeFileSystems and DeleteFileSystem have no entry and no per-service fallback, so they are free — which is right for the describe and wrong in spirit for a file system that keeps existing after the create call returns, since FSx bills for provisioned storage by the hour rather than per request. No pricing provider maps onto fsx, so the figure is the static one and does not move with a loaded price list.
MSK
Endpoint: kafka.{region}.amazonaws.com
Protocol: REST-JSON over versioned paths — POST /v1/clusters rather than an X-Amz-Target header — with error codes carried in the x-amzn-errortype header. API version 2018-11-14. The signing name and service key are kafka, not msk.
Routing: on the HTTP method and path together, resolved by a single ordered switch. Since #1009 an empty path parameter routes to the single-cluster operation rather than folding onto the list operation, so GET /v1/clusters/ reaches DescribeCluster with an empty ARN and is refused for the reason it is wrong. Two arms match on a path suffix before any version prefix is tested, which is a divergence in its own right.
Nine operations over one resource: a provisioned cluster, addressed by ARN, in both the v1 and the v2 shapes. No Kafka runs — a cluster is a record, brokers are synthesised from the requested count on each ListNodes call rather than stored — so a bootstrap-broker string here resolves to nothing and a producer cannot connect. Serverless clusters are not modelled: every cluster reports clusterType: "PROVISIONED".
Supported operations
| Operation | Route, and what it answers |
|---|---|
| CreateCluster | POST /v1/clusters → {clusterArn, clusterName, state} as published. Only clusterName is checked of four required members; the cluster is ACTIVE at once |
| ListClusters | GET /v1/clusters → {clusterInfoList}. maxResults, nextToken and clusterNameFilter are all ignored |
| DescribeCluster | GET /v1/clusters/{clusterArn} → {clusterInfo}. The ARN resolves by cluster name alone; eight of twenty-one members are reported |
| DeleteCluster | DELETE /v1/clusters/{clusterArn} → {clusterArn, state}. The record is removed while the state says DELETING |
| GetBootstrapBrokers | GET /v1/clusters/{clusterArn}/bootstrap-brokers → one of the fourteen published broker strings. The path is matched by suffix |
| ListNodes | GET /v1/clusters/{clusterArn}/nodes → {nodeInfoList}, synthesised from the cluster's broker count. Matched by suffix and unpaginated |
| CreateClusterV2 | POST /api/v2/clusters, preferring a Provisioned sub-object and delegating to CreateCluster. The path is unverifiable and clusterType is not reported |
| DescribeClusterV2 | GET /api/v2/clusters/{clusterArn} → {clusterInfo} in the v2 shape, with the broker detail under provisioned |
| ListClustersV2 | GET /api/v2/clusters → {clusterInfoList} in the v2 shape, one page, no token |
There is no v2 delete arm, so DELETE /api/v2/clusters/{clusterArn} is refused as an unrecognised route. The rest of the kafka API is unrouted: the whole update surface (UpdateBrokerCount, UpdateBrokerStorage, UpdateBrokerType, UpdateClusterConfiguration, UpdateClusterKafkaVersion, UpdateMonitoring, UpdateSecurity, UpdateConnectivity, UpdateStorage, RebootBroker), the configuration surface, the cluster-operation history, the SCRAM-secret operations, the cluster-policy operations, VPC connections, the Kafka-version listings, replicators and the three tag operations. A cluster here can therefore be created, read, listed and deleted and nothing else: it cannot be scaled, reconfigured, upgraded, or tagged after creation, and because ListClusterOperations is unrouted there is no history to read either.
A path ending in nodes or bootstrap-brokers routes before its API version is read
The published URIs are exactly /v1/clusters/{clusterArn}/bootstrap-brokers and /v1/clusters/{clusterArn}/nodes. Substrate matches both on an unanchored path suffix, and places those two arms ahead of every prefix arm, so the /v1/clusters/ prefix is never actually required — the TrimPrefix that is supposed to strip it is a no-op when it is absent. Three things follow. A GET /api/v2/clusters/{arn}/nodes answers HTTP 200, because the mangled ARN still splits with kafka in its third field and the cluster lookup succeeds, so Substrate serves a path AWS does not publish and a consumer can come to depend on it. Any GET whose path merely ends in one of those two words — GET /anything/at/all/nodes — is refused as a bad cluster ARN rather than as an unrecognised route, which sends a caller looking at its ARN instead of at its URL. And GET /bootstrap-brokers with no cluster at all reaches the empty-ARN guard. Anchoring both arms to their published prefix, below the version arms, leaves every published call routed as it is today. #1205.
A cluster is ACTIVE from the moment it is created
The published ClusterState is ACTIVE, CREATING, UPDATING, DELETING, FAILED, MAINTENANCE, REBOOTING_BROKER and HEALING, and a real cluster takes tens of minutes to leave CREATING. Substrate writes ACTIVE in CreateCluster and never writes anything else, so CREATING is unreachable and a consumer's wait-for-active loop returns on its first poll. That is the one MSK observable a test most wants to drive, because a cluster create is the slowest step in a streaming stack's deployment: a seeded observation count would let the CREATING path be asserted without waiting on anything. #1196.
CreateCluster requires only the cluster name
CreateClusterRequest marks four members required: brokerNodeGroupInfo, clusterName, kafkaVersion and numberOfBrokerNodes. Substrate checks clusterName, defaults kafkaVersion to 3.5.1 and numberOfBrokerNodes to 2, and accepts an absent brokerNodeGroupInfo — whose own clientSubnets and instanceType are required in turn. A call or template missing any of the three succeeds here and is refused by AWS, which is the failure mode this emulator exists to catch. The defaults are not harmless either: a cluster created without a broker count reports two brokers, and ListNodes then reports two nodes, so the count a consumer asked for is not what it reads back. #1197.
A cluster ARN resolves by name and its UUID is ignored
The published ARN form is arn:aws:kafka:us-east-1:0123456789019:cluster/SalesCluster/abcd1234-abcd-cafe-abab-9876543210ab-4, and the trailing UUID is what distinguishes one cluster named SalesCluster from the next. Substrate parses the region, the account and the name out of the ARN and looks the cluster up by name, never reading the UUID, so an ARN whose UUID belongs to a cluster that no longer exists resolves to whatever cluster now holds that name. A test that deletes a cluster, recreates it under the same name and reuses the old ARN gets HTTP 200 where AWS answers not-found, which hides exactly the class of stale- reference bug a deploy-destroy-redeploy test is written to find. #1204.
Every MSK list operation answers one page and reads no filter
GET /v1/clusters publishes three query parameters — nextToken, clusterNameFilter ("Specify a prefix of the name of the clusters that you want to list") and maxResults ("The maximum number of results to return in the response (default maximum 100 results per API call)") — and /v1/clusters/{clusterArn}/nodes publishes nextToken and maxResults. ListClustersResponse and ListNodesResponse both publish a nextToken member. Substrate reads none of them in ListClusters, ListClustersV2 or ListNodes, and deliberately omits nextToken rather than sending it empty, since an empty token invites a caller to page on it. The consequence is that a clusterNameFilter silently returns every cluster in the account and region, which is a wrong answer rather than a missing feature: a test asserting that a filter narrowed the list passes for the wrong reason. #1195.
DeleteCluster removes the cluster while reporting it DELETING
The response body is right — DeleteClusterResponse publishes exactly clusterArn and state, and clusterName is deliberately not reported because it is not a member — and DELETING is a published state. What diverges is what the state describes: the record and its index entry are removed before the response is written, so the very next DescribeCluster answers not-found rather than a cluster in DELETING, and the published deletion window is unobservable. The currentVersion query parameter DELETE /v1/clusters/{clusterArn} publishes is also never read, so a delete that names a stale version cannot be exercised. #1197.
The v2 cluster surface has no page in the MSK API reference
CreateClusterV2, DescribeClusterV2 and ListClustersV2 are routed under /api/v2/clusters, and that is the one routing fact in this section that could not be verified: the MSK API reference's resource index lists no v2 resource page, and the two plausible page URLs return no API content, so the HTTP paths themselves are unverified against any AWS reference page. The v2 request and response shapes were verified only from the AWS CLI reference, which publishes four CreateClusterV2 output members — ClusterArn, ClusterName, State and ClusterType — against the three Substrate emits, because the v2 create delegates to the v1 create and answers the v1 body. A consumer switching on clusterType to tell a provisioned cluster from a serverless one reads an absent member, even though every cluster here is provisioned and the describe and list responses do report the value. #1211.
The reported ClusterInfo carries eight of twenty-one published members
ClusterInfo publishes twenty-one members and Substrate reports eight: clusterArn, clusterName, state, brokerNodeGroupInfo, currentBrokerSoftwareInfo, numberOfBrokerNodes, tags and creationTime. Absent are activeOperationArn, clientAuthentication, currentVersion, customerActionStatus, encryptionInfo, enhancedMonitoring, loggingInfo, openMonitoring, rebalancing, stateInfo, storageMode, zookeeperConnectString and zookeeperConnectStringTls. currentVersion is the one with teeth, because CloudFormation publishes it as an attribute and no value is stored for it, and stateInfo is the one a failure test would want, since it is where a real cluster explains an unusable state. ListNodes is thinner still: the published NodeInfo members addedToClusterTime, controllerNodeInfo and zookeeperNodeInfo are absent, as are brokerNodeInfo's endpoints, attachedENIId and clientVpcIpAddress, so a node reports its ARN, type, instance type, broker ID, subnet and Kafka version and nothing a client could connect to. Every name Substrate does emit is a published one, including nodeARN, the single MSK response member that is not the plain lowerCamel of its name. #1199.
GetBootstrapBrokers reports one of fourteen published broker strings
GetBootstrapBrokersResponse publishes fourteen members — bootstrapBrokerString, bootstrapBrokerStringTls, bootstrapBrokerStringSaslIam, bootstrapBrokerStringSaslScram, and their public, IPv6 and VPC-connectivity variants. Substrate reports bootstrapBrokerString alone, so a consumer that asks for the TLS or SASL/IAM string — the normal case, since the published default for client-broker encryption is TLS — reads an absent member. The value it does report is broker1.{cluster}.{region}.kafka.amazonaws.com:9092,broker2.…, where the page's own example is b-1.exampleClusterName.abcde.c2.kafka.us-east-1.amazonaws.com:9094: the broker prefix, the cluster suffix and the port all differ, so a test that parses a broker hostname parses a form AWS never sends. #1204.
What a refusal reports
| Condition | Code | Status |
|---|---|---|
| a body that will not parse | BadRequest | 400 |
clusterName absent on a create | BadRequest | 400 |
| an empty cluster ARN in the path | BadRequest | 400 |
an ARN whose third field is not kafka | BadRequest | 400 |
| a cluster ARN that resolves to no cluster | NotFoundException | 404 |
| a cluster name that already exists | ConflictException | 409 |
| a method and path Substrate does not route | UnknownOperationException | 404 |
MSK is the one service in Substrate's inventory whose refusal codes cannot be verified against anything, and that is a fact about the API rather than about the plugin. Every MSK resource page documents its failures as a table of status codes against the model Error, whose schema is {"message", "invalidParameter"} — there is no code member — and MSK publishes no common-errors page and no Errors section on any operation. So BadRequest, NotFoundException and ConflictException are spellings Substrate chose, not spellings AWS published, and no amount of reading the reference can make one of them correct.
The statuses, by contrast, are all published rows: 400 is "The request isn't valid because the input is incorrect. Correct your input and then submit it again.", 404 is "The resource could not be found due to incorrect input. Correct the input, then retry the request.", and 409 appears only on POST /v1/clusters as "This cluster name already exists. Retry your request using another name." — so the duplicate-name conflict is the one refusal here whose status is published for exactly the condition that raises it. The published invalidParameter member, which is where a real MSK refusal names the member at fault, is never set on any of the eleven refusal sites. The remaining published statuses — 401, 403, 429, 500 and 503 — have no site: no credential is validated inside the plugin, no request is throttled, and nothing fails internally.
CloudFormation resource types
AWS::MSK::Cluster deploys through POST /v1/clusters and deletes by path. Four template properties reach the plugin: ClusterName (defaulting to the logical ID), KafkaVersion (defaulting to 3.5.1), and BrokerNodeGroupInfo's InstanceType (defaulting to kafka.m5.large) and ClientSubnets. NumberOfBrokerNodes is hard-coded to 2 and the template's value is not read, even though the resource publishes it as "Required: Yes" — so a stack asking for six brokers gets two, ListNodes then reports two nodes, and a template's broker count is unassertable. ClientAuthentication, ConfigurationInfo, EncryptionInfo, EnhancedMonitoring, LoggingInfo, OpenMonitoring, Rebalancing, StorageMode, Tags and ZookeeperAccess are all dropped.
Ref returns the cluster ARN, which is what the resource publishes. Of the two published Fn::GetAtt attributes, Arn resolves; CurrentVersion has no stored value, because currentVersion is not a member of the cluster record, so a template that reads it to drive an update gets nothing. #1203.
Cost
CreateCluster is attributed $0.0002 and GetBootstrapBrokers $0.000001, a broker hour and a request respectively, prorated across a single API call. CreateClusterV2 has no entry and there is no per-service fallback, so a cluster created through the v2 path is free while the same cluster created through the v1 path is charged — the cost of a resource should not depend on which API version created it. A loaded price list can supply MSK figures, since AmazonMSK maps onto this service. #1202.
Redshift
Endpoint: redshift.{region}.amazonaws.comProtocol: Query (API version 2012-12-01), XML responses Routing: Action form parameter
Substrate models the Redshift cluster control plane: creating, describing, modifying and deleting a cluster, and recording a parameter group, a subnet group and a manual snapshot. Ten of the API's 141 operations are routed. Nothing about the data plane is here — for ExecuteStatement and the rest of the statement API see Redshift Data API, which is a separate plugin on a separate endpoint. The single most important thing to know before writing a test is that substrate's XML is not the XML the reference publishes: there is no <XxxResponse> envelope, no namespace, no ResponseMetadata, and every list and single-cluster element is wrapped in member. An SDK cannot parse it, so these operations are reachable today only by a caller that reads the body itself.
Supported operations
| Operation | Action |
|---|---|
| CreateCluster | CreateCluster |
| DescribeClusters | DescribeClusters |
| ModifyCluster | ModifyCluster |
| DeleteCluster | DeleteCluster |
| CreateClusterParameterGroup | CreateClusterParameterGroup |
| DescribeClusterParameterGroups | DescribeClusterParameterGroups |
| CreateClusterSubnetGroup | CreateClusterSubnetGroup |
| DescribeClusterSubnetGroups | DescribeClusterSubnetGroups |
| CreateClusterSnapshot | CreateClusterSnapshot |
| DescribeClusterSnapshots | DescribeClusterSnapshots |
The other 131 operations are not routed. The whole of snapshot restore and copy, resize and DescribeNodeConfigurationOptions, pause and resume, RebootCluster, GetClusterCredentials, the event, HSM, usage-limit, reserved-node and scheduled-action families, every tagging operation, and the parameter-level operations DescribeClusterParameters and ModifyClusterParameterGroup reach the dispatch switch's default arm and refuse with InvalidAction at 400. Redshift's own Common Errors page does not publish a code for an unrecognised action — it is one of the five regenerated eighteen-entry Query lists, from which InvalidAction is absent — so that answer is the Query family's code as cited from SQS's page, not a Redshift-published one.
No Redshift response carries its XxxResponse envelope
redshiftXMLResponse marshals the result struct as the document root, so a CreateCluster response begins <CreateClusterResult> where the reference's sample begins <CreateClusterResponse xmlns="http://redshift.amazonaws.com/doc/2012-12-01/"> (emulator/redshift_plugin.go:545). The helper's third parameter is the request ID and it is declared _ string, discarded before the body is built, so no <ResponseMetadata><RequestId> element is emitted on any of the ten operations. A Query-protocol SDK locates a result by the …Response/…Result pair and finds neither, and the request ID a consumer is told to capture for support escalation does not exist. Fixing this is a prerequisite for the rest of the section being observable at all (#1208).
A cluster is wrapped in a spurious member element
redshiftClusterXML declares XMLName xml.Name tagged member (emulator/redshift_plugin.go:76), and the single-cluster results tag their field Cluster>member (:99, :105, :111), so CreateCluster, ModifyCluster and DeleteCluster emit <Cluster><member>…</member></Cluster> where the reference publishes the members directly inside <Cluster>. The four list results are wrong in the other direction: they flatten to <member> (Clusters>member at :94, ParameterGroups>member at :282, ClusterSubnetGroups>member at :363, Snapshots>member at :446) where the reference names each element for its member type — <Clusters><Cluster>, <ParameterGroups><ClusterParameterGroup>, <ClusterSubnetGroups><ClusterSubnetGroup> and <Snapshots><Snapshot>. The element name is per-list rather than a convention: Cluster genuinely does publish two member-named lists, ClusterNodes.member.N and PendingActions.member.N, which is why a global default cannot be right. Because the struct's own XMLName forces member, renaming the field tags alone would not fix the single-cluster case (#1208).
Two refusal codes carry a Fault suffix the reference does not publish
Substrate answers ClusterAlreadyExistsFault at 400 (emulator/redshift_plugin.go:143) and ClusterNotFoundFault at 404 (:216, :536). The reference publishes the wire codes without the suffix: CreateCluster documents "ClusterAlreadyExists — The account already has a cluster with the given identifier. HTTP Status Code: 400", and DescribeClusters, ModifyCluster, DeleteCluster and CreateClusterSnapshot all document "ClusterNotFound — The ClusterIdentifier parameter does not refer to an existing cluster. HTTP Status Code: 404". Both statuses are right; the spellings are the Smithy shape names rather than what the service puts on the wire, so a consumer branching on ClusterNotFound never takes the branch (#1198).
A cluster and its snapshots are available the moment they are asked for
createCluster stores ClusterStatus: "available" (emulator/redshift_plugin.go:163) and createClusterSnapshot stores Status: "available" (:470). The reference's CreateCluster sample publishes <ClusterStatus>creating</ClusterStatus> and its CreateClusterSnapshot sample publishes <Status>creating</Status>. Cluster publishes twenty valid status values, from creating and modifying through resizing, paused, storage-full and incompatible-parameters, and substrate emits exactly one of them. A consumer's wait-until-available loop therefore exits on its first poll, which is the one thing such a loop exists to make testable, and there is no seed that would make it poll. A seedable status progression, following the pattern the Bedrock and SageMaker job-status seeds establish, is what would make it assertable (#1196).
Deleting a cluster erases it instead of reporting deleting
deleteCluster calls state.Delete and removeFromStringIndex and then returns the record it loaded before the delete, with its available status intact (emulator/redshift_plugin.go:252). The reference's DeleteCluster sample publishes <ClusterStatus>deleting</ClusterStatus> on a cluster that remains describable while the deletion proceeds. In substrate the following DescribeClusters refuses with ClusterNotFoundFault at 404 instead, so a consumer that polls for the transition cannot observe it. SkipFinalClusterSnapshot and FinalClusterSnapshotIdentifier are read by nothing, so no final snapshot appears in DescribeClusterSnapshots either (#1196).
The cluster record carries eleven of sixty-three members
redshiftClusterXML has fields for ClusterIdentifier, ClusterStatus, NodeType, MasterUsername, DBName, NumberOfNodes, ClusterCreateTime, VpcId, AvailabilityZone, ClusterNamespaceArn and Endpoint (emulator/redshift_plugin.go:76). The reference's Cluster type publishes sixty-three. Absent are the members a test most often asserts on: ClusterAvailabilityStatus, ClusterVersion, ClusterSubnetGroupName, ClusterParameterGroups, ClusterNodes, Encrypted, KmsKeyId, PubliclyAccessible, IamRoles, Tags, PendingModifiedValues, PreferredMaintenanceWindow, AutomatedSnapshotRetentionPeriod and TotalStorageCapacityInMegaBytes. One of the eleven present members carries the wrong value rather than no value: ClusterNamespaceArn is filled from the cluster's own ARN (:126, built at :160 as arn:aws:redshift:{region}:{account}:cluster:{id}), where the reference documents it as "The namespace Amazon Resource Name (ARN) of the cluster" — a :namespace: ARN. Cluster publishes no member at all that carries a :cluster: ARN, so there is nowhere correct for that value to go and no published error code for handing a namespace field a cluster ARN (#1199).
Marker and MaxRecords are read by nothing
None of the four Describe operations paginates. Three of them do not even take the request: describeClusterParameterGroups, describeClusterSubnetGroups and describeClusterSnapshots are declared with _ *AWSRequest (emulator/redshift_plugin.go:325, :406, :495). Each page publishes MaxRecords — "The maximum number of response records to return in each call… Default: 100, Constraints: minimum 20, maximum 100" — and a Marker on both request and response; substrate returns the whole index and emits no Marker, so a consumer's paginator terminates after one page and a paging bug in its own code cannot surface. The same three signatures discard every filter the pages publish: ClusterIdentifier, SnapshotIdentifier, SnapshotType, StartTime, EndTime, OwnerAccount, TagKeys and TagValues on snapshots, and ParameterGroupName, TagKeys and TagValues on parameter groups. Only DescribeClusters filters at all, and only on ClusterIdentifier (:192) (#1195).
A resize takes effect before the call returns
modifyCluster writes NodeType and NumberOfNodes onto the stored record and returns it (emulator/redshift_plugin.go:222), so the new shape is visible on the response to the modify call itself. The reference states that a resize sets the cluster status to resizing and publishes a PendingModifiedValues member for the values not yet applied; substrate sets neither. The other modifiable parameters the page publishes — among them ClusterType, MasterUserPassword, ClusterVersion, AllowVersionUpgrade, PreferredMaintenanceWindow, AutomatedSnapshotRetentionPeriod, Encrypted and PubliclyAccessible — are discarded without a refusal, so a call that modifies only those appears to succeed and changes nothing (#1197).
Parameter groups and subnet groups are recorded without their required inputs
createCluster checks ClusterIdentifier and nothing else (emulator/redshift_plugin.go:135), though the page publishes NodeType "Required: Yes" and MasterUsername "Required: Yes"; substrate defaults the first to dc2.large (:146) and reads the second unchecked (:151). createClusterParameterGroup checks only ParameterGroupName (:295) where the page publishes Description and ParameterGroupFamily both "Required: Yes". createClusterSubnetGroup checks only ClusterSubnetGroupName (:376) where the page publishes Description and SubnetIds.SubnetIdentifier.N both "Required: Yes", discards the subnet list entirely, and reads req.Params["VpcId"] (:375) — VpcId is a member of the ClusterSubnetGroup response type and is not a parameter of the request, so for a correct caller the recorded group's VpcId is always empty. Neither group checks for a duplicate name, though the pages publish ClusterParameterGroupAlreadyExists and ClusterSubnetGroupAlreadyExists at 400. MissingParameter at 400 is on Redshift's Common Errors page — "A required parameter for the specified action is not supplied" — so every one of these has a published code to land on and simply does not use it (#1197).
What a refusal reports
| Condition | Code | Status |
|---|---|---|
ClusterIdentifier absent on CreateCluster | MissingParameter | 400 |
ClusterIdentifier absent on ModifyCluster or DeleteCluster | MissingParameter | 400 |
ClusterIdentifier or SnapshotIdentifier absent on CreateClusterSnapshot | MissingParameter | 400 |
ParameterGroupName absent on CreateClusterParameterGroup | MissingParameter | 400 |
ClusterSubnetGroupName absent on CreateClusterSubnetGroup | MissingParameter | 400 |
| Cluster identifier already recorded | ClusterAlreadyExistsFault | 400 |
| Named cluster not recorded | ClusterNotFoundFault | 404 |
| Any of the other 131 operations | InvalidAction | 400 |
MissingParameter at 400 is Redshift's own Common Errors entry and is used correctly everywhere it appears above. The two …Fault codes are the shape names rather than the published wire codes ClusterAlreadyExists and ClusterNotFound, whose statuses substrate does match. Several published codes have no site in substrate at all: ClusterParameterGroupAlreadyExists (400), ClusterSubnetGroupAlreadyExists (400), ClusterSnapshotAlreadyExists (400), ClusterSnapshotNotFound (404), ClusterParameterGroupNotFound (404), ClusterSubnetGroupNotFound (400), InvalidClusterState (400), NumberOfNodesQuotaExceeded (400), ClusterQuotaExceeded (400), InsufficientClusterCapacity (400), UnsupportedOperation (400) and InvalidSubnet (400). Because substrate never records a non-available status, InvalidClusterState in particular is unreachable by construction rather than merely unimplemented.
CloudFormation resource types
Substrate has no Redshift provisioner. AWS::Redshift::Cluster appears only in cfnSnapshotCapableTypes (emulator/cfn_deployer.go:182), which records that the reference lists the type as supporting the Snapshot deletion policy; nothing dispatches it. A template declaring one falls to dispatchResource's default arm (:2957), which logs "unknown CloudFormation resource type; using generic stub" and deploys a generic stub, so the stack reaches CREATE_COMPLETE and no cluster exists for DescribeClusters to find. No AWS::Timestream::* or other Redshift type is dispatched.
Cost
| Operation | Cost per call (USD) |
|---|---|
CreateCluster | 0.0002 |
CreateClusterSnapshot | 0.00002 |
Both keys are redshift/{Operation} and both name routed operations, so both attribute on every matching call. The other eight routed operations are free.
Timestream
Endpoint: ingest.timestream.{region}.amazonaws.com (write) and query.timestream.{region}.amazonaws.com (query) Protocol: JSON 1.0 Routing: X-Amz-Target: Timestream_20181101.{Operation}
Substrate models Timestream's database and table control plane, accepts records without interpreting them, and answers Query from a seeded result set. Twelve of the API's thirty-three operations are routed, spanning two API versions that AWS publishes as separate references — timestream-write-2018-11-01 and timestream-query-2018-11-01 — behind one plugin and one dispatch switch. The single most important thing to know before writing a test is that a seeded query result is keyed by query string alone, with no account or Region in the key, so one seed serves every caller of the emulator and a wildcard seed left behind by an earlier test will answer a later one.
Supported operations
| Operation | X-Amz-Target |
|---|---|
| CreateDatabase | Timestream_20181101.CreateDatabase |
| DescribeDatabase | Timestream_20181101.DescribeDatabase |
| DeleteDatabase | Timestream_20181101.DeleteDatabase |
| ListDatabases | Timestream_20181101.ListDatabases |
| CreateTable | Timestream_20181101.CreateTable |
| DescribeTable | Timestream_20181101.DescribeTable |
| DeleteTable | Timestream_20181101.DeleteTable |
| ListTables | Timestream_20181101.ListTables |
| WriteRecords | Timestream_20181101.WriteRecords |
| DescribeEndpoints | Timestream_20181101.DescribeEndpoints |
| Query | Timestream_20181101.Query |
| CancelQuery | Timestream_20181101.CancelQuery |
The other twenty-one operations are not routed and refuse with UnknownOperationException at 404, which is what both Timestream Common Errors pages publish for an unrecognised action — a live citation rather than a substituted code. That covers UpdateDatabase, UpdateTable, the three tagging operations, the whole batch-load family (CreateBatchLoadTask, DescribeBatchLoadTask, ResumeBatchLoadTask, ListBatchLoadTasks), the account settings pair, the six scheduled-query operations, and PrepareQuery. Substrate also does not enforce which endpoint an operation arrives on: the parser maps both the ingest. and query. host prefixes to the one plugin (emulator/parser.go:455), so WriteRecords answers on the query host and Query on the ingest host, where AWS publishes each on one endpoint only.
One seeded query result serves every account, Region and endpoint
Query returns, in order of preference, a result seeded for the exact query string, a result seeded under the "*" wildcard, rows reconstructed from records written to the table a SELECT … FROM … names, or an empty result set (emulator/timestream_plugin.go:370). Seeds are installed and cleared through the control plane:
POST /v1/timestream-query/results {"queryString": "…", "result": {…}}
DELETE /v1/timestream-query/results (all seeds; ?queryString=… for one)Seeds live in the timestream-ctrl namespace keyed result:{queryString} (emulator/timestream_types.go:103) and are not scoped by account or Region, unlike every other Timestream key in that file — db:{acct}/{region}/{name}, table:{acct}/{region}/{db}/{name} and the records key are all scoped. One seed therefore serves every caller of the emulator. The seed is also barely validated: the handler refuses only a body that fails to decode and a body whose result is null (emulator/timestream_ctrl.go:18), so a result whose row arity disagrees with its ColumnInfo is stored and returned verbatim. An omitted queryString silently becomes the "*" wildcard (:26) rather than being refused, which is how a seed intended for one query comes to answer all of them. The lookup discards the store's own error — if err != nil || raw == nil { continue } (emulator/timestream_plugin.go:381) — so a state-layer failure is indistinguishable from an absent seed (#1200).
Every Timestream timestamp is a string where the model publishes a number
TimestreamDatabase and TimestreamTable declare CreationTime and LastUpdatedTime as string (emulator/timestream_types.go:20, :22, :36, :38), and both create paths fill them with p.tc.Now().UTC().Format(time.RFC3339) (emulator/timestream_plugin.go:106, :202). The reference publishes all four as Type: Timestamp, rendering them in every JSON sample as "CreationTime": number. An SDK deserialising the member into a timestamp field fails on a string, so the call errors inside the client rather than returning a record, which makes the divergence fatal to an SDK-driven test rather than merely cosmetic (#1207).
A conflict answers 409 and a missing resource 404
CreateDatabase and CreateTable refuse a duplicate with ConflictException at 409 (emulator/timestream_plugin.go:103, :192), and loadDatabase and loadTable refuse an absent one with ResourceNotFoundException at 404 (:470, :490). The reference publishes both at 400: "ConflictException … HTTP Status Code: 400" and "ResourceNotFoundException — The operation tried to access a nonexistent resource. HTTP Status Code: 400". Across both Common Errors pages and every routed operation's page, InternalServerException at 500 is the only published Timestream error that is not a 400, so a consumer whose retry policy keys on status — retry 409, do not retry 400 — behaves differently against substrate than against the service (#1198).
Three operations answer an empty JSON object
DeleteDatabase, DeleteTable and CancelQuery each return map[string]any{}, serialised as {} (emulator/timestream_plugin.go:152, :252, :363). The two delete pages state "If the action is successful, the service sends back an HTTP 200 response with an empty HTTP body", so substrate sends two bytes where the service sends none. CancelQuery is a different mistake: the page publishes a response body of {"CancellationMessage": "string"}, and substrate omits the member, so a consumer reads an empty string with no indication it was never sent. CancelQuery is also declared (_ *RequestContext, _ *AWSRequest) (:362), which means it reads nothing at all — the page publishes QueryId as "Required: Yes" with a one-to-sixty-four character length constraint, and cancelling an ID that was never issued succeeds (#1206).
Pagination is accepted and discarded on four operations
ListDatabases and ListTables read neither MaxResults nor NextToken and emit no NextToken (emulator/timestream_plugin.go:155, :255), though ListTables publishes "MaxResults — The total number of items to return in the output… Valid Range: Minimum value of 1. Maximum value of 20" and a NextToken on both request and response. Query is worse than silent: it emits "NextToken": "" (:357), an empty token rather than an absent member, which an SDK paginator can read as a further page. Query also never checks QueryString, published "Required: Yes", so an omitted query string falls through the seed lookup to an empty result set instead of the ValidationException at 400 the page publishes; and MaxRows is discarded. The same is true of any query parseTimestreamSelect cannot handle (:396), which recognises a bare SELECT … FROM … and nothing else — a join, an aggregate or a time-series function returns zero rows rather than a refusal, so a consumer testing that a malformed query is rejected sees a success (#1195).
A table is ACTIVE at birth and TableCount never moves
createTable stores TableStatus: "ACTIVE" (emulator/timestream_plugin.go:207) where the page publishes the valid values ACTIVE | DELETING | RESTORING, so the transition a consumer polls for is never observable and there is no seed that would produce one. createDatabase stores TableCount: 0 (:110) and nothing increments it, so after creating three tables DescribeDatabase still reports zero against a member the reference defines as "The total number of tables found within a Timestream database". DeleteDatabase compounds this by not requiring the database to be empty (:136): the page states "All tables in the database must be deleted first, or a ValidationException error will be thrown", and substrate deletes the database record while leaving every table in the store, still reachable by DescribeTable, under a database that no longer exists. ValidationException at 400 is published on that page, so the refusal has a site (#1196).
WriteRecords accepts any batch and rejects nothing
Records is stored at whatever length it arrives (emulator/timestream_plugin.go:283) against a published constraint of "Minimum number of 1 item. Maximum number of 100 items", and the response reports RecordsIngested with Total and MemoryStore both set to the record count and MagneticStore fixed at zero. CommonAttributes is discarded: the page defines it as "A record that contains the common measure, dimension, time, and version attributes shared across all the records in the request… will be merged with the measure and dimension attributes in the records object", so a request that factors its dimensions out loses them, and a subsequent unseeded Query over those records returns rows missing every common dimension. Nothing in substrate emits RejectedRecordsException at 400 or its RejectedRecords list of per-record Reason and ExistingVersion, so the duplicate-record, out-of-retention and schema-mismatch surface a consumer's partial-failure handler exists for is unreachable, by construction and without a seed (#1197).
Every query column is VARCHAR
recordsToQueryResult types every column ScalarType: "VARCHAR" (emulator/timestream_plugin.go:444) and renders every value with fmt.Sprintf("%v", v) (:454), with the columns sorted alphabetically rather than in the order the query asked for them. The reference's Type shape publishes BIGINT | BOOLEAN | DOUBLE | DATE | INTEGER | INTERVAL_DAY_TO_SECOND | INTERVAL_YEAR_TO_MONTH | TIME | TIMESTAMP | UNKNOWN | VARCHAR, and its Datum shape publishes ArrayValue, NullValue, RowValue and TimeSeriesValue alongside ScalarValue; substrate's TimestreamDatum carries only ScalarValue and its TimestreamColumnInfoType only ScalarType (emulator/timestream_types.go:68, :82), so no nested or null datum can be represented. A consumer that inspects ColumnInfo to decide how to parse each value finds every column claiming to be text. A seeded result is unaffected — substrate returns the seed's own columns verbatim — so this shapes only the records-derived path (#1209).
DescribeEndpoints answers a host neither published endpoint uses
describeEndpoints returns a single endpoint whose address is "timestream." + reqCtx.Region + ".amazonaws.com" with a CachePeriodInMinutes of 1 (emulator/timestream_plugin.go:322). Timestream requires endpoint discovery and publishes its endpoints under the ingest. and query. prefixes; the bare timestream.{region}.amazonaws.com is not an address AWS publishes for either API. Substrate's parser accepts it anyway, because a host it does not special-case falls back to its first label (emulator/parser.go:482), so the discovery loop closes inside the emulator and the divergence only surfaces against the real service. The published CachePeriodInMinutes member is required but its value is not documented, so substrate's 1 is unverified rather than wrong (#1209).
What a refusal reports
| Condition | Code | Status |
|---|---|---|
| A request body that is not valid JSON | ValidationException | 400 |
DatabaseName absent on any database or table operation | ValidationException | 400 |
TableName absent on CreateTable, DescribeTable, DeleteTable or WriteRecords | ValidationException | 400 |
| Database name already recorded | ConflictException | 409 |
| Table name already recorded in the database | ConflictException | 409 |
| Named database not recorded | ResourceNotFoundException | 404 |
| Named table not recorded | ResourceNotFoundException | 404 |
| Any of the other twenty-one operations | UnknownOperationException | 404 |
ValidationException at 400 is published on every routed operation's page and is the right code for both a malformed body and a missing required member, so substrate reuses it for both and a consumer cannot distinguish them. UnknownOperationException at 404 is published on both Common Errors pages at exactly that status. ConflictException and ResourceNotFoundException are published at 400, not at the 409 and 404 substrate returns. Published codes with no site in substrate at all are RejectedRecordsException (400), AccessDeniedException (400), ThrottlingException (400), ServiceQuotaExceededException (400), InvalidEndpointException (400), QueryExecutionException (400) and InternalServerException (500) — so throttling, quota and endpoint-staleness retries, which are the paths a Timestream client's retry policy is written for, cannot be exercised here and have no seed.
Cost
| Operation | Cost per call (USD) |
|---|---|
WriteRecords | 0.0000005 |
Query | 0.000001 |
Both keys are timestream/{Operation} and both name routed operations, so both attribute on every matching call. The cost is per call and does not scale with the record count or the bytes scanned, which is how the service prices both.
Transfer Family
Endpoint: transfer.{region}.amazonaws.comProtocol: JSON (X-Amz-Target: TransferService.{Operation}, API version 2018-11-05)
Substrate routes ten of the seventy-three operations the Transfer Family API reference lists: create, describe, update and delete for servers and for users, plus the two collections that list them. Server and user records are keyed by account and Region, so two accounts or two Regions in one run do not see each other's servers. Nothing behind the API is modelled — no SFTP, FTPS, FTP or AS2 endpoint listens, no file moves, and no identity provider is called — because a file transfer is not observable through a Transfer Family API call. What a consumer can assert is the control-plane record: that a server exists with the endpoint type and tags it was given, that it has the users created on it, and that deleting it takes its users with it.
Supported operations
| Operation | Notes |
|---|---|
| CreateServer | Reads Domain, EndpointType, IdentityProviderType and Tags; every other published member is dropped |
| DescribeServer | Eight of the members DescribedServer publishes, plus three it does not |
| UpdateServer | Reads EndpointType and Tags only |
| DeleteServer | Cascade-deletes the server's users and their index |
| ListServers | One page; body not decoded, so MaxResults and NextToken are unread |
| CreateUser | Requires ServerId and UserName; Role is not checked |
| DescribeUser | Five of the members DescribedUser publishes, plus three it does not |
| UpdateUser | Reads HomeDirectory and Role only |
| DeleteUser | Answers an empty JSON object |
| ListUsers | One page; requires ServerId |
The other sixty-three operations are unrouted and are refused UnknownOperationException at 404 with the message The action {name} is not recognized., which is the code and status the service's own Common Errors page publishes for an unrecognised action. Among them are the two that would make a server's state observable (StartServer, StopServer); the whole access, agreement, connector, profile, certificate and workflow surface (CreateAccess, CreateAgreement, CreateConnector, CreateProfile, ImportCertificate, CreateWorkflow and their describe/list/update/delete counterparts); StartFileTransfer and StartDirectoryListing, which are the operations a caller would use to move data and which substrate does not model by design; the SSH-key operations (ImportSshPublicKey, DeleteSshPublicKey); TestIdentityProvider; tagging (TagResource, UntagResource, ListTagsForResource); and the security-policy and web-app families. A consumer that needs one of these needs real AWS or a seeded stand-in, not a substrate run.
Domain defaults to SFTP, which is not a Domain value
CreateServer substitutes SFTP when the request omits Domain, and SFTP is not a value the member can carry. API_CreateServer publishes Domain as Valid Values: S3 | EFS and states The default value is S3.; SFTP belongs to Protocols, which is a different member and is not read at all. The effect is not confined to the one response: the value is stored, so every later DescribeServer and ListServers reports a storage domain of SFTP, and a consumer that branches on S3 versus EFS to decide where to stage fixtures takes neither branch (#1198). EndpointType defaulting to PUBLIC in the same block is correct — PUBLIC is the first of the three published values — so the two defaults must not be read as one decision.
A missing server or user is reported 404 where Transfer publishes 400
Both loaders answer ResourceNotFoundException with HTTP 404. Every operation page that publishes that code — DescribeServer, DescribeUser, UpdateServer, DeleteServer, DeleteUser, CreateUser — publishes it as HTTP Status Code: 400. Transfer is a JSON-target service, so a code-aware SDK reads the code out of the body and a consumer catching ResourceNotFoundException is unaffected; a consumer that classifies by status is not, and the common shape of that mistake is a retry wrapper that treats 404 as "not yet consistent, try again" and 400 as "malformed, fail now". Against substrate it retries an absent server forever (#1198).
A duplicate user name is refused a code CreateUser does not publish
Creating a user that already exists on the server is refused ConflictException at 409. API_CreateUser publishes five codes — InternalServiceError (500), InvalidRequestException (400), ResourceExistsException (400), ResourceNotFoundException (400) and ServiceUnavailableException (500) — and ConflictException is not among them; it is published on UpdateServer, where the reason is a concurrent update rather than a name collision. ResourceExistsException at 400 is the code with a site on this operation, so a consumer whose idempotent-create helper catches it never catches anything (#1198).
A server is ONLINE from birth and no other state is reachable
CreateServer records State: "ONLINE" and nothing ever writes the field again. API_DescribedServer publishes Valid Values: OFFLINE | ONLINE | STARTING | STOPPING | START_FAILED | STOP_FAILED, and the two operations that would move a server between them, StartServer and StopServer, are not routed. Five of the six published values are therefore unobservable, and no transition can be asserted: a wait-until-ONLINE loop exits on its first poll, and a wait-until-OFFLINE loop cannot exit at all. A state progression is the kind of thing substrate models well — an observation countdown or a simulated-clock deadline, seeded per server — so the gap is the absence of a seed, not a scope boundary (#1196). What real AWS reports on the first DescribeServer after a CreateServer is not published on either operation's page and is recorded here as unverified.
Both Transfer collections send an empty NextToken and ignore MaxResults
ListServers and ListUsers return every record in one page and set "NextToken": "". ListServers does not decode its body at all, so a supplied MaxResults or NextToken is not merely ignored but unread. The reference publishes NextToken with Length Constraints: Minimum length of 1. Maximum length of 6144, which makes the empty string an illegal value rather than a terminator, and MaxResults with Valid Range: Minimum value of 1. Maximum value of 1000. The consequence depends on the idiom: a paginator that stops when the token is falsy terminates correctly by accident, while one written as while "NextToken" in response loops forever, because the member is always present. It also follows that InvalidNextTokenException, published at 400 on both operations, can never be reached (#1195).
CreateUser accepts a request with no Role
The required-member check tests ServerId and UserName. API_CreateUser publishes three members as Required: Yes — Role, ServerId and UserName — so a request that names a user and a server but no access role is accepted, stored and reported successful. This is precisely the shape of defect a run against substrate exists to catch before a template reaches AWS: the omission is invisible locally and fatal on the first real deploy (#1197).
DescribeServer counts zero users and omits most published members
UserCount is declared on the stored server record and is never assigned, so DescribeServer reports UserCount: 0 however many users exist — including immediately after a CreateUser that succeeded. API_DescribedServer documents the member as the number of users assigned to the server. The same response sends eight published members (ServerId, Arn, Domain, EndpointType, IdentityProviderType, State, Tags, UserCount) and omits the rest, including Protocols, EndpointDetails, LoggingRole, HostKeyFingerprint, IdentityProviderDetails, SecurityPolicyName and IpAddressType; and it adds CreatedAt, AccountID and Region, none of which DescribedServer publishes. DescribeUser has the matching shape: five published members plus ServerId, AccountID and Region inside the user object, with HomeDirectoryMappings, HomeDirectoryType, Policy, PosixProfile and SshPublicKeys absent. The two list shapes are thinner still — ListedServer publishes eight members and substrate sends four, ListedUser publishes six and substrate sends four — so a consumer that lists to filter on EndpointType or SshPublicKeyCount reads a missing member as a zero value (#1199).
UpdateServer reads two members and publishes neither of them as one it accepts
The update handler decodes ServerId, EndpointType and Tags, applies the latter two and reports success. API_UpdateServer publishes no Tags member at all — tagging a server is TagResource, which substrate does not route — and does publish Certificate, EndpointDetails, HostKeyId, IdentityProviderDetails, IpAddressType, LoggingRole, PostAuthenticationLoginBanner, PreAuthenticationLoginBanner, ProtocolDetails, Protocols, S3StorageOptions, SecurityPolicyName, StructuredLogDestinations and WorkflowDetails, every one of which is dropped silently. A consumer that adds FTPS by setting Protocols and Certificate gets a 200 carrying the server ID and reads back an unchanged server (#1199).
Server IDs are unseeded, so no two runs produce the same one
generateTransferServerID reads from crypto/rand. The shape is right — s- followed by 17 hexadecimal characters, 19 in total, which is what API_DescribedServer publishes as Pattern: s-([0-9a-f]{17}) and Length Constraints: Fixed length of 19 — but the value is tied neither to the simulated clock nor to a seed, so the same test run twice produces different IDs, a recorded run cannot be re-derived from its inputs, and an exported fixture that pins a server ID is stale as soon as it is written. That is a direct cost to the property the rest of the emulator is built around (#1204).
A stack-deployed server is invisible to DescribeServer
AWS::Transfer::Server is deployed as a stub: the properties are written to the CloudFormation stub namespace and never into the Transfer plugin's own state, so the ten routed operations cannot see the resource the stack created. Calling DescribeServer on the value a template exported answers ResourceNotFoundException. The physical ID compounds it — it is s- followed by the lower-cased logical ID, so a resource named MySftpServer gets s-mysftpserver, which satisfies neither the published fixed length of 19 nor the published s-([0-9a-f]{17}) pattern, and cannot be a value any Transfer operation would accept. Ref is correct: AWS publishes Ref returns the server ARN, such as arn:aws:transfer:us-east-1:123456789012:server/s-01234567890abcdef, and that is what substrate returns (#1203).
The two delete operations answer an empty JSON object
DeleteServer and DeleteUser answer {} at 200, where both pages state If the action is successful, the service sends back an HTTP 200 response with an empty HTTP body. This is listed for completeness and ranked last deliberately: API_DeleteServer's own example response block shows { }, so the reference does not agree with itself, and no SDK distinguishes an empty body from an empty object for an operation with no response members (#1206).
What a refusal reports
| Condition | Code | Status |
|---|---|---|
| Request body that is not JSON | InvalidRequestException | 400 |
ServerId absent or empty | InvalidRequestException | 400 |
ServerId or UserName absent on a user operation | InvalidRequestException | 400 |
| Server ID that does not exist | ResourceNotFoundException | 404 |
| User that does not exist on the named server | ResourceNotFoundException | 404 |
| User name already present on the server | ConflictException | 409 |
| Operation outside the routed ten | UnknownOperationException | 404 |
Two of those seven do not match the reference: ResourceNotFoundException is published at 400 on every operation that carries it, and the duplicate-user case has no site for ConflictException on CreateUser at all, where ResourceExistsException at 400 is the published code. The refusal for an undecodable body is InvalidRequestException at 400 rather than the Common Errors code MalformedHttpRequestException, also 400, because that code's published gloss is about content-encoding decompression rather than JSON syntax; the reasoning is recorded at the call site.
Published codes with no site in substrate: AccessDeniedException, which CreateServer, UpdateServer and DeleteServer publish at 400 and the Common Errors page publishes at 403; ResourceExistsException (400); InvalidNextTokenException (400), unreachable because neither collection reads a token; ThrottlingException (400 per operation, 400 on Common Errors); InternalServiceError (500); and ServiceUnavailableException (500). The rest of the Common Errors vocabulary is likewise unemitted: ExpiredTokenException (403), IncompleteSignature (403), InternalFailure (500), MalformedHttpRequestException (400), NotAuthorized (401), OptInRequired (403), RequestAbortedException (400), RequestEntityTooLargeException (413), RequestTimeoutException (408), ServiceUnavailable (503), UnrecognizedClientException (403) and ValidationError (400).
CloudFormation resource types
| Type | Ref | Notes |
|---|---|---|
| AWS::Transfer::Server | server ARN | Physical ID is s- + lower-cased logical ID, which the published s-([0-9a-f]{17}) pattern rejects. Deployed as a stub, so DescribeServer cannot see it. Fn::GetAtt Arn resolves; ServerId, State and As2ServiceManagedEgressIpAddresses answer an empty string |
AWS::Transfer::User is not deployed, so a template that creates a server and its users gets the server stub and a refusal for each user.
Cost
Creating a server is charged $0.30, an approximation of the published $0.30 per protocol per hour collapsed into a single per-creation charge. The key is matched on the operation name, which a JSON-target request carries in its X-Amz-Target header, so the entry is live. No other Transfer operation is charged, and no per-hour or per-gigabyte component is modelled.
OpenSearch
Endpoint: search-{domain}-{suffix}.{region}.es.amazonaws.com, {id}.{region}.aoss.amazonaws.comProtocol: OpenSearch REST (paths and JSON bodies, not an AWS service model) Routing: any host containing .es. or .aoss., and the SigV4 signing names es and aoss, are mapped to this plugin. The domain or collection name in the host is data, not a routing key, and substrate does not read it.
Substrate routes the OpenSearch data plane only. Indexing, searching, bulk writes, scrolling and the handful of index operations below are emulated; the es/opensearch control plane is not routed at all. There is no CreateDomain, DescribeDomain, UpdateDomainConfig, ListDomainNames or DeleteDomain, and no aoss collection operation. A control-plane call is not refused as an unknown action either: it arrives on a host this plugin owns, its path is split as {index}/{rest…}, and POST /2021-01-01/opensearch/domain is therefore read as an index named 2021-01-01 and refused route_not_found at 404 in the OpenSearch engine's error envelope — a body with no AWS Code in it, which an opensearch SDK client cannot turn into a modelled error. A consumer must treat the domain as pre-existing and address the data plane directly (#1212).
There is one cluster per run and it is shared. Substrate holds no notion of which domain a request was addressed to, and its state keys carry no account or Region segment, so every caller in a run reads and writes the same indices.
Supported operations
| Method and path | Notes |
|---|---|
PUT /{index} | Stores a supplied mappings and settings; refuses an existing index |
GET /{index} | Always reports one shard and no replicas; never returns stored settings |
HEAD /{index} | Answers the same JSON body as GET, where the reference publishes a status and no body |
DELETE /{index} | Deletes the index and every document in it |
PUT|POST /{index}/_mapping | Acknowledged and discarded |
POST /{index}/_refresh | Reports a one-shard _shards object |
PUT|POST /{index}/_doc/{id} | Always 201 with _version: 1, _seq_no: 0, no _primary_term |
POST /{index}/_doc | ID drawn from crypto/rand |
GET /{index}/_doc/{id} | Reports _version: 1 always; a miss is the error envelope, not found: false |
DELETE /{index}/_doc/{id} | Always result: "deleted" at 200, even for a document that never existed |
POST|PUT /{index}/_bulk | index, create and delete only; errors always false |
{any method} /{index}/_search | query, from, size, aggs and scroll are read; sort and _source are parsed and ignored |
GET|POST /_search/scroll | Body or scroll_id parameter; reports the page length as the total |
DELETE /_search/scroll | Reports succeeded and num_freed |
GET /_cluster/health | A fixed single-node green response |
Anything else is refused route_not_found at 404. That set includes PUT\|POST /{index}/_create/{id}, which the reference publishes alongside _doc; the index-less forms GET\|POST /_search and POST /_bulk, which are misread as indices named _search and _bulk and answer index_not_found_exception and route_not_found respectively; the published path form GET\|POST /_search/scroll/{scroll_id}; DELETE /_search/scroll/_all; _update, _update_by_query, _delete_by_query, _reindex, _count, _msearch, _alias, _aliases, _settings, _cat/*, _nodes/*, _snapshot/* and the point-in-time API. Substrate is not an OpenSearch engine and does not aim to be one: what it models is the set of observations a consumer's index-then-query code makes.
OpenSearch state keys carry no account or Region, so one cluster is shared
Index metadata is stored at index:{name}, a document at doc:{index}/{id}, a document-ID list at doc_ids:{index} and a scroll context at scroll:{id}. Every other plugin in substrate prefixes its keys with the account and Region from the request context, which is what makes a multi-account or multi-Region run meaningful. Here two accounts in one run write into the same index, a document indexed while acting as one principal is searchable as another, and a test that separates fixtures by Region does not separate them. The domain name is not part of the key either — nor of the routing — so two domains in one template are also one cluster (#1200).
Every search hit reports _index as unknown
The paging helper that builds a search response emits the literal string unknown for _index on every hit. The search reference publishes _index on each hit as the name of the index the document came from, and the scroll continuation path in this same plugin emits the real name, so the two disagree with each other as well as with the reference. A consumer that reads _index off a hit to address a follow-up write — the normal pattern for a multi-index search, a delete-by-hit, or a reindex loop — builds a request against an index literally called unknown (#1213).
A query with two top-level clauses is decided by Go map order
The matcher iterates the query object as a Go map and returns as soon as it sees match_all (matching) or match_none (not matching). Go randomises map iteration order, so a body such as {"query": {"match_all": {}, "term": {"status": "open"}}} matches every document or applies the term filter depending on which key the runtime happens to visit first, and the same test run twice gives different answers. That is the one defect class this repository cannot tolerate: reproducibility by construction is the premise, and here the same input produces different output within a single build. No published page supplies a code to refuse such a body — the Query DSL documents compound queries as the way to combine clauses and says nothing about a multi-clause object, so there is no published error for substrate to answer — which means the fix is to make the decision order-independent, not to start refusing (#1201).
Result order is a random ID sorted lexicographically
An auto-generated document ID is twelve bytes from crypto/rand, and the doc_ids index that drives document loading is re-sorted lexicographically on every append. Search therefore returns documents in the order of random strings: different on every run, and unrelated to any property of the documents. Nothing else supplies an order, because _score is hard-coded to 1.0 on every hit and max_score to 1.0 on every response, and the sort member is decoded into the request struct and then never read — as is _source, so field filtering does nothing. A test that asserts the first hit, or that pins a response body, is asserting a coin flip (#1201).
The two OpenSearch cost entries can never match a request
The cost table registers opensearch/IndexDocument and opensearch/Bulk. The lookup key is the lower-cased service name joined to req.Operation, and for a REST-routed plugin req.Operation holds the HTTP method — this plugin's own dispatcher says so on its first line. The resolver table that rewrites a verb into a semantic operation name has no opensearch entry, so the keys a request actually offers are opensearch/PUT, opensearch/POST and opensearch/GET. Neither table entry is reachable, and an indexing workload reports a cost of zero. Fixing it means adding an opensearch resolver, not editing the cost table: the operation name is also what the audit log, the fault injector and the IAM condition-key evaluation see (#1202).
Indexing a document is always 201 and never reports _primary_term
A write to _doc answers HTTP 201 with _version: 1 and _seq_no: 0, whether it created the document or overwrote one, and omits _primary_term. The reference publishes 200 for an updated document and 201 for a created one, a _version that increments with each write, and both _seq_no and _primary_term in the response body. The consequence is that the whole optimistic-concurrency contract is unreachable: a consumer cannot read a _seq_no/_primary_term pair worth sending back, cannot provoke the published 409 version conflict, and cannot distinguish a create from an update by status. The result member is computed correctly (created or updated), so the body and the status contradict each other (#1213).
A bulk update is dropped and a bulk response never reports an error
The bulk handler switches on three action names. The reference publishes four — create, index, update, delete — and an update line is consumed as an action header whose body line is then read as the next action header, so the update and whatever followed it disappear without a diagnostic. The response makes that invisible: errors is hard-coded to false, every item reports status: 200 even when it created a document, and no item carries _version, _shards, _seq_no, _primary_term or the published error object. The reference documents item statuses of 200 (updated), 201 (created), 404 (not found) and 409 (version conflict), and errors as reporting whether any action failed. A consumer whose ingest checks if response["errors"] never enters the branch it wrote that check for (#1213).
Deleting a document that is not there reports deleted
The delete handler issues the state delete unconditionally and answers result: "deleted" with _version: 2 at 200. The reference states that the operation Returns deleted if the document was successfully deleted or not_found if the document did not exist. A delete-then-confirm test therefore cannot tell a real delete from a no-op, and neither can a cleanup routine that counts how many of its targets were actually present. The _version: 2 is fixed rather than derived, so it is wrong for any document written more than once (#1213).
A scroll continuation reports its page length as the total
The continuation response sets hits.total.value to the number of hits in the page it is returning. The scroll reference is explicit that hits.total shows the total count from the original search query, not the current batch. The initial search is correct — it reports the size of the full filtered set — so the first response and every later one disagree about what the number means. A loop that scrolls while its accumulated count is below total exits after one page (#1213).
An index read reports settings it was never given
GET /{index} always answers number_of_shards: "1" and number_of_replicas: "0", even though PUT /{index} does store a supplied settings object. A _mapping update is worse: it is acknowledged and thrown away, so a consumer that puts a mapping and reads it back sees only whatever the create call carried. HEAD /{index} is routed to the same handler as GET and so answers a JSON body, where the index-exists reference states the operation returns only one of two possible response codes: 200 … and 404 and publishes no body at all (#1213).
What a refusal reports
Refusals on this endpoint are the engine's own JSON envelope — {"error": {"type": …, "reason": …}, "status": N} — and not an AWS Code/Message pair, because the data plane is the domain's REST API rather than an AWS control-plane operation. The status member repeats the HTTP status.
| Condition | error.type | Status |
|---|---|---|
| Path with no index segment | index_not_specified | 400 |
PUT /{index} where the index exists | resource_already_exists_exception | 400 |
GET, HEAD or DELETE on an index that does not exist | index_not_found_exception | 404 |
GET /{index}/_doc/{id} where the document is absent | not_found | 404 |
| Search, scroll or clear-scroll body that is not valid JSON | json_parse_exception | 400 |
Scroll with neither a body scroll_id nor a scroll_id parameter | illegal_argument_exception | 400 |
| Scroll ID with no stored context | search_context_missing_exception | 404 |
| Stored scroll context that will not decode | internal_error | 500 |
| Any path the dispatcher does not recognise, including every control-plane path | route_not_found | 404 |
None of these type strings comes from an AWS API model, because there is no AWS API model for the data plane; they follow the engine's own lower-cased exception names. Two are substrate's own coinages with no counterpart in the engine — index_not_specified and route_not_found — and are named as such so that a reader does not mistake them for published values. The envelope also omits root_cause: a published OpenSearch error nests {"error": {"root_cause": [{"type": …, "reason": …}], "type": …, "reason": …}, "status": N}, so a consumer reading error.root_cause[0].reason finds no such key (#1213). Published outcomes with no site here include the 409 version conflict on a document write and on a bulk item, and the 404-with-found: false body the get-document reference publishes; the reference publishes no error body for an expired scroll ID or for a query object carrying several top-level clauses, so substrate's answers in those two cases have nothing to be measured against.
CloudFormation resource types
| Type | Ref | Notes |
|---|---|---|
| AWS::OpenSearchService::Domain | domain name | Deployed as a stub; the data plane does not see it and serves one shared cluster regardless. Fn::GetAtt Arn and DomainArn resolve to the domain ARN; DomainEndpoint, DomainEndpointV2 and Id answer an empty string |
AWS::Elasticsearch::Domain, the legacy type AWS still documents a migration path from, is not deployed. Because DomainEndpoint resolves to an empty string, a template that passes the endpoint into a Lambda environment variable or a stack output hands on an empty value (#1203).
Cost
No OpenSearch request is charged. Two entries exist in the cost table, opensearch/IndexDocument and opensearch/Bulk, and neither can match: the lookup key is built from req.Operation, which on this endpoint is the HTTP method, so the only keys ever offered are opensearch/PUT, opensearch/POST and opensearch/GET. Treat an OpenSearch workload as free until the operation-name resolver covers this service.
execute-api (API Gateway data plane)
Endpoint: {apiId}.execute-api.{region}.amazonaws.com/{stage}/{resourcePath}Protocol: whatever the deployed API accepts — there is no service model Routing: any host containing .execute-api.; the first host label is the API ID, and is data rather than part of the service name
This is not a modelled AWS API. It is the runtime endpoint a browser or an HTTP client calls, and substrate's job on it is to do what a deployed stage does: find the AWS_PROXY integration for the requested method and path, build the proxy event AWS would build, invoke the Lambda function through the registry, and turn the function's proxy response back into an HTTP response. Nothing is stored and no state is owned — the API, its routes and its integrations are read out of the API Gateway and API Gateway v2 plugins' state, and the function is whatever the Lambda plugin holds. An SDK-generated client never calls this endpoint, which is why it has no operation list.
The name a caller needs is execute-api. The plugin registry keys every plugin on its Name(), and this one returns execute-api; that is the value a request's resolved service name must equal, and the value to use in a fault injection rule, a cost key or an audit filter. The string apigateway-proxy that appears beside the plugin in the registration table is a label used only to interpolate into an initialization error message. It is not an alias, it never reaches routing, and addressing it gets ServiceNotAvailable at 501.
What a request does
| Step | Result |
|---|---|
| Host header read for the API ID | Missing host is refused 400; a host without .execute-api. is refused 400 |
| First path segment taken as the stage | Never validated against a stage or a deployment |
| Remainder taken as the resource path | Compared to route keys and resource paths by exact string equality |
| v2 route lookup, then v1 resource lookup | The first AWS_PROXY integration found wins; v2 falls back to $default |
| Proxy event built | 2.0 format for an HTTP API, 1.0 format for a REST API, chosen by which plugin held the API |
| Lambda invoked | Through the registry, as POST /2015-03-31/functions/{name}/invocations |
| Response parsed | statusCode, headers, body and isBase64Encoded are read; anything else is relayed at 200 |
Nothing else in the data-plane surface is modelled. There are no authorizers, no usage plans or API keys, no WAF, no request or response mapping templates, no non-proxy integration types, no binary media-type handling beyond a single base64 decode, no CORS preflight handling, no throttling and no gateway response customisation. A request whose API cannot be found, or whose method and path match no AWS_PROXY integration, is refused 502.
The stage in the path is never checked
The stage is split off the path and used only to populate the event. No stage record is read, no deployment is read, and no check ties the two together, so /prod/users, /dev/users and /typo/users are one request. A template that creates a resource, a method and an integration but never a AWS::ApiGateway::Deployment or AWS::ApiGateway::Stage — a real and common CDK mistake — is indistinguishable from a correct one when driven through this endpoint, which removes exactly the signal a pre-AWS validation run is supposed to produce. What AWS returns for a stage that does not exist is not stated in the gateway-response table and is recorded here as unverified; the table does publish MISSING_AUTHENTICATION_TOKEN at 403 for the cases when the client attempts to invoke an unsupported API method or resource, which is the landing for an unknown resource path (#1214).
A path parameter never matches, so a proxy resource is unreachable
Resolution compares the request's resource path to the stored path with != for a REST API and == for an HTTP API route key. Neither compare understands a path template, so a REST API whose only resource is /{proxy+} matches nothing, and one with /users/{id} cannot serve /users/42. Since /{proxy+} forwarding to a single handler is the shape most CDK and SAM applications emit, the common case is that a correctly deployed API answers 502 for every request. pathParameters is hard-coded to nil in the v1 event and absent from the v2 event for the same reason: with no template there is nothing to extract. An HTTP API is partially rescued by its $default route, which substrate does fall back to, so a $default-only HTTP API works and a parameterised one does not (#1214).
A failed Lambda is reported to the caller as a success
Only the invoke response's body is examined. The invoke status and headers are discarded, including X-Amz-Function-Error, which substrate's own Lambda plugin sets when a function errors and which a control-plane seed can force. The body is then decoded leniently: a body that is not JSON is passed through verbatim at HTTP 200, and a body that is JSON but not a proxy response yields a zero statusCode that is promoted to 200 with an empty body. The REST API developer guide states the opposite — If the function output is of a different format, API Gateway returns a 502 Bad Gateway error response. — so an unhandled exception payload, which is neither a proxy shape nor an error to this code, reaches the caller as a 200 carrying {"errorMessage": …} or as a 200 carrying nothing at all. The path a test seeds a Lambda failure in order to exercise is therefore the path that cannot be observed (#1214).
The v2 rawQueryString is assembled from Go map order
The 2.0 event's rawQueryString is built by concatenating key=value while iterating the parsed query parameters, which are held in a Go map. With two or more parameters the resulting string differs between runs of the same test, so an event payload cannot be pinned and a handler that parses rawQueryString itself sees a different input each time. Values are also inserted without percent encoding, so a value containing & or = silently changes the shape of the string. Deterministic replay is the property the rest of the emulator is built to guarantee, and this breaks it inside the payload a consumer's code reads (#1201).
PayloadFormatVersion is recorded by the control plane and ignored by the proxy
The v2 integration record carries PayloadFormatVersion, and the proxy never reads it: the event format is decided by which plugin's state held the API, 2.0 for an HTTP API and 1.0 for a REST API. The HTTP API reference states The supported values are 1.0 and 2.0 and documents two different event shapes, so an HTTP API deliberately configured for 1.0 — which is what a consumer does when migrating a REST API handler unchanged — receives a 2.0 event, and its handler reads event["httpMethod"] and event["path"] as absent (#1215).
Both proxy events are thinner than the published ones
The 1.0 event carries a version member, which the REST API proxy input reference does not publish at all — payload-format versioning belongs to HTTP APIs — so a handler that dispatches on event.get("version") sends a REST API request down its HTTP API branch. It omits multiValueHeaders and multiValueQueryStringParameters, sends stageVariables as an empty object where the published example shows null, and reduces requestContext to stage, requestId, httpMethod, resourcePath and apiId — five of the fifteen members published, with identity and authorizer among the absent, so requestContext.identity.sourceIp and requestContext.authorizer.claims cannot be read. The 2.0 event omits cookies, queryStringParameters, pathParameters and stageVariables, and its requestContext omits time, timeEpoch, domainName, domainPrefix, accountId and the http members protocol, sourceIp and userAgent. The omission that bites first is queryStringParameters: substrate does parse the query string into rawQueryString, so the information is present and simply not offered in the member a 2.0 handler reads (#1215). Whether a named stage contributes a segment to the 1.0 path and the 2.0 rawPath, as substrate assumes, is unverified: both published examples use the $default stage, where the question does not arise, and the REST example shows path equal to resource.
What a refusal reports
A refusal is a JSON object with a single message member carrying substrate's own diagnostic text, which is not the shape or the status API Gateway publishes for any of these conditions.
| Condition | Body | Status |
|---|---|---|
| Plugin initialized without a registry | {"message": "proxy plugin not wired to registry"} | 500 |
No Host header | {"message": "missing Host header"} | 400 |
Host without an .execute-api. label | {"message": "unexpected host: …"} | 400 |
| API ID not found in either plugin's state | {"message": "no Lambda integration found: API … not found"} | 502 |
No AWS_PROXY integration for the method and path | {"message": "no Lambda integration found: no AWS_PROXY integration for …"} | 502 |
| Proxy event will not marshal | {"message": "build proxy event: …"} | 500 |
| Lambda invocation refused, or nil | {"message": "lambda invoke: …"} or {"message": "nil lambda response"} | 502 |
| Lambda answered a body that is not a proxy response | the body verbatim, or an empty body | 200 |
API Gateway publishes a gateway response type and a default status for each of these situations, and none of them is a 502 carrying prose. MISSING_AUTHENTICATION_TOKEN is 403 and its gloss covers the cases when the client attempts to invoke an unsupported API method or resource, which is the published landing for an unrecognised path — substrate answers 502. RESOURCE_NOT_FOUND is 404, INTEGRATION_FAILURE and INTEGRATION_TIMEOUT are 504, API_CONFIGURATION_ERROR is 500, BAD_REQUEST_BODY and BAD_REQUEST_PARAMETERS are 400, UNAUTHORIZED is 401, ACCESS_DENIED, EXPIRED_TOKEN, INVALID_API_KEY, INVALID_SIGNATURE and WAF_FILTERED are 403, REQUEST_TOO_LARGE is 413, UNSUPPORTED_MEDIA_TYPE is 415, and THROTTLED and QUOTA_EXCEEDED are 429; DEFAULT_4XX and DEFAULT_5XX publish no default status of their own. Every one of those is currently without a site in substrate. Which type covers a resource that exists but carries no AWS_PROXY integration is not stated in the table and is recorded here as unverified. The published answer for a malformed function output is not a gateway response type at all but a plain 502 Bad Gateway, which is the one status substrate does emit — for the wrong conditions, and never for that one (#1214).
Fault injection
Fault injection is cross-service rather than a plugin, so it lives here rather than in a per-service section. Rules are armed in process through NewFaultController, from a configuration file's fault: block, or over the wire:
POST /v1/fault/rules {"enabled":true,"rules":[{…}]}
GET /v1/fault/rules → the live configuration, each rule carrying its fired count
DELETE /v1/fault/rules → disable and clearFaults are evaluated before the request reaches a plugin, so a rule fires whether or not the operation it names is implemented, and no state is written for a request a fault refuses. POST /v1/state/reset clears the rules along with the state.
A rule matches on five fields, all AND-ed, each ignored when empty:
| Matcher | Matches |
|---|---|
service | the service name (s3, ec2, …) |
operation | the semantic operation name (PutObject, UploadPart, …) |
path_suffix | requests whose path ends with the string (.parquet, /big.bin) |
query_key | requests carrying the query parameter, whatever its value (uploads, uploadId, partNumber) |
header_prefix | requests carrying a header whose name starts with the prefix, compared case-insensitively |
S3 operations are named semantically, like every other service's. The request parser resolves an S3 REST request to PutObject, UploadPart, CompleteMultipartUpload and so on before faults are evaluated, so a rule naming PutObject fires on PutObject and not on UploadPart, which is also a PUT to an object path. Previously an S3 request carried its bare HTTP verb at this point, so operation: PutObject matched nothing at all and operation: PUT took out both. A rule naming a bare HTTP method therefore no longer matches an S3 request — a rule on GET still fires for a service whose operation genuinely is its method, such as execute-api.
query_key is what separates the multipart sub-operations. CreateMultipartUpload, UploadPart and CompleteMultipartUpload share a path and differ from each other by a POST-versus-PUT and by a sub-resource parameter — ?uploads, ?partNumber=&uploadId=, ?uploadId=. Presence is what those parameters signal, so the value is not compared. The three wire matchers exist for distinctions an operation name does not carry: one key rather than every key, or one header family rather than every request.
times bounds a rule, and zero means one
times | Fires on |
|---|---|
absent / 0 | exactly one matching request |
n > 0 | the first n matching requests |
| negative | every matching request |
The bound is what makes retry assertable: fail twice, then succeed, is the outcome that distinguishes working retry from no retry, and an unbounded rule can only ever produce failure. Zero means one rather than unlimited deliberately — reading a missing field as unlimited turns a typo into a fixture that consumes a consumer's whole retry budget.
A rule that has reached its bound is skipped rather than ending evaluation, so a later rule still gets its turn. The match, the probability roll and the increment all happen under one lock: with times: 1, N concurrent requests produce exactly one failure, which is not something a counter on the client side can arrange.
Set times: -1 when a fixture arms a fault and then clears it to assert the retry succeeds — with the default of one the rule would already be spent, and the assertion would pass whether or not clearing worked.
The fired count
Each rule reports a fired count through GET /v1/fault/rules; FaultsFired() sums them for an in-process test. A rule that matches nothing produces exactly the same passing test as a consumer's retry working, so a fixture that arms a fault and then observes success has proven nothing without asserting the count. Arming rules again replaces the configuration and resets every count, so a fixture that re-arms the same rule between phases gets its full budget back rather than a spent one.
An injected error is indistinguishable from a real one
An injected error is serialized in the wire shape the target service's own errors use. That matters most for S3, whose error document is a bare <Error> with a <RequestId> rather than the <ErrorResponse> wrapper the Query protocol uses: an SDK recovers no code from the wrapped form and falls back to the HTTP status, so an injected SlowDown used to arrive at the client as ServiceUnavailable and a consumer matching on SlowDown never saw their own fault. The bytes now come from the same function the S3 plugin uses, so an injected NoSuchKey and a genuine one are byte-identical — which is the one property a fault injector must not lack, since a caller who can tell the two apart can tell a fixture from production.
EC2 is the second such service, and it was broken more widely than S3: it is the only service on the ec2 protocol, whose error document wraps the error in a plural<Errors> element and spells the request id <RequestID> with a capital D.
<Response><Errors><Error><Code>InsufficientInstanceCapacity</Code><Message>…</Message></Error></Errors><RequestID>SUBSTRATE</RequestID></Response>The AWS SDKs read the code at the XPath Errors>Error>Code, which finds nothing in a <ErrorResponse><Error> document, so every EC2 error — organic as much as injected — arrived at an SDK caller as UnknownError. That made a consumer's InvalidInstanceID.NotFound branch unreachable and its test vacuous, the same failure S3 had, one service over and on both paths rather than just the injected one.
This is also a lesson about which tests can catch a wire-shape bug. Neither case was caught by substrate's own coverage, because botocore is lenient where the SDKs are strict: its EC2 parser falls back to the document root when <Errors> is absent, so the AWS CLI reported the correct code out of the wrong document the entire time, and every CLI-driven test passed. Only a real SDK client is a gate here — see test/e2e/journey_ec2_errors_test.go.
cloudfront and route53 are REST-XML like S3 but keep the <ErrorResponse> shape: their real error documents genuinely are wrapped, so they were already correct. Every other XML service is on the Query protocol and keeps that wrapper too; S3 and EC2 are the only two carve-outs.
The <RequestID> is the fixed string SUBSTRATE rather than a generated value, as it is in S3's document, so two replays of one recorded run produce byte-identical error bodies.
probability draws from a per-rule PRNG
Each rule draws from its own PRNG stream, seeded from the controller's seed and the rule's index. A rule's outcome sequence therefore depends only on how many requests that rule itself matched: adding an unrelated rule, or changing how often one matches, leaves the others' rolls unchanged. Streams are re-derived whenever a configuration is armed, so re-arming resets them exactly as it resets each rule's fired count.
The stream is keyed by a rule's index, so reordering rules does change their outcomes. That is deliberate — two rules with identical matchers are legitimate and useful, and keying by the matchers instead would make them share a stream and would make editing a path suffix silently reshuffle results. Prefer times for a bounded outcome regardless: it needs no roll at all.