logicspike/docs

Communication

SES-readiness gap log

Standing rule (owner, 2026-10-05): every gap or improvement noticed while building is fixed in the same change or recorded here with an owner and a reason. Nothing found is left unrecorded and nothing is mentioned only in chat. A row is closed only when the fix is committed and says how it was checked. Created 2026-10-05 on branch ses/production-access; nothing here is pushed or deployed unless a row says so.

Status words: open (not started) · fixed (committed, not deployed) · deployed · accepted (a conscious decision, reason given) · user (needs your console, secret, DNS or production data) · log (recorded for a later phase, reason given). Steps refer to the build order in the plan: A1…A14 are Track A (unblocks the AWS request), B1…B7 are Track B (privacy and data lifecycle).

Open at a glance (2026-10-07)

The rows below are the full record. This is what is still to do.

Yours to run or decide

ID What Next step
S5 Contacts' INTERNAL_KEY set 2026-10-27, so the daily workspace purge can complete its counting pass After a day, read the would_purge lines and set the switches (docs/data-lifecycle/plan.md, deploy record)
G61 Done 2026-10-26: abuse@ and postmaster@ passed the outside test, and the 502 un-consented contacts were deleted (G17) Nothing left
G59 Privacy says "SCCs with every sub-processor" List the providers whose terms you accepted; I reword the rest
G63 AWS contact details and the workspace's own address Set the AWS alternate contacts and the alarm email to your private inbox (decided); tell me if I may set the workspace address
G70 The handbook is reachable without a login (checked 2026-10-07) Put handbook.vlozi.app behind Cloudflare Access, as ops.vlozi.app is: switch its DNS record to Proxied, SSL/TLS Full (strict), add an Access application that allows your emails. Do it before the next push
G73 Database and R2 regions Read them from Neon and Cloudflare and tell me; I state them or soften
G24 Production INBOUND_DOMAIN It is set on comms (checked 2026-10-07) but I cannot read the value; tell me and the docs follow
G64 Log retention Read the real period from Cloudflare and tell me
G21 TRACKING_EMIT and UNSUB_V2_EMIT Switch on when you want first-party tracking, or the site claim is reworded
G25, G47 Two data scripts (G17 is done: the 502 contacts were deleted 2026-10-26) clear-system-mail-content.mjs, close-deleted-workspaces.mjs, each dry run first
G32 Retention jobs nobody ran: deployed 2026-10-27 (data-lifecycle S3, engagement 54be06c8, flows 2934304f, billing-crons 9bdb38a6). Comment IPs and flow runs now run daily from the billing ticker, counting only until each service's RETENTION_ENFORCE; media's cleanup was already running (its janitor, live 2026-10-12) Deploy, read a day of counts, switch on

Mine to build or check

ID What
G11, G12 Reject mail to unknown addresses instead of dropping it: built 2026-10-21, deployed 2026-10-21 (main 4e24ab6b; comms 94c8834c, rollback fd6d99ec; mailbox 3b790d0e, rollback 6dfeb7b6). To check: send from an outside account to a made-up @vlozi.app address; a bounce should come back
G14, G36 Retention jobs: 90 days for content, 13 months for metadata, 30 days for payloads: deployed 2026-10-27, counting only (comms cde984f3, indexes created); owner sets RETENTION_ENFORCE after a day of counts
G16, G35 Subscriber erase that really erases, with a hashed trail: built 2026-10-22 (S2, newsletter lib/erase.ts + comms /internal/erase-recipient), deployed 2026-10-27 (docs/data-lifecycle/plan.md S2)
G15, G26 (billing), G28 to G31 Workspace purge after 90 days (comms, newsletter, mailbox, contacts and forms deployed 2026-10-27, counting only (data-lifecycle S5, S6); chat and the rest are S6); cancel billing on delete (deployed 2026-10-27, S4, and the Privacy and Terms pages say so since that day); per-service erase
G43 Dashboard error-message sweep, about 35 call sites
G65, G67 A subscriber CSV export; a staff tool for platform-wide suppression
G72, G74, G75 Staff suspend across services; every service honouring a deleted workspace; unsubscribe enforcement outside newsletter campaigns

Logged for later, or accepted: G18, G19, G20, G22, G23, G45, G48, G50, G52, G53, G54, G55, G56, G60.

