Delivery plan · extraction → phase 1 → phase 2
Two phases, ordered so that each one replaces a whole system rather than half of two. This document is phase 1 — it builds the ConnectIQ CRM and retires HubSpot, leaving Airtable running operations exactly as it does today, untouched and unconnected. Phase 2 takes on the deployment domain and absorbs Airtable table by table, and has its own page.
Through phase 1, data flows into ConnectIQ from HubSpot alone, and nothing flows back out. Airtable is not connected at all — DR-01 put locations in ConnectIQ and DR-18 collapsed with it, taking the read sync out of the release. That constraint is what keeps the first release from acquiring a second system's failure modes before it has earned its own — and phase 2 is what earns the right to remove it.
This revision folds in the Lovable extraction — a UX discovery record and a conceptual model, read out of the prototype and written in business language. It supplies the entities, the cardinalities and the rules the plan previously gestured at. It also supplies fifty-four decisions; twenty-one still need an answer — fifteen here, five on phase 2, one on the development strategy. The seven original blockers are all answered — and the milestone DR-31 added is now M7, which brought eight decisions with it. Every open entry in §19 now carries a recommended answer, so the work is agreeing or disagreeing rather than starting from the documents.
DR-01 is closed and it moved the release boundary. ConnectIQ owns locations, so the Airtable read sync left phase 1 entirely — one integration instead of two, and that one is migration code that gets deleted. §18 has what went where.
Sections 01–07 settle the release boundary and the decisions the build cannot cheaply revisit. Sections 08–10 are the model the extraction established — the entities, the one relationship everything else derives from, and where each entity lands in the domain map. Sections 11–17 are the build itself: what ships, in what order, and what "done" means. Section 18 points at phase 2, and 19–20 are what is still undecided plus the prompts that run each milestone.
How anything gets built — the stack, environments, branching, testing, hosting and the quality bar every milestone is held to — moved to the development strategy. 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 · Context
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.
| 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 §08. The code is not the build. |
| ConnectIQ | — | Accounts + commerce system of record | New. Owns everything HubSpot owned, plus quoting, subscriptions and forecast. |
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 · Target state
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 to nothing. It is not connected to Airtable at all. Airtable keeps running operations on the other side of a boundary this release does not cross.
The release writes to nothing it does not own. Every integration 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 integration rather than two, and the one it has is finite migration 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 §17 — for the whole of phase 1, operational data is only in Airtable.
03 · Boundaries
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.
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. §10 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. HubSpot quotes are not migrated — see 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. §14 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.
04 · Integration
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 §03 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.
05 · 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. |
06 · 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.
07 · Decisions
Two have since been superseded by the architecture, and one by the development strategy. 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.
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 development strategy, 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 — development strategy §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.
08 · The extraction
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 §19 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 |
09 · The multiplication
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, units per location, 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.
Per-location economics × Rollout Periods (schedule) = 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 defined as software units per location × total locations. No other category contributes licences. That formula needs the two halves of the model kept separate, which is why collapsing them into one table is DR-08.
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.
10 · 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 §13.
Marks: ? the assignment is open, see §19, 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. |
| 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. |
| 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 §09. |
| 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 ? | cl_email_messages | M7 | DR-48 — same domain as activities, whichever that is; rename the prefix and change nothing else. 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 ? | cl_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 ? | cl_email_attachments | M7 | Metadata only — name, type, size. No file storage and no download endpoint. DR-50. |
Email template ? | cl_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 cl_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.
11 · The goal
Phase 1 is finished 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.
Read literally, that sentence sets the scope, and it is the tie-breaker for every argument about what belongs in the release.
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.
12 · Scope
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.
13 · Sequence
Ordered by dependency, not by visibility. Every milestone ends in something demonstrable to a rep, because a plan whose first four steps are invisible is a plan nobody can correct early. Each carries the decisions it is blocked on — unblock them before starting, not during.
The skeleton everything else assumes, and the point at which the decisions in sections 01–07 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.Blocked on · DR-17 · DR-24 · DR-26
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.Blocked on · DR-22
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 §17.
Blocked on · DR-15 · DR-20 · DR-21 · DR-31
Unglamorous and entirely blocking. The quote builder cannot be trusted until the numbers behind it are.
Blocked on · nothing — but see the cost-source risk in §17
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.Blocked on · DR-11 · DR-19 · DR-40
What a manager needs before they will agree to switch off HubSpot.
QuoteSigned, in their own domain — created awaiting activation, not active. DR-42.Blocked on · DR-05 · DR-09 · DR-10 · DR-16 · DR-33
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 §17 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.rpt_record_email_timeline, created WITH (security_invoker = true).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.Blocked on · DR-15 · DR-40 · DR-47 · DR-48 · DR-49 · DR-51 · DR-52 · DR-53 · DR-54
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.
Blocked on · DR-14 · DR-19 · DR-23 · DR-25
M4 can run in parallel with M3 — it touches almost nothing they share. M8's import work should begin as soon as M2 lands, because the import surfaces data-quality problems that are cheaper to fix while the schema is still soft. Nothing else in the list can safely overlap.
14 · Data model
Grouped by what they do. Every table ships with a domain prefix assigned using the tiebreak rule in the architecture; §10 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 packageactivities — one entity, typedactivity_links — four-way DR-15notes, tagsemail_messages, email_message_links DR-48email_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 — + expected activation month 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.
15 · 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.
16 · Migration & cutover
| 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. |
17 · 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. Thirty-six are still open; 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. §19 is the register; §20 is where it is enforced. |
| 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 §17's date risk 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 §12 and the corrections in §19 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. |
18 · Phase 2
Phase 2 used to be two sections here. It is now a separate document — phase 2 & deferred — carrying the deployment domain scope, the S1–S5 dependency order, the S-prompts, the five decisions that only bite after cutover, and the parking lot for everything cut from phase 1.
The reason is not tidiness. This document is used to run build sessions, and a prompt-driven session that scrolls past unbuilt work tends to build some of it. Everything left in this document is phase 1 and is meant to be built.
Quote lines carry a location from the start. A quote line that represents a deployable system is the thing a future ConnectIQ object is born from. Phase 1 writes com_quote_line_locations and nothing reads it — the single cheapest piece of forward compatibility available, and retrofitting it after a migration means reconstructing intent from history. DR-01 made this obligation cheaper: the column now points at owned cl_ master data rather than into a mirror a nightly sweep could mark absent.
Sections — phase 2 scope and phase 2 shape, in full. Decisions — DR-12, DR-35, DR-36, DR-37 and DR-41 left the register below; they keep their numbers, because the series is global rather than per-document. Prompts — S0 to S5 moved to crm/phase-2/prompts/. Parked — the Airtable read sync, the display-only jobs view, inbox and calendar sync, the customer portal, e-signature, activity analytics, and finance and billing, each with the trigger that would bring it back.
DR-10 and DR-11 stayed here despite sounding like phase 2 work — both are decided during phase 1, in M6 and M5 respectively.
19 · Register
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 development strategy — 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-four in total. Forty-six here — fifteen awaiting an answer, seven needing none, twenty-four closed. Five on phase 2, all open. Three on the development strategy, 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-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.
Needs · Sky + sales · before M3
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.
com_activities with a real four-way link table, and an rpt_ view for any timeline that crosses a prefix. Most activity volume is commerce-side — opportunities and quotes — and the prefix registry is locked, so inventing act_ would breach rule 1 of the standing context rather than resolve anything.
Binding whichever domain wins: do not flatten the four-way link. That part is not a preference.
cl_, if contacts and companies turn out to be the dominant anchors rather than opportunities.Needs · Sky · before M3 · may need an architecture change
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.Needs · Sky · with DR-02 · before M3
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 §15 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.
cl_. Decide the two together rather than in sequence.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 §17 — 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 §16. 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
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 is worth its own entry because it resolves the opposite way to DR-15's own recommendation.
cl_, and record it as an override
DR-15 recommends com_activities, with cl_ as the alternative, on the tiebreak of counting activities by link target. Email logging makes contacts the dominant target by a wide margin, which flips that count. DR-31 already says so in as many words: decide the two together rather than in sequence.
The design depends only on messages and activities sharing a prefix. If DR-48 resolves to com_, rename every cl_email_* table to com_email_* and change nothing else.
Say it is an override, not an agreement. A recommendation quietly reversed by a later entry is how a register stops being trustworthy.
Needs · Sky · with DR-15 · before M7 creates a table
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. cl_email_messages.provider_message_id and cl_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
M3 models activities as one entity with a type — call, meeting, email — with its own four-way link table. M7 builds a second message store with a second four-way link table. That overlap is not accidental and it is not yet resolved.
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.
1 · Capture also writes an activity row of type email, linked to the message. Last-contact-date keeps working untouched, at the cost of a row per message and two things to keep consistent.
2 · Re-derive last-contact-date from activities and messages. No duplicate rows; every consumer of that date has to learn about a second source. Recommended — it avoids two records of one event, which is the thing that rots.
3 · Retire the activity email type and drop manual email-activity logging. Cleanest model, but it deletes a behaviour M3 shipped.
Whichever is chosen, say which component renders the entity timeline — the M2 timeline, the new Email tab, or one merged view. Both, somehow is how a record ends up showing the same email twice.
Needs · Sky · with DR-15 and DR-48 · before a table is created
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 §13.
Quote lines offer monthly, quarterly and annual; the save path writes Monthly for every recurring line, confirmed on both create and update. Subscription value normalises from that field at signature, so the defect silently rewrites the commercial terms.
The migration must not assume the source values survived — they cannot be reconstructed from the data, so anything back-filled has to come from a human-checked source.
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.
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.
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. §08 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 §13 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.
Closed · Sky · plan unchanged, dates restated after M2
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. §17 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 §17
Raised by the answer to DR-04. Margin is computable, so cost is held — the M4 prompt already specifies it — but neither §15'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 §15'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. 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.
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.
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. §14 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 §17.
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 §17
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 development strategy. 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 development strategy §13
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.
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-15 · 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-48 · DR-49 · DR-51 · DR-52 · DR-53 · DR-54 · DR-15 · 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-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 |
20 · Build prompts
The register is only useful if a build session actually stops at one. These are the prompts that run each milestone, in crm/prompts/. Each is self-contained: the tables it creates with their domain prefix, the rules that are Enforced and where they came from, an explicit do not build list, the decisions it is blocked on, the exit criterion from §13 unmodified, and what has to be tested.
M1 to M8 only. The six S-prompts moved to phase 2 along with the phase they describe. The standing context below is shared by both and has not moved.
Every prompt ends with the same one: when you reach a blocked decision that has not been answered, stop and say so. Do not pick the option that makes the code easier. Every entry in the register is cheap to decide now and expensive to reverse once data has landed — and a build session that resolves one silently is exactly how the prototype acquired decisions nobody made.
crm/prompts/; the S-prompts are at crm/phase-2/prompts/.
1. Check what the milestone is blocked on in §19, and unblock it before starting rather than during. 2. In Lovable, load the two knowledge files once, then paste 00-prompt-header.md and the milestone prompt. In a session with no Knowledge panel, paste the full standing context, then the milestone prompt. 3. Treat the do not build list as exactly as binding as the scope list — scope creep from the prototype is a named risk in §17, and the deferred lists are the answer to it. 4. A milestone is not done because the code runs. It is done when it has been demoed to a real user of that surface.
21 · Execution tracker
The prompts in §20 say what to build; this says whether it happened. One tab per milestone, every task carrying the observation that closes it — not is it written but what did you watch it do. The exit criterion at the top of each tab is the one from §13, unmodified.
Owners matter here. Lovable is generated in the build session. Sky is yours and mostly has a clock on it — DNS propagation, a plan upgrade, a setting. Repo is CI and lint, added to the repository after it is born from the Lovable project, not by prompting.
Ticks are shared — one record everyone loads, kept in a public GitHub Gist rather than in this repository. Only the edit token below can change it; without it the boxes are read-only. It is a working surface, not a record: a milestone is done when it has been demoed to a real user of that surface, which is a conversation rather than a checkbox.
Decision chips are reconciled against §19, not copied from the prompts — several prompts list entries that have since been answered. §19 draws the distinction the prompts flatten: an orange Settle first must have an answer before the milestone starts, while Honour is a correction the milestone must not reintroduce, or an answer it needs before it finishes rather than before it begins. Nobody has to choose anything for the second kind.
One row disagrees with itself. DR-50's own card reads blocks M7, but §19's own table and the M7 prompt both omit it from the milestone's list. That is flagged on the M7 tab in red rather than silently resolved — the two say different things and one of them is wrong.
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.
An admin can invite a colleague, approve them, and see the empty app with the right navigation for their role. 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.
| 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 | |
Lovable branch switching enabled in Labs, and staging exists — turn it on at Lovable → Settings → Account → Labs, then confirm staging is present in the repo before anyone cuts a feature branch from it. The active branch belongs to the project, not the person: one person drives Lovable, anyone else works their branch in an editor. |
Switching branch in Lovable moves the repo with it, and no feature branch is cut from main — check each branch’s fork point resolves to staging. Two people driving Lovable on two branches at once is the failure this setting exists to make visible. |
Sky | |
| Two-way sync confirmed on a non-default branch — cut a throwaway branch, make it Lovable’s active branch, edit in Lovable, then edit the same branch in an editor. Lovable documents that it syncs whichever branch is active; a secondary source claims two-way sync applies only to the default branch. Both cannot be true and the whole promotion path assumes the first. | A commit made in Lovable on the throwaway branch lands on that branch in GitHub, and an editor commit on it appears back in Lovable. Ten minutes now, against discovering it in M3 with three people mid-feature. | Sky | |
Knowledge loaded — paste style-kit/LOVABLE_WORKSPACE_KNOWLEDGE.md into Lovable → Workspace Knowledge and style-kit/LOVABLE_PROJECT_KNOWLEDGE.md into Project Knowledge, whole, both of them. The workspace file carries the binding rules and says nothing phase-specific; the project file carries the stack, the environments and the visual reference values and overrides no rule. One without the other is half a brief. |
Ask Lovable to state the eleven domain prefixes back, and to name where the visual source of truth lives, before prompting anything. A partial answer means the paste did not take — each slot caps at 10,000 characters and both files are sized just under it, so a counter reading exactly 10,000 is truncation, not a fit. | 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 Lovable and never into the repo. 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 |
| Lovable project created, Supabase and GitHub connected — the repo is born from the project. Create the Lovable project, connect Supabase, then use Lovable’s GitHub connection to create the repository; CI, the lint rules, the RLS check and the migration dry-run are added to the repo it produces. Lovable cannot import an existing repository, so the reverse order has no recovery. | The repo exists on GitHub with the project’s history and Vercel deploys from it, connected to the production branch only. No attempt was made to import an existing repo, which Lovable cannot do. Once Vercel is connected, Lovable’s Publish button is never used again. | Lovable | |
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 prompt, and no status field anywhere is written or compared as a raw string. Ask Lovable to list the files it intends to create and confirm each stays inside one domain boundary. | Lovable | |
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 the ones Lovable generates; 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. | Repo | |
| 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. |
Repo |
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. | Lovable | |
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. Settings, user management and imports are admin-only by role and need no permission flag of their own. |
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. | Lovable | |
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. |
Lovable | |
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. | Lovable |
| Email + password and Google OAuth, no anonymous sign-in — enable both providers in Supabase → Authentication → Providers and leave anonymous sign-ins off. 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. |
Lovable | |
The gate — awaiting_approval / approved / revoked on plat_profiles.access_status. A new sign-in lands in awaiting_approval and gets a status card and a sign-out button, nothing else. An admin moves people between the three states, approval sends the invitation email through Supabase Auth on Resend SMTP, and revocation is immediate. |
A new sign-in sees a status card and a sign-out button: no navigation, no data, and the API returns nothing either. Revoke somebody mid-session and their next request fails, rather than their next sign-in. | Lovable | |
Role assignment single-by-precedence — assigning one strips the others, so plat_user_roles holds at most one row per person. Only an admin assigns, through a server path: no client path writes plat_user_roles or plat_role_permissions for the current user. 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. |
Lovable | |
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. |
Lovable |
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. | Lovable | |
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. |
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. |
Lovable | |
| 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. |
Lovable |
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. |
Lovable | |
plat_email_queue, plat_email_send_log, plat_suppressed_emails, plat_email_unsubscribe_tokens, drained by the same scheduled worker as the outbox. Only two sends exist in phase 1 — quote approval requests and user invitations — but the queue, the suppression list and the unsubscribe tokens are foundation, not something bolted on when the third send arrives. |
Application mail goes through the queue and the worker via the Resend HTTP API — not SMTP, which a serverless function must not hold open. A queued send to an address in plat_suppressed_emails is never dispatched, and the attempt is visible in plat_email_send_log. |
Lovable | |
Resend bounce and complaint webhook — a public endpoint Resend posts delivery events to, writing the rows in plat_suppressed_emails. The suppression list is fed by this webhook, not by hand: it is what stops the system re-sending to an address that has already hard-bounced and taking the sending domain’s reputation with it. |
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, and the raw event lands in int_webhook_events — never plat_domain_events. |
Lovable |
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. | Lovable | |
| 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. | Lovable | |
| 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. | Lovable |
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. |
|||
A rep can find any customer, see its sites and people, and edit a record without leaving the list.
| 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. |
Lovable | |
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. |
Lovable | |
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. 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. | Lovable | |
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. |
Lovable | |
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. | Lovable | |
| 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. | Lovable | |
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 owns the polymorphic-link shape, notes included. |
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. | Lovable | |
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. |
Lovable |
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. |
|||
A rep works one real deal end to end in ConnectIQ while HubSpot still holds the rest.
| 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. |
Lovable | |
| 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. | Lovable | |
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. |
Lovable | |
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). | Lovable | |
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. | Lovable | |
| Activities with type, due date, assignee, and links to any entity — one entity carrying a type of call, meeting or email, 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. DR-15 decides which domain owns the table — settle it first. | 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. |
Lovable |
| 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. |
|||
Every product a rep needs to quote exists, priced in every currency they sell in.
| Task | How you know it is done | Owner | |
|---|---|---|---|
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 §13 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. |
Lovable | |
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. |
Lovable | |
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 §17, not an open decision. |
Lovable | |
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. |
Lovable | |
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. | Lovable | |
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. |
Lovable |
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. | |||
A real quote is built, approved and sent from ConnectIQ, and its PDF is one a customer would accept.
| 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. | Lovable | |
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. |
Lovable | |
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. |
Lovable | |
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. | Lovable | |
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. | Lovable | |
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. |
Lovable | |
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. | Lovable | |
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. | Lovable | |
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. |
Lovable | |
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. | Lovable | |
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. | Lovable | |
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. | Lovable | |
| 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. | Lovable |
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. |
|||
A manager runs a forecast conversation from ConnectIQ and the numbers survive scrutiny.
| 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. |
Lovable | |
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. | Lovable | |
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. |
Lovable | |
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. |
Lovable | |
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. |
Lovable | |
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. | Lovable | |
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. |
Lovable |
| 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. |
|||
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_.
| 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. |
Lovable | |
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. |
Lovable | |
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. |
Lovable | |
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 cl_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. |
Lovable | |
Automatic association — contact, its company, and an opportunity only when there is exactly one open: the capture worker writes one row per target into cl_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 whichever domain wins. The prefix is DR-48’s; whether capture also writes an activity, and so whether a contact’s date of last contact still moves, is DR-54’s. |
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. | Lovable | |
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. |
Lovable | |
rpt_record_email_timeline created WITH (security_invoker = true) — the opportunity and quote Email tabs join cl_ messages to com_ 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; contact and company 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. |
Lovable | |
Compose and reply from a record, from shared or private templates — cl_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 cl_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 cl_email_messages has no row at all. |
Lovable | |
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. |
Lovable |
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 cl_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 cl_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. |
|||