Phase 1 — Replace HubSpot

Move daily CRM work to ConnectIQ. Operations stays in Airtable.

Current milestone
Status not verified
Next review
Date not set
Launch forecast
Date not set
Last updated
6 Oct 2026

Milestone tracker

Work through eight milestones in dependency order. Select a milestone to review its scope, unresolved decisions, and verification checklist.

A milestone is complete when its acceptance criteria have been demonstrated to a real user of that surface.

Dependencies

  • Resolve prerequisite decisions before starting a milestone.
  • M4 can run alongside M3.
  • Start M8’s import work after M2 so data-quality issues can inform the schema early.

M1 · Foundation

Live (prod)
Owner
Accepted by Sky
Status
Live (prod)
Target date
Date not set
Latest forecast
Date not set
Next review
Date not set

Outcome Approved users can sign in with the right access.

Next actions

No tasks assigned yet. Tasks in the tracker: status not verified.

Needs a decision

No open decisions recorded.

Done when

CheckEvidence
An admin can invite and approve a colleague.No evidence linked
Users see only the features and data they are allowed to access.No evidence linked
Users can update their name and password without changing their permissions.No evidence linked
A scheduled event reaches its consumer; replaying it creates no duplicate effect.No evidence linked

Accepted by Sky on 20 Aug 2026. View evidence. The four checks were met; Sky verified them by testing during the phase, and no evidence is linked.

MoreCompleted work · Scope · Decisions · Acceptance criteria · Checklist · Prompt

Completed work

Accepted by Sky on 20 Aug 2026. M1 is implemented and released. Sky verified the four exit checks by testing during the phase; no evidence is linked. View evidence.

Scope

Project, auth, roles, permissions, outbox

M1
Foundation

The skeleton everything else assumes, and the point at which the decisions in sections 01–06 and 16 become code. Nothing here is user-visible except a login and an empty shell, which is exactly why it must not be rushed or partially done.

  • Repository, CI, and the three lint rules — design tokens, cross-domain imports, and tables without RLS policies.
  • Database, first migrations, the RLS pattern established as a copyable template.
  • Sign-in, the pending/approved/revoked gate, and the shell that enforces it.
  • plat_user_roles, plat_role_permissions, the role-check function, and the resolved can() on the client. No implicit defaults.
  • plat_domain_events, its dispatcher and the idempotency convention — added here because M5 depends on it and it cannot be retrofitted.
  • int_external_system_mappings and the email queue.
  • App shell from the design system — sidebar, top bar, tokens wired, footer mark.

Work Access and roles · User profiles · App navigation · Background jobs · Email delivery

Decisions and blockers

Existing decision to respect DR-17 DR-24 DR-26

Acceptance criteria

Acceptance criteria

An admin can invite a colleague, approve them, and see the empty app with the right navigation for their role. An approved user can open My Profile, update their full name, see email, role and access status as read-only, change their password and sign out without gaining any additional privilege. Plus: an event written inside a domain transaction is picked up by the dispatcher, delivered to a test consumer, and delivering it a second time changes nothing.

Verification checklist

What this tracker tracks

This tracker follows work as far as staging. A task is done when it is merged to staging and verified there. Nothing here is released to production task by task. The milestone goes to production in one release at its end, and that release is checked in production before the milestone is accepted.

0 of 39

Pre-flight — none of this is build work

TaskHow you know it is doneOwner
Resend DNS started — DKIM on the domain, SPF and MX on send., DMARC at _dmarc. Add connectiq.giant-pumpkin.com in Resend → Domains, then publish the DKIM TXT, the SPF TXT and the MX on send.connectiq.giant-pumpkin.com, and a DMARC TXT at _dmarc.connectiq.giant-pumpkin.com. The MX is the return path — without it bounce feedback never arrives and plat_suppressed_emails never fills. Resend → Domains reports every record verified, and a test send from notifications@connectiq.giant-pumpkin.com reaches an address outside giant-pumpkin.com. Until it verifies Resend delivers only to your own address, so “invite a colleague, approve them” cannot be demonstrated at all. Propagation time, not build time — start it on day one. Sky
Supabase on the Pro plan — upgrade the project in the Supabase dashboard → Billing before any branch is cut, then turn on Branching for the project. ADR-07’s three tiers are a preview branch per feature, the persistent staging branch and production, and preview branches are a Pro feature. Pro is not a preference; the environment model depends on it. The Branching panel is available in the Supabase dashboard and a throwaway preview branch actually creates and deletes — ADR-07’s three tiers depend on ADR-08 having been paid for. A greyed-out panel means the upgrade did not land on this project. Sky
staging exists before any feature branch is cut. Confirm staging is present in the repository before anyone cuts a feature branch from it. No feature branch is cut from main. Check that each branch’s fork point resolves to staging. Sky
The builder's standing instructions carry the binding rules: the domain rules, the stack, the environments and the visual reference values. Load them in full before the first build. Before the first build, the builder states the eleven domain prefixes and names where the visual source of truth lives. A partial answer means the instructions are incomplete. Sky
Resend API key in Supabase secrets; Resend SMTP set as Supabase custom SMTP. The key goes to Supabase → Project Settings → Edge Functions → Secrets, never into application code and never into the repository. The SMTP host, port and credentials go to Supabase → Authentication → SMTP Settings, which is what carries invitations, resets and confirmations. An auth invitation arrives via Resend and appears in the Resend send log, not from the built-in sender capped at two messages an hour project-wide, which fails while approving the third person. Then find the auth email rate limit — Authentication → Rate Limits, defaulting to thirty new users an hour — and know where it is before it bites. Sky
Persistent staging application runtime — configure the persistent staging branch as a Vercel Preview deployment paired only with the persistent Supabase staging database. main remains the only Vercel Production deployment. The Vercel staging runtime exists so the scheduled worker, provider webhooks and other server-side integration routes have a stable pre-production target. Feature branches do not require persistent Vercel deployments as part of the normal workflow — ADR-11. A commit merged to staging produces or updates the standing Vercel Preview deployment. The staging runtime uses the staging Supabase URL and keys, never the production project. The scheduled-worker route is reachable on the staging runtime without a local development server running. main still deploys independently as Vercel Production. Inspect the staging environment configuration and confirm no production database credential is present. Sky

Repository and CI

Project and repository foundation. The GitHub repository exists, the external Supabase project is connected, and CI, the lint rules, the RLS check and the migration dry-run are added to that repository. The repository exists on GitHub with the project’s history, and Supabase is connected as an external project the team owns.
Supabase branching and GitHub integration — configure connectiq-lvbl so main targets production, staging carries a persistent Supabase branch, and every feature branch receives its own temporary preview branch. A preview branch starts empty and applies the committed migrations plus seed.sql on creation; staging must never receive that synthetic development seed. Cut one preview branch and confirm both the migrations and seed.sql applied, then delete the branch once the test is done. Sky
The empty src/domains/<domain>/ structure exists before any feature prompt — one folder per locked prefix, each carrying components/ pages/ services/ types/ hooks/ constants/. Land the shared status constants and enums with it, before the first status field rather than the fourth. An assistant given no structure invents one, and two inventions in one repository is the failure this model exists to prevent. Folders present for all eleven domains before the first build, and no status field anywhere is written or compared as a raw string. Before a build, the builder lists the files it intends to create; confirm each stays inside one domain boundary.
CI — type-check, lint, unit tests, migration dry-run on every pull request into staging. The dry-run applies the numbered migrations to a throwaway database, so a broken one is caught before it reaches a branch somebody is working on. Schema changes are committed SQL files, including generated ones; a console edit never appears here at all. A deliberately red build blocks the pull request rather than warning about it. Push a type error and a syntactically broken migration on separate commits and watch each stop the merge on its own.
The three lint rules — design tokens, cross-domain imports, tables without RLS. The token rule bans raw hex and palette classes in feature code; the import rule stops a module under one prefix importing another domain’s data access — one of Architecture §1.1’s five mandatory substitutes for the boundary prefixes do not give. Write the RLS check now; it is worthless added later. Each one fails on a deliberate violation. Prove the RLS check by adding a policy-less table and watching CI fail; prove the import rule by importing a cl_ service from a file under com_; prove the token rule with a planted hex value.
Production deployment. Vercel is connected to the repository’s production branch only, and nothing else publishes to production. Vercel deploys from the production branch, and no other publish path is used.

Database foundation

plat_profiles, plat_user_roles, plat_role_permissions, plat_permissions — RLS policies in the same migration. Profiles hold full name, email, access_status, invited_at and approved_at; plat_permissions holds key, label and description and is effectively the in-product policy document; plat_role_permissions is role × permission → granted, and no row means not granted. Every table: id uuid primary key default gen_random_uuid(). Every table has policies in the file that creates it, so the CI check passes with nothing waived. A query as a non-owner returns zero rows rather than being hidden by the UI, and a missing role–permission row reads as not granted rather than as absent-so-allowed.
Role check as a SECURITY DEFINER helper — roles never on the profile row. Roles live in plat_user_roles and every policy calls the helper rather than reading a column on plat_profiles. Three roles only: admin, sales manager, user. manage_users is a real permission flag, not an implicit admin fallback — the intended grant can still be admin-only, but the server checks the flag itself rather than substituting the role, and a missing grant means not granted even for admin. Updating your own profile row cannot change your role — try it as a plain user and the update is refused or leaves the role untouched. This is privilege escalation by profile update, and it is the entire reason the column does not exist.
can(permission) resolved on the client from plat_role_permissions, over the eleven phase-1 flags — view_all_records, edit_all_records, delete_records, create_edit_quotes, approve_quotes, edit_pricing, open_forecast, dashboard_all_users, open_settings, manage_users, run_imports. Not the prototype’s twenty-one. It is presentation only; RLS is the actual control. An unset flag is not granted, including for an admin. Editing a flag in plat_role_permissions takes effect on the next resolve, not the next deploy — flip one and reload. The mechanism must express per-tab gating for the deferred forecast tabs without four flags guarding two tabs.
seed.sql — roles, permissions, one approved member per role, committed and synthetic. Carry admin, sales manager and user at the non-routable @seed.invalid domain with a known development password, already access_status = approved and roles assigned: that is what removes the gate loop without touching a permission check. Preview branches seed once, at creation — reseeding means deleting and recreating the branch. A fresh preview branch is usable without typing data in by hand, and it grows with every milestone — by M5 it must carry a company, a contact, a priced product and a seller or the quote wizard cannot be opened. Synthetic only; staging is a restore from production, and nothing customer-shaped is committed.

Auth and the access gate

