Built for a real business. Running in production. Whetstone Platform is a multi-tenant SaaS pricing tool I designed, built, and operate end to end — from the Flask application and database layer through the container infrastructure, CI/CD pipeline, and serverless backend. The problem it solves is real. The infrastructure is production-grade. It has paying subscribers.
terraform apply or terraform destroy. Nothing was clicked into existence manually.
get_credentials() helper works whether the source is a file path (local dev) or a JSON string (ECS). Nothing sensitive touches the codebase or sits on disk.
terraform apply builds everything. One terraform destroy tears it all down cleanly.
whetstone-cicd IAM user with least-privilege permissions handles all AWS operations.
payment_succeeded creates a Cognito user, writes a tenant record to DynamoDB, and sends a welcome email via Brevo SMTP. invoice_payment_failed disables the Cognito account and notifies the admin. customer_subscription_deleted removes access and updates the tenant record. The entire customer lifecycle — signup to cancellation — runs without human intervention.
whetstone/* prefix: Flask secret key, Google service account JSON, Cognito config, buyer sheet IDs, Stripe keys, and Brevo SMTP credentials. The ECS task role grants the container permission to retrieve them at startup — no access keys stored in environment variables, no credentials in the codebase, no secrets in version control. The Lambda function accesses the same Secrets Manager entries via its own least-privilege execution role.
cancel_at_period_end and a confirmation modal to prevent accidents. Custom forgot-password flow using DynamoDB tokens with 1-hour TTL and Brevo delivery — completely replacing the original Cognito/SES dependency that was permanently rejected for this AWS account. Payment failure handling fires operator notifications on invoice.payment_failed with Stripe dunning configured for 4 retries before subscription cancellation.
static/css/whetstone.css stylesheet with CSS custom properties for the full color palette and typography scale. Removed approximately 1,100 lines of duplicated inline CSS across ten templates. All authenticated pages now pull from the shared stylesheet, making platform-wide design changes a single-file edit.
/login on mobile with no error. No Set-Cookie header visible in mobile browser network tab.
return statement — dead code, no syntax error, no warning, only caught by tracing the HTTP response. Second: SESSION_COOKIE_SAMESITE had an unclosed string quote, silently invalidating the config. Mobile browsers enforce cookie policies more strictly than desktop. Fixed both, set SESSION_COOKIE_DOMAIN = None so the cookie scopes to whatever domain serves the request, and set SESSION_COOKIE_SAMESITE = 'Lax' correctly.
curl, which isn't present in slim Python images. Replaced the health check with a Python one-liner using urllib from the standard library — no additional dependencies required. The container started passing health checks immediately.
:3 to :2 after a terraform apply. Site returned 502.
terraform apply ran, it saw the newer task definition as drift and corrected it back to the version in state. Manually forced the service back to the correct task definition with aws ecs update-service --task-definition whetstone-app:3. Lesson: when CI/CD deploys new task definitions outside of Terraform, running terraform apply will roll back to the state's version. Manage task definition updates through Terraform or accept that the pipeline owns that resource.
whetstoneplatform.com at the old EC2 IP from a previous development session. The hosts file overrides everything — DNS flush, ISP cache, Google DNS, all of it. Removed the stale entry from C:\Windows\System32\drivers\etc\hosts. Site resolved correctly immediately. The hosts file is the first thing to check when DNS changes aren't taking effect on a specific machine.
{"error":"You did not provide an API key"} on the deployed ECS service.
get_secret() but the task definition only referenced the original Phase 2 secrets — the Stripe keys weren't in scope for the running container. Registered a new task definition revision with the Stripe secrets added and updated the ECS service to use it.
whetstone/brevo-smtp. Brevo free tier (300 emails/day) covers low-volume transactional email without any configuration issues.
terraform apply errored with "Tried to create resource record set but it already exists" during DNS cutover from EC2 to ECS.
allow_overwrite = true to both aws_route53_record resources in the cutover Terraform config. Applied cleanly on the next run.
whetstone-operators table is keyed by operator_id (UUID), not email. The /app route was calling get_item with Key={'email': email}, which raised a ClientError on key schema mismatch. A broad except block swallowed the error and left show_intro = False. Same bad key pattern was in the cancel-subscription route, meaning self-service cancel always returned "Account not found." Fix: moved the has_seen_intro flag to whetstone-operator-prefs (keyed by email), updated both read and write paths. Lesson: DynamoDB will not warn you about a wrong key at write time — only on get_item or update_item. Know which table uses which key before you write the route.
filter_log_events without a stream name searches all streams but hits the oldest stream first within the result limit. Lambda creates a new log stream per execution context, so recent runs were in newer streams the query never reached. Fix: call describe_log_streams with orderBy=LastEventTime and descending=True first, then pass the most recent stream name explicitly to filter_log_events. Lesson: never query CloudWatch logs across all streams for a Lambda that has accumulated multiple execution contexts — always identify the most recent stream first.
UpdateItem throwing ValidationException in production on a contact edit route. Error: "Attribute name is a reserved keyword: source."
source as a reserved keyword. Using it directly in an UpdateExpression causes a runtime ValidationException — no error at write time, only on update. Fix: added #src to ExpressionAttributeNames mapping to source, updated UpdateExpression to use #src. Same pattern already used for name → #n in the same call. Lesson: DynamoDB reserved keyword errors are runtime errors, not syntax errors. Would have been caught immediately with local dev testing before deploy.
.get() calls — admin dashboard showing 0 active subscribers and $0 MRR despite subscribers existing in DynamoDB.
sub.get('field_name') throws a KeyError because the SDK intercepts attribute access and treats 'get' as a key lookup. Fields like trial_end, current_period_end, and discount were all throwing silently inside an except block, preventing counters from incrementing. Fix: replaced all .get() calls with individual try/except blocks using direct attribute access. Lesson: never use .get() on Stripe SDK objects — the SDK's __getattr__ intercepts everything.
sheets.googleapis.com.
onclick not firing for contacts with spaces in their name. btn.onclick returned null in the browser console even though getAttribute('onclick') showed the string was present.
onclick attribute used double quotes as the HTML delimiter. The Jinja tojson filter wraps string values in double quotes. A contact name like "John Smith" produced onclick="showPurgeModal('id', "John Smith")" — the browser parsed the attribute as ending at the second double quote before the name, leaving the handler malformed and unbound. Fix: replaced inline onclick with data attributes (data-contact-id, data-contact-name using Jinja's | e filter for HTML encoding) and a delegated event listener on document. Lesson: never mix tojson output inside an HTML attribute delimited by double quotes — use data attributes and event delegation instead.
actions/checkout@v4 and aws-actions/configure-aws-credentials@v4 may not work as expected." Hard deadline: Node.js 20 forced to Node.js 24 on June 16, 2026.
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true to the env block in deploy.yml — opts the entire workflow into Node.js 24 without requiring version bumps on individual actions. Lesson: when action version bumps aren't available yet, FORCE_JAVASCRIPT_ACTIONS_TO_NODE24 is the clean interim fix. Check for updated action versions periodically and bump them properly when they're released.
/admin/analytics showing inflated quote counts — test account activity was being included in operator-facing metrics despite being filtered correctly on per-operator views.
TEST_ACCOUNT_EMAILS filter was applied to per-operator quote breakdowns but not to the platform-wide aggregation queries that drove the summary tiles. Two separate code paths — one filtered, one not — both contributing to the same displayed metrics. Fix: added FilterExpression=not_test_account to all DynamoDB scan and query operations on the analytics route. Lesson: when the same data is read through multiple code paths, each path needs its own filter. A filter applied in one place doesn't automatically apply everywhere that touches the same table.
platform/ flagged as a stdlib shadowing risk before any code was written.
sys.path. Any top-level directory named after a stdlib module becomes a local package that shadows the real one. Flask, Gunicorn, and boto3 all use Python's platform module internally for OS and version detection — shadowing it causes obscure import failures that are very difficult to diagnose. Named the platform layer directory whetstone/ instead. lambda/platform/ and templates/platform/ are safe — they are not Python packages and are not on sys.path. Lesson: never name a top-level Python package after a stdlib module. Verify with python3 -c "import <name>" before choosing a directory name.
whetstone-pricing to whetstone-platform. ECR repository names are immutable — no in-place rename exists in AWS or Terraform.
app_v2 ECR resource alongside the existing one and applied — AWS created the new repo. Updated the ECS task definition image reference to the new repo URL and merged to main — the CI/CD pipeline pushed the image to the new repo and ECS deployed from it. Verified production was serving correctly, then deleted the old repo via AWS CLI and cleaned up Terraform state with terraform state rm and terraform state mv to rename app_v2 back to app. Final terraform apply showed zero changes. Lesson: for any immutable AWS resource rename, the pattern is always create-new, migrate traffic, delete old, then reconcile Terraform state. State surgery is safe and reversible — it only touches the state file, not AWS.
null to DynamoDB on checkout completion despite the promotion code being present in the Stripe dashboard. Standard dict and attribute access patterns all returned None.
stripe.PromotionCode.retrieve() returns a StripeObject, not a plain dict. Calling .get('code'), ['code'], or getattr(obj, 'code', None) are all unreliable on these objects. str(promo_obj) produces valid JSON with all fields present. Fix: json.loads(str(promo_obj)).get('code'). Additional finding: when a checkout total is $0 due to a 100% coupon, Stripe sends an empty discounts array on the session object — the discount is on the subscription object instead, requiring a fallback retrieval path. Lesson: never use .get() or getattr() directly on Stripe SDK objects. Use json.loads(str(obj)) to get a plain dict with reliable field access.
'dexcom g7' never matched FCM's product names because FCM uses 'G7 1 Pk...' without a Dexcom prefix — verified by running parse() locally and filtering output. Changed keyword to 'g7'. Second: the sheet URL was being appended to all_issues as its own list item, causing len(all_issues) to count it as an additional issue. Refactored to append the sheet URL inline to the last real issue entry instead. Lesson: canary keywords must match parser output exactly — check parse() output locally before writing canary strings. And when a counter drives user-visible output like an email subject line, audit every item that gets added to the list it counts.