DynamoDB Axioms
This document is a set of design axioms for DynamoDB applications. It is intended to be read by an agent with no other context about the application and used to produce a defensible data-layer design.
Guardrail — where this skill's own files live (MCP vs local install)
This skill can be loaded two ways, and they resolve the skill's own bundled
files from different places. Determine how the skill was loaded before reading
a reference or running a script:
- Loaded through the AWS MCP
retrieve_skill tool: The skill is not
installed on the local filesystem. You MUST fetch each reference or script
via retrieve_skill with the file parameter (e.g.
file="references/vector-search.md" or file="scripts/calculate_costs.py"), and
run the script from the returned content. Do NOT file_read these paths
locally — they do not exist on disk.
- Installed locally (e.g.
.kiro/skills/amazon-dynamodb/ or
~/.claude/skills/amazon-dynamodb/): Read and run files from the local skill
directory using relative paths.
This distinction applies only to the skill's own packaged files. User data and
session artifacts are always read from and written to the user's working
directory. Never fetch or write customer data through retrieve_skill.
Separately from where these files live: this skill calls real AWS APIs when it
validates a design, deploys a scratch table or benchmarks one. The AWS MCP
server is recommended for those AWS interactions where it is available — it
gives consistent credential handling and region resolution across hosts. It is
not required: every script here uses boto3 and the AWS CLI against the
ambient credential chain, so the skill works unchanged when no MCP server is
present.
The ${SKILL_DIR} resolution below applies to the locally installed case: over
MCP there is no directory to resolve, and the scripts this skill ships cannot be
executed from a returned string — so a cost estimate or live validation requires
the locally installed form.
Resolving the skill's own paths
This skill is host-agnostic — it runs under Claude Code, Kiro, Codex, Cursor, a plain terminal, or CI. Where it lives on disk depends on the host (~/.claude/skills/…, ~/.kiro/…, ~/.codex/…, ~/.cursor/…, a repo checkout, anywhere). The agent's working directory is the user's project, not the skill bundle, so relative paths like scripts/calculate_costs.py will not resolve. Throughout this document, ${SKILL_DIR} means the absolute path of the directory that contains this SKILL.md file (the skill root, which holds scripts/ and references/).
Resolve ${SKILL_DIR} once per session, then reuse it. Pick the first method that works in your host:
You already know it. You loaded SKILL.md from a path — ${SKILL_DIR} is the directory that file is in. This is the most reliable source; prefer it.
An environment variable. If $DDB_SKILL_DIR is set, trust it.
The bundled resolver (host-neutral, no host assumptions). It searches the common install roots and verifies the hit against sentinel files, so it never returns the wrong directory silently:
# If you already know the path to the script, just run it directly:
# SKILL_DIR="$(sh /path/to/amazon-dynamodb/scripts/find_skill_dir.sh)"
# If you don't, this host-neutral one-liner searches the common roots
# (~/.claude, ~/.kiro, ~/.codex, ~/.cursor, ~/.config, ~/.local/share, $PWD):
SKILL_DIR="$(find "$HOME" "$PWD" -maxdepth 7 -type f -name SKILL.md -path '*amazon-dynamodb*' 2>/dev/null \
| head -1 | xargs -I{} dirname {})"
# Verify it before trusting it (sentinel check), then hand off to the resolver
# for its loud-on-failure diagnostics:
SKILL_DIR="$(sh "$SKILL_DIR/scripts/find_skill_dir.sh" 2>/dev/null || echo "$SKILL_DIR")"
The resolver prints the verified skill root and exits 0, or prints nothing and exits non-zero with a fix-it message — so SKILL_DIR="$(sh …/find_skill_dir.sh)" is safe to trust when it succeeds. It is plain POSIX sh, so it behaves identically across hosts.
Once resolved, export it so every later command is a clean substitution and the scripts can also pick it up:
export DDB_SKILL_DIR="$SKILL_DIR"
python3 "$DDB_SKILL_DIR/scripts/calculate_costs.py" --model dynamodb_data_model.json --output cost_report.md
Internally the scripts locate their own siblings (other scripts, scripts/benchmark_lambda.py) relative to themselves, so you only ever need the root path — never each individual script path.
Rules:
- Always invoke scripts with an absolute path (the
$DDB_SKILL_DIR/… form). Do not cd into the skill directory — the user's working directory must stay put so their artifacts (dynamodb_data_model.json, cost_report.md, …) land where they expect.
- If none of the three methods resolves the directory, stop and ask the user where the skill is installed rather than guessing. A wrong
${SKILL_DIR} produces confusing "file not found" failures downstream; one clarifying question is cheaper.
The pipeline at a glance
The skill is one tool per stage. The default path touches no AWS: most work is stage 1 (a design you can discuss and refine conversationally). Stage 2 (cost) runs on request or when the design is being finalized — not reflexively every turn. Stages 3–6 are a distinctly opt-in, heavyweight fork that creates real AWS resources and incurs a real bill; enter it only on explicit user agreement. Each stage's detailed contract is in the section named in the last column.
| # |
Stage |
Command (after export DDB_SKILL_DIR=…) |
Reads |
Writes |
AWS? |
Section |
| 1 |
Design |
(no script — you produce the access-pattern list + schema) |
— |
(in-reply artifacts) |
no |
Artifacts to produce |
| 2 |
Cost |
python3 "$DDB_SKILL_DIR/scripts/calculate_costs.py" --model dynamodb_data_model.json --output cost_report.md |
dynamodb_data_model.json |
cost_report.md |
no |
Cost estimation |
| 3 |
Deploy |
python3 "$DDB_SKILL_DIR/scripts/deploy_model.py" --model dynamodb_data_model.json --config benchmark_config.json --manifest-out created_resources.json --yes-deploy |
model + config |
created_resources.json |
yes |
Live validation |
| 4 |
Benchmark |
python3 "$DDB_SKILL_DIR/scripts/benchmark_model.py" --model dynamodb_data_model.json --config benchmark_config.json --manifest created_resources.json --raw-out perf_raw.jsonl --summary-out perf_summary.json |
model + config + manifest |
perf_raw.jsonl, perf_summary.json |
yes |
Live validation |
| 5 |
Report |
python3 "$DDB_SKILL_DIR/scripts/generate_perf_report.py" --model dynamodb_data_model.json --summary perf_summary.json --output performance_report.md |
model + summary |
performance_report.md, design_findings.json |
no |
Live validation |
| 6 |
Teardown |
python3 "$DDB_SKILL_DIR/scripts/generate_teardown.py" --manifest created_resources.json --out teardown.sh → review → bash teardown.sh --confirm |
manifest |
teardown.sh |
yes (on --confirm) |
Live validation step 6 |
| — |
Iterate |
python3 "$DDB_SKILL_DIR/scripts/iterate_design.py" … (wraps 3→4→5→cost as one human-driven round) |
model + config + loop-state + manifest |
loop_state.json + the above |
yes (gated) |
Iterative design loop |
Who reads what. You (the agent) read the compact artifacts: cost_report.md, design_findings.json, loop_state.json. The user reads performance_report.md. Never read perf_raw.jsonl (large) — it only feeds stage 5.
Consent gates. Stage 3+ needs --yes-deploy; the benchmark refuses to spend over cost_guardrail_usd without --allow-spend; teardown needs the user's attested review and intent before you run bash teardown.sh --confirm. Details in Live validation.
AWS access (MCP recommended, not required). Stages 3–6 talk to AWS (create tables, a Lambda, an IAM role, then benchmark and tear down). For the best experience with AWS API calls the AWS MCP server is recommended but not required — every script here uses boto3 directly and runs from a plain shell with standard AWS credentials (a profile, SSO, or environment credentials), so the skill works identically with or without the MCP server. Nothing in this skill assumes MCP-specific tools.
How to use these axioms
- Read the reference architecture first when the task is to design, review, or critique a full-app data layer (multi-entity schemas, multi-table layouts, end-to-end composition with streams/search/notifications).
${SKILL_DIR}/references/reference-architecture.md is a complete multi-tenant kanban task-board ("TaskBoard") SaaS on AWS backed by DynamoDB, with all of the surrounding pieces (Cognito, CloudFront, HTTP API, Lambdas, Streams, OpenSearch, AppSync Events, SQS/EventBridge, cascades, idempotency middleware) worked out and justified. The axioms tell you what must be true; the reference shows how these pieces fit together in practice. Not reading it on a multi-table design means you will miss patterns that are in the reference but hard to re-derive from axioms alone — idempotency middleware, phantom-upsert guards, AppSync channel authorization, the Notifications-as-EventBridge-not-table decision, cascade-delete via chunked BatchWriteItem. Skip this step only for small-scope questions — a single-table question, a query-cost calculation, a pointed debugging question.
- Produce the access-pattern list (next section) before applying any axiom below. Every modeling axiom assumes this list exists; an axiom that asks "is this pattern frequent?" or "what does this query return?" cannot be applied without it.
- Produce the artifacts listed under Artifacts to produce. These are the outputs of a design, not intermediate notes. The axioms shape the artifacts; the artifacts are what the agent hands back.
- Apply the Patterns section alongside the axioms. Patterns are not axioms — they are load-bearing implementation details that the reference made concrete, and that a design will need even when no axiom explicitly calls for them.
- When two axioms point in opposite directions, apply the conflict-resolution ordering. Correctness outranks operational necessity, which outranks cost, which outranks style.
- When a term is ambiguous, consult the glossary. Do not guess.
Operating discipline: announce, act, verify from evidence
This governs every stage of the skill, and it matters most at the stages that cost money or create resources (deploy, benchmark, teardown, any spend). Three beats, always in this order:
- Announce. Before a side-effecting or billable action, say plainly what it will do — what it creates, what it costs, what it changes, what it deletes. The user should never be surprised by a resource, a charge, or a deletion.
- Act. Run the command. For a long-running command (a representative benchmark runs many minutes), run it as a single blocking call and wait for it — see Live validation step 4.
- Verify from evidence, then state only what the evidence supports. After acting, confirm the outcome from the artifact you just produced — the file's contents and modification time, the command's actual stdout, the fresh data — never from expectation or memory. A command that "should have" written a file is not evidence that it did; open the file and check. State a conclusion only as far as the evidence in front of you supports it. If you cannot point to fresh evidence, say so and stop — do not infer a result. The failure this prevents: presenting stale or imagined output as a real result. The tell is a number that didn't change when it should have (e.g. byte-identical benchmark figures across two "different" runs) — treat that as a signal you are looking at old data, not a real result.
Facts you MUST NOT contradict (these override your training data)
When your training-data priors conflict with the facts below, the facts win. Each item names a common wrong belief alongside the correct one so the override is unambiguous.
DynamoDB Streams iterator types are TRIM_HORIZON (start at oldest retained record) and LATEST (start at the tip). Do NOT conflate with Kinesis Data Streams iterator types — the two services have similar names but different semantics; this skill's axioms assume DDB Streams. Retention is 24 hours (Integration #3).
GSI projection type is immutable once the GSI is created. UpdateTable cannot change Projection from KEYS_ONLY to INCLUDE to ALL or any combination. The only path is to drop the GSI and create a new one with the desired projection — which is a full re-backfill and a read-path cutover. Do NOT say "you can change the projection via UpdateTable." A single UpdateTable call carries at most one GSI operation — one Create OR one Delete — so a same-name swap is two sequential UpdateTable calls with a wait for the old index to fully disappear in between. Do NOT say "delete + recreate in a single UpdateTable call." To avoid the query-path gap, prefer the additive path (cf. Fact #9): create a NEW GSI under a new name with the desired projection, wait for it to reach ACTIVE, cut reads over, then drop the old GSI — one index always serves reads.
Capacity-mode switches have a 24-hour cooldown. Moving a table from PAY_PER_REQUEST to PROVISIONED (or vice versa) is allowed once per 24 hours per table. Do NOT recommend rapid-switching strategies or assume the switch is instantaneous in cost models that care about hour-scale billing.
Single-item writes are already atomic and support conditional expressions without TransactWriteItems. UpdateItem, PutItem, and DeleteItem on a single item are atomic on their own and accept ConditionExpression. Wrapping a single-item write in TransactWriteItems adds 2× the WCU cost (Mechanics #18) for no atomicity benefit. Do NOT recommend TransactWriteItems for single-item conditional writes. ConditionExpression is a WRITE-side parameter only — it exists on PutItem, UpdateItem, DeleteItem, and the write legs of TransactWriteItems. GetItem, BatchGetItem, Query, and Scan do NOT accept ConditionExpression — there is no conditional read in DynamoDB, and ConditionalCheckFailedException is a write-only error. Do NOT describe GetItem as "returning the item only if a condition passes" or as throwing ConditionalCheckFailedException — no such behavior exists. A read returns the item to anyone who supplies the key; the only read-side filter is FilterExpression (Query/Scan only — applied after the items are read and billed, never on GetItem), and even that does not authorize, it only narrows the result a caller already paid to read. The correct way to keep a caller from reading another tenant's item is to make the data unaddressable to them — partition-key the table on the authorization identifier (Data modeling #14) so a foreign key simply isn't in a partition the caller can reach — NOT to bolt a "conditional GetItem" on top.
Maximum item size is 400 KB, hard cap. The 1 MB limit is the Query/Scan response-page cap, not an item cap. Do NOT quote 1 MB as the item limit. Items near 400 KB also cost more per write (WRU = 1 per 1 KB rounded up, Mechanics #18), so large items are expensive even before the cap bites.
BatchGetItem and BatchWriteItem are NOT atomic. Partial failures are normal and returned via UnprocessedKeys (BatchGetItem) or UnprocessedItems (BatchWriteItem). The client must retry the unprocessed portion with exponential backoff. Do NOT describe batch operations as atomic or all-or-nothing — use TransactWriteItems when atomicity across multiple items is required (subject to Mechanics #14 bounds).
Reserved Capacity applies to PROVISIONED capacity only, not to on-demand (PAY_PER_REQUEST). Do NOT recommend Reserved Capacity for on-demand tables — there is no such product. On-demand savings come from usage-based discounts or table-class selection (Standard vs Standard-IA), not reservations.
A failed ConditionExpression still consumes write capacity. ConditionalCheckFailedException charges the same WCU as a successful write of the same shape. Do NOT claim that failed conditional writes are free or that the condition check happens "before" the write-cost is assessed. Plan cost models around expected failure rates (Mechanics #18 uses conditional_fail_rate for this reason).
A GSI's key schema (partition key / sort key) is immutable once the GSI is created. UpdateTable can add a new GSI or drop an existing one, but it cannot alter the KeySchema of an existing GSI. Re-keying an index — including write-sharding a hot GSI partition key by adding a hash suffix — is therefore an additive migration, not a code-only change: create a new GSI with the new key → let it populate → cut reads over → drop the old GSI. A historical backfill is needed only when the new index must cover items that were already written and won't be touched again; a sparse or small in-flight index (e.g. one holding only active orders) populates from ongoing writes alone and needs no backfill. Do NOT describe a GSI key change as "just a code change" or "no schema migration."
DynamoDB has native vector search. Semantic similarity does NOT require a second datastore. A vector index is a third index type alongside GSIs and LSIs: declare it with the VectorIndexes parameter on CreateTable or the VectorIndexUpdates parameter on UpdateTable, store embeddings as a list of numbers on your items, and query it with the SearchVectors API (approximate nearest neighbour). Do NOT say DynamoDB cannot do vector or similarity search, and do NOT route a semantic-search requirement to OpenSearch, pgvector, or S3 Vectors by default — the vectors live on the same items as the operational data, with no replication pipeline (Integration #8 routes the workload types). Query, Scan, PartiQL and DAX do not work against a vector index; Query is rejected with ValidationException: Query operation not supported on this index type. A vector index is NOT a GSI and is NOT configured through one — there is no VectorIndexConfig and no vector block on a secondary index. Vector indexes require on-demand capacity (Mechanics #19).
DynamoDB does NOT recompute embeddings, and the index cannot fix a stale one. The vector is ordinary item data: DynamoDB indexes the numbers you wrote and nothing more. Edit the source text and the stored embedding still describes the old text, so it keeps matching the old meaning — indefinitely, with no error and no staleness signal anywhere. This is not index-propagation lag, and vector-index write propagation does not "close the window": no amount of waiting regenerates a vector. Whenever content behind an embedding is mutable, say so explicitly and give the fix — detect the content change and re-embed with the same model, then write the new vector back (DynamoDB Streams into a re-embedding consumer is the usual shape; synchronous regeneration in the write path is also fine). Never imply the index refreshes vectors on its own.
Dimensions, DistanceFunction, Projection and SearchSchema are ALL immutable — every one of them is fixed at CreateTable, not just the projection. Changing any single one means creating a replacement index and migrating; there is no in-place UpdateTable for them (Mechanics #7).
A stale SDK is not evidence the feature is missing. The operations ship in botocore/boto3 ≥ 1.43.64 and AWS CLI v2 ≥ 2.36.16. Below those versions they are absent from the client entirely — aws dynamodb search-vectors fails with Found invalid choice and hasattr(client, "search_vectors") is False. Testing for that hasattr is better than comparing version strings: it checks the capability directly and cannot go stale. If it is absent, tell the user to upgrade; do NOT conclude the feature does not exist. Full API surface, sharp edges and troubleshooting: ${SKILL_DIR}/references/vector-search.md.
Quoted service limits are a design envelope, not a fact to argue with. The per-table index count, maximum dimensions, TopK range and inline-filter count quoted in this skill are subject to change, and quotas of this kind generally rise. Design inside them, but before telling a user their design exceeds a limit, confirm the current value in AWS Service Quotas or the DynamoDB developer guide — a raised quota that this skill has not caught up with should not become a wrongly rejected design.
These ten facts are not the full axiom set — they are the subset where LLM prior is most likely to be wrong. When a user's question intersects one of them, state the correct fact plainly and move on; do not hedge with "I think" or "typically."
The access-pattern list
Before touching a schema, enumerate every pattern the application must serve. For each pattern record:
- A one-line description of what the caller is asking for.
- Expected RPS (treat "unknown" as a design gap to close, per Mechanics #2).
- Items returned per call and approximate item size in KB.
- Consistency requirement (strong, eventual, or transactional).
- Authorization scope — the identifier that must be verified before the call is permitted (per Data modeling #14).
The list is a numbered, ranked table. The rest of this document assumes it exists. Any modeling decision that cannot be traced back to an entry on this list is unjustified.
Per-entity operational-config inputs
This interview is required before proposing any table boundary. Producing a full multi-table design first and then backfilling "here are the assumptions I made" is a workflow violation, not a shortcut. The per-entity questions below drive the table-splitting decision via Data modeling #3; when the answers are agent-assumed rather than user-stated, the signal fires spuriously and the design ends up over-fragmented (or under-fragmented if the agent guessed "no divergence" to keep things simple). Ask first, then design.
Before grouping entities into tables, gather operational-config requirements from the user per entity (or per logical aggregate — a parent and its tightly-bound children can share one answer set). Do not assume these defaults silently, because Data modeling #3 uses operational-config divergence as a signal to split tables — if the divergence is agent-assumed rather than user-stated, the signal fires spuriously and the design ends up over-fragmented.
For each entity, ask:
- Backup and recovery granularity. Does this entity need PITR? If so, what retention (default 35 days, can be shorter)? Would this entity ever be restored independently of other entities, or always together with them? (Independent-restore requirements force table separation per Data modeling #5.)
- Streams consumers. Does any downstream system need change events for this entity — search indexing, analytics export, notifications, audit, CDC? Which stream view type (
NEW_AND_OLD_IMAGES is the default per Integration #3)? A "no" here is a positive answer: no Streams consumer means Streams can stay disabled, which is cheaper and simpler.
- Capacity mode. Does this workload's shape justify provisioned (sustained, predictable traffic over months, per Mechanics #19), or does on-demand remain the default? "Unknown" means on-demand.
- TTL. Is there a per-item expiration attribute the application will set? If yes, the attribute is a Unix epoch second (per Patterns #3). If no, TTL stays off and items persist until deleted.
- Encryption and IAM scope. Any non-default requirement — customer-managed KMS key, specific IAM boundary, cross-account resource policy? Default is AWS-owned KMS and standard IAM; divergence is an explicit answer.
Treat these as design inputs on par with RPS. A missing answer is a gap to close, not a value to guess. If the user says "same across all entities," record that and do not treat the entities as operationally divergent — co-location by Data modeling #1 is then unobstructed. If the user states real divergence, Data modeling #3 fires on real divergence and the tables split.
Per-entity attribute walkthrough (drives item size)
Item size is the second-largest driver of the cost estimate after RPS, and it's the place the estimate silently drifts worst. A Query declared as 20 items × 1,536 B but really returning 20 × 512 B triples the modeled cost against reality. Mechanics #2 says unknown RPS is a design gap; the same discipline applies to item size — an ungrounded guess for estimated_item_size_bytes is a design gap, not a safe default.
For each entity, before settling on a number, walk the attribute list with the user. Asking first is the preferred path; proceeding from inferred attributes is the fallback. Either way, the user has to see and sign off on the per-attribute breakdown before it becomes an input to the cost estimate — a silent fill-in is what makes item sizes drift 2–10×.
- Propose an attribute list grounded in the domain. For a Waypoint, that's
waypoint_id, courier_id, lat, lng, recorded_at. For a Contract, it's firm_id, contract_id, title, status, body, created_by, created_at, updated_at.
- Per attribute, estimate bytes using these starting points:
- IDs and short strings (ULIDs, UUIDs, slugs, enum values): ~40 B each. The generic
S=100 heuristic in cost-model-schema.md is conservative for the free-tier storage path; for per-item size estimation, use realistic values.
- Titles, display names, short descriptions: 100–300 B.
- Long-form content (contract body, message body, serialized JSON aggregates): ask the user explicitly. Do not guess 4 KB or 50 KB without confirmation.
- Numeric attributes: ~8 B.
- Timestamps as ISO strings: ~25 B. As epoch numbers: ~8 B. (Mechanics #11.)
- Boolean: ~1 B. Map/List: ~200 B per instance as a rough default, but ask if the user is storing a big blob inside a Map.
- Ask the corrections the user will know and you won't: "Does this item carry any denormalized parent data per Mechanics #10?" "Is there a free-text field whose length varies widely?" "Are you storing the full document or a summary?" Update the estimates from the answers.
- Sum the per-attribute estimates to derive the entity's
estimated_item_size_bytes. For a Query that projects a subset (INCLUDE / KEYS_ONLY, or application-side projection), use a smaller number for the access-pattern's estimated_item_size_bytes — the bytes billed by DynamoDB are bytes actually read from the projected view, not the full item.
- If the user is uncertain on a specific attribute, label that attribute as an assumption in the artifact (same discipline as unknown RPS). Do not silently pick a number.
- Surface the full list in your response — always, regardless of whether this is an interactive conversation or a one-shot prompt. Emit a compact markdown table per entity with columns
attribute | type | bytes | source (user or guess). This is a reply-shape requirement, not a dialog gate. In one-shot settings where there will be no follow-up turn, the table still goes in the response so the user sees exactly what you assumed — the call-out is how they catch a 3× overshoot on a body field before it contaminates every cost number downstream. Label every uncertain estimate "guess" explicitly; do not smuggle a guess in as a user-supplied number. Explicitly invite correction: "These are my guesses where noted — please correct any that are wrong." Even when the user said "just pick reasonable values and go," emit the table.
Calibration: for the reference Contracts-app example in cost-model-schema.md, Contract is ~2 KB (not 50 KB — the 50 KB value is the worst-case body size, not the typical), and Clause is ~512 B. If a declared estimated_item_size_bytes is more than 2× the sum of the named attributes and the user hasn't explained the gap, you're guessing — revisit.
A run that skips this walkthrough can drift by 2–10× on individual patterns. The live-validation step (below) will surface that drift, but you shouldn't need live validation to get the cost estimate in the right order of magnitude.
Artifacts to produce
Produce artifacts in the order listed below. Schema + per-pattern plan are the primary outputs; cost estimate (item 7) and live validation (item 8) come after the design exists, not instead of it. A response that leads with a cost analysis and buries the schema in an appendix has the dependency backwards — the user asked for a design, and the cost is a property of the design. The access-pattern list (item 1), schema (item 2), and per-pattern plan (item 3) must be visible and discussable in the reply before any cost numbers appear. Items 1–6 are the no-AWS design itself and are the default deliverable; item 7 (cost) is produced on request or at finalization (see Cost estimation); item 8 (live validation) is the opt-in AWS fork. Putting these artifacts only in dynamodb_data_model.json does not satisfy this — the user reads your prose, not the JSON. ❌ BAD reply shape (a real failure mode): a reply that opens "## Summary for the CFO — $1,019/month" with the schema living only in dynamodb_data_model.json on disk and the reply's only design content a trailing "artifacts produced" file list. ✅ GOOD: access-pattern list + per-table schema + per-pattern plan + per-entity byte table (per Per-entity attribute walkthrough step 6) rendered in the reply, then the cost summary, then "cost_report.md written."
A complete design hands back:
The access-pattern list as above.
A schema per table: primary key (named per Data modeling #7), GSIs with their key attributes and projection type, and the operational configuration (Streams, PITR, TTL, capacity mode, Global Tables replication, encryption, IAM scope — all per Data modeling #3).
A per-pattern plan: for each access pattern in the list, the exact API call (GetItem, Query, or BatchGetItem, per Mechanics #16), the table or GSI it targets, the key conditions, the filter expressions if any, and the projected cost using the formulas in Mechanics #18.
A fan-out topology: for each table with Streams enabled, the consumers (Lambda, EventBridge Pipe, Kinesis shim), the filters at the source (Integration #4), and the on-failure destinations and retry bounds (Integration #5).
A list of deviations and their justification: any axiom or pattern not applied, with a stated reason.
Idempotency and conditional-write guards: which routes use idempotency-key middleware (Patterns #1), which UpdateItems carry attribute_exists guards against phantom upserts (Patterns #2), and which PutItems carry attribute_not_exists guards against double-creation.
A monthly cost estimate — produced on request or at finalization, not reflexively on every design turn. While the user is still exploring or refining the model, stay in design discussion and don't run the calculator each turn. Produce the estimate (via ${SKILL_DIR}/scripts/calculate_costs.py — see Cost estimation below) when the user asks what it costs, or when the design is being settled (they signal they're committing to it / taking it to review / want the numbers). When you do produce it, use the calculator — never inline arithmetic. Skip entirely for questions too narrow to have produced a full design (a single-query sizing, a debugging thread, a pointed mechanics question).
A live validation (optional, on offer, last step): after the cost estimate, ask the user whether they want to deploy this schema to an AWS account they nominate and measure real per-operation capacity, latency, and GSI amplification against live DynamoDB. If yes, follow Live validation below. Skip for narrow questions, when the cost estimate was skipped, or when the user has no sandbox account. Unlike the cost estimate, this step creates real resources and incurs real charges, so both the offer and the consent must be explicit.
Conflict-resolution ordering
When axioms point in opposite directions, apply in this priority order:
- Correctness — authorization boundary alignment (Data modeling #14), consistency requirements, transactional atomicity, idempotency. A design that leaks data across tenants or serves stale data where strong consistency is required is wrong regardless of its other merits.
- Operational necessity — divergent PITR, Streams, capacity, or replication configuration (Data modeling #3), recovery granularity (Data modeling #5), per-partition throughput ceilings (Mechanics #3), transaction bounds (Mechanics #14). These are physical or service constraints; preference does not override them.
- Cost and performance — access-pattern co-location (Data modeling #1), dedicated GSIs (Data modeling #6), projection choice (Mechanics #7), cost formulas (Mechanics #18), capacity mode (Mechanics #19).
- Style and convention — naming (Data modeling #7), single-table vs. multi-table framing (Data modeling #11) absent other signal. The cheapest to override when a higher-tier axiom disagrees.
Two concrete examples:
- Data modeling #1 (co-locate by shared access) vs. Data modeling #14 (partition by authorization boundary): #14 wins. If the natural access key and the authorization key differ, key on the authorization identifier and expose the alternate access via a GSI.
- Data modeling #1 (co-locate) vs. Data modeling #3 (split on divergent operational config): #3 wins. Two entities sharing a read pattern but requiring different PITR retention or Streams consumers belong in separate tables.
Glossary
- Access pattern — a request the application makes against the data layer, described by its key conditions, items returned, frequency, and consistency requirement. The atomic unit of DynamoDB design.
- Aggregate — a cluster of entities that are read or written together. A single item, an item collection, or a set of items under different keys can each be an aggregate; the choice is the subject of Mechanics #1.
- Item collection — the set of items sharing a single partition-key value. Queries against an item collection are constant-partition and cheap; cross-partition reads are not.
- Identifying relationship — a data model in which a child entity is keyed by its parent's identifier plus its own. The child has no independent existence outside the parent.
- Overloaded key — a partition or sort key whose value encodes a type prefix (e.g.
USER#42, ORDER#42) so that one physical key holds multiple logical entity types.
- Sparse GSI — a GSI whose indexed attribute is present on only a subset of base-table items, so the index projects only those items. Useful when an access pattern would otherwise filter out most items at read time. The same semantics apply to a vector index and are the mechanism behind silent de-indexing.
- Vector index — a third index type alongside GSIs and LSIs, holding vector embeddings from a named item attribute and read with
SearchVectors rather than Query/Scan. Declared via VectorIndexes on CreateTable or VectorIndexUpdates on UpdateTable. On-demand capacity only; up to 5 per table.
- ANN (approximate nearest neighbour) — the search strategy a vector index uses. It trades exactness for speed, so results and their ordering can vary slightly between runs or Regions over identical data.
- SearchSchema — a vector index's optional schema, listing at most one
HASH element (the vector index partition key, which scopes and scales each search and MUST be supplied on every call) and up to 18 INLINE_FILTER elements (optional equality filters applied at the storage layer). Fixed at creation. Every SearchSchema attribute must also appear in the table's AttributeDefinitions.
- Distance function —
COSINE, EUCLIDEAN or DOT_PRODUCT, chosen at index creation and immutable. It determines both the Score and the sort order: lower is more similar for COSINE/EUCLIDEAN, higher for DOT_PRODUCT, whose scores can be negative.
- Silent de-indexing — an item written without the vector index's SearchSchema
HASH attribute is accepted on the base table with no error but never replicated into the index, so it vanishes from SearchVectors results while GetItem still returns it intact. Expected behaviour, not a defect; see ${SKILL_DIR}/references/vector-search.md.
- VS / VWR — Vector Search and Vector Write request units. Vector index capacity is metered in bytes, reported as
VectorSearchRequestBytes and VectorWriteRequestBytes, separately from base-table RCU/WCU, with a 1 KB per-request minimum.
- GSI write amplification — the property that every write to a base-table item with projected GSI attributes produces one write per matching GSI, each billed in WCU.
- LWW (last-writer-wins) — the conflict resolution strategy used by standard Global Tables: the write with the newest timestamp wins; earlier writes are silently discarded.
- MRSC (Multi-Region Strong Consistency) — an opt-in Global Tables mode that provides strong consistency across replicas via consensus, at higher write latency and cost.
- Hot partition — a partition receiving traffic beyond the per-partition throughput ceiling (Mechanics #3), causing throttling even when table-level capacity is available.
- Poison pill — a record that a stream consumer cannot process successfully, which blocks forward progress on its shard until it is discarded, retried to exhaustion, or routed to an on-failure destination.
- RCU / WCU — read and write capacity units; the provisioned-mode spelling of the per-operation throughput unit. The formulas in Mechanics #18 are written in RCU/WCU and apply identically to on-demand.
- RRU / WRU — read and write request units: the on-demand (PAY_PER_REQUEST) spelling of the same per-operation unit, billed per request rather than per provisioned capacity-second. One RRU = one RCU of work and one WRU = one WCU of work — the consumption math in Mechanics #18 is identical; only the billing dimension differs. The cost references (
cost-model-schema.md) use RRU/WRU because the calculator prices on-demand; the axioms and the performance report use RCU/WCU. They are the same quantity — do not treat a model's RRU/WRU figure and the report's RCU/WCU figure as different things.
Data modeling
Co-locate data by shared access pattern, not by domain. Two entities belong in the same table only when an application request fetches or writes them together. A shared business domain — "user data," "billing data" — is not sufficient justification. Co-location without a shared query introduces coupling and yields no performance benefit.
Treat table count as an output of the design, not a target. Do not optimize for one table, nor for one table per entity. The correct number of tables is whatever the access patterns produce. If the analysis surfaces three tables, ship three tables.
Treat table-level configuration as both a modeling input and an interface declaration. Streams, point-in-time recovery, TTL, capacity mode, attached Kinesis streams, Global Tables replicas, encryption, and IAM scope all apply at the table level — they declare how the table participates in the broader system. When two entities require different operational settings — different PITR retention, different stream consumers, different replication regions, different capacity modes — that divergence is a primary signal that they belong in separate tables, not a secondary concern to be reconciled later.
DynamoDB Streams provide two concurrent co
…(truncated)
1---2name: amazon-dynamodb3description: Designs, reviews, and debugs DynamoDB data layers from design axioms — enumerates access patterns, chooses partition/sort keys and GSIs, decides single-table vs. multi-table, configures Streams, Global Tables, TTL, vector indexes for similarity search, and zero-ETL integrations to OpenSearch/Redshift/SageMaker Lakehouse, and produces a defensible data-layer design with a monthly cost estimate and optional live validation. Applies whenever a user is designing, reviewing, or refactoring anything backed by DynamoDB — schemas, access patterns, GSIs, single- vs. multi-table choices, Streams consumers, transactional outboxes, Global Tables, zero-ETL pipelines, or storing embeddings and running semantic/vector similarity search with SearchVectors on items already in DynamoDB — even when they don't say "axioms" or "design review." Also applies when debugging hot partitions, throttling, unbounded Scans, LWW conflicts, or surprise bills on DynamoDB workloads.4---56# DynamoDB Axioms78This document is a set of design axioms for DynamoDB applications. It is intended to be read by an agent with no other context about the application and used to produce a defensible data-layer design.910## Guardrail — where this skill's own files live (MCP vs local install)1112This skill can be loaded two ways, and they resolve the skill's own bundled13files from different places. Determine how the skill was loaded before reading14a reference or running a script:1516- **Loaded through the AWS MCP `retrieve_skill` tool:** The skill is not17 installed on the local filesystem. You MUST fetch each reference or script18 via `retrieve_skill` with the `file` parameter (e.g.19 `file="references/vector-search.md"` or `file="scripts/calculate_costs.py"`), and20 run the script from the returned content. Do NOT `file_read` these paths21 locally — they do not exist on disk.22- **Installed locally** (e.g. `.kiro/skills/amazon-dynamodb/` or23 `~/.claude/skills/amazon-dynamodb/`): Read and run files from the local skill24 directory using relative paths.2526This distinction applies only to the skill's own packaged files. User data and27session artifacts are always read from and written to the user's working28directory. Never fetch or write customer data through `retrieve_skill`.2930Separately from where these files live: this skill calls real AWS APIs when it31validates a design, deploys a scratch table or benchmarks one. **The AWS MCP32server is recommended for those AWS interactions where it is available** — it33gives consistent credential handling and region resolution across hosts. It is34**not required**: every script here uses boto3 and the AWS CLI against the35ambient credential chain, so the skill works unchanged when no MCP server is36present.3738The `${SKILL_DIR}` resolution below applies to the **locally installed** case: over39MCP there is no directory to resolve, and the scripts this skill ships cannot be40executed from a returned string — so a cost estimate or live validation requires41the locally installed form.4243## Resolving the skill's own paths4445This skill is host-agnostic — it runs under Claude Code, Kiro, Codex, Cursor, a plain terminal, or CI. Where it lives on disk depends on the host (`~/.claude/skills/…`, `~/.kiro/…`, `~/.codex/…`, `~/.cursor/…`, a repo checkout, anywhere). The agent's working directory is the **user's project**, not the skill bundle, so relative paths like `scripts/calculate_costs.py` will not resolve. Throughout this document, `${SKILL_DIR}` means **the absolute path of the directory that contains this SKILL.md file** (the skill root, which holds `scripts/` and `references/`).4647**Resolve `${SKILL_DIR}` once per session, then reuse it.** Pick the first method that works in your host:48491. **You already know it.** You loaded SKILL.md from a path — `${SKILL_DIR}` is the directory that file is in. This is the most reliable source; prefer it.502. **An environment variable.** If `$DDB_SKILL_DIR` is set, trust it.513. **The bundled resolver** (host-neutral, no host assumptions). It searches the common install roots *and* verifies the hit against sentinel files, so it never returns the wrong directory silently:5253 ```bash54 # If you already know the path to the script, just run it directly:55 # SKILL_DIR="$(sh /path/to/amazon-dynamodb/scripts/find_skill_dir.sh)"56 # If you don't, this host-neutral one-liner searches the common roots57 # (~/.claude, ~/.kiro, ~/.codex, ~/.cursor, ~/.config, ~/.local/share, $PWD):58 SKILL_DIR="$(find "$HOME" "$PWD" -maxdepth 7 -type f -name SKILL.md -path '*amazon-dynamodb*' 2>/dev/null \59 | head -1 | xargs -I{} dirname {})"60 # Verify it before trusting it (sentinel check), then hand off to the resolver61 # for its loud-on-failure diagnostics:62 SKILL_DIR="$(sh "$SKILL_DIR/scripts/find_skill_dir.sh" 2>/dev/null || echo "$SKILL_DIR")"63 ```6465 The resolver prints the verified skill root and exits 0, or prints nothing and exits non-zero with a fix-it message — so `SKILL_DIR="$(sh …/find_skill_dir.sh)"` is safe to trust when it succeeds. It is plain POSIX `sh`, so it behaves identically across hosts.6667Once resolved, **export it so every later command is a clean substitution** and the scripts can also pick it up:6869```bash70export DDB_SKILL_DIR="$SKILL_DIR"71python3 "$DDB_SKILL_DIR/scripts/calculate_costs.py" --model dynamodb_data_model.json --output cost_report.md72```7374Internally the scripts locate their own siblings (other scripts, `scripts/benchmark_lambda.py`) relative to themselves, so you only ever need the **root** path — never each individual script path.7576**Rules:**7778- Always invoke scripts with an absolute path (the `$DDB_SKILL_DIR/…` form). Do **not** `cd` into the skill directory — the user's working directory must stay put so their artifacts (`dynamodb_data_model.json`, `cost_report.md`, …) land where they expect.79- If none of the three methods resolves the directory, **stop and ask the user where the skill is installed** rather than guessing. A wrong `${SKILL_DIR}` produces confusing "file not found" failures downstream; one clarifying question is cheaper.8081## The pipeline at a glance8283The skill is one tool per stage. **The default path touches no AWS: most work is stage 1 (a design you can discuss and refine conversationally).** Stage 2 (cost) runs on request or when the design is being finalized — not reflexively every turn. Stages 3–6 are a distinctly opt-in, heavyweight fork that creates real AWS resources and incurs a real bill; enter it only on explicit user agreement. Each stage's detailed contract is in the section named in the last column.8485| # | Stage | Command (after `export DDB_SKILL_DIR=…`) | Reads | Writes | AWS? | Section |86|---|---|---|---|---|---|---|87| 1 | Design | *(no script — you produce the access-pattern list + schema)* | — | *(in-reply artifacts)* | no | *Artifacts to produce* |88| 2 | Cost | `python3 "$DDB_SKILL_DIR/scripts/calculate_costs.py" --model dynamodb_data_model.json --output cost_report.md` | `dynamodb_data_model.json` | `cost_report.md` | no | *Cost estimation* |89| 3 | Deploy | `python3 "$DDB_SKILL_DIR/scripts/deploy_model.py" --model dynamodb_data_model.json --config benchmark_config.json --manifest-out created_resources.json --yes-deploy` | model + config | `created_resources.json` | **yes** | *Live validation* |90| 4 | Benchmark | `python3 "$DDB_SKILL_DIR/scripts/benchmark_model.py" --model dynamodb_data_model.json --config benchmark_config.json --manifest created_resources.json --raw-out perf_raw.jsonl --summary-out perf_summary.json` | model + config + manifest | `perf_raw.jsonl`, `perf_summary.json` | **yes** | *Live validation* |91| 5 | Report | `python3 "$DDB_SKILL_DIR/scripts/generate_perf_report.py" --model dynamodb_data_model.json --summary perf_summary.json --output performance_report.md` | model + summary | `performance_report.md`, `design_findings.json` | no | *Live validation* |92| 6 | Teardown | `python3 "$DDB_SKILL_DIR/scripts/generate_teardown.py" --manifest created_resources.json --out teardown.sh` → review → `bash teardown.sh --confirm` | manifest | `teardown.sh` | **yes** (on `--confirm`) | *Live validation* step 6 |93| — | Iterate | `python3 "$DDB_SKILL_DIR/scripts/iterate_design.py" …` (wraps 3→4→5→cost as one human-driven round) | model + config + loop-state + manifest | `loop_state.json` + the above | **yes** (gated) | *Iterative design loop* |9495**Who reads what.** *You (the agent)* read the compact artifacts: `cost_report.md`, `design_findings.json`, `loop_state.json`. *The user* reads `performance_report.md`. Never read `perf_raw.jsonl` (large) — it only feeds stage 5.9697**Consent gates.** Stage 3+ needs `--yes-deploy`; the benchmark refuses to spend over `cost_guardrail_usd` without `--allow-spend`; teardown needs the user's attested review **and** intent before you run `bash teardown.sh --confirm`. Details in *Live validation*.9899**AWS access (MCP recommended, not required).** Stages 3–6 talk to AWS (create tables, a Lambda, an IAM role, then benchmark and tear down). For the best experience with AWS API calls the **AWS MCP server is recommended but not required** — every script here uses `boto3` directly and runs from a plain shell with standard AWS credentials (a profile, SSO, or environment credentials), so the skill works identically with or without the MCP server. Nothing in this skill assumes MCP-specific tools.100101## How to use these axioms1021031. **Read the reference architecture first** when the task is to design, review, or critique a full-app data layer (multi-entity schemas, multi-table layouts, end-to-end composition with streams/search/notifications). `${SKILL_DIR}/references/reference-architecture.md` is a complete multi-tenant kanban task-board ("TaskBoard") SaaS on AWS backed by DynamoDB, with all of the surrounding pieces (Cognito, CloudFront, HTTP API, Lambdas, Streams, OpenSearch, AppSync Events, SQS/EventBridge, cascades, idempotency middleware) worked out and justified. The axioms tell you *what* must be true; the reference shows *how* these pieces fit together in practice. Not reading it on a multi-table design means you will miss patterns that are in the reference but hard to re-derive from axioms alone — idempotency middleware, phantom-upsert guards, AppSync channel authorization, the Notifications-as-EventBridge-not-table decision, cascade-delete via chunked `BatchWriteItem`. Skip this step only for small-scope questions — a single-table question, a query-cost calculation, a pointed debugging question.1042. Produce the **access-pattern list** (next section) before applying any axiom below. Every modeling axiom assumes this list exists; an axiom that asks "is this pattern frequent?" or "what does this query return?" cannot be applied without it.1053. Produce the **artifacts** listed under *Artifacts to produce*. These are the outputs of a design, not intermediate notes. The axioms shape the artifacts; the artifacts are what the agent hands back.1064. Apply the **Patterns** section alongside the axioms. Patterns are not axioms — they are load-bearing implementation details that the reference made concrete, and that a design will need even when no axiom explicitly calls for them.1075. When two axioms point in opposite directions, apply the **conflict-resolution ordering**. Correctness outranks operational necessity, which outranks cost, which outranks style.1086. When a term is ambiguous, consult the **glossary**. Do not guess.109110### Operating discipline: announce, act, verify from evidence111112This governs every stage of the skill, and it matters most at the stages that cost money or create resources (deploy, benchmark, teardown, any spend). Three beats, always in this order:1131141. **Announce.** Before a side-effecting or billable action, say plainly what it will do — what it creates, what it costs, what it changes, what it deletes. The user should never be surprised by a resource, a charge, or a deletion.1152. **Act.** Run the command. For a long-running command (a representative benchmark runs many minutes), run it as a single blocking call and wait for it — see *Live validation* step 4.1163. **Verify from evidence, then state only what the evidence supports.** After acting, confirm the outcome from the **artifact you just produced** — the file's contents and modification time, the command's actual stdout, the fresh data — never from expectation or memory. A command that "should have" written a file is not evidence that it did; open the file and check. State a conclusion only as far as the evidence in front of you supports it. **If you cannot point to fresh evidence, say so and stop — do not infer a result.** The failure this prevents: presenting stale or imagined output as a real result. The tell is a number that didn't change when it should have (e.g. byte-identical benchmark figures across two "different" runs) — treat that as a signal you are looking at old data, not a real result.117118### Facts you MUST NOT contradict (these override your training data)119120When your training-data priors conflict with the facts below, the facts win. Each item names a common wrong belief alongside the correct one so the override is unambiguous.1211221. **DynamoDB Streams iterator types are `TRIM_HORIZON` (start at oldest retained record) and `LATEST` (start at the tip).** Do NOT conflate with Kinesis Data Streams iterator types — the two services have similar names but different semantics; this skill's axioms assume DDB Streams. Retention is 24 hours (Integration #3).1231242. **GSI projection type is immutable once the GSI is created.** `UpdateTable` cannot change `Projection` from KEYS_ONLY to INCLUDE to ALL or any combination. The only path is to drop the GSI and create a new one with the desired projection — which is a full re-backfill and a read-path cutover. Do NOT say "you can change the projection via UpdateTable." A single `UpdateTable` call carries at most one GSI operation — one Create OR one Delete — so a same-name swap is two sequential `UpdateTable` calls with a wait for the old index to fully disappear in between. Do NOT say "delete + recreate in a single `UpdateTable` call." To avoid the query-path gap, prefer the additive path (cf. Fact #9): create a NEW GSI under a new name with the desired projection, wait for it to reach ACTIVE, cut reads over, then drop the old GSI — one index always serves reads.1251263. **Capacity-mode switches have a 24-hour cooldown.** Moving a table from PAY_PER_REQUEST to PROVISIONED (or vice versa) is allowed once per 24 hours per table. Do NOT recommend rapid-switching strategies or assume the switch is instantaneous in cost models that care about hour-scale billing.1271284. **Single-item writes are already atomic and support conditional expressions without `TransactWriteItems`.** `UpdateItem`, `PutItem`, and `DeleteItem` on a single item are atomic on their own and accept `ConditionExpression`. Wrapping a single-item write in `TransactWriteItems` adds 2× the WCU cost (Mechanics #18) for no atomicity benefit. Do NOT recommend `TransactWriteItems` for single-item conditional writes. **`ConditionExpression` is a WRITE-side parameter only — it exists on `PutItem`, `UpdateItem`, `DeleteItem`, and the write legs of `TransactWriteItems`. `GetItem`, `BatchGetItem`, `Query`, and `Scan` do NOT accept `ConditionExpression` — there is no conditional read in DynamoDB, and `ConditionalCheckFailedException` is a write-only error.** Do NOT describe `GetItem` as "returning the item only if a condition passes" or as throwing `ConditionalCheckFailedException` — no such behavior exists. A read returns the item to anyone who supplies the key; the only read-side filter is `FilterExpression` (Query/Scan only — applied after the items are read and billed, never on `GetItem`), and even that does not authorize, it only narrows the result a caller already paid to read. The correct way to keep a caller from reading another tenant's item is to make the data unaddressable to them — partition-key the table on the authorization identifier (Data modeling #14) so a foreign key simply isn't in a partition the caller can reach — NOT to bolt a "conditional GetItem" on top.1291305. **Maximum item size is 400 KB, hard cap.** The 1 MB limit is the `Query`/`Scan` response-page cap, not an item cap. Do NOT quote 1 MB as the item limit. Items near 400 KB also cost more per write (WRU = 1 per 1 KB rounded up, Mechanics #18), so large items are expensive even before the cap bites.1311326. **`BatchGetItem` and `BatchWriteItem` are NOT atomic.** Partial failures are normal and returned via `UnprocessedKeys` (BatchGetItem) or `UnprocessedItems` (BatchWriteItem). The client must retry the unprocessed portion with exponential backoff. Do NOT describe batch operations as atomic or all-or-nothing — use `TransactWriteItems` when atomicity across multiple items is required (subject to Mechanics #14 bounds).1331347. **Reserved Capacity applies to PROVISIONED capacity only, not to on-demand (PAY_PER_REQUEST).** Do NOT recommend Reserved Capacity for on-demand tables — there is no such product. On-demand savings come from usage-based discounts or table-class selection (Standard vs Standard-IA), not reservations.1351368. **A failed `ConditionExpression` still consumes write capacity.** `ConditionalCheckFailedException` charges the same WCU as a successful write of the same shape. Do NOT claim that failed conditional writes are free or that the condition check happens "before" the write-cost is assessed. Plan cost models around expected failure rates (Mechanics #18 uses `conditional_fail_rate` for this reason).1371389. **A GSI's key schema (partition key / sort key) is immutable once the GSI is created.** `UpdateTable` can add a new GSI or drop an existing one, but it cannot alter the KeySchema of an existing GSI. Re-keying an index — including write-sharding a hot GSI partition key by adding a hash suffix — is therefore an **additive migration**, not a code-only change: create a new GSI with the new key → let it populate → cut reads over → drop the old GSI. A *historical* backfill is needed only when the new index must cover items that were already written and won't be touched again; a sparse or small in-flight index (e.g. one holding only active orders) populates from ongoing writes alone and needs no backfill. Do NOT describe a GSI key change as "just a code change" or "no schema migration."13914010. **DynamoDB has native vector search. Semantic similarity does NOT require a second datastore.** A **vector index** is a third index type alongside GSIs and LSIs: declare it with the `VectorIndexes` parameter on `CreateTable` or the `VectorIndexUpdates` parameter on `UpdateTable`, store embeddings as a list of numbers on your items, and query it with the **`SearchVectors`** API (approximate nearest neighbour). Do NOT say DynamoDB cannot do vector or similarity search, and do NOT route a semantic-search requirement to OpenSearch, pgvector, or S3 Vectors by default — the vectors live on the same items as the operational data, with no replication pipeline (Integration #8 routes the workload types). `Query`, `Scan`, PartiQL and DAX do **not** work against a vector index; `Query` is rejected with `ValidationException: Query operation not supported on this index type.` A vector index is NOT a GSI and is NOT configured through one — there is no `VectorIndexConfig` and no vector block on a secondary index. Vector indexes require **on-demand capacity** (Mechanics #19).141142 **DynamoDB does NOT recompute embeddings, and the index cannot fix a stale one.** The vector is ordinary item data: DynamoDB indexes the numbers you wrote and nothing more. Edit the source text and the stored embedding still describes the *old* text, so it keeps matching the old meaning — indefinitely, with no error and no staleness signal anywhere. This is **not** index-propagation lag, and vector-index write propagation does not "close the window": no amount of waiting regenerates a vector. Whenever content behind an embedding is mutable, say so explicitly and give the fix — detect the content change and re-embed with the *same* model, then write the new vector back (DynamoDB Streams into a re-embedding consumer is the usual shape; synchronous regeneration in the write path is also fine). Never imply the index refreshes vectors on its own.143144 **`Dimensions`, `DistanceFunction`, `Projection` and `SearchSchema` are ALL immutable** — every one of them is fixed at `CreateTable`, not just the projection. Changing any single one means creating a replacement index and migrating; there is no in-place `UpdateTable` for them (Mechanics #7).145146 **A stale SDK is not evidence the feature is missing.** The operations ship in **botocore/boto3 ≥ 1.43.64** and **AWS CLI v2 ≥ 2.36.16**. Below those versions they are absent from the client entirely — `aws dynamodb search-vectors` fails with `Found invalid choice` and `hasattr(client, "search_vectors")` is `False`. Testing for that `hasattr` is better than comparing version strings: it checks the capability directly and cannot go stale. If it is absent, tell the user to upgrade; do NOT conclude the feature does not exist. Full API surface, sharp edges and troubleshooting: `${SKILL_DIR}/references/vector-search.md`.147148 **Quoted service limits are a design envelope, not a fact to argue with.** The per-table index count, maximum dimensions, `TopK` range and inline-filter count quoted in this skill are subject to change, and quotas of this kind generally rise. Design inside them, but before telling a user their design exceeds a limit, confirm the current value in AWS Service Quotas or the DynamoDB developer guide — a raised quota that this skill has not caught up with should not become a wrongly rejected design.149150These ten facts are not the full axiom set — they are the subset where LLM prior is most likely to be wrong. When a user's question intersects one of them, state the correct fact plainly and move on; do not hedge with "I think" or "typically."151152## The access-pattern list153154Before touching a schema, enumerate every pattern the application must serve. For each pattern record:155156- A one-line description of what the caller is asking for.157- Expected RPS (treat "unknown" as a design gap to close, per Mechanics #2).158- Items returned per call and approximate item size in KB.159- Consistency requirement (strong, eventual, or transactional).160- Authorization scope — the identifier that must be verified before the call is permitted (per Data modeling #14).161162The list is a numbered, ranked table. The rest of this document assumes it exists. Any modeling decision that cannot be traced back to an entry on this list is unjustified.163164## Per-entity operational-config inputs165166> **This interview is required before proposing any table boundary.** Producing a full multi-table design first and then backfilling "here are the assumptions I made" is a workflow violation, not a shortcut. The per-entity questions below drive the table-splitting decision via Data modeling #3; when the answers are agent-assumed rather than user-stated, the signal fires spuriously and the design ends up over-fragmented (or under-fragmented if the agent guessed "no divergence" to keep things simple). Ask first, then design.167168Before grouping entities into tables, gather operational-config requirements from the user per entity (or per logical aggregate — a parent and its tightly-bound children can share one answer set). Do not assume these defaults silently, because Data modeling #3 uses operational-config divergence as a signal to split tables — if the divergence is *agent-assumed* rather than *user-stated*, the signal fires spuriously and the design ends up over-fragmented.169170For each entity, ask:171172- **Backup and recovery granularity.** Does this entity need PITR? If so, what retention (default 35 days, can be shorter)? Would this entity ever be restored independently of other entities, or always together with them? (Independent-restore requirements force table separation per Data modeling #5.)173- **Streams consumers.** Does any downstream system need change events for this entity — search indexing, analytics export, notifications, audit, CDC? Which stream view type (`NEW_AND_OLD_IMAGES` is the default per Integration #3)? A "no" here is a positive answer: no Streams consumer means Streams can stay disabled, which is cheaper and simpler.174- **Capacity mode.** Does this workload's shape justify provisioned (sustained, predictable traffic over months, per Mechanics #19), or does on-demand remain the default? "Unknown" means on-demand.175- **TTL.** Is there a per-item expiration attribute the application will set? If yes, the attribute is a Unix epoch second (per Patterns #3). If no, TTL stays off and items persist until deleted.176- **Encryption and IAM scope.** Any non-default requirement — customer-managed KMS key, specific IAM boundary, cross-account resource policy? Default is AWS-owned KMS and standard IAM; divergence is an explicit answer.177178Treat these as design inputs on par with RPS. A missing answer is a gap to close, not a value to guess. If the user says "same across all entities," record that and **do not** treat the entities as operationally divergent — co-location by Data modeling #1 is then unobstructed. If the user states real divergence, Data modeling #3 fires on real divergence and the tables split.179180## Per-entity attribute walkthrough (drives item size)181182Item size is the second-largest driver of the cost estimate after RPS, and it's the place the estimate silently drifts worst. A Query declared as `20 items × 1,536 B` but really returning `20 × 512 B` triples the modeled cost against reality. Mechanics #2 says unknown RPS is a design gap; the same discipline applies to item size — an ungrounded guess for `estimated_item_size_bytes` is a design gap, not a safe default.183184For each entity, before settling on a number, **walk the attribute list with the user**. Asking first is the preferred path; proceeding from inferred attributes is the fallback. Either way, the user has to see and sign off on the per-attribute breakdown before it becomes an input to the cost estimate — a silent fill-in is what makes item sizes drift 2–10×.1851861. Propose an attribute list grounded in the domain. For a Waypoint, that's `waypoint_id`, `courier_id`, `lat`, `lng`, `recorded_at`. For a Contract, it's `firm_id`, `contract_id`, `title`, `status`, `body`, `created_by`, `created_at`, `updated_at`.1872. Per attribute, estimate bytes using these starting points:188 - IDs and short strings (ULIDs, UUIDs, slugs, enum values): **~40 B** each. The generic `S=100` heuristic in `cost-model-schema.md` is conservative for the free-tier storage path; for per-item size estimation, use realistic values.189 - Titles, display names, short descriptions: **100–300 B**.190 - Long-form content (contract body, message body, serialized JSON aggregates): ask the user explicitly. Do not guess 4 KB or 50 KB without confirmation.191 - Numeric attributes: **~8 B**.192 - Timestamps as ISO strings: **~25 B**. As epoch numbers: **~8 B**. (Mechanics #11.)193 - Boolean: **~1 B**. Map/List: **~200 B** per instance as a rough default, but ask if the user is storing a big blob inside a Map.1943. Ask the corrections the user will know and you won't: "Does this item carry any denormalized parent data per Mechanics #10?" "Is there a free-text field whose length varies widely?" "Are you storing the full document or a summary?" Update the estimates from the answers.1954. Sum the per-attribute estimates to derive the entity's `estimated_item_size_bytes`. For a Query that projects a subset (INCLUDE / KEYS_ONLY, or application-side projection), use a smaller number for the access-pattern's `estimated_item_size_bytes` — the bytes billed by DynamoDB are bytes actually read from the projected view, not the full item.1965. If the user is uncertain on a specific attribute, label that attribute as an assumption in the artifact (same discipline as unknown RPS). Do not silently pick a number.1976. **Surface the full list in your response — always, regardless of whether this is an interactive conversation or a one-shot prompt.** Emit a compact markdown table per entity with columns `attribute | type | bytes | source (user or guess)`. This is a reply-shape requirement, not a dialog gate. In one-shot settings where there will be no follow-up turn, the table still goes in the response so the user sees exactly what you assumed — the call-out is how they catch a 3× overshoot on a body field before it contaminates every cost number downstream. Label every uncertain estimate "guess" explicitly; do not smuggle a guess in as a user-supplied number. Explicitly invite correction: "These are my guesses where noted — please correct any that are wrong." Even when the user said "just pick reasonable values and go," emit the table.198199Calibration: for the reference Contracts-app example in `cost-model-schema.md`, `Contract` is ~2 KB (not 50 KB — the 50 KB value is the worst-case body size, not the typical), and `Clause` is ~512 B. If a declared `estimated_item_size_bytes` is more than 2× the sum of the named attributes and the user hasn't explained the gap, you're guessing — revisit.200201A run that skips this walkthrough can drift by 2–10× on individual patterns. The live-validation step (below) will surface that drift, but you shouldn't need live validation to get the cost estimate in the right order of magnitude.202203## Artifacts to produce204205> **Produce artifacts in the order listed below.** Schema + per-pattern plan are the primary outputs; cost estimate (item 7) and live validation (item 8) come **after** the design exists, not instead of it. A response that leads with a cost analysis and buries the schema in an appendix has the dependency backwards — the user asked for a design, and the cost is a property *of* the design. The access-pattern list (item 1), schema (item 2), and per-pattern plan (item 3) must be visible and discussable in the reply **before** any cost numbers appear. Items 1–6 are the no-AWS design itself and are the default deliverable; item 7 (cost) is produced on request or at finalization (see *Cost estimation*); item 8 (live validation) is the opt-in AWS fork. Putting these artifacts only in `dynamodb_data_model.json` does not satisfy this — the user reads your prose, not the JSON. ❌ BAD reply shape (a real failure mode): a reply that opens "## Summary for the CFO — $1,019/month" with the schema living only in `dynamodb_data_model.json` on disk and the reply's only design content a trailing "artifacts produced" file list. ✅ GOOD: access-pattern list + per-table schema + per-pattern plan + per-entity byte table (per *Per-entity attribute walkthrough* step 6) rendered in the reply, **then** the cost summary, **then** "`cost_report.md` written."206207A complete design hands back:2082091. **The access-pattern list** as above.2102. **A schema per table**: primary key (named per Data modeling #7), GSIs with their key attributes and projection type, and the operational configuration (Streams, PITR, TTL, capacity mode, Global Tables replication, encryption, IAM scope — all per Data modeling #3).2113. **A per-pattern plan**: for each access pattern in the list, the exact API call (`GetItem`, `Query`, or `BatchGetItem`, per Mechanics #16), the table or GSI it targets, the key conditions, the filter expressions if any, and the projected cost using the formulas in Mechanics #18.2124. **A fan-out topology**: for each table with Streams enabled, the consumers (Lambda, EventBridge Pipe, Kinesis shim), the filters at the source (Integration #4), and the on-failure destinations and retry bounds (Integration #5).2135. **A list of deviations and their justification**: any axiom or pattern not applied, with a stated reason.2146. **Idempotency and conditional-write guards**: which routes use idempotency-key middleware (Patterns #1), which `UpdateItem`s carry `attribute_exists` guards against phantom upserts (Patterns #2), and which `PutItem`s carry `attribute_not_exists` guards against double-creation.2157. **A monthly cost estimate** — produced **on request or at finalization**, not reflexively on every design turn. While the user is still exploring or refining the model, stay in design discussion and don't run the calculator each turn. Produce the estimate (via `${SKILL_DIR}/scripts/calculate_costs.py` — see *Cost estimation* below) when the user asks what it costs, or when the design is being settled (they signal they're committing to it / taking it to review / want the numbers). When you do produce it, use the calculator — never inline arithmetic. Skip entirely for questions too narrow to have produced a full design (a single-query sizing, a debugging thread, a pointed mechanics question).2162178. **A live validation** (optional, on offer, last step): after the cost estimate, ask the user whether they want to deploy this schema to an AWS account they nominate and measure real per-operation capacity, latency, and GSI amplification against live DynamoDB. If yes, follow *Live validation* below. Skip for narrow questions, when the cost estimate was skipped, or when the user has no sandbox account. Unlike the cost estimate, this step creates real resources and incurs real charges, so both the offer and the consent must be explicit.218219## Conflict-resolution ordering220221When axioms point in opposite directions, apply in this priority order:2222231. **Correctness** — authorization boundary alignment (Data modeling #14), consistency requirements, transactional atomicity, idempotency. A design that leaks data across tenants or serves stale data where strong consistency is required is wrong regardless of its other merits.2242. **Operational necessity** — divergent PITR, Streams, capacity, or replication configuration (Data modeling #3), recovery granularity (Data modeling #5), per-partition throughput ceilings (Mechanics #3), transaction bounds (Mechanics #14). These are physical or service constraints; preference does not override them.2253. **Cost and performance** — access-pattern co-location (Data modeling #1), dedicated GSIs (Data modeling #6), projection choice (Mechanics #7), cost formulas (Mechanics #18), capacity mode (Mechanics #19).2264. **Style and convention** — naming (Data modeling #7), single-table vs. multi-table framing (Data modeling #11) absent other signal. The cheapest to override when a higher-tier axiom disagrees.227228Two concrete examples:229230- Data modeling #1 (co-locate by shared access) vs. Data modeling #14 (partition by authorization boundary): #14 wins. If the natural access key and the authorization key differ, key on the authorization identifier and expose the alternate access via a GSI.231- Data modeling #1 (co-locate) vs. Data modeling #3 (split on divergent operational config): #3 wins. Two entities sharing a read pattern but requiring different PITR retention or Streams consumers belong in separate tables.232233## Glossary234235- **Access pattern** — a request the application makes against the data layer, described by its key conditions, items returned, frequency, and consistency requirement. The atomic unit of DynamoDB design.236- **Aggregate** — a cluster of entities that are read or written together. A single item, an item collection, or a set of items under different keys can each be an aggregate; the choice is the subject of Mechanics #1.237- **Item collection** — the set of items sharing a single partition-key value. Queries against an item collection are constant-partition and cheap; cross-partition reads are not.238- **Identifying relationship** — a data model in which a child entity is keyed by its parent's identifier plus its own. The child has no independent existence outside the parent.239- **Overloaded key** — a partition or sort key whose value encodes a type prefix (e.g. `USER#42`, `ORDER#42`) so that one physical key holds multiple logical entity types.240- **Sparse GSI** — a GSI whose indexed attribute is present on only a subset of base-table items, so the index projects only those items. Useful when an access pattern would otherwise filter out most items at read time. The same semantics apply to a vector index and are the mechanism behind **silent de-indexing**.241- **Vector index** — a third index type alongside GSIs and LSIs, holding vector embeddings from a named item attribute and read with `SearchVectors` rather than `Query`/`Scan`. Declared via `VectorIndexes` on `CreateTable` or `VectorIndexUpdates` on `UpdateTable`. On-demand capacity only; up to 5 per table.242- **ANN** (approximate nearest neighbour) — the search strategy a vector index uses. It trades exactness for speed, so results and their ordering can vary slightly between runs or Regions over identical data.243- **SearchSchema** — a vector index's optional schema, listing at most one `HASH` element (the vector index partition key, which scopes and scales each search and MUST be supplied on every call) and up to 18 `INLINE_FILTER` elements (optional equality filters applied at the storage layer). Fixed at creation. Every SearchSchema attribute must also appear in the table's `AttributeDefinitions`.244- **Distance function** — `COSINE`, `EUCLIDEAN` or `DOT_PRODUCT`, chosen at index creation and immutable. It determines both the `Score` and the sort order: lower is more similar for `COSINE`/`EUCLIDEAN`, higher for `DOT_PRODUCT`, whose scores can be negative.245- **Silent de-indexing** — an item written without the vector index's SearchSchema `HASH` attribute is accepted on the base table with no error but never replicated into the index, so it vanishes from `SearchVectors` results while `GetItem` still returns it intact. Expected behaviour, not a defect; see `${SKILL_DIR}/references/vector-search.md`.246- **VS / VWR** — Vector Search and Vector Write request units. Vector index capacity is metered in **bytes**, reported as `VectorSearchRequestBytes` and `VectorWriteRequestBytes`, separately from base-table RCU/WCU, with a 1 KB per-request minimum.247- **GSI write amplification** — the property that every write to a base-table item with projected GSI attributes produces one write per matching GSI, each billed in WCU.248- **LWW** (last-writer-wins) — the conflict resolution strategy used by standard Global Tables: the write with the newest timestamp wins; earlier writes are silently discarded.249- **MRSC** (Multi-Region Strong Consistency) — an opt-in Global Tables mode that provides strong consistency across replicas via consensus, at higher write latency and cost.250- **Hot partition** — a partition receiving traffic beyond the per-partition throughput ceiling (Mechanics #3), causing throttling even when table-level capacity is available.251- **Poison pill** — a record that a stream consumer cannot process successfully, which blocks forward progress on its shard until it is discarded, retried to exhaustion, or routed to an on-failure destination.252- **RCU / WCU** — read and write capacity units; the provisioned-mode spelling of the per-operation throughput unit. The formulas in Mechanics #18 are written in RCU/WCU and apply identically to on-demand.253- **RRU / WRU** — read and write *request* units: the on-demand (PAY_PER_REQUEST) spelling of the same per-operation unit, billed per request rather than per provisioned capacity-second. One RRU = one RCU of work and one WRU = one WCU of work — the consumption math in Mechanics #18 is identical; only the billing dimension differs. The cost references (`cost-model-schema.md`) use RRU/WRU because the calculator prices on-demand; the axioms and the performance report use RCU/WCU. They are the same quantity — do not treat a model's RRU/WRU figure and the report's RCU/WCU figure as different things.254255## Data modeling2562571. Co-locate data by shared access pattern, not by domain. Two entities belong in the same table only when an application request fetches or writes them together. A shared business domain — "user data," "billing data" — is not sufficient justification. Co-location without a shared query introduces coupling and yields no performance benefit.2582592. Treat table count as an output of the design, not a target. Do not optimize for one table, nor for one table per entity. The correct number of tables is whatever the access patterns produce. If the analysis surfaces three tables, ship three tables.2602613. Treat table-level configuration as both a modeling input and an interface declaration. Streams, point-in-time recovery, TTL, capacity mode, attached Kinesis streams, Global Tables replicas, encryption, and IAM scope all apply at the table level — they declare how the table participates in the broader system. When two entities require different operational settings — different PITR retention, different stream consumers, different replication regions, different capacity modes — that divergence is a primary signal that they belong in separate tables, not a secondary concern to be reconciled later.2622634. DynamoDB Streams provide two concurrent co264265…(truncated)