{"playbook":"Data","url":"https://agentwranglers.se/playbook/data","updated":"2026-10-21","license":"https://creativecommons.org/licenses/by/4.0/","credit":"Required by the licence: credit Agent Wranglers with a link to the rule or playbook you used.","how":"Each rule's How text is on its own page: add .md to its url.","rules":[{"chapter":"Schema design","name":"Enforce rules with constraints","level":"Recommended","rule":"Put every rule the data must always follow into the database as a constraint. App checks alone fail when a second app, a script or a bug writes directly.","check":"Insert a row that breaks each rule straight into the database, bypassing the app. Every insert fails with a constraint error.","url":"https://agentwranglers.se/playbook/data/schema-design/constraints","related":["/playbook/architecture/errors-and-nulls/required-fields","/playbook/app-ui/forms/server-side-validation"],"sources":[]},{"chapter":"Schema design","name":"Choose exact types","level":"Recommended","rule":"Store each value in the type that means it. Money is an exact number, time is a time with a zone, and true or false is a boolean.","check":"A query over the catalog lists every float column and every timestamp without a zone. The list is empty, or each entry is documented as not money and stored as UTC.","url":"https://agentwranglers.se/playbook/data/schema-design/exact-types","related":["/playbook/api-design/requests-and-responses/money","/playbook/payments/amounts-and-orders/money-in-minor-units","/playbook/app-ui/dates-and-numbers/calendar-dates"],"sources":[]},{"chapter":"Schema design","name":"Give every row a stable key","level":"Recommended","rule":"Give every table a primary key that never changes. Never use a value people can edit, such as an email or a name, as the key.","check":"A catalog query finds no table without a primary key, and every generated key is `bigint` or a UUID. A search of the code finds no `UPDATE` that sets a key column.","url":"https://agentwranglers.se/playbook/data/schema-design/stable-keys","related":["/playbook/api-design/requests-and-responses/string-ids","/playbook/accounts/sign-in-methods/own-user-id","/playbook/app-ui/lists-and-tables/stable-row-keys"],"sources":[{"label":"RFC 9562 Universally Unique IDentifiers (UUIDs)","url":"https://www.rfc-editor.org/rfc/rfc9562"}]},{"chapter":"Schema design","name":"Name things the same way everywhere","level":"Recommended","rule":"Name tables, columns and constraints by one convention, so people and agents can guess a name without looking it up.","check":"A script lists all table and column names. Every one matches the chosen pattern.","url":"https://agentwranglers.se/playbook/data/schema-design/schema-names","related":["/playbook/architecture/names-and-functions/intention-revealing-names","/playbook/architecture/names-and-functions/searchable-names"],"sources":[]},{"chapter":"Schema design","name":"Keep one fact in one place","level":"Recommended","rule":"Store each fact once, and derive the rest. Copies of a fact drift apart sooner or later.","check":"For each copied column, the schema notes name its source and how the copy is kept in step.","url":"https://agentwranglers.se/playbook/data/schema-design/normalise","related":["/playbook/app-ui/client-and-server-state/derived-values","/playbook/payments/amounts-and-orders/order-snapshot"],"sources":[]},{"chapter":"Schema design","name":"Plan how rows are removed","level":"Recommended","rule":"Decide for each table what deleting means, and make the foreign keys say it.","check":"Delete a test parent row. Its owned rows disappear, protected references block the delete, and no orphan rows remain.","url":"https://agentwranglers.se/playbook/data/schema-design/deletion-design","related":["/playbook/website/privacy/data-requests"],"sources":[]},{"chapter":"Schema design","name":"Keep each customer's data apart","level":"Recommended","applies":["accounts"],"rule":"When one database holds data for many customer accounts, tag every row with its account and let the database refuse rows from other accounts.","check":"Connect as the app's role with the setting for account A and select from every shared table. No row from account B comes back.","url":"https://agentwranglers.se/playbook/data/schema-design/tenant-isolation","related":["/playbook/ai-assisted-development/apps-built-with-ai-builders/row-level-security","/playbook/adversarial-testing/access-and-accounts/object-access","/playbook/accounts/roles-and-permissions/org-scoped-roles","/playbook/accounts/organisations-and-teams/organisation-owns-data"],"sources":[{"label":"OWASP Multi-Tenant Security Cheat Sheet","url":"https://cheatsheetseries.owasp.org/cheatsheets/Multi_Tenant_Security_Cheat_Sheet.html"}]},{"chapter":"Queries and indexes","name":"Pass values as parameters","level":"Required","risk":"data","rule":"Send every value as a query parameter. Never build SQL by joining strings with input.","check":"Search the code for SQL built with string joins or template strings that hold variables. None remain.","url":"https://agentwranglers.se/playbook/data/queries-and-indexes/parameters","related":["/playbook/adversarial-testing/injection/sql-injection","/playbook/adversarial-testing/injection/command-injection","/playbook/stack/databases-and-storage/orm"],"sources":[{"label":"OWASP SQL Injection Prevention Cheat Sheet","url":"https://cheatsheetseries.owasp.org/cheatsheets/SQL_Injection_Prevention_Cheat_Sheet.html"}]},{"chapter":"Queries and indexes","name":"Index the queries you run","level":"Recommended","rule":"Add an index for each frequent query's filter, join and sort, and no index that no query uses.","check":"Run the 20 most frequent queries with `EXPLAIN` on production-sized data. None does a full scan of a table with more than 10,000 rows. The unused index query returns no rows on statistics collected for at least a month.","url":"https://agentwranglers.se/playbook/data/queries-and-indexes/index-the-queries-you-run","related":["/playbook/app-ui/lists-and-tables/server-sort-and-filter"],"sources":[]},{"chapter":"Queries and indexes","name":"Read the query plan","level":"Recommended","rule":"Before you change a slow query or add an index, read its plan. Check again after the change.","check":"The change notes for each performance fix show the plan and the time before and after, such as \"from 840 ms to 3 ms\".","url":"https://agentwranglers.se/playbook/data/queries-and-indexes/query-plans","related":["/playbook/data/operations/slow-query-log","/playbook/stack/languages-and-runtimes/sql"],"sources":[]},{"chapter":"Queries and indexes","name":"Load related rows in one query","level":"Recommended","rule":"Never run one query per item in a list. Load related rows together with a join or one `IN` query.","check":"A test loads a list with 5 items and with 50, counting the queries through the driver's query hook or log. Both counts are equal.","url":"https://agentwranglers.se/playbook/data/queries-and-indexes/no-n-plus-one","related":["/playbook/operations/metrics-and-traces/distributed-tracing","/playbook/stack/databases-and-storage/orm"],"sources":[]},{"chapter":"Queries and indexes","name":"Select only the columns you use","level":"Recommended","rule":"Name the columns a query needs. Avoid `SELECT *` in app code.","check":"Search the app code for `SELECT *`. It appears only in ad hoc scripts and tests.","url":"https://agentwranglers.se/playbook/data/queries-and-indexes/select-columns","related":["/playbook/adversarial-testing/data-and-secrets/only-needed-fields"],"sources":[]},{"chapter":"Queries and indexes","name":"Page through long lists by key","level":"Recommended","rule":"For long lists, page by the last key seen instead of `OFFSET`. Offsets get slower with every page and skip or repeat rows when data changes.","check":"Follow the keys to a page 50,000 rows into a large list. It answers as fast as page 1, and its plan shows an index range scan.","url":"https://agentwranglers.se/playbook/data/queries-and-indexes/keyset-pagination","related":["/playbook/api-design/lists/cursor-pagination","/playbook/adversarial-testing/abuse-and-limits/resource-limits"],"sources":[]},{"chapter":"Transactions and concurrency","name":"Wrap multi-step changes in a transaction","level":"Required","risk":"data","rule":"When one change writes several rows or tables, do it in one transaction, so it fully happens or not at all.","check":"Make the last step fail on purpose in a test. No row from the earlier steps remains.","url":"https://agentwranglers.se/playbook/data/transactions-and-concurrency/transactions","related":["/playbook/payments/payment-events-and-reconciliation/payment-ledger","/playbook/payments/invoices-and-records/sequential-invoice-numbers"],"sources":[]},{"chapter":"Transactions and concurrency","name":"Know your isolation level","level":"Recommended","rule":"Know which isolation level each database uses by default, and raise it where a check and a write must see the same data.","check":"A test runs the same \"check, then write\" from two connections at once. Exactly one succeeds, and the other retries or fails with a clear error.","url":"https://agentwranglers.se/playbook/data/transactions-and-concurrency/isolation-levels","related":["/playbook/adversarial-testing/abuse-and-limits/race-conditions"],"sources":[]},{"chapter":"Transactions and concurrency","name":"Make conditional writes","level":"Required","risk":"data","rule":"Put the condition into the write itself, so the database decides atomically. Never read a value, decide in the app and write it back.","check":"Send the same coupon redemption 10 times at once. One succeeds, and the others change zero rows.","url":"https://agentwranglers.se/playbook/data/transactions-and-concurrency/conditional-writes","related":["/playbook/architecture/coupling/guard-shared-state","/playbook/api-design/reliability/optimistic-concurrency","/playbook/adversarial-testing/abuse-and-limits/race-conditions","/playbook/payments/amounts-and-orders/payment-state-machine"],"sources":[]},{"chapter":"Transactions and concurrency","name":"Make writes safe to repeat","level":"Required","risk":"money","rule":"Let clients retry a write without doing it twice. Networks fail after the server has acted, so retries will happen.","check":"Send the same order twice with one idempotency key, both at once. One order exists, and both replies are the same.","url":"https://agentwranglers.se/playbook/data/transactions-and-concurrency/idempotent-writes","related":["/playbook/api-design/reliability/idempotency-keys","/playbook/notifications/push-notifications/refresh-push-subscriptions","/playbook/stack/databases-and-storage/background-jobs"],"sources":[{"label":"Expired IETF draft on the Idempotency-Key HTTP Header Field","url":"https://datatracker.ietf.org/doc/draft-ietf-httpapi-idempotency-key-header/"}]},{"chapter":"Transactions and concurrency","name":"Send side effects through an outbox","level":"Recommended","rule":"When a change must also send an email, a webhook or a message, write that task to a table in the same transaction. Send it afterwards from that table.","check":"Kill the process right after commit. On restart, the pending email is sent. Run two workers at once, and no task is sent by both.","url":"https://agentwranglers.se/playbook/data/transactions-and-concurrency/outbox","related":["/playbook/data/caches-and-copies/search-index-sync","/playbook/data/product-analytics/server-side-business-events","/playbook/email/automation-and-agents/send-once"],"sources":[]},{"chapter":"Transactions and concurrency","name":"Set aside messages that keep failing","level":"Recommended","rule":"After a set number of failed attempts, move a message or job to a separate dead letter queue and alert. Never retry it forever or drop it.","check":"Send a message that always fails. After the limit it sits in the dead letter queue with its error, an alert has fired, and other messages still flow.","url":"https://agentwranglers.se/playbook/data/transactions-and-concurrency/dead-letter-queue","related":["/playbook/api-design/reliability/retry-backoff","/playbook/data/product-analytics/event-validation","/playbook/stack/databases-and-storage/background-jobs"],"sources":[]},{"chapter":"Migrations","name":"Change the schema only with migrations","level":"Recommended","rule":"Every schema change is a numbered migration file in version control. Nobody changes a shared database by hand.","check":"Build a fresh database from the migrations alone. Its schema matches production exactly, compared with a schema dump.","url":"https://agentwranglers.se/playbook/data/migrations/versioned-migrations","related":["/playbook/engineering/version-control-and-review/everything-in-version-control","/playbook/stack/languages-and-runtimes/sql","/playbook/stack/web-frameworks/django","/playbook/stack/databases-and-storage/migration-tool"],"sources":[]},{"chapter":"Migrations","name":"Never edit an applied migration","level":"Recommended","rule":"Once a migration has run anywhere outside your own machine, never change it. Fix mistakes with a new migration.","check":"The migration tool's checksum validation is on. CI also fails when a pull request changes or deletes a migration file that exists on the main branch.","url":"https://agentwranglers.se/playbook/data/migrations/immutable-migrations","related":["/playbook/ai-assisted-development/rules-the-agent-cannot-break/blocking-hooks"],"sources":[]},{"chapter":"Migrations","name":"Expand first and contract later","level":"Required","risk":"outage","rule":"Make every schema change work with both the old and the new code, so deploys and rollbacks never break.","check":"Deploy the migration, then run the previous release's tests against the new schema. They pass.","url":"https://agentwranglers.se/playbook/data/migrations/expand-and-contract","related":["/playbook/engineering/build-test-and-deploy/atomic-deploys-with-rollback"],"sources":[]},{"chapter":"Migrations","name":"Keep migrations from locking tables","level":"Recommended","rule":"Write migrations that never block a busy table for long. A long lock stops every request that touches it.","check":"Run the migration on a copy under simulated traffic. No request waits longer than the lock timeout, and the invalid index query returns no rows afterwards.","url":"https://agentwranglers.se/playbook/data/migrations/lock-safe-migrations","related":["/playbook/data/migrations/test-migrations"],"sources":[]},{"chapter":"Migrations","name":"Test migrations on a copy of production data","level":"Recommended","rule":"Run each migration on a recent copy of production data before it reaches production, and time it.","check":"The deploy pipeline shows a passed migration run on the copy, with its duration, before the production step.","url":"https://agentwranglers.se/playbook/data/migrations/test-migrations","related":["/playbook/testing/integration-and-contract-tests/same-database-engine","/playbook/data/migrations/lock-safe-migrations","/playbook/data/personal-data/masked-copies","/playbook/stack/databases-and-storage/migration-tool"],"sources":[]},{"chapter":"Migrations","name":"Fix production data with reviewed scripts","level":"Required","risk":"data","rule":"Change or delete production data only with a reviewed script in version control. The script checks how many rows it touched before it commits.","check":"Every data change in production has a matching script in the repository. Run a script with a wrong expected count on a copy, and it rolls back with nothing changed.","url":"https://agentwranglers.se/playbook/data/migrations/data-fixes","related":["/playbook/compliance/controls-and-evidence/change-evidence"],"sources":[]},{"chapter":"Backups and recovery","name":"Encrypt backups","level":"Required","risk":"data","rule":"Encrypt every backup before it leaves the server, and keep the key apart from the backups.","check":"Download a backup file. It cannot be opened without the key, and the private key is not on the database server.","url":"https://agentwranglers.se/playbook/data/backups-and-recovery/encrypted-backups","related":["/playbook/adversarial-testing/data-and-secrets/encrypt-data","/playbook/operations/disaster-recovery/off-site-backups"],"sources":[{"label":"GDPR Article 32 on security of processing","url":"https://eur-lex.europa.eu/eli/reg/2016/679/oj"},{"label":"OWASP Cryptographic Storage Cheat Sheet","url":"https://cheatsheetseries.owasp.org/cheatsheets/Cryptographic_Storage_Cheat_Sheet.html"}]},{"chapter":"Backups and recovery","name":"Keep point-in-time recovery for busy databases","level":"Recommended","rule":"Where losing a day of writes is not acceptable, keep the database's change log, so you can restore to any moment.","check":"Restore a staging copy to a chosen time 10 minutes ago. A row written 5 minutes ago is missing, and one written 15 minutes ago is there.","url":"https://agentwranglers.se/playbook/data/backups-and-recovery/point-in-time-recovery","related":["/playbook/operations/disaster-recovery/recovery-targets","/playbook/stack/databases-and-storage/postgresql"],"sources":[]},{"chapter":"Backups and recovery","name":"Prove restores work","level":"Required","risk":"data","rule":"Restore a backup on a schedule and check the result. An untested backup is a hope, not a backup.","check":"The last automated restore test ran within the past 31 days and passed within the target time. Its log shows the row counts.","url":"https://agentwranglers.se/playbook/data/backups-and-recovery/restore-tests","related":["/playbook/operations/disaster-recovery/restore-drills","/playbook/data/operations/corruption-checks","/playbook/operations/disaster-recovery/off-site-backups","/playbook/stack/databases-and-storage/sqlite"],"sources":[{"label":"GDPR Article 32 on security of processing","url":"https://eur-lex.europa.eu/eli/reg/2016/679/oj"},{"label":"NIST SP 800-34 Rev. 1 Contingency Planning Guide","url":"https://csrc.nist.gov/pubs/sp/800/34/r1/upd1/final"}]},{"chapter":"Backups and recovery","name":"Test what a failover loses","level":"Recommended","rule":"When a database has a standby for failover, write down how much data a failover may lose. Then promote the standby in a drill.","check":"A drill on staging promotes the standby under write load. The app reconnects within the recovery time objective, the old primary refuses writes, and the lost writes fit the recovery point objective.","url":"https://agentwranglers.se/playbook/data/backups-and-recovery/failover","related":["/playbook/operations/disaster-recovery/recovery-targets","/playbook/data/caches-and-copies/replica-lag","/playbook/operations/disaster-recovery/restore-drills"],"sources":[{"label":"PostgreSQL log-shipping standby servers","url":"https://www.postgresql.org/docs/current/warm-standby.html"},{"label":"PostgreSQL failover","url":"https://www.postgresql.org/docs/current/warm-standby-failover.html"}]},{"chapter":"Personal data","name":"Store only the personal data you need","level":"Required","risk":"law","applies":["eu-users"],"rule":"Add a personal data column only when a feature needs it, and write down why. GDPR Article 5 asks for data that is limited to its purpose.","check":"Every personal data column appears in the list with a purpose. A script compares the list with the schema and fails on unlisted columns.","url":"https://agentwranglers.se/playbook/data/personal-data/minimal-columns","related":["/playbook/website/privacy/data-minimization","/playbook/compliance/records-and-data-map/data-map"],"sources":[{"label":"GDPR Article 5 on principles of processing","url":"https://eur-lex.europa.eu/eli/reg/2016/679/oj"}]},{"chapter":"Personal data","name":"Delete old data automatically","level":"Required","risk":"law","applies":["eu-users"],"rule":"Give each kind of personal data a retention period, and let a scheduled job delete or anonymise rows when it ends.","check":"Insert a test row dated past its retention period and run the job. The row is gone, and so are its copies in the search index and cache.","url":"https://agentwranglers.se/playbook/data/personal-data/retention-jobs","related":["/playbook/website/privacy/data-requests","/playbook/operations/disaster-recovery/backup-retention","/playbook/compliance/records-and-data-map/retention-schedule","/playbook/data/product-analytics/analytics-retention","/playbook/payments/invoices-and-records/payment-record-retention","/playbook/ai-features/logs-privacy-and-feedback/prompt-text-retention","/playbook/accounts/profile-and-account-lifecycle/inactive-accounts","/playbook/compliance/online-platforms/trader-traceability","/playbook/notifications/in-app-notifications/inbox-retention"],"sources":[{"label":"GDPR Article 5 on principles of processing","url":"https://eur-lex.europa.eu/eli/reg/2016/679/oj"}]},{"chapter":"Personal data","name":"Keep personal data out of test copies","level":"Required","risk":"data","rule":"Never copy personal data into development, test or staging databases. Mask or replace it while copying.","check":"Compare the email column of a staging copy with production's. No address appears in both, and every staging address is on a test domain such as `example.com`.","url":"https://agentwranglers.se/playbook/data/personal-data/masked-copies","related":["/playbook/ai-assisted-development/safety-and-permissions/no-production-credentials","/playbook/testing/test-data-and-environments/data-factories","/playbook/testing/test-data-and-environments/production-like-staging","/playbook/data/migrations/test-migrations","/playbook/email/email-privacy/test-mail-capture"],"sources":[{"label":"GDPR Article 25 on data protection by design","url":"https://eur-lex.europa.eu/eli/reg/2016/679/oj"}]},{"chapter":"Personal data","name":"Encrypt the most sensitive fields","level":"Recommended","rule":"Encrypt fields whose leak would cause serious harm, such as ID numbers or health data, in the app before they reach the database.","check":"Query a sensitive column directly in the database. It shows unreadable ciphertext, and two rows with the same value hold different ciphertext.","url":"https://agentwranglers.se/playbook/data/personal-data/field-encryption","related":["/playbook/adversarial-testing/data-and-secrets/encrypt-data","/playbook/infrastructure/configuration-and-secrets/key-management","/playbook/compliance/records-and-data-map/special-category-flags"],"sources":[{"label":"GDPR Article 32 on security of processing","url":"https://eur-lex.europa.eu/eli/reg/2016/679/oj"},{"label":"OWASP Cryptographic Storage Cheat Sheet","url":"https://cheatsheetseries.owasp.org/cheatsheets/Cryptographic_Storage_Cheat_Sheet.html"}]},{"chapter":"Personal data","name":"Log who reads personal data","level":"Recommended","applies":["audit"],"rule":"Record when staff or support tools read or change a person's data, so misuse can be found and proved.","check":"Open a test customer's record in the admin tool. A new audit entry names you, the customer ID and the time. Deleting that entry with the staff role fails.","url":"https://agentwranglers.se/playbook/data/personal-data/access-logging","related":["/playbook/accounts/support-and-staff-access/impersonation","/playbook/compliance/audit-logs-and-access/audit-log-immutable","/playbook/payments/invoices-and-records/money-audit-log","/playbook/accounts/support-and-staff-access/staff-accounts","/playbook/compliance/audit-logs-and-access/audit-log-events"],"sources":[{"label":"OWASP Logging Cheat Sheet","url":"https://cheatsheetseries.owasp.org/cheatsheets/Logging_Cheat_Sheet.html"}]},{"chapter":"Personal data","name":"Erase retired databases and every copy","level":"Required","risk":"data","rule":"When a database, replica or test copy is no longer needed, erase it with every snapshot and volume it left behind. Record what was erased, how and when.","check":"For each retired store in the data map, a listing of the cloud account finds no database, snapshot or volume with its name or tag. The erasure record names the store, the method and the date.","url":"https://agentwranglers.se/playbook/data/personal-data/retired-data-stores","related":["/playbook/compliance/records-and-data-map/data-map","/playbook/operations/disaster-recovery/backup-retention","/playbook/infrastructure/configuration-and-secrets/key-management"],"sources":[{"label":"NIST SP 800-88 Rev. 2 Guidelines for Media Sanitization","url":"https://csrc.nist.gov/pubs/sp/800/88/r2/final"},{"label":"GDPR Article 5 on principles of processing","url":"https://eur-lex.europa.eu/eli/reg/2016/679/oj"}]},{"chapter":"Operations","name":"Pool database connections","level":"Recommended","rule":"Reuse a small, fixed pool of connections instead of opening one per request.","check":"Under a load test, the app's open connections stay at or below the pool size times the number of app processes.","url":"https://agentwranglers.se/playbook/data/operations/connection-pooling","related":["/playbook/architecture/errors-and-nulls/release-resources","/playbook/operations/capacity-and-cost/exhaustion-alerts","/playbook/adversarial-testing/data-and-secrets/database-scram-auth","/playbook/stack/databases-and-storage/postgresql"],"sources":[]},{"chapter":"Operations","name":"Set time limits on every query","level":"Required","risk":"outage","rule":"Give every query and transaction a time limit, so one slow query cannot hold locks and connections forever.","check":"Run `SELECT pg_sleep(60)` in PostgreSQL, or `SELECT SLEEP(60)` in MySQL, through the app's connection. It is cancelled at the set limit.","url":"https://agentwranglers.se/playbook/data/operations/query-timeouts","related":["/playbook/api-design/reliability/timeouts"],"sources":[]},{"chapter":"Operations","name":"Watch slow queries","level":"Recommended","rule":"Record queries that take longer than a set time, and fix or explain the five worst every week.","check":"Run a deliberately slow query. It appears in the slow query log or the statistics view.","url":"https://agentwranglers.se/playbook/data/operations/slow-query-log","related":["/playbook/data/queries-and-indexes/query-plans","/playbook/operations/metrics-and-traces/red-metrics"],"sources":[]},{"chapter":"Operations","name":"Let routine maintenance run","level":"Recommended","rule":"Keep the database's own maintenance running, so tables don't bloat and the planner has fresh statistics.","check":"The statistics view shows a vacuum and analyse within the last 7 days for every table over 10,000 rows. No table has more dead rows than live ones, and the transaction ID age stays under the alert.","url":"https://agentwranglers.se/playbook/data/operations/maintenance","related":["/playbook/data/operations/capacity"],"sources":[]},{"chapter":"Operations","name":"Watch size and growth","level":"Recommended","rule":"Track how fast the database and its biggest tables grow, and alert well before disk or plan limits are reached.","check":"The monitoring shows a size graph for the database, and a test alert fires when the threshold is lowered below today's size.","url":"https://agentwranglers.se/playbook/data/operations/capacity","related":["/playbook/data/operations/maintenance","/playbook/operations/capacity-and-cost/exhaustion-alerts"],"sources":[]},{"chapter":"Operations","name":"Give the app a least-privilege database user","level":"Recommended","rule":"Connect the app with a user that can read and write its tables, but cannot change the schema or read other databases.","check":"Through the app's connection, try `DROP TABLE` on a test table. The database refuses it.","url":"https://agentwranglers.se/playbook/data/operations/app-database-role","related":["/playbook/adversarial-testing/data-and-secrets/encrypt-data","/playbook/adversarial-testing/data-and-secrets/database-scram-auth"],"sources":[{"label":"OWASP Database Security Cheat Sheet","url":"https://cheatsheetseries.owasp.org/cheatsheets/Database_Security_Cheat_Sheet.html"}]},{"chapter":"Operations","name":"Turn on checksums and check for corruption","level":"Recommended","rule":"Let the database detect damaged pages, and run its full integrity check on every restored backup. A silent disk error otherwise spreads into every new backup.","check":"`SHOW data_checksums` returns `on` in PostgreSQL. The last restore test log shows `pg_amcheck` reporting no corruption, or `PRAGMA integrity_check` returning `ok` and `PRAGMA foreign_key_check` returning no rows.","url":"https://agentwranglers.se/playbook/data/operations/corruption-checks","related":["/playbook/data/backups-and-recovery/restore-tests"],"sources":[{"label":"PostgreSQL initdb","url":"https://www.postgresql.org/docs/current/app-initdb.html"},{"label":"PostgreSQL pg_amcheck","url":"https://www.postgresql.org/docs/current/app-pgamcheck.html"},{"label":"SQLite PRAGMA statements","url":"https://www.sqlite.org/pragma.html"}]},{"chapter":"Operations","name":"Rebuild text indexes when the collation library changes","level":"Recommended","rule":"Treat an operating system or ICU upgrade under a database as a change to its sort order. Rebuild the affected text indexes before the database takes writes again.","check":"After each operating system or database upgrade, both queries below return no rows on every database.","url":"https://agentwranglers.se/playbook/data/operations/collation-versions","related":["/playbook/website/languages-and-regions/locale-collation"],"sources":[{"label":"PostgreSQL ALTER COLLATION","url":"https://www.postgresql.org/docs/current/sql-altercollation.html"},{"label":"PostgreSQL locale support","url":"https://www.postgresql.org/docs/current/locale.html"}]},{"chapter":"Caches and copies","name":"Name the source of truth","level":"Recommended","rule":"For every piece of data, one store is the source of truth. Caches, search indexes and replicas are copies that can be rebuilt from it.","check":"Empty the cache and drop the search index on staging, then run the rebuild. The app works and shows the same results.","url":"https://agentwranglers.se/playbook/data/caches-and-copies/source-of-truth","related":["/playbook/ai-features/retrieval/index-freshness","/playbook/app-ui/client-and-server-state/server-state-cache"],"sources":[]},{"chapter":"Caches and copies","name":"Decide how stale a cache may be","level":"Recommended","rule":"Give every cached value an expiry or an invalidation rule, chosen for how stale it may safely be.","check":"Change a cached value through the app. The next read shows the new value, or the stale one only within its stated expiry.","url":"https://agentwranglers.se/playbook/data/caches-and-copies/cache-freshness","related":["/playbook/website/performance/cache-control","/playbook/data/caches-and-copies/replica-lag","/playbook/ai-features/latency-cost-and-fallbacks/answer-cache","/playbook/app-ui/client-and-server-state/refresh-after-change","/playbook/stack/databases-and-storage/redis-or-valkey"],"sources":[]},{"chapter":"Caches and copies","name":"Read from replicas only what may lag","level":"Recommended","rule":"Send reads to a replica only when a few seconds of delay is acceptable. Read your own writes from the primary.","check":"A test saves a change and reads it back at once, 100 times in a row, while replica lag is simulated. Every read shows the change.","url":"https://agentwranglers.se/playbook/data/caches-and-copies/replica-lag","related":["/playbook/data/backups-and-recovery/failover","/playbook/data/caches-and-copies/cache-freshness"],"sources":[]},{"chapter":"Caches and copies","name":"Keep search indexes in step","level":"Recommended","rule":"Update the search index from the source of truth through a reliable queue, and rebuild it fully on a schedule.","check":"Delete a product. Within the stated delay it disappears from search, and a full rebuild finds no differences.","url":"https://agentwranglers.se/playbook/data/caches-and-copies/search-index-sync","related":["/playbook/data/transactions-and-concurrency/outbox","/playbook/ai-features/retrieval/index-freshness","/playbook/stack/databases-and-storage/full-text-search"],"sources":[]},{"chapter":"Product analytics","name":"Keep a tracking plan in the code","level":"Recommended","rule":"List every analytics event in one file in the repository, with its name, purpose, owner and properties. Name events as an object and a past-tense action.","check":"A test reads the plan and fails on any name that does not match `^[a-z]+(_[a-z]+)+$`. A grep finds no call to the raw `track(` function outside the generated module.","url":"https://agentwranglers.se/playbook/data/product-analytics/tracking-plan","related":["/playbook/website/privacy/analytics-privacy","/playbook/ai-features/logs-privacy-and-feedback/feedback-on-every-answer"],"sources":[{"label":"JSON Schema 2020-12","url":"https://json-schema.org/draft/2020-12"}]},{"chapter":"Product analytics","name":"Validate every event before you store it","level":"Recommended","rule":"Check each incoming event against its schema at the collector. Store valid events, and set invalid ones aside with the reason.","check":"Send one valid and one invalid event to the staging collector. The valid one is in the events table, and the invalid one is in the rejects table with its error.","url":"https://agentwranglers.se/playbook/data/product-analytics/event-validation","related":["/playbook/data/transactions-and-concurrency/dead-letter-queue","/playbook/architecture/data-shapes/parse-at-the-boundary"],"sources":[{"label":"JSON Schema 2020-12","url":"https://json-schema.org/draft/2020-12"}]},{"chapter":"Product analytics","name":"Track only after consent","level":"Required","risk":"law","applies":["eu-users"],"rule":"Send no analytics event that uses device storage or identifies a person until that person has agreed. Read the stored choice in the tracking module itself.","check":"A browser test with a fresh profile clicks through sign-up and checkout without consenting. The collector receives no event with a user or device ID, and no analytics storage exists.","url":"https://agentwranglers.se/playbook/data/product-analytics/analytics-consent","related":["/playbook/website/privacy/cookie-consent","/playbook/website/privacy/analytics-privacy","/playbook/website/privacy/global-privacy-control","/playbook/email/email-privacy/tracking-pixels"],"sources":[{"label":"ePrivacy Directive Article 5(3)","url":"https://eur-lex.europa.eu/eli/dir/2002/58/oj"},{"label":"GDPR Article 7 on conditions for consent","url":"https://eur-lex.europa.eu/eli/reg/2016/679/oj"}]},{"chapter":"Product analytics","name":"Keep personal data out of events","level":"Required","risk":"data","rule":"Never put names, email addresses, phone numbers, free text or full URLs with query strings in event properties. Use an internal ID that analytics cannot turn back into a person.","check":"A query over one day of events finds no `@`, no run of 8 or more digits and no `?` in any property value. The schema test fails on any property of type free string not marked as an ID.","url":"https://agentwranglers.se/playbook/data/product-analytics/events-without-personal-data","related":["/playbook/operations/logs/logs-without-personal-data"],"sources":[{"label":"GDPR Recital 26 on pseudonymised data","url":"https://eur-lex.europa.eu/eli/reg/2016/679/oj"}]},{"chapter":"Product analytics","name":"Record business facts on the server","level":"Recommended","rule":"Record events that count money, sign-ups or other business facts on the server, at the moment the fact is committed. Use browser events only for behaviour in the page.","check":"Place 20 test orders with an ad blocker on. The events table holds exactly 20 `order_completed` rows, matching the orders table. A daily query compares both counts and alerts on any difference.","url":"https://agentwranglers.se/playbook/data/product-analytics/server-side-business-events","related":["/playbook/data/transactions-and-concurrency/outbox","/playbook/adversarial-testing/abuse-and-limits/server-side-business-rules"],"sources":[]},{"chapter":"Product analytics","name":"Define each metric once","level":"Recommended","rule":"Define every business metric once, in version control, and build every dashboard and report from that definition.","check":"A script lists every saved dashboard query and fails on any that computes a named metric without the shared view or metric. Two dashboards showing the same metric for the same day show the same number.","url":"https://agentwranglers.se/playbook/data/product-analytics/metric-definitions","related":["/playbook/engineering/documentation/one-home-per-value","/playbook/data/product-analytics/exclude-test-traffic"],"sources":[]},{"chapter":"Product analytics","name":"Keep test and bot traffic out of the numbers","level":"Recommended","rule":"Mark every event from staff, test accounts, automated tests and known bots at the collector, and leave marked events out of every metric.","check":"Run the browser test suite against staging with the header. The collector stores its events with the flag set. A query over every metric view finds no row built from a flagged event.","url":"https://agentwranglers.se/playbook/data/product-analytics/exclude-test-traffic","related":["/playbook/data/product-analytics/metric-definitions"],"sources":[]},{"chapter":"Product analytics","name":"Write the experiment plan before it starts","level":"Recommended","rule":"Before an A/B test starts, write down its one primary metric, guardrail metrics, minimum effect, sample size and stop date. Decide only at that sample size.","check":"Every experiment in the registry has a plan row dated before its start, with all five fields. A query finds no experiment ended before its planned sample size except by a guardrail stop.","url":"https://agentwranglers.se/playbook/data/product-analytics/experiment-plan","related":["/playbook/data/product-analytics/experiment-assignment"],"sources":[]},{"chapter":"Product analytics","name":"Assign each user to one variant for good","level":"Recommended","rule":"Assign variants by hashing a stable user ID with the experiment name, so a person sees the same variant on every visit and device. Log an exposure when the variant is shown.","check":"A unit test assigns the same user 1000 times and gets one variant. A test over 100 000 fake IDs gets a 50/50 split within 1 percent. The analysis script stops on a sample ratio mismatch.","url":"https://agentwranglers.se/playbook/data/product-analytics/experiment-assignment","related":["/playbook/data/product-analytics/experiment-plan","/playbook/engineering/running-in-production/deploy-is-not-release"],"sources":[]},{"chapter":"Product analytics","name":"Delete raw events on a schedule","level":"Recommended","applies":["eu-users"],"rule":"Keep raw events with an analytics ID for a fixed time only, such as 14 months. Then delete them or roll them up into counts without IDs.","check":"A query finds no raw event older than the stated period. The aggregate table still has rows from before it, and none of them hold an ID.","url":"https://agentwranglers.se/playbook/data/product-analytics/analytics-retention","related":["/playbook/data/personal-data/retention-jobs","/playbook/compliance/records-and-data-map/retention-schedule"],"sources":[{"label":"GDPR Article 5 on principles of processing","url":"https://eur-lex.europa.eu/eli/reg/2016/679/oj"}]}]}