Email + password, no anonymous sign-in — enable the Email provider in Supabase → Authentication → Providers and leave anonymous sign-ins off. Google OAuth is deliberately launch-deferred, not an M1 requirement — it is not built now and its absence is not a gap to close before this milestone is done. Let the session persist in local storage, so one sign-in per browser survives a hot reload. The friction the banned bypass reached for is solved by the seeded accounts, not by removing the session. Anonymous sign-in is disabled in Supabase, not merely unused — the toggle reads off in the dashboard. With no session auth.uid() is null and every policy evaluates against nothing, which is why “just turn login off in development” tests a different application that renders the same screens. Supabase’s Google provider stays off until launch planning turns it on — that is expected, not outstanding work.
The gate — invited / pending / approved / suspended on plat_profiles.access_status, invite-only with no public signup. Only approved carries normal application access; a missing profile and a suspended one are denied it the same way an invited or pending one is — a status card and a sign-out button, nothing else. Moving someone between states goes through the server-only manage_users path below, never a client write. A signed-in user without approved access sees the status card only — no navigation, no data, and the API returns nothing either. Suspend somebody mid-session and their next request fails rather than their next sign-in. Enforcement is the database and the server function, not the route guard: hit the API directly without approved status and get nothing back regardless of what the UI shows.
Role assignment single-by-precedence — assigning one strips the others, so plat_user_roles holds at most one row per person. Only an approved user holding manage_users assigns, through a server-only function on a service-role RPC: no authenticated client writes plat_user_roles or plat_role_permissions, for the current user or anyone else. That holder may manage other users but is rejected server-side from changing their own role or access status — the UI disabling their own controls is a safeguard, not the authority. This is D-10, a decision taken deliberately, not a limitation to route around. Assigning a second role removes the first — check plat_user_roles for that person afterwards and find one row. A signed-in user posting the write directly is refused by policy, not merely denied a button. Runtime-verified for the self-target case: with the current user’s own role selector and Suspend control disabled in the UI, an authenticated set-role request was replayed with the target swapped to the caller and the server rejected it — “You cannot change your own role” — while another user’s role remained editable and the seed admin stayed admin after reload.
Development quick sign-in calling the real signInWithPassword — buttons that sign in as the seeded admin, sales manager and user with the seed.sql credentials, gated on the build-time development flag. The auth path, the JWT and every policy are exercised exactly as in production; the only thing skipped is typing. “Switch role” becomes “sign in as someone else”. Absent from a production bundle, proven by a check rather than by trust — search the built assets for the panel. No can() returning true unconditionally, no self-promotion, no auto-sign-in, and the service-role key nowhere the browser can reach.
Enable Supabase Auth leaked-password protection before production — A security review found it disabled. Not part of the SQL foundation and not a blocker for the three database tasks above it, but it must be on before a real person signs in. The toggle reads on in Supabase → Authentication, and a password already known from a public breach is refused at sign-up rather than merely accepted with a warning. Sky

The outbox

plat_domain_events and an insert helper callable only inside a domain transaction — id, event_name, aggregate_id, payload jsonb, occurred_at, processed_at, attempts, last_error. Names are past-tense facts (QuoteApproved, JobCreated, UnitReplaced), and the payload carries identifiers and snapshots, never a whole record. An event never survives a rollback and is never lost on commit — raise an error after the insert and find no row, commit and find exactly one. That shared transaction is the entire guarantee; the transport is replaceable, the table is not.
Dispatcher as a scheduled worker, plus a dead-letter view with retry — one worker drains rows where processed_at is null, stamps it on success, and increments attempts and records last_error on failure. The same scheduled-worker pattern as email and sync: there is no second infrastructure component and no separate queue service. The worker implementation already exists; what is still open is persistent staging’s pg_cron/pg_net scheduling actually calling it on the persistent staging Vercel runtime. An event reaches a test consumer and comes back with processed_at set. A consumer that throws leaves the row unprocessed with attempts climbing and its error text carried, and an admin can retry it from the dead-letter view. This task is not done on a manual or hand-triggered call — it stays open until one real scheduled invocation from persistent staging’s pg_cron/pg_net is observed hitting the deployed worker.
A documented idempotency convention with a worked example — write down how a consumer decides it has already handled an event, and commit one consumer that follows it, so the next domain copies rather than invents. The dispatcher may deliver twice; absorbing that is the consumer’s job, not the transport’s. The same event delivered twice produces the same result — replay one QuoteApproved through the test consumer and the second pass writes no row, creates no duplicate and moves no counter.

Integration and email infrastructure

int_external_system_mappings and int_webhook_events — the mapping table carries system, entity_type, internal_id, external_id, first_seen_at and last_seen_at; the webhook table holds inbound external events only and is never merged with plat_domain_events, which is internal. External ids live here and in no domain table. No external system id appears in any domain table — search the migrations for an external id column outside the int_ prefix and find none. This is what keeps the migration reversible.
plat_email_queue, plat_email_send_log, plat_suppressed_emails, plat_email_unsubscribe_tokens — infrastructure for application-generated email only, drained by the same scheduled worker as the outbox. Supabase Auth email (invitation, confirmation, password reset) stays on its own path — Supabase Auth → Resend custom SMTP — and is never routed through this queue or wrapped by it. The one application send identified for phase 1 is the quote approval request; a future application notification reuses this queue rather than a new delivery path. The worker itself already exists from the outbox foundation above — what remains is proving persistent staging’s pg_cron/pg_net scheduling actually invokes it on the persistent staging Vercel runtime, which email infrastructure reuses once that is verified, rather than a second worker. Two channels, kept separate and both provable. An Auth invitation or password reset still arrives through Supabase Auth and Resend SMTP, unchanged. A queued application notification is drained by the scheduled worker and sent through the Resend HTTP API — not SMTP, which a serverless function must not hold open — and a send to an address in plat_suppressed_emails is never dispatched, with the attempt visible in plat_email_send_log. Search the application code for anything enqueuing a Supabase Auth lifecycle email into plat_email_queue and find none.
Resend bounce and complaint webhook — a public endpoint Resend posts delivery events to. Provider delivery events are external, so they land first in int_webhook_events, never plat_domain_events; a verified application-email hard bounce or complaint then writes a row in plat_suppressed_emails, which blocks later application-email dispatches to that address. This does not touch Supabase Auth delivery — account email has its own path and is not gated by this suppression list. An unsigned request is rejected before any write — replay a real payload with the signature mangled and find nothing written. A bounce writes a suppression row from the raw event in int_webhook_events, never directly into plat_domain_events. Confirm the suppression list blocks only the next application-email send to that address — an Auth invitation or password reset to the same address is unaffected, since it never consults plat_suppressed_emails.

App shell

Design tokens wired — semantic CSS variables, style-kit/tokens.json committed as the visual source of truth. Feature code reads variables only: no raw hex, no framework palette class, and no new value introduced without first naming the missing token and its proposed semantic purpose. The lint rule enforces this, not review. No raw hex or palette class in feature code — the token rule passes on a clean tree and fails on a planted hex value. Changing one token moves the UI without a component being touched.
Sidebar, top bar, footer mark, all from the design system — a 224px sidebar collapsing to 64px, 1440px maximum page width, 24px desktop padding, light theme, Inter. Navigation groups appear and disappear by permission: a group the person cannot use is absent, not present-and-broken, and never merely disabled. Compact 14px base, the 4/6/8/12/16/20 radius scale, orange not the default filled primary. Sign in as each seeded role in turn and the navigation differs — groups absent rather than greyed out.
My Profile — an approved signed-in user can manage their own safe account fields from the application shell. Show full name, email, role and access status; full name is editable, while email, role and access status are read-only. Provide Change password through Supabase Auth and Sign out. This is self-service only — no admin/user-management controls and no service-role client. Sign in as each seeded role and open My Profile. Change full name and reload: it persists. Email, role and access status have no editable control. Change the password, sign out, and verify the new password authenticates. The user’s role and access status remain unchanged. A suspended/non-approved account cannot use the profile route to regain application access.
A page the user lacks access to redirects home with a plain “you don’t have permission” card — no stack trace, no half-rendered screen, no empty table where the data would have been. The route guard is the polite version of an answer the database already gives on its own. The redirect is not the control — the same request through the database also returns nothing. Hit the route directly as somebody lacking the permission, then run the query that page would have made and get zero rows.

Hand-written tests — these block a merge on their own

Permission resolution as a matrix — three roles × the eleven flags × a representative query set, asserted against the database, not against the UI. Sign in as each seeded role from seed.sql, run the set, and assert every cell; an unset role–permission pair asserts not granted, admin included.
RLS proven by a query as a non-owner returning zero rows, not by a hidden button. Signed in as one seeded user, select rows owned by another through the ordinary client and assert an empty result — the failing version of this test returns rows while the screen still looks correct.
Outbox idempotency — same event, twice, no change. Insert one plat_domain_events row inside a domain transaction, run the test consumer, snapshot the tables it touched, deliver the identical event again and assert the snapshot is unchanged: no duplicate row, no second increment.
CI fails on a table with no policy. Prove it by adding one — commit a migration creating a table with no policy in the same file, watch the check go red, then take it back out. A check nobody has watched fail is a check nobody knows works.
CI fails if the quick sign-in panel or any bypass flag reaches a production bundle — build for production, search the output for the panel and for any permission-bypass flag, and fail the build on a hit. Prove it the same way: plant one, watch it fail. DR-24 says CI enforces the ban.
The seeded development accounts exist nowhere but a seeded database — assert that seed.sql is the only place the @seed.invalid accounts and their development password are created, and that the seed is never applied to production. The credentials stay inert even if the panel leaked.
My Profile security test, as an ordinary authenticated approved user, never as service-role: update full_name and see it succeed; attempt to mutate role, access status and email through the profile self-service path and see each one refuse to change; change the password only through Supabase Auth’s authenticated update; sign out and confirm normal application access is gone; and confirm a suspended or non-approved account cannot use the profile route to bypass the access gate.

Implementation prompt

View implementation prompt

M2 · Customer records

Live (prod)
Owner
Accepted by Sky
Status
Live (prod)
Target date
Date not set
Latest forecast
Date not set
Next review
Date not set

Outcome Reps can find and update a customer's companies, contacts, brands and locations.

Next actions

No tasks assigned yet. Tasks in the tracker: status not verified.

Needs a decision

No open decisions recorded.

Done when

CheckEvidence
A rep can find a customer and see its contacts and locations.No evidence linked
Records can be edited without leaving the list.No evidence linked
An unverified address requires an override reason.No evidence linked
Duplicates can be flagged and restored without deleting records.No evidence linked

Accepted by Sky on 14 Sep 2026. View evidence. The four checks were met; Sky verified them by testing during the phase, and no evidence is linked.

MoreCompleted work · Scope · Decisions · Acceptance criteria · Checklist · Prompt

Completed work

Accepted by Sky on 14 Sep 2026. M2 is implemented and released. Sky verified the four exit checks by testing during the phase; no evidence is linked. View evidence.

Scope

Companies, brands, contacts, locations

M2
Accounts

