Contributing a Service Plugin
This guide walks through adding a new AWS service plugin to Substrate and the conventions your change must follow. It assumes you have read Scope & Philosophy — the scope test below is the first gate for any new behaviour.
For general project rules (Go style, changelog, release process) see CLAUDE.md. All non-trivial work starts with a GitHub issue tied to a milestone.
Before you write code: the scope test
Substrate models what is observable through an AWS API call, not what software inside a resource does. Before adding a behaviour, ask:
Is this observable through an API call, or is it resource-internal?
- In scope: request/response shapes, error codes, resource state and its transitions over the simulated clock, pagination, and seedable outcomes (errors, capacity failures, terminal job states, specific result sets).
- Out of scope: actually running the workload — executing user-data, running a Lambda's code, performing an inference or training job. Capture such inputs as recorded intent and expose a seedable completion signal instead.
If a feature needs to model box-internals, it belongs in a different tool or test tier, not in Substrate.
Anatomy of a plugin
A plugin implements the Plugin interface (emulator/types.go):
type Plugin interface {
Name() string // AWS service name for routing, e.g. "s3"
Initialize(ctx context.Context, config PluginConfig) error // wire up state, logger, time controller
HandleRequest(ctx *RequestContext, req *AWSRequest) (*AWSResponse, error)
Shutdown(ctx context.Context) error
}A complete, runnable minimal plugin lives in examples/custom_plugin — start there. The essentials:
type WeatherPlugin struct {
state emulator.StateManager
logger emulator.Logger
}
func (p *WeatherPlugin) Name() string { return "weather" }
func (p *WeatherPlugin) Initialize(_ context.Context, cfg emulator.PluginConfig) error {
p.state = cfg.State
p.logger = cfg.Logger
return nil
}
func (p *WeatherPlugin) HandleRequest(_ *emulator.RequestContext, req *emulator.AWSRequest) (*emulator.AWSResponse, error) {
switch req.Operation {
case "GetWeather":
return p.handleGetWeather(req)
default:
return nil, &emulator.AWSError{
Code: "UnsupportedOperation",
Message: fmt.Sprintf("operation %q is not supported", req.Operation),
HTTPStatus: http.StatusBadRequest,
}
}
}
func (p *WeatherPlugin) Shutdown(_ context.Context) error { return nil }If your plugin is time-dependent, pull the *TimeController from cfg.Options["time_controller"] in Initialize (see any built-in plugin such as ssm_plugin.go) and read the clock with p.tc.Now() — never time.Now(), which would break deterministic replay.
Conventions
Verify against the real AWS API
Request/response shapes, error codes, pagination, and IAM condition keys must be checked against the official AWS API reference for the service — not built-in knowledge. This is a hard rule (CLAUDE.md → AWS service emulation).
State keys
Persist state through StateManager under a namespace (usually the service name) with a stable, documented key shape, e.g. object:{bucket}/{key} for S3 or queue:{account}/{name} for SQS. Keep keys consistent across the plugin's handlers so ResetState and replay behave predictably.
Errors
Return *AWSError with the exact AWS Code and HTTP status for API-level errors. Wrap internal Go errors with context (fmt.Errorf("...: %w", err)) and never discard them.
Seedable outcomes (the deterministic-emulator pattern)
A new operation defaults to its nominal success path. Alternate outcomes (errors, capacity failures, terminal states, specific result sets) are exposed as seedable values a test sets via a control-plane endpoint, read at request time — never as nondeterministic behaviour. Follow the established pattern: POST/DELETE /v1/{service}/... keyed by an ID or the "*" wildcard. See ssm_control.go, spawn_control.go, Athena/SageMaker/Bedrock seeds for worked examples, and register the routes in server.go's buildRouter.
Registration + service reference
Register the plugin in RegisterDefaultPlugins (emulator/plugins.go). Then add a metadata entry (display name + protocol) in cmd/gen-service-reference and regenerate the reference:
make docs-referenceThe docs-reference-check CI job fails if a registered plugin has no metadata entry, so this is not optional.
Testing
- Tests live in
package emulator_test(emulator/{service}_plugin_test.go), are table-driven where practical, and must be race-safe. - No test may depend on network access, real AWS, or wall-clock time — drive time through the
TimeController/TestServer.AdvanceTime. - Use
StartTestServer(t)for integration-style tests over HTTP; it registers all plugins and cleans up automatically. See the Testing Guide. - Aim for >80% coverage.
make testruns the race detector;make coveragegenerates a report. - If your change diverges from real AWS behaviour in any way, document it (and the reason) so reviewers can see it.
Checklist before opening a PR
- [ ] There is a tracked issue for the work.
- [ ] Behaviour passes the scope test (API-observable, not resource-internal).
- [ ] Shapes/errors/pagination verified against the official AWS API docs.
- [ ] Alternate outcomes are seedable via a control-plane endpoint, not random.
- [ ] Plugin registered in
RegisterDefaultPluginsand metadata added tocmd/gen-service-reference;make docs-referencerun. - [ ] Tests added (table-driven, race-safe, no wall-clock/network), coverage maintained.
- [ ]
make testandmake lintpass. - [ ]
CHANGELOG.mdupdated under## [Unreleased]. - [ ] Every exported symbol has a doc comment ending in a period.