ID Gap Found in Status Owner Step / reason
G1 A workspace/user name with a newline makes comms reject the whole email (CR/LF check, 400), so invites, welcome and billing/dunning notices vanish. Subjects interpolate raw workspaceName, inviterName, first name communication.templates.ts subjects; comms index.ts subject refine; swallowed at team.ts, communication.ts fixed (A2) me A2: oneLine() in @repo/email-kit, applied to every user-chosen value in the 7 manager templates; invite shows the workspace name as quoted text. Related sites found while fixing: G40, G41
G2 Workspace names validated three different ways (3–50, 1–100, 1 with no max); a whitespace-only rename writes an empty name; no control/bidi stripping; users.name unbounded onboarding.ts, workspaces.ts, tenants.ts, register.ts, oauth.ts fixed (A3) me A3: normalizeWorkspaceName (2 to 60 characters by code point, NFC, control/bidi/zero-width stripped, whitespace collapsed, at least one letter/number) in @repo/core-tenant, called by all four routes (422 with a plain-string error + code) and as a throwing backstop inside the tenant factory; sign-up and OAuth sanitise leniently so they never fail over a name; profile update is strict. No brand-word block: it would reject the platform's own rename to "Vlozi" and real businesses ("Apple Orchard Co"); impersonation is handled by quoting the workspace name in the invite template
G3 Zod failures return {error: ZodError}, so the dashboard shows [object Object] and onboarding's setError(data.error) would throw as a React child settings/page.tsx, onboarding/page.tsx, workspace/new/page.tsx fixed (A4) me A4: errorMessage() in src/lib/api-error.ts reads a plain string, a machine code beside a sentence (shows the sentence, never the code) and a zod failure (first issue), and always returns a string. Applied to the rename, onboarding, new-workspace pages, the transfer/delete handlers beside the rename, the profile hook, and the onboarding proxy (which dropped message and code). Onboarding also checks 2 to 60 characters on the client and sends a name the server rejects back to step 1, where the field is. The rename input limit is now 60. Same pattern elsewhere: G43
G4 Alert emails show raw machine strings (ai.customer_chat.sla_breach); the friendly default never renders chat-engine scheduler.ts, comms alert routes fixed (A5) me A5: an identifier-shaped reason (letters/digits joined by . _ : -, no spaces) is replaced by a plain sentence; a reason written as a sentence, or a single ordinary word, is shown as written; the out-of-credits sentence names the channel ("on WhatsApp") and is escaped once (the channel used to be escaped twice)
G5 Alert routes echoed the recipient address in the response comms index.ts alert routes fixed (A9) me A9: the three routes now answer {sent: true, id}, {skipped: true, reason} or {sent: false, suppressed: true}; the address never appears. A suppressed owner address is reported as not sent instead of "sent"
G6 Preheaders double-escaped (& in inbox previews) welcomeEmail, workspaceCreatedEmail, trialEndingEmail; email-kit wrap() fixed (A2) me A2: templates pass plain text, wrap() escapes once; a comment on wrap() says so. Checked: no other worker double-escapes
G7 Docs name a switch that does not exist (UNSUB_MAILTO); stale comment "windows ≤ 60s" on a job that prunes 24 h windows correctly; the contact doc assumes the catch-all is the comms worker (unverified); backlog.md says campaign-failed goes to the reply-to address phase-b.md, billing.ts, contact-addresses.md, README, readiness checklist, docs/communication/backlog.md fixed (A1, A9) me A1 fixed the first three and the catch-all hedge in four docs. The backlog.md line was corrected in A9 together with the code.
G8 Owner alerts went to a tenant-chosen, unverified address; the Vlozi workspace has none set, so its alerts were silently skipped comms resolveOwnerEmail fixed (A8-A9), deployed 2026-10-06 me A8-A9: lib/owner-email.ts reads the verified account email of the workspace owner from the core DB (memberships joined to users and tenants; owner = flagged is_owner OR holding role_owner, flagged first then earliest joined; the user must be verified and the workspace not deleted). sender_settings.sender_email is no longer used for alerts. The Vlozi workspace now gets its alerts as soon as its owner account is verified, with nothing to configure
G9 Owner alerts unbounded per tenant (SLA alert was per session, campaign-failed per (campaign, failedAt)) comms alert routes fixed (A9), deployed 2026-10-06 me A9: SLA alert is at most one per workspace per hour (any number of sessions); campaign-failed is one per campaign per UTC day (it keyed on the failure time, different every time); out-of-credits was already one per day; the reputation alert is bounded by its state machine
G10 The owner lookup would hit the core DB on every chat message (chat-engine awaits out-of-credits in the webhook path) design fixed (A8) me A8: 60 s per-isolate cache, positive and negative, never caching an error (so a database blip cannot silence alerts for a minute), bounded to 500 entries
G11 Mail to an unknown recipient is accepted and silently dropped by the comms worker. (The founder confirms the catch-all hands mail to the worker, 2026-10-06, so the earlier "may be a Cloudflare Drop rule" doubt is settled; the Activity check is no longer a gate.) comms inbound.ts; SMTP probe fixed (B1), deployed 2026-10-21 me, you (send one test to a made-up address) B1: the comms worker now refuses the message (setReject, a permanent SMTP error, so the sender's server bounces it) only on proof: a reply+ address for a send that doesn't exist, an address every lookup says nobody holds (the root vlozi.app account, the mail.vlozi.app map and the legacy alias), a deactivated mailbox (same words as an unknown one, so a bounce doesn't reveal it was ever claimed), a message over 25 MB, an address it can't read, or the mailbox answering the forward with 410 or 413. Never bounced: any lookup that failed instead of answering (unbound binding, network error, 5xx, 403, a plain-text 404, a bad body), a mailbox forward that fails, and an unsub+ opt-out to an unknown send. Those are logged with reason= (lookup_failed, missing_binding, forward_failed) and lost as before. A database error still throws out of the handler, as it always did. Tests: comms inbound.test.ts (old drop cases now expect a bounce; 12 new cases), mailbox-client.test.ts; mailbox accounts-lifecycle.test.ts; 15 deliberate breaks, all caught. built 2026-10-21, deployed 2026-10-21 (main 4e24ab6b; comms 94c8834c, rollback fd6d99ec; mailbox 3b790d0e, rollback 6dfeb7b6). To check: send from an outside account to a made-up @vlozi.app address; a bounce should come back
G12 resolveAddress returns the same {null,false} for "not found", "lookup failed" and "binding missing" mailbox-client.ts fixed (B1), deployed 2026-10-21 me B1: resolveAddress also returns status: found, inactive, not_found (only the mailbox route's own JSON 404) or unknown (everything else). tenantId and inactive are unchanged, so dispatchSend (CM-17) behaves as before. The mailbox's 404 now also carries code: ADDRESS_NOT_FOUND; the older {error: "not found"} answer still counts, so either worker can be deployed first
G13 Role addresses could not reach more than one inbox (a Cloudflare rule has one destination) Cloudflare docs closed by G49 — Moot: they are Mailbox mailboxes now, and a mailbox can be given to as many people as you like through Manage
G14 No retention or purge anywhere; the Privacy draft promises periods audit built 2026-10-21 (S1, lib/retention.ts), not deployed; ships counting only (RETENTION_ENFORCE off) me, you (index script, deploy, then the switch) Now docs/data-lifecycle/plan.md S1: the pass runs in the sweeper's 6-hour alarm
G15 Deleting a workspace/account is soft-delete only; nothing cascades workspaces.ts, tenants.ts first four services built 2026-10-22 (data-lifecycle S5), not deployed me Daily via the billing ticker → manager → each service's /internal/tenant-purge, behind TENANT_PURGE_ENFORCE; forms, chat, blog, media, content, flows, engagement, collections and the core rows remain (S6)
G16 Subscriber erase leaves the address in nl_subscriber_events and nl_campaign_sends audit NL-10 built 2026-10-22 (S2, newsletter lib/erase.ts + comms /internal/erase-recipient), not deployed me Same change as G35
G17 Vlozi's own ~503 contacts from the 2026-07-10 bulk import are active and mailable: import-contacts.mjs stamps confirmed_at = now() on every row when double opt-in is off, so consent was asserted, not given MCP check (507 total, 1 pending, 3 unsubscribed); scripts/import-contacts.mjs done 2026-10-26: deleted me What happened: the revoke script's dry run on 2026-10-26 found the batch (502 rows, created in one instant at 2026-07-10 19:36:20 UTC, all stamped confirmed) but matched none: the rows carry no metadata.importedFrom, so they came through the dashboard import route, not import-contacts.mjs (first committed 2026-07-11). The owner then chose deletion over revoking (and chose to keep everyone else). scripts/delete-unconsented-import.mjs deleted the 502 active rows imported that day, in one statement, with their 48 events (0 campaign sends, 0 clicks, 0 re-permission entries: none of them was ever mailed). Kept: 2 widget signups and 3 unsubscribes (the only record of those opt-outs). Checked over MCP: 5 subscribers left. The selector and everything it takes with it are tested on pglite (10 tests, 3 deliberate breaks caught). The original plan, kept for the record: A10: apps/newsletter-service/scripts/revoke-unconsented-import.mjs. Revokes consent and keeps every row (confirmed_at back to NULL; the dispatcher requires it, so they are never mailed), with one consent_revoked event per person written in the same statement as the update. Selector = the import script's own signature (source import, metadata.importedFrom, an imported event, consent stamped within 5 minutes of creation, no later confirmed/resubscribed event) on the batch day; refuses 0 or more than 600 matches and a tenant with no imports. Dry run by default (survey of every import batch, files, tags, three masked samples, the SQL, a rollback hint). Root cause for Phase B: an import with double opt-in off needs a consent attestation
G18 .dev.vars files hold live credentials agent finding accepted — Checked: gitignored and never committed. Keep it so
G19 senderEmail is also the Reply-To on tenant mail and the forward target for replies; tenant-settable and unverified comms index.ts, inbound.ts log Phase B Out of scope here; alerts stop using it in A9
G20 acceptInvitation hard-codes isOwner:false even for role_owner invites, so "owner" means two things services/team.ts log later A8's lookup treats isOwner or role_owner as an owner. Changing the ownership model is a separate decision
G21 Newsletter "real-time analytics" claim is untrue until TRACKING_EMIT is on; the waitlist is single opt-in site user you Your call to switch TRACKING_EMIT and UNSUB_V2_EMIT on. Waitlist rule: make it double opt-in or reword "no spam"
G22 With the declared-use guard on and SES approved, the Vlozi workspace's own form notifications (notify@mail.vlozi.app) are refused (503); its newsletter from vlozi.app still goes via Resend a65fe12b consequence accepted — The founder wants this workspace treated as an ordinary customer, so no Vlozi-only exemption. Submissions stay visible in the dashboard
G23 While tenant sending is off, a campaign fires, takes a credit hold, then auto-pauses on the first send newsletter startFire accepted — Nothing is lost and the hold is released on cancel or settle. Revisit when the switch goes on in Phase B
G24 apps/communication/.dev.vars sets INBOUND_DOMAIN="vlozi.app"; the docs say in.vlozi.app; the production value is unknown research user you Read the production value from where you set it (wrangler secret list shows names only); docs then follow
G25 Credentials are stored in comms logs forever. OTP codes, password-reset links and team-invite links (the invite URL is the bearer token) are written to comms_message_logs.content for tenant system; nothing reads that column communication.templates.ts; comms insert sites; research fixed in code (A7), deployed 2026-10-06; history still to clear me (code), you (script) A7: shouldStoreContent (src/lib/content-policy.ts) at all four insert sites. It is an allow-list, not the deny-list first planned: platform mail stores a body only if its type is known harmless (welcome, workspace-created, the four billing notices) or it is one of comms' own alert.* notices, so a NEW secret-bearing mail type stores nothing until someone lists it. Tenant mail is unaffected. History: scripts/clear-system-mail-content.mjs (dry run first) clears the bodies already stored; see "Your steps" below
G26 A deleted workspace is only a flag. API keys keep working, memberships stay, the subscription / recurring charges / add-ons are not cancelled, nothing downstream is told workspaces.ts, tenants.ts, api-key.middleware.ts partly fixed (A12, A13), deployed 2026-10-06; billing half (G26b) built 2026-10-22 (S4, services/workspace-billing.ts), not deployed me, you (migration, script) A12 added the deleted_at clock (migration is yours to run). A13: both delete routes now call one softDeleteWorkspace (services/workspace-deletion.ts) that, in one transaction, flags the workspace, stamps deleted_at once (a retried or racing delete never restarts the 90 days), revokes every API key of the workspace, and repoints every member's default (see G46). Still open (B5): the subscription, recurring charges and add-ons are not cancelled, so a deleted workspace is still billed; the delete must cancel billing and refuse if cancellation fails. Workspaces deleted before this change are handled by G47
G27 Account deletion is broken twice: 409 for the owner of an already-deleted workspace, 500 for any user with a row in a NO ACTION FK table; leftover verifications and invitations by email profile.ts fixed (A14), deployed 2026-10-06 me A14: only workspaces that still exist block the delete (a deleted one no longer does, and its memberships are removed with the rest); inside the one transaction, children go before the user row: notification and appearance preferences, todos, OAuth refresh tokens and authorization codes, CLI device-login requests; feedback is detached, not deleted (the table already supports anonymous feedback; switch to delete if you would rather erase it); invitations they sent stay with no inviter; pending invitations addressed to their email (the invitation id is a bearer token) and their one-time codes (email matched case-insensitively, and phone) are deleted. The list is every foreign key to users.id in the core schema, checked against schema.ts; the code says to extend it when a new table references users. The manager harness now has those tables with real foreign keys, so a missed one fails the test exactly as it fails in production. 12 tests, 17 mutations each caught (incl. removing the transaction, which fails the rollback test). Deliberately kept: ids in permission_audit_log (no FK on purpose, "the trail must outlive the member"), API keys they created (they belong to the workspace)
G28 Contact-intelligence deleteContact is a soft delete that leaves phone/Telegram ids, an LLM profile, snapshots and outreach; its comment claims the cascade fires contact-ops.ts workspace purge hard-deletes contacts (built 2026-10-22 (data-lifecycle S5), not deployed); the single-contact soft delete is still open (S6) me The cascade from ci_contacts covers all nine child tables
G29 Chat has no session or message delete; session ids embed phone numbers; the SessionStore DO has no purge chat-engine open me B6
G30 Mailbox deletes nothing: no message/thread/attachment delete, no R2 delete anywhere mailbox workspace purge built 2026-10-22 (data-lifecycle S5), not deployed: rows, the three R2 prefixes and the hub me Per-message delete beyond Trash is product work, not this gap
G31 Forms: deleting a form is a soft delete so every submission stays; no erase-by-email; uploaded files in media are not removed forms-service workspace purge built 2026-10-22 (data-lifecycle S6), not deployed: every form, submission, delivery and view count of a deleted workspace goes after 90 days me Still open: deleting one form keeps its submissions, no erase by email, and the files wait for the media step
G32 Three retention jobs exist but nothing runs them: engagement /cron/purge-comment-ips, flows purgeOldRuns, media garbage collection jobs.ts, flows and media wrangler.toml built 2026-10-22 (S3), not deployed me, then you (deploy, then the two switches) Billing ticker jobs purge_comment_ips (engagement) and purge_flow_runs (flows, new route /cron/purge-runs, batched), daily. Media was already handled: do/media-janitor.ts runs every 6 hours (live 2026-10-12), so this row's media part was stale
G33 The stale-claim sweeper may never have been armed in production. It arms only from GET /health, reachable only through the gateway with a JWT; nothing calls it. The retention pass would inherit the hole comms index.ts fixed (A6) me A6: armSweeperOnce (new lib/sweeper-arming.ts) asks for /ensure once per isolate from a global middleware (every route, including refused requests) and from the inbound email() entry point; it never throws or delays a request, resets and retries after a 60 s pause if the sweeper is unreachable or answers an error, and sends one ping for concurrent requests. /health keeps its ping. Still your check after deploy: wrangler tail logicspike-communication should show stale_claim_swept or (later) retention_pass lines within minutes of traffic; I cannot read prod logs
G34 check-prod-schema covered newsletter, comms and mailbox only; core DB changes (a mix of drizzle migrations and hand-applied SQL in manual-migrations/, the pattern that goes unapplied) had no drift check, the cause of earlier "migration never ran" incidents scripts/check-prod-schema.ts fixed (A11) me A11: --app core (also covered by --app all). Found while building it: the core database declares a second Postgres schema, communication, and the old check matched tables by name in ANY schema, so a missing table could have been satisfied by a same-named one elsewhere. The comparison is now in scripts/lib/schema-drift.ts and checks each table in its own schema (three queries per schema instead of per table). New --list mode loads each schema with no database, to prove it loads. The core URL is read from CORE_DATABASE_URL, else packages/core-database/.env (DATABASE_URL), else the comms or newsletter .dev.vars; the output names which source, never the value. 18 tests against pglite, mutation-checked
G35 Newsletter "erase" cannot erase. By design the events table keeps the address, and the delete handler adds a deleted event containing it subscribers.route.ts, mcp/routes.ts, schema.ts built 2026-10-22 (S2, newsletter lib/erase.ts + comms /internal/erase-recipient), not deployed me Delete, the MCP remove tool and the ops "Erase a subscriber" page all run one erase: the address leaves every newsletter table and comms' copies of the newsletter mail; the activity log keeps an HMAC fingerprint; an unsubscribe or complaint is kept as a comms suppression entry (the erase is refused if it can't be)
G36 comms_message_events.received_at is a trap as an age column: migration 0012 added it NOT NULL DEFAULT now(), so every old row carries the migration time apply-0012-deliverability.mjs fixed in S1 (2026-10-21), not deployed me The retention pass ages events by occurred_at; a test fails if it reads received_at
G37 Terms §13 and Privacy §6 conflate cancelling a plan (deletes nothing) with deleting a workspace (only hides it, with no export afterwards). The live site's Privacy, Terms and Security pages carried the same false "exportable 30 days, archived 60 days" promise drafts; apps/website privacy, terms, security pages fixed (A1) me A1: drafts reworded, bracketed sentences gated on their gaps; the three site pages now say what is true today (cancel deletes nothing; delete hides it; erasure on request within 30 days). Deployed 2026-10-06 (Cloudflare Pages 79cc17b9; checked on the live domain: the pages return 200, cite privacy@ and security@, and carry no placeholders)
G38 The product UI makes the same false claim: the Delete-workspace card says "Permanently delete this workspace and all its data", and the account page "all global data" seller-dashboard dashboard/settings/page.tsx, settings/page.tsx, settings/account/page.tsx fixed (A4b) me A4b: the delete-workspace card and dialog now say what happens (gone for everyone at once, cannot be exported afterwards, erasure on request at privacy@vlozi.app) and no longer claim "all its data" or "all services and memberships" are removed; the button reads "Delete workspace"; the account page describes what is deleted (login, profile, sign-in methods). Reword again when the purge ships (E3). The dashboard copy cites privacy@vlozi.app and is now live, so that mailbox must exist (G49, gate R4). A14 follow-up: two pieces of copy A4b missed still said "all associated data will be erased" (the account page's card, and the card on the settings overview); both now describe what the delete really removes (login, profile, settings, tasks, connected apps; workspace work stays with the workspace), and the Privacy draft line matches
G39 The newsletter backlog recommended purging the consent audit trail at 365 days, which would delete the proof that justifies mailing active subscribers docs/newsletter/backlog.md G2 fixed (A1) me A1: marked superseded with the decided design
G40 The comms campaign-failed alert builds its subject from the tenant-chosen campaign name. It stripped ASCII control characters but not Unicode line/paragraph separators or bidi overrides, and an empty name gave a hollow "" in the subject comms index.ts campaign-failed route fixed (A5) me A5: now oneLine (shared with the manager templates), bounded to 80, and no quotes when the name is empty. (My first note here said a CR/LF reached the subject; it did not, the route already stripped those.)
G41 Forms and engagement notification subjects embed tenant-chosen text (New submission: <form name>, New comment: <post label>). A newline makes comms reject the whole email (400), so the owner is silently never told about a lead or a comment forms-service notify.ts, engagement-service notify.ts fixed (A2b) me A2b: oneLine on the forms notification subject, the forms reply subject (the admin types it) and the engagement subject, each bounded to 80 characters (200 for the reply), with tests. Forms 211/211 and engagement 497/497 pass
G42 createTenant in the tenant factory is dead code (nothing calls it) and built a tenant from an unchecked name, a latent bypass of the name rule packages/core-tenant/src/tenant.factory.ts fixed (A3) me A3: guarded with the same check. Deleting it is a separate cleanup, left to its owner
G43 About 35 other dashboard call sites do new Error(data.error || "...") or setError(data.error) (members, billing, settings services, verify-email, chatbot channels, comms alias card and more). Each has the same flaw as G3 if its endpoint ever returns a zod object or a machine code seller-dashboard (grep "data.error ||") open me A15, after Track A: apply errorMessage() mechanically in its own commit. Not done here because it is a sweep across ~15 files unrelated to names, and most of those endpoints return plain strings today
G44 The three owner-alert emails (SLA breach, out of credits, campaign failed) and the reputation alert were plain unbranded <p> HTML ("Hi, ... — Vlozi") with emoji in two subjects, unlike every other platform email, which uses the shared email-kit frame comms index.ts alert routes fixed (A9b), deployed 2026-10-06 me A9b: lib/alert-email.ts (renderAlert) renders all four through wrap() from @repo/email-kit, takes plain text and escapes it once, and writes a deliberate plain-text part; the decorative emoji are gone from the subjects. Tests assert the frame, the single escape, the plain-text part and that the body (not just the hidden preheader) carries the message
G45 The console email provider (used only when no AWS credentials are configured) logs the first 300 characters of every email body, so a login code could reach the Worker logs if production credentials were ever missing. It is a developer convenience (read the OTP locally); the code sits deep in the HTML, so the 300-character window usually misses it apps/communication/src/providers/ses-provider.ts console fallback log me Left alone in A7 because changing it changes how developers read OTPs locally. Proposed fix: log the body only when an explicit COMMS_LOG_BODIES=true is set in .dev.vars; needs your OK on the dev-workflow change
G46 Deleting a workspace locked its other members out of login. Only the person who clicked delete had their default workspace repointed. Every teammate whose default was that workspace got "No active workspace found" (login.ts) even when they belong to another active one. The old code also took the newest membership first and only then checked whether it was deleted, so even the deleter got NULL when an older active workspace existed workspaces.ts, tenants.ts, auth/login.ts fixed (A13), deployed 2026-10-06 me A13: softDeleteWorkspace repoints every member whose default was the workspace, in one statement, to their most recently joined workspace that still exists (the status filter is in the query), or NULL (login then resumes onboarding). 17 tests incl. two active candidates, a deleted newer one, nobody left, and a rollback test proving all three writes go through the caller's transaction; mutation-checked (the first run missed "oldest instead of newest", test added)
G47 Workspaces already deleted still have live API keys and stranded members. The helper only fixes future deletes; every workspace deleted before it keeps active keys (the gateway checks the key, not the tenant) and its members' defaults still point at it production data fix built (A13), you run it me (script), you (run) A13: packages/core-database/scripts/close-deleted-workspaces.mjs (dry run first: counts per workspace, a member count, never an address) revokes those keys and repoints those members with the same rule as the helper; refuses to report success unless nothing is left. Its four statements are tested on pglite (8 tests), mutation-checked. See "Your steps"
G48 Access tokens outlive the account and the membership. Gateway access tokens are stateless, signature-checked JWTs valid 15 minutes (MCP OAuth access tokens 1 hour, core-auth/jwt.ts); nothing looks the user up per request. After an account deletion, a removed teammate or a deleted workspace, an already-issued token keeps working until it expires. Refresh stops at once (the refresh tokens and membership are gone) packages/core-auth/src/jwt.ts, gateway log later A14 deletes the user's OAuth refresh tokens, so an MCP client cannot renew; the remaining exposure is at most the token's remaining life, and it is the same for every membership change, so it is a platform trade-off, not an account-deletion bug. A revocation check would cost a lookup per request: decide deliberately, not as part of this work
G49 The platform's published role addresses could not be Mailbox mailboxes. The founder decided (2026-10-06) to receive abuse@, privacy@, postmaster@ and the rest in Vlozi's own Mailbox instead of Cloudflare rules (which I had recommended), with the mailbox domain being vlozi.app itself. Two things blocked it: the names are reserved for every workspace, and (on the shared lane) a mailbox costs 20 credits a month, which the free plan's 100 credits would not cover for ten and which would suspend an abuse inbox if it lapsed mailbox/routes/accounts.ts, mailbox/lib/receivable.ts, comms/inbound.ts, comms/index.ts, seller-dashboard CreateMailboxDialog.tsx fixed, deployed 2026-10-06 me, you (deploy, claim, test) Model: every workspace has mail.vlozi.app by default and may add its own domain; Vlozi's own workspace holds the verified vlozi.app domain, so its role addresses are mailboxes on vlozi.app. The workspace is recognised by that verified-domain row (no one can create it for themselves, only one tenant can hold it, comms already uses it to decide who may send as vlozi.app): no config, no tenant id in code, unreachable comms means no. Receiving: comms asks the Mailbox for an account on <name>@vlozi.app first and delivers into it; otherwise the old mail.vlozi.app lookup, so customers' aliases are unaffected. Provisioning: on vlozi.app only the platform's role and platform names (an ordinary name would shadow a customer's <slug>@mail.vlozi.app alias); the platform workspace may also claim those names on mail.vlozi.app (free, Vlozi sender name allowed). Never, for anyone: notify (the shared sender every workspace goes out from) and the brand names. Replies leave from <name>@vlozi.app through Resend, which serves vlozi.app, so the SES guard (G6) does not refuse them (a reply from a mail.vlozi.app role mailbox WOULD be refused until tenant sending is enabled). Shared rule isPlatformClaimableLocalPart in core-types; canReceiveDirectly in the Mailbox. Tests: core-types 7, mailbox 30 (vlozi.app lane, incl. 3 unit) + 25 (shared lane), comms 31 + 8 inbound + 3 guard, dashboard 7; each safeguard mutated and caught. Trade-offs accepted (contact-addresses.md §2): an abuse inbox depends on comms, the Mailbox and the catch-all all being up (failures are silent); unclaimed addresses are still black holes. Deploy comms, then mailbox, then the dashboard
G50 A reserved name is also refused on a workspace's OWN verified domain. support@acme.com, info@acme.com, admin@acme.com cannot be created as mailboxes by the domain's owner, because the shared list was written for mail.vlozi.app and is applied to every domain mailbox/routes/accounts.ts log me, later Found while building G49, unchanged (the new exception is the shared lane only). Probably wrong: on a customer's own domain the namespace is theirs and the brand-impersonation worry does not apply. Not changed here because it needs its own decision and tests (custom-domain mailboxes are send-only today, so it has little effect yet)
G51 The Mailbox could not send as the root vlozi.app, so a reply to abuse@vlozi.app would have left from abuse@mail.vlozi.app mailbox account model closed by G49 — Moot for the role addresses: they are accounts on vlozi.app itself, and replies leave from the same address. A custom domain other than vlozi.app is still send-only (audit roadmap 6.5)
G52 On the shared lane, the Create-mailbox dialog still advertises 20 credits a month for the platform workspace's role names, which are free there. (On vlozi.app the dialog now says free.) It shows the price for every name on mail.vlozi.app; the free decision is made in comms and the dialog is never told seller-dashboard CreateMailboxDialog.tsx log me, later Cosmetic. Use vlozi.app for the role addresses and it does not arise. Fixing it needs the provisioning response, or a small read, to say which names are free
G53 Three tables the core schema declares do not exist in the production core database. The drift check (2026-10-06) reports communication.message_events, communication.message_logs and communication.provider_configs missing. They are leftover declarations in packages/core-database/src/schema.ts (a communication Postgres schema from before comms had its own database); no app imports them (checked: nothing references providerConfigs, messageLogs or messageEvents from @repo/core-database; comms uses its own tables) packages/core-database/src/schema.ts, scripts/check-prod-schema.ts log me, later Harmless today, but it makes check-prod-schema --app core exit 1 forever, which hides a real drift. Fix by deleting the three dead declarations (and their relations) in a separate change, not by creating the tables. Until then read the core report as: only these three lines are expected
G54 Several workers answer on a public workers.dev address. The 2026-10-06 deploys printed logicspike-mailbox, logicspike-manager, vlozi-forms-service and vlozi-engagement-service URLs; comms and newsletter set workers_dev = false, and gateway is the intended public door. For mailbox, /internal/* is guarded only by INTERNAL_KEY (the same reason comms closed it: /internal is exempt from the gateway-key check) apps/*/wrangler.toml log me, later Pre-existing, not changed by this work. Closing it (workers_dev = false plus a redeploy) is cheap but each worker's service bindings and webhooks must be checked first, and the mailbox wrangler.toml is untracked. Recorded rather than done because it was not asked for and a wrong guess breaks inbound mail
G55 Mail to hello@vlozi.app does not reach the Mailbox. On 2026-10-06 the ten role mailboxes were created and each was sent a test from the platform. Nine (abuse, postmaster, privacy, security, ops, legal, support, billing, info) were seen arriving at the comms worker and filed by the Mailbox (worker logs: Email from … to:<name>@vlozi.app Ok, resolve-address 200, inbound 200, a realtime publish). hello@ was accepted by Cloudflare (Resend recorded it delivered) but neither worker saw it Cloudflare Email Routing for vlozi.app accepted (2026-10-07) you Almost certainly an existing explicit Email Routing rule for hello (a specific rule wins over the catch-all that hands the other addresses to the worker), forwarding it somewhere, probably a personal inbox. I cannot read Email Routing. Check whether the test mail [inbound test 5/5] hello@vlozi.app is in your own inbox or its spam. If it is, that confirms it. Then choose: keep the rule (hello@ keeps landing in that inbox and the hello mailbox stays empty) or delete the rule in Cloudflare so hello@ flows into the Mailbox like the rest. hello@ is already public on the live site, so this is not a new exposure, and mail to it was not lost if it reached that inbox Decided 2026-10-07: keep the rule. Mail to hello@ reaches the operator's personal inbox, so nothing is lost; the outside test (R4) is satisfied when a test mail arrives there.
G56 founder@vlozi.app is the AWS account's root address, and today an explicit Cloudflare Email Routing rule delivers it to a personal inbox, not the Mailbox. Until M3 it could not be a mailbox at all (not a claimable name). Now it can, but a specific rule beats the catch-all, so claiming the mailbox changes nothing until that rule is deleted Cloudflare Email Routing; AWS account contact accepted (2026-10-07) you Your decision: keep the rule (simplest; AWS mail keeps reaching an inbox that does not depend on this platform) or claim the mailbox and delete the rule. If you move it, keep the AWS alternate contacts (billing, operations, security) on an inbox outside vlozi.app: otherwise the account's recovery mail depends on the domain it is also sent from, and a DNS or routing mistake would cut both. Test from an outside account before and after. Found 2026-10-06 while choosing the operator identity Decided 2026-10-07: keep the rule. The AWS root address stays independent of this platform.
G57 Terms section 11 sent disputes to the courts in Bengaluru, but the operator is an individual in Azamgarh, Uttar Pradesh. The clause was carried over from the earlier page and named a city with no link to the operator apps/website/app/terms/page.tsx section 11; docs/legal/terms-of-service.md fixed (2026-10-07) me The owner chose the courts in Azamgarh, Uttar Pradesh. Changed in the page source and in the draft (clause and drafting note); governing law stays India. Counsel should still confirm the venue clause. Not deployed
G58 The postal address spelling "Deogoan" was taken exactly as typed and may be "Deogaon". It is now a public legal address, in the footer and every policy page apps/website/lib/legal.ts fixed (2026-10-07) you Confirm the spelling. One edit in lib/legal.ts (the footer and every policy page read it), then search docs/ for the old spelling (the drafts, the case message and the checklist carry the literal text). The AWS account's contact details must carry the identical text (readiness gate R2) The owner confirmed "Deogaon" on 2026-10-07. Changed in apps/website/lib/legal.ts and in every doc that carried the address. The AWS account contact must still carry the same text (R2).
G59 The Privacy page says international transfers rely on standard contractual clauses (or equivalent) "with every sub-processor". Not verified for each of the ten providers. The page said this before 2026-10-07 and the batch kept it. Also: the AI "does not train on customer data" sentence was removed from the new sub-processors page because no agreement is on record apps/website/app/privacy/page.tsx, apps/website/app/sub-processors/page.tsx open you (confirm), me (reword) List the providers whose data-processing terms you have accepted. Anything not confirmed gets reworded to "the provider's standard terms". Restore the no-training sentence for an AI provider only if you hold its agreement Update 2026-10-07: the Privacy page now rewords the SCC claim (only where a provider offers them) and no longer says providers are barred from training; it says what is sent and that each provider's API terms apply. Still open: read each provider's terms, and which Gemini tier is in use.
G60 docs/legal/terms-of-service.md is fuller than the live Terms page (the site page is a shorter, hand-written version). Only the operator identity, the pointers to the new policies, a transfer clause and the contact block were applied to the site docs vs site log later Reconcile with counsel's review. Until then the live page is what applies, and the draft must not be quoted as published
G61 Deploy order. The new Abuse page promises that abuse@ is monitored with a one-business-day acknowledgement, and the Anti-Spam page sets a no-unconsented-lists rule. Deploying before abuse@ and postmaster@ are tested from an outside account (readiness gate R4, gap G49) and before revoke-unconsented-import.mjs has run (gap G17) publishes statements that are not yet true apps/website/app/abuse/page.tsx, anti-spam/page.tsx open (overdue, 2026-10-07) you Do not deploy the website until both are done. Both are in your steps below and in the runbook. Found 2026-10-07 while building the pages The website was deployed on 2026-10-07 before the outside test and the revoke run (the live text was checked and matches the repository). The live Abuse page now promises a monitored abuse@ with a one-business-day acknowledgement, and the Anti-spam page a no-unconsented-lists rule. Do both now.
G62 The effective date was written in three places (LEGAL_EFFECTIVE, and a LAST_UPDATED in each of Terms and Privacy), so a deploy on a later day would leave stale dates on some pages apps/website deployed (2026-10-07) me Terms and Privacy now read LEGAL_EFFECTIVE. At deploy, set that one constant to the real deploy date; until then every page says 7 October 2026 The website was deployed on 7 October 2026, so the date on the pages is the deploy date.
G63 The AWS account's contact details and the Vlozi workspace's own organisation address (the one newsletter footers carry) are not yet the published operator and address AWS console; comms sender settings user you AWS: Account, Contact information, and the alternate contacts (keep them on an inbox outside vlozi.app, see G56). Workspace address: I can set it through the comms sender-settings tool when you say so; I will not write production settings without your explicit OK Decided 2026-10-07: AWS alternate contacts and the CloudWatch alarm email use the operator's existing private inbox (not named in the repo). Still open: the AWS console changes, and the OK to set the Vlozi workspace's own organisation address.
G64 Server-log retention is unknown. The Privacy page said logs are kept "~30 days, then aggregated or deleted"; nothing in the repo backs a number (the logs sit in Cloudflare's observability retention) apps/website/app/privacy/page.tsx fixed (page), open (the fact) you (read the setting), me (publish the number) The 2026-10-07 audit removed the figure ("kept briefly, then deleted"). Read the real retention in Cloudflare (Workers observability) and tell me; the page and data-retention-schedule.md then state it
G65 The live Privacy and Security pages promised exports that do not exist: "CSV / markdown / media bundle", "markdown export of blog content, CSV export of subscribers". What exists: form submissions (CSV or JSON), comments (CSV), a campaign's recipient report (CSV). Blog content only imports markdown, and subscribers have no download privacy and security pages fixed (pages), open (product) me, later The pages now name only what exists and promise a copy within 30 days for the rest. Product gap: a subscriber CSV export (audit NL-25), a blog markdown export, a media bundle. Marketing copy in apps/website/lib/products-data.ts matched a search for export wording; read it before launch
G66 The Security page said "There is no admin path that crosses tenants", but the internal operations dashboard (apps/internal-api, behind Cloudflare Access with a staff allow-list) reads across all workspaces security page fixed me The page now says so, and the Privacy page says staff can view workspace data for support, billing and abuse. Fixed on the same page: "TLS 1.3 on every connection", "re-encrypts to origin", "secrets never in code" (known committed credentials sit in scripts/, see the credentials note), token "rotation on suspicion", R2 "lifecycle-policied", Workers "region-pinned", SPF/DMARC "aligned"
G67 No staff tool adds a platform-wide suppression. The Abuse page offers to suppress a reporting address "for every workspace" on request; today that is a manual database write. The ops dashboard can read and clear suppression rows (Phase 4 PR6) but not add one abuse page; apps/internal-api open me, later Add POST /internal/admin/suppression (ops token, scope *) and a button in ops. Until then the operator does it by hand and the promise is kept manually
G68 Whether REPUTATION_ENFORCE is on in production is not known to me (it is a comms secret). The Anti-Spam page says sending "may" be paused at the thresholds, which is true either way (staff can pause by hand), but automatic pausing only happens when the switch is on anti-spam page closed (2026-10-07) - Check wrangler secret list for comms (names only) and tell me whether it is true. If it is not, say so in the AWS request and do not claim automatic pausing Checked 2026-10-07: REPUTATION_ENFORCE is not in comms's wrangler secret list, so it is off and nothing pauses automatically. The AWS request must not claim it. Turn it on only when the ops pages are proven (Phase B).
G69 Terms section 5 said "The chatbot answers from your own posts and cites the source on every reply". There is no citation logic in chat-engine or the brain service terms page fixed (2026-10-07) me The sentence is removed from the live page and the Terms draft. Nothing to build unless citations are wanted
G70 The docs site (apps/docs, handbook.vlozi.app) serves everything under docs/ with no exclusion list. If it is not access-protected, these files are public: the private ops@ address, worker version IDs, security gap descriptions and internal analysis apps/docs/src/lib/docs.ts open (confirmed 2026-10-07) you Confirm the site is behind Cloudflare Access or similar, or exclude docs/communication and docs/legal. Now a pre-flight item for the website deploy (R8). Found in the 2026-10-07 audit Checked 2026-10-07: the site (Vercel, not proxied by Cloudflare) serves its pages to anyone who has the address, with no login. It sends noindex, which asks search engines to stay away but is not access control. The owner states it is purely internal and shared with no one, so exposure depends on the address staying unknown. Recommended fix: Cloudflare Access, the same setup as ops.vlozi.app (DNS record Proxied, SSL/TLS Full (strict), an Access application with an allow policy on the team's emails and One-time PIN). I cannot tell whether a push redeploys it, so do this before the next push.
G71 The Terms say subscription refunds are "prorated within 7 days of payment, on request". There is no refund flow in the code (the manager only observes refunds made in the payment dashboard), and "prorated" is undefined terms page fixed (2026-10-07) you (policy), me (reword) Decide what you really do, then reword. The sentence was on the page before the audit Decided 2026-10-07: within 7 days of a payment, on request by email, the payment minus the credits used; pack credits stay non-refundable. The Terms page and draft are updated; refunds stay manual.
G72 The Acceptable Use and Terms pages reserve the right to suspend an account, but no staff control suspends a workspace across services; there is only the reputation pause (needs REPUTATION_ENFORCE) and a manual pause in the ops tool acceptable-use page; terms open me, Phase B Staff tenant suspend is a Phase B control. Worded as a right, which is true; do not describe it as automatic
G73 The Privacy and sub-processors pages name Mumbai (ap-south-1) for the primary database, but no region appears anywhere in the repo except those sentences, each service has its own database, and the R2 location is unset privacy and sub-processors pages user you Confirm each database's region in the Neon console and the R2 location in Cloudflare, then state them, or soften
G74 Deleting a workspace flags it and revokes API keys, but only the manager, the newsletter MCP and the owner-email lookup check the workspace status. Public forms, chat widgets and inbound mail for a deleted workspace may keep working (from the 2026-10-07 audit; recheck each service) all services partly built 2026-10-22 (data-lifecycle S5), not deployed: comms now refuses every send and every inbound message for a deleted workspace (P3), which stops campaigns, signup confirmations, form notifications and mail to its addresses in one place; a deleted workspace's campaign pauses on the refusal me Public forms and chat widgets still accept submissions and messages until S6
G75 Unsubscribes and the automatic footer, one-click header and address check apply to newsletter campaigns only. Flows and form replies have no suppression or unsubscribe checks, and mailbox mail has none (from the 2026-10-07 audit; recheck before building) flows-service, forms-service, mailbox open me, Phase B The Anti-spam page now says "campaigns". The product gap is enforcement across lanes
G76 The Terms draft lets customers use Vlozi "as an agency for your clients", but the live Acceptable use page forbids sending "on behalf of customers you have not disclosed to us" and gives no way to disclose them docs/legal/terms-of-service.md; apps/website/app/acceptable-use/page.tsx fixed (2026-10-07) you Decide whether agency use is allowed and how a customer discloses its clients, then align both. Not changed because it is a policy choice. Found in the 2026-10-07 audit Decided 2026-10-07: allowed; the agency sends from its own workspace and is responsible for its clients' lists, consent and content. The Acceptable use page and the Terms draft are updated.
G77 The AWS CLI on the development machine is signed into account 829547187034 (IAM user dipanshu). In ap-south-1 that account has no SES identities, no configuration sets and no SNS topics, and shows the default sandbox (200 a day, 1 a second). The SES account that sends Vlozi's mail is a different one (the account under founder@vlozi.app), so CLI commands in the runbook would hit the wrong account, and this account's sandbox status is not evidence for the request AWS CLI default profile; runbook R3, R5 accepted (2026-10-07) you Found 2026-10-07 by a read-only check. Confirm the SES account's ID in the console, then either configure a CLI profile for it or use the console equivalents (the runbook lists them). Do not apply for production access in the empty account Confirmed 2026-10-07 from the owner's console screenshot: the SES account is the one named "vlozi" (Mumbai; sandbox, 200 emails per 24 hours, 1 per second; account health Healthy). Decision: do every AWS step in the console, which needs no CLI.
G78 A6 kept the comms database awake around the clock. Once A6 armed the stale-claim sweeper (deployed 2026-10-06), it swept every 5 minutes, and Neon only suspends a database after 5 idle minutes, so the comms compute never suspended. The October 2026 Neon bill shows it: about 5–6 compute-hours a day until ~7 October, about 16 a day after comms stale-claim-sweeper.ts; Neon invoice fixed, deployed 2026-10-21 (main b54d3f33; comms fd6d99ec, rollback 1b8a7824; billing-crons 5bf00f20, rollback 3894b773; dashboard via Vercel) me Comms: the sweep now runs every 6 hours (SWEEP_INTERVAL_MS), with a test that it stays at least an hour; nothing waits on it, because a retried send takes over an expired claim inline. Core: the billing ticker (apps/billing-crons) ran reconcile every 5 minutes through the manager, which kept the core database awake the same way (the likely cost before October). It now runs hourly; its only task cancels a Razorpay subscription before the next monthly charge. A test keeps every ticker job at hourly or slower. Dashboard: the chatbot inbox (every 12 s and 8 s) and the engagement setup card (every 8 s) kept polling in a hidden tab, so a page left open in the background kept those databases awake. Both now use useVisibleInterval (polls only while visible, once on return), as the mailbox and the bell already did. Checked and fine: the newsletter campaign polls, the bell, the nav badges and the domains page already pause when hidden; the newsletter scheduler's 5-minute safety alarm runs only while a campaign is sending. Left: after the change reconcile may not line up with the other hourly jobs, so the core database may wake twice an hour (about $3 a month at most). Lesson: any recurring job against Neon must leave idle gaps well over 5 minutes
G79 AWS says the account's verification is still in progress. On 2026-10-26 CloudShell refused to start in the SES account: "Unable to create the environment. Your account verification is in progress. This may take up to two days for new accounts." The account has sent through SES since July, so this should not still be pending. If AWS treats the account as unverified, that alone could explain the two generic denials ("for security reasons") and would sink the next request too AWS console (CloudShell), 2026-10-26 fixed 2026-10-26 owner The cause was India customer verification (AWS Health event AWS_ACCOUNT_CUSTOMER_VERIFICATION_REQUIRED, opened 2026-10-26). Billing → Account → Customer verification showed the name verified but the address unverified since 2026-07-04: the address typed then did not match the Aadhaar. The owner set the account contact address to the Aadhaar wording and verified Aadhaar through DigiLocker; name and address are both Verified. An unverified address since July may have contributed to the July denials; AWS never said so
G80 The public blog shows test posts. vlozi.app/blog serves "hello-from-mcp-a-test-post", "hotel-os-functional-master-specification-1", "markdown-import-full-feature-test" and "mcp-test-draft-gateway-permission-fix-verification", with tags such as test and fixture (all live, checked 2026-10-26 while redeploying the site). An AWS reviewer reads the website named in the request; test content makes it look unfinished website build (blog posts come from the Vlozi workspace's blog at build time) user you decide, me unpublish Before submitting: unpublish or delete the test posts in the Vlozi workspace's blog, then rebuild and redeploy the website so their static pages go away. The website build only ever adds pages, so unpublishing alone leaves the old pages live until the next deploy

Your steps

Things only you can run (production data, secrets, consoles). Nothing here has been run. Each is dry-run first and safe to repeat unless stated.

The whole deploy, in order, with rollback targets, is in deploy-runbook.md. Done on 2026-10-06: the first attempt stopped at its first production write (the core migration) when the permission classifier denied it; after the founder authorised it, the whole runbook ran (core migration, six workers, the push to main, the dashboard, the website). The live versions and rollback targets are in the runbook's Status table, so the steps below that say "after the deploy" are runnable now.

When Step Command / where What it does
After comms is deployed with A7 Clear credential bodies already stored (G25) cd apps/communication, node scripts/clear-system-mail-content.mjs (dry run, prints counts by mail type, never content), then --apply Sets content to NULL on platform-mail rows that are not on the harmless list, in batches of 1000. Leaves recipient, subject, status and metadata untouched. Run it after the deploy, or new rows keep arriving
Any time (independent of deploys) Revoke consent on Vlozi's own un-consented import (G17) cd apps/newsletter-service, node scripts/revoke-unconsented-import.mjs (dry run: read the survey and the count, expect about 503), then --apply. Afterwards MCP newsletter_list_subscribers status=pending should rise from 1 to about 504 Sets confirmed_at to NULL on those rows and writes one consent_revoked event each. Nothing is deleted. Refuses to run on a wrong tenant or an implausible count. Reads NL_DATABASE_URL from .dev.vars; check it is the production newsletter database first, since the script says "no imported subscribers" if it is not
Before the A12 migration, and again after Baseline the core database for drift (G34) from the repo root: npx tsx scripts/check-prod-schema.ts --app core (read-only; prints the host and which file the URL came from, never the URL). Expect No drift. If it lists missing tables, columns or indexes tell me before anything else is applied: it means a migration in manual-migrations/ or drizzle was never run Reads information_schema and pg_indexes only; writes nothing
After the baseline check is clean, and before the manager is deployed (the A13 manager code writes deleted_at; without the column every workspace delete returns 500) Add the workspace-deletion clock (E2) from the repo root: node packages/core-database/scripts/apply-tenant-deleted-at.mjs (dry run: shows whether the columns exist, how many workspaces there are, how many are already deleted, and the three statements), then --apply. Then npx tsx scripts/check-prod-schema.ts --app core again: expect No drift. Reads DATABASE_URL (or CORE_DATABASE_URL) from the environment or packages/core-database/.env; prints the host, never the URL Adds two nullable columns to tenants and sets deleted_at = now() on workspaces that are already deleted, so their 90 days start on the day you run it, never earlier. Safe to repeat: a second run changes nothing. Existing deleted workspaces are NOT erased by this; nothing erases anything until the purge pipeline (B4) ships and is switched on
After the manager with A13 is deployed (or any time: it does not depend on the migration) Close workspaces that were deleted before the fix (G47) from the repo root: node packages/core-database/scripts/close-deleted-workspaces.mjs (dry run: how many workspaces are already deleted, how many API keys are still active on them and on which workspaces, how many members still have a deleted workspace as their default; prints a count of members, never an address), then --apply. Re-running it afterwards reports "nothing to do". Reads DATABASE_URL (or CORE_DATABASE_URL) from the environment or packages/core-database/.env; prints the host, never the URL Revokes the still-active API keys of workspaces that are already deleted, and moves members whose default workspace is a deleted one to their most recently joined workspace that still exists (or to none, so login resumes onboarding). Erases nothing, removes no membership, changes no billing. Check the dry-run key list first: a key you expected to keep working on a "deleted" workspace is the only thing this can surprise you with
After this branch is deployed: comms, then mailbox, then the dashboard Claim the role addresses in Vlozi Mailbox (G49) Signed in to the Vlozi workspace: Mailbox → Manage → New mailbox, pick @vlozi.app, and create hello, privacy, abuse, postmaster, security, legal, support, billing, info, ops (display names like Vlozi Privacy). Then send a test mail from an outside account to each <name>@vlozi.app, confirm it appears, and reply once from the dashboard. Full steps: contact-addresses.md §4 Creates ten free mailboxes. If @vlozi.app is not in the domain list, that workspace does not hold the verified domain. A test mail that never appears gives no bounce: look for [Inbound] lines in wrangler tail logicspike-communication. Repeat the outside test monthly
Optional, after mailbox (and comms) are redeployed with M3 Move founder@vlozi.app into the Mailbox (G56) Signed in to the Vlozi workspace: Mailbox → Manage → New mailbox → @vlozi.app → founder. Then delete the explicit Cloudflare Email Routing rule for founder. Send a test from an outside account before and after Until the rule is deleted, mail keeps going to the personal inbox and the mailbox stays empty. Do it in this order so the AWS root address is never unreachable. Redeploy comms too so the alias-claim refusal and the free-charge rule agree with the Mailbox

Commits

Appended as steps land (a commit cannot contain its own hash, so each entry is added by the next commit that touches this file).

Step Commit Notes
A1 cc05afbe gap log; doc and policy wording; website claims; newsletter backlog G2; billing.ts comment
A2 b7041018 oneLine() in email-kit; manager template subjects and preheaders; tests in the manager suite
A2b fab5f684 forms and engagement notification subjects flattened; tests in both suites
A3 4c5f4739 name rule in core-tenant; four routes + sign-up/OAuth/profile; 44 tests in the manager suite, mutation-checked
A4 677232de dashboard errorMessage() and validateWorkspaceName(), the three pages, profile hook, onboarding proxy; 12 + 6 + 6 tests, mutation-checked
A4b 8e123a31 delete-workspace and delete-account copy made true (G38)
A5 a32d7887 comms alert copy and campaign-failed subject (oneLine); @repo/email-kit added as a comms dependency (package.json + lockfile, verified with a frozen offline check); 13 new tests, mutation-checked
A6 fdef2aff sweeper armed from request traffic and inbound email; 12 tests, mutation-checked
A7 22e18d5a credentials not stored in the mail log (allow-list, all four insert sites); clear-system-mail-content.mjs for history; 14 + 6 tests, mutation-checked
A8-A9 71e55b58 verified-owner lookup (lib/owner-email.ts), hourly/daily alert bounds, no address echoed, suppressed reported; five suites reworked, 17 + 36 tests, mutation-checked; docs and case text updated
A9b eff967be owner alerts rendered in the shared email frame (lib/alert-email.ts); 8 renderer tests + frame assertions, mutation-checked
A10 1c5b3296 revoke-unconsented-import.mjs with its real SQL tested on pglite (19 tests); consent_revoked event kind; mutation-checked
A11 5aaa1d5a check-prod-schema --app core, schema-qualified comparison in scripts/lib/schema-drift.ts, --list; 18 tests, mutation-checked
A12 45796c7d tenants.deleted_at / purged_at (E2): drizzle schema, manual-migrations/2026-10-05-tenant-deleted-at.sql, user-run packages/core-database/scripts/apply-tenant-deleted-at.mjs (dry run first, post-verifies); 7 tests in the manager suite (the real SQL on pglite), mutation-checked. core-database has no test runner, so its script helper is tested from the manager suite
A13 297fbe05 shared softDeleteWorkspace for both delete routes (flag, deleted_at once, every API key revoked, every member's default repointed); close-deleted-workspaces.mjs for the ones already deleted; shared scripts/lib/core-db.mjs; 17 + 8 tests in the manager suite, mutation-checked
A14 61b97440 account deletion: no longer blocked by deleted workspaces, deletes or detaches every row that points at the user (all in one transaction), clears their invitations-to-email and one-time codes; manager harness gains the user-linked tables with real foreign keys; account-page copy made true; 12 tests, 17 mutations caught
M1 35a128c0 role addresses as Mailbox mailboxes for Vlozi's own workspace (G49): shared rule in core-types, platform-workspace recognition, free role mailboxes, Vlozi sender name; docs rewritten for the new routing
M2 bb9576e2 the role addresses on vlozi.app (G49): receiving at the root domain, provisioning rule for both lanes, canReceiveDirectly, dialog for vlozi.app; the earlier platform-workspace pieces for the shared lane kept; docs rewritten
Runbook e2944438 ordered deploy runbook for the branch; core schema drift logged (G53)
Deploy record 6ed1a46a the 2026-10-06 deploy: live versions, rollback targets, what is still to do
M2b 3411df2e the Manage page no longer says "replies only" for mailboxes on vlozi.app
Inbound tests 2f20f776 role-mailbox inbound tests, the website deploy, the hello@ finding (G55)
M3 57f4ce9b founder is a platform name: Vlozi's own workspace can claim founder@vlozi.app, nobody else can claim it on the shared lane; three tests that used it as an "ordinary name" changed; mutation-checked
Identity and policy pack 9673b57d operator named; Acceptable use, Anti-spam, Abuse and Sub-processors pages; Terms, Privacy and Security updated; placeholders filled in the docs; G57-G63
Venue 63ef7b1f Terms disputes go to the courts in Azamgarh, Uttar Pradesh (G57 closed)
Audit, public claims 930816ca claims the product does not back removed from Security, Privacy, Terms and the new pages; open-at-a-glance table; G64-G69
Audit, suppression, deletion, AI 344eeeaa Anti-spam suppression and unsubscribe scope, out-of-credits, workspace deletion, controller and processor roles, AI wording
Audit, SES runbook bccc4c62 AWS request corrected; gates renamed R1-R10; procedures that could not work fixed; deploy runbook turned into a deploy record
Audit, legal drafts 299cb26f one copy of each policy; the Terms and Privacy drafts made accurate; G70-G75
Communication