Proposals — Sales-Side Project Modeling (Architecture & Build Plan)
Status: Planning (2026-06-27). Owner-approved direction; chunked for incremental delivery (the proven George→Lenny cadence — spec each phase when the prior lands). Purpose: Let an agency owner scaffold and price a prospective engagement during a sales conversation — using the agency's real cost data, people, disciplines, and target margin — without polluting operational data and without surfacing anything to the team until the deal is won. This is the "see around the corner" surface clients keep asking for.
1. The problem (why this exists)
Owners told us, repeatedly, that they want to use VERA during the sale. When a prospect gets specific, the owner wants to shape the engagement the same way they'd run a real one:
"Our price is $100,000. We've kept the 5:1 ratio intact by projecting $20k of people cost — two designers for six weeks, a strategist part-time, a vendor for the build."
VERA already knows everything needed to answer that honestly: real cost rates, the agency blended rate, each person's discipline, the target Labor Efficiency Ratio, the committed-cost and Forecasted-P&L math (shipped #1559). The owner wants to point that engine at a deal that does not exist yet.
The trap is contamination. A prospective engagement must not:
- leak into the operational numbers — utilization, capacity, availability, "team of N" denominators, dashboards, reminders, the morning briefing, P&L. A project that may never sell cannot move any real figure.
- signal anything to the team — nobody gets an envelope, a DM, a morning-briefing line, or an audit-channel post for speculative work. The people being modeled must not know a proposal exists until (and unless) it converts.
What this is not
Proposals is not a CRM. No contacts, no pipeline stages, no email tracking, no activity log, no deal-flow reporting. It is a project modeler for a named prospect — the shape and price of one possible engagement — and nothing more. If a feature request smells like CRM, it belongs in a different product.
2. The two hard invariants (load-bearing)
Everything below is in service of these two. They are non-negotiable and every phase must preserve them.
No operational pollution. Proposals live in their own data domain (
proposals, and laterproposal_roles) that no operational query reads. Isolation is structural, not a filter: there is nothing to exclude because nothing joins to it.readProjects,readBudgets,readTimeEntries, the dashboard CTEs, the forecast queries, capacity, reminders — none of them reference the proposals tables, so a proposal cannot appear in any aggregate by construction.No user-facing signal until conversion. Proposals are owner/admin-only. The endpoints are console-only (defined in
mcp-server.js, called viacallEndpoint, absent fromMCP_TOOLS), so they are unreachable from Slack — Claude has no tool for them and a user could never trigger one. No DMs, no morning-briefing lines, and no audit-channel post at all — not even a dollar-free one, because the audit channel is team-visible and the prospect's existence must stay private until conversion. A team member learns of the work only when a proposal is Won and explicitly converted into a realplannedproject (Phase 2).
Reading real data is allowed; writing back is not
The owner explicitly wants realistic modeling, so the builder may read real user data — discipline, current cost rate, availability — to populate and price the model. This is one-directional: proposals read from users / rate_history / availability, and write only to the proposals domain. Nothing in the proposals flow writes to users, budgets, projects, time_entries, or any operational table (until an explicit Phase-2 conversion, which is a deliberate, owner-initiated, audited act).
3. The decision: a separate in-app data domain
Broaden VERA with a new, self-contained data domain — proposals (Phase 0) and proposal_roles (Phase 1) — that reuses VERA's existing financial engine and lives behind the same auth, RLS, and console the owner already uses. Isolation is free because no operational query references the new tables.
Why this shape
- Reuse, don't reinvent the crown jewels. Pricing a proposal is exactly the Forecasted-P&L question already solved on the project detail page (#1559): revenue vs. fully-committed people cost, with LER and a target-ratio check. We reuse the same primitives —
TARGET_LER,getAgencyBlendedCostRate(), "people budget = contract ÷ target LER", the revenue/cost/LER/profit panel grammar. No second financial engine to keep in sync. - Inherit the platform. Same OAuth/session, same per-tenant RLS (
requestContext.run), same owner-gating idiom (isOwnerLevel), same server-rendered console. A new owner-only page slots in beside Reports / Forecast / Portfolio. - The Won handoff is an in-process status flip, not a cross-system integration (Phase 2).
Rejected alternatives
| Alternative | Why rejected |
|---|---|
Ghost users (one shadow users row per real person, the owner's first instinct) | Inverts the safety model. You'd create real users/budgets rows, then try to exclude them from every aggregate — utilization, capacity denominators, the morning briefing, reminders, dashboard, forecast. Every existing and future aggregate becomes a place you must remember to filter; one missed filter leaks a phantom project to the team. It also fights identity invariants (Slack-ID validation, display-name auto-sync, isDeliveryResource). Pollution-by-default with exclusion bolted on is exactly backwards from isolation-by-construction. |
| A separate companion app that talks to VERA | Must re-implement or re-read the crown-jewel math (LER, blended rate, committed cost) behind a second RLS/auth/deploy boundary, plus data sync, plus turning the Won→handoff into a brittle cross-system integration. All of the cost of a new system, none of the benefit over an in-app domain that already has the data and the math in-process. |
4. Data model
proposals (Phase 0)
One row = one possible engagement for one named prospect.
| Column | Type | Notes |
|---|---|---|
tenant_id | uuid | PK part; RLS GUC |
id | integer | PK part; app-assigned MAX(id)+1 per tenant (matches disciplines/liabilities) |
name | text NOT NULL | Working title, e.g. "Acme Q3 Rebrand" |
prospect_name | text | Free-text prospect label — deliberately not a clients FK (no CRM, no clients pollution). Nullable. |
project_type | text NOT NULL DEFAULT 'Fixed Price' | CHECK IN ('T&M','Fixed Price','Pro Bono','Internal') — the four creatable types, so a Won proposal maps cleanly to create_project in Phase 2 |
contract_value | numeric(12,2) | The price being proposed. Nullable while drafting. |
target_ler | numeric(6,2) | Per-proposal target ratio override. Nullable → costing uses the code TARGET_LER (5). Lets an owner model a different ratio for one deal without touching the global constant. |
status | text NOT NULL DEFAULT 'draft' | CHECK IN ('draft','won','lost','converted'). Phase 0 uses draft; won/lost/converted anchor Phase 2. |
notes | text | Freeform. Nullable. |
created_by | text NOT NULL | Slack ID of the owner (matches liabilities.created_by). |
created_at | timestamptz NOT NULL DEFAULT now() | List sorts newest-first by id. |
converted_project_id | integer | Nullable soft reference to projects.id (no hard FK) — set once at Phase-2 conversion; NULL otherwise. |
RLS: ENABLE + FORCE ROW LEVEL SECURITY + the standard tenant_isolation policy (tenant_id = current_setting('app.tenant_id', true)::uuid), GRANT SELECT, INSERT, UPDATE, DELETE … TO vera_app. No sequence grant (app-assigned integer id).
proposal_roles (Phase 1 — shipped #1566)
One row = one staffing line on a proposal.
| Column | Type | Notes |
|---|---|---|
tenant_id | uuid | PK part; composite FK to proposals |
id | integer | PK part; app-assigned MAX(id)+1 per tenant |
proposal_id | integer NOT NULL | Composite FK → proposals(tenant_id, id) ON DELETE CASCADE |
discipline_id | integer | Nullable; maps to disciplines.id — not a hard FK so archived disciplines survive |
user_id | integer | Nullable; "model as this specific person"; overrides discipline for costing |
planned_hours | numeric(10,2) NOT NULL DEFAULT 0 | Hours for this line |
billing_rate | numeric(10,2) | Nullable; used only for T&M revenue per line |
task | text | Optional label (e.g. "Design", "Strategy") |
created_at | timestamptz NOT NULL DEFAULT now() |
Cost-rate precedence per line:
- Specific person (
user_id != null) → that person's current cost rate fromrate_history(latesteffective_from ≤ today, id-tiebreak). Person with no rate row →$0.00/hr,rate_source: 'person'(visible and intentional). - Discipline-blended (
discipline_id != null, no user) → mean of current cost rates of active delivery resources in that discipline (rates > 0 only). Discipline with no rated resources → falls through to agency. - Agency-blended (neither, or discipline with no rates) →
getAgencyBlendedCostRate()(trailing-90d delivery-resource weighted mean).
Revenue rule:
- T&M → Σ(
planned_hours×billing_rate) per line (requires billing_rate set per line). - Fixed Price / Pro Bono / Internal →
contract_value(Pro Bono/Internal have null/0 → $0 revenue).
People budget = revenue ÷ (target_ler ?? TARGET_LER). LER = revenue ÷ cost. Margin = (profit / revenue) × 100.
Implementation: lib/proposal-costing.js — computeProposalPnl(proposal, roles) (pure, unit-tested) + resolveRoleRates(roles) (DB-backed, calls getAgencyBlendedCostRate() before entering withTenant to avoid nested transaction). One-directional import: proposal-costing imports forecast; forecast never imports proposal-costing — the structural guard that keeps operational reads unable to reach proposal data.
RLS, PK, grant: same pattern as proposals (migration 0028).
5. Phasing roadmap (each row = one future Lenny issue)
Vertical slices; spec each when the prior lands. Do not open them all at once.
| Phase | Deliverable | Proves / de-risks | Depends on |
|---|---|---|---|
| P0 — Data domain + console shell ✅ shipped #1563 | proposals migration (+ RLS), DAL readers/writers, console-only owner-gated MCP endpoints (create_proposal / list_proposals / update_proposal / delete_proposal), and /console/proposals — list + create/edit/delete the proposal shell (name, prospect, type, contract value, target LER, status, notes). No staffing, no costing yet. | The whole new domain end-to-end and both invariants: a brand-new tenant-scoped table with RLS; owner-only gating; a console page; Slack-unreachability (endpoints absent from MCP_TOOLS); structural operational invisibility. Highest-leverage de-risk — keep it a shell. | Console v1 |
| P1 — Staffing + live costing ✅ shipped #1566 | proposal_roles migration + DAL + role endpoints; a proposal detail page with a Cost breakdown table (internal staffing rows + vendor rows unified, inline Edit/Remove, two Add buttons) and a live Proposed P&L card reusing the Forecasted-P&L grammar (revenue vs people cost, LER, target-ratio check, profit/loss spill). Reads real cost rates / blended rate; writes only to proposal_roles. | The crown-jewel math re-pointed at proposals — the actual "$100k, 5:1 intact" answer. | P0 |
| P2 — Won → convert ✅ shipped #1568 | Mark a proposal won; a Convert action creates a real, active project via create_project, flips the proposal to converted (recording converted_project_id), best-effort-enables the staffing-line tasks, and shows an envelope-assignment checklist. The first audited moment — one dollar-free audit post. | The deliberate, owner-initiated boundary crossing from speculative to real. | P1 |
| P3 — Capacity simulation (opt-in, read-only) ✅ shipped #1570 | An opt-in overlay that shows what a proposal's staffing would do to capacity if it landed — read-only, never written, never shown to the modelled people. | "Can we even staff this?" during the sale, without committing anything. | P1 |
| P4 — Vendor liabilities / make-vs-buy ✅ shipped #1576 | proposal_liabilities table + DAL + three console-only endpoints; Vendors & liabilities table on the detail page; vendor cost and hours fold into the Proposed P&L and a split Envelope + liability budget bar. | "What does the outsourced portion cost us?" — make-vs-buy in one view. | P1 |
| Deadline + effort-vs-timeline ✅ shipped #1577 | proposals.deadline column (nullable date); Deadline field on create/edit forms and the list table; Deadline cell in the detail header; Effort vs. timeline card (proposed hours ÷ working weeks = required pace; chip shows full-timers needed); capacity-sim Weeks defaults to the deadline span; convert form pre-fills the deadline. | "How fast does the team need to move?" visible during the sale, before committing. | P1 |
P0–P4 plus the deadline have all shipped. The proposals arc is feature-complete: a separate in-app data domain, staffing + live costing, vendor liabilities, Won→convert, opt-in capacity simulation, and effort-vs-timeline pace — with both hard invariants (no operational pollution, no Slack reach) intact throughout.
7. Conversion (Phase 2 — shipped #1568)
Conversion is the one place a proposal touches operational data, and it is deliberate, owner-initiated, and audited.
Flow. won proposal → owner clicks Convert to project on /console/proposals/:id → confirm form (project name, client [defaults to the prospect], deadline, hours budget [defaults to Σ staffing-line hours], contract value for Fixed Price, optional billing rate for T&M) → POST /console/proposals/:id/convert.
Orchestration (console route, no new MCP endpoint). The route composes existing endpoints via callEndpoint: get_proposal_costing (load proposal + roles) → create_project (real, active project; client auto-created from the prospect via findOrCreateClient) → best-effort enable_task_for_project for each distinct staffing-line task → update_proposal (status converted + converted_project_id). Because it is orchestrated in the console and adds no MCP tool, proposals remain unreachable from Slack.
Audit. Exactly one postConsoleAudit message — the first audited moment in a proposal's life. Per Rule 21 it carries no dollar figures (the project's contract value is never named in the audit).
Guards. Only a won proposal converts, and never twice. create_project runs first (the irreversible act); if it fails — including a duplicate project name (returned as success:false) — the proposal stays won and nothing is flipped.
After conversion. Status is converted; converted_project_id links to the project. The detail page shows a staffing checklist (each modelled role → person/discipline, hours, task, and what to do) plus a link into the project to create the envelopes. Envelopes are not auto-created — the checklist is guidance; the owner assigns them with the project's existing Add-envelope flow.
Hardening (#1600) — duplicate-project guard, status lock, and nomenclature. Three related changes shipped together:
Duplicate-project guard (load-bearing): the convert route now checks
converted_project_id != nullbefore checking status. Becauseconverted_project_idis written once at conversion and never cleared by a status edit, this prevents a secondcreate_projectcall even if an owner manually edits the proposal's status back towonordraftvia the Edit form. The priorstatus === 'converted'guard is preserved as a belt-and-suspenders check; the link guard runs first.Status-field lock: the Edit-proposal modal (on both the list page and the detail page) replaces the status
<select>with a static "Closed (Won)" label whenconverted_project_idis set. This eliminates the silent-revert bug where saving any field on a converted proposal would default the select todraft(becauseconvertedwas never an option in the dropdown) and overwrite the stored status."Closed (Won)" / "Closed (Lost)" nomenclature (display-only):
wonandconvertedboth display as "Closed (Won)";lostdisplays as "Closed (Lost)";draftremains "Draft". Stored status values (draft/won/lost/converted) are unchanged — no migration, no CHECK-constraint change. The list page also surfaces aproject →link next to "Closed (Won)" whenconverted_project_idis set, so owners can navigate directly to the project from the proposals index. The "Converted" card on the detail page now keys onconverted_project_id != null(not onstatus === 'converted'), so it renders correctly even for proposals whose status was reverted.
Closed-proposal lock (#1604). A proposal is "closed" (_proposalLocked) when converted_project_id != null || status === 'won' || status === 'lost'. Closed proposals are read-only in the console:
- UI: all Edit/Add/Remove buttons and their modals are hidden on the detail page (scalar Edit, Add staffing line, per-role Edit/Remove, Add vendor, per-vendor Edit/Remove) and on the list page (per-row Edit button). Staffing and vendor rows still render — a closed proposal is viewable, not hidden. On the list, the Delete button is hidden only for
converted;won/lostrows keep Delete as an escape hatch (an owner who closed it by mistake can still remove it, since they can no longer edit it back todraft). - Server: every write route checks
_isProposalLockedvia_loadProposalByIdand redirects with an error if the proposal is closed. The convert route (POST /proposals/:id/convert) is exempt — conversion is not an edit, and it already guardsconverted_project_idindependently. - Immutability for
converted:_isProposalConvertedadditionally blocksPOST /proposals/delete— a proposal that produced a real project cannot be deleted. - Closing mechanism stays the same: the Edit modal's status dropdown (
Draft/Closed (Won)/Closed (Lost)) works normally while the proposal is still adraft. Once closed, that modal is no longer shown.
6. What this does not change
- Operational tables and queries —
users,projects,budgets,time_entries, dashboards, forecast, capacity, reminders, the morning/evening briefing — all untouched. A proposal cannot move any of them. - The RLS / tenant-isolation contract — the new tables use the identical pattern; no change to how isolation works.
- The Slack surface — proposals are console-only; Slack stays exactly the
user-role surface. Claude gets no proposals tool. - Financial isolation — proposals are owner/admin-only; managers and users never see them. The existing endpoint-as-security-boundary model is reused, not re-implemented.
- The financial engine —
TARGET_LER,getAgencyBlendedCostRate(), committed-cost and Forecasted-P&L math are reused, not forked.
8. Capacity simulation (Phase 3 — shipped #1570)
The last phase, and the one most exposed to the isolation contract — so it is built to need it least.
What it does. On /console/proposals/:id (owner/admin-only) a Capacity simulation card lets the owner choose a forward window (From week + Weeks, 1–12) and press Simulate. The card then shows, per discipline: Free (the team's unused capacity over the window — Σ of each discipline's delivery resources' available − committed per cell, the same figures the Capacity page shows), Proposed (the proposal's staffing hours mapped to that discipline), After (Free − Proposed), and a Fits / Tight / Short status, plus an aggregate verdict. Tight means under 15% headroom; Short means negative — or a discipline with demand but no people at all.
Why discipline-grained. At sale time the question is "do we have the capability and the slack", not "is this specific person free on these exact dates". A person-pinned staffing line folds into that person's discipline; per-person booking is deferred to envelope-creation time, after Convert.
Opt-in = free by default. The simulation computes only when ?sim_weeks is present in the request (the Simulate button is a GET form). A plain page load renders the form + a hint and runs no capacity query — the detail page stays as cheap as before.
How it keeps the invariants — more cleanly than P1. The capacity engine is reused in-route, exactly like /console/capacity: lib/console-routes.js calls queryCapacityWeeks(...) directly, so there is no new MCP endpoint, nothing is added to MCP_TOOLS, and Slack stays unreachable. The math lives in the pure transformer lib/proposal-capacity.js (computeProposalCapacitySim), which imports nothing operational — the route fetches the capacity snapshot and hands it in as plain data. lib/availability.js never imports the helper; the dependency arrow points only into it. The simulation writes nothing, posts nothing, notifies nobody — it is the most read-only thing in the domain.
No schema change. Pure compute over existing data (the proposal's roles + the live capacity query). No migration.
9. Vendor liabilities — make-vs-buy in the sale (Phase 4 — shipped #1576)
The gap this fills
A proposal in Phases 0–3 can only model internal staffing: hours × cost rate per discipline or person. Real engagements often include a vendor commitment — a fixed-fee outsourced line (print, translation, photography, specialist sub-contractor). Without it, the Proposed P&L under-states cost, over-states margin, and the "can we staff this?" simulation is silent on the make-vs-buy dimension.
Phase 4 adds a proposal_liabilities table and folds vendor agreed amounts + hours into the costing engine, mirroring the project detail page's "Envelope and liability budget" pattern.
Data model: proposal_liabilities
One row = one vendor commitment on a proposal:
| Column | Type | Notes |
|---|---|---|
tenant_id | uuid | RLS key |
id | integer | App-assigned (MAX+1); composite PK with tenant_id |
proposal_id | integer | FK → proposals (tenant_id, id) ON DELETE CASCADE |
vendor_user_id | integer | FK → users (tenant_id, id); must have employment_type = 'vendor' |
description | text | What the vendor delivers |
agreed_amount | numeric(12,2) | Fixed fee; folds into cost |
agreed_hours | numeric(10,2) | Hours the vendor contributes; folds into total hours for LER/make-vs-buy |
created_at | timestamptz | — |
Same isolation contract as proposal_roles (migration 0028): composite PK, RLS enabled + forced, grant to vera_app, app-assigned id (no sequence).
The vendor-identity decision
A proposal_liabilities row references an existing users row with employment_type = 'vendor'. The Add/Edit modal's Vendor field is a dropdown of current vendor users. Onboarding a brand-new vendor still happens on the Users page first. This was the same decision made for the production liabilities table and is carried forward for consistency.
Make-vs-buy costing
computeProposalPnl(proposal, roles, liabilities = []) now accepts a third parameter (backward-compatible default []):
committed_envelope= Σ (planned_hours × cost_rate) — internal labour cost (unchanged)committed_liability= Σ agreed_amount — vendor fixed feescost=committed_envelope + committed_liabilityvendor_hours= Σ agreed_hourstotal_hours= internal hours +vendor_hoursler= revenue / cost — now reflects the true make-vs-buy cost
All existing callers omit the third argument and see identical results (both new fields are 0, cost and total_hours are unchanged).
Console surface
Cost breakdown table on /console/proposals/:id: a single unified table with columns Name · Type · Hours · Effective rate · Cost · [Billing rate — T&M only] · Actions. Internal staffing rows show Staffing type and cost rate/source; vendor rows show Vendor type with fixed fee of $X agreed. Inline Edit/Remove on every row; + Add staffing line and + Add vendor buttons in the card header. Empty-state row when no rows of either kind. Total row reuses pnl.total_hours/pnl.cost (includes vendor agreed hours/amount). PRG routes: POST /console/proposals/:id/liabilities/{new,update,delete} with ?saved=liability-{added,updated,removed} notices. When no vendor users exist, a muted note directs the owner to the Users page.
Envelope (and liability) budget bar (below Proposed P&L): a faithful port of the project detail page's bar. Split bar: Envelopes share (brand colour) + Vendors share (amber) vs the people budget (contract ÷ TARGET_LER). Red overflow band when over. Legend shows Envelopes $X · Vendors $Y when liabilities are present. When people_budget = 0 (e.g. T&M with no billing rate, Pro Bono, Internal) the card is hidden.
Invariants (unchanged)
- Console-only — three new endpoints are NOT added to
MCP_TOOLS; Slack stays unreachable. - Audit-silent — no audit-channel message on create/update/delete. The first audited moment in a proposal's life remains conversion.
- No operational pollution —
lib/dashboard.js,lib/forecast.js,reminders.js,lib/availability.jsnever referenceproposal_liabilities.lib/proposal-costing.jsimports FROMforecast.jsonly (one-directional);forecast.jsnever imports from it.
10. Deadlines (shipped #1577)
Proposals now carry an optional deadline (proposals.deadline, a nullable date column, migration 0032). The deadline surfaces three places:
Effort vs. timeline card on
/console/proposals/:id— shows the proposed hours (Σ staffing-line hours, including vendor agreed hours) divided by the working weeks from today to the deadline, net of weekends and company holidays. The result is the required pace (h/wk and h/day), plus a chip estimating how many full-time people that pace implies. When the deadline has passed, the card says so; when no deadline is set, it shows guidance instead.Capacity-simulation Weeks default — when a deadline is set, the sim-window Weeks field defaults to the span from today to the deadline (clamped 1–12), with a one-line hint explaining the default. An explicit
?sim_weeksquery param always wins. The sim remains opt-in (nothing computes on a plain page load).Convert-to-project pre-fill — the Convert form's Deadline field is pre-filled with the proposal's deadline, removing one manual copy-paste.
The pace math is computed in the console route via the same countNetWorkdays engine as the project detail page. Nothing is written; nothing reaches Slack. The proposals table remains operationally invisible.