The first real data. Also the first test of the shared table component that every later list depends on, so it is worth over-investing here. DR-01 is answered — locations are owned, created inside the company record, and there is no sync to build.

  • Companies with hierarchy, derived status, detail panel.
  • Brands as entities, many-to-many with company — DR-14, confirmed by evidence rather than assumed. Chosen from a list, never typed free. Duplicate flagging applies to them too.
  • Contacts with inline create, company link, tags and labels. No lifecycle stage.
  • Locations as owned cl_ master data — created and edited inside the company record, one company each. No sync, no status page, no request-a-location flow.
  • Address verification via Google Places Autocomplete — DR-55. A rep typing a company or location address gets real suggestions; selecting one records google_place_id and address_verified. An unverified address needs a conscious Override and a required note before it can save — never a silent downgrade, never a content block.
  • Duplicate flagging — reversible, non-destructive, excluded from every list, search and report. Implemented once as a policy, not repeated in forty queries.
  • The shared data table — column picker, resize, sort, filter, inline edit, saved views — built once, properly.
  • Notes and the entity timeline component.

Work Customer lists · Record editing · Addresses · Duplicate flags · Notes and history

Decisions and blockers

Existing decision to respect DR-22

Acceptance criteria

Acceptance criteria

A rep can find any customer, see its sites and people, and edit a record without leaving the list.

Verification checklist

What this tracker tracks

This tracker follows work as far as staging. A task is done when it is merged to staging and verified there. Nothing here is released to production task by task. The milestone goes to production in one release at its end, and that release is checked in production before the milestone is accepted.

0 of 13

Build

TaskHow you know it is doneOwner
Companies with hierarchy, derived status and a detail panel — create cl_companies in a numbered migration with its RLS policies in the same file, carrying parent_company_id and duplicate_of_id. Declared number of stores is what the customer tells us and is not the count of cl_locations held — keep both, derive neither. Status is system-maintained (DR-16), reached as an event consumer or an rpt_ view. The hierarchy rejects a cycle at any depth, not just self-parenting — chain three companies, point the first at the last as parent, and watch the recursive check refuse it. Nothing on the company form writes status, and no company screen reads a com_ table directly.
Brands as entities, many-to-many with company, chosen from a list and never typed free — cl_brands holds a name and an optional owning company that stays empty for a brand spanning a group, and cl_company_brands carries the pairing in both directions (DR-14). Creating a brand stays open to anyone, as a deliberate act with a near-match warning at the point of creation. A free-typed brand cannot be saved — the prototype’s autocomplete over a string is the trap, because it suggests while still accepting anything, which is how KFC and KFc both reach the list. Create a near-match and confirm the warning fires. Duplicate flagging applies to brands exactly as it does to companies.
Contacts with inline create, company link, tags and labels — cl_contacts takes first name required, then last name, email, phone, job title, labels and duplicate_of_id; date of last contact is derived from activities, never entered — from M7, also from sent or received email messages, never a duplicate activity row (DR-54). At most one company, and none at all is permitted — contacts imported without one exist, so allow it rather than pretending otherwise. No lifecycle stage field exists anywhere — not in the migration, the form or the type (DR-23); it duplicates company status. Save a contact carrying a first name and nothing else, then one with no company, and both persist.
Locations as owned cl_ master data, created and edited inside the company record — cl_locations holds name, address, city, postal code, country, an optional external reference and cl_company_id. Build the in-company path first; the flat standalone register is the secondary view. Site type, opening date, operational status and operator are deliberately absent — they are phase 2. A location cannot be saved without a company and cannot belong to two — the column is not null and there is exactly one of it, no link table. No sync, no int_*_mirror, no synced_at, no status page and no request-a-location flow: DR-18 closed at none.
Address verification on the company and location address fields — DR-55. Google Places Autocomplete (New), a browser key restricted by HTTP referrer to ConnectIQ's domains and by API restriction to Places only, no server proxy. Selecting a suggestion populates address, city, postal code and country from Place Details and writes google_place_id plus address_verified = true on cl_companies and cl_locations. On a location, the same response also writes google_maps_url, latitude and longitude — no second call. Typing past the suggestions, or editing a field after selecting one, disables save and shows an Override action requiring a non-empty address_override_note before save re-enables; the map link and coordinates are left as they were, not cleared. A check constraint — address is null or address_verified or address_override_note is not null — rejects a bypass at the database. Narrowed as built — see DR-58. The stored address is Google's full formattedAddress rather than a recomposed street line; city, postal_code and country are still extracted and stored but are analytics fields rather than the identity of the address; the city lookup falls back through sublocality_level_1 to administrative_area_level_1 because Thailand has no city component outside Bangkok; suggestions are soft-biased to Southeast Asia; and the place's own name is offered as a suggestion for the record's Name field, never applied. Select a Places suggestion and the four fields populate with address_verified true, a non-null google_place_id, and — on a location — a non-null google_maps_url, latitude and longitude. Type an address with no selection and try to save: disabled. Click Override and try to save with an empty note: still disabled. Provide a note: saves with address_verified false, the note attached, and any previously-set map link and coordinates unchanged. Re-select a matching suggestion afterward and the note clears, address_verified returns to true, and the coordinates refresh. Network tab shows no server round trip carrying the Places key; it is a browser call from a key that Google Cloud Console shows restricted to this domain and to the Places API.
Duplicate flagging implemented once as a policy — duplicate_of_id on cl_companies, cl_brands and cl_contacts, pointing at one record of the same type, with any number pointing back at a primary. Exclude flagged rows in a single place, as a policy or a shared filter, not a WHERE clause repeated across forty queries. A chain never loops. A flagged record is absent from list, search and every report — asserted per surface, not once at the source. Un-flagging restores it with links intact. Nothing is deleted and nothing is re-pointed, because a merge re-points records other domains hold identifiers to and cannot be made atomic across a boundary — binding rule 6 again.
The shared data table — column picker, resize, sort, filter, inline edit, saved views and CSV export of the visible rows, built once and properly. Semantic tokens only, keyboard reachable with visible focus, and empty, loading and error states designed rather than left to default. This is the component most likely to be rebuilt three times if it is rushed here. Companies, brands, contacts and locations all render through the one component — a second implementation anywhere is the failure, so search for a rival table before calling it done. The design-token lint rule passes on it without an escape hatch.
Notes and the entity timeline component — com_notes is free text attached to exactly one record of any type, and the timeline puts that record’s history in order, reused on every later surface. com_tags is a shared label across companies, contacts and opportunities, an entity with identity rather than a string repeated per record. DR-15 (closed — com_) owns the polymorphic-link shape, notes included; the shape itself is still §5 of the decision brief, open separately. The same component renders on company, contact and location from one implementation. Renaming a tag reaches every record using it in one act — no record is left holding the old label.
Contact chips on commerce screens read through a service client or an rpt_ view — DR-06 closed with contacts in cl_, and they link many-to-many to opportunities and quotes, so a com_ screen fetches them through the Customer and Location service client or a reporting view created WITH (security_invoker = true). Expose that read path here, before M3 needs it. No PostgREST nested select crosses a prefix, even where a foreign key offers one — search the commerce domain for .select('*, cl_contacts(*)') and its kind and find nothing. DR-06 put contacts in cl_; M3 is where that first bites.

Hand-written tests — these block a merge on their own

A location cannot be saved without a company, and cannot belong to two — insert a cl_locations row with a null cl_company_id and assert the write is rejected, then assert the model offers a single company reference and no second link. The fixture is two companies and one site.
Company hierarchy rejects a cycle at any depth, not just self-parenting — build a chain of three companies, set the first as parent of the last, assert the write fails, then repeat four deep. A self-reference guard alone passes the trivial case and fails this one, which is the point of the test.
A flagged duplicate is absent from list, search and every report — flag one company as a duplicate of another, then assert it is gone from the company list, from per-page filtering and from every rpt_ view that counts companies. Asserted per surface, because a shared filter can be missed on exactly one of them.
Un-flagging restores it, with all its links intact — clear duplicate_of_id on the record flagged in the previous test and assert its brands, contacts, locations and notes are all still attached. Nothing was deleted and nothing was re-pointed, so there is nothing to rebuild.

Implementation prompt

View implementation prompt

M3 · Sales pipeline

Live (prod)
Owner
Accepted by Sky
Status
Live (prod)
Target date
Date not set
Latest forecast
Date not set
Next review
Date not set

Outcome Reps can manage a deal from its first stage to its outcome.

Next actions

TaskOwnerDueStatus
Check the seven m3_ migrations in the production ledger. Needs someone with production access.SkyDate not setDone · 7 Oct · confirmed in production (see M4)
Run the DR-16 reconciliation script against production (scripts/reconcile-client-status.ts).SkyDate not setDone · 7 Oct · no drift (see M4)
Enable the outbox drain in production from the generated SQL, reviewed and run by hand (Plan 33).SkyDate not setDone · 7 Oct · verified (see M4)
Smoke-check the live deploy.Planning session and SkyDate not setDone · 7 Oct · record page not opened (see M4)
Add register cards for DR-59 and DR-60.SkyDate not setOpen

Needs a decision

No open decisions recorded.

Done when

CheckEvidence
A rep works a real deal end to end in ConnectIQ.M3 closure record
Stage changes and follow-ups are recorded.No evidence linked
Forecasts respect whole-deal quantities, per-location quantities and period overrides.No evidence linked

Accepted by Sky on 5 Oct 2026. View evidence. The exit criterion was met by a staging walkthrough with demo data, not by a real deal.

MoreCompleted work · Scope · Decisions · Acceptance criteria · Checklist · Prompt

Completed work

