Skip to content

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.

  1. No operational pollution. Proposals live in their own data domain (proposals, and later proposal_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.

  2. No user-facing signal until conversion. Proposals are owner/admin-only. The endpoints are console-only (defined in mcp-server.js, called via callEndpoint, absent from MCP_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 real planned project (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

AlternativeWhy 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 VERAMust 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.

ColumnTypeNotes
tenant_iduuidPK part; RLS GUC
idintegerPK part; app-assigned MAX(id)+1 per tenant (matches disciplines/liabilities)
nametext NOT NULLWorking title, e.g. "Acme Q3 Rebrand"
prospect_nametextFree-text prospect label — deliberately not a clients FK (no CRM, no clients pollution). Nullable.
project_typetext 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_valuenumeric(12,2)The price being proposed. Nullable while drafting.
target_lernumeric(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.
statustext NOT NULL DEFAULT 'draft'CHECK IN ('draft','won','lost','converted'). Phase 0 uses draft; won/lost/converted anchor Phase 2.
notestextFreeform. Nullable.
created_bytext NOT NULLSlack ID of the owner (matches liabilities.created_by).
created_attimestamptz NOT NULL DEFAULT now()List sorts newest-first by id.
converted_project_idintegerNullable 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.

ColumnTypeNotes
tenant_iduuidPK part; composite FK to proposals
idintegerPK part; app-assigned MAX(id)+1 per tenant
proposal_idinteger NOT NULLComposite FK → proposals(tenant_id, id) ON DELETE CASCADE
discipline_idintegerNullable; maps to disciplines.id — not a hard FK so archived disciplines survive
user_idintegerNullable; "model as this specific person"; overrides discipline for costing
planned_hoursnumeric(10,2) NOT NULL DEFAULT 0Hours for this line
billing_ratenumeric(10,2)Nullable; used only for T&M revenue per line
tasktextOptional label (e.g. "Design", "Strategy")
created_attimestamptz NOT NULL DEFAULT now()

Cost-rate precedence per line:

  1. Specific person (user_id != null) → that person's current cost rate from rate_history (latest effective_from ≤ today, id-tiebreak). Person with no rate row → $0.00/hr, rate_source: 'person' (visible and intentional).
  2. 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.
  3. 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.jscomputeProposalPnl(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.

PhaseDeliverableProves / de-risksDepends on
P0 — Data domain + console shellshipped #1563proposals 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 costingshipped #1566proposal_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 → convertshipped #1568Mark 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 #1570An 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-buyshipped #1576proposal_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-timelineshipped #1577proposals.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 != null before checking status. Because converted_project_id is written once at conversion and never cleared by a status edit, this prevents a second create_project call even if an owner manually edits the proposal's status back to won or draft via the Edit form. The prior status === '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 when converted_project_id is set. This eliminates the silent-revert bug where saving any field on a converted proposal would default the select to draft (because converted was never an option in the dropdown) and overwrite the stored status.

  • "Closed (Won)" / "Closed (Lost)" nomenclature (display-only): won and converted both display as "Closed (Won)"; lost displays as "Closed (Lost)"; draft remains "Draft". Stored status values (draft/won/lost/converted) are unchanged — no migration, no CHECK-constraint change. The list page also surfaces a project → link next to "Closed (Won)" when converted_project_id is set, so owners can navigate directly to the project from the proposals index. The "Converted" card on the detail page now keys on converted_project_id != null (not on status === '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/lost rows 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 to draft).
  • Server: every write route checks _isProposalLocked via _loadProposalById and 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 guards converted_project_id independently.
  • Immutability for converted: _isProposalConverted additionally blocks POST /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 a draft. Once closed, that modal is no longer shown.

6. What this does not change

  • Operational tables and queriesusers, 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 engineTARGET_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:

ColumnTypeNotes
tenant_iduuidRLS key
idintegerApp-assigned (MAX+1); composite PK with tenant_id
proposal_idintegerFK → proposals (tenant_id, id) ON DELETE CASCADE
vendor_user_idintegerFK → users (tenant_id, id); must have employment_type = 'vendor'
descriptiontextWhat the vendor delivers
agreed_amountnumeric(12,2)Fixed fee; folds into cost
agreed_hoursnumeric(10,2)Hours the vendor contributes; folds into total hours for LER/make-vs-buy
created_attimestamptz

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 fees
  • cost = committed_envelope + committed_liability
  • vendor_hours = Σ agreed_hours
  • total_hours = internal hours + vendor_hours
  • ler = 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 pollutionlib/dashboard.js, lib/forecast.js, reminders.js, lib/availability.js never reference proposal_liabilities. lib/proposal-costing.js imports FROM forecast.js only (one-directional); forecast.js never 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:

  1. 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.

  2. 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_weeks query param always wins. The sim remains opt-in (nothing computes on a plain page load).

  3. 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.