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 |
| Staffing lines get a window and shape ✅ shipped #1818/#1819 | proposal_roles gains the same optional start_date/end_date window and week_hours shape an envelope has; validators shared via the new pure lib/week-shape.js; a staffing line is edited on its own page reusing the envelope shape editor verbatim. Storage + editing only — the simulation still compared window totals. | Gives a staffing line something to shape against, ahead of the simulation actually using it (§11). | P1, P3 |
| Per-week overlay + worst-week verdict ✅ shipped #1821 | The capacity simulation stops comparing window totals and compares per week: the two-pool shaped/unshaped split from #1812, reimplemented in lib/proposal-capacity.js; a row's status is its worst week, not its total; outside_window_hours surfaces shaped demand the window doesn't cover; a per-week grid overlays the numbers a totals table hides. | Closes the gap the previous row opened — a shape that changes nothing about the verdict was a half-finished feature. | Staffing lines get a window and shape |
P0–P4, the deadline, staffing-line windows/shapes, and the per-week overlay have all shipped. The proposals arc is feature-complete: a separate in-app data domain, staffing + live costing, vendor liabilities, Won→convert, a week-by-week capacity simulation with a worst-week verdict, 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; per-week overlay + worst-week verdict — shipped #1821)
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.
8.1 Per-week overlay + worst-week verdict (#1821)
Phase 3 as shipped compared window totals: sum free capacity across every week, sum proposed hours across every staffing line, subtract. That let a real problem hide in plain sight — a line needing 90h of Design in one shoot week and 30h spread across the other five "fit" a window with 200h free spread evenly, even though week three alone was impossible.
The fix decomposes demand per discipline per week, using the two-pool split already proven for envelope capacity in #1812 (shaped hours land exactly where the PM placed them, no proration; unshaped hours spread evenly), reimplemented locally in lib/proposal-capacity.js rather than imported — a proposal has no logged hours to discharge against the shape, so the envelope version's discharge step doesn't apply, and the isolation contract (§8, "imports nothing operational") rules out reaching into lib/availability.js regardless. A staffing line's week_hours (its shape, from #1818/#1819) places hours in the exact simulated week they land in; everything else — the unshaped remainder of planned_hours — spreads evenly across every week of the simulated window, not the line's own window. A line with no shape at all is entirely unshaped, which is what makes the change safe: it reproduces the exact pre-#1821 per-discipline totals, proven by a dedicated regression test that hand-computes the old totals-only result and asserts equality without touching any existing fixture.
A row's status is now driven by its worst single week, not its total — after = free − proposed per week, classified by the same Fits/Tight/Short thresholds Phase 3 always used, and the row takes the worst of those. worst_week names the deciding week (e.g. "tightest 14 Sep"), but only when a specific week is genuinely worse than what the row's totals alone would already say — an evenly-spread unshaped line has every week reading the same as the total, so there's no single week to blame and worst_week is null.
Shaped hours placed in a week the simulation window doesn't cover are never silently dropped. They're tracked as outside_window_hours (per discipline and in aggregate) and surfaced next to the verdict — a "Fits" computed while most of a line's shaped effort sits outside the simulated window would be a lie by omission, which is the most likely way this feature could mislead someone if it weren't tracked explicitly.
The card additionally renders a compact per-week grid (weeks across the top, disciplines down the side, each cell that week's after colored by that week's own status) — the overlay a totals-only table cannot show, and the reason a single red cell among green neighbours is now visible at a glance rather than averaged away.
Some existing verdicts changed from Fits to Tight or Short when this shipped — that is the feature, not a regression. Every case where a fixture's expected verdict changed was confirmed by hand against the new per-week arithmetic before the fixture was touched.
Nothing about the isolation contract moved. lib/proposal-capacity.js still imports nothing (test-enforced: its require( count is asserted to be zero). index.js, lib/dashboard.js, lib/forecast.js, reminders.js, lib/availability.js, and envelopes.js all have empty diffs for this change. The simulation is still opt-in, still read-only, still writes and posts nothing, and is still never shown to the people it models — it reports a verdict, it does not advise a schedule, level a shape, or suggest a different window.
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 labor 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 color) + 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.
11. Staffing lines get a window and shape (Phase 5 — shipped #1818)
A proposal_roles row (a staffing line) carries planned_hours and nothing about when — so, before this phase, the capacity simulation (§8) could only ever compare a window-total demand figure to a window-total free-capacity figure. Phase 5 gives a staffing line the same optional window (start_date/end_date) and shape (week_hours, a sparse array of { week, hours } keyed to Monday-aligned weeks) an allocation envelope has carried since #1809/#1810 — three nullable columns added in migration 0038, mirroring budgets column-for-column.
This was storage + editing only. lib/proposal-capacity.js was untouched by this phase — the simulation still compared window totals exactly as it did before this issue. Overlaying the shape onto queryCapacityWeeks's per-week available/committed cells, so the verdict could catch "impossible in week three" even when the window total fit, was the deliberate follow-up this phase set up but did not do — that follow-up shipped as #1821 (§8.1).
Shared validators, not duplicated ones. The window/shape validators (isValidWindowDate, validateEnvelopeWindow, isMondayISO, mondayOfISO, validateEnvelopeWeekHours) lived in envelopes.js — an operational router. A proposal role has no business importing that, and two copies of the same validator drifting apart is worse than the import. They moved, unchanged, into a new pure module, lib/week-shape.js (imports nothing). envelopes.js now imports from it and still re-exports validateEnvelopeWeekHours off the router object for backward compatibility with existing callers/tests.
Same rules as an envelope's shape, restated for a staffing line: a window is both-or-neither and validated before any write; a shape requires a window ("set a window first" otherwise); every week must be a Monday inside the window; the sum is capped at the line's planned_hours, checked server-side regardless of the editor's own client-side clamp. create_proposal_role/update_proposal_role accept the three fields with update using the same presence-based semantics as update_allocation: a key absent leaves the stored value untouched, []/null clears the shape, both window keys present-but-empty clears the window. Both endpoints remain absent from MCP_TOOLS and audit-silent — Phase 5 changes nothing about proposals' Slack-unreachability or conversion being the first audited moment in a proposal's life.
A staffing line is now edited on its own page, GET /console/proposals/:proposalId/roles/:roleId (owner/admin), following the /console/envelopes/:id precedent from #1815 exactly: the inline Edit form that used to live inside the Cost breakdown table row was outgrowing its space (now doubly true with a window field and a shape chart added), so it moved to a standalone page and the row's Edit trigger became a plain link. The page reuses _envelopeShapeEditor/_ENVELOPE_SHAPE_SCRIPT verbatim — a staffing line's { planned_hours, start_date, end_date, week_hours } maps directly onto an envelope's { allocatedHours, startDate, endDate, weekHours }, so there was no reason to fork a second chart. A context panel above the form shows this line's planned hours against the proposal's total planned hours across all lines (hours only — no dollar figures, so no owner/manager branching beyond the page's existing owner-only gate). Save posts to the existing POST /console/proposals/:id/roles/update route (unchanged endpoint/redirect contract, now additionally forwarding the three fields and honouring an optional return_to); Remove posts to the existing delete route, which already lands back on the proposal by default.
The closed-proposal lock (§ "closed-proposal lock", #1604/#1606) applies unmodified. A won/lost/converted proposal's staffing lines are read-only on this new page too — no edit form, no Remove button, and the shape chart's script (_ENVELOPE_SHAPE_SCRIPT) is simply not appended to a locked page, so the rendered bars have no click handler. This is the same lock every other proposal edit surface already has; Phase 5 does not add a second gate, it just makes sure the new fields travel through the existing one.
The shape is a forecast, never an instruction — this invariant is permanent, not phase-specific. No alert, chip, color, or signal may ever derive from a proposal staffing line's shape, and nothing may compare it to logged time (a proposal has no actuals to compare against in the first place). Any future issue proposing such a comparison is wrong for the same reason it would be wrong on an allocation envelope.