Move daily CRM work to ConnectIQ. Operations stays in Airtable.
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
Progress could not be loaded. Last updated: 6 Oct 2026.
Outcome Approved users can sign in with the right access.
No tasks assigned yet. Tasks in the tracker: status not verified.
No open decisions recorded.
| Check | Evidence |
|---|---|
| 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.
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.
Project, auth, roles, permissions, outbox
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.
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.Work Access and roles · User profiles · App navigation · Background jobs · Email delivery
Existing decision to respect DR-17 DR-24 DR-26
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.
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.
| Task | How you know it is done | Owner | |
|---|---|---|---|
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 |
| 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. |
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. |
| 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 |
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. |
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. |
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. |
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. |
|||
Outcome Reps can find and update a customer's companies, contacts, brands and locations.
No tasks assigned yet. Tasks in the tracker: status not verified.
No open decisions recorded.
| Check | Evidence |
|---|---|
| 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.
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.
Companies, brands, contacts, locations
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.
cl_ master data — created and edited inside the company record, one company each. No sync, no status page, no request-a-location flow.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.Work Customer lists · Record editing · Addresses · Duplicate flags · Notes and history
Existing decision to respect DR-22
A rep can find any customer, see its sites and people, and edit a record without leaving the list.
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.
| Task | How you know it is done | Owner | |
|---|---|---|---|
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. |
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. |
|||
Outcome Reps can manage a deal from its first stage to its outcome.
| Task | Owner | Due | Status |
|---|---|---|---|
Check the seven m3_ migrations in the production ledger. Needs someone with production access. | Sky | Date not set | Done · 7 Oct · confirmed in production (see M4) |
Run the DR-16 reconciliation script against production (scripts/reconcile-client-status.ts). | Sky | Date not set | Done · 7 Oct · no drift (see M4) |
| Enable the outbox drain in production from the generated SQL, reviewed and run by hand (Plan 33). | Sky | Date not set | Done · 7 Oct · verified (see M4) |
| Smoke-check the live deploy. | Planning session and Sky | Date not set | Done · 7 Oct · record page not opened (see M4) |
| Add register cards for DR-59 and DR-60. | Sky | Date not set | Open |
No open decisions recorded.
| Check | Evidence |
|---|---|
| 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.
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.
| Brief | Pull 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).
Opportunities, lines and rollout, activities
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.
Work Opportunity list and board · Stage history · Follow-ups · Deal lines · Rollout forecasts
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.
A rep works one real deal end to end in ConnectIQ while HubSpot still holds the rest.
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.
| Task | How you know it is done | Owner | |
|---|---|---|---|
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. |
| 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. |
|||
Outcome Reps have the products, prices and commercial settings they need to quote.
| Task | Owner | Due | Status |
|---|---|---|---|
| Close the billing-periods decision, then write the forecast projection (moved from M3). | Owner unassigned | Date not set | Open |
| Find where product cost data lives today. | Owner unassigned | Date not set | Open · needs a named owner and a date |
| Name who maintains the exchange-rate table (DR-07). | Owner unassigned | Date not set | Open |
| Confirm the peso currency codes before seeding (DR-07). | Finance | Date not set | Status not verified |
Run the DR-16 reconciliation (scripts/reconcile-client-status.ts) against production daily until 21 Oct 2026, then weekly. | Sky | First run 8 Oct 2026 | Open |
| Independently re-check production after the pre-M4 release. | Planning session | Date not set | Open |
| Store banner on locations and MBO on companies (Plan 40, DR-65). | Owner unassigned | Date not set | Not started |
| Location type on every location (Plan 43, DR-68). | Owner unassigned | Date not set | Not started |
| Record M4's base commit when M4 begins. | Sky | Date not set | Open |
| Question | Decision owner | Needed by | Blocks |
|---|---|---|---|
| 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 unassigned | Date not set | M4 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) | Sky | Date not set | How co-branded stores are entered (not Plan 40) |
| Check | Evidence |
|---|---|
| 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 |
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.
| Item | Production 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 20261006120000 | Verified. Nothing else new. |
Outbox drain: job connectiq-scheduled-worker, every minute | Verified. Runs succeeded; worker returned HTTP 200. |
Drain checks: drain-verify, queue-stale-check, reconcile-client-status | Verified. VERIFIED, ok, no drift. |
| Live-site smoke check: Companies, Deals board, Deals list | Passed. 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.
Products, price books, commercial settings
Unglamorous and entirely blocking. The quote builder cannot be trusted until the numbers behind it are.
Work Catalogue · Customer prices · Costs · Exchange rates · Tax and payment settings
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.
Every product a rep needs to quote exists, priced in every currency they sell in.
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.
| Task | How you know it is done | Owner | |
|---|---|---|---|
| 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. |
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. | |||
Outcome Reps can prepare, approve and record a customer's signed quote.
| Task | Owner | Due | Status |
|---|---|---|---|
Record the com_consume_opportunity_won prefix tension when QuoteSigned is added. | Owner unassigned | Date not set | Open · carried from the M3 closure |
| Question | Decision owner | Needed by | Blocks |
|---|---|---|---|
| What does a signed quote do for operations in phase 1, while handover stays deferred? (DR-11) | Sky + operations | At M5 scoping | M5 scope |
| Check | Evidence |
|---|---|
| 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 |
No completed work is recorded in the current Build Plans.
Wizard, approval, print · 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.
com_quote_line_locations and the location plan — written, and read by nothing until phase 2.QuoteSigned event, after which sub_ owns it. DR-42.Work Quote builder · Totals · Approval · Versions · Customer document · Signature and change orders
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.
A real quote is built, approved and sent from ConnectIQ, and its PDF is one a customer would accept.
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.
| Task | How you know it is done | Owner | |
|---|---|---|---|
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. |
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. |
|||
Outcome The team can manage subscriptions and explain the revenue forecast.
No tasks assigned yet. Tasks in the tracker: status not verified.
| Question | Decision owner | Needed by | Blocks |
|---|---|---|---|
| Which reasons can a user select when ending a subscription? (DR-05) | Sky + finance | Before M6 ships | M6 release |
| What do new and migrated subscriptions hang off? (DR-10) | Sky | Before M6 | M6 subscriptions |
| Check | Evidence |
|---|---|
| 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 |
No completed work is recorded in the current Build Plans.
Subscriptions, forecast, dashboard
What a manager needs before they will agree to switch off HubSpot.
QuoteSigned, in their own domain — created awaiting activation, not active. DR-42.Work Activation · Renewals · Terminations · Revenue forecast · Today dashboard
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.
A manager runs a forecast conversation from ConnectIQ and the numbers survive scrutiny.
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.
| Task | How you know it is done | Owner | |
|---|---|---|---|
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. |
| 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. |
|||
Outcome Reps can send customer email and read logged replies on CRM records.
| Task | Owner | Due | Status |
|---|---|---|---|
| Check whether Google requires a security assessment for the Gmail restricted scopes. | Sky | Now — it sets the cutover date | Open |
| Read the team's per-mailbox daily send limit and the Gmail API rate limit. | Sky | Now | Open |
| Count sequences and enrolments in the last ninety days. | Sky + sales | Before email is sized | Open |
| Question | Decision owner | Needed by | Blocks |
|---|---|---|---|
| Is working connected email a mandatory launch condition? Check the provider approval requirements and sending limits. (DR-47) | Sky | Now — it sets the cutover date | M7 · launch date |
| Which mail provider does the team use, and how much does it use automated sequences? (DR-31) | Sky + sales | Before email is sized | M7 sizing |
| Poll or push for incoming mail? (DR-49) | Sky | Before M7 | M7 infrastructure |
| Store attachment files, or metadata only? (DR-50) | Sky | Before M7 | M7 storage |
| How much message body is stored, and for how long? (DR-51) | Sky | Before M7 | M7 retention |
| Does stored correspondence need anything said to the other party? (DR-52) | Sky. The data-protection owner's input is needed. | Before M7 | M7 |
| Grant the exception for provider message ids on a domain table, or take the foreign key? (DR-53) | Sky | Before M7 | M7 schema |
| Check | Evidence |
|---|---|
| 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 |
No completed work is recorded in the current Build Plans.
Connected email — capture, send, timeline, templates
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.
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.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.int_ either. The privacy control the rest of the milestone is permitted to exist on.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.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.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.giant-pumpkin.com's own reputation, which the Resend subdomain split was chosen to prevent.Work Mailbox connection · Sending · Reply capture · Templates · Privacy and visibility
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.
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_.
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.
| Task | How you know it is done | Owner | |
|---|---|---|---|
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. |
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. |
|||
Outcome The team works in ConnectIQ, with HubSpot retained as a read-only archive.
| Task | Owner | Due | Status |
|---|---|---|---|
| Back-fill sheet of existing subscriptions (DR-34). | Boss and Ning | 16 Aug 2026 | Date passed · completion not verified |
| Confirm the back-filled recurring-revenue total against the business's own figure. | Owner unassigned | Date not set | Open |
| Review ambiguous Airtable–HubSpot customer matches (DR-25). | Chris | One week before migration starts | Status not verified |
| Write down the archive location and retention period. | Owner unassigned | Date not set | Open |
| Rehearse restoring a backup before cutover. | Owner unassigned | Date not set | Open |
| Question | Decision owner | Needed by | Blocks |
|---|---|---|---|
| How do Chris and Sebastian decide if they disagree about the switch? (DR-29) | Chris + Sebastian | Date not set | M8 approval |
| Check | Evidence |
|---|---|
| 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 |
No completed work is recorded in the current Build Plans.
Migration, parallel run, cutover
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.
Work Data cleanup · Import rehearsals · Reconciliation · Training · Two-week parallel run · Archive
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.
HubSpot is read-only, the team is working in ConnectIQ, and the archive plan is written down.
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.
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.
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.
ConnectIQ replaces HubSpot for customer records, sales, quotes, subscriptions, email and reporting. Jobs, inventory and support stay in Airtable during Phase 1.
Launch follows a two-week parallel run, including a full week without HubSpot. Data checks, rep feedback and approval from Chris and Sebastian are required before the switch.
Historical quotes migrate. Old email threads, notes and approval history remain in HubSpot during the six-month read-only archive window.
This page has three parts. Project (sections 01–08) says what phase 1 delivers and what changes for the team. Technical reference (09–17) holds the ownership boundaries, the HubSpot import, security, operations, the rollout and revenue rules, the entity map, the data model, access and the migration detail. Decisions (18–20) holds the architecture records and the register of what is still undecided. History (21) records what the prototype taught us. The milestones themselves — order, scope, prompts, checklists — are in the Milestones view.
How anything gets built — the stack, environments, branching, testing, hosting and the quality bar every milestone is held to — moved to the strategy page. It is separate because it does not change between milestones: this plan launches one domain at a time, that page is the machinery each launch runs on.
The Lovable prototype established the working shape of the CRM — the quote wizard, the forecast matrix, the permission surface. It is the specification. It is not the codebase.
01 · What we are delivering
ConnectIQ will become the sales team's system for customer records, opportunities, quotes, subscriptions, email and revenue forecasts.
Phase 1 is complete when a sales rep, a sales manager and an administrator can each do their entire job in ConnectIQ for a full week without opening HubSpot, the migrated data has been checked, and reps confirm they are ready to switch. All three conditions must hold (DR-29).
Airtable continues to run operations. Jobs, inventory, installed equipment and support stay there until phase 2.
Read literally, the full-week test sets the scope, and it is the tie-breaker for every argument about what belongs in the release. It implies four things.
Companies, contacts and locations, complete and trustworthy enough that nobody keeps a private spreadsheet.
Opportunities move through stages with the history that makes a manager's forecast conversation possible.
Build, price, approve, send, sign — including multi-currency, VAT and per-location lines.
Real HubSpot data lands intact, and the switch-off has a date and a named owner.
Giant Pumpkin runs on three systems that each hold a partial copy of the same commercial reality. HubSpot holds the pipeline and the contact history. Airtable holds the operational truth — jobs, inventory, sites, what is actually installed where. A prototype CRM built in Lovable proved the quote, forecast and deployment-plan model but was never a production system.
The cost of that split is paid at the seams: a quote signed in one place does not create a deployment in another, a site renamed in Airtable stays wrong in HubSpot, and no single system can answer what did we sell, is it installed, and is it still being paid for without a human joining three tabs.
Airtable is load-bearing for daily operations and its replacement touches the deployment domain — the hardest part of the object model. HubSpot is load-bearing for a smaller team, has a well-understood export path, and its data model maps almost one-to-one onto the Accounts domain. Replacing it first produces a shippable system, a real migration rehearsal, and a populated accounts layer that a later Airtable migration can attach to.
02 · What changes for the team
| Work | Where it happens after phase 1 |
|---|---|
| Manage companies, brands, contacts and customer locations | ConnectIQ |
| Track opportunities and sales activity | ConnectIQ |
| Price, approve and record signed quotes | ConnectIQ |
| Manage subscriptions and forecast revenue | ConnectIQ |
| Log new customer email and send from a customer record | ConnectIQ, connected to the rep's own mailbox. The details of M7 are still open — see §06. |
| Check installations, jobs, inventory or support | Airtable |
| Look up old email threads and notes | HubSpot, read-only, for the agreed six-month archive window (DR-27) |
ConnectIQ owns customer location records for CRM work (DR-01). Airtable's existing site records stay in place for operations. There is no ongoing synchronisation between the two in phase 1, so a change in one system does not update the other.
ConnectIQ reads from HubSpot for as long as the parallel run lasts, and writes nothing back to HubSpot or Airtable. The diagram and the ownership rules are in §09.
03 · What is included
| Area | What ships |
|---|---|
| Customer records | Companies, brands, contacts and locations, with notes, tags and duplicate flagging. |
| Sales pipeline | A shared pipeline, opportunities, stage history, activities and rollout forecasts. |
| Pricing and quotes | Products, price books, currencies, discounts, value-added tax (VAT), internal approval, quote versions and customer documents. Signing is recorded with a signed date and the signed document. Electronic signature capture is deferred. |
| Subscriptions and reporting | Subscription activation, renewal and termination; forecasts of new annual recurring revenue (ARR) and one-time revenue; and the Today dashboard. |
| Connected email | Logging, sending from records and templates. Provider eligibility and several implementation and privacy decisions remain open (DR-31, DR-47). |
| Administration and migration | User access, permissions, settings, HubSpot import, data checks and the switch to ConnectIQ. |
Deferred: deployment handover screens, calendar sync, a customer portal, global search, activity analytics, and team or territory quotas. Finance and invoicing have no assigned delivery phase (DR-37). Automated email sequences need a separate scope decision, based on how much the team uses them today (DR-31).
Email in the CRM — argued, and settled the expensive way. This entry said reps would feel the loss immediately, and that if a compose link were unacceptable, inbox sync becomes a milestone of its own and phase 1 gets longer. DR-31 answered exactly that. Email logging is in scope as its own milestone; its size depends on which HubSpot tier the team actually uses today, which is the open half of that entry. Privacy is settled — managers and admins see all, reps see their own.
Subscription units forecast. Genuinely useful, but it is fed by deployment data this release does not own. Building it against quotes alone produces a number that quietly disagrees with operations.
Leads. Cheap to add, and every CRM has them. Left out because a separate pre-qualification object with its own conversion flow earns its place only once someone is actually working an inbound queue — and the prototype's Lead concept is a residue in tag configuration with no screen and no workflow.
04 · How a sale works
| Step | What happens |
|---|---|
| 1 · Create an opportunity | Record the customer, business line, products and expected rollout. |
| 2 · Plan quantities and timing | Each line states whether its quantity applies per location or to the whole deal (DR-62). Rollout periods describe when locations are expected to start. A period can override individual lines (DR-63). |
| 3 · Prepare and approve a quote | Resolve pricing, review the discount and margin, and keep the version sent to the customer. Reps submit and managers or admins approve (DR-04). Every version stays viewable (DR-13). |
| 4 · Record the signed agreement | Attach the signed document. ConnectIQ then creates the related subscriptions, marks the opportunity won and makes the company a client. These updates can show as being created for a moment (DR-02, DR-11). |
| 5 · Activate and manage subscriptions | Signing creates the subscription. It does not start billing. The forecast month and the real billing start are separate dates (DR-42). Renewals and terminations work against each subscription item (DR-05). |
05 · Delivery order
This table is the build sequence, not verified completion status. Use each milestone's current Build Plan for progress, acceptance evidence and remaining work. The names below describe the outcome; the tracker tabs use shorter names.
| Milestone | Outcome |
|---|---|
| M1 — Foundation | Approved users can sign in with the right access. The application and background-processing foundations are in place. |
| M2 — Customer records | The team can manage companies, contacts, brands and locations. |
| M3 — Sales pipeline | Reps can manage opportunities, activities and rollout plans. |
| M4 — Products and pricing | Products, prices, costs and exchange rates support quoting. |
| M5 — Quotes | Reps can prepare, submit, revise and record signed quotes. |
| M6 — Subscriptions and reporting | The team can manage subscriptions and use forecasts and dashboards. |
| M7 — Connected email | Reps can connect a supported mailbox and work with customer correspondence. |
| M8 — Migration and launch | Checked data is loaded, the team completes the parallel run, and HubSpot becomes an archive. |
M4 can run in parallel with M3. Migration preparation should start once M2 lands, so data problems are found early. Final migration and launch follow the required feature and acceptance work.
The decision history records a target of the end of October 2026 for M7, with M8 after it. That target depended on a checkpoint at the end of M3 (DR-30). This page holds no reconciled current forecast, so do not present a launch date as confirmed.
06 · Open questions and actions
Decision status lives on the cards in the register, and the register is the source of truth. If this table and a card disagree, the card wins. The recommendations on the cards are proposals until an owner accepts them.
| When needed | Question or action | Recorded owner | Card |
|---|---|---|---|
| At M5 scoping | Confirm how operations is told after a quote is signed, while the handover screens stay deferred. | Sky + operations | DR-11 |
| Before M6 | Confirm how new and migrated subscriptions link to quote lines, locations and future deployed objects. | Sky | DR-10 |
| Before M6 ships | Agree the reasons a user can select when ending a subscription. | Sky + finance | DR-05 |
| Before email is sized | Confirm the team's mail provider and how much it uses automated sequences. | Sky + sales | DR-31 |
| Before the launch schedule is committed | Confirm whether working connected email is a mandatory launch condition. Check the provider's approval requirements and sending limits. | Sky | DR-47 |
| Before M7 | Choose polling or push, attachment storage, message retention and how provider message ids are stored. Settle the data-protection question about customer correspondence. | Sky. The data-protection owner's input is needed. | DR-49–DR-53 |
Actions are not decisions. A closed decision can still leave work to do. Source the product costs. Confirm the peso currency codes, and name who maintains the exchange-rate table (DR-07). Reconcile the existing subscriptions (DR-34). State how Chris and Sebastian resolve a disagreement about launch (DR-29). Track each action in the owning Build Plan with an owner, a due date and completion evidence.
07 · Moving from HubSpot
The import brings across customer records, opportunities, historical quotes and existing subscriptions. Historical quotes arrive as their original PDF documents plus structured data for reporting (DR-27). Old email threads, notes and approval history do not migrate.
The migration is a temporary, repeatable import. It does not write changes back to HubSpot. A one-off Airtable customer export supports matching where needed. It does not create an ongoing Airtable integration (DR-25).
| Step | What happens |
|---|---|
| 1 · Prepare the data | Export, find duplicates and missing information, and agree how every source field is handled. |
| 2 · Rehearse and reconcile | Run repeatable imports, check counts and recurring revenue, and have reps inspect their own accounts. Keep the historical quote documents. Do not create duplicate subscriptions (DR-34). |
| 3 · Run both systems for two weeks | New work happens in ConnectIQ. HubSpot becomes read-only in week two, which is the week the team must show it can work without HubSpot (DR-29). |
| 4 · Approve the switch | Sebastian and Chris weigh the data checks and the rep feedback. The rule for when they disagree is still to be recorded (DR-29). Confirm the connected-email launch condition before scheduling this step (DR-47). |
| 5 · Archive HubSpot | Keep read-only access for six months, complete the required exports and write down the archive retention. Remove the temporary import code once migration is complete (DR-27). |
Before launch, rehearse restoring a backup. Check that administrators can see and recover failed background jobs and email deliveries. The step-by-step gates are in §17.
08 · Risks
| Risk | Likelihood | Mitigation |
|---|---|---|
| The quote wizard swallows the schedule. It is the largest, most detailed surface, and its requirements are the most negotiable. | High | Build the totals module first, standalone and tested. Freeze the wizard's step list before the first screen. Treat any new step as a scope change with a date attached. |
| Decisions get made by inference. Some decisions are still open, and a build session that hits one and picks the easier option produces a schema nobody agreed. | High | Every milestone prompt lists its blockers by ID and instructs the session to stop rather than choose. §20 is the register; each milestone’s implementation prompt is linked from the tracker. |
| Migration data quality. Duplicate companies, contacts with no company, stage names nobody recognises, and billing frequencies that were silently overwritten. | High | Profile the export in step 2, before mappings are written. Budget clean-up time in HubSpot, by a person who knows the accounts. |
| Sales resist the switch because something they relied on is missing — most likely email logging. | Medium | Partly bought off: DR-31 put email logging in scope as its own milestone rather than deferring it. The risk has moved rather than gone — it is now schedule pressure instead of a missing feature, and the date risk below absorbs it. Still show M3 to reps early and list what is deferred in writing. Tell them the email timeline starts empty — DR-27 does not migrate history. |
| Scope creep from the prototype. It already does things this release excludes, so "it used to work" becomes an argument. | Medium | The deferred list in §03 and the corrections in §20 are the answer, and they live in this document rather than in someone's memory. |
| The target dates and the milestone sequence do not reconcile. 11 September is scheduled and 21 August is being pushed for, while M1 to M8 are unstarted and DR-29 fixes a two-week parallel run before cutover is declared. | High | From DR-30. 21 August needs the parallel run to start around 7 August; 11 September needs it around 28 August, leaving roughly four working weeks for eight milestones across two developers. Decided: the plan does not change and the dates are targets, not commitments. The mitigation is a checkpoint rather than a cut — re-forecast at the end of M2, around 14 August, which is the first real velocity signal this project has. Moving a date then is cheap; moving it in week four, after a parallel run has been scheduled and people told, is not. |
| Cost data has no known source. Margin depends on it, M4 builds the table that holds it, and nobody has named the system it currently lives in. | High | Left over from DR-45, which settled the shape but not the sourcing. M4 is unglamorous and entirely blocking for M5, so an unfound input stalls the critical path rather than a corner of it. Find it before M4 starts. Like DR-34's subscription back-fill, assembling it is not a developer's job — it needs a named owner and a date, and historical dated cost may simply not be recoverable. |
| No operational data in ConnectIQ. With the Airtable sync cut, a rep who needs to know what is installed at a site opens Airtable — for the whole of phase 1. Most likely to bite mid-quote, on a renewal or an expansion into a site that already has kit. | Medium | New, and the direct cost of DR-18. Watch for it in M3 and M5 specifically. If a screen cannot be used without live deployment data, the pipeline returns as its own milestone — not folded into the milestone that found it. Parked with its trigger on the phase 2 page. |
09 · Which system owns what
This section is about the boundary between ConnectIQ and the systems it is replacing. The boundary inside ConnectIQ — how domains are separated, which prefix owns which table, and how one domain is allowed to reach another — is defined by the architecture and is binding there, not here.
Each entity has exactly one owner. Where an entity exists on both sides, one system owns the record and the other holds a read copy — never a shared write surface. A field-level split of ownership is explicitly rejected: it is the arrangement that makes every future bug a forensic exercise.
| System | Holds today | After the release | Why |
|---|---|---|---|
| HubSpot | Companies, contacts, deals, pipeline, email history, notes | Retired | Its domain is exactly what ConnectIQ CRM replaces. Clean boundary, single cutover, no operational dependency on it. |
| Airtable | Jobs, inventory, locations, deployment state, support | Unchanged — remains system of record | Operations run on it daily. Replacing it is a later phase and must not be coupled to the CRM release. |
| Lovable app | Prototype CRM — quotes, forecast, deployment plan | Specification input | Its behaviour is the most complete description of what the CRM must do. Extracted into the two documents described in §21. The code is not the build. |
| ConnectIQ | — | Accounts + commerce system of record | New. Owns everything HubSpot owned, plus quoting, subscriptions and forecast. |
ConnectIQ becomes the system of record for accounts and commerce — including locations. It reads from HubSpot for as long as the parallel run lasts, and writes nothing back to HubSpot. It is not connected to Airtable at all. Airtable keeps running operations on the other side of a boundary this release does not cross. Connected email (M7) is separate: it reads and sends through each rep's own mailbox, and it writes to neither HubSpot nor Airtable.
The release writes nothing back to HubSpot or Airtable. Every integration with those two systems is a read. That single constraint removes two-way conflict resolution, write-back retries, loop detection and reconciliation from the first release entirely — and they are, reliably, where integration projects die.
It used to show locations arriving from Airtable on a scheduled read sync. DR-01 settled locations as cl_ master data owned by ConnectIQ — the object model and the architecture both put them there, and the discovery record found sites are created inside the company record in practice. With locations gone, the sync had nothing left to carry that phase 1 needs, so DR-18 collapsed with it and the whole Airtable pipeline left the release.
The simplification is larger than it looks. Phase 1 now has one data migration rather than two, and the one it has is finite code that gets deleted. There is no long-lived pipeline to operate, no staleness to display, no reconciliation to schedule. The accepted cost is in §08 — for the whole of phase 1, operational data is only in Airtable.
Domain ownership inside ConnectIQ follows the tiebreak rule in the architecture: the domain that performs the write owns the table; if two domains write, the concept is two tables. The eleven domain prefixes, the cross-domain interaction matrix and the identifier conventions all live there. §14 applies that rule to every entity the extraction found.
| Entity | Owner after the release | Copy lives where | Notes |
|---|---|---|---|
| customers / companies | ConnectIQ | Read copy in Airtable stays as-is | Migrated from HubSpot. Airtable's customer rows are matched to it by the mapping table, not merged. See DR-25. |
| brands | ConnectIQ | — | A first-class entity, not a text column on company. DR-14. |
| contacts | ConnectIQ | — | Migrated from HubSpot. Deduplicated on email at import. Which domain owns them is DR-06. |
| locations | ConnectIQ | Airtable's site rows stay as-is, unlinked | Was "Airtable, read copy in ConnectIQ". DR-01 — decided: cl_ master data, owned, created and edited inside the company record. Airtable's copy is reconciled in phase 2, not before. |
| leads | — | — | Not an entity. A lead is a company or contact that has not reached opportunity status. |
| opportunities | ConnectIQ | — | Migrated from HubSpot deals with stage mapping. |
| quotes / line items | ConnectIQ | — | New in ConnectIQ. Historical HubSpot quotes migrate as original PDFs plus structured data. Email threads, notes and approval history do not — DR-27. |
| products / price books | ConnectIQ | — | Seeded from the current catalogue, wherever that lives today. |
| subscriptions | ConnectIQ | — | Created on quote signature. Existing live subscriptions are back-filled at migration — DR-34. |
| jobs / inventory / objects | Airtable | — | Deployment domain. Untouched by this release, and not visible from ConnectIQ — the display-only read copy went with DR-18. |
| tickets / support | Airtable | — | Out of scope entirely. |
This plan assumed locations were owned by Airtable but needed by the quote builder, which is in ConnectIQ — so reps would select from synced locations and request new ones. That could not be built. §15 requires a quote line to carry a location, and the architecture forbids joining a mirror into an operational write path; the two positions could not both hold, whatever anyone decided about reps.
Precedence settled it — the object model makes locations an Accounts-domain table with dated ownership periods, the architecture lists them under cl_ master data and names them a cutover entity rather than a mirror, and the discovery record found the register is actually populated inside the company record. ConnectIQ owns locations. There is no request-a-location flow, because there is no second writer to request from.
10 · The HubSpot import
Phase 1 has exactly one integration and it is temporary. There is no Airtable connection of any kind — DR-18 collapsed when DR-01 moved locations into ConnectIQ, and what remained did not earn a long-lived pipeline. The parked pipeline, with the trigger that would bring it back, is on the phase 2 page.
HubSpot is leaving, so the connection is deliberately crude and finite. A CSV/API export runs into the import module, which maps companies, contacts and deals, deduplicates against anything already present, and records every source id. During the parallel run the same import can be re-run to top up new activity; the moment cutover completes, the whole pipeline is deleted rather than maintained.
| Aspect | Decision |
|---|---|
| Mechanism | Batch import job — upload, map, dry-run, commit. Row-level status and downloadable error file. |
| Cadence | Once for the bulk load, re-runnable on demand during the parallel run. No scheduled job. |
| Idempotency | The HubSpot id is recorded in int_external_system_mappings — never on the entity row. Re-import updates rather than duplicates, keyed on external id and never on name or email. |
| Lifetime | Deleted after cutover. It is migration code and it should not outlive the migration. |
No pull, no mirror tables, no schedule, no staleness model. The boundary in §09 is enforced by there being no wire across it.
| What is not built | Why not |
|---|---|
| Scheduled pull, 15-minute incremental and nightly full sweep | Its main payload was locations. Those are ConnectIQ's now — DR-01. |
int_*_mirror read tables with synced_at and absent_since | Nothing to land. Soft deletes existed to stop a hiccup erasing a customer's sites; there are no synced sites. |
| Sync run logging, two-failure alerting, stale-data banners | An operational surface with no operational payload. All of it is maintenance the release does not have to earn. |
| The display-only jobs and installed-objects view | Not required for a sales team can run a full week without opening HubSpot. DR-18 — the goal names HubSpot, not Airtable. |
For the whole of phase 1, a rep who wants to know what is installed at a site opens Airtable. That is the trade: one system to build and operate instead of two, against a gap the sales team will feel on the days they need deployment context mid-quote. Watch for it during M3 and M5. If a screen genuinely cannot be used without live operational data, the pipeline comes back as its own milestone and phase 1 gets longer — it does not get bolted onto whichever milestone noticed.
Every record that came from somewhere else carries its origin in a single table rather than a scatter of id columns. That table is int_external_system_mappings, and it is specified in the architecture along with the rest of the int_ domain. Phase 1 uses one row type in it — HubSpot. The table is still built properly, because phase 2 fills it with Airtable and a mapping table retrofitted after a migration cannot reconstruct what it missed.
Origin ids on the entity table look cheaper until the second source arrives, or the same record is matched from two systems, or a merge has to be undone. One table answers "where did this come from and when did we last see it" for every entity uniformly, and it is the natural place to hang the import audit that support questions will need. Keeping external ids out of the domain tables entirely is also what keeps the migration reversible.
11 · Security
Permissions are a data-layer concern. The client hides what a user cannot do as a courtesy; the database refuses it as the actual control.
| Control | Rule |
|---|---|
| Row-level security | Enabled on every table in the public schema. A table without a policy is a bug, caught in CI. |
| Roles | Three — admin, sales manager, user — stored in plat_user_roles and checked through a SECURITY DEFINER helper. Never stored on the profile, to prevent privilege escalation by profile update. Single role by precedence: assigning one strips the others. |
| Permissions | Per-role flags in plat_role_permissions, resolved into a single can(permission) check. Editing a flag takes effect on next resolve, not on next deploy. |
| No implicit defaults | A role–permission pair is granted or it is not. Admins do not fall back to allowed on an unset flag — that is prototype behaviour and it is a defect. DR-24. |
| Access gate | New sign-ins land in pending and see an awaiting-approval screen until an admin approves. Revocation is immediate. |
| Service credentials | The privileged database client is importable only from server code. A lint rule prevents it appearing in a client bundle. |
| Public endpoints | Anything unauthenticated — webhooks, unsubscribe, cron targets, the quote approve/reject links — verifies a signature or signed token before any write, and re-checks that the person still holds the role. |
| Input validation | Every server input parsed by a schema. Identifiers regex-constrained, strings length-bounded. |
| Integration secrets | Email and HubSpot export credentials in the platform secret store, rotated on staff change, never in the repo. |
| PII | Contact data is customer PII. Exports are permission-gated and logged; the error CSV from an import excludes full rows by default. |
| No development bypass | The prototype has a mode granting every permission unconditionally, auto-signing-in a super-admin and self-promoting it, plus demo seeding that runs on approval. It must not exist in any environment. DR-24. |
12 · Operations
Heavy aggregates — dashboard KPIs, forecast roll-ups — are precomputed nightly into snapshot tables and topped up with live counts, rather than computed per page load. Routes fetch through the loader and suspend, never through an effect. These are cheap decisions now and expensive retrofits later.
13 · How rollout affects revenue
This is the most important concept in the application and the one most likely to be mis-modelled if it is treated as a set of numbers. It has two parts, and they are not the same shape.
What a single location receives. Not a quote and not a catalogue entry: the commercial shape of the deal, expressed once per site, before any document exists.
A list of ordered opportunity lines, each with a category, an optional catalogue product, free-text detail, a quantity and its basis (per location, or for the whole deal — DR-62), a unit price and a billing basis. They hang off the opportunity directly — DR-08 removed the Site Package wrapper, because it was only ever justified by reuse and reuse never happened.
How many locations go live in a given month. An expected start date plus a month-by-month count of new sites.
Any number per opportunity, non-overlapping. Presented as an editable table and a chart: month, new locations, cumulative locations, revenue added, cumulative revenue.
A period can override individual lines — product, price, quantity, or any combination — without changing the opportunity's default lines (DR-63).
Each line's quantity, multiplied by the Rollout Periods' location counts for per-location lines and counted once for whole-deal lines, with any period overrides applied = the opportunity's revenue, licence count, store count, and month-by-month forecast contribution.
The multiplication is untouched by DR-08. What was removed is an identity wrapped around one half of it, not the half itself — the two shapes still have to stay separate, and licence count still needs both.
Every forecast in the application is a different projection of this one product. If the physical design gets this relationship wrong, nothing downstream is recoverable.
Category carries fixed commercial meaning, shown in the interface and enforced in the calculation:
| Category | Billing basis | Contributes |
|---|---|---|
| Hardware | One-time | One-time revenue |
| Software | Monthly | Recurring revenue and licences |
| Services | One-time | One-time revenue |
Licence count is the software quantity summed across the lines. A per_location software line contributes its quantity × the locations it covers. A deal_total software line contributes its quantity once. No other category contributes licences (DR-62). That still needs the two halves of the model kept separate, which is why collapsing them into one table is DR-08.
The earlier rule, units per location × total locations, counted 55 displays for a deal that bought eleven. Two quotes for the same product disagreed about whether the number meant units per site or units across the deal. Every opportunity line now declares its basis, so the forecast never has to guess (DR-62). For example, two displays per location across ten locations is twenty. Two displays for the whole deal is two.
Definitions rather than data. Nothing about a forecast is stored — every lens is computed from opportunities, packages, rollout periods, quotes and subscriptions.
| Class | Definition |
|---|---|
| Pipeline | Open opportunities flagged for forecast inclusion |
| Won | Opportunities in a stage that closes as won |
| Committed | Signed quotes, regardless of the parent opportunity's inclusion flag, stage, or existence |
Lost opportunities are excluded from every forecast, always. And signed quotes with no parent opportunity still contribute committed revenue, grouped by company or brand. The system deliberately counts orphans, and migration will produce them. Do not "fix" this.
The conceptual model asks for this to be validated against ten real historical deals before anything is built on top of it, and for confirmation that a package really is one-per-opportunity rather than something reused across deals. That validation has not happened. It is the cheapest insurance available on the most consequential relationship in the system.
14 · Entity map
The conceptual model's entity list, put through the architecture's tiebreak rule — the domain that performs the write owns the table; if two domains write, the concept is two tables — and placed against the sequence in the tracker.
Marks: ? the assignment is open, see §20, do not pick one silently. ▢ the table exists in phase 1 but nothing reads it — forward compatibility only.
cl_ Customer & Location| Entity | Table | M | Notes |
|---|---|---|---|
| Company | cl_companies | M2 | Self-referencing parent, no cycles. Status derived, never entered — DR-16. Duplicate pointer, never merged — DR-22. google_place_id, address_verified, address_override_note — DR-55. |
| Brand | cl_brands | M2 | First-class — DR-14, confirmed. A company operates several; a brand spans several companies. Selected from a list, never typed. Duplicate flagging applies as it does to companies. |
| Company–Brand | cl_company_brands | M2 | Many-to-many both ways — both directions confirmed real, DR-14. |
Contact ? | cl_contacts | M2 | DR-06. Last-contact date derived from activities. No lifecycle stage — DR-23. |
Location ? | cl_locations | M2 | DR-01. No operational attributes — type, opening date, status and operator belong to phase 2. google_place_id, address_verified, address_override_note, google_maps_url, latitude, longitude — DR-55. The last three are location-only. |
| Location ownership | cl_location_ownerships | — | Dated periods, one open row. Phase 2 unless migration finds a site that changed hands. |
| Customer Billing Entity | cl_company_billing_entities | M4 | The legal entity actually invoiced. A group may be invoiced through several. |
com_ CRM / Commerce| Entity | Table | M | Notes |
|---|---|---|---|
| Pipeline Stage | com_pipeline_stages | M3 | Shared — DR-21. Declared won/lost flags — DR-20. |
| Opportunity | com_opportunities | M3 | Value, ARR, TCV, stores and licences all derived. Only probability is directly editable. |
| Opportunity ↔ Contact | com_opportunity_contacts | M3 | Holds cl_contact_id. Read through a service client or view, never a nested select. |
| Stage Change | com_stage_changes | M3 | The only sales-side history the prototype keeps, and worth keeping. |
| Loss Reason | com_loss_reasons | M3 | Shared and growing. A new reason is immediately available to everyone. |
com_site_packages | — | Not built. DR-08 — never used for reuse, so the wrapper buys nothing. | |
| Opportunity Line | com_opportunity_lines | M3 | Category with fixed commercial meaning, hanging off the opportunity. See §13. |
| Rollout Period | com_opportunity_rollout_periods | M3 | Non-overlapping within an opportunity. Shape still owes validation against ten real deals — DR-08. |
| Activity | com_activities | M3 | DR-15. One entity with a type, not three. |
| Activity link | com_activity_links | M3 | DR-15. Four-way many-to-many, straddling two domains. |
| Email message | com_email_messages | M7 | DR-48, decided with DR-15 — same domain as activities. owner_user_id denormalised so RLS never joins across a prefix to decide visibility. Body sanitised on write. DR-53 covers the provider id. |
| Email link | com_email_message_links | M7 | The second four-way link table. Not collapsed into a polymorphic pair — DR-15 is explicit that this is binding whichever domain wins. |
| Email attachment | com_email_attachments | M7 | Metadata only — name, type, size. No file storage and no download endpoint. DR-50. |
| Email template | com_email_templates | M7 | Private or shared. A fixed, documented placeholder set resolved server-side — not arbitrary field paths, which would be an injection surface. |
| Note | com_notes | M2 | Attached to exactly one record of any type. |
| Tag | com_tags | M2 | An entity with identity — renaming one reaches every record using it. |
| Product | com_products | M4 | Categories in the database with a settings screen, not a front-end file. No cost column — see below. |
| Product Cost | com_product_costs | M4 | Supplier, volume floor, effective dates, cost, currency. Volume bands on the quote total, not the line — DR-45. |
| Supplier | com_suppliers | M4 | Flat lookup only. Purchasing stays phase 2 — DR-45. |
| FX rate | com_fx_rates | M4 | Dated. Most recent rate on or before the transaction date; no rate means refuse, never guess. DR-07 · DR-45. |
| Product Price | com_product_prices | M4 | At most one per product per currency. |
| Customer Price | com_customer_prices | M4 | Company × product. May carry a default discount. |
| Tax Rate | com_vat_rates | M4 | Inclusive or exclusive per quote. |
| Payment Term | com_payment_terms | M4 | |
| Seller Entity | com_seller_entities | M4 | The entity we issue from. Distinct from the customer's billing entity. |
| Document Number Series | com_document_number_series | M4 | Central allocator. Unique, never reused — including for duplicates and renewals. |
| Quote | com_quotes | M5 | Identity only — number, company, seller entity, currency, signed_version_id, changes_quote_id. DR-13. |
| Quote Version | com_quote_versions | M5 | Everything revisable. Status and approval state are two axes and live here. Never collapse them. DR-13. |
| Quote Line | com_quote_lines | M5 | Hangs off the version. Billing frequency persisted for real — DR-19. Carries the four-field cost_snapshot — DR-45. |
| Line Location Assignment | com_quote_line_locations ▢ | M5 | Hangs off the versioned line. Written in phase 1, read by nothing until S3. Assigned quantity never exceeds line quantity. |
| Quote Location Plan | com_quote_location_plans ▢ | M5 | A location appears at most once per quote's plan. |
| Approval Request | com_approval_requests | M5 | Attaches to a version — that is the point of DR-13. Any number over a quote's life. |
| Approval Decision | com_approval_decisions | M5 | At most one per request. The pattern other audit trails should follow. |
Approval Rule ? | com_approval_rules | M5 | DR-04. |
sub_ SubscriptionThe architecture carries a written decision record putting subscriptions in their own domain, settled by the device-swap scenario: fold subscription into inventory and it dies with the unit; keep it separate and a swap is a non-event. Grouping them under commerce implies a build that violates two binding rules — DR-09.
| Entity | Table | M | Notes |
|---|---|---|---|
sub_contracts | — | Not built. DR-03 decided: the signed quote is the contract. Adding one later is a table and a backfill. | |
| Subscription | sub_subscriptions | M6 | Created only by consuming QuoteSigned — DR-02 — with one exception: migration (DR-34), so com_quote_line_id is nullable. billing_start_date null until activation (DR-42); term_end_date is a term boundary, not a billing stop (DR-05). |
| Activation group | sub_activation_groups | M6 | Mode, resolved start date, stored com_root_quote_id. Spans quotes — a change order joins the original group. DR-42 · DR-43. |
| Subscription Termination | sub_subscription_terminations | M6 | The only thing that stops billing. Reason vocabulary still owed — DR-05. |
| Renewal | sub_renewals | M6 | One or more subscriptions → exactly one quote, all the same company. Grouping is presentational — each item restarts its own term. DR-05. |
| Subscription ↔ Unit | sub_subscription_units | S1 | Dated periods. What makes a device swap invisible to billing. |
plat_ Platform · int_ Integration · rpt_ Reporting| Entity | Table | M | Notes |
|---|---|---|---|
| Domain events | plat_domain_events | M1 | The outbox. Binding, and previously absent from this plan — DR-17. |
| Member | plat_profiles | M1 | Awaiting approval → approved → revoked, revocation reversible. |
| Role | plat_user_roles | M1 | Single role by precedence. Never on the profile row. |
| Role Permission | plat_role_permissions | M1 | Granted or not. No implicit defaults — DR-24. |
Forecast Scenario ? | plat_forecast_scenarios | M6 | The only stored thing about a forecast. Could equally be com_; low stakes. |
| External mapping | int_external_system_mappings | M1 | No external id ever lives in a domain table. |
| Import log | int_import_logs | M8 | Row counts and errors per HubSpot import run. Was int_sync_logs at M2 — DR-18. |
int_*_mirror | — | Not built. Removed by DR-01 and DR-18 — see the phase 2 parking lot. | |
| Mail account | plat_mail_accounts | M7 | One connected mailbox per user. The refresh token is never a column here — credential_ref points at an encrypted secret, and no client role selects it. capture_from enforces DR-27. |
| Never-log entry | plat_mail_never_log | M7 | Not plat_suppressed_emails, which stops us sending to a dead address. This stops us recording a message. Never merged, never unioned, neither seeds the other. |
| Mail send queue / log | plat_mail_send_queue · plat_mail_send_log | M7 | Mirrors the plat_email_queue shape. Distinct from the Resend send log and never reported on together — different sender, credential, quota owner and retention obligation. |
| Mail sync run | plat_mail_sync_runs | M7 | Without it, it hasn't synced and there was nothing to sync are indistinguishable and the staleness warning cannot be built honestly. |
| Raw mail payload | int_mail_messages_raw | M7 | The landing table, written only through the int_ service client. Rule 10 does not bite here — a provider id in an integration table is its purpose. It does bite on com_email_messages — DR-53. Purged on a short window after processing. |
| Webhook events | int_webhook_events | M1 | Inbound-external. Never merged with plat_domain_events. Phase 1 use is Resend delivery callbacks, not Airtable. |
| Import staging | int_import_* | M8 | Duplicates resolved here, before records land. |
Every screen that shows data across a prefix boundary needs an rpt_ view, created WITH (security_invoker = true) — without it a view runs with the creator's rights and bypasses RLS on everything underneath. The quote register alone has company, brand and contact columns, so it crosses two domains before it renders a row. Budget for rpt_opportunity_summary, rpt_quote_register, rpt_company_360, rpt_subscription_estate, rpt_forecast_periods and rpt_today_feed.
15 · Data model
Grouped by what they do. Every table ships with a domain prefix assigned using the tiebreak rule in the architecture; §14 is the full mapping, entity by entity. The deployment tables are shown for orientation but are not created in this release.
cl_ accounts ownedcompanies — hierarchy, derived status, duplicate pointerbrands, company_brands DR-14 closedcontacts DR-06 closedlocations DR-01 closedcompany_billing_entitiescom_ pipeline ownedpipeline_stages — shared, declared won/lostopportunities, opportunity_contactsstage_changes, loss_reasonsopportunity_lines, opportunity_rollout_periods DR-08 · no packagecom_ activity ownedactivities — one entity, typed DR-15 closedactivity_links — four-way DR-15 closednotes, tagsemail_messages, email_message_links DR-48 closedemail_attachments, email_templatescom_ commerce ownedproducts, product_prices, customer_pricesproduct_costs, suppliers DR-45fx_rates — dated DR-07 closedquotes — identity + signed_version_id DR-13quote_versions — status, approval, totals DR-13quote_lines — hang off the versionchanges_quote_id — change orders DR-43quote_line_locations — activation_mode DR-42quote_location_plans ▢approval_requests, approval_decisions, approval_rules DR-04seller_entities, vat_rates, payment_terms, document_number_seriessub_ subscription own domainsubscriptions DR-09billing_start_date, term_end_date DR-42 · DR-05activation_groups — mode, date, root quote DR-43subscription_terminations DR-05renewalscontracts table DR-03 closedplat_ platform owneddomain_events — the outbox DR-17profiles, user_roles, role_permissionsforecast_scenariosemail_queue, email_send_log, suppressed_emailsint_ integrationexternal_system_mappingsimport_logs, webhook_events*_mirror tables DR-18 closedinv_ — objects, units, placementsops_ — jobs, tasks, schedulesplan_ — handover, BOM, readinesssup_, mon_ — tickets, device healthNothing episodic owns anything persistent. Even though this release does not build the deployment domain, it must not create a schema that the deployment domain cannot attach to later. In practice that means one rule now: a quote line that represents a deployable system is the thing a future object is born from, so quote lines carry a location from the start, even while nothing downstream reads it yet.
Drop the legacy deals table. The prototype carried both deals and an opportunity model. One of them, and it is opportunities.
Move front-end constants into the database. Product categories, and anything else a non-developer will eventually want to edit, belong in a table with a settings screen.
Keep status and approval state as two columns. Nearly every gate in the business tests the approval state, not the status. Collapsing them breaks the system.
Brands are rows, not a string. The forecast groups by brand, and free text will not survive migration or reconciliation.
External ids leave the entity tables entirely. They live only in the mapping table, which is what keeps the migration reversible.
16 · Access
The prototype accumulated roughly twenty-one permission flags. Most were genuinely needed; a few encode one person's preference rather than a policy. Phase 1 keeps the mechanism established in ADR-02 and trims the list to what the three roles cannot express on their own.
| Capability | User | Sales manager | Admin | Flag needed? |
|---|---|---|---|---|
| See own records | Yes | Yes | Yes | No — role covers it |
| See everyone's records | Flag | Yes | Yes | Yes — some reps need cross-visibility |
| Edit others' records | No | Flag | Yes | Yes |
| Create and edit quotes | Flag | Yes | Yes | Yes — not every user quotes |
| Approve quotes | No | Flag | Yes | Yes — the core separation of duty |
| Delete records | No | Flag | Yes | Yes |
| Edit pricing and price books | No | Flag | Yes | Yes |
| Open forecast | Flag | Yes | Yes | Yes — revenue visibility is sensitive |
See others' logged email view_all_email | No | Yes | Yes | Yes — DR-31. Reps see their own correspondence, managers and admins see all. Pair it with a never-log list so personal mail never enters the system in the first place. The twelfth permission: grant it in M1, use it in M7 — both are phase 1, so it costs a row rather than a migration. Never resolve email visibility through view_all_records: that is the row above, DR-26 expects it to be on for most people, and reusing it would show nearly everyone nearly every mailbox. |
| See cost and margin | Yes | Yes | Yes | No — DR-44: reps see cost today and keep doing so, so the roles agree and a flag would express nothing. A role-based split is a later phase — render margin through one gate-able component so adding it is one check, not forty screens. |
| Dashboard across whole company | Flag | Yes | Yes | Yes |
| Open settings | No | No | Yes | No — admin only |
| Manage users and roles | No | No | Yes | No — admin only |
| Run imports | No | No | Yes | No — admin only |
Visibility is a first-class permission, not a consequence of ownership. Whether someone can see a forecast lens is independent of what they own. The prototype had four separate per-tab permissions on one page — the business treats revenue visibility as sensitive and wants it slice-able. Phase 1 ships two tabs, so one open_forecast flag is enough. Keep the mechanism able to express per-tab gating for when the deferred tabs arrive — do not create four flags to guard two tabs.
People without company-wide visibility are silently scoped to their own records. On the daily overview the scope picker does not render at all, rather than rendering disabled. That is the right behaviour and worth reproducing.
17 · Migration and cutover detail
| Step | What happens | Gate before proceeding |
|---|---|---|
| 1 · Export | Full HubSpot export of companies, contacts, open deals, owners, and the current pipeline definition. | Export is complete and dated. Record the export timestamp — it defines the top-up window. |
| 2 · Profile | Analyse the export before writing any mapping: duplicate rate, missing domains, orphan contacts, stage names in actual use versus configured, and recurring lines whose real billing frequency is not monthly. | A written list of the data problems, with a decision on each: fix in HubSpot, fix in transit, or accept. |
| 3 · Map | Field mappings per entity, owner translation to ConnectIQ users, stage translation to the new pipeline, brand column split into rows. | Every source field is either mapped or explicitly dropped. No silent drops. |
| 4 · Dry run | Import into production, while it is still empty and unused. Full row-level error report. Fix, wipe, repeat until the error rate is understood rather than merely small. Back up before every attempt and keep outbound email suppressed. | At least two clean runs, and a rep confirming their own accounts look right. |
| 5 · Load | The run that is kept, followed by the deduplication review pass and the subscription back-fill. Re-enable outbound email. | Row counts reconcile against the export. Spot-check by a rep, not by a developer. This gate is the point of no return — production cannot be wiped after it. |
| 6 · Parallel | Both systems live and monitored — this is the probation period. New work happens in ConnectIQ; HubSpot is used for reference only, and is the fallback if something is badly wrong. Training happens here. | Fixed duration, decided in advance — see DR-29. |
| 7 · Cutover | HubSpot to read-only, top-up import for anything created during the parallel run, announcement, switch-off date set. | A named person declares it. Not a consensus, not a drift. |
| 8 · Archive | Final export retained per the retention policy. Import pipeline deleted from the codebase. | Archive location and retention period written down somewhere findable. |
The common way this goes wrong is not a failed import — it is an open-ended parallel run. Both systems stay half-used, data diverges, and a year later nobody can say which one is right. The defence is a fixed parallel window, a named decision-maker, and HubSpot going read-only on a date that is set before the parallel run begins.
| Hazard | What to do |
|---|---|
| Billing frequency is unrecoverable | Every recurring line in prototype data says Monthly regardless of what was agreed. Step 2 must hunt for non-monthly arrangements by hand; they cannot be reconstructed from the data. DR-19. |
| Brands are a comma-separated column | Splitting into rows, with fuzzy matching on near-identical names, is unscoped import work. DR-14. |
| Contact lifecycle stage is dropped | Decide where it lands — dropped entirely, or mapped into something — before mapping, not during. DR-23. |
| Orphan quotes are not errors | The forecast deliberately counts signed quotes with no opportunity. Migration will produce them. Do not clean them up. |
| The legacy deals table | Migrate opportunities. Do not recreate deals. |
| Handover timestamp | The prototype may set the handover status without its timestamp. Any migrated quote in that state has an inconsistent pair. |
18–20 · Decisions
Architecture holds the records of how the system is built. Application is the decision register: what is still open, and every entry already settled. Both are closed until opened, and a link to any record inside opens its section.
Two have since been superseded by the architecture, and one by the strategy page. They are kept rather than deleted — the record of what was decided, and why it changed, is the point of an ADR. ADR-07 onwards live on the strategy page, because they are decisions about method rather than about this release. ADR-01, ADR-02, ADR-05 and ADR-06 are strategy-based decisions and are indexed in Strategy History; ADR-03 and ADR-04 are plan-based decisions that remain authoritative here until their migration into Build Plans.
Superseded by the architecture. The conclusion held — one deployable, separated internally — but the separation is now defined by eleven domain prefixes and a binding cross-domain interaction matrix, not by four bounded contexts talking through module interfaces.
Decision — one deployable application, internally partitioned. Contexts talk through defined interfaces, not by reaching into each other's tables.
Beat — separate services per domain.
Because — the boundary is about ownership clarity, not network topology. A two-developer team gets the whole benefit of the separation and none of the deploy, latency and distributed-consistency cost.
Cost — the boundary is a convention, so it needs enforcing. A cross-context import is a review failure, and ideally a lint failure.
Decision — every table carries policies; the client's can() check is presentation only.
Beat — application-layer authorisation with an unrestricted database role.
Because — the permission surface is wide. Enforcing that in application code means every new query is a chance to leak. Policies fail closed.
Cost — policies are harder to debug than an if statement, and every migration must remember them. CI checks for tables without policies.
Decision — ConnectIQ never writes to Airtable or HubSpot in this release.
Beat — two-way sync with Airtable so operations see quotes immediately.
Because — two-way sync requires conflict rules, loop suppression, and a reconciliation story for every field — a project in its own right, attached to a release that already has a migration and a cutover in it.
Cost — operations keep working from Airtable and get signed-quote information late or by hand until a later phase. This must be stated to them explicitly rather than discovered.
Superseded by the architecture. The principle stands and is now a binding rule there — external ids never live in domain tables. The table is int_external_system_mappings, and it covers Freshdesk and Xero as well as HubSpot and Airtable.
Decision — a single table maps (system, external id, entity type) to the ConnectIQ row.
Beat — per-source id columns on each entity table.
Because — it generalises to the second and third source without a migration, survives merges and un-merges, and gives support one place to answer provenance questions.
Cost — one join to answer "where did this come from". Acceptable — it is not a hot path.
Decision — numbered SQL files, applied in CI, RLS policies in the same file as the table they protect.
Beat — editing schema through the hosting console, as the prototype did.
Because — console edits make environments diverge silently and make rollback a matter of memory. The migration history is also the only honest changelog of the data model.
Cost — slower for small changes. Worth it from the first week, not from the first incident.
Superseded by ADR-07 in the strategy page, which runs four tiers including a persistent staging branch. Two things changed the trade: Supabase branching makes the environment reproducible rather than hand-tended, and a code-generating assistant produces whole features that have to be seen somewhere before production. The warning below has not stopped being true — a staging tier allowed to go stale becomes exactly the false confidence it describes.
Decision — development and production only, plus a temporary rehearsal environment for the migration.
Note · the rehearsal environment was later dropped too. The migration runs in production before anyone uses it — strategy page §03.
Beat — a standing dev/staging/prod trio.
Because — a staging environment that nobody keeps populated becomes a source of false confidence and a second thing to maintain. The genuine risk in this release is the migration, and that gets its own rehearsal environment with real exported data.
Cost — pre-release verification happens in development with seeded data. Revisit the moment more than a handful of people depend on the system.
These records concern delivery plans and remain authoritative here during the transition.
The agreed destination is Decision history inside each record’s owning Build Plan. A separate build will verify those plan relationships, move the records and update collection and review. Existing IDs, evidence and links will be preserved.
Ordered by whether anyone has to do something about it, not by kind. Everything in the first band is waiting on a person; everything below it is settled and kept for the record, dimmed so the eye skips it but readable when someone asks why.
Two kinds of entry sit in the first band. Conflicts are places where the five documents actively disagree — the object model, the architecture, this plan, the conceptual model and the discovery record. Assumptions are questions this plan answered with a default so it could be written; for those, the existing default is the recommendation, so confirming one takes a minute.
Repo precedence is model → architecture → plan → screens. Where precedence alone settles a row the recommendation says so — but it is still listed, because several of those rows mean rewriting a section of this document, and that is a decision someone should take knowingly rather than discover.
The series is global and the numbers never move. DR-38 to DR-40 continue it on the strategy page — questions about method rather than about this release, one still open: whether the system emails customers. DR-12, DR-35, DR-36, DR-37 and DR-41 continue it on the phase 2 page. Fifty-eight in total. Fifty here — ten awaiting an answer, five needing none, thirty-five closed. Five on phase 2, all open. Three on the strategy page, one of them still open. DR-47 to DR-54 arrived with M7, the connected-email milestone DR-31 called for — an eight-entry jump in one milestone, and the clearest evidence yet that DR-42 to DR-46 were all found in review rather than in the extraction, most of them falling out of answering the one before — the number of open questions is not guaranteed to only go down. DR-55 followed the same pattern once more, found while building out M2 rather than in the original extraction.
M3 and earlier are historical; Review is enforced starting with M4. connectiq-current PR #71 — M3, FAIL — is the first real Review and is accepted as the historical baseline, not a remediation target: it and every milestone before it are judged exactly as before, and their accounting may honestly stay unknown. A hidden data-review-boundary element below declares the split: data-governed-from="M4" names the first milestone Review enforces, and data-historical-through="DR-58 ADR-13" names the highest historical id in each family — every id at or below it may legitimately be undated; a governed milestone that depends on one, such as DR-07/43/44/45 below, must carry a real entry for that milestone instead of resting on the historical exemption. See docs/briefs/07-review-governance-boundary.md in connectiq-current.
DR-03 (no contract entity) and DR-06 (contacts live in cl_) both resolve questions the architecture records as open — and the architecture is binding while this document is not. Closing them here settles the build; it does not settle the rule. Until those two open items are struck at source, a future reader of the binding document will find a question that has an answer nobody wrote down.
Every open entry carries a recommendation in green, and where a real alternative exists, what choosing it would mean instead. A dashed box means the answer is a fact about the business rather than a design question — it lists what to ask and who to ask, because no amount of architecture settles it. Four of the seven have one.
Nothing here is resolved by reading it. A recommendation becomes a decision when a named person agrees to it and this document is edited — not when a build session assumes it.
Each entry states the conflict, then a recommended answer and what it rules out. Where no answer is obviously right — because it is a business fact rather than a design question — there is a list of questions instead. Answer those and the decision usually makes itself.
A recommendation is a proposal, not a resolution. It is written so that agreeing takes a minute and disagreeing takes an argument, which is the right way round.
Nothing did. There was no cancellation, no churn, no non-renewal — only an Expired status that nothing set. The conceptual model called this "the largest gap in the MVP and the one net-new decision the business must make".
The lifecycle is now settled, and it is not what the schema assumed. What remains open is narrower and cannot be designed: the reason vocabulary.
A subscription does not stop when its term ends. It moves to renewal due and keeps billing until it is actively terminated, renewed or closed. In practice a renewal is often signed after the original period has technically ended, and the service still has to be billed in the meantime unless the customer has explicitly stopped it.
That gives the prototype's orphan status a meaning at last. Expired was a state nothing set because nothing had defined it. It is term end reached, still billing, awaiting renewal or termination.
Renewal is per subscription item, never per quote. Each item keeps its own start date, term end and renewal cycle, because the real service periods differ — different locations, different hardware, different installation dates. A renewal quote may still group several items for customer convenience, but that grouping is presentational. The billing and renewal logic stays tied to the item.
Two groupings now exist and they are not the same one. Activation may be synchronised across a group (DR-42); renewal never is. Conflating them would make one slow site delay not just the start of billing but the end of every term in the contract.
Rename end_date to term_end_date. A field called end date invites WHERE end_date > now(), and that query silently drops revenue that is still being billed. The name is the cheapest available defence.
MRR is never filtered by term end. It is filtered by termination. A subscription past its term and awaiting renewal is contributing revenue and must appear in every estate figure, company record and dashboard.
Billing past term end continues at the last agreed price until a signed renewal changes it, and rolls indefinitely — there is no automatic stop. The auto-renew flag therefore governs whether a renewal is prompted, not whether billing continues.
Signing a renewal gives each item its own new term computed from its own dates. One renewal quote, many independent term restarts.
A renewal backdates to the original expiry date; a termination takes the date the notice arrived. Not arbitrary — one preserves continuity of service, the other says you owe up to the day you told us. But it forces two rules that a naive build gets wrong:
1 · A renewal's term starts at the previous term's end, not at signature. Sign six weeks late and the new term's billing_start_date is six weeks in the past. The obvious implementation starts it today and silently creates a gap in a service that never stopped.
2 · Renewals skip activation entirely. DR-42's activation exists because a new deployment waits on installation. A renewal has no installation — service is already running. Without this stated, a renewed subscription sits in awaiting activation forever waiting for an install that will never happen.
Two dates are tracked and both matter: when it expired, and when it was actually terminated. Whether the gap is collected or written off is a finance question and is deliberately deferred — it must not affect the operational or sales status.
Each term is its own subscription row. Extending in place would be simpler for MRR and would lose the term history, which is the thing a renewal conversation actually needs.
1 · Half-open intervals, or "no overlap by construction" is only a slogan. billing_start_date is inclusive, term_end_date is exclusive. The predecessor's end is the successor's start, the same date on both rows, and the boundary day belongs to exactly one of them. Make the end inclusive and every renewal double-counts a day; leave a day between them and every renewal drops one. This fits DR-42's first-of-the-month rule without adjustment.
2 · Carry a root id as well as a predecessor. renews_subscription_id gives lineage order; root_subscription_id gives the whole chain in one filter instead of a recursive walk. Same pattern as DR-43's com_root_quote_id — and "show me this service's history" is a screen, not a rare query.
3 · The renewal quote must say which subscription each line renews. A stored sub_subscription_id on the quote line, riding the QuoteSigned payload — stored, never joined. Without it the consumer cannot link the chain and every renewal creates an orphan root.
The status machine gains a state. Derived, never typed: no billing_start_date → awaiting activation · future one → scheduled · inside the term → active · term end passed with no successor and no termination → expired, still billing · term end passed with a successor → renewed, historical · termination effective → ended. Without the successor check, every renewed row reads as expired and the work queue fills with subscriptions that were dealt with months ago.
MRR is not "the current term". It is the current term or an expired row with no successor and no termination — because expired still bills. Miss the second clause and revenue silently disappears the day a term ends.
term_end_date, so no further trigger exists. And the auto-renew flag only prompts a renewal, it does not roll the term — so nothing generates a term nobody agreed to.Needs · Sky + finance · vocabulary before M6 ships · cannot be defaulted
Three anchors in three documents: the quote line it originated from, the deployed object it runs on, and the physical units it covers over dated periods. Compatible in the end state — they answer different questions — but phase 1 has no object, so the object anchor is a nullable column written by nothing.
Quote line is the anchor. Ship the nullable object column in M6, empty. Same argument as quote-line locations, and it is the argument this plan already accepted once: the column is nearly free now, and retrofitting it after subscriptions exist means reconstructing intent from history.
Unaffected by DR-42. The activation date and the object anchor are different questions: one says when billing starts, the other says what the subscription runs on. A subscription can be active for months before phase 2 gives it an object.
But DR-34 gave it a second case. Back-filled subscriptions arrive with no quote at all — they exist against hardware at a location, and the object that would anchor them is phase 2. So com_quote_line_id is nullable and this entry has to answer for two populations, not one: new subscriptions anchored on a quote line, and migrated ones anchored on nothing. The recommendation is to give the migrated ones cl_location_id — locations are owned since DR-01, and the phase 2 object carries an immutable location too, so the path joins up.
Needs · Sky · before M6
The conceptual model treats handover as part of the CRM and specifies it fully. This plan defers it to S3. Precedence answers the phase, and most of this is already settled: the line-to-location assignment table and its constraint ship in M5; only the screen is deferred.
The genuine remainder is smaller and easier to miss: what does a signed quote actually do in phase 1, if it never reaches handover?
Nothing beyond the cascade it already triggers — subscriptions created, opportunity won, company promoted to client. Operations are told out of band, exactly as they are today. Phase 1 is not making that worse; it is declining to make it better yet.
Say this to operations before M5, not at cutover. A signed quote that visibly produces nothing for them is the most likely source of "the new system does less than the old one".
Needs · Sky + operations · at M5 scoping
Exposed by closing DR-14. Brand links to company, many-to-many. Nothing links a brand to an opportunity or a quote. So when a company that operates three brands signs for £100k, the model cannot say which brand earned it.
A single-brand column would have dodged this by being wrong quietly. The many-to-many makes it explicit — and the stated reason for wanting brand reporting was performance and profit by brand, which is revenue attribution, not a company label.
A nullable cl_brand_id on the opportunity, defaulted from the company when it has exactly one brand. The forecast then groups on the deal's brand rather than inferring it. One column, set automatically in the common case, and the only case anyone has to think about is the one that is genuinely ambiguous.
Cheap now, awkward later. Adding it after deals exist means attributing historical revenue by memory.
cl_brand_id. A quote can be raised with no opportunity and still contributes to committed revenue, so quote-level is not optional extra work — without it those deals fall out of every brand report.Closed · Sky · 15 September 2026 · M3 carries the columns; M6 reads them
Company status is system-maintained — a company becomes a Client when any opportunity is won or any quote signed, from three separate paths. But companies are Accounts and opportunities are Commerce, so a derived field cannot read across. Distinct from DR-02, which is only about the write path at signature.
An event consumer that maintains a stored column in cl_. Status appears on every company list, every search result and most filters — computing it per query in a view gets expensive fast and makes filtering on it awkward. A stored column fed by OpportunityWon and QuoteSigned keeps reads cheap and keeps the write inside Accounts.
rpt_ view and never stored. Cannot drift, which is a real advantage. Pay for it on every list render and give up cheap filtering.cl_companies, fed by a consumer of OpportunityWon and QuoteSigned. The write stays inside Accounts; nothing reads across the boundary.rpt_ view. Status appears on every company list, every search result and most filters. Computing it per query makes the most-used read path the expensive one, and filtering on it awkward. Drift is manageable with a reconciliation query; a slow companies list is not manageable at all.20260821074722 revoked application writes to it, so the tracker’s bar that nothing on the company form writes status was met in M2. What M3 adds is the maintainer.Lead. Correct, and only obvious to someone who knows. Say so on the screen rather than leaving it to be discovered.scripts/reconcile-client-status.ts in connectiq-system (Build Plan 33, pull request #113). Owner: Sky. Initial default run frequency, confirmed by Sky on 6 October 2026: during drain verification; after every staging to main promotion; daily for the first two weeks after the drain is enabled; then weekly. Nothing runs it on a schedule yet, and nothing alerts anyone: it is a check until Sky runs it or wires it up.Closed · Sky · 15 September 2026 · built in M3 · DR-02 settled separately
Answered: no, it is not optional. That triggers the consequence this plan already wrote down — email becomes its own milestone and phase 1 gets longer. It does not get squeezed into M3. It is the largest scope addition in the register.
The milestone now exists: M7, after M6 and before cutover, which pushed migration to M8. It is scoped in its own feature definition and built from crm/prompts/M7-connected-email.md. What is still open under this entry is unchanged — the two checks below. The eight decisions the milestone itself needs are DR-47 to DR-54.
Privacy is decided: managers and admins see all logged email; reps see their own. Same shape as the existing see everyone's records flag, so it is one more row in §13 rather than a new mechanism.
1 · Connected inbox. OAuth to Gmail or Microsoft 365, or IMAP+SMTP for anything else. HubSpot then reads sender, recipients, subject and body.
2 · Logging rules. Account-level, e.g. log all emails from known contacts, plus reply-only rules and a never-log list of addresses and domains. That list is the privacy control that makes the rest acceptable.
3 · BCC or forwarding address. Works with no connected inbox at all. Logs content and attachments, and auto-associates to the contact, its primary company and the five most recent open deals. No open or click tracking.
4 · Tracking. A one-pixel image for opens, wrapped links for clicks. HubSpot free tier.
5 · Templates and snippets (Starter), sequences (Professional and above). Sequences are not available below Professional at roughly $100 per seat per month.
I said IMAP was impossible. More precisely: HubSpot offers three connection paths and ConnectIQ can only offer two. Gmail API and Microsoft Graph are HTTP and work on this runtime. IMAP needs a long-lived TCP connection, which the serverless runtime forbids — so HubSpot's fallback for everyone else is not available to us.
That turns into a hard eligibility question: anyone not on Google Workspace or Microsoft 365 cannot be connected at all. Confirm what the team actually uses before scoping. If it is all one provider, nothing is lost; if it is mixed, some people get BCC logging and others get sync, and that difference has to be designed rather than discovered.
Professional unlocks sequences, and a sequence is not an email feature — it is a workflow engine. Up to five scheduled emails per recipient, terminating automatically on a reply or a booked meeting, follow-up tasks, bulk enrolment up to fifty, one active sequence per contact, and rate limiting at three sends a minute against a 500-a-day cap.
Available is not used. This is DR-08 exactly: the site package existed, was tried, and was never used for the thing that justified it. Check before building. Count how many sequences exist and how many enrolments happened in the last ninety days — if that number is near zero, sequences are not part of what is being replaced, whatever the licence permits.
The milestone is connected-inbox capture, send-from-record, and templates. That is the part reps would demonstrably miss, and everything else sits on top of it.
Sequences cannot come first even if they are wanted. Exit-on-reply requires inbound capture, so the connected inbox is a prerequisite rather than an alternative. Build capture and sending; treat sequences as a separate, later, evidence-gated decision. If the ninety-day count says they are used, that is its own milestone — not an extension of this one.
com_.Needs · Sky + sales · mail provider, and ninety days of sequence usage
Placed: M7, after M6, before cutover — which pushed migration and cutover to M8 and made phase 1 eight milestones. This affirms DR-31 rather than reversing it, and DR-35 is untouched: nothing here reaches into phase 2, so the phase 2 page needs no amendment and no exception note.
The cost is accepted deliberately: phase 1 gets longer and the cutover date moves. That is the trade DR-31 already named. What it buys is the risk in §15 — sales resist the switch because something they relied on is missing — which was the whole reason DR-31 was answered the expensive way.
Every slip in M1 to M6 lands on M7, and the pressure at that point will be to cut over without it. That is DR-31's outcome reached by a different route: the milestone would not be squeezed into M3, it would be squeezed out of phase 1. M5 alone is described in this plan as its own project, so this is not a hypothetical.
Close it with a gate, not with intent. Either M7's exit criterion is a cutover gate — cutover does not happen until a rep can connect a mailbox and see a threaded reply on a record — or the placement is decorative. Answer that, explicitly, in this entry. If it is not a gate, then say so, because a milestone everyone expects to be dropped should not be the reason the plan claims reps cut over with email working.
capture_from is set at connection, so connection can happen well before cutover. Every week a mailbox is connected ahead of the switch is a week of real correspondence already on the record when reps arrive. Make connecting mailboxes an explicit cutover-prep step in §14. It imports nothing and does not touch DR-27 — it is the difference between a designed empty state and a populated timeline on day one.Needs · Sky · now — it sets the cutover date
Google Pub/Sub push notifications, or scheduled incremental polling on a stored history cursor.
Polling runs on the existing scheduled worker, the same pattern as the email queue and the event dispatcher, and adds no second infrastructure component. Push is closer to real time and costs a topic to operate, a subscription to renew, and a public unauthenticated endpoint that must verify the message signature before any write.
Whichever wins, two rules hold. Advance the cursor only after that page's rows are committed — a cursor advanced first loses mail silently, which is the one failure mode nobody notices. And when the provider rejects a cursor as too old, fall back to a bounded resync from capture_from, never a full history fetch.
State the sync interval on the settings screen, so within one sync interval is a number a rep can see rather than a promise.
Needs · Sky · before M7
v1 records file name, type and size and shows them on the message. Storing the contents is a storage-cost and data-retention decision rather than a build one.
No file storage and no download endpoint. A rep who needs the file has it in their own mailbox, which is where it already is. Storing bytes turns ConnectIQ into a document store with a retention obligation it has not been designed for — and it is additive later, whereas deleting accumulated attachments is not.
Needs · Sky · before M7
Full body makes the timeline worth opening — and makes ConnectIQ a store of customer correspondence with a retention obligation. Snippet-only is cheaper and much less useful.
A timeline that shows only snippets sends the rep back to Gmail, which is the behaviour this milestone exists to remove. Store the full body, sanitised on write.
The retention period is the part that must be written down rather than implied. Raw int_ payloads are separate and shorter-lived — delete them once processed and a short window has passed, because they duplicate the content at the same sensitivity and none of the usefulness.
Needs · Sky · before M7
Reps consent by connecting. Customers do not — and their mail is being stored in a system they have never heard of. The never-log list covers the rep's own privacy; it does nothing for the person on the other end.
Needs · Sky · before M7
Rule 10 says external system ids live only in int_external_system_mappings. com_email_messages.provider_message_id and com_email_attachments.provider_attachment_id break it.
For: the provider id is the natural key the idempotency constraint needs, and enforcing uniqueness at the database rather than in a check-then-insert is what makes reprocessing safe. Google is also not one of the migration systems rule 10 exists to protect — the rule keeps the HubSpot and Airtable migrations reversible, and a Gmail message id is not that kind of id.
Against: rule 10 says no exception, and int_mail_messages_raw already holds the provider id legitimately, so a same-domain FK to the raw row would satisfy both.
The standing context says to flag a rule conflict rather than resolve it. Grant the exception explicitly, or take the FK. Either is fine; deciding it inside a build session is not.
Needs · Sky · before M7
Written down so that "it used to work like this" is answered from a document rather than from memory. Nobody has to choose anything here. If one of these turns out to be contested, it moves up a band and stops being a correction.
The event outbox is binding in the architecture — an event row written in the same transaction as its cause, a dispatcher delivering it later, past-tense names, idempotent consumers. It appeared nowhere in this plan's table map or operations section. An omission rather than a disagreement, but DR-02 makes M5 depend on it, so it is foundation work. Now in the tracker.
An opportunity line carries billing_basis (one-time or recurring), unit_price — which for a recurring line is the price per unit per month — billing_frequency (monthly, annual, biennial upfront), term_months and free_months. Revenue derives from the rate; cash timing derives from the frequency; they are independent.
Evidence: quote 20260723-060802415 offers 490/screen/month, 5,880/year and 11,760/2-year, and 490 × 24 = 11,760 — the tiers carry no discount, so they are prepayment options rather than prices. An optional prepaid_period_price overrides the derivation where a prepayment is genuinely discounted, so that its use is visible.
DR-19 was recorded as blocking M5. It binds M3, because the billing basis sits on the opportunity line and the multiplication module reads it.
Closed · Sky · 15 September 2026 · M3 · full record
Stages named exactly those words carry special behaviour — the discovery record marks it Enforced, and fragile, and the prototype prints the caveat on its own settings page. Replaced by declared flags on the stage. Stage semantics must be declared, not spelled.
Closed · Sky · 15 September 2026 · M3. A stage carries closes_as_won and closes_as_lost as declared flags; nothing anywhere resolves outcome from the stage's name. Renaming a stage is a label change and never a behaviour change.
Which conflicts directly with a forecast that reads across everyone. One shared configuration for the workspace. The prototype's behaviour here is an accident of seeding, not a decision.
Closed · Sky · 15 September 2026 · M3. One pipeline configuration for the workspace, not one per person. Stages are workspace settings; no per-user seeding, and no per-user stage list to reconcile before a forecast can read across everyone.
Company merge re-points every related record and deletes the duplicates — the discovery record calls it "the single most destructive operation in the application". Replaced by reversible duplicate flagging: flagged against a nominated primary, excluded from every list, search and report, never deleted, never re-pointed.
The reasoning is architectural, not a preference — a merge re-points records other domains hold identifiers to, and cannot be made atomic across domain boundaries. Import-time deduplication in staging, before records land, is a different thing and is permitted.
It duplicates company status — Customer versus Client, the same concept named twice. If a marketing lifecycle is wanted later it is a separate, explicitly marketing-owned concept. The only thing to confirm is that nobody is relying on the HubSpot field, and that has to happen before M8 writes its mapping.
The prototype has a mode granting every permission unconditionally, auto-signing-in a super-admin and self-promoting it, plus demo seeding that runs on approval and a button generating random subscriptions.
Related and subtler: admins currently fall back to allowed on any unset permission, which hides configuration gaps rather than surfacing them. There are no implicit defaults, and CI checks it.
This plan versioned a sent quote in place. The conceptual model has no version concept — a quote is duplicated from an earlier one and takes a new number. The recommendation here was duplication, on the grounds that only duplication has been observed working.
Decided — versioning. The quote number stays stable, signature attaches to a version, and every version remains viewable. It answers which quote did they sign more cleanly than duplication does, and it makes the quality bar's own test — approval history staying attached to what was approved — structural rather than a convention.
com_quotes becomes identity only: number, company, seller entity, currency, signed_version_id, changes_quote_id. The things that never change across a revision.
com_quote_versions holds everything revisable: version number, status, approval state, expiry, terms snapshot, totals, signature date, proof of signature. The two axes stay two axes — status and approval state are per version and are still never collapsed into one field.
Lines hang off the version, and so therefore do com_quote_line_locations and their expected activation month (DR-42), and the four-field cost_snapshot (DR-45). That last one is a genuine improvement: each version resolves cost against its own quote total, which is what DR-45's banding rule requires anyway.
Subscriptions → the signed version's line. More precise than before, and it narrows DR-10 rather than reopening it.
Change orders (changes_quote_id) → the quote, not a version. A change order changes the agreement, and the agreement is the quote.
Activation groups (com_root_quote_id) → the quote. Groups span quotes; versions live inside one.
Approval requests and decisions → the version. This is the point of the decision.
Everything asking "what was agreed" → the quote, reading through signed_version_id. That pointer is what keeps the rest of the schema from having to care about versions at all.
A version is created when a sent document is superseded — never on an edit. Editing a draft does not version. Otherwise a typo fix on an unsent quote produces v7 and the history becomes noise instead of a record of what the customer saw.
Signing locks the quote. No version after signature, because DR-33 made signature irreversible and DR-43 made change orders the only route to a post-signature change. The two mechanisms stay cleanly separated by the signature, exactly as the row-action preconditions already have them.
Closed · Sky · schema split resolved here so M5 does not have to
The conceptual model and the architecture agree brand is first-class and lives in Customer & Location; this plan had it as a column on company, and the prototype has it as free text with autocomplete.
Decided — a first-class entity in cl_, many-to-many both ways, as specified. cl_brands and cl_company_brands.
The proposal was brand as a column plus a reporting view. Two facts decided against it: companies do operate several brands, and brands do span several companies.
Each fact breaks a different option. Companies with several brands turn a column into a comma-separated string — the exact thing M8 is unpicking, made permanent. Brands spanning companies kill even a thin lookup with a single foreign key, because shared identity is the whole point: it is what makes a rename happen once instead of once per company, and what stops a missed one silently splitting the report in half.
The reporting view was never the alternative. Brand reporting reads cl_ and com_, so it crosses a prefix boundary and needs an rpt_ view with security_invoker = true either way. The entity decides what that view groups on, not whether it exists.
And a view cannot rescue free text. "Starbucks", "Starbucks ", "starbucks" and "Starbucks Ltd" are four rows in a report; a view can trim and lowercase but it cannot merge. §07 already records this as a defect found in the prototype — brands have no identity, but the forecast groups by them.
A picker over cl_brands, not an autocomplete over a string. The prototype has autocomplete, and autocomplete suggests while still letting anyone type anything — which is how "KFC" and "KFc" both end up in the list. A selection control forbids new values outright.
Creating a brand stays open to anyone, but as a deliberate act with a near-match warning at the point of creation — the same shape as the shared loss-reason list, where a person adds one and it is immediately available to everyone. Admin-only creation would just block a rep on a genuinely new brand and teach them to pick the nearest wrong one.
Closed · Sky · cleanup owned by Boss, escalating to Chris and Sebastian
Three entities in the conceptual model — package, package lines, rollout periods — against one opportunity_forecast_items table in this plan. The recommendation here was three.
Decided — there is no package. It exists in the prototype, it was tried, and it has never once been used for the thing that justified it: reuse across opportunities. Without reuse a package is an identity wrapped around a list, and an indirection that buys nothing.
Both halves still have to exist — that part of the original reasoning holds. Licence count is software units per location × total locations, and no single table carries both the per-site bundle and the site schedule.
What goes is the wrapper, not the content. The lines and the rollout periods hang off the opportunity directly: com_opportunity_lines (product and quantity per site) and com_opportunity_rollout_periods (period and site count). The month-by-month series computes exactly as before.
The conceptual model was read out of the prototype. It describes a package because someone built one — not because the business uses one. This answer is observation from practice, and the extraction had no way to see it.
Worth remembering for the entries still open: the documents record what was built, and only people record what is actually done.
Closed · Sky + sales · ten-deal validation still owed before M3
The architecture carries a written decision record putting subscriptions in their own domain, settled by the device-swap scenario, and describes the alternative as "structural, unrecoverable without remodelling". This plan had grouped them under commerce.
Decided — sub_, its own domain, ratifying what precedence already required. The plan's grouping was not a naming slip: it implied a build that violates the no-cross-prefix-write and no-cross-prefix-join rules.
M6's shape follows from it. Subscriptions cannot be reached by joining from quotes, and the quote-to-subscription link is an event rather than a foreign key traversal — which is the same mechanism DR-02 already settled for the signature cascade. Nothing new to build; something not to build.
Closed · Sky · architecture already said so
Decided — ConnectIQ takes ownership at migration, seeded from HubSpot, with Airtable customers matched into int_external_system_mappings by name and domain. HubSpot is the commercial record and this is a commercial system.
Chris reviews the ambiguous matches, one week before migration starts. The week was already booked; it now has someone in it.
DR-18 removed every Airtable connection: no pipeline, no mirror, no credential. Matching needs Airtable customer data anyway.
A one-off CSV export, dropped into the import staging area. Migration code — used once, deleted after cutover — not an integration, so DR-18 stays closed. The count of Airtable customers with no HubSpot equivalent falls out of that export, and it is needed before the review week rather than during it: those records are net-new, not matches, and they change how long the review takes.
Closed · Chris · one-off CSV, reviewed the week before migration
Decided — eleven people: four managers, two seniors, three sales team, two others. Larger than the assumed handful of reps, one or two managers, one admin, but not large enough to need a fourth role. The three roles hold.
The ratio is the interesting part. Four managers to three sales team means more approvers than submitters, which usually means the managers also sell.
Nobody may approve their own quote, whatever role they hold. DR-04 settled that managers and admins approve — but if a manager raises a quote, that permission lets them approve it themselves, and the separation of duty the whole approval flow exists for disappears silently.
The rule is correct whether or not managers sell, so it costs nothing to enforce now: the approver must not be the submitter, checked at the database, not by hiding a button. With four managers there is always someone else available, so it blocks nothing.
Closed · Sky · self-approval prohibition added to M5
Decided — the middle option. Quotes migrate; email threads and notes do not. HubSpot is kept in read-only access for a six-month archive window. Historical quotes are needed both as documents and as numbers in a report.
Migrate the original PDF as an attachment. Do not re-render historical quotes through ConnectIQ's print view. That view is constrained by the contract template (DR-32) and would produce a document the customer never received — which is worse than not having one, because it looks authoritative.
Migrate the structured data separately, for the reporting half. Two needs, two artifacts, and the PDF is the one that answers "what did they actually sign".
Migrated quotes arrive already signed. They must not fire QuoteSigned. If they do, the DR-02 cascade creates subscriptions that DR-34's back-fill sheet is also creating, and every one is duplicated.
State this as a rule for the whole of M8, not just for quotes: the importer writes rows, the outbox stays quiet.
Closed · Sky · six-month archive, PDFs migrated as attachments
Decided — two weeks, HubSpot read-only in week two, declared by Sebastian and Chris. The test is all three of: a data check, a week without anyone opening HubSpot, and reps saying yes.
The test and the schedule line up neatly, which is worth noticing rather than assuming: HubSpot goes read-only in week two, so a week without opening HubSpot is week two. The parallel run is the test, not a wait before it.
Closed · Sebastian + Chris · tiebreak still to state
Decided — no hard external deadline, but two dates now exist: scheduled for 11 September, with 21 August as the target being pushed for. This entry warned that a real date "would change the shape of the plan and should be stated now". It has been stated, and it does.
DR-29 fixes a two-week parallel run before cutover is declared. Working backwards from each date, with M1 to M8 not yet started:
21 August requires the parallel run to begin around 7 August — which leaves under a week for all eight milestones, including M5, which this plan tells you to treat as its own project. That is not a tight plan; it is a different plan.
11 September requires cutover to begin around 28 August, leaving roughly three and a half weeks for M1 to M8. Aggressive, and not arithmetically impossible — but only with scope cut deliberately and in advance rather than discovered in M5.
Resolved: the plan does not change. Scope is not cut, the milestone sequence stays dependency-ordered exactly as the tracker has it, and 11 September and 21 August are targets rather than commitments — restated when there is evidence, not defended against arithmetic.
That is the honest option of the three. Cutting scope to fit a date nobody is contractually held to would trade a known plan for an unknown one, and the dependency order is not a preference — it is what the data model allows. M4 cannot precede M2, and M5 cannot precede M4, whatever the calendar says.
M2 closed 14 September. This entry expected the end-of-M2 re-forecast "around 14 August", so the checkpoint arrives a month after the date the plan had M2 finishing. Both stated targets — 21 August and 11 September — passed with two of eight milestones done.
The velocity signal, from the migration ledger (file dates as a proxy for when the work happened, which is an inference rather than a record): M1 ran 11–20 August, roughly ten days. M2 ran 21 August – 14 September, twenty-five days. Two milestones in thirty-five calendar days, against a plan that needed eight in about four weeks.
Projected flat, that is late December 2026 at the two-milestone average and mid-February 2027 at M2's own pace — and M5 is a milestone this plan tells you to treat as its own project while M7 is the largest scope addition in the register, so the slower figure is the more honest one. DR-29's two-week parallel run sits after all of it.
Restated target: M7 complete by the end of October 2026, cutover (M8) after it. This is a goal set deliberately against the flat projection, not a projection itself. It assumes the M2 tax does not recur: Lovable regenerating files underneath the build, the frozen Supabase client, the Places loader, the wrong-tree harness runs and the environment loop were most of M2's twenty-five days, and ADR-13 removed the cause of the first of them. It requires roughly 7 days per milestone against M2's 25 — a 3.5× step change, and saying so is what makes it a target rather than a hope.
M3 is the test, and the next checkpoint is M3 rather than M7. M3 at ten days or fewer keeps the end-of-October goal live. M3 at fifteen days or more means the goal is dead and the date gets restated then — which is this entry's own reasoning applied one milestone later: moving a date at the next checkpoint is cheap, moving it in the week before a parallel run is not. If the date turns out to matter more than the scope, that remains a separate decision to reopen, not something absorbed by quietly dropping work from M5.
M3 (pipeline) started 15 September 2026, one day after M2 closed. Seven briefs, Brief 1 (the deal spine) first and not blocked by the ten-deal rollout validation running in parallel against Briefs 5 and 6.
The checkpoint is unchanged and now has a real clock on it: ten days or fewer — a start-of-M3 end date of 25 September or earlier — keeps the end-of-October goal for M7 live. Fifteen or more — 30 September or later — means the goal is dead and the date gets restated then, not carried quietly into December. This is the first milestone built entirely under the M2.5 practice (the CI one-list fix, dev-target liveness, the generated-client guard's attacher fix, review-delivery, the branch guard), so it is also the first clean read on whether that practice actually bought the 3.5× step change M7's target assumes.
Closed · Sky · M3 started 15 September 2026 — due 25 September to keep end-of-October live
This one did not go the recommended way, and that matters. The recommendation was to follow the design system's customer-facing conventions rather than reproduce the existing document. A customer and a contract template constrain the layout — which was precisely the escalation condition listed against it.
Decided — the current template is the starting point, constrained by the contract template, with the design reviewed during development. Effort on the print view goes up accordingly, and M5 was already the highest-risk milestone in the plan.
Check the runtime before promising fidelity — first, not last. Serverless forbids native binaries and child processes, so several PDF libraries are unavailable (DR-28). That caps what the print view can render regardless of what a review asks for. Find the ceiling in week one of M5, not after three review rounds have assumed it away.
Separate content from presentation and freeze the content. Which fields appear and what the terms say is the constrained part and should be fixed early. Iterate on presentation only. §15 already rates "the quote wizard swallows the schedule" as high likelihood, and "reviewed during development" with no bound is the exact mechanism — so bound it: a stated number of review rounds, with a date.
Terms and conditions text must be snapshotted onto the quote, not referenced from settings. If a contract template constrains the document, the terms are legal content — and a later edit to standard terms would otherwise retroactively change what a customer agreed to. That is legal exposure, not a data-tidiness point.
The architecture's snapshot list names quote pricing, customer name and address, and the delivery-note recipient. It does not name terms. It should.
Confirmed since: the terms are being adjusted during development, which means they will change at least once while quotes are already being produced. That is the exact scenario the snapshot prevents — without it, the first terms revision rewrites every quote sent before it.
Closed · Sky · bound the review loop before M5 starts
Subscriptions are created from this event, so the definition matters more than it looks — it is the trigger the whole DR-02 cascade hangs off.
Decided — a status change made by a person, with the signed date and a document attachment, and the attachment is required at the moment of signing. That makes it a constraint on the write, not a follow-up: the signature action fails without the document, and the row-action precondition table in M5 gains it alongside internally approved.
No countersignature. The subscription starts after signing but not immediately — its start is governed by installation completion, per DR-42. Confirms that entry rather than complicating it.
A signature cannot be reversed. Changes go through a change order (DR-43). That removes the second cascade entirely — nothing ever has to un-create subscriptions, un-win an opportunity or demote a company, and no compensating event needs designing.
Closed · Sky · attachment enforced at the write
Decided — yes, and it has an owner and a date at last: Boss and Ning, due 16 August. This was the item most likely to be discovered late and hold up M8 on its own, and it is now the only migration input with a name against it.
Source: HubSpot, and "pretty close". That changes the job from assembling a list from scratch to seeding from HubSpot and correcting by hand — less work, plus a reconciliation step that did not previously exist.
Migrating approximately-right recurring revenue means the system starts with knowably wrong MRR and no measure of the error. The first forecast a manager runs would be wrong by an unknown amount, which is the fastest way to lose their trust in the whole system.
Add an explicit sign-off before cutover: a named person confirms the back-filled MRR total against the business's own figure, and the variance is written down rather than shrugged at. It belongs in the M8 gate sequence, not in someone's head.
They exist against the hardware at a location, which links to the ConnectIQ object — and the object is phase 2. So in phase 1 a migrated subscription has no origin at all.
com_quote_line_id must be nullable, and the rule subscriptions are created only by a QuoteSigned event gains exactly one exception: migration. Say so, or the constraint gets written tight and M8 fails against it.
Anchor them to cl_location_id in the meantime. Locations are ConnectIQ-owned since DR-01, the hardware is at one, and the phase 2 object carries an immutable location too — so the path joins up later. DR-10 now has two cases to answer, not one: new subscriptions anchored on a quote line, migrated ones with no quote line at all.
billing_start_date, in the past — these are already billing, so they skip activation entirely (DR-42).term_end_date, or an explicit "unknown". Without it nothing can compute renewal due (DR-05), and a blank will silently read as never-renewing.Closed · Boss + Ning · due 16 August · reconciliation gate in M8
The architecture records this as open — "where do contacts live, here or CRM/Commerce" — in a binding document. The conceptual model answers it on access-sensitivity grounds, placing contacts in Customer & Location so that row-level security on that domain is the authoritative access layer.
Decided — cl_ Customer & Location. It was never a contradiction, only an open question with one good answer and no competing argument.
The knock-on is accepted, not discovered. Contacts link many-to-many to opportunities and quotes, so every contact chip on every commerce screen reads through a service client or an rpt_ view — never a join. That is a real cost across a lot of surfaces, and M3 is where it will first be felt.
Closed · Sky · update the architecture
The conceptual model specifies four-step price resolution across currencies. The discovery record found the prototype converts currencies throughout with no rate governance at all — where rates come from, and as of when, was invisible.
Decided. A fixed currency list covering both sides, buying and selling — the same set is quoted in and billed by suppliers. Rates are dated, the rate used is the one current at the time of the transaction, and a signed quote's value is never restated at a later rate. Cost stays in the currency it was billed in (DR-45).
Given as USD · MYR · SGD plus peso, written twice. I have not assumed which peso. Philippine peso is the likely reading next to MYR and SGD, but Mexican, Colombian and Chilean pesos all share the name and none of them share a rate.
Confirm the ISO codes before the table is seeded. Currency is now in the money path for revenue and margin, so a wrong code is not a label problem — it is a silently wrong number in two places. Four codes or five is also unresolved: the duplicate may be a typo or may be two genuinely different pesos.
Use the most recent rate dated on or before the transaction date. If no rate exists for that pair on that date, refuse rather than guess — the same rule the conceptual model already applies to an unresolvable price: a rule of the business, not of the interface.
Committed and pipeline revenue convert differently, and the forecast must say so. Signed quotes carry their snapshot rate and never move. Open pipeline has no snapshot, so it converts at the rate current on the forecast date — meaning the same pipeline forecast re-run next month legitimately gives a different number. Show the forecast's as-of date so that difference reads as correct rather than as a bug.
Closed · finance · confirm the peso codes before seeding
Raised by DR-44. Cost varies by supplier, by volume and by date, so the single base cost column the M4 prompt specified was wrong — and every margin in the system would have read from it.
Decided — com_product_costs: product, supplier, volume floor, effective from, effective to, cost, currency. com_suppliers is a flat lookup in phase 1, enough to tell cost rows apart and nothing more; purchasing stays a phase 2 concern. A product with no resolvable cost has no margin, not a margin of zero.
Cost does not resolve per line. It resolves per quote. Price resolution is a four-step lookup on one line; cost resolution needs the quote's total quantity of that product across every line before it can pick a band. The earlier note that cost "mirrors the four-step price resolution" was wrong and is corrected here.
Three consequences the build has to carry:
Adding a line recomputes margin on every other line sharing that product. The money module takes a quote as input for cost, not a line — and that module is the one the plan insists is built standalone and tested before it touches a screen.
Two identical lines in different quotes can carry different margins. That is correct, and it will look like a bug. Say so on the screen rather than in a comment.
Change orders do not aggregate. A change order is a separate purchase at a separate time, so it bands on its own totals. It joins the original activation group (DR-43) but not its volume — those are different things and the resemblance is a trap.
Cost is held as billed, not converted at entry — accuracy over convenience, and the right call. Margin therefore converts at the moment quote pricing freezes, which makes the snapshot bigger than one number.
cost_snapshot is four fields: amount, currency, the FX rate used, and that rate's date. Store all four. Without the rate and its date, a margin cannot be re-derived a year later and an approval cannot be audited — only re-guessed.
Rates are dated. That answers half of DR-07, and it promotes the rate table from a forecast concern to a money-path one. Two different numbers now depend on it.
com_fx_rates and seed it late.Closed · Sky + finance · sourcing tracked as a risk in §15
Raised by the answer to DR-04. Margin is computable, so cost is held — the M4 prompt already specifies it — but neither §13's permission matrix nor the prototype's twenty-one flags said who may see it.
Decided — everyone sees cost and margin in phase 1. Reps see it today and will keep seeing it. A role-based split for the sales team is a later phase, so no see_margin flag is built now — which is consistent with §13's own principle: a flag only where the three roles are not enough. Right now they are enough, because the answer is the same for all three.
The caution that motivated the flag does not apply. It was that a number reps have already seen cannot be un-shown. They have already seen it, so there is nothing to protect.
Render margin through a single component, gate-able from day one. Not gated — gate-able. When the later phase adds the flag it should be one can() check in one place, not forty screens to find. Margin inlined into each surface is what makes that later phase expensive.
Closed · Sky · role-based split deferred to a later phase
The conceptual model made Approval Rule an entity: a one-time value threshold, a maximum discount without approval, a minimum margin, and whether any discount forces approval. This plan assumed a simple split.
Decided — the fixed split, stored as data rather than code. Reps submit, managers and admins approve. Build com_approval_rules as a real table holding one row, so becoming configurable later is a settings screen and an insert rather than a schema change and a rewrite of the approval path.
Both banding questions came back no: no quote has ever needed approval purely because of its value, and there is no discount level requiring a second signature. There is no policy to configure, so an Approval Rule entity would have shipped empty.
The prototype shows an approver three checks and computes only one. With no thresholds behind any of them, all three go:
Discount within 20% — computed, but nothing makes 20% a rule. A tick against an invented number is worse than no tick, because it teaches an approver to trust it.
Margins meet thresholds — hardcoded to pass, and there is no threshold.
No pricing conflicts — hardcoded to pass, and the concept is undefined.
Ship zero automated checks and one honest summary instead. Show the approver the discount percentage, the margin and the total, and let them decide. That is more useful than three ticks of which two lie — and it is what the approver is for.
Closed · Sky · no thresholds exist to configure
Three documents, three answers. This plan created a contract record at signature. The conceptual model stated there is no contract entity — the signed quote is the only signed object, a quote produces many subscriptions flat, and Airtable's subscription contracts map onto quotes. The architecture recorded the ownership as explicitly open between CRM/Commerce and Subscription, and noted it may be two concepts.
Decided — no contract entity. The signed quote is the contract, and a subscription's quote-line origin carries everything a term needs. Precedence pointed this way and finance confirmed the two facts it turned on: nothing live has a term that differs from the quote that sold it, and there is never a signatory who is not the quote's recipient. A contract would have carried a term, a signatory and a renewal cycle. It has none of the three to carry.
The one thing that does span quotes is not a contract. DR-43 established that a change order's subscriptions join the original activation group — but sub_activation_groups holds a mode and a date, nothing commercial. What spans quotes is timing, not terms. Each quote in a change-order chain is still signed separately and still holds its own.
Renewals do not need one either. The renewal flow reads a set of subscriptions and generates one quote per company. That was the most plausible remaining job for a contract, and it works without one.
Reinforced by DR-05. Renewal is per subscription item, and a renewal quote groups items only for the customer's convenience. The grouping is presentational — exactly the kind of thing a contract entity would have been invented to hold, and it holds nothing.
Closed · Sky + finance · strike the open item in the architecture
Raised by the answer to DR-42. A location cannot be dropped from a signed quote — changes require a change order, a mechanism that appeared nowhere in this plan, the conceptual model, the discovery record or the prototype.
Decided — a change order is a new quote that points at the one it changes. One nullable self-reference on com_quotes, changes_quote_id, and no new quote-side entity. It gets its own number and document, and goes through the same approval path as any other quote. Additions only in phase 1 — removing a sold location needs DR-05's termination machinery, so reductions are handled by hand until that lands, and the UI says so rather than half-offering it.
It can only be raised against a signed quote. That makes the split with DR-13 enforceable rather than a convention: Duplicate is available on any quote and revises it before signature; Raise change order appears only once a quote is signed. The two never both apply.
A change order's subscriptions join the original activation group. That reverses what DR-42 recorded, and the correction is noted on that entry rather than quietly applied. The group is no longer "the subscriptions one signed quote produced" — it outlives the quote that started it.
It becomes a real table: sub_activation_groups. It holds the activation mode, the resolved billing start date, and a stored com_root_quote_id. com_ resolves the change-order chain to its root at signature and puts that id in the QuoteSigned payload, so sub_ looks the group up by a value it already holds and never traverses into com_.
Joining rule, and it needs confirming at M5 scoping. Before the group activates, a joiner extends the wait and the shared date recomputes. After it activates, a late arrival cannot join a date that has passed and starts on its own installation instead.
sub_.Closed · Sky + sales · joining rule confirmed at M5 scoping
Found in review, not in the extraction. The prototype has no concept of this and neither did this plan: a subscription is created at signature but does not bill from signature. Installation runs free, and billing starts on the first of the month following installation completion. It was the missing start half of DR-05.
Decided — the model carries two dates that must never be confused.
1 · Expected activation month — superseded 5 October 2026 by the line-level activation_mode; see the note below. Entered by sales, required, and explicitly labelled as forecast-only. It is the answer to "committed revenue with no month" — rather than bucketing signed-not-started revenue outside the monthly series, the series gets a month from the person best placed to guess. It never bills anything.
2 · Billing start date. Set at activation from the real installation completion date, and the only date that moves money. Null until then.
Activation mode is chosen by sales and is editable after signature. Synchronised — the whole group starts together from the first of the month after the last location is installed — or per location. It is seeded onto sub_ from the QuoteSigned payload and sub_ owns it from that moment; the quote is never re-read. This is a transfer of ownership, not a frozen snapshot, and switching a stuck group to per-location is the supported way to release its revenue.
activation_mode
activation_mode, not an activation date, and that M3 forecasts the start from the rollout period's month plus the mode's offset plus free_months. It was found to sit in tension with point 1 above, a month entered by sales.activation_mode governs. The "expected activation month" passage is superseded by it. Everything else here stands.sub_activation_groups, holding the mode, the resolved start date and a stored root quote id. Everything else on this entry stands: the two dates, the free period, the rounding rule, the ownership transfer.The free period is genuinely free. No accrual, no retrospective charge, no catch-up invoice. MRR is simply zero until billing_start_date, everywhere.
Rounding has no same-day case. Billing always starts the first day of the month after installation completes — including when installation completes on the 1st. One rule, no branch, and it gets a real test.
Nothing is dropped from a signed quote. Scope is immutable after signature; changes go through a change order. That removed the synchronised-deadlock risk and raised DR-43, which is now open.
A required forecast-only field decays into noise unless the variance is visible. Nobody has ever tracked expected against actual activation, so nothing today would notice the month always being optimistic. Put expected-versus-actual somewhere a manager sees it, or the field becomes a box reps clear to leave the wizard.
The expected month must not print on the customer-facing quote. A customer reads a date on a document as a commitment, and this one is explicitly a guess.
Granularity is the one open sub-question. Under per-location mode the months genuinely differ, so the field belongs on com_quote_line_locations — a column on a table M5 already builds — with a quote-level bulk set that is the only editable control in synchronised mode. The cheaper alternative, one month on the quote header, is wrong for exactly the case per-location mode exists to serve.
An opportunity line carries an activation_mode — first_of_month_after_install, on_install, on_signature or fixed_date — not an activation date. M3 forecasts the start from the rollout period's month plus the mode's offset plus free_months. Actual installation dates live in Field Operations and supersede the forecast; DR-42 keeps actual activation, change orders and the subscription lifecycle for M5/M6.
Evidence: quote 20260723-060802415 prints "Start date: the first of the following month after installation." Nobody has that date at quote time, which is when the forecast is needed.
Line level closed · Sky · 15 September 2026 · M3 · full record
Closed · Sky · granularity confirmed at M5 scoping · forecast variance surfaced in M6
The conceptual model: signing "creates a subscription from every recurring line, wins the linked opportunity at full probability, and makes the company a client — as one atomic business event", and the discovery record confirmed the prototype does exactly that in one path. The architecture: "cross-domain database transaction — No. No exception." Those three effects live in three domains.
The technical answer was already settled by precedence — an outbox chain, where the quote status and the event row commit together in one com_ transaction and sub_ and cl_ consume the event idempotently afterwards. What was open was what the rep sees in the seconds between.
Decided — show the rest as pending. The quote flips to Signed immediately, because that write is guaranteed and in one transaction. Subscriptions and company status render with a visible being created state that resolves on the next fetch. It is the only option that is honest when the dispatcher is slow and honest when it fails; blocking turns an eventual system back into a synchronous one, and rendering optimistically means a failed consumer leaves the screen having lied.
Do not confuse this state with DR-42. This pending is milliseconds, resolves itself, and nobody confirms it. The business-visible wait between signature and billing is DR-42 and lasts weeks. They must not look the same on screen — a rep who learns to ignore one will ignore the other.
One measurement still owed: time the dispatcher in M1. If it reliably completes in under a second, the pending state can be a brief inline spinner rather than a persistent chip. That is a presentation detail inside the decision, not a reopening of it.
Closed · Sky · UI must stay distinct from DR-42
This plan said Airtable owns locations and ConnectIQ mirrors them. The object model makes locations an Accounts-domain table with dated ownership periods; the architecture lists locations under cl_ master data and names them a cutover entity, not a mirror; the conceptual model makes each location belong to exactly one company; and the discovery record found that sites are created and edited inside the company record, which is where the register is actually populated.
It was not just a preference. §12 requires a quote line to carry a location, and the architecture says a mirror is "never joined into an operational write path". Those two positions could not both hold. It cascaded into phase 2 as well: the object's location is immutable and set at creation, and an immutable foreign key into a table a nightly sweep can mark absent is not a stable anchor.
Decided — ConnectIQ owns locations, as cl_ master data, created and edited inside the company record, one company each. Precedence settled it. There is no request-a-location flow, because there is no second writer to request from.
What it cost. §02, §03 and §04 were rewritten, and DR-18 collapsed with it. The accepted consequence is a medium risk in §15.
Closed · Sky + operations · §02–§04 rewritten
Assumed: locations, plus a display-only view of jobs and installed objects. Every additional table is sync surface to maintain. DR-01 moved locations into ConnectIQ, so the list lost its main item — and a display-only jobs view is not required for "a full week without opening HubSpot". The goal names HubSpot, not Airtable.
This decision did not get resolved, it stopped existing. Phase 1 builds no Airtable integration — no scheduled pull, no int_*_mirror tables, no staleness model, no sync dashboard. The pipeline is parked with its return trigger on the phase 2 page.
Closed with DR-01 · cost recorded in §15
Decided: Vercel for the application at connectiq.giant-pumpkin.com, managed Supabase on the Pro plan for the database, Resend for email, droplet for the documentation site only. Recorded as ADR-08 and ADR-10 in the strategy page. Pro is not a preference — branching for preview environments requires it, so the environment model depends on it.
What did not change: the serverless runtime still forbids native binaries, child processes and long-lived connections. That constraint on the quote print view and the import module survives the hosting decision intact — see DR-32.
Closed · see strategy page §13
Two problems at once. This plan proposed a polymorphic task_links, which is exactly the simplification the conceptual model warns against — the four-way many-to-many is "one of the firmest structural findings". And no activity domain exists in the locked prefix registry, while the four link targets straddle two domains, so the timeline screen cannot join to any of them.
Decided — com_activities and com_activity_links. Calls, meetings, emails, and follow-up commitments are actions performed as part of the commercial relationship, not attributes of the customer record they point at. Companies and contacts are the subjects of an activity; cl_ does not perform the work, it owns who the customer is and where they operate. Typed links may still reference cl_companies and cl_contacts alongside com_opportunities and com_quotes — the four-way link table is unchanged in shape, only in prefix — and a customer timeline reads Commerce activity through a service client or an rpt_ view rather than needing the rows to live under cl_.
Binding, unchanged: do not flatten the four-way link. That part was never a preference.
ops_, rather than writing into com_activities. Unifying the record, not the table, is what a reporting read model is for.cl_ on email volume. That test conflates who is acted upon with who performs the action. A contact receiving the most activity links doesn't make Customer & Location the writer, any more than a job referencing a location makes the job a cl_ record. Ownership follows the capability performing the behaviour, not the record most referenced by it — so the count was never going to settle this correctly, on either side of M7's email volume.Closed · Sky · 21 August 2026 · DR-48 decided with it, see below · full decision brief
Logged messages must live in the same domain as activities, so one worker writes both without crossing a prefix. That makes this DR-15's question, not a new one — but it was worth its own entry because it originally resolved the opposite way to DR-15's own recommendation.
Decided — logged messages belong to Commerce. Connected-email infrastructure remains in plat_, and raw provider ingestion remains in int_. Once interpreted as business correspondence, messages, attachments, templates, and their record links are owned by com_. Customer and contact screens access correspondence through the Commerce service client or an authorised rpt_ timeline view. The original recommendation here was cl_, reached by counting link targets and finding email logging makes contacts the dominant target by a wide margin. That count answers who is pointed at, not who performs the write — see DR-15's decided entry for why the tiebreak itself was wrong. DR-48's original cl_ recommendation is what gets overridden — not DR-15's; the override direction described above is reversed by this decision.
plat_mail_accounts — mailbox connections, credentials, sync configuration. plat_, unchanged.int_mail_messages_raw — raw provider payloads and sync state, before interpretation. int_, unchanged.com_email_messages, com_email_message_links, com_email_attachments, com_email_templates — interpreted CRM correspondence, its record links, attachment metadata, and templates. com_, decided here.plat_ and int_ were never candidates for cl_ or com_ — connection infrastructure and raw ingestion sit outside the domain layer entirely, which is why this decision only touches the four com_ tables above.
com_.cl_companies and cl_contacts.Closed · Sky · 21 August 2026 · rename every cl_email_* table already written as com_email_* · full decision brief
M3 models activities as one entity with a type — call, meeting, email (renamed email_task below) — with its own four-way link table. M7 builds a second message store with a second four-way link table. That overlap was not accidental, and DR-48's com_ decision is what settles it.
The concrete consequence: M2's contact record carries date of last contact, derived from activities, not entered. If a logged email is not an activity, that date goes stale every time a rep emails someone — the CRM reports a customer as untouched for three weeks the day after they were written to. Worse than HubSpot, in exactly the place reps look.
Decided — option 2, re-derive, now that both sides are com_. Do not create a duplicate activity for every captured email — a message is already a first-class row in com_email_messages, and a second row in com_activities for the same event is two records of one thing. Derive last-contact-date from completed com_activities and sent or received com_email_messages directly. Both tables are com_ after DR-15/DR-48, so this is a same-domain read — cheap in a way it would not have been under the original cl_ recommendation for email.
com_activities.type = 'email' no longer means "an email happened" — it means a planned or manual email follow-up a rep chose to log as a task, distinct from the captured message itself. Rename it to email_task. The old name invites exactly the confusion this entry exists to resolve: a build session reading "email" on both an activity type and a message table has no way to tell, from the name alone, which one a given row is.Closed · Sky · 21 August 2026 · rename com_activities' email type to email_task · full decision brief
Companies and locations both take a free-typed address, with no check that it exists, is complete, or is spelled the way the postal system expects. Typos and incomplete addresses surface downstream — a failed courier delivery, a site visit sent to the wrong door, a report that cannot map a customer at all.
Decided — Google Places Autocomplete (New), applied to cl_companies.address and cl_locations.address. As a rep types, the field suggests real matching addresses; selecting one fills address, city, postal code and country from the Place Details response, and records google_place_id and address_verified = true on the row. An unverified address is never saved silently. Typing past the suggestions, or editing a field after selecting one, disables the save button and shows an Override action; overriding requires a non-empty note explaining why, and the save stays disabled until it is provided. Re-selecting a matching suggestion clears the override and the note. The address content itself is never rejected — the override is a conscious, attributable act, not a second validation system.
google_place_id text, nullable — the selected suggestion's identity, kept so the address can be re-validated or re-geocoded later without asking the rep to retype it.address_verified boolean not null default false — true only immediately after a Places selection populated the fields; false whenever the address is unverified. The same provenance idea as link_source on com_email_message_links: a rep's version is distinguishable from a confirmed one, never silently merged with it.address_override_note text, nullable — required whenever address_verified is false and the address is non-null. Enforced by a check constraint — address is null or address_verified or address_override_note is not null — so a form bypass cannot save an unverified address with no reason attached.google_maps_url text, latitude double precision, longitude double precision, all nullable — read off the same Place Details response as google_place_id, no second call. A company's registered address has no use for coordinates or a map link in this plan; a site does. Left untouched on override, refreshed on a fresh selection — the same staleness rule as google_place_id.address_verified = false with no record of why. Override makes the drift a decision. A rep who genuinely needs a non-Google address still gets it, but has to say so, and the note is visible on the record afterward — the same shape as the won-without-quote justification on a stage change: a required note attached to an unusual path, not a block on taking it.Closed · Sky · 21 August 2026 · applies to M2's cl_companies and cl_locations address fields
A site inside a mall, market hall or arcade has two addresses: the building, which Google knows, and the unit, which only the landlord and the customer know — G-2-1, Unit 3, The Arches, Kiosk 12. DR-55 gave the second nowhere to live.
Why it could not be left. With no home for the unit, a rep types it into the address, Google disagrees, and the only way to save is Override with a reason. For a whole class of sites — plausibly the most common class in a hospitality and retail customer base — the normal state becomes "unverified, with a reason". A flag that is normally set carries no information, and reps stop reading override notes that every third record has. That is exactly what DR-55 was written to prevent, reached from the other direction.
Decided — unit_reference on cl_locations, and address verification is a claim about the building only. Free text, ConnectIQ's own data, distinct from external_reference. It never participates in verification: editing it cannot mismatch an address, which is enforced structurally by keeping it out of the verified field set rather than by remembering not to compare it.
Closed · Sky · 4 September 2026 · M2 · full record
The architecture says a domain may read another domain's data by calling that domain's service client — interaction rule 2 calls it "the only operational path for cross-domain data". The enforced lint rule had no such exception: it blocked every import across domains, including a domain's own exported query functions. Notes and tags were the first feature to attempt a cross-domain read, so nobody had hit the gap.
The rule and the architecture disagreed, and the rule was the stricter. Left alone that does not produce a purer codebase — it produces one where the boundary is respected inside src/domains/** and quietly evaded next to it, because the work still has to ship and every route around the rule lands somewhere the rule does not look.
Decided — each domain declares its public surface in one barrel, src/domains/<domain>/index.ts, and the lint rule permits a cross-domain import only from that path. Every deeper path stays blocked. "Defined interface" stops being a phrase in a document and becomes a file that can be opened and reviewed. A domain with nothing to offer other domains has no barrel.
The data rule is untouched. No cl_ file may query a com_ table, and no PostgREST nested select may cross a prefix however inviting the foreign key looks. The barrel exports functions; the reading happens inside the domain that owns the tables.
Closed · Sky · 10 September 2026 · every domain · full record
Delivered during the DR-55 walkthrough, and narrowing it rather than reopening it. No schema change — the columns are DR-55's.
The stored address is Google's full formattedAddress, verbatim, rather than a street line recomposed from components. A recomposed line is our taxonomy imposed on Google's and loses whatever Google knows about how an address is written in that country.
city, postal_code and country become analytics fields — still extracted, still stored, still shown, but no longer the source of truth for what the address is, and not guaranteed to be present.
The city fallback chain, pragmatically extended: postal_town → locality → administrative_area_level_2 → sublocality_level_1 → administrative_area_level_1. No component type means "city" in every country — Thailand has no city at all outside Bangkok, and Bangkok's own districts are tagged sublocality_level_1. This is a fallback to the coarsest available label, not a claim that any of them is the city; for a Thai address the last link yields the province, which is wrong as a semantic and better than an empty field.
Suggestions are soft-biased to Southeast Asia — a re-ranking, not a restriction; addresses outside the region still resolve. The place's own name is offered as a suggestion for the record's Name field, never applied, and deliberately outside the address field set so it cannot trigger or clear a mismatch.
Closed · Sky · 11 September 2026 · M2 · full record
Every opportunity line declares a quantity_basis: per_location (multiplied by the location count) or deal_total (never multiplied).
Evidence: on one opportunity, quote 20260427-071511013 lists LG - 43UH5Q-EQ × 5 meaning five units across five sites, while 20260723-060802415 lists the same product × 1 meaning one unit for one site. Without the flag, the superseded rule licence count = units per location × total locations computes 55 displays for a deal that bought eleven.
Closed · Sky · 15 September 2026 · M3 · full record
Opportunity lines describe the deal's default bundle. A rollout period may override individual lines — product, price, quantity, or any combination — through com_opportunity_period_line_overrides. Overrides key to the period, not to a named location, because an opportunity carries location counts rather than named sites, and at forecast time the sites frequently do not exist as records.
Evidence: on one opportunity, Chaiyaphum (installed 2026-05) received LG - 43UH7N-EP with delivery at THB 4,400, while Chiang Rai (installed 2027-01) received LG - 43UH5Q-EQ with delivery at THB 7,000. Every other line matched to the baht. Both differences fall between periods, which is why period-keyed overrides are sufficient for the evidence in hand.
Amended by Brief 5R (PR #45, 16 September 2026): an override may apply to some of a period's locations via location_count; the counted rows must not exceed the period's new locations, and a deal_total line cannot be split. The change was forced by Thai Watsadu: one period, one line, 20 locations at ฿1,350 and 70 at ฿1,050.
DR-08 stands. It removed a reusable Site Package — an entity justified by reuse across opportunities that was never once used that way. This is per-period variation within one opportunity, observed in signed contracts, with no reuse and no identity outside its opportunity. The two are different, and the difference is recorded here rather than assumed.
Closed · Sky · 15 September 2026 · M3 · full record
Giant Pumpkin runs two business lines — digital signage (TVs and monitors with display-management software) and music streaming (the Lisa box). They differ in economics, not just in product: signage hardware is priced and installed, music hardware is bundled at THB 0 and delivered.
Every HubSpot deal carries Service and every Airtable quote carries Quote Service (HS) as a controlled two-value vocabulary. labels is not a substitute — it is free text and drifts. Deriving the line from the opportunity's categories fails for a deal with no lines yet, which is every opportunity at the moment it is being forecast, and for repairs, whose categories say nothing about the line. This is DR-46's argument again: revenue split by business line is a certainty for M6, and a column added after deals exist means attributing history by memory.
Closed as built, 5 October 2026.
com_opportunities.business_line. The name business_line is accepted, not service, because service means nothing without HubSpot and Airtable context.not null, with no default. Every deal examined is exactly one line, so DR-46's reversal does not apply.digital_signage and music_streaming.The column's database comment read "not yet closed" until Build Plan 33 (connectiq-system pull request #113): its migration 20261006120000_m3_stage_changes_close_client_writes.sql replaces it with a comment that cites DR-64 and the 5 October 2026 close.
Closed · Sky · 5 October 2026 · M3 · full record
A location belonged to a company and had no brand. ConnectIQ could say that Copperwired operates iStudio, but not which Copperwired stores are iStudio stores, nor how many KFC stores QSA, CRG and RD each run. The forecast groups by brand, so store counts by brand are needed before M4 and M8 add data. Adding the banner after locations are imported would mean assigning a banner to every migrated store from memory, which is DR-46's argument again.
There are four brand relationships, and they are not interchangeable.
| Relationship | Meaning | Where it lives |
|---|---|---|
| Company owns brand | Brand ownership — Yum! owns KFC | cl_brands.owning_company_id (exists) |
| Company operates brand | Runs businesses under that brand — QSA, CRG and RD operate KFC | cl_company_brands (exists) |
| Location trades as brand | The store's banner or storefront identity | cl_locations.cl_brand_id (Plan 40) |
| Location sells brand | Carries that brand's products | Not built |
Copperwired operates iStudio. A Copperwired location trades as iStudio and sells Apple products. Selling Apple does not make it an Apple-branded store.
Closed as follows, 7 October 2026.
cl_locations.cl_brand_id. It is optional, holds one brand, and must be a brand the location's company operates. The database enforces that with a composite foreign key onto cl_company_brands, not only the form.parent_company_id already models groups. No level is added.digital_signage and music_streaming.cl_company_brands), never to owning_company_id. In Airtable it holds the operator.This changes M2's accounts model. M2 closed with a location that has a company and no brand. DR-65 adds the store banner to it, and extends DR-14 and D-3, which fixed that a brand is a selection and not free text. It is M4 work because it came from M3 feedback and Sky placed it in M4 on 7 October 2026. Delivered by Plan 40 (connectiq-system pull request #123, merge commit 99c9d11, 7 October 2026).
Narrowed by DR-68: a store banner is now allowed only on a location whose type is Client site or Office. A Warehouse or Repair center cannot carry one.
Closed · Sky · 7 October 2026 · M4 · Build Plan docs/plans/40-brand-relationships-store-banner-mbo.md in connectiq-system
Some physical stores trade under two banners at once. DR-65 gave a location one optional banner, which leaves the question of what a co-branded store records.
A store banner is a single brand per store (Sky, 7 October 2026). One location never carries several banners.
Splitting a co-branded store into two locations is rejected as a default. It would duplicate store counts, screens and contracts.
Which single banner a co-branded store records (for example its main banner, or none), and whether any exception allows two locations.
Evidence needed first: how many co-branded stores exist in the current Airtable data.
This blocks how co-branded stores are entered. It does not block Plan 40.
Needs · Sky · before co-branded stores are entered
Reporting needs to split revenue into hardware, software and service. The line categories (com_line_categories) are a shared, growing vocabulary: Display, Player Box, Software, Installation, Delivery. Nothing groups them. Recurring against one-time already lives in com_opportunity_lines.billing_basis and needs no change.
A required reporting_group on com_line_categories, with the values hardware, software and service.
Every new category must choose a group. Not built: it waits for this decision and for its own plan.
Needs · Sky · before hardware, software and service reporting is built
Every location was implicitly a customer site. A warehouse entered today inflates the company's site count and can be given a store banner. The four kinds of place need to be recorded in the product and explained in the documentation, and the type decides flow: what a location may carry and what it counts toward. Adding the type before M8 imports locations means the import can set it, rather than someone classifying every migrated site from memory afterwards (DR-46's argument, as in DR-65).
Closed as follows, 7 October 2026.
client_site, warehouse, repair_center and office, labelled Client site, Warehouse, Repair center and Office. The field is Location type, cl_locations.location_type.client_site; every existing location is backfilled as a Client site.This reverses part of M2's "Do not build" list. M2 excluded location operational attributes (type, opening date, status, operator). Type only is built. Opening date, status and operator stay excluded. It also narrows DR-65: a store banner, previously allowed on any location, is now allowed only on a Client site or Office. It is M4 work because it came from M3 feedback and Sky placed it in M4 on 7 October 2026. Delivered by Plan 43 (connectiq-system pull request #126, merge commit 7139a23).
Closed · Sky · 7 October 2026 · M4 · Build Plan docs/plans/43-location-type.md in connectiq-system
DR-12 and DR-41 (what creates an object, and how many), DR-35 (does phase 2 overlap phase 1), DR-36 (is Airtable retired table by table) and DR-37 (who owns finance and billing) are on the phase 2 page. They keep their numbers — the DR series is global, not per-document, and renumbering a register that build prompts reference by ID is how a stopped session becomes a silent one.
None of them blocks a phase 1 milestone. DR-10 and DR-11 sound like phase 2 and stayed above, because both are decided during phase 1 — in M6 and M5 respectively.
Where a new decision goes. Every DR gets a card in the register of the page that owns its subject — this one for phase 1, the phase 2 page, or the strategy page for DR-38, DR-39 and DR-40. A standalone file is optional backing and never the only record. DR-56, DR-57 and DR-58 were each decided, written up as a file, and left out of this register for days — a reader looking here would have concluded M2's last three decisions did not exist.
Bold means a decision must exist before the milestone starts. The rest are entries the milestone has to honour — corrections it must not reintroduce, or answers it needs before it finishes rather than before it begins. A milestone prompt lists both, because a build session needs to read both.
| Milestone | Must be settled or honoured |
|---|---|
| M1 Foundation | DR-17 · DR-24 |
| M2 Accounts | DR-22 |
| M3 Pipeline | DR-20 · DR-21 · DR-31 · DR-46 |
| M4 Commerce | nothing open — cost sourcing is a risk, not a decision |
| M5 Quotes | DR-11 · DR-19 · DR-40 |
| M6 Subscriptions & forecast | DR-05 — before it ships · DR-10 · DR-16 |
| M7 Connected email | DR-47 · DR-49 · DR-51 · DR-52 · DR-53 · DR-40 — and DR-27, which it must not quietly undo |
| M8 Migration | DR-19 · DR-23 · DR-46 — are migrated deals back-filled? |
| S1–S5 Phase 2 | DR-10 · DR-12 · DR-35 · DR-36 · DR-41 — listed on that page |
| Unscheduled | DR-37 — on phase 2 |
| Closed | DR-01 · DR-02 · DR-03 · DR-04 · DR-06 · DR-07 · DR-08 · DR-09 · DR-13 · DR-14 · DR-15 · DR-18 · DR-25 · DR-26 · DR-27 · DR-28 · DR-29 · DR-30 · DR-32 · DR-33 · DR-34 · DR-42 · DR-43 · DR-44 · DR-45 · DR-48 · DR-54 · DR-55 · DR-56 · DR-57 · DR-58 · DR-16 · DR-20 · DR-21 · DR-46 |
21 · What we learned from the prototype · history
This section is history. It records what the Lovable prototype taught us. Nothing in it is a task.
The prototype was read out into two documents. The UX/CX discovery record captures what the application does, screen by screen, with each statement marked Enforced (the interface prevents the alternative), Convention (it guides but does not prevent) or Inferred (implied by the design, confirmed nowhere). The conceptual model turns that into entities, attributes and relationships in business language, and takes a position on each of the ten questions the record left open.
Coverage was substantial: roughly forty thousand lines across twenty-six routes and thirty domain components. Route files, page components, dialogs, form validation, permission gates, client state and derived calculations were read. Migrations, generated database types and the schema were deliberately not read — no table or column name from the prototype appears anywhere in either document, and the prototype's schema plays no part in the physical design.
Opportunities, activities, contacts, companies. The part a conventional CRM would recognise.
Quotes with multi-currency pricing, customer price books, internal approval, VAT, legal entities and PDF output.
Five separate lenses, all built on the per-location model. Three revenue classes that coexist rather than derive.
Allocating sold units to physical sites and passing the result downstream. The commercial process does not end at signature.
That fourth concern is what makes this not a conventional CRM. A deal is not a single amount: it is a per-location model multiplied by a rollout plan. Revenue, unit counts and licence counts are derived from those two things rather than entered.
It is the most detailed description of intended behaviour that exists, and it should be read before every milestone. But it describes a system built by an assistant against a moving brief, so it also contains decisions nobody made deliberately. Treat it as a requirements document with the answers already sketched in, not as a design to reproduce faithfully.
The discovery record's own gap analysis. Each of these is a correction in §20 rather than an open question, because the documents agree — only the prototype dissents.
| Finding | What it means | Ref |
|---|---|---|
| Billing frequency is collected and discarded | Quote lines offer monthly, quarterly and annual; the save path writes Monthly for every recurring line, on both create and update. Subscription value normalises from that field at signature, so the defect silently rewrites commercial terms — and migrated data cannot be repaired from itself. | DR-19 |
| Approval checks are theatre | Of three checks shown to an approver, only the discount limit is computed. Margin thresholds and pricing conflicts are hardcoded to pass. | DR-04 |
| Statuses that are read but never written | Ready for deployment is filtered on, colour-coded and gates actions — and nothing sets it. Expired is offered as a filter; quotes carry an expiry date and nothing acts on it. Expiry is a date with no process. | — |
| Stage semantics are name-based | Stages named literally Won and Lost carry special behaviour. Enforced, and fragile. | DR-20 |
| Pipelines are per person | Stages are seeded per person, which conflicts directly with a forecast that reads across everyone. | DR-21 |
| Merge is destructive | Company merge re-points every related record and deletes the duplicates. The single most destructive operation in the application. | DR-22 |
| Brands have no identity | Free text on companies, with autocomplete — but the forecast groups by them. | DR-14 |
| Development bypass and demo seeding | A mode granting every permission, auto-signing-in a super-admin and self-promoting it. Demo data seeds on approval; a button generates random subscriptions. | DR-24 |
| No lifecycle end | Nothing terminates a subscription. No cancellation, no churn, no non-renewal. For a subscription business this is the largest single gap. | DR-05 |
| No concurrency handling | Every save is last-write-wins. Nothing warns that a record changed underneath. | — |
| Almost no audit trail | Stage changes and quote approvals are logged. Nothing else — not price changes, not permission changes, not merges, not deletions. | — |
| No FX rate governance | Conversion happens throughout; where rates come from, and as of when, is not visible anywhere. | DR-07 |