Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 28 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -386,6 +386,34 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
and the nine uppercase alphanumeric characters substrate mints. Reading AWS's example is not the
borrowing-a-bound-from-a-sibling #671 rules out, and it is all the reference offers; the docs now say so
where they previously called the shape published.
- **The long tail of draw sites is derived** (#856). Four generators across four unrelated services
move onto `IDMint`: an SSM Run Command `CommandId`, the identifier inside a RAM `resourceShareArn`,
AWS Backup's `BackupPlanId`, `VersionId` and `SelectionId`, and a Bedrock batch-inference job ID.
Three of the four keep the exact rendering their `crypto/rand` version produced, because their pages
publish no pattern to follow (#671). Backup is the only draw site in the tree that mints **twice in
one request** — `CreateBackupPlan` answers a plan ID and a version ID, and `UpdateBackupPlan` mints a
third — so the mint's counter is what keeps a plan from being reported as its own version. Every one
of the four identifiers is addressed by a later call, so an underived one was refused rather than
merely reported differently: reverting the Backup minter alone to confirm the tier's replay assertion
is not vacuous produces 14 differences and 3 refused reads out of a 10-request stream, because a
selection is stored *under* its plan ID and a re-minted plan strands it too. 2 draw sites remain on
`crypto/rand`: `randomHex`, whose one caller is the CloudFormation deployer, and `IDMint`'s own
seedless fallback, which goes when the deployer threads a request ID through.
- **A Bedrock batch-inference job ID is twelve characters, not a UUID** (#856). `jobArn` is published as
`arn:aws:bedrock:{region}:{account}:model-invocation-job/[a-z0-9]{12}` and `jobIdentifier` as that ARN
or a bare `[a-z0-9]{12}` — an alphabet that **excludes the hyphen** and a length fixed at twelve, and
the 36-character UUID substrate minted satisfied neither, so a consumer validating the ARN it was
handed, or handing the bare ID back to `GetModelInvocationJob`, was validating against a value AWS
would never issue. This is the one place in #856 where the rendering changes: the
no-byte-changes rule protects a rendering the model permits and cannot protect one the model forbids.
`GetModelInvocationJob`'s own sample request shows `BATCHJOB1234`, which the pattern beside it would
reject; the pattern is the model.
- **An AWS Backup plan ARN's case is recorded as an observed difference** (#856). AWS's sample is
`arn:aws:backup:us-east-1:123456789012:plan:8F81F553-3A74-4A3F-B93D-B3360DC80C50`, an **uppercase**
UUID where substrate renders lowercase. `BackupPlanId` publishes no pattern and no length, so this is
observed from the example rather than required; it is documented beside the `backup-plan` vs. `plan`
segment gap (#1181) rather than folded into the derivation, since it is a rendering question and not
one about where the bytes come from.
- **A stream recorded under a seed replays under the same seed** (#1140). Every seedable outcome in
substrate is written through a control-plane endpoint, and only the AWS path recorded anything — so
a seed never entered the event stream. A replay opens by resetting the whole `StateManager`, and a
Expand Down
35 changes: 32 additions & 3 deletions docs/services.md
Original file line number Diff line number Diff line change
Expand Up @@ -2210,7 +2210,8 @@ Three kinds of value stay random, and one more is still migrating:
CloudWatch Logs, CloudFront, Service Quotas, API Gateway (v1 and v2), AppSync, Batch, EMR
Serverless, ECR, ELB, Route 53, Cognito (both the user-pool and the identity-pool API), IAM
Identity Center, KMS, ACM, Secrets Manager, WAFv2, Athena, Redshift Data, Glue, Timestream,
OpenSearch, QuickSight, CodeBuild, CodeDeploy and CodePipeline identifiers are derived today. A
OpenSearch, QuickSight, CodeBuild, CodeDeploy, CodePipeline, Systems Manager Run Command, RAM,
AWS Backup and Bedrock batch inference identifiers are derived today. A
CloudFront
distribution, invalidation and origin access control all draw from one generator, so the three
moved together with the origin access control family (#1277). The remaining services are
Expand Down Expand Up @@ -2295,6 +2296,28 @@ CodeBuild's, which is worse than a refusal — `BatchGetBuilds` reports an unkno
`buildsNotFound` in a **200**, so an underived one stalled a consumer's poll loop instead of
failing it.

**A published pattern outranks the rendering a draw site had.** Everywhere else in #856 the
identifier a caller sees is byte-for-byte what substrate published before it was derived, because the
point of the change is where the bytes come from and not what they look like. Bedrock's batch-inference
job ID is the one exception, and the API model forces it: `CreateModelInvocationJob` publishes `jobArn`
as `arn:aws:bedrock:{region}:{account}:model-invocation-job/[a-z0-9]{12}` and `GetModelInvocationJob`
publishes `jobIdentifier` as that ARN or a bare `[a-z0-9]{12}`, which **excludes the hyphen** and fixes
the length at twelve — and the 36-character UUID substrate minted satisfied neither. The
no-byte-changes rule protects a rendering the model permits; it cannot protect one the model forbids,
so a job ID is now twelve characters of the published alphabet. A consumer that validates the ARN it
was handed, or hands the bare ID back to `GetModelInvocationJob`, is what the pattern exists for.
(`GetModelInvocationJob`'s own sample request shows `BATCHJOB1234`, which the pattern beside it would
reject. The pattern is the model.)

The other three services in that family publish no pattern to follow. An SSM `CommandId` publishes a
**fixed length of 36** and no pattern, which the UUID shape already met; a RAM `resourceShareArn` and
an AWS Backup `BackupPlanId`, `VersionId` and `SelectionId` publish neither, so all four keep the
rendering their draw sites produced (#671). Backup's is the only site in the tree that mints **twice
in one request** — `CreateBackupPlan` answers a plan ID and a version ID, and `UpdateBackupPlan` mints
a third — so the counter is what keeps a plan from being its own version. AWS's own sample plan ARN
shows the UUID in *upper* case where substrate renders lower; that is an observed difference the model
does not require, tracked with the [resource segment](#arn-shapes) rather than with the derivation.

An ECR image digest is minted rather than computed from the manifest, so it is reproducible across
a replay but is not the SHA-256 of the image it names, and two pushes of identical manifest bytes
store two images where AWS stores one. [#1283](https://github.com/scttfrdmn/substrate/issues/1283)
Expand Down Expand Up @@ -12928,7 +12951,7 @@ Secrets Manager API calls: $0.05 per 10,000 API calls.
| 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 |
| SendCommand | Run Command; records the intent — substrate does not execute the command. The `CommandId` is the published fixed length of 36, [derived from the request ID](#derived-identifiers) ([#856](https://github.com/scttfrdmn/substrate/issues/856)) |
| GetCommandInvocation | |
| DescribeInstanceInformation | |

Expand Down Expand Up @@ -19506,6 +19529,12 @@ vault matches; its plan does not, in the API handler and in the CloudFormation d
IAM policy or an ARN parser written against Substrate's plan ARN matches nothing on AWS
([#1181](https://github.com/scttfrdmn/substrate/issues/1181)).

The published example differs in one more way the table does not show: AWS's sample is
`arn:aws:backup:us-east-1:123456789012:plan:8F81F553-3A74-4A3F-B93D-B3360DC80C50`, an **uppercase**
UUID, where Substrate renders lowercase. `BackupPlanId` publishes no pattern and no length, so this is
observed from the example rather than required by the model, and it is a rendering question rather than
a derivation one — the plan ID is [derived from the request ID](#derived-identifiers) either way.

### Cost

`CreateBackupPlan` is attributed $0.000001 per call. Real AWS Backup charges for protected storage
Expand Down Expand Up @@ -19538,7 +19567,7 @@ running.
|-----------|-------|
| InvokeModel | `POST /model/{modelId}/invoke`. Answers a [seeded response body](#seeding-a-model-response) 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](#invokemodel-reads-nothing-but-the-model-id) |
| ApplyGuardrail | `POST /guardrail/{guardrailIdentifier}/version/{guardrailVersion}/apply`. [`NONE` or `GUARDRAIL_INTERVENED`, decided by a blocklist](#how-a-guardrail-decides); 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 |
| 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. The job ID is twelve characters of `[a-z0-9]`, the published `jobArn` pattern, [derived from the request ID](#derived-identifiers) ([#856](https://github.com/scttfrdmn/substrate/issues/856)) |
| GetModelInvocationJob | `GET /model-invocation-job/{jobIdentifier}`. Returns the stored record whole, so `accountID` and `region` reach the wire ([#756](https://github.com/scttfrdmn/substrate/issues/756)), and reports a [seeded status](#seeding-a-batch-job-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`](#stopping-a-batch-job-is-immediate) |
Expand Down
5 changes: 3 additions & 2 deletions docs/testing-guide.md
Original file line number Diff line number Diff line change
Expand Up @@ -339,8 +339,9 @@ Two caveats. **Not every service's identifiers are derived yet.** EC2, IAM, STS,
Lambda, EFS, FSx, Transfer, ECS, Step Functions, EventBridge, CloudWatch Logs, CloudFront,
Service Quotas, API Gateway (v1 and v2), AppSync, Batch, EMR Serverless, ECR, ELB, Route 53,
Cognito (both APIs), IAM Identity Center, KMS, ACM, Secrets Manager, WAFv2, Athena,
Redshift Data, Glue, Timestream, OpenSearch, QuickSight, CodeBuild, CodeDeploy and
CodePipeline are; the rest are migrating one family at a time, and until a service moves, a
Redshift Data, Glue, Timestream, OpenSearch, QuickSight, CodeBuild, CodeDeploy,
CodePipeline, SSM Run Command, RAM, AWS Backup and Bedrock batch inference are; the rest are
migrating one family at a time, and until a service moves, a
replay of a stream creating one of its resources still diverges. And a recording made against an
**unfrozen** clock can still diverge on a `state_hash_after` even when every identifier
matches, because a handler reading the live clock stamps its record a few hundred
Expand Down
37 changes: 22 additions & 15 deletions emulator/backup_plugin.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,6 @@ package emulator

import (
"context"
"crypto/rand"
"encoding/hex"
"encoding/json"
"fmt"
"net/http"
Expand Down Expand Up @@ -177,8 +175,8 @@ func (p *BackupPlugin) createBackupPlan(reqCtx *RequestContext, req *AWSRequest)
return nil, &AWSError{Code: "InvalidRequestException", Message: "BackupPlanName is required", HTTPStatus: http.StatusBadRequest}
}

planID := generateBackupUUID()
versionID := generateBackupUUID()
planID := generateBackupUUID(reqCtx.IDs)
versionID := generateBackupUUID(reqCtx.IDs)
now := p.tc.Now()

plan := BackupPlan{
Expand Down Expand Up @@ -252,7 +250,7 @@ func (p *BackupPlugin) updateBackupPlan(reqCtx *RequestContext, req *AWSRequest,
if input.BackupPlan.Rules != nil {
plan.Rules = input.BackupPlan.Rules
}
plan.VersionID = generateBackupUUID()
plan.VersionID = generateBackupUUID(reqCtx.IDs)

goCtx := context.Background()
data, err := json.Marshal(plan)
Expand Down Expand Up @@ -331,7 +329,7 @@ func (p *BackupPlugin) createBackupSelection(reqCtx *RequestContext, req *AWSReq
return nil, &AWSError{Code: "InvalidRequestException", Message: "SelectionName is required", HTTPStatus: http.StatusBadRequest}
}

selectionID := generateBackupUUID()
selectionID := generateBackupUUID(reqCtx.IDs)
now := p.tc.Now()
selection := BackupSelection{
SelectionID: selectionID,
Expand Down Expand Up @@ -455,15 +453,24 @@ func (p *BackupPlugin) loadSelection(acct, region, planID, selectionID string) (
return &selection, nil
}

// generateBackupUUID generates a UUID-style string for backup resource IDs.
func generateBackupUUID() string {
b := make([]byte, 16)
_, _ = rand.Read(b)
return hex.EncodeToString(b[0:4]) + "-" +
hex.EncodeToString(b[4:6]) + "-" +
hex.EncodeToString(b[6:8]) + "-" +
hex.EncodeToString(b[8:10]) + "-" +
hex.EncodeToString(b[10:16])
// generateBackupUUID mints one of AWS Backup's three identifiers from m — a backup-plan ID, a plan
// version ID or a selection ID — derived from the request id so a replayed create reports what the
// recording reported (#856).
//
// It is the tier's one generator with more than one call site, and the only one that draws *twice in
// one request*: `CreateBackupPlan` mints a plan ID and a version ID, so the ordinal is what keeps
// them apart. Both are addressed — every later `GetBackupPlan`, `UpdateBackupPlan`,
// `DeleteBackupPlan` and `CreateBackupSelection` takes the plan ID in its URL path — and a plan
// version is how Backup reports which revision a read answered from.
//
// Nothing about the shape is published: `BackupPlanId` is a String with no pattern and no length,
// and `VersionId` is documented only as "unique, randomly generated … at most 1,024 bytes long". So
// the rendering is the one the crypto/rand form produced, [IDMint.HexUUID] (#671). AWS's own sample
// ARN — `arn:aws:backup:us-east-1:123456789012:plan:8F81F553-3A74-4A3F-B93D-B3360DC80C50` — shows
// the UUID in *upper* case where substrate renders lower; that is an observed difference the model
// does not require, and changing it is a rendering change rather than part of deriving the value.
func generateBackupUUID(m *IDMint) string {
return m.HexUUID()
}

// backupJSONResponse serializes v to JSON and returns an AWSResponse with
Expand Down
28 changes: 21 additions & 7 deletions emulator/bedrock_runtime_plugin.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,6 @@ package emulator

import (
"context"
"crypto/rand"
"encoding/json"
"fmt"
"net/http"
Expand Down Expand Up @@ -311,11 +310,26 @@ func bedrockModelInvocationJobIDsKey(acct, region string) string {
return "job_ids:" + acct + "/" + region
}

// generateBedrockJobID generates a UUID-formatted model invocation job ID.
func generateBedrockJobID() string {
b := make([]byte, 16)
_, _ = rand.Read(b)
return fmt.Sprintf("%x-%x-%x-%x-%x", b[0:4], b[4:6], b[6:8], b[8:10], b[10:16])
// bedrockJobIDChars is the alphabet Bedrock publishes a batch-inference job ID in: `[a-z0-9]`, from
// the `jobArn` pattern on API_CreateModelInvocationJob and API_GetModelInvocationJob.
const bedrockJobIDChars = "abcdefghijklmnopqrstuvwxyz0123456789"

// generateBedrockJobID mints a model-invocation job ID from m, derived from the request id so a
// replayed CreateModelInvocationJob reports the ARN the recording reported (#856).
//
// This is the tier's one *shape* change, and it is a fidelity fix rather than a consequence of
// deriving the value. Bedrock publishes `jobArn` as
// `arn:aws…:model-invocation-job/[a-z0-9]{12}` and `jobIdentifier` as that ARN or a bare
// `[a-z0-9]{12}`, so the published alphabet **excludes the hyphen** and the length is twelve — and
// the UUID substrate minted here satisfied neither. #856's rule is not to change the bytes a caller
// sees, but that rule exists to protect a rendering the API model permits; it cannot preserve one
// the model forbids. Twelve characters over the whole published set is what a caller validating the
// ARN, or handing the bare ID back to `GetModelInvocationJob`, can accept.
//
// The `GetModelInvocationJob` sample request shows `BATCHJOB1234`, which the pattern it sits beside
// would reject. The pattern is the model, so the rendering follows the pattern.
func generateBedrockJobID(m *IDMint) string {
return m.Chars(12, bedrockJobIDChars)
}

// createModelInvocationJob handles CreateModelInvocationJob, storing a new batch
Expand All @@ -335,7 +349,7 @@ func (p *BedrockRuntimePlugin) createModelInvocationJob(ctx *RequestContext, req
return nil, bedrockValidationError("jobName is required")
}

jobID := generateBedrockJobID()
jobID := generateBedrockJobID(ctx.IDs)
jobArn := fmt.Sprintf("arn:aws:bedrock:%s:%s:model-invocation-job/%s", ctx.Region, ctx.AccountID, jobID)
job := BedrockModelInvocationJob{
JobArn: jobArn,
Expand Down
2 changes: 1 addition & 1 deletion emulator/ec2_types.go
Original file line number Diff line number Diff line change
Expand Up @@ -495,7 +495,7 @@ func generateAssociationID(m *IDMint) string {
// flag day: a caller moves by taking a mint and calling [IDMint.Hex] with the same width.
// EC2's own ids no longer come through here.
//
// TODO(#856): 6 draw sites remain on crypto/rand, tiered by service family on the issue;
// TODO(#856): 2 draw sites remain on crypto/rand — this one and [IDMint.bytes]'s own fallback;
// delete this function when the last caller moves.
func randomHex(n int) string {
b := make([]byte, n)
Expand Down
12 changes: 8 additions & 4 deletions emulator/ids.go
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,9 @@ import (
// already derived from its inputs: a public IP from its instance id, a secret's ARN from its
// name, CloudFormation's stack UUIDs from account and region.
//
// TODO(#856): 6 draw sites remain on crypto/rand, tiered by service family on the issue.
// TODO(#856): 2 draw sites remain on crypto/rand — randomHex, whose one caller is the
// CloudFormation deployer, and bytes's own seedless fallback, which goes when the deployer
// threads a request id through.

// IDMint mints the identifiers one request publishes, derived from that request's own id so
// that replaying the request mints the same ones.
Expand Down Expand Up @@ -203,9 +205,11 @@ func (m *IDMint) Base64URL(n int) string {
//
// The callers are Lambda revision and code ids, ECS task ids, Step Functions execution names,
// SQS message ids, EventBridge event ids, CloudWatch Logs upload sequence tokens, Service Quotas
// request ids, Batch job ids and EMR Serverless job-run ids. Until this method existed the first
// seven of those reached a helper declared in lambda_plugin.go, which is how a Batch job id came
// to be minted by a function named for a Lambda revision.
// request ids, Batch job ids, EMR Serverless job-run ids, CodeBuild build and CodeDeploy
// application and deployment-group ids, SSM command ids, RAM resource-share ids and AWS Backup
// plan, version and selection ids. Until this method existed the first seven of those reached a
// helper declared in lambda_plugin.go, which is how a Batch job id came to be minted by a
// function named for a Lambda revision.
func (m *IDMint) HexUUID() string {
h := m.Hex(16)
return h[0:8] + "-" + h[8:12] + "-" + h[12:16] + "-" + h[16:20] + "-" + h[20:32]
Expand Down
Loading
Loading