Accepted by Sky on 5 Oct 2026. M3 is implemented and released (app pull request #105, 2 Oct 2026). The exit criterion was met by a staging walkthrough with demo data, not a real deal. View evidence.

BriefPull request
1 deal spine#43
5 lines, rollout, period overrides#44
5R override location split#45
4 activities and the four-way link#47
1R business line, currency default#48
2 stage transition guards and history#51
3 status maintainer#52
7 board, list, opportunity record#54
N1 navigation grouping#55

Not delivered: the forecast projection (moved to M4) and the ten-deal validation (closed at 2 of 10).

Scope

Opportunities, lines and rollout, activities

M3
Pipeline

The point at which a rep could plausibly work a day in it. Worth putting in front of the team as soon as it stands up, even unfinished — that is the mitigation for the resistance risk in §08.

  • Shared pipelines and stages in settings, with declared won/lost flags rather than literal names.
  • Opportunity list and board, detail panel with tabs, quick create.
  • Brand on the opportunity for revenue attribution — defaulted from the company where there is only one. DR-46.
  • Stage moves writing to stage change history, with the won-without-quote justification and the shared loss-reason list.
  • The site package and rollout periods, and the multiplication built as a pure, unit-tested module before it is wired to a screen.
  • Activities with type, due date, assignee, and links to any entity.

Work Opportunity list and board · Stage history · Follow-ups · Deal lines · Rollout forecasts

Decisions and blockers

Existing decision to respect DR-15 DR-19 DR-20 DR-21 DR-42 DR-46 DR-62 DR-63 DR-64

Needs clarification DR-31 — Open. §20’s table lists it under M3, but its card says it is now M7’s and M3 is accepted.

These references disagree. The decision register is the record. Settle them at this milestone’s scoping; do not pick one during a build.

Acceptance criteria

Acceptance criteria

A rep works one real deal end to end in ConnectIQ while HubSpot still holds the rest.

Verification checklist

What this tracker tracks

This tracker follows work as far as staging. A task is done when it is merged to staging and verified there. Nothing here is released to production task by task. The milestone goes to production in one release at its end, and that release is checked in production before the milestone is accepted.

0 of 10

Build

TaskHow you know it is doneOwner
Shared pipelines and stages in settings, with declared won/lost flags rather than literal names — com_pipeline_stages carries name, position, default probability, rotting threshold in days, closes_as_won and closes_as_lost, seeded once from Lead 10% through Lost 0%. One configuration for the workspace (D-11), never per person: the prototype seeds stages per rep, which contradicts a forecast that reads across everyone. Renaming a stage to “Won” grants it no special behaviour, and setting closes_as_won on a stage called anything else fires every won guard. Stage semantics are declared, not spelled. Two people open settings and see one list, with nothing seeded per person to diverge from.
Opportunity list and board, detail panel with tabs, quick create — board, list and forecast are three views of one filtered set, so a filter on any of them applies to all three. Board columns show count, total recurring revenue and probability-weighted recurring revenue, with Lost collapsed by default. An amber triangle marks any opportunity with no lines, wherever it is listed. Both views drive the same stage-change path — a drag on the board and an edit on the detail panel are indistinguishable afterwards. Filter the list and the board and forecast follow. Creating an opportunity lands on the lines tab, because one with no lines is treated as incomplete.
Brand on the opportunity for revenue attribution, defaulted from the company where there is only one — a nullable cl_brand_id on com_opportunities, filled automatically where the company operates exactly one brand and chosen only where it is genuinely ambiguous (DR-46). Add it before deals exist: afterwards, historical revenue is attributed by memory. A company operating three brands still produces one answer to which brand earned the deal, and a company operating one needs no click. The brand is read through the Customer and Location service client or an rpt_ view — cl_brands sits across the prefix and stays there.
Stage moves write to stage change history, with the won-without-quote justification and a shared loss-reason list — com_stage_changes records from, to, when and by whom on every move, and com_loss_reasons is a shared vocabulary a lost deal cites exactly one of, extendable inline and immediately available to everyone. A move overwrites any manual probability with the stage default — kept, but made visible rather than silent. Exactly one history row per move, including moves made by drag. Won-without-quote cannot be saved with a blank justification; won with a signed quote proceeds at 100 and asks for none. Promotion of the company to Client leaves as an event, never a direct write across the prefix (DR-16).
Opportunity lines and rollout periods — the multiplication as a pure, unit-tested module before a screen touches it. com_opportunity_lines holds what a single location receives: category, description, units per location, unit price, billing basis, position. com_opportunity_rollout_periods holds month and number of new locations. Two tables, not three — DR-08 removed the wrapper, not the content, and there is no site package. The module passes standalone against the ten historical deals DR-08 asks for, before it is wired to a screen. Non-overlapping periods enforced at the database, not the form. Licence count is software units per location × total locations and nothing else contributes — a hardware line returns zero.
Activities with type, due date, assignee, and links to any entity — one entity carrying a type of call, meeting or email_task, a required subject, notes, due date, due time for meetings only and cleared for the rest, duration, status and assignee. Completing one stamps a time and offers a follow-up defaulted seven days out. com_activities — DR-15 closed. email_task is a planned or manual follow-up a rep logs, distinct from a captured message in com_email_messages — DR-54, closed. The four-way link is not flattened into a single nullable column — one activity attaches to contacts, companies, opportunities and quotes simultaneously and independently. The delivery plan’s task_links proposes exactly the flattening the conceptual model forbids, so a single link column is the failing answer.

Hand-written tests — these block a merge on their own

The multiplication module standalone — licence count, store count, one-time, MRR, ARR, TCV and the month-by-month series, against the ten historical deals. The fixtures are those deals entered as lines and rollout periods and compared with what actually closed; a deal carrying no software line must return zero licences, not a plausible number.
Non-overlapping rollout periods enforced at the database, not the form — insert two rollout rows covering the same month on one opportunity, past the UI entirely, and assert the constraint rejects the second. A form-level check never sees that insert, which is exactly why the test goes to the database.
Won-without-quote cannot be saved with a blank justification — move an opportunity with no signed quote into a closes_as_won stage with the justification empty, then with whitespace only, and assert both are refused. With a signed quote attached the same move proceeds at 100 and asks for nothing.
Every stage move produces exactly one history row, including moves made by drag — count com_stage_changes either side of a drag on the board and of an edit on the detail panel, and get one row from each. Two rows from the drag means the board wrote its own path.

Implementation prompt

View implementation prompt

M4 · Products and pricing

Started
Owner
Owner unassigned
Status
Started
Target date
Date not set
Latest forecast
Date not set
Next review
Date not set

Outcome Reps have the products, prices and commercial settings they need to quote.

Next actions

TaskOwnerDueStatus
Close the billing-periods decision, then write the forecast projection (moved from M3).Owner unassignedDate not setOpen
Find where product cost data lives today.Owner unassignedDate not setOpen · needs a named owner and a date
Name who maintains the exchange-rate table (DR-07).Owner unassignedDate not setOpen
Confirm the peso currency codes before seeding (DR-07).FinanceDate not setStatus not verified
Run the DR-16 reconciliation (scripts/reconcile-client-status.ts) against production daily until 21 Oct 2026, then weekly.SkyFirst run 8 Oct 2026Open
Independently re-check production after the pre-M4 release.Planning sessionDate not setOpen
Store banner on locations and MBO on companies (Plan 40, DR-65).Owner unassignedDate not setNot started
Location type on every location (Plan 43, DR-68).Owner unassignedDate not setNot started
Record M4's base commit when M4 begins.SkyDate not setOpen

Needs a decision

QuestionDecision ownerNeeded byBlocks
What do the months in a rollout forecast mean — go-live schedule or billed installed base? Needed before the forecast projection can be written. (decision note)Owner unassignedDate not setM4 forecast view
How is a co-branded store recorded: which single banner does it carry, and is there any exception that allows two locations? Needed before co-branded stores are entered. (DR-66)SkyDate not setHow co-branded stores are entered (not Plan 40)

Done when

CheckEvidence
Required products are priced in every supported sales currency.No evidence linked
Price and cost calculations follow the agreed rules.No evidence linked
Missing prices, costs or exchange rates are shown explicitly.No evidence linked
MoreCompleted work · Scope · Decisions · Acceptance criteria · Checklist · Prompt

Completed work

Pre-M4 hardening (Plan 33). Released to production on 6 Oct 2026 (app pull request #120, merge commit 2dd6cdb). Sky enabled the outbox drain on 7 Oct 2026. This is groundwork before M4 and counts as its first task; the milestone's own build tasks have not begun. View evidence.

ItemProduction result
Stage history: client writes to com_stage_changes removed (B1)Verified. Signed-in users can only read; anonymous users have no access.
Migration ledger: seven m3_ migrations and 20261006120000Verified. Nothing else new.
Outbox drain: job connectiq-scheduled-worker, every minuteVerified. Runs succeeded; worker returned HTTP 200.
Drain checks: drain-verify, queue-stale-check, reconcile-client-statusVerified. VERIFIED, ok, no drift.
Live-site smoke check: Companies, Deals board, Deals listPassed. No console errors; all requests 200.

Not done: the stage-history queries in checklist §5 (production holds no business data), opening a record page (none exist), and an explicit sign-in check. Nothing runs the stale-queue check or the reconciliation on a schedule. The planning session's independent re-check of production is pending.

M4's base commit is recorded when M4 begins.

Scope

Products, price books, commercial settings

M4
Commerce

Unglamorous and entirely blocking. The quote builder cannot be trusted until the numbers behind it are.

  • Product catalogue — one-time, recurring and usage types, SKU, category, default price. Categories in the database, not a front-end file.
  • Price books, per-currency sell, per-customer assignment, and the four-step resolution as part of the money module.
  • Cost by supplier, volume and date, banding on the quote total rather than the line, held in the supplier's currency, and snapshotted onto the quote line with its FX rate when pricing freezes. DR-45.
  • Missing-price request flow so a rep is never simply stuck.
  • Settings: seller entities, customer billing entities, payment terms, VAT rates, tags.
  • The number allocator as a database function with proper locking — never max-plus-one.

Work Catalogue · Customer prices · Costs · Exchange rates · Tax and payment settings

Decisions and blockers

Existing decision to respect DR-07 DR-43 DR-44 DR-45

Needs clarification DR-66 — Open, needed “before co-branded stores are entered”. M4’s “Needs a decision” lists it, but §20’s table says M4 has nothing open.

Needs clarification Billing periods vs rollout periods — Open. M4’s “Needs a decision” and “Next actions” list it, but it has no register card, only this file.

These references disagree. The decision register is the record. Settle them at this milestone’s scoping; do not pick one during a build.

Acceptance criteria

Acceptance criteria

Every product a rep needs to quote exists, priced in every currency they sell in.

Verification checklist

What this tracker tracks

This tracker follows work as far as staging. A task is done when it is merged to staging and verified there. Nothing here is released to production task by task. The milestone goes to production in one release at its end, and that release is checked in production before the milestone is accepted.

3 of 13

Build

TaskHow you know it is doneOwner
Pre-M4 hardening (Plan 33) — stage-history client writes closed, migration ledger confirmed, outbox drain enabled and verified in production. Groundwork before M4, recorded here so the count reflects it. The one exception to the staging rule on this tracker: it went to production on its own on 6 Oct 2026, ahead of the M4 release; drain enabled 7 Oct 2026. Verified in production: com_stage_changes is read-only for signed-in users; drain-verify VERIFIED, queue-stale-check ok, reconcile-client-status no drift. Not done: the checklist §5 history queries and a record-page smoke check. Evidence. Sky
Store banner on locations and MBO on companies (Plan 40, DR-65). cl_locations.cl_brand_id is optional, holds one brand, and must be a brand the company operates, enforced by a composite foreign key. MBO is calculated from the distinct brands a company operates and is never entered. Sells and Exclusive are not built. Merged to staging 7 Oct 2026 (pull request #123); not released to production. A location saves with an operated banner and with none, and is refused with a brand its company does not operate. Removing an operated brand is refused, in plain words, while a location trades as it. A company operating two brands shows MBO and one operating one does not; a brand linked with its flagged duplicate counts once. Merged to staging 7 Oct · verified
Location type on every location (Plan 43, DR-68). cl_locations.location_type is required, one of Client site, Warehouse, Repair center or Office, and defaults to Client site. A store banner is allowed only on a Client site or Office, and the company's held site count counts Client sites only. Both rules are database constraints. MBO is unchanged. Merged to staging 7 Oct 2026 (pull request #126); not released to production. A location saves as each of the four types and is refused with a fifth or none. A Warehouse and a Repair center are refused a store banner by the database; a Client site and an Office are not. A company with one site of each type shows one held client site. Every existing location reads Client site, and every company's held count, operated brand count and MBO are the same before and after the migration. Merged to staging 7 Oct · verified
Product catalogue — one-time, recurring and usage types, SKU, category, default price. com_products carries name, SKU, category, product type, billing basis, base currency and base sell price, and no cost column. Categories belong in a table with a settings screen, not the front-end file the prototype used — plan §15 names it as one of three corrections to carry over. Billing frequency infers the pricing type on selection, monthly or annual becoming recurring and everything else one-time, and the person can override. Categories live in the database, not in a front-end file — rename one on the settings screen and the catalogue reads the new name with no deploy. Add a software product to a line and watch it infer recurring, then override it and watch the override hold. A base_cost column anywhere on com_products is the failure.
Price books, per-currency sell, per-customer assignment, and the four-step resolution inside the money module. com_product_prices holds the sell price in a currency other than base, at most one per product per currency; com_customer_prices holds a company’s negotiated unit price and default discount, keyed by cl_company_id. Resolution runs customer price at quote currency, customer price at base, product price at quote currency, then product base — in the pure money module, never inside a form component. All four steps in order, plus the no-resolution case. Default discount from a customer price applies to a line with nobody touching the field. Insert a second com_product_prices row for the same product and currency and watch the write reject; where nothing resolves, the product cannot be added and the warning names the missing currency.
Cost by supplier, volume and date — banded on the quote total, held in the supplier’s currency. com_product_costs holds product, supplier, volume floor, effective from, effective to, cost and currency, most specific match wins, and com_suppliers stays a flat lookup — purchasing is phase 2. The module takes a quote as its input, not a line, so adding a line recomputes margin on every other line sharing that product; a change order bands on its own totals and never joins the original’s volume. Four fields snapshot onto the quote line — cost amount, cost currency, the rate used and that rate’s date — or a margin cannot be re-derived a year later and an approval can only be re-guessed. com_fx_rates takes the most recent rate dated on or before the transaction and refuses where there is none; a product with no resolvable cost shows no margin, not zero. Do not start this until the source of cost data is found — a named risk in plan §08, not an open decision.
Missing-price request flow — the rep meets the no-resolution refusal and raises a request against that product and that currency from the same warning, rather than abandoning the quote or inventing a price on the line. The request names both, and the queue is visible to whoever holds edit_pricing. A rep is never simply stuck with no way forward — put a product priced only in USD onto an MYR quote, watch the add refuse with the currency named, and watch the request land. Sign in as a user without edit_pricing and confirm the queue is unreadable from the database side, not merely hidden by a button.
Settings — seller entities, customer billing entities, payment terms, VAT rates, tags. com_seller_entities is the entity we issue from — company name, tax/VAT id, address, logo, contact details, default flag. com_vat_rates takes name, rate and default flag, a quote applying at most one, inclusive or exclusive; com_payment_terms takes label and default flag. cl_company_billing_entities is the customer’s invoiced legal entity — note the prefix, it is customer master data, one company each, several per group. Every one is a shared constant or enum, never a raw string comparison — search the domain folder for a quoted status or document kind and expect nothing back. Each new table carries its RLS policies in the same numbered migration; add a policy-less one on a preview branch and watch CI fail before you delete it.
The number allocator as a database function with proper locking. com_document_number_series holds document kind, current sequence and format, and the function takes the lock, advances the sequence and returns the formatted number in one call. Numbers are allocated centrally, unique and never reused — a duplicate and a renewal each take a new one. The number is its own column generated by that function, never the primary key. Two simultaneous requests get two numbers, and no number is ever issued twice. Never max-plus-one — search the application code for a max( against the series and expect nothing; the function is the only writer to com_document_number_series.

Hand-written tests — these block a merge on their own

All four resolution steps, in order, plus the no-resolution case. Fixture: a product based in USD, a com_product_prices row in MYR, and a com_customer_prices row for one company — assert each step wins in turn as the one above it is removed, and that customer-at-base applies only where the quote currency is the product’s base currency. The fifth case resolves to nothing, the line is refused, and the message names the missing currency.
Default discount from a customer price applied to a line. Fixture: a com_customer_prices row for that company and product carrying a default discount, added to a quote with nobody touching the discount field. Assert the resolved unit price and the discount separately — one correct total can hide two errors cancelling each other out.
Margin derivation across currencies. Cost held in the supplier’s currency against a quote in another, converted at the rate dated on or before the transaction: assert the four snapshot fields re-derive the same margin from the line alone after the supplier raises its price, that a missing rate refuses rather than guesses, and that an unresolvable cost gives no margin rather than zero. A second line of the same product moves the first line’s band, and the test says so.
Number allocation under concurrency — two simultaneous requests, two numbers, none ever reissued. Call the function from parallel sessions against one series and assert the count of distinct numbers equals the count of calls; then duplicate one document and renew another and assert each took a fresh number rather than carrying the old one.

Implementation prompt

View implementation prompt

M5 · Quotes

Status not verified
Owner
Owner unassigned
Status
Status not verified
Target date
Date not set
Latest forecast
Date not set
Next review
Date not set

Outcome Reps can prepare, approve and record a customer's signed quote.

Next actions

TaskOwnerDueStatus
Record the com_consume_opportunity_won prefix tension when QuoteSigned is added.Owner unassignedDate not setOpen · carried from the M3 closure

Needs a decision

QuestionDecision ownerNeeded byBlocks
What does a signed quote do for operations in phase 1, while handover stays deferred? (DR-11)Sky + operationsAt M5 scopingM5 scope

Done when

CheckEvidence
A real quote is built, approved and sent, with a customer-ready document.No evidence linked
Previous sent versions remain available.No evidence linked
Signing requires the signed date and supporting document.No evidence linked
Related records update without duplicate subscriptions.No evidence linked
MoreCompleted work · Scope · Decisions · Acceptance criteria · Checklist · Prompt

Completed work

No completed work is recorded in the current Build Plans.

Scope

Wizard, approval, print · highest risk

M5
Highest risk

The largest and most intricate piece in the release. In the prototype the wizard alone ran to roughly 1,900 lines, which is a warning rather than an estimate: budget for it as its own project.

  • Build the money maths first, standalone and tested, before any of it is wired to a screen.
  • Quote header — customer, billing entity, currency, VAT treatment, payment terms. Customer name and address snapshotted, not referenced.
  • Quote versions — identity on the quote, everything revisable on the version, lines hanging off the version. A version is created when a sent document is superseded, never on an edit. DR-13.
  • Line items — product, quantity, unit price, discount, term, billing frequency actually persisted, per-location scope.
  • Totals as a pure, unit-tested module: one-time, MRR, ARR, TCV over the full term, VAT inclusive and exclusive.
  • Approval flow with the submit/approve permission split, the request email, and the signed approve/reject endpoint that re-checks the role. No automated checks — the approver sees discount, margin and total, and decides. DR-04. Nobody approves their own quote, enforced at the database — DR-26.
  • com_quote_line_locations and the location plan — written, and read by nothing until phase 2.
  • Signature as an outbox chain, not one transaction — with the rest of the cascade shown as pending until the consumers land (DR-02, decided).
  • Activation mode on the quote — synchronised across every location, or each starting on its own installation. Sales chooses it here and it rides on the QuoteSigned event, after which sub_ owns it. DR-42.
  • Expected activation month, required, forecast-only — on the location assignment, bulk-set from the quote. Labelled as an estimate and never printed on the customer document. DR-42.
  • Change orders — a new quote pointing at the one it changes, additions only, offered only on a signed quote. Same approval path. DR-43.
  • Customer-facing print view rendered from the seller profile and billing entity. The contract template constrains the layout — check the serverless rendering ceiling in week one, and bound the review loop. DR-32.
  • Signature requires the document. Status change, signed date and attachment, all three enforced at the write. Irreversible — no un-sign action exists. DR-33.

Work Quote builder · Totals · Approval · Versions · Customer document · Signature and change orders

Decisions and blockers

Existing decision to respect DR-19 DR-42

Needs clarification DR-11 — Open. Its card says “at M5 scoping” and the M5 prompt lists it as blocking, but §20’s table does not mark it as needed before M5 starts.

Needs clarification DR-40 — Open. The M5 prompt lists it as blocking, but its card says “before M5 builds step three” and §20’s table does not mark it as needed before M5 starts.

These references disagree. The decision register is the record. Settle them at this milestone’s scoping; do not pick one during a build.

Acceptance criteria

Acceptance criteria

A real quote is built, approved and sent from ConnectIQ, and its PDF is one a customer would accept.

Verification checklist

What this tracker tracks

This tracker follows work as far as staging. A task is done when it is merged to staging and verified there. Nothing here is released to production task by task. The milestone goes to production in one release at its end, and that release is checked in production before the milestone is accepted.

0 of 19

Build

TaskHow you know it is doneOwner
The money maths first — standalone and tested, before any of it is wired to a screen. One pure module in the com_ domain exporting line value, subtotal, VAT both ways, MRR, ARR and TCV, plus M4’s four-step price resolution. It takes a quote as its input for cost, never a line — volume bands resolve across the quote total. No React, no Supabase client and no form component anywhere in its import graph: the tests run on numbers alone. Prove it by deleting the wizard route — the module and its tests still compile and pass.
Quote header — customer, billing entity, currency, VAT treatment, payment terms. com_quotes holds only what never changes across a revision: the number allocated once from com_document_number_series, company, seller entity, currency, signed_version_id, changes_quote_id. VAT treatment, payment terms and expiry sit on com_quote_versions — a revisable field on the quote rewrites history at every revision. Customer name and address on the document are snapshots, the company and seller entity are references by id. Rename the company in cl_, reload a quote sent last month: the document still reads the old name while the company record reads the new one.
Versions — identity on the quote, everything revisable on the version, lines hanging off the version. Two independent axes on com_quote_versions, never collapsed into one field: status — Draft, Pending approval, Approved, Sent, Signed, Withdrawn, Expired, Ready for deployment, Handed to deployment — and approval state, pending, approved or rejected. Nearly every gate tests the approval state. A version is created when a sent document is superseded, never on an edit and never after signature. Edit a draft five times and the version count stays one; supersede a sent quote and the number is unchanged while the version number moves to 2. Everything asking what was agreed follows signed_version_id.
Line items — product, quantity, unit price, discount, term, billing frequency actually persisted, per-location scope. com_quote_lines hangs off the version and carries type, billing frequency, term in months, renewal type, position and a four-field cost_snapshot: cost amount, cost currency, the FX rate used and that rate’s date, written when pricing freezes. Recompute every line sharing a product whenever one is added, removed or requantified. Monthly, quarterly and annual all survive a save and a reload — read the row back from the database, not the form, and repeat it through an edit, because the prototype discarded the selection on update as well as create and wrote Monthly for every recurring line. Subscription value normalises from this field at signature, so a wrong value is wrong money.
Totals as a pure, unit-tested module. Subtotal is one-time plus full-term recurring, and TCV covers the whole subscription term rather than one year. VAT is one treatment per quote against at most one com_vat_rates row: exclusive adds tax on top of the line prices, inclusive means the line prices already contain it and the net is derived. One-time, MRR, ARR and TCV over the full term, VAT both ways: a 36-month line at 100 a month is 3,600 of TCV, not 1,200, and at a 10% rate that same 100 totals 110 exclusive and 100 inclusive with 90.91 net. Assert the derived fields on the version, not the figures on the screen.
Approval flow — submit/approve permission split, the request email, a signed approve/reject endpoint that re-checks the role. com_approval_rules holds one real row — reps submit, managers and admins approve, no value banding — so configurability later is an insert, not a migration. Requests and decisions hang off the version, the mail is queued to plat_email_queue for the worker rather than sent from the handler, and a repeat click returns already approved. No automated checks: the approver sees discount, margin and total and decides — and margin reads the line’s cost_snapshot, never a live lookup, saying pending data where no cost was sourced. Nobody approves their own quote, enforced at the database. Submit as a manager, then post that same manager’s valid approve token and watch the write refuse.
com_quote_line_locations and the location plan — one row per line per location carrying its quantity, plus com_quote_location_plans for the locations a quote rolls out to, with tentative install date, note and position, each location at most once. Never collapse the assignments to a location list on the line: thirty sites is thirty rows, and that grain is what phase 2 counts objects from. Written, and read by nothing until phase 2. Assigned quantity can never exceed line quantity, and it is the database that says so — insert 6 then 5 against a line of quantity 10 in raw SQL, bypassing the form, and the second insert fails.
Signature as an outbox chain, not one transaction. One com_ transaction flips the quote to Signed, wins the opportunity at 100% inline and inserts QuoteSigned into plat_domain_events; the dispatcher then feeds idempotent sub_ and cl_ consumers that create a subscription per recurring line and promote the company to Client. Time the dispatcher here — reliably sub-second earns an inline spinner instead of a persistent chip. The rest of the cascade shows as pending until the consumers land — a visible being-created state that resolves on the next fetch, never optimistic and never a blocked screen, and visually distinct from the weeks-long awaiting-activation state. Stop the worker, sign, and the screen still tells the truth.
Activation mode on the quote — synchronised across locations, or each on its own installation. Synchronised starts every subscription from the first of the month after the last location is installed; per location, each starts after its own. Resolve changes_quote_id to the root quote at signature and carry both the mode and that root id on the QuoteSigned payload — sub_ cannot read com_ tables. Sales chooses it here and it rides the QuoteSigned event, after which sub_ owns it — a seed, not a frozen snapshot, still editable there, and the quote is never re-read. Grep the sub_ consumer for a com_ table name and find none.
Expected activation month — required, forecast-only, bulk-set from the quote. It lives on com_quote_line_locations, because under per-location mode the months genuinely differ; the quote-level control bulk-sets them and is the only editable one in synchronised mode. Required before the quote can leave Draft, alongside the existing step-1 gates. The billing start date is M6’s and is not writable here. Labelled an estimate in both field label and helper text, and never printed on the customer document — set a month, render the PDF and search it for that date, finding nothing. A quote with the month unset cannot leave Draft.
Change orders — a new quote pointing at the one it changes, additions only. One nullable changes_quote_id self-reference, a fresh number from the allocator, the same approval path and the same signature cascade. Reductions need DR-05’s termination machinery — say so in the UI rather than half-building it. Duplicate before signature, Raise change order after: mutually exclusive by status, and they must not look alike to a rep. Offered only on a signed quote, and it takes the same approval path — post the raise against an unsigned quote and the server refuses rather than the button merely being hidden. Under synchronised mode the screen states, as the order is raised, how far the billing start moves and how many subscriptions move with it.
Customer-facing print view rendered from the seller profile and billing entity — com_seller_entities and cl_company_billing_entities — following the design system’s customer-facing conventions. Snapshot the terms and conditions text onto the quote, not a reference to settings. Freeze the content, which fields appear and what the terms say, then iterate presentation only. Check the serverless rendering ceiling in week one and bound the review loop with a number and a date — the contract template constrains the layout. Edit the standard terms in settings afterwards and reopen a quote sent last month: it still reads what the customer agreed to.
Signature requires the document — status change, signed date and attachment, all three enforced at the write, so the proof is a constraint on signing and not a follow-up. Accept & sign is offered only where the version is internally approved and not draft, signed or withdrawn. No countersignature and no e-signature integration. Irreversible: no un-sign action exists, and a signed quote cannot be deleted by any path. Attempt the sign write with the attachment omitted and it fails at the database, not in the form. A quote signed by mistake is corrected by an admin in the database with an audit note, deliberately unreachable from the UI.

Hand-written tests — these block a merge on their own

Totals — one-time, recurring across a term, discounts, VAT inclusive and exclusive, TCV over the full term, multi-currency. Fixture: a 5,000 one-time line beside three units of a 100-a-month line over 36 months at 10% discount, where TCV counts all 36 months and not 12. At a 10% rate that 100 is 110 exclusive and 100 inclusive with 90.91 net, and a line in another currency resolves on the dated com_fx_rates row — and refuses where no rate exists on or before the date.
Approval history stays attached to the version that was approved. Approve v1, send it, supersede it with v2, and the request and decision rows — requested approver, decided at, status before, status after — still point at v1, while v2 starts with approval state pending and no request against it.
Revert-to-draft destroys the approval record completely, and re-approval starts clean. After the revert the version carries no approver, no decision and no submission time, its approval state is pending, and the next submission is a new request with no decision on it.
A signed quote cannot be deleted, by any path. Try the row action, the service function and a raw delete issued with admin rights — all three refused by the database, so nothing that skips the UI can get through.
Assigned quantity can never exceed line quantity. Against a line of quantity 10, insert assignments of 6 and 5 straight into com_quote_line_locations and watch the second fail, then reduce the line to 8 while 10 are assigned and watch that fail the same way.
QuoteSigned delivered twice creates one set of subscriptions, not two. Replay the same plat_domain_events row through the sub_ consumer and count: the second delivery changes nothing — no duplicate subscription and no second promotion of the company.

Implementation prompt

View implementation prompt

M6 · Subscriptions and reporting

Status not verified
Owner
Owner unassigned
Status
Status not verified
Target date
Date not set
Latest forecast
Date not set
Next review
Date not set

Outcome The team can manage subscriptions and explain the revenue forecast.

Next actions

No tasks assigned yet. Tasks in the tracker: status not verified.

Needs a decision

QuestionDecision ownerNeeded byBlocks
Which reasons can a user select when ending a subscription? (DR-05)Sky + financeBefore M6 shipsM6 release
What do new and migrated subscriptions hang off? (DR-10)SkyBefore M6M6 subscriptions

Done when

CheckEvidence
A manager can run a forecast review and verify the numbers.No evidence linked
Signing and billing start are treated as separate events.No evidence linked
Renewals retain each subscription item's dates and history.No evidence linked
Passing the term end does not silently remove continuing revenue.No evidence linked
MoreCompleted work · Scope · Decisions · Acceptance criteria · Checklist · Prompt

Completed work

No completed work is recorded in the current Build Plans.

Scope

Subscriptions, forecast, dashboard

M6
Reporting

What a manager needs before they will agree to switch off HubSpot.

  • Subscriptions created only by consuming QuoteSigned, in their own domain — created awaiting activation, not active. DR-42.
  • Activation groups and the activation screen. Set a billing start date across a group or one subscription at a time, per its mode. A group spans quotes — change orders join it. Manual in phase 1; nothing in ConnectIQ can see an installation finish. DR-42 · DR-43.
  • Subscription list grouped by account — term, renewal type, status, MRR, ARR — read through a view, never a raw cross-domain join.
  • Renewal: select subscriptions, generate one quote per company. Per item, not per quote — the grouping is for the customer, and signing restarts each item's own term from its own dates. DR-05.
  • Term end does not stop billing. A subscription past its term is renewal due and still contributing MRR until someone terminates it. Never filter revenue by term end.
  • Forecast matrix: new ARR and one-time revenue by period, with currency conversion. Nothing stored. Committed revenue lands in its expected activation month until activation replaces it with the real one — plus an expected-versus-actual variance, without which the required field decays into noise. DR-42.
  • Today dashboard as a derived work queue from nightly snapshots plus live counts.

Work Activation · Renewals · Terminations · Revenue forecast · Today dashboard

Decisions and blockers

Existing decision to respect DR-09 DR-16 DR-33 DR-42

Needs clarification DR-05 — Open, needed “before M6 ships” per its card and §20’s table. The M6 prompt does not list it.

Needs clarification DR-10 — Open. Its card says “before M6” and the M6 prompt lists it as blocking, but §20’s table does not mark it as needed before M6 starts.

These references disagree. The decision register is the record. Settle them at this milestone’s scoping; do not pick one during a build.

Acceptance criteria

Acceptance criteria

A manager runs a forecast conversation from ConnectIQ and the numbers survive scrutiny.

Verification checklist

What this tracker tracks

This tracker follows work as far as staging. A task is done when it is merged to staging and verified there. Nothing here is released to production task by task. The milestone goes to production in one release at its end, and that release is checked in production before the milestone is accepted.

0 of 11

Build

TaskHow you know it is doneOwner
Subscriptions created only by consuming QuoteSigned, in their own sub_ domain — one numbered migration creating sub_subscriptions with its RLS policies in the same file. Monthly value, billing frequency, nullable billing_start_date, term_end_date, next billing date, auto-renew flag; com_quote_line_id and cl_company_id held and never joined, plus DR-10’s nullable inv_connectiq_object_id shipped empty. Status is derived in code, never a column — the prototype’s orphan Expired is exactly what a typed status produces. Created awaiting activation, not active. Never reached by joining from quotes — the link is the event. Falsify it: read the migration for a status column and find none, and confirm every row lands with billing_start_date null and contributing zero MRR.
Activation groups and the activation screen — sub_activation_groups holds activation_mode, the resolved billing start date and a stored, never joined com_root_quote_id; the subscription points at its group. Create it when a quote is first signed and join it when a change order signs, by the root id already in the QuoteSigned payload. The mode is seeded from that payload, owned by sub_ thereafter and still editable — switching a stuck synchronised group to per location is how its revenue gets released. A billing start date set across a group or one subscription at a time, per its mode. A group spans quotes, so change orders join it. Manual in phase 1 — nothing can see an installation finish. Enter an installation completing on the 1st and billing starts the 1st of the next month, and a joiner arriving before activation moves the shared date for everyone already in the group.
Subscription list grouped by account — term, renewal type, status, MRR, ARR, and per group the active count, next renewal date and flags for renewal in progress and expiring soon. It reads rpt_subscription_estate, created WITH (security_invoker = true) — rule 12. A raw join to cl_companies from a sub_ screen breaks rule 4, and a view missing that clause hands every company’s estate to everyone. Read through a view, never a raw cross-domain join. Prove it by signing in as a user whose RLS hides most companies — the estate shrinks; a view without security_invoker = true returns the same rows to every role. Watch the requests and find no embedded cl_companies select.
Renewal — select subscriptions, generate one quote per company: sub_renewals ties one or more subscriptions to exactly one quote, and every subscription in it must share a company. Ask com_ for the draft by service call or event, per rules 2 and 7, never a direct insert. Each renewal line carries a stored sub_subscription_id — without it every renewal creates an orphan root — and signing writes a new row with renews_subscription_id and root_subscription_id, skipping activation. Per item, not per quote: signing restarts each item’s own term from its own dates. A renewal spanning two companies is rejected with the reason, not silently split. Sign one six weeks after expiry and the new row’s billing_start_date is the predecessor’s term_end_date, not the signature date — no gap, and it never sits in awaiting activation.
Term end does not stop billing — derive the six states in one shared module (awaiting activation, scheduled, active, expired, renewed, ended) and build sub_subscription_terminations — effective date, reason, customer-initiated flag, notice date — as the only thing that stops it. Half-open intervals: billing_start_date inclusive, term_end_date exclusive, and the column is never called end_date. Seed the shared extensible reason list before shipping; removed by change order is already known and retention must exclude it. A subscription past its term is renewal due and still contributing MRR until someone terminates it. Never filter revenue by term end. Take a row one day past term_end_date with no successor and no termination — it still appears in MRR, the estate and the dashboard, and giving it a successor makes it read renewed rather than expired, with the total value sitting in Expired and the age of the oldest both on screen.
Forecast matrix — new ARR and one-time revenue by period, with currency conversion, stored nowhere: two tabs behind one open_forecast flag, not four, and plat_forecast_scenarios the only stored row. It reads rpt_forecast_periods, created WITH (security_invoker = true) per rule 12 — the view crosses com_, sub_ and cl_, and rule 4 means forecast code touches none of those raw tables. Lost opportunities are excluded always; signed quotes contribute regardless of the parent’s flag, stage or existence. Committed revenue lands in its expected activation month until activation replaces it, plus an expected-versus-actual variance. Two runs a month apart on unchanged data agree, and the forecast prints its as-of date — open pipeline converts at the current rate while signed quotes hold their snapshot (DR-07), so without that date a legitimate move reads as a bug. Run the matrix and no row is written anywhere.
Today dashboard as a derived work queue from nightly snapshots plus live counts — five items only: opportunity starts soon, missing forecast, quote awaiting signature, quote pending approval, activity due today, each ranked HIGH, MEDIUM or LOW from age or the proximity of a date. It reads rpt_today_feed, created WITH (security_invoker = true), because rule 4 forbids dashboard code reading com_ or cl_ tables raw. Omit deployment ready for handover — DR-11 left no handover in phase 1. Derived, not a second source of truth. Sign in without dashboard_all_users and the scope picker is absent from the markup rather than rendered disabled, and the feed is silently scoped to that person’s own records. Change the base currency and every figure reformats, and the choice survives a sign-out.

Hand-written tests — these block a merge on their own

Forecast classes — a lost opportunity never appears; a signed quote with no opportunity does; a signed quote under an unflagged opportunity still contributes committed revenue. Fixture: a lost opportunity, an open one flagged for inclusion, an open one with the flag off carrying a signed quote, and a signed quote with no parent at all. Assert the lost row lands in no class and the orphan lands in committed grouped by company — counting orphans is deliberate (D-7), not a defect to fix.
Currency conversion uses the dated rate, and two runs a month apart against unchanged data agree. Fixture: a signed quote carrying its own snapshot rate and an open opportunity carrying none. Assert committed converts at the snapshot and is identical on both runs, that pipeline converts at the rate current on each forecast date, and that the as-of date is on the output — the two classes converting differently is DR-07, not drift.
QuoteSigned twice → one subscription per recurring line. Deliver the identical payload twice against a quote with two recurring lines and one one-time line: assert two subscriptions after the first delivery and still two after the second, nothing for the one-time line, every row with billing_start_date null, and one sub_activation_groups row rather than two.
A renewal spanning two companies is rejected with the reason, not silently split. Fixture: two subscriptions under different cl_company_id values selected together. Assert the error names the constraint, that no draft quote is created and no number allocated from the series, and that neither subscription moves to Renewal in progress.

Implementation prompt

View implementation prompt

M7 · Email

Status not verified
Owner
Owner unassigned
Status
Status not verified
Target date
End Oct 2026 goal — not reconciled (DR-30)
Latest forecast
Date not set
Next review
Date not set

Outcome Reps can send customer email and read logged replies on CRM records.

Next actions

TaskOwnerDueStatus
Check whether Google requires a security assessment for the Gmail restricted scopes.SkyNow — it sets the cutover dateOpen
Read the team's per-mailbox daily send limit and the Gmail API rate limit.SkyNowOpen
Count sequences and enrolments in the last ninety days.Sky + salesBefore email is sizedOpen

Needs a decision

QuestionDecision ownerNeeded byBlocks
Is working connected email a mandatory launch condition? Check the provider approval requirements and sending limits. (DR-47)SkyNow — it sets the cutover dateM7 · launch date
Which mail provider does the team use, and how much does it use automated sequences? (DR-31)Sky + salesBefore email is sizedM7 sizing
Poll or push for incoming mail? (DR-49)SkyBefore M7M7 infrastructure
Store attachment files, or metadata only? (DR-50)SkyBefore M7M7 storage
How much message body is stored, and for how long? (DR-51)SkyBefore M7M7 retention
Does stored correspondence need anything said to the other party? (DR-52)Sky. The data-protection owner's input is needed.Before M7M7
Grant the exception for provider message ids on a domain table, or take the foreign key? (DR-53)SkyBefore M7M7 schema

Done when

CheckEvidence
A rep connects a supported mailbox and emails a contact from their record.No evidence linked
The reply appears in the same thread within the stated sync interval.No evidence linked
Other reps cannot read it; users with the email-visibility permission can.No evidence linked
Never-log addresses leave no stored message, including raw import data.No evidence linked
MoreCompleted work · Scope · Decisions · Acceptance criteria · Checklist · Prompt

Completed work

No completed work is recorded in the current Build Plans.

Scope

Connected email — capture, send, timeline, templates

M7
Email

The milestone DR-31 created, and it ships before cutover — a rep arriving in ConnectIQ with no correspondence on any record is the resistance risk in §08 arriving on schedule. Scoped as a separate feature definition with thirteen user stories, because the acceptance criteria are what make the build prompt testable.

  • User mail and system mail never touch. Resend and plat_email_queue keep system mail and are not modified. Anything sent as a rep goes through that rep's own Google mailbox, or it will not thread for the customer and will not appear in their Sent folder. Separate tables, separate worker, separate send log, separate limiter.
  • Mailbox connection in plat_ — OAuth, the encrypted credential outside the table, the sync cursor, and a capture_from that is set at connection and never moved backwards. That column is what enforces DR-27.
  • The never-log list — addresses and domains, organisation-wide and per user, sub-domains matching on a dot boundary. Evaluated before anything is landed, so a suppressed message never exists in int_ either. The privacy control the rest of the milestone is permitted to exist on.
  • Raw provider payloads land in int_mail_messages_raw and are interpreted into the activities domain by a second worker. Neither worker writes across a prefix — the service client is the only door.
  • Automatic association: contact, its company, and an opportunity only when there is exactly one open. Several known contacts on one message means many links and one message row. No known participant means nothing is written at all.
  • Email tab on contact, company and opportunity, threaded and newest first. Bodies sanitised on write, not on render, with remote images rewritten so opening a logged email cannot confirm receipt to the sender. Messages live in com_ (DR-48), so the contact and company tabs read rpt_record_email_timeline, created WITH (security_invoker = true); the opportunity and quote tabs stay same-domain.
  • Compose and reply from a record, from shared or private templates. Replies carry In-Reply-To and References as well as the provider thread id — the id threads it for us, the headers thread it for the customer. An unresolved placeholder refuses the send and names itself.
  • The per-mailbox rate limiter is not optional. Daily and per-minute caps below Google's, sends deferred rather than dropped, bounded backoff. Without it the first bulk send throttles a rep's real email account — and bounced CRM mail now degrades giant-pumpkin.com's own reputation, which the Resend subdomain split was chosen to prevent.
  • No sequences, no open or click tracking, no BCC logging, no IMAP, no Microsoft Graph, no attachment bytes, no history import. Each is deferred with the trigger that brings it back.

Work Mailbox connection · Sending · Reply capture · Templates · Privacy and visibility

Decisions and blockers

Resolve before starting DR-47 DR-49 DR-51 DR-52 DR-53

Existing decision to respect DR-15 DR-27 (must not be quietly undone) DR-48 DR-54

Needs clarification DR-31 — Open. M7’s “Needs a decision” lists it, but §20’s table and the M7 prompt do not.

Needs clarification DR-40 — Open. The M7 prompt says it “must be answered or explicitly separated”; §20’s table lists it for M7 without marking it as needed before M7 starts.

Needs clarification DR-50 — Open. Its card says it is needed before M7, and M7’s “Needs a decision” lists it. §20’s table does not list it for M7. The M7 prompt’s “Blocked on” line does not list it, but the prompt body and §14’s entity map already specify “metadata only”.

These references disagree. The decision register is the record. Settle them at this milestone’s scoping; do not pick one during a build.

Acceptance criteria

Acceptance criteria

A rep connects their Google mailbox, emails a contact from that contact’s record, and the customer’s reply appears on the record within one sync interval — threaded. A second rep cannot see any of it; a colleague holding view_all_email can. An address on the never-log list produces no row anywhere, including in int_.

Verification checklist

What this tracker tracks

This tracker follows work as far as staging. A task is done when it is merged to staging and verified there. Nothing here is released to production task by task. The milestone goes to production in one release at its end, and that release is checked in production before the milestone is accepted.

0 of 13

Build

TaskHow you know it is doneOwner
User mail and system mail never touch — build plat_mail_send_queue and plat_mail_send_log with their own worker and limiter, leaving the Resend path untouched. Do not add a source column to plat_email_queue and route both through it: they differ in sender, credential, quota owner and retention obligation. Sending as the rep routes CRM mail back onto giant-pumpkin.com, partly undoing the isolation the Resend subdomain split bought. Read the migration diff: any change to plat_email_queue, plat_email_send_log, plat_suppressed_emails or plat_email_unsubscribe_tokens means the two paths are being conflated — stop and say so. A rep’s send lands in that rep’s own Gmail Sent folder, from their address, not from notifications@connectiq.giant-pumpkin.com.
Mailbox connection in plat_ — OAuth, the encrypted credential outside the table, a sync cursor: plat_mail_accounts, one row per user, user_id unique, with history_cursor and a credential_ref pointing at a Supabase Vault secret rather than a token column. Vault is per-user and new to this documentation set — name it as new. Disconnect nulls credential_ref, destroys the secret and revokes the grant at Google. Push or poll is DR-49; whether this gates cutover is DR-47. capture_from is set at connection and never moved backwards. That column is what enforces DR-27 — which is also why connecting mailboxes is a cutover-prep step done as early in M7 as the feature allows, not on the day reps arrive. Open the admin health screen as a sales manager: it is a view that omits credential_ref, so the column is not selectable at all.
The never-log list — addresses and domains, organisation-wide and per user, sub-domains matching on a dot boundary: plat_mail_never_log, user_id null for an organisation entry and required for a user one by check constraint, pattern lowercased on write, pattern_type derived from whether it contains @. Say in the migration comment that this is not plat_suppressed_emails — that stops us sending, this stops us recording. It covers the rep’s privacy, not the counterparty’s; that is DR-52. Evaluated by the fetch worker before anything lands, so a suppressed message never exists in int_ either. Run example.com against anyone@mail.example.com and against notexample.com — the first matches on the dot boundary, the second must not — and watch the suppressed count on plat_mail_sync_runs rise while no raw row is written.
Raw provider payloads to int_mail_messages_raw, interpreted by a second worker — provider_message_id unique with provider, so idempotency is the database’s job and not the worker’s, plat_mail_account_id a RESTRICT cross-prefix FK, processed_at null until capture has read it. Purge processed rows after a short window, stated in the migration comment; the window is DR-51’s. Rule 10 does not bite in int_, but it bites on com_email_messages.provider_message_id — DR-53. Neither worker writes across a prefix — the service client is the only door, including the call that marks a raw row processed. Search the tree for writes naming the int_ tables: a hit outside the int_ domain folder is the rule 3 breach, however the transaction is dressed up.
Automatic association — contact, its company, and an opportunity only when there is exactly one open: the capture worker writes one row per target into com_email_message_links, one non-null target column per row by check constraint, link_source auto or manual. Do not collapse it into a polymorphic pair — DR-15 binds that, closed. The prefix is DR-48’s, closed — com_. Capture does not also write an activity row — DR-54, closed — so a contact’s date of last contact is derived from completed com_activities and sent or received com_email_messages together. Several known contacts on one message means many links and one message row. No known participant means nothing is written at all — not a message row with no links. Two open opportunities links contact and company only: a guessed opportunity is worse than none, and a rep attaches by hand.
Email tab on contact, company and opportunity — threaded, newest first by sent_at, grouped on provider_thread_id, showing direction, counterpart, subject, snippet and an attachment indicator, a company listing all its contacts’ correspondence, attributed. Bodies are sanitised into body_html_sanitised on write — script, event handlers, style with url(), iframes, objects and forms out, remote image sources rewritten. Attachments are metadata only per DR-50, and the empty state says capture starts at the connection date. Assert against the stored body_html_sanitised, not the rendered page, so the guarantee does not depend on the renderer: a fixture payload carrying a script tag, an inline event handler and a remote image stores none of the three live. A remote image that still loads confirms receipt to the sender — the tracking pixel we chose not to build, operated against us.
rpt_record_email_timeline created WITH (security_invoker = true) — messages live in com_ (DR-48, decided), so the contact and company Email tabs join com_ messages to cl_ records, and rules 4 and 5 forbid both the raw read and the nested select, reporting and dashboard code included. Those two tabs read the view and never the tables; opportunity and quote stay inside one prefix and need no view. Query the view as a rep who holds no view_all_email: another rep’s messages must not appear. Without security_invoker the view runs with the creator’s rights, bypasses RLS on everything underneath, and passes every other test in this list.
Compose and reply from a record, from shared or private templates — com_email_templates, a fixed placeholder set resolved server-side, not arbitrary field paths. The send is a plat_mail_send_queue row drained through that rep’s own Gmail credential, and a com_email_messages row exists only once Google returns an identifier. Refuse before the rep writes anything when there is no address or the mailbox needs reauthentication. Build the com_quote_id link column, but no send-quote action until DR-40. Replies carry In-Reply-To and References as well as the provider thread id, and inherit the record links of the message they answer. An unresolved placeholder refuses the send and names itself. Make Google reject one: the composed body comes back to the rep, plat_mail_send_log records failed with a reason, and com_email_messages has no row at all.
The per-mailbox rate limiter — not optional: a daily cap and a per-minute cap held as configuration rather than constants, seeded from the confirmed Workspace per-mailbox send limit and the Gmail API per-user rate limit, and set below Google’s rather than at them. On the daily cap a send becomes deferred_rate_limit; a quota error backs off exponentially to a bounded attempt count and then failed with the reason. Send at the cap boundary, then one more: the second sits in plat_mail_send_queue as deferred_rate_limit, told and not lost — not failed, not silently rolled into tomorrow. Backoff terminates at the attempt ceiling instead of looping against a rep’s own mailbox; the margin below Google’s numbers is what leaves them able to send at 5pm.

Hand-written tests — these block a merge on their own

Never-log matching as a standalone module against fixtures — address, bare domain, sub-domain on a dot boundary with example.com not matching notexample.com, mixed case, and the participant in From, in To and in Cc separately, with an organisation entry and the synced mailbox’s own user entry both applied. Assert no row in int_mail_messages_raw — absence from com_email_messages is a different and weaker guarantee — and the suppressed count on plat_mail_sync_runs incremented instead.
Idempotency — the same provider message processed twice yields one message row and one set of links, held by the unique constraint on provider and provider_message_id rather than by a check-then-insert. Assert again with the raw row re-marked unprocessed, which is the real-world case after a capture bug is fixed. Cursor safety belongs with it: a fetch failing mid-page leaves history_cursor unadvanced and the next run re-fetches without duplicating.
Association matrix — one open opportunity links it with link_source auto; two links contact and company only; none links contact and company; three known contacts in Cc gives three rows in com_email_message_links and one message row; no known participant writes nothing at all. Plus the compose case: a send from an opportunity links that opportunity however many are open.
Permission matrix per role × own mailbox / another’s, exercised through the database with the caller’s own credentials rather than through the UI, resolved by the SECURITY DEFINER helper against plat_user_roles and plat_role_permissions. A rep holding view_all_records but not view_all_email cannot see another rep’s mail — DR-26 has the records flag on for most people, so resolving email through it would show nearly everyone nearly every mailbox. Include the unset flag for an admin: not granted.

Implementation prompt

View implementation prompt

M8 · Launch

Status not verified
Owner
Owner unassigned
Status
Status not verified
Target date
Date not set
Latest forecast
Date not set
Next review
Date not set

Outcome The team works in ConnectIQ, with HubSpot retained as a read-only archive.

Next actions

TaskOwnerDueStatus
Back-fill sheet of existing subscriptions (DR-34).Boss and Ning16 Aug 2026Date passed · completion not verified
Confirm the back-filled recurring-revenue total against the business's own figure.Owner unassignedDate not setOpen
Review ambiguous Airtable–HubSpot customer matches (DR-25).ChrisOne week before migration startsStatus not verified
Write down the archive location and retention period.Owner unassignedDate not setOpen
Rehearse restoring a backup before cutover.Owner unassignedDate not setOpen

Needs a decision

QuestionDecision ownerNeeded byBlocks
How do Chris and Sebastian decide if they disagree about the switch? (DR-29)Chris + SebastianDate not setM8 approval

Done when

CheckEvidence
Imported records and recurring revenue reconcile, and reps check their accounts.No evidence linked
Historical quote documents and reporting data are preserved.No evidence linked
The team completes a full week without HubSpot during the two-week parallel run.No evidence linked
Chris and Sebastian approve the switch.No evidence linked
HubSpot is read-only, with a six-month access window and a documented archive plan.No evidence linked
MoreCompleted work · Scope · Decisions · Acceptance criteria · Checklist · Prompt

Completed work

No completed work is recorded in the current Build Plans.

Scope

Migration, parallel run, cutover

M8
Release

The part most likely to be underestimated. Start the dry runs during M5, not after M7 — the first import always finds data problems that take days rather than hours to resolve.

  • Import module — upload, map, dry-run with a full error report, then commit.
  • Mappings for companies, contacts and open deals, with owner and stage translation, and brands split from a comma-separated column into rows.
  • Deduplication in staging, before records land, with a human review pass for ambiguous matches.
  • Back-fill of live subscriptions and current price books.
  • Rehearsal in a production-shaped environment, at least twice, against a real export.
  • Parallel run, training, then HubSpot to read-only and the switch-off.

Work Data cleanup · Import rehearsals · Reconciliation · Training · Two-week parallel run · Archive

Decisions and blockers

Existing decision to respect DR-14 DR-19 DR-23 DR-25 DR-46

Needs clarification DR-29 — Closed, but its card says “tiebreak still to state”. M8’s “Needs a decision” and the M8 prompt treat it as open.

These references disagree. The decision register is the record. Settle them at this milestone’s scoping; do not pick one during a build.

Acceptance criteria

Acceptance criteria

HubSpot is read-only, the team is working in ConnectIQ, and the archive plan is written down.

Verification checklist

What this tracker tracks

This tracker follows work as far as staging. A task is done when it is merged to staging and verified there. Nothing here is released to production task by task. The milestone goes to production in one release at its end, and that release is checked in production before the milestone is accepted.

Verification checklist not yet defined.

Implementation prompt

View implementation prompt

About this trackerShared checklist · editing access · completion

Checklist updates are shared. Editing requires the tracker's edit token; otherwise, the checklist is read-only. Use the checklist to track progress. Completion also requires a demonstration against the milestone's acceptance criteria.

Edit mode (Locked)

Every visitor sees the same ticks — they load from one shared, public record, no sign-in needed to view. Checking a box requires an edit token, so only whoever holds it can change what everyone sees. Paste it below to edit; it is stored only in this browser's local storage and sent only to api.github.com, never written into this site's source.

The token must be a classic one. GitHub → Settings → Developer settings → Personal access tokens → Tokens (classic) → Generate new token, with the gist scope and nothing else.

Edit token
Loading shared state…