# Personal Software Factory: full text > Personal Software Factory is a free library of blueprints for internal business tools, such as a CRM, an approval workflow or a time-off tracker, that a small team can build with an AI app builder (Claude, ChatGPT, Cursor, Lovable, Replit or Type) instead of paying per seat for a SaaS product. Each blueprint is a single Markdown spec with the data model, business rules, screens, phased build prompts and acceptance checks, and the site publishes what happened when an AI builder followed it. Source: https://personalsoftwarefactory.com # How we test blueprints Source: https://personalsoftwarefactory.com/how-we-test Updated: 2026-10-14 > What "build-tested" means on this site, how an AI builder is given a blueprint, how acceptance checks are verified in a real browser, and what we do not test. ## The short version A blueprint is marked **build-tested** only when an AI builder was given nothing but the blueprint, followed its phase prompts in order, and every acceptance check in the blueprint was then verified in a real browser. Each build-tested page shows four numbers: acceptance checks passed, automated tests in the build, spec gaps the build exposed, and build phases. It also lists what was not tested. Anything less is labelled as what it is. A blueprint with a working demo but no independent build test says so on its page. ## Why test a spec at all Most "build an app with AI" advice stops at the prompt. A prompt that produces something that looks right is easy. A prompt that produces software a team can rely on is not: the hard parts are the rules nobody wrote down, such as who can approve what, what happens when someone is on holiday, or how a refund changes a commission that has already been paid. The only way to find those holes is to build from the spec and check the result. Every gap the build exposes is fixed in the published spec, so the next team does not hit it. ## The process ### 1. The blueprint is the only input The builder receives the blueprint as a single Markdown file, exactly as a reader would download it. It does not see our demo code, earlier drafts or notes. The blueprint's build plan splits the work into phases; each phase is a prompt that ends with "list what you built and anything in the spec you had to interpret". ### 2. Phases are built in order The builder sends the Phase 1 prompt, builds only that, and records what it had to interpret. It does not build ahead. Then Phase 2, and so on. This mirrors how a person would use the blueprint, and it is where vague specs fall apart: a rule that seems obvious in Phase 2 turns out to need a field that Phase 1 never created. ### 3. Acceptance checks run in a real browser Every phase in a blueprint ends with acceptance checks, for example: "Signed in as Ana while Maya's step is active, there is no Approve button and a direct API call is refused." Each check is written as an automated browser script (Playwright driving Chromium) that clicks through the app as the named person and asserts on what is visible on screen. Where a check says the server must refuse something, the script also makes the request directly. After the last phase, the database is reset to the seed data and every check from every phase is run again in one pass. Only that final run counts. ### 4. Gaps go back into the spec When the builder has to guess, because the spec is silent, ambiguous or contradicts itself, that is recorded as a spec gap with the exact wording that would have prevented it. The gap is then fixed in the published blueprint and listed in its build notes. A blueprint with zero gaps found is rare; a blueprint whose gaps are listed is more trustworthy than one that claims none. ### 5. Results are published as they are The page shows the checks that passed out of the total. If a check could not be verified, it is not counted. Build notes list what was not tested. ## What we do not test These are listed on each build-tested page, and they are the same for most blueprints: - **Production sign-in.** Builds use a development-only "sign in as" switcher to test as different people. Google sign-in or your platform's own auth needs to be set up and checked in your environment. - **Live third-party services.** Email, Slack and calendar integrations are built as the spec describes and tested through an outbox or with correctly signed test requests, not by delivering real messages. - **Hosting at scale.** Test builds run on a single server with SQLite. For a hosted deployment, use your platform's managed database with the same schema. - **Every AI builder.** Builds are run with Claude. The blueprints are written to be builder-agnostic, but a different builder may interpret the same words differently. The acceptance checks are how you find out. ## Verification levels on this site | Label | What it means | |---|---| | Build-tested | An AI builder followed the blueprint's prompts in order; every acceptance check passed in a real browser in a final full run; gaps found were fixed in the spec. | | Working demo; build test pending | The full spec and an interactive demo exist, with automated logic tests. The spec and demo were written together, so an independent build from the prompts alone has not been verified yet. | | Draft | A starting prompt and data model only. Not built or tested. | ## The interactive demos Most blueprints include a demo you can open in your browser. The demos run entirely in your browser with fictional sample data, simulate sign-in with a person switcher and simulate time with a clock control. They show how the tool behaves; they are not the production build and they do not send email or store anything on a server. ## Corrections If you build from a blueprint and something in it is wrong or unclear, that is a spec gap. Tell us through the request form on the home page and we will fix the blueprint and credit the finding in its build notes. --- ## Can you build your own CRM instead of paying for HubSpot? Yes, if your sales team is 1 to 15 people and mostly needs contacts, deals and follow-ups. This blueprint specifies a CRM built around one rule: every contact has a next follow-up date, and the overdue ones are always at the top. It adds a deal pipeline with weighted totals, activity logging, CSV import and export, and a daily digest email, built in four phases with 27 acceptance checks. It deliberately leaves out email sync, sequences, marketing contacts, lead scoring and forecasting. HubSpot pricing: HubSpot's free CRM is free for up to 2 users (and, per its product page, up to 1,000 contacts). Paid HubSpot seats start at $15 per seat per month on Starter. Pipedrive, the other common choice for small teams, has no free tier: Lite is $14, Growth $39, Premium $59 and Ultimate $79 per seat per month, billed annually. (source: hubspot.com/pricing/crm and pipedrive.com/en/pricing, checked October 2026) **What should a simple CRM for a small team include?** Three things: a list of people and companies with an owner, the deals in play by stage, and a reminder of who to call next. This blueprint adds an activity timeline, a pipeline board with stage totals and weighted totals, search, CSV import and export, and one daily email per person listing what is overdue or due today. **What data model does a simple CRM need?** Six tables: Person (the users, with an admin flag), Company (name, domain, owner), Contact (name, email, company, owner, next follow-up date, archived), Deal (title, contact, value in integer cents, stage, expected close date, closed date, lost reason), Activity (call, email, meeting, note or system entry, with who logged it and when) and DigestLog (one row per daily email, so the job never sends twice). **How do follow-up reminders work?** Every active contact has exactly one next follow-up date. It can be moved but never cleared; to stop following someone up, you archive them. Contacts are sorted overdue first, then due today, then upcoming. Logging a call, email, meeting or note always sets the next follow-up (tomorrow or later, default three days), and a daily digest at 08:00 tells each owner what is overdue, due today, or closing soon. **What is a weighted pipeline?** Each open deal's value multiplied by its stage probability. In this blueprint the stages are Lead 10%, Demo 30%, Proposal 60% and Negotiation 80%, so a $32,000 deal in Negotiation counts as $25,600 weighted. With the sample data the open pipeline is $108,400 and the weighted pipeline is $61,220. **Can I import contacts from a spreadsheet?** Yes. An admin pastes CSV or cells copied from a spreadsheet, with a header row. The app previews every row before importing: missing names, invalid emails, unknown owners and bad dates are errors, and emails already in the CRM (or repeated in the paste) are flagged as duplicates and skipped. Companies that do not exist yet are created. Import adds new contacts only; updating existing records by import is left out of v1. **How much does HubSpot cost for a small team?** HubSpot's free CRM covers up to 2 users, and paid seats start at $15 per seat per month on Starter. Pipedrive has no free tier and starts at $14 per seat per month billed annually, with Growth at $39. Prices checked on the vendors' own pages in October 2026. **Who can see and change what?** Everyone on the team can see every contact, company and deal, and anyone can log an activity on any active contact. Only the record's owner or an admin can edit it, reschedule its follow-up, reassign it, archive it, or move a deal's stage. Only admins add or deactivate people, and deactivating someone requires choosing who receives their records. # Simple CRM Blueprint BP-001 · Department: Sales · Replaces: HubSpot (free CRM), Pipedrive For teams of 1 to 15 people. > How to use this file: paste this whole document into your AI builder (Claude, ChatGPT, Cursor, Lovable, Replit, Type) as context. Then send the build prompts in section 13 one at a time. Do not move to the next phase until the acceptance checks for the current phase pass. --- ## 1. The problem "We pay for a giant CRM and use about ten percent of it." A small sales team needs three things from a CRM: a list of people and companies, the deals in play, and a reminder of who to call next. Everything else (email sequences, scoring, forecasting, marketing contacts) sits unused. Meanwhile the one thing that matters slips: a promised follow-up is forgotten because nothing put it in front of the right person on the right day. This blueprint is a CRM built around one rule: every contact has a next follow-up date, and the ones that are late are always at the top. ## 2. What the real product does Research basis (October 2026): - HubSpot free CRM product page and pricing: hubspot.com/products/crm, hubspot.com/pricing/crm - HubSpot knowledge base: knowledge.hubspot.com/object-settings/set-up-and-customize-pipelines, knowledge.hubspot.com/records/manually-log-activities-on-records, knowledge.hubspot.com/import-and-export/import-objects - Pipedrive pricing: pipedrive.com/en/pricing - Pipedrive knowledge base: support.pipedrive.com/en/article/activities, support.pipedrive.com/en/article/pipeline-view, support.pipedrive.com/en/article/importing-data-into-pipedrive-with-spreadsheets What we verified: 1. **Price and limits.** HubSpot's free CRM is free for up to 2 users and, per its product page, up to 1,000 contacts. Paid HubSpot seats start at $15 per seat per month (Starter, which adds required fields, permission sets and multiple currencies). Pipedrive has no free tier: Lite is $14, Growth $39, Premium $59 and Ultimate $79 per seat per month, billed annually, with a 14-day trial. 2. **Contacts and companies.** Both products keep people and companies (Pipedrive calls them organizations) as separate records, linked to each other and to deals. Records have an owner. 3. **Deal pipeline.** HubSpot's default sales pipeline has seven stages, each with a probability: Appointment scheduled (20%), Qualified to buy (40%), Presentation scheduled (60%), Decision maker bought-in (80%), Contract sent (90%), Closed won (100%), Closed lost (0%). The board view shows a weighted amount per stage: the total amount in the stage multiplied by the stage probability. Pipedrive's pipeline view moves deals through stages until they are marked Won or Lost; closed deals leave the active board. Cards show title, contact, value and owner. Deals are sorted by next activity by default and can be sorted by value or expected close date. A Pipedrive deal must be linked to a person or an organization. 4. **Activities.** HubSpot lets you log a note, email, call or meeting on a record with the date it happened, and offers a "task follow up" option in the same form ("log an email and create a task due in two business days to follow up"). Pipedrive activity types are call, meeting, task, deadline, email and lunch; every person and deal shows a "last activity date" (last activity marked done) and a "next activity date" (next scheduled activity not yet done). 5. **Import.** HubSpot accepts a file upload or data pasted directly from a spreadsheet, matches existing contacts by email, and shows mapping errors you can fix or skip before importing. Pipedrive requires a header row, maps columns by name, requires a name for people and organizations, suggests email as an extra identifier to reduce duplicates, previews the result, and reports how many rows were added, updated, merged or skipped, with a reason for each skipped row (for example, an invalid date format). ### What this blueprint keeps Contacts and companies with owners; one deal pipeline with stage probabilities and Won/Lost; a next follow-up date on every contact (Pipedrive's "next activity date", reduced to the one date that matters); a log-activity form that always sets the next follow-up (HubSpot's "task follow up", made mandatory); a pipeline board with per-stage totals and weighted totals; simple search; CSV export; CSV import by paste with a validation preview and duplicate detection by email; a daily email digest of what is due. ### What this blueprint deliberately leaves out Email sync and open tracking, sequences, marketing contacts and forms, multiple pipelines, custom fields and custom objects, lead scoring, meeting scheduler and calendar sync, quotes, tickets, AI assistants, forecasting beyond weighted totals, and updating existing records by import. These earn their keep in larger sales teams. A team of 1 to 15 people needs the list, the board and the reminder. ## 3. Scope of v1 - One team, one currency (set once by an admin; default USD). Deal values are stored as integer cents. - One pipeline with fixed stages (section 7). - Follow-up and close dates are calendar dates (no time of day), interpreted in one team time zone (a configuration value such as a `TEAM_TIME_ZONE` environment variable; default America/New_York). "Today" means today in that time zone. - Web app that works on a phone. One email per person per day (the digest). No other notifications. ## 4. Roles | Role | Who | Can do | |---|---|---| | Member | Everyone on the team | See every contact, company and deal. Create contacts, companies and deals. Log activities on any contact. Edit, reschedule, archive or move only records they own. Export CSV. | | Owner | The person set as owner of a record | Edit the record, change its owner, reschedule its follow-up, archive it; for deals, move stages, mark won or lost, reopen. | | Admin | One or more people | Everything an owner can do, on every record. Manage the team (add people, deactivate with reassignment). Import CSV. Delete any logged activity. | Everyone can see everything: on a small team, a colleague often covers a call. Only changes are restricted. Any member can log an activity on any contact (the activity records who logged it). ## 5. Data model All IDs are strings. Dates written as "date" are calendar dates stored as `YYYY-MM-DD`. Timestamps are UTC. **Person** (a user of the CRM) - id - name (required) - email (required, unique, case-insensitive) - is_admin (boolean, default false) - active (boolean, default true) **Company** - id - name (required, max 120 chars, unique case-insensitive) - domain (optional; normalised to lowercase without `https://`, `www.` or a path, e.g. `acme.example`; unique when present) - owner_id (references an active Person) - created_at (timestamp) **Contact** - id - name (required, max 120 chars) - email (optional; unique across all contacts, including archived ones, case-insensitive) - phone (optional, free text) - title (optional, job title) - company_id (nullable, references Company) - owner_id (references an active Person) - next_follow_up (date, required while the contact is not archived) - archived (boolean, default false) - created_at (timestamp) - last_activity_on (derived: the latest `happened_on` among the contact's call, email, meeting and note activities; empty if none) **Deal** - id - title (required, max 120 chars) - contact_id (required, references Contact) - company_id (nullable; copied from the contact's company whenever the deal's contact is set) - owner_id (references an active Person while the deal is open) - value_cents (integer, 0 or more) - stage: lead | demo | proposal | negotiation | won | lost - expected_close (date, required) - closed_on (date; set when the deal is won or lost, cleared on reopen) - lost_reason (text; required when the deal is lost, cleared on reopen) - created_at (timestamp) **Activity** (the timeline of a contact) - id - contact_id (required) - deal_id (nullable; set when the activity is about a deal) - type: call | email | meeting | note | system - happened_on (date) - note (text, max 2,000 chars; required for note and system types) - by_id (the Person who logged it) - created_at (timestamp) `system` activities are written by the app (contact created or imported, owner changed, follow-up moved, deal created, deal stage changed, archived, unarchived). People cannot log or delete them. **DigestLog** (one row per digest email, so the job never sends twice) - id, person_id, date (the day the digest covers), overdue_count, due_today_count, deals_count, sent_at, error ## 6. Business rules Follow-ups - **R1.** Every contact that is not archived has exactly one next follow-up date. It can be moved but never cleared. To stop following someone up, archive them. - **R2.** A contact's follow-up status is computed against today: **overdue** if `next_follow_up` is before today, **due today** if equal, **upcoming** if later. "Overdue N days" means today minus `next_follow_up` in days. - **R3.** Contact lists are sorted overdue first (oldest date first), then due today, then upcoming (soonest first). Ties sort by contact name A to Z. Archived contacts never appear in this list unless the Archived filter is chosen. - **R4.** When a person enters or changes a follow-up date by hand (new contact, edit, reschedule), it must be today or later. Saving other fields on an overdue contact does not force a new date. - **R5.** Logging an activity (call, email, meeting or note) always sets the contact's next follow-up. The new date must be after today (tomorrow or later): logging means you followed up today. The form offers quick picks Tomorrow, 3 days, 1 week, 2 weeks, and defaults to 3 days. - **R6.** An activity's `happened_on` must be today or earlier. The note is required for the note type (at least 3 characters) and optional for call, email and meeting. - **R7.** Anyone can log an activity on any active contact. Archived contacts cannot get new activities until they are unarchived. - **R8.** The author of an activity or an admin can delete a call, email, meeting or note. Deleting does not change the contact's follow-up date. System activities cannot be deleted. Ownership and permissions - **R9.** Every company, contact and open deal has an active owner. New records default to the person creating them. - **R10.** Only the record's owner or an admin can edit it, change its owner, reschedule its follow-up, archive or unarchive it, or (for deals) move its stage, mark it won or lost, or reopen it. The refusal message names the owner: "Only Sam Okafor (the owner) or an admin can change this contact." - **R11.** Changing a contact's owner or follow-up date writes a system activity ("Owner changed from Jo Park to Maya Chen", "Follow-up moved from Oct 10 to Oct 20"). Duplicates and validation - **R12.** Contact email is unique across all contacts, archived included, compared trimmed and case-insensitive. The error names the existing contact: "Kim Olsen already uses kim@olsen.example." - **R13.** Company names are unique (case-insensitive, trimmed). When a contact form or an import row names a company that does not exist, the company is created, owned by the contact's owner. When it matches an existing company (ignoring case), that company is used. - **R14.** Archiving a contact who has open deals is refused: "Kim Olsen has 1 open deal. Close it or move it to another contact first." Unarchiving sets `next_follow_up` to today if the stored date is in the past. Deals - **R15.** A deal needs a title, a contact who is not archived, a value of $0 or more, and an expected close date. When an expected close date is set or changed on an open deal, it must be today or later. A deal is created in an open stage only. - **R16.** An open deal whose expected close is before today is flagged "Past close date" on the board, on the Today screen and in the owner's digest. The flag clears when the date is moved or the deal is closed. - **R17.** Moving a deal to Lost requires a reason of at least 3 characters. Won and Lost set `closed_on` to today. Reopening (moving a closed deal to an open stage) clears `closed_on` and `lost_reason`. If its owner has been deactivated, the reopened deal is assigned to the active admin reopening it (R9 and R10); otherwise the owner stays unchanged. Every stage change writes a system activity on the deal's contact with `deal_id` set. - **R18.** Stage totals use open deals only: count, sum of `value_cents`, and weighted sum (value × stage probability, rounded to the nearest cent per deal). "Won this month" is the sum of deals with stage won and `closed_on` in the current calendar month. Team - **R19.** Only admins can add or deactivate people. An admin cannot deactivate themselves. Deactivating requires picking an active person to receive their records: all their companies and contacts (archived included) and their open deals are reassigned. Closed deals keep their original owner, so history stays true. Inactive people disappear from owner pickers and from Act as. Search, import and export - **R20.** Search is case-insensitive. The query is split on spaces and every term must appear somewhere in the record: contact name, email, phone, title and company name; company name and domain; deal title, company name and contact name. Queries shorter than 2 characters are refused: "Type at least 2 characters." Archived contacts appear in results, labelled Archived. - **R21.** Only admins can import. Export is open to everyone and exports exactly the rows in the current view (filters applied, in display order). ## 7. Deals and pipeline | Stage | Probability | Open? | |---|---|---| | Lead | 10% | yes | | Demo | 30% | yes | | Proposal | 60% | yes | | Negotiation | 80% | yes | | Won | 100% | no | | Lost | 0% | no | ### Deal lifecycle | Action | Who | From | To | Side effects | |---|---|---|---|---| | Create | Anyone | (none) | lead, demo, proposal or negotiation | `company_id` copied from the contact; system activity "Deal created" | | Edit | Owner or admin | any | same | Contact change re-copies `company_id`; new expected close must be today or later if the deal is open | | Move | Owner or admin | open stage | another open stage | System activity "Moved from Proposal to Negotiation" | | Mark won | Owner or admin | open stage | won | `closed_on` = today; system activity | | Mark lost | Owner or admin | open stage | lost | Reason required (R17); `closed_on` = today; system activity with the reason | | Reopen | Owner or admin | won or lost | an open stage | Clears `closed_on` and `lost_reason`; system activity. If the expected close is now in the past the deal shows "Past close date" until it is updated | Moving a deal to the stage it is already in is refused: "Bluebird annual plan is already in Proposal." ### Worked examples (with the seed data in section 12; T is today) **Contact order (Everyone).** Grace Kim (overdue 6 days), Priya Shah (3), Hana Sato (2), Rosa Diaz (1), then due today: Kim Olsen, Nina Patel (name order), then Leo Martins (T+1), Owen Hart (T+2), Tom Becker (T+4), Ivan Petrov (T+5), Zoe Carter (T+7), Marcus Lee (T+9), Felix Wong (T+14). Ruth Adams is archived and not listed. **Clock moves one day.** Today becomes T+1. The order is now Grace Kim (overdue 7 days), Priya Shah (4), Hana Sato (3), Rosa Diaz (2), Kim Olsen (1), Nina Patel (1), then Leo Martins (due today), then the rest. Kim Olsen and Nina Patel tie on date and sort by name. **Logging a call.** On day T, Sam logs a call with Priya Shah ("Walked through pricing, needs Tom's sign-off") and picks "1 week". Priya's next follow-up becomes T+7, her last activity becomes T, and she drops out of Sam's overdue list. Sam's overdue count goes from 2 to 1. **Board totals.** | Stage | Deals | Total | Weighted | |---|---|---|---| | Lead | Acme expansion $12,500, Kestrel starter $3,200 | $15,700 | $1,570 | | Demo | Bluebird add-on seats $4,500, Acme pilot $6,000, Pine Labs team plan $7,400 | $17,900 | $5,370 | | Proposal | Bluebird annual plan $18,000, Ridgeway onboarding $9,800 | $27,800 | $16,680 | | Negotiation | Olsen & Co rollout $32,000, Harbor renewal $15,000 | $47,000 | $37,600 | | Open pipeline | 9 deals | $108,400 | $61,220 | Filtered to Sam's deals: Bluebird annual plan, Bluebird add-on seats, Harbor renewal: $37,500 open. Moving Bluebird annual plan from Proposal to Negotiation: Proposal becomes $9,800 (1 deal), Negotiation $65,000 (3 deals). The open total is unchanged; the weighted total rises by $3,600 (from 60% to 80% of $18,000). ## 8. Screens 1. **Today** (home). For the signed-in person, with a Me / Everyone toggle. Three sections: Overdue (red, most overdue first, "Overdue 3 days"), Due today, and Deals to watch (their open deals that are past close date or closing in the next 7 days). Each contact row has a Log activity button that opens the log form for that contact. Shows a one-line note when today's digest was sent ("Digest sent for Oct 14: 2 overdue, 0 due today, 1 deal"). Empty state: "Nothing due today. Nice." 2. **Contacts.** Table: name (with title and company), owner, open deal (most advanced stage and value), last activity ("Call · 10 days ago"), next follow-up (badge: "Overdue 3d" red, "Today" amber, or a date). Filters: Mine / Everyone / a specific owner; All / Overdue / Due today / Archived. Summary line: "3 overdue · 2 due today". Buttons: New contact, Export CSV, Import (admins). 3. **Contact detail.** Name, title, company link, email, phone, owner, follow-up status. **Log activity** card: type (Call, Email, Meeting, Note), date it happened (default today), note, optional deal (open deals of this contact), next follow-up with quick picks. Timeline (newest first, system entries muted, delete for the author or admins). Deals list with New deal. Actions for owner or admin: Edit, Reschedule follow-up, Archive or Unarchive. 4. **New / edit contact.** Name, email, phone, title, company (pick an existing one or type a new name), owner, next follow-up (default today). 5. **Companies.** Table: name, domain, owner, active contacts, open deal value, earliest next follow-up among its contacts. New company. 6. **Company detail.** Fields, contacts (with follow-up badges), deals, and the combined recent timeline of its contacts. Edit for owner or admin. 7. **Pipeline.** Open tab: one column per open stage with header "Proposal · 2 · $27,800 · weighted $16,680", cards sorted by expected close (title, company, value, owner, expected close; "Past close date" in red). Each card has a "Move to" control (works on a phone; drag and drop is optional). Page header: open pipeline total, weighted total, won this month, number past close date. Mine / Everyone filter. New deal. Closed tab: won and lost deals with closed date and lost reason. 8. **Deal detail.** Title, contact, company, owner, value, stage, probability, expected close, closed date, lost reason. Actions for owner or admin: Edit, Move to stage, Mark won, Mark lost (reason), Reopen. History: the deal's activities and stage changes. 9. **Search.** One box in the top bar. Results grouped into Contacts, Companies and Deals, each linking to its page. 10. **Import** (admins). Paste box with the expected columns shown. Check button shows a preview table: each row's line number, values, and a status (Ready, Duplicate, or Error with the message). Import button imports the Ready rows and shows the summary. 11. **Team** (admins). People with email, admin flag and active status. Add person (name, email, admin). Deactivate with a required "Give their records to" picker, showing how many contacts, companies and open deals will move. All screens must work on a phone at 390px wide without sideways scrolling of the page. ## 9. CSV import and export ### Import by paste - The first line is a header row. Columns are matched by name, ignoring case, spaces and underscores: `name` (required), `email`, `phone`, `title`, `company`, `owner`, `next_follow_up`. Unknown columns are ignored and listed as a warning ("Ignored column: Source"). A paste without a `name` column is refused: "The header row needs a name column." - Values may be separated by commas (a CSV file) or tabs (cells copied from a spreadsheet). The separator is chosen from the header line: tab if it contains a tab, otherwise comma. Double-quoted values may contain commas, line breaks and doubled quotes (`"Olsen & Co, Ltd"`). - At most 500 data rows per paste. Blank lines are skipped. - Per row, checked in this order: 1. `name` is required: "Name is required." 2. `email`, when present, must look like an email: "Invalid email: max@@bluebird". 3. `email` already used by a contact in the CRM: status Duplicate, "Already in the CRM as Kim Olsen", not imported. Email repeated earlier in the same paste: Duplicate, "Same email as line 2". 4. `owner`, when present, must match an active person by email or full name (case-insensitive): "Unknown owner: Pat". Blank means the importer. 5. `next_follow_up`, when present, must be a real date in `YYYY-MM-DD` form: "Invalid date: 13/01/2026 (use YYYY-MM-DD)". Blank means today. Past dates are accepted (they come from your old system) and show as overdue. - Rows with status Ready are imported; Duplicate and Error rows are not. Importing re-checks every row first, so nothing changed between Check and Import can slip through. - Each imported contact gets a system activity "Imported by Dana Ruiz". Companies are resolved by R13. - The summary reads: "Imported 2 contacts (1 new company). Skipped 1 duplicate and 3 rows with errors." ### Sample paste (use it for the acceptance checks) ``` name,email,phone,title,company,owner,next_follow_up Ella Brooks,ella@harbor.example,,Office manager,Harbor,sam@northwind.example, Kim Olsen,kim@olsen.example,,,Olsen & Co,, Max Ford,max@@bluebird,,,Bluebird,, ,noname@acme.example,,,Acme,, Ava Reed,ava@newco.example,,,Newco,lee@northwind.example,13/01/2026 Ben Ortiz,ben@quill.example,555-0142,Founder,Quill Studio,Lee Park, ``` Expected preview: line 2 Ella Brooks Ready (owner Sam Okafor, follow-up today, existing company Harbor); line 3 Duplicate (already in the CRM as Kim Olsen); line 4 Error "Invalid email: max@@bluebird"; line 5 Error "Name is required."; line 6 Error "Invalid date: 13/01/2026 (use YYYY-MM-DD)"; line 7 Ben Ortiz Ready (owner Lee Park, new company Quill Studio). Import creates 2 contacts and 1 company. ### Export - **Contacts CSV** (`contacts-YYYY-MM-DD.csv`): Name, Email, Phone, Title, Company, Owner, Next follow-up, Follow-up status (overdue, today, upcoming, archived), Last activity, Open deals, Open deal value. Values in dollars without the currency sign (`18000.00`). - **Deals CSV** (`deals-YYYY-MM-DD.csv`): Title, Company, Contact, Owner, Stage, Value, Probability, Weighted value, Expected close, Closed on, Lost reason. - Both export the rows in the current view (R21). Quote any value containing a comma, quote or line break, and double inner quotes. ## 10. Daily digest (the only notification and the only scheduled job) Runs once a day at 08:00 in the team time zone (on Vercel: a cron hitting `/api/cron/digest` protected by a `CRON_SECRET` header; accept an optional `today` override in development). For each active person: 1. Count their overdue contacts, their contacts due today, and their open deals that are past close date or closing within 7 days. 2. If all three are zero, send nothing. 3. If a DigestLog row already exists for this person and date, send nothing (the job is safe to run twice). 4. Otherwise write the DigestLog row, then send one email: subject "3 follow-ups due today" (overdue plus due today), body listing each contact with company and how overdue, then the deals, each linking to its page. If sending fails, keep the row with the error and retry on the next run. There are no other scheduled jobs and no other notifications (no email per activity or per stage change). Overdue status is always computed from today's date, never stored. ## 11. Edge cases - A contact changes company: their existing deals keep the old company (it was copied when the deal's contact was set). Editing the deal's contact re-copies it. - An owner leaves: deactivate them in Team and pick who gets their records (R19). Closed deals keep the old owner for history. - Two people log activities on the same contact on the same day: both are kept; the last one saved sets the follow-up. - Logging an activity on an overdue contact with a follow-up date of today is refused (R5), so the overdue list cannot be cleared without moving the date forward. - A pasted spreadsheet has a trailing empty column or blank lines: ignored. - The same email appears twice in one paste: the second is a Duplicate of the first (section 9). - A deal's contact is archived later: not possible while the deal is open (R14). Closed deals may point to archived contacts. - Daylight-saving changes: dates have no time of day, so nothing shifts. - The browser and the team are in different time zones: "today" always uses the team time zone. ## 12. Seed data Team (from the shared sample company, Northwind): | Person | Email | Admin | |---|---|---| | Dana Ruiz | dana@northwind.example | yes | | Ana Lima | ana@northwind.example | yes | | Maya Chen | maya@northwind.example | no | | Sam Okafor | sam@northwind.example | no | | Ben Cho | ben@northwind.example | no | | Jo Park | jo@northwind.example | no | | Lee Park | lee@northwind.example | no | Companies: Bluebird (bluebird.example, Sam), Acme (acme.example, Maya), Ridgeway (ridgeway.example, Lee), Olsen & Co (olsen.example, Dana), Harbor (harbor.example, Sam), Kestrel (kestrel.example, Jo), Pine Labs (pinelabs.example, Lee). Contacts (T is the day the seed is loaded): | Contact | Title | Company | Owner | Next follow-up | |---|---|---|---|---| | Grace Kim | Director of IT | Harbor | Sam | T-6 | | Priya Shah | Head of Operations | Bluebird | Sam | T-3 | | Hana Sato | Operations lead | Pine Labs | Lee | T-2 | | Rosa Diaz | VP Finance | Acme | Maya | T-1 | | Kim Olsen | Founder | Olsen & Co | Dana | T | | Nina Patel | COO | Ridgeway | Lee | T | | Leo Martins | CEO | Kestrel | Jo | T+1 | | Owen Hart | Procurement manager | Acme | Maya | T+2 | | Tom Becker | CTO | Bluebird | Sam | T+4 | | Ivan Petrov | Head of Sales | Pine Labs | Lee | T+5 | | Zoe Carter | Independent consultant | (none) | Maya | T+7 | | Marcus Lee | Office manager | Olsen & Co | Dana | T+9 | | Felix Wong | Engineering manager | Ridgeway | Lee | T+14 | | Ruth Adams | Former buyer | Kestrel | Jo | archived | Emails are first name at the company domain (priya@bluebird.example); Zoe Carter is zoe@carter.example. Deals: | Deal | Contact | Owner | Value | Stage | Expected close | |---|---|---|---|---|---| | Acme expansion | Owen Hart | Maya | $12,500 | Lead | T+40 | | Kestrel starter | Leo Martins | Jo | $3,200 | Lead | T+30 | | Bluebird add-on seats | Tom Becker | Sam | $4,500 | Demo | T+25 | | Acme pilot | Rosa Diaz | Maya | $6,000 | Demo | T-2 (past close date) | | Pine Labs team plan | Hana Sato | Lee | $7,400 | Demo | T+18 | | Bluebird annual plan | Priya Shah | Sam | $18,000 | Proposal | T+10 | | Ridgeway onboarding | Nina Patel | Lee | $9,800 | Proposal | T+14 | | Olsen & Co rollout | Kim Olsen | Dana | $32,000 | Negotiation | T+5 | | Harbor renewal | Grace Kim | Sam | $15,000 | Negotiation | T+3 | | Pine Labs pilot | Ivan Petrov | Lee | $5,000 | Won (T-2) | T-2 | | Harbor first order | Grace Kim | Sam | $8,000 | Won (T-20) | T-20 | | Ridgeway consulting | Felix Wong | Lee | $2,500 | Lost (T-8, "Chose to build in-house") | T-8 | Give every contact one to three past activities (calls, emails, meetings, notes) so timelines and "last activity" have content. ## 13. Build plan Send these prompts one at a time after pasting this file. Each phase ends with checks you can run by clicking through the app. ### Phase 1: Foundation, team, companies and contacts ``` Using the Simple CRM blueprint above, build Phase 1 only. 1. Set up the data model from section 5 (Person, Company, Contact, Deal, Activity, DigestLog) with a real database. 2. Add sign-in with the platform's built-in auth or Google sign-in limited to our company domain. A signed-in email must match an active Person; anyone else sees "Ask an admin to add you." 3. Build Team (screen 11), Companies (5, 6) and Contacts (2, 4) with create and edit. Enforce rules R1 to R4 and R9 to R14 and R19 on the server, not only in the UI. 4. Sort the contacts list exactly as R3, with the Mine / Everyone / owner filters and the All / Overdue / Due today / Archived filters. 5. Load the seed data from section 12, with dates relative to today. 6. Add a small "act as" switcher visible only in development, so I can test as different people, and a development-only control that moves "today" forward one day. Do not build activities, deals, import or export yet. When you're done, list what you built and anything in the spec you had to interpret. ``` **Acceptance checks** - [ ] Seven people exist; Dana and Ana are admins. Acting as Jo, Team is not available; acting as Dana it is. - [ ] Contacts (Everyone) lists Grace Kim first with "Overdue 6d", then Priya Shah, Hana Sato and Rosa Diaz, then Kim Olsen and Nina Patel with "Today". Ruth Adams is not listed; the Archived filter shows her. - [ ] Acting as Sam, Mine shows exactly Grace Kim, Priya Shah and Tom Becker. - [ ] Creating a contact with email kim@olsen.example is refused with "Kim Olsen already uses kim@olsen.example." - [ ] Creating a contact with a follow-up date of yesterday is refused. Creating "Lena Fox" with company "Lumen Dental" (new) and follow-up today creates both, and Lumen Dental appears in Companies owned by the creator. - [ ] Acting as Lee, opening Priya Shah's edit form and saving is refused with "Only Sam Okafor (the owner) or an admin can change this contact." - [ ] Acting as Dana, deactivating Jo Park with records going to Maya Chen moves Leo Martins, Ruth Adams, Kestrel and the Kestrel starter deal to Maya. Jo no longer appears in owner pickers. ### Phase 2: Activities, follow-ups, Today and search ``` Build Phase 2 of the Simple CRM blueprint: activities, follow-ups, the Today screen and search. 1. Contact detail (screen 3) with the Log activity card and timeline. Implement R5 to R8 and R11 exactly. Logging an activity and setting the next follow-up happen in one database transaction. 2. Reschedule follow-up and Archive / Unarchive for the owner or an admin (R4, R10, R14). 3. Today screen (screen 1) for the signed-in person with the Me / Everyone toggle. 4. Search (screen 9) exactly as R20. 5. Write unit tests for follow-up status, the R3 sort order (use the worked example in section 7), and the R5 date rule. ``` **Acceptance checks** - [ ] Acting as Sam, Today shows Grace Kim (Overdue 6 days) and Priya Shah (Overdue 3 days), and Harbor renewal under Deals to watch. - [ ] Sam logs a call on Priya Shah with "1 week". Priya leaves Sam's overdue list, her timeline shows the call by Sam Okafor, and her last activity is today. - [ ] Logging an activity with next follow-up today is refused ("Pick a next follow-up date after today."). Logging a Note with no text is refused. - [ ] Acting as Lee, logging an email on Priya Shah works and shows "by Lee Park", but Reschedule is refused with the owner message. - [ ] Pressing +1 day makes Kim Olsen and Nina Patel overdue by 1 day and Leo Martins due today. - [ ] Searching "olsen" finds Kim Olsen and Marcus Lee, the company Olsen & Co, and the deal Olsen & Co rollout. Searching "a" is refused. - [ ] Acting as Dana, archiving Kim Olsen is refused because she has an open deal. Archiving Marcus Lee works; he leaves the list and shows under Archived. ### Phase 3: Deals and pipeline ``` Build Phase 3 of the Simple CRM blueprint: deals and the pipeline board. 1. Deals with create, edit, move, won, lost and reopen exactly as the lifecycle table in section 7 and rules R15 to R18. Enforce owner-or-admin on the server. 2. Pipeline screen (screen 7) with per-stage count, total and weighted total, the header totals, the Mine / Everyone filter, the "Move to" control on each card, and the Closed tab. 3. Deal detail (screen 8). Show open deals on the contact and company pages, and offer the contact's open deals in the Log activity form. 4. Write unit tests for the board totals using the worked example in section 7. ``` **Acceptance checks** - [ ] The board shows Lead $15,700 (2), Demo $17,900 (3), Proposal $27,800 (2) and Negotiation $47,000 (2); the header shows open pipeline $108,400 and weighted $61,220. - [ ] Acme pilot shows "Past close date". Mine as Sam shows 3 deals and $37,500. - [ ] Acting as Lee, moving Harbor renewal is refused with "Only Sam Okafor (the owner) or an admin can change this deal." - [ ] Acting as Sam, moving Bluebird annual plan to Negotiation updates Proposal to $9,800 and Negotiation to $65,000, and Priya Shah's timeline shows "Moved from Proposal to Negotiation". - [ ] Marking Harbor renewal lost without a reason is refused. With "Budget frozen" it leaves the board and appears in Closed with the reason. - [ ] Acting as Dana, marking Olsen & Co rollout won raises Won this month by $32,000. - [ ] A new deal for Rosa Diaz with expected close yesterday is refused. With a date next month and stage Proposal it appears in the Proposal column and on Rosa Diaz's page. ### Phase 4: Import, export and the daily digest ``` Build Phase 4 of the Simple CRM blueprint: CSV import by paste, CSV export and the daily digest. 1. Import screen (screen 10) exactly as section 9: header matching, comma or tab separator, quoted values, per-row checks in the stated order, preview, then import of Ready rows with the summary line. Admins only (R21). 2. Export Contacts CSV and Deals CSV with the columns in section 9, exporting the current view. 3. The daily digest job in section 10 at /api/cron/digest with a CRON_SECRET header and a development-only "today" override. If no email provider is configured, log the emails instead of sending. 4. Write unit tests for the CSV parser (quotes, commas, tabs, line breaks) and for every row of the sample paste. ``` **Acceptance checks** - [ ] Acting as Jo, there is no Import button and the import page is refused. Acting as Dana it opens. - [ ] Checking the sample paste shows 2 Ready, 1 Duplicate (Kim Olsen) and 3 Errors with the messages in section 9. - [ ] Importing creates Ella Brooks (owner Sam Okafor, due today, company Harbor) and Ben Ortiz (owner Lee Park, new company Quill Studio), and shows "Imported 2 contacts (1 new company). Skipped 1 duplicate and 3 rows with errors." - [ ] Export with the Overdue filter downloads contacts-YYYY-MM-DD.csv with a header row and one row per overdue contact (4 with the seed data). - [ ] Export from the pipeline downloads deals-YYYY-MM-DD.csv with one row per deal in the view and the weighted value column. - [ ] Moving the clock forward one day runs the digest: acting as Sam, Today shows "Digest sent" for the new date with his counts. Running the job again for the same day sends nothing new. ## 14. Variations - Contacts from a form on your website (a public endpoint that creates a contact owned by a round-robin owner, follow-up today). - A Friday email to each owner of everything that slipped this week. - Custom stages and probabilities that match how you actually sell. - Update existing contacts by email during import (HubSpot's "create and update"). - Business-day follow-up picks that skip weekends. - A "rotting" flag for deals with no activity in N days. --- ## Can you build your own sales commission calculator instead of paying for QuotaPath? Yes, for a team of 3 to 30 reps on one or two plans. This blueprint specifies a commission calculator that pays marginal tiers with an accelerator above quota, handles two-way deal splits, shows every rep a statement with the tier math line by line, locks each period with one finance approval, creates clawbacks when a paid deal is cancelled, and exports a CSV for payroll. The calculation is a pure function with worked examples down to the cent, built in four phases with 24 acceptance checks. It leaves out CRM sync, multi-step approvals, draws, team roll-up plans and ASC 606 amortisation. QuotaPath pricing: QuotaPath Growth is $35 per user per month and Premium is $50 per user per month, each plus a monthly platform fee, billed annually. QuotaPath says the platform fee covers the first five users, setup and support, and that every rep, manager and admin who uses the product counts as a user. (source: quotapath.com/pricing, checked October 2026) **How do tiered commission rates work?** In this blueprint tiers are marginal, like tax brackets: each part of a rep's credited bookings is paid at the rate of the tier it falls in. With a $60,000 quota and tiers of 5% up to 50% of quota, 10% from 50% to 100%, and 15% above quota, one $75,000 deal pays $30,000 at 5% ($1,500), $30,000 at 10% ($3,000) and $15,000 at 15% ($2,250): $6,750 in total. **How are split deals credited?** A deal can be split between two reps by a whole percentage. The split rep's credit is the amount times the split percentage, rounded half up to the cent, and the owner gets the rest, so the two credits always add up to the deal amount exactly. Each rep's credit then runs through their own plan's tiers in close-date order. **How do you avoid rounding errors in commission calculations?** Store money as integer cents and rates as integer basis points, and round only once per tier slice of each deal line, half up to the cent. Statement totals are plain sums of those lines, and clawbacks copy the exact cents that were paid. For example, $21,612.45 at 10% is $2,161.245, which pays $2,161.25, and the statement shows both numbers. **What is a commission clawback?** A negative line that takes back commission already paid on a deal that was later cancelled. In this blueprint, cancelling a deal in an approved (locked) period never edits that period; it creates a clawback line for the exact amount paid, for each rep who was paid on it, in an open period, so history stays true and the rep sees why their payout dropped. **What does a rep see?** Their own statement only: each deal, the tier slices it fell into, the rate and the commission, plus attainment against quota and any adjustments or clawbacks. A "what if I close this deal" calculator shows the extra commission from a new deal without saving anything. For example, a rep at 96.6% of a $60,000 quota who closes another $25,000 earns $3,650 more, because most of it falls in the 15% accelerator tier. **How much does QuotaPath cost?** QuotaPath Growth is $35 per user per month and Premium is $50 per user per month, each plus a monthly platform fee, billed annually (checked October 2026). QuotaPath counts reps, managers and admins as users, and says the platform fee covers the first five users. # Commission Calculator Blueprint BP-002 · Department: Sales · Replaces: QuotaPath, Spiff (Salesforce), CaptivateIQ, the commission spreadsheet For teams of 3 to 30 reps. > How to use this file: paste this whole document into your AI builder (Claude, ChatGPT, Cursor, Lovable, Replit, Type) as context. Then send the build prompts in section 14 one at a time. Do not move to the next phase until the acceptance checks for the current phase pass. --- ## 1. The problem "Every month-end we spend two days arguing over the commission spreadsheet." Closed deals live in the CRM. The commission plan lives in a PDF. The math lives in a spreadsheet one person understands. At month-end, finance pastes deals in, reps rebuild the numbers in their own sheets to check them, and every disagreement turns into a hunt through formulas. When a customer cancels after the rep was paid, someone edits last month's tab and nobody can say what was actually paid. This blueprint replaces that with one place where the plan rules are written down once, every payout shows the exact math that produced it, and a month that finance has approved can no longer move. ## 2. What the real product does Research basis (October 2026): QuotaPath pricing and product pages (quotapath.com/pricing, quotapath.com/automate-commission-tracking) and its help center: Payout Cycle Overview (help.quotapath.com/en/articles/6193525-payout-cycle-overview), Approvals Overview (help.quotapath.com/en/articles/9450038-approvals-overview), Approve Earnings (help.quotapath.com/en/articles/6236136-approve-earnings), Clawbacks Overview (help.quotapath.com/en/articles/15325102-clawbacks-overview), Schedule a Clawback (help.quotapath.com/en/articles/6193646-schedule-a-clawback) and the Building New Plans Guide (help.quotapath.com/en/articles/13162825-building-new-plans-guide). CaptivateIQ's product page (captivateiq.com). Salesforce's Spiff pages returned "not found" when we checked, so this blueprint makes no claims about Spiff specifically. What these tools do for a small sales team: 1. **Plans.** An admin builds a plan with quotas, rates and tiers, assigns reps to it, and sets effective dates. CaptivateIQ lists "tiers and accelerators, bonuses and SPIFs, draw structures, eligibility rules" as plan building blocks. QuotaPath's guide tells admins to check, by viewing as a rep, that "decelerators, accelerators, or bonuses are applying correctly". 2. **Deals in, earnings out.** Closed deals sync from the CRM and each deal's earnings are calculated per rep. QuotaPath shows "a breakdown of reps who will receive commissions from the deal and why", plus attainment and effective rates. 3. **Approve, then pay.** QuotaPath's payout cycle is three steps: approve earnings, schedule the payout, export a CSV for payroll. The Approvals page is a zero inbox of unapproved earnings, filtered to the pay period's deal dates. Reps who are not approvers can still open it "for transparency". 4. **Clawbacks.** In QuotaPath a clawback is "a payout with a negative earnings amount". It appears when a deal that was already paid is removed or its earnings drop. Clawbacks are recorded per earnings component, can be dismissed, and are scheduled separately from normal payouts. 5. **Rep visibility and forecasting.** Reps see their own earnings and attainment, and a forecast view that "translates their pipeline into actual earnings". Both vendors sell this as the end of "shadow accounting", where reps keep their own spreadsheet to check the company's. 6. **Locking.** QuotaPath has a "Locked Plan Data" feature that stops syncing changes into a date range, and archives plans once they are paid out. ### What this blueprint keeps Plans with marginal tiers and an accelerator above quota, monthly quotas per rep, closed deals with an optional two-way split, a per-period statement for each rep that shows the tier math line by line, a single finance approval that locks the period, clawback lines in a later period when a paid deal is cancelled, manual adjustment lines (SPIFs, corrections), a "what if I close this deal" calculator for reps, a CSV export for payroll, and an append-only history. ### What this blueprint deliberately leaves out CRM sync (v1 has finance enter closed deals; CSV import is a variation), multi-step approval chains, payout scheduling in instalments, payout eligibility rules (paid when invoiced or collected), draws, team and manager roll-up plans, multi-currency, ASC 606 amortisation, disputes inside the app, AI plan builders and plan modeling across a whole team. A team of 3 to 30 reps on one or two plans does not need them to stop arguing about the spreadsheet. ## 3. Scope of v1 - One company, one currency (USD). All money is stored as integer cents. Rates are integer basis points (1 bp = 0.01%, so 10% is stored as 1000). - Periods are calendar months ("2026-10"). A deal belongs to the month of its close date. - Quotas are monthly and set per rep in whole dollars. - A deal is credited to one rep, or split between two reps by a whole percentage. - Two roles: rep and finance admin. - Web app that works on a phone. No emails or Slack messages in v1. ## 4. Roles | Role | Who | Can do | |---|---|---| | Rep | Anyone on a commission plan | See their own statement for any period, their own deals, and their plan's rates. Use the what-if calculator for themselves. Nothing else; reps never change inputs. | | Finance admin | Finance lead, head of ops | Everything: add, edit and cancel deals; manage reps, quotas and plans; add adjustments; see every statement and the payouts summary; approve a period; open the next period; export CSV. | Reps cannot see each other's statements or payouts, and the server refuses it, not just the menu (R11). Any finance admin can approve; v1 has one approval step. ## 5. Data model All timestamps are UTC ISO strings. IDs are strings. **Person** - id - name (required, unique) - title (e.g. "Account Executive") - role: rep | finance - plan_id (reps only; references Plan; the plan used for open periods) - quota_cents (reps only; monthly quota; integer, > 0, whole dollars so it is a multiple of 100) - active (boolean) **Plan** - id - name (required, unique) - tiers: ordered list of { from_pct, rate_bps }, 1 to 6 entries - from_pct: whole percent of quota where the tier starts. The first tier starts at 0. Each tier runs until the next tier's start; the last tier has no end. - rate_bps: 0 to 10000 (0% to 100%, up to two decimals) - A tier that starts at 100% or above is an accelerator. **Period** - id ("YYYY-MM") - status: open | approved - approved_by (Person id), approved_at - snapshot (JSON; the full statements for every rep, frozen at approval; null while open) **Deal** - id - number (integer, sequential: #1001, #1002 …; never reused) - name (required, max 80 chars, e.g. "Bluebird new logo") - amount_cents (integer, > 0, max $10,000,000) - close_date (YYYY-MM-DD) - period_id (the month of close_date; must exist and be open when the deal is added or edited) - owner_id (the rep who closed it) - split_rep_id (nullable; a different rep) - split_pct (nullable; whole percent 1 to 99 credited to split_rep_id; required when split_rep_id is set) - status: closed | cancelled - cancel_reason, cancelled_at (nullable) - created_by, created_at **Adjustment** (a signed line on a rep's statement that does not count toward attainment) - id - period_id (must be open when created) - rep_id - kind: clawback | manual - amount_cents (signed integer, non-zero; clawbacks are negative) - reason (required, min 3 chars) - deal_id, source_period_id (clawbacks only: the cancelled deal and the approved period it was paid in) - created_by, created_at **Event** (history; append-only, never edited or deleted) - id, at, actor_id, period_id (nullable) - type: deal_added | deal_edited | deal_cancelled | clawback_created | adjustment_added | plan_created | plan_updated | rep_added | rep_updated | period_opened | period_approved - text (a readable sentence, e.g. "Clawback -$1,540 for Tom Reed in October 2026: #1011 Northwind add-on was paid in September 2026") **Statement** (calculated, not stored, except inside Period.snapshot) - rep, period, status, plan name and tiers, quota_cents, credited_cents, attainment_tenths - deal_lines: for each deal, in credit order: deal number and name, close date, deal amount, share %, credit_cents, start_cents and end_cents (cumulative credited bookings before and after this deal), slices (tier, rate_bps, slice amount, commission), commission_cents - adjustments: kind, reason, amount_cents, source period - commission_cents (sum of deal lines), adjustments_cents (sum of adjustments), payout_cents (the two added) ## 6. How a payout is calculated This is the core of the product. It must be a pure function of (deals, adjustments, rep's plan and quota) with unit tests for every worked example below. Same inputs, same cents, every time. - **R1. Credit order.** A rep's credited deals in a period are taken in close-date order, then by deal number. Each deal's credit sits on top of the credit before it: the first deal runs from $0, the next starts where the first ended, and so on. - **R2. Splits.** For a split deal, the split rep's credit is amount × split_pct / 100, rounded half up to the cent. The owner gets the rest (amount minus the split credit), so the two credits always add up to the deal amount exactly. - **R3. Rounding.** Integer cents everywhere. The only rounding happens on each tier slice of each deal line: slice_cents × rate_bps / 10,000, rounded half up to the nearest cent. Compute it in integers: floor((2 × slice × rate + 10,000) / 20,000). A deal line's commission is the sum of its rounded slices. Statement totals are plain sums of lines. Nothing is rounded twice. Clawbacks copy the exact cents that were paid. - **R4. Tiers are marginal.** Tier start points in cents are quota_cents × from_pct / 100 (exact, because quotas are whole dollars). Each part of a deal's credit is paid at the rate of the tier it falls in, like tax brackets. A deal that crosses a tier boundary is split into slices, one per tier, and the statement shows every slice. - **R5. Attainment** is credited bookings / quota, shown to one decimal and rounded down: floor(credited × 1000 / quota) tenths of a percent. It is display only; the tiers use the cent thresholds from R4, not the displayed percent. Adjustments and clawbacks never change attainment. ### Rounding worked example $12,345.65 at 10%: 1,234,565 × 1000 / 10,000 = 123,456.5 cents, rounds up to 123,457 cents = $1,234.57. In the seed data, Rosa's Kestrel new logo deal is $21,612.45 at 10% = $2,161.245, so the line pays $2,161.25. The statement shows both numbers so nobody has to guess. ### Worked examples (with the seed data in section 13) AE Standard plan: 0% to 50% of quota at 5%, 50% to 100% at 10%, 100%+ at 15% (accelerator). Account Manager plan: 0% to 100% at 4%, 100%+ at 6% (accelerator). | Rep, period | Deal and credit | Range covered | Tier math | Commission | |---|---|---|---|---| | Cy, October (quota $60,000) | Bluebird new logo $72,500, Cy 80% = $58,000 | $0 to $58,000 | $30,000 at 5% = $1,500; $28,000 at 10% = $2,800 | $4,300 (attainment 96.6%) | | Rosa, October (quota $50,000) | Bluebird new logo, Rosa 20% = $14,500 | $0 to $14,500 | $14,500 at 5% = $725 | $725 | | Rosa, October | Ridgeway upsell $12,400 | $14,500 to $26,900 | $10,500 at 5% = $525; $1,900 at 10% = $190 | $715 | | Lena, September (quota $80,000) | Acme renewal $88,000 | $0 to $88,000 | $80,000 at 4% = $3,200; $8,000 at 6% = $480 | $3,680 (attainment 110.0%) | | Tom, September (quota $40,000) | Northwind add-on $26,500, Tom 60% = $15,900 | $19,000 to $34,900 | $1,000 at 5% = $50; $14,900 at 10% = $1,490 | $1,540 | | Cy, September | Northwind add-on, Cy 40% = $10,600 | $45,000 to $55,600 | $10,600 at 10% = $1,060 | $1,060 | | Rosa, September | Kestrel new logo $21,612.45 | $27,000 to $48,612.45 | $21,612.45 at 10% = $2,161.245, rounded to $2,161.25 | $2,161.25 | | Any AE at $60,000 quota | One $75,000 deal | $0 to $75,000 | $30,000 at 5% = $1,500; $30,000 at 10% = $3,000; $15,000 at 15% = $2,250 | $6,750 | | Cy what-if, October | A new $25,000 deal | $58,000 to $83,000 | $2,000 at 10% = $200; $23,000 at 15% = $3,450 | +$3,650 (96.6% → 138.3%) | ## 7. Lifecycle ### Period | Action | Who | From | To | Side effects | |---|---|---|---|---| | Open next period | Finance | (none) | open | Creates the month after the latest period. Event `period_opened`. Several periods may be open at once (October waiting for approval while November deals arrive). | | Approve | Finance | open | approved | Only if every earlier period is approved (R6). Calculates every rep's statement and stores it in `snapshot`. Sets approved_by, approved_at. Event `period_approved` with the total. | There is no "unapprove". Mistakes found after approval are fixed with an adjustment or a clawback in an open period (R7). ### Deal | Action | Who | From | To | Side effects | |---|---|---|---|---| | Add | Finance | (none) | closed | Its period must exist and be open. Event `deal_added` | | Edit | Finance | closed | closed | Old and new period must both be open. Event `deal_edited` | | Cancel (period open) | Finance | closed | cancelled | Reason required. The deal drops out and the open period recalculates (R9). Event `deal_cancelled` | | Cancel (period approved) | Finance | closed | cancelled | Reason required. One clawback line per rep who was paid on it, in an open period (R10). Events `clawback_created` (one per line) and `deal_cancelled` | Cancelled is final. To undo a mistaken cancellation, add the deal again. ## 8. Business rules R1 to R5 are in section 6. - **R6. Approval.** Only finance approves. Periods are approved in order: approving November while October is open fails with "Approve October 2026 first. Periods are approved in order." A period is approved once. - **R7. Locked means locked.** Once a period is approved, nothing can change it: no adding, editing or moving deals into it, no adjustments, and its statements are always read from the snapshot. The error says what to do instead: "October 2026 is approved and locked, so you can't add or move deals into it. Use a clawback or adjustment in an open period instead." - **R8. Plan and quota changes** apply to open periods immediately and never to approved ones. The history records the old and new values. - **R9. Cancelling a deal in an open period** removes it from the calculation. No clawback is needed because nothing was paid. - **R10. Cancelling a deal in an approved period** leaves that period exactly as approved. For each rep whose snapshot line for that deal paid more than $0, create a clawback adjustment of minus exactly that line's commission, in the earliest open period (or one finance picks). Do not recalculate the old period's tiers: the clawback reverses what that deal paid, nothing more. If no period is open, refuse with "Open a period first. The clawback has to land in an open period." The cancel form previews the clawback lines before finance confirms. - **R11. Visibility.** A rep sees only their own statement and the deals they are credited on. Opening another rep's statement, by link or by API, returns "You can only see your own statement." The payouts summary, reps, plans, history and export are finance only. - **R12. What if.** A rep enters a deal amount (and optionally a split %) for an open period. The calculator credits it after every deal already in the period, using R2 to R5, and shows the tier slices, the extra commission, and attainment and payout before and after. Nothing is saved. - **R13. Export** is finance only and only for approved periods, so payroll never receives a number that can still move. One row per rep: period, rep, plan, quota, credited_bookings, attainment_pct, deal_commission, adjustments, payout, approved_by, approved_at. Money columns are plain numbers with two decimals (3611.25), no currency symbols or thousands separators. - **R14. History.** Every change writes exactly one event (a cancellation with two clawbacks writes three). Events are never edited or deleted. - **R15. Display.** Money shows thousands separators and two decimals only when cents are non-zero ($4,300 / $2,161.25). Negative amounts show a minus sign (-$925). ## 9. Screens Finance sees: Payouts, Deals, Reps, Plans, History. A rep sees: My statement, My deals, What if. 1. **Payouts** (finance home). Period switcher (August 2026 · Approved, September 2026 · Approved, October 2026 · Open). Totals: payout total, reps, status. Table: rep, plan, quota, credited, attainment, commission, adjustments, payout; each row opens the statement. Buttons: Approve and lock (open periods; asks for confirmation and shows the total), Export CSV (approved periods only; for an open period it explains why not), Open next period. 2. **Statement** (one rep, one period). Header: rep, plan, period, status badge (Open or Approved by Ana on Oct 2). Four numbers: credited bookings, attainment with a progress bar against quota, commission, payout. "How you're paid": the plan's rate card. **Deal lines**, each showing deal, close date, amount, share ("80% of $72,500, split with Rosa Diaz"), the range it covers ("$0 → $58,000 of a $60,000 quota"), and one row per tier slice ("Tier 1 (0% to 50%) · $30,000 at 5% = $1,500"; accelerator slices are labelled). When a slice was rounded, show the exact value too ("$2,161.245, rounded to $2,161.25"). **Adjustments** with reason; clawbacks name the deal and the period it was paid in. Finance on an open period gets an Add adjustment form. 3. **Deals.** Period filter. Table: number, deal, close date, rep (and split), amount, status. Finance: Add deal. Deal page: details, credit per rep, Edit (open periods), Cancel with a reason and a preview of any clawbacks. 4. **Reps** (finance). Name, title, plan, monthly quota, editable inline. Add rep. 5. **Plans** (finance). Each plan's tiers with an editor (start %, rate %) and a live example: "A rep with a $60,000 quota who closes $75,000 earns $6,750." 6. **What if** (reps for themselves; finance picks a rep). Amount, optional split %, period (open periods only). Result: the tier slices for the new deal, "+$3,650", attainment 96.6% → 138.3%, payout $4,300 → $7,950. 7. **History** (finance). Every event, newest first, with who and when. All screens must work on a phone (390px wide) without sideways scrolling. ## 10. Export to payroll v1 has no payroll API. The export is a CSV file in the format of R13 that finance uploads to payroll (QuotaPath's own cycle ends the same way: "Export CSV to upload into payroll"). File name: `payouts-2026-09.csv`. One header row, then one row per rep in the snapshot, in the same order as the Payouts table. ## 11. Periods and time There are no scheduled jobs. Periods are calendar months, and the only thing that moves a period forward is finance clicking Open next period and Approve. Deal close dates decide the period; the time a deal was entered does not. A deal with a close date in a month that has no period yet is refused with "There is no December 2026 period. Finance opens periods on the Payouts page." ## 12. Edge cases - **A rep's payout goes negative.** If Rosa's Kestrel deal ($2,161.25 paid in September) is cancelled in October, her October payout is $515 - $2,161.25 = -$1,646.25. Show it as negative and export it as negative; payroll decides how to recover it. Carrying it forward automatically is a variation. - **A split deal is cancelled after it was paid.** Each rep gets their own clawback line for exactly what they were paid (R10). - **A plan changes mid-month.** Open periods recalculate with the new rates; approved periods do not (R8). The history shows the change. - **Two deals close on the same day.** Credit order falls back to deal number (R1), so the result never depends on the order rows come back from the database. - **Rounding on splits.** $1,000.01 split 50/50: the split rep gets $500.01 (half up), the owner $500.00. They add up to the deal. - **A rep with no plan or quota** cannot be credited on a deal: "Cy Ward has no plan or quota. Set one on the Reps page first." - **Someone edits on a stale screen after approval.** Every write re-checks the period status inside the same transaction as the change. - **A rep opens a link to someone else's statement.** Refused by R11, with the message, not a blank page. ## 13. Seed data | Person | Title | Role | Plan | Monthly quota | |---|---|---|---|---| | Ana Lima | Finance lead | Finance | | | | Dana Ruiz | Head of Ops | Finance | | | | Cy Ward | Senior Account Executive | Rep | AE Standard | $60,000 | | Rosa Diaz | Account Executive | Rep | AE Standard | $50,000 | | Tom Reed | Account Executive | Rep | AE Standard | $40,000 | | Lena Fox | Account Manager | Rep | Account Manager | $80,000 | | Plan | Tiers | |---|---| | AE Standard | 0% to 50%: 5% · 50% to 100%: 10% · 100%+: 15% (accelerator) | | Account Manager | 0% to 100%: 4% · 100%+: 6% (accelerator) | | # | Close date | Deal | Amount | Rep (split) | Notes | |---|---|---|---|---|---| | 1001 | 2026-08-06 | Acme new logo | $32,000 | Cy | | | 1002 | 2026-08-14 | Harbor new logo | $18,500 | Rosa | Cancelled Oct 3 ("Customer cancelled within the 60-day out clause"); clawback -$925 in October | | 1003 | 2026-08-20 | Olsen & Co upsell | $22,000 | Tom | | | 1004 | 2026-08-27 | Bluebird renewal | $64,000 | Lena | | | 1005 | 2026-08-28 | Ridgeway new logo | $40,000 | Cy 70%, Rosa 30% | | | 1006 | 2026-09-03 | Pine Labs new logo | $27,000 | Rosa | | | 1007 | 2026-09-10 | Kestrel new logo | $21,612.45 | Rosa | The rounding example | | 1008 | 2026-09-15 | Bluebird upsell | $45,000 | Cy | | | 1009 | 2026-09-18 | Acme renewal | $88,000 | Lena | Reaches the accelerator | | 1010 | 2026-09-24 | Olsen & Co renewal | $19,000 | Tom | | | 1011 | 2026-09-29 | Northwind add-on | $26,500 | Tom 60%, Cy 40% | | | 1012 | 2026-10-01 | Northwind renewal | $48,000 | Lena | | | 1013 | 2026-10-05 | Acme upsell | $31,800 | Tom | | | 1014 | 2026-10-08 | Bluebird new logo | $72,500 | Cy 80%, Rosa 20% | | | 1015 | 2026-10-09 | Ridgeway upsell | $12,400 | Rosa | | Adjustments: Harbor clawback (above), and Dana's +$300 for Tom in October, "Q4 kickoff SPIF: first multi-year deal of the quarter". Periods: August 2026 approved by Ana on Sep 3, September 2026 approved by Ana on Oct 2, October 2026 open. Build the seed by running the real actions in order (add deals, approve August, and so on), so the snapshots are produced by the same code as everything else. Resulting payouts (your build must match these to the cent): | Period | Cy | Rosa | Tom | Lena | Total | |---|---|---|---|---|---| | August 2026 (approved) | $4,500 | $1,800 | $1,200 | $2,560 | $10,060 | | September 2026 (approved) | $4,060 | $3,611.25 | $2,490 | $3,680 | $13,841.25 | | October 2026 (open) | $4,300 | $515 | $2,480 | $1,920 | $9,215 | ## 14. Build plan Send these prompts one at a time after pasting this file. Each phase ends with checks you can run by clicking through the app. ### Phase 1: Foundation ``` Using the Commission Calculator blueprint above, build Phase 1 only. 1. Set up the data model from section 5 exactly (Person, Plan with tiers, Period, Deal, Adjustment, Event) with a real database. Money is integer cents, rates are integer basis points. Never store money as a float. 2. Add sign-in with the platform's built-in auth. A signed-in email must match an active Person; anyone else sees "Ask finance to add you." Add an "Act as" switcher visible only in development so I can test as each person. 3. Build Reps and Plans (section 9, screens 4 and 5) for finance, with the tier validation from section 5: first tier at 0%, starts strictly increasing, rates 0% to 100%, at most 6 tiers. 4. Build Deals (screen 3): list by period, add and edit with an optional split. Enforce the deal rules in section 5 and R7 for approved periods, on the server. 5. Load the seed data from section 13, including the periods and their statuses. (Approving and snapshots come in Phase 3; for now load the seed periods' statuses as given.) 6. Write one Event per change (R14). Do not calculate commissions yet. When you're done, list what you built and anything in the spec you had to interpret. ``` **Acceptance checks** (use the Act as switcher) - [ ] Reps shows Cy, Rosa and Tom on AE Standard with quotas $60,000, $50,000 and $40,000, and Lena on Account Manager at $80,000. - [ ] Plans shows AE Standard as "0% to 50%: 5% · 50% to 100%: 10% · 100%+: 15% (accelerator)". - [ ] As Ana, add "Harbor renewal", $10,000, closing 2026-10-12, for Lena. It appears in October's deals as #1016. - [ ] Adding a deal split 100% with Rosa is refused with "Split must be a whole percent from 1 to 99." - [ ] Adding a deal dated 2026-08-20 is refused with the "August 2026 is approved and locked" message. - [ ] Acting as Cy, the menu has no Payouts, Reps, Plans or History, and there is no Add deal button. ### Phase 2: The calculation and statements ``` Build Phase 2 of the Commission Calculator blueprint: the calculation and statements. 1. Implement section 6 (R1 to R5) as one pure function that takes a rep's deals, adjustments, plan and quota and returns a Statement (section 5). Integer math only, rounding exactly as R3. Add unit tests for every row of the worked-examples table and the rounding example. 2. Build the Statement screen (section 9, screen 2) with every deal line's tier slices, the exact value when a slice was rounded, and adjustments. 3. Build the Payouts screen (screen 1) without the Approve and Export buttons for now. For open periods calculate live. 4. Approved seed periods: until Phase 3 adds snapshots, calculate them live too. ``` **Acceptance checks** - [ ] Cy's October statement shows Bluebird new logo at "80% of $72,500", credit $58,000, with "$30,000 at 5% = $1,500" and "$28,000 at 10% = $2,800", commission $4,300 and attainment 96.6%. - [ ] Rosa's October statement shows her 20% share of Bluebird new logo ($725) before Ridgeway upsell ($715, across two tiers), the Harbor clawback of -$925, and a payout of $515. - [ ] Lena's September statement shows the accelerator: "$80,000 at 4% = $3,200" and "$8,000 at 6% = $480", labelled as accelerator. - [ ] Rosa's September statement shows Kestrel new logo as "$21,612.45 at 10% = $2,161.25" and the exact $2,161.245 before rounding. - [ ] Payouts shows October at $9,215 and September at $13,841.25, matching section 13 to the cent. ### Phase 3: Approve, lock, clawbacks and export ``` Build Phase 3 of the Commission Calculator blueprint. 1. Approve and lock (R6, R7): store every rep's statement as the period snapshot; approved periods are always read from the snapshot. Re-check the period status inside the same transaction as every write. 2. Open next period. 3. Cancel deal with a reason (R9, R10), including the clawback preview on the cancel form. 4. Manual adjustments on the Statement screen for finance (open periods only). 5. Plan and quota changes reach open periods only (R8). 6. Export CSV for approved periods (R13) and the History screen (R14). ``` **Acceptance checks** - [ ] Ana cancels #1011 Northwind add-on (September) with the reason "Customer churned". The form first previews -$1,540 for Tom and -$1,060 for Cy. Afterwards October shows both clawbacks and September still totals $13,841.25. - [ ] Ana adds a -$150 adjustment for Lena in October ("Correction: duplicate SPIF"). Lena's October payout becomes $1,770. - [ ] Ana changes AE Standard's second tier from 10% to 12%. Cy's October commission becomes $4,860; his September statement still shows $4,060. - [ ] Ana opens November 2026, then tries to approve November and gets "Approve October 2026 first." - [ ] Ana approves October. It shows Approved by Ana Lima, the Approve button is gone, and adding a deal dated 2026-10-20 is refused as locked. - [ ] Export CSV on September downloads payouts-2026-09.csv with a header and 4 rows; Rosa's row ends 3611.25 with approver Ana Lima. On an open period, export is refused with a reason. - [ ] History lists the cancellation, both clawbacks, the adjustment, the plan change and the approval, with who did each. ### Phase 4: Rep statements and the what-if calculator ``` Build Phase 4 of the Commission Calculator blueprint: what reps see. 1. Reps land on their own statement (section 9, screen 2) with a period switcher, see My deals (only deals they are credited on) and the What if screen (screen 6). 2. Enforce R11 on the server: a rep asking for another rep's statement, the payouts summary or the export is refused with the messages in section 8. 3. Implement R12 using the same calculation function as statements, so the what-if can never disagree with the real statement. ``` **Acceptance checks** - [ ] Acting as Cy, the app opens on Cy's own October statement ($4,300). - [ ] Cy opens a link to Rosa's October statement and sees "You can only see your own statement." - [ ] Cy's deals for October show only #1014 Bluebird new logo. - [ ] In What if, Cy enters $25,000 and sees +$3,650, made of "$2,000 at 10% = $200" and "$23,000 at 15% = $3,450", attainment 96.6% → 138.3%, payout $4,300 → $7,950. - [ ] With a 50% split the same deal shows +$1,775. - [ ] Cy's statement still shows $4,300 afterwards: the what-if saved nothing. ## 15. Variations - Retroactive tiers: once a rep crosses a tier, every dollar in the period is paid at the higher rate (simpler to explain, more expensive, and needs a "true-up" line). - Quarterly or annual quotas with monthly payouts against year-to-date attainment. - Import closed-won deals from your CRM as a CSV, matched on deal ID so re-imports update rather than duplicate. - Pay on collection: commission becomes payable when the invoice is paid, not when the deal closes. - Negative balances carry forward automatically into the next period instead of being exported as negative. - Rep sign-off: each rep acknowledges their statement (or flags a line with a question) before finance approves. - Email each rep their statement when the period is approved. - Manager overrides: a sales manager earns a small rate on their team's credited bookings. - SPIF rules (for example $500 per new logo above $50,000) instead of manual adjustment lines. --- ## Can you build your own quote builder instead of paying for PandaDoc? Yes, if your team of 1 to 20 people sends quotes from a price list and does not need legally binding e-signatures or payment collection. This blueprint specifies quotes with line items, line and quote discounts, tax and live totals, templates, a manager approval rule for discounts over a threshold, a client link with Accept and Decline, automatic expiry, a print view and an invoice summary, in four build phases with 29 acceptance checks. The pricing math is spelled out step by step with worked examples, down to rounding. PandaDoc pricing: PandaDoc Starter is $19 per seat per month and allows up to 5 templates; Business is $49 per seat per month with unlimited templates. Proposify's Build plan is $49 per user per month (monthly billing), and its Grow plan, which adds discount-based approvals, is $325 per month for 5 seats. (source: pandadoc.com/pricing and proposify.com/pricing, checked October 2026) **How should quote totals be calculated?** In a fixed order, rounding to the nearest cent at each step: line gross (unit price times quantity), minus the line discount, summed into the lines subtotal; then the quote-level discount on that subtotal; then tax on what remains. For example, 40 seats at $150 with 10% off, plus $4,200 of other items and a 5% quote discount, gives a $9,120 taxable amount, $729.60 of tax at 8%, and a $9,849.60 total. **How does discount approval work?** The total discount (line discounts plus the quote discount) is compared with the list subtotal. If it is strictly over the threshold, 15% in the sample settings, the quote cannot be sent until a manager approves it. Exactly 15.0% does not need approval; 15.5% does. A manager cannot approve their own quote, and rejecting needs a comment. **What happens when a quote expires?** Each quote has a valid-until date, 30 days by default. A daily job marks sent quotes expired from the day after that date, and sends an "expires tomorrow" notification the day before, once per date. The client cannot accept after the date, even if the daily job has not run yet. The owner or a manager can extend the date, which keeps any approval and schedules a new reminder. **Can clients accept a quote online?** Yes. Each sent quote has a private link. The client types their full name, ticks "I accept" and the app records the name and the time; they can also decline. Views are counted only from the client link. This is a recorded acceptance, not an e-signature service with signing certificates. **What data model does a quote builder need?** People and settings (approval threshold, default validity), clients with terms and tax rate, a price list of products, templates (bundles of price-list items), quotes with numbered line items, approvals, invoices, an append-only event log and a notification outbox. Money is stored as integer cents and percentages as integer basis points. **How much does PandaDoc cost?** PandaDoc Starter is $19 per seat per month with up to 5 templates, and Business is $49 per seat per month with unlimited templates (checked October 2026). For 10 people on Business that is $5,880 a year. # Quote Builder Blueprint BP-003 · Department: Sales · Replaces: PandaDoc (quotes and proposals), Proposify, Qwilr For teams of 1 to 20 people. > How to use this file: paste this whole document into your AI builder (Claude, ChatGPT, Cursor, Lovable, Replit, Type) as context. Then send the build prompts in section 15 one at a time. Do not move to the next phase until the acceptance checks for the current phase pass. --- ## 1. The problem "Writing a custom quote takes an hour, every single time." A rep copies last month's quote in a doc, retypes the client's details, looks up prices in a spreadsheet, does the discount math by hand and exports a PDF. Nobody knows which version the client has, whether that 25% discount was ever approved, or whether the quote was opened at all. When the client says yes in an email, someone has to turn the quote into an invoice by retyping it again. ## 2. What the real product does Research basis: PandaDoc pricing and help center (pandadoc.com/pricing, support.pandadoc.com articles 9714665 "Auto expirations", 9714706 "Pricing table. Discounts and taxes", 9714799 "Approval workflow", 9714822 "Review document analytics", pandadoc.com/features/quote/pricing-table, pandadoc.com/features/quote/product-catalog, pandadoc.com/quoting-software), Proposify (proposify.com/pricing, proposify.com/quoting-software) and Qwilr (qwilr.com/pricing, help.qwilr.com/article/175-getting-documents-accepted), October 2026. What these tools do for quoting: 1. **Product catalog.** One price list (name, description, unit, price, SKU) that reps pull line items from, so prices stay consistent. PandaDoc calls it the single source of truth for prices; Proposify includes a product catalog and content library from its Build plan ($49 per user per month, monthly billing). 2. **Pricing table.** Line items with quantity, unit price, a line discount and a total discount (percent or flat), and tax. In PandaDoc the total discount applies to the subtotal after line discounts, and total tax applies to the subtotal. 3. **Templates and bundles.** Reusable templates and product bundles so a common package is one click. PandaDoc Starter ($19 per seat per month) allows up to 5 templates; Business ($49) allows unlimited. 4. **Approval workflows.** Proposify (Grow plan, $325 per month for 5 seats) lets you "add conditions that when met require certain roles to get approval before sending to a buyer. Conditions include price total, discount percentage, and line item discount percentage." PandaDoc routes a document to an approver only when a condition is met (document value, line item discount, total discount), with Approve or Reject plus a comment; a rejected document must be edited and resubmitted. In PandaDoc only Admins and Managers can approve. 5. **Tracking.** The rep is told when the client opens the quote. PandaDoc shows times viewed and last view per recipient (Business and Enterprise plans). 6. **Expiry.** PandaDoc expires documents automatically (60 days by default; Business plans can change it), counts from the moment of sending, can warn the signer 1 day before, shows a countdown when under 5 days, and lets you extend the expiration. Once expired, the client can no longer open it. 7. **Client acceptance.** Qwilr's Accept block can be a single click with no e-signature: the client fills in their name (and email), clicks Accept, and sees a confirmation. Once accepted, a Qwilr page cannot be reversed, deleted or edited; you clone it to make a new version. 8. **After acceptance.** Proposify converts accepted quotes into contracts; PandaDoc and Qwilr can collect payment. ### What this blueprint keeps Clients with terms, a price list, quotes with line items (quantity, unit price, line discount), a quote-level discount, tax and live totals, sequential quote numbers, templates (bundles), a manager approval rule for discounts over a threshold, statuses from draft to accepted, declined or expired, a valid-until date with automatic expiry and a reminder the day before, a client page with Accept (typed name and time) and Decline, a printable view, duplicate, and an invoice summary created from an accepted quote. ### What this blueprint deliberately leaves out Drag-and-drop document designer, rich media, legally binding e-signatures with signing certificates, collecting payment, CRM sync, recurring and volume pricing, multiple currencies, optional items the client can tick, multiple signers, page-level view analytics. These matter for large sales teams or regulated contracts. A team of 1 to 20 people that sends a few quotes a week does not need them in v1. Section 16 lists the most useful ones as variations. ## 3. Scope of v1 - One company (the seller), one currency (USD, set once). Amounts are integer cents. - Two internal roles (sales rep, manager). Clients have no account; they use a private link. - One approval rule: total discount over a threshold percent needs one manager's approval. - Web app that works on a phone. The client page and the print view must look good on paper and on a phone. ## 4. Roles | Role | Who | Can do | |---|---|---| | Sales rep | Anyone who sells | Create quotes. Edit, request approval, send, extend, take back to draft and invoice their own quotes. View and duplicate any quote. Create and edit clients. View the price list, templates and settings. | | Manager | Sales lead, finance lead | Everything a rep can do, on any quote. Approve or reject discount approvals on quotes they do not own and did not submit. Manage the price list, templates and settings. | | Client | The buyer, outside the company | Open the quote link, accept by typing their name, or decline with an optional reason. No sign-in. | Everyone inside the company sees all quotes (it is a small team and deals get handed over). Only the actions above are restricted, and they are checked on the server. ## 5. Data model All timestamps are UTC. Dates without a time (valid_until, issued_on, due_on) are `YYYY-MM-DD`. All IDs are strings. Percentages are stored as integer basis points: 1% = 100, 15% = 1500, 7.5% = 750. **Person** - id, name (required), email (required, unique) - role: rep | manager - active (boolean) **Settings** (one row) - company_name, company_address, company_email (shown on quotes and invoices) - approval_threshold_bp (integer 0 to 10000, default 1500) - validity_days (integer 1 to 365, default 30) - default_tax_rate_bp (integer 0 to 2500, default 800; used for new clients) - default_payment_terms_days (one of 0, 15, 30, 45, 60; default 30) - next_quote_number (integer, starts at 1001), next_invoice_number (integer, starts at 2001) **Client** - id, name (required, unique ignoring case, max 80) - contact_name (required), contact_email (required, valid email) - address (optional, max 200) - payment_terms_days (0, 15, 30, 45 or 60; 0 shows as "Due on receipt", others as "Net 30") - tax_rate_bp (0 to 2500) - created_at **Product** (the price list) - id, sku (required, unique, 2 to 20 characters), name (required, max 80), description (optional, max 200) - unit (required, e.g. "per seat / year", "each", "per hour") - unit_price_cents (integer, 0 to 100,000,000) - active (boolean; archived products cannot be added to quotes) **Template** (a bundle of price-list items) - id, name (required, unique), description (optional) - lines: list of { product_id, qty, discount_bp } - created_by, updated_at **Quote** - id, number (integer, sequential, shown as Q-1042) - owner_id (references Person), client_id (references Client) - title (optional, max 80; lists show the client name when empty) - status: draft | pending_approval | approved | sent | viewed | accepted | declined | expired - valid_until (date) - tax_rate_bp (copied from the client at creation, editable in draft) - quote_discount_bp (0 to 10000) - payment_terms_days (copied from the client at creation) - notes (optional, shown to the client, max 2000) - list_cents, discount_cents, tax_cents, total_cents (computed by section 6 on every save; stored so lists do not recompute) - public_token (nullable; a random string of at least 24 characters, set on send, cleared on back to draft) - sent_at, first_viewed_at, last_viewed_at (nullable timestamps), view_count (integer) - accepted_at, accepted_by_name, declined_at, decline_reason, expired_at (nullable) - expiry_reminder_for (nullable date; the valid_until a reminder was already sent for) - duplicated_from_id (nullable), invoice_id (nullable) - created_at, updated_at **QuoteLine** - id, quote_id, position (1, 2, 3 ...) - product_id (nullable; null means a custom line) - name, description, unit (copied from the product when added) - unit_price_cents (copied from the product when added; typed for custom lines) - qty (decimal with at most 2 places, more than 0, at most 100,000) - discount_bp (0 to 10000) **Approval** - id, quote_id, requested_by, requested_at - discount_bp, total_cents (the numbers the manager approved) - status: pending | approved | rejected | cancelled - decided_by, decided_at, comment (nullable) **Invoice** (summary handed to accounting) - id, number (integer, sequential, shown as INV-2001), quote_id (unique), client_id - issued_on, due_on (dates) - bill_to (copied: client name, contact name, email, address) - lines (copied from the quote), list_cents, discount_cents, tax_cents, total_cents (copied, never recomputed) - created_by, created_at **Event** (activity log; append-only) - id, quote_id, actor_kind: person | client | system, actor_id (nullable) - type: created | edited | approval_requested | approved | rejected | approval_cancelled | sent | viewed | accepted | declined | expired | extended | withdrawn | duplicated | invoiced | reminder_sent - data (JSON, nullable), created_at **Notification** (outbox) - id, person_id (nullable) or email (nullable), channel: in_app | email - kind, quote_id, text, created_at, sent_at, error ## 6. Pricing math Compute totals in this order. Round to the nearest cent at each step, halves round up. Never round percentages; compare them with integers. 1. **Line gross** = unit_price_cents × qty. 2. **Line discount** = line gross × discount_bp / 10000. 3. **Line net** = line gross minus line discount. 4. **List subtotal** (list_cents) = sum of line gross. **Lines subtotal** = sum of line net. 5. **Quote discount** = lines subtotal × quote_discount_bp / 10000. The quote discount applies after line discounts, as in PandaDoc. 6. **Taxable amount** = lines subtotal minus quote discount. 7. **Tax** = taxable amount × tax_rate_bp / 10000. 8. **Total** = taxable amount plus tax. 9. **Total discount** (discount_cents) = sum of line discounts plus quote discount. **Total discount percent** = total discount / list subtotal (0 when the list subtotal is 0). Show it with one decimal ("10.6%"). 10. **Needs approval** when total discount × 10000 > approval_threshold_bp × list subtotal. Strictly greater: exactly 15.0% does not need approval. ### Worked examples (seed data, threshold 15%) | Quote | Lines | List | Discounts | Tax | Total | Approval | |---|---|---|---|---|---|---| | Q-1042 Kestrel Schools (Growth package, 0% tax) | Team seat × 25 at $150; Onboarding package $2,400; Data migration $1,200; Training session × 2 at $400 | $8,150 | $0 | $0 | $8,150 | No | | Q-1039 Acme Logistics (8% tax) | Team seat × 40 at $150, 10% off ($600); Onboarding package $2,400; Premium support $1,800; quote discount 5% of $9,600 = $480 | $10,200 | $1,080 (10.6%) | 8% of $9,120 = $729.60 | $9,849.60 | No | | Q-1040 Ridgeway Partners (8% tax) | Team seat × 100 at $150, 20% off ($3,000); Onboarding package $2,400 | $17,400 | $3,000 (17.2%) | 8% of $14,400 = $1,152 | $15,552 | Yes | | Boundary | Team seat × 20, 15% off | $3,000 | $450 (15.0%) | | | No (not over) | | Boundary | Team seat × 20, 15.5% off | $3,000 | $465 (15.5%) | | | Yes | | Rounding | Integration work × 1.5 hours at $175, 7.5% off | $262.50 | $19.6875 rounds to $19.69 | | net $242.81 | | ## 7. Lifecycle ### Quote status transitions | Action | Who | From | To | Side effects | |---|---|---|---|---| | Create | Rep or manager | (none) | draft | Next quote number; valid_until = today + validity_days; tax and terms copied from the client; event `created` | | Edit | Owner or manager | draft | draft | Totals recomputed; event `edited` | | Request approval | Owner or manager | draft, only when it needs approval | pending_approval | Approval row (pending) with discount and total; notify every active manager except the owner and requester; event `approval_requested` | | Approve | Manager who is not the owner or requester | pending_approval | approved | Approval approved; notify owner; event `approved` | | Reject | Manager who is not the owner or requester | pending_approval | draft | Comment required; approval rejected; notify owner with the comment; event `rejected` | | Send | Owner or manager | draft (no approval needed) or approved | sent | New public_token; sent_at; email the client contact the link; event `sent` | | Client opens link | Client | sent, viewed | viewed | view_count + 1, last_viewed_at; on the first view also first_viewed_at, notify owner, event `viewed` | | Accept | Client | sent, viewed (not past valid_until) | accepted | accepted_by_name, accepted_at; notify owner; email the client a confirmation; event `accepted` | | Decline | Client | sent, viewed | declined | decline_reason (optional), declined_at; notify owner; event `declined` | | Expire | Daily job, or the client opening an out-of-date link | sent, viewed | expired | expired_at; notify owner; event `expired` | | Extend | Owner or manager | approved, sent, viewed | same | New valid_until; expiry_reminder_for cleared; event `extended`. The approval stays valid | | Back to draft | Owner or manager | pending_approval, approved, sent, viewed | draft | Pending approval cancelled or approval cleared; public_token cleared so the old link stops working; view tracking reset; event `withdrawn` (or `approval_cancelled`) | | Duplicate | Rep or manager | any | new draft | New number, owner = the person duplicating, same client and lines (same prices and discounts), fresh valid_until; events `duplicated` on both | | Create invoice | Owner or manager | accepted, no invoice yet | accepted | Invoice with the next invoice number; event `invoiced` | **Final states:** accepted, declined, expired. Nothing changes a quote in a final state except creating its invoice (accepted only). To quote again, duplicate it. ## 8. Business rules - **R1.** Money is integer cents and percentages are integer basis points. Display amounts with thousands separators and two decimals only when cents are non-zero ($2,400 / $2,400.50). - **R2.** Totals follow section 6 exactly, in that order, with rounding at each step. The server recomputes on save; it never trusts totals sent by the browser. - **R3.** A price-list line keeps the price the product had when the line was added. Nobody can change that unit price on the quote. To sell for less, use a line or quote discount, so the approval rule sees it. Custom lines have a typed unit price of $0 or more. - **R4.** A quote whose total discount is over the threshold (section 6, step 10) cannot be sent until a manager approves it. The check uses the threshold in Settings at the moment of sending or requesting approval. - **R5.** A manager cannot approve or reject a quote they own or submitted for approval. Reject needs a comment of at least 3 characters. - **R6.** An approval covers the quote exactly as approved. The quote is locked while awaiting approval and while approved. Back to draft cancels or clears the approval. Changing only the valid-until date (Extend) keeps it. - **R7.** Quote numbers and invoice numbers are sequential, assigned at creation, never reused and never changed. Quotes are never deleted, so there are no gaps. - **R8.** Only the quote's owner or a manager can edit, request approval, send, extend, take back to draft or invoice it. Anyone inside the company can view and duplicate any quote. - **R9.** Editing is only possible in draft. A quote needs a client and at least one line to request approval or send. At most 50 lines. - **R10.** valid_until must be today or later and at most 365 days ahead when saving, sending or extending. An extension must be later than the current date. A quote is valid through the end of its valid_until day (company time zone; UTC in v1) and expired from the next day. - **R11.** Accepting needs the client's typed full name (2 to 100 characters, at least one letter) and the "I accept" box ticked. It records the name and the time. It is refused once today is after valid_until, even if the daily job has not run yet. - **R12.** Views are counted only from the client link, only while the quote is sent or viewed. Opening the quote inside the app never counts as a view. - **R13.** One invoice per accepted quote. The invoice copies the lines and totals exactly (no recalculation, even if prices changed since). due_on = issued_on + the quote's payment terms days. - **R14.** Every action writes exactly one event. Events are never edited or deleted. - **R15.** Changing the price list, a client's tax rate or terms, or Settings never changes existing quotes. Archiving a product removes it from pickers only. - **R16.** Templates contain only price-list items. Applying a template adds its lines at today's prices, after any existing lines. Items that have since been archived are skipped with a message ("Skipped 1 archived item: Legacy setup fee"). ## 9. Screens 1. **Quotes** (home). Four tiles: Open quotes (sent and viewed: count and total), Awaiting approval (count), Accepted in the last 30 days (count and total), Win rate over 90 days (accepted divided by accepted + declined + expired, by the date they reached that state). Below: "Updates for you" (latest in-app notifications). Then the quote list with filter tabs All, Draft, Approval (pending_approval and approved), Open (sent and viewed), Accepted, Closed (declined and expired), and a "Mine only" toggle. Columns: number, title, client, owner, total, status badge, valid until with a countdown ("Expires in 2 days", "Expires today") for open quotes. Button: New quote. 2. **New quote.** The editor (screen 3) with no number yet. Pick the client first (required); start blank or from a template ("Use template" on the Templates screen opens it prefilled). The quote number is assigned on the first save. 3. **Quote editor.** Client, title, valid until, tax rate, quote discount, notes for the client. Line table: item (name, unit), qty, unit price (plain text for price-list lines, an input for custom lines), discount %, line total, move up/down, remove. Add item (picker over active products), Add custom line, Apply template. A totals panel updates as you type: list price, line discounts, quote discount, tax (rate), total, and "10.6% total discount (limit 15%)". When over the limit, a warning says a manager must approve before sending. Inline errors per field; Save does nothing until they are fixed. 4. **Quote page** (internal). Left: the quote as the client will see it. Right: status, owner, valid until, views ("Viewed 3 times, last Oct 12, 2:15 PM"), the client link with Copy, and the actions the viewer may take (Edit, Request approval, Approve / Reject with comment box, Send, Extend, Back to draft, Duplicate, Print, Create invoice, Save as template for managers). Approval box when awaiting approval or after a rejection (who, when, comment). Activity log (collapsed). 5. **Client page** (`/q/`, no sign-in, not indexed). Company name and address, quote number, client, valid until, lines, totals, notes. Accept form (full name, "I accept this quote" box, Accept button) and Decline (optional reason). After a decision: "Accepted by Mark Bell on Oct 13, 2026, 2:15 PM" or "Declined". Expired: "This quote expired on Oct 20. Contact Jo Park at jo@northwind.example for an updated quote." An unknown or withdrawn token: "This quote link is no longer valid." Print button. 6. **Print view.** The quote page and client page print only the document: no navigation, buttons or side panel; one column; lines never cut in half across pages. Use the browser's print dialog (Save as PDF). 7. **Approvals** (managers). Quotes awaiting approval, oldest first: number, client, rep, total, total discount %, waiting time. Approve from the row; Reject on the quote page (it needs a comment). A manager's own requests show "Waiting on another manager" with no buttons. 8. **Clients.** List with contact, terms, tax, open quotes and accepted total. Add and edit. Client page shows its quotes. 9. **Price list.** SKU, name, unit, price, active. Managers add, edit and archive or restore; reps see it read-only. 10. **Templates.** Each template with its lines and list total, and "Use template". Managers create, edit and delete templates, and can save any quote's price-list lines as a template. 11. **Invoices.** List (number, quote, client, issued, due, total) and an invoice summary page with Print and Download CSV. 12. **Settings.** Company details, approval threshold, default validity, default tax rate, default terms. Managers edit; reps see read-only. ## 10. Notifications | Trigger | Recipient | Channel | Content | |---|---|---|---| | Approval requested | Every active manager except the owner and requester | in-app, email | "Q-1040 for Ridgeway Partners ($15,552, 17.2% discount) needs your approval." | | Approved / rejected | Owner | in-app, email | Who decided, and the comment | | Quote sent | Client contact | email | "Jo Park from Northwind sent you quote Q-1042 ($8,150), valid until Nov 10." Link to the client page | | First view | Owner | in-app | "Kestrel Schools opened Q-1042." (the link has no sign-in, so name the client, not a person) | | Accepted | Owner; client contact | in-app; email | "Mark Bell accepted Q-1042 ($8,150)." The client gets a confirmation with the quote details | | Declined | Owner | in-app, email | With the reason | | Expires tomorrow | Owner; client contact | in-app; email | "Q-1041 for Bluebird Health expires tomorrow." | | Expired | Owner | in-app | "Q-1041 expired without a decision." | Write every notification to the outbox first, then send. A failed email keeps its error and is retried by the next job run. A notification failure must never block the action. ## 11. Client link and email - The link is `https:///q/`. Generate the token with a cryptographically secure random generator (at least 24 characters, URL-safe). Never put the quote number or ID in the URL. - The page needs no sign-in. Add `` and do not list these pages anywhere. - Look up the quote by token only. If no quote has that token (wrong, or taken back to draft), show "This quote link is no longer valid." - Opening the page runs the expiry check for that quote first (R10), then counts the view (R12). - Accept and Decline are POST requests that re-check the status and date on the server inside one transaction, so a double click or two people clicking cannot accept twice. - Email can use any provider. If none is configured, write the email to the outbox and log it instead of sending. ## 12. Scheduled job and time Run once a day shortly after midnight (on Vercel, a cron hitting `/api/cron/daily` protected by a `CRON_SECRET` header). Make it accept a "now" override in development so expiry can be tested without waiting. - **Expire.** Every sent or viewed quote whose valid_until is before today becomes expired, with an event and an owner notification. - **Remind.** Every sent or viewed quote whose valid_until is tomorrow, and whose expiry_reminder_for is not already that date, gets the "expires tomorrow" notifications; set expiry_reminder_for = valid_until so it is sent once. Extending clears it, so a new date gets its own reminder. - **Retry** unsent emails. The quote list shows "Expires in N days" for open quotes, "Expires today" on the last day, and highlights quotes with 3 days or fewer left. ## 13. Edge cases - The price of a product changes after it was quoted: existing quotes keep the old price (R3, R15). Duplicating copies the quote's prices, not today's; applying a template uses today's. - A product is archived: existing quotes and invoices are unchanged; it disappears from pickers; templates skip it (R16). - A client edits nothing but asks for a change after the quote was sent: Back to draft, edit, send again. The old link stops working; the client gets a new one. - The client accepts at 11:58 PM on the valid-until day: allowed. At 12:01 AM the next day: refused with the expired message, even if the job has not run. - Two managers decide the same approval on stale screens: the second gets "This approval was already decided." (check status in the same transaction as the update). - The client clicks Accept twice: the second click shows the accepted confirmation; it does not write a second event. - The threshold is lowered while a quote is awaiting approval: the manager still decides; an approved quote stays approved. - Every manager owns the quote or requested it: nobody can approve it. Show "No other manager can approve this. Ask an admin to add a manager." on request. - A quote with a 100% line discount (a free item) is allowed; it counts toward the total discount. - Quantity with more than 2 decimals, a negative price, or a discount over 100%: refused with a field error. ## 14. Seed data Seller: Northwind, 18 Mill Road, Portland, OR 97205, sales@northwind.example. Threshold 15%, validity 30 days, default tax 8%, default terms Net 30. Dates below are relative to today (T). | Person | Role | |---|---| | Dana Ruiz (Sales manager) | manager | | Ana Lima (Finance lead) | manager | | Jo Park (Account executive) | rep | | Lee Park (Account executive) | rep | | Maya Chen (Account executive) | rep | | Client | Contact | Terms | Tax | |---|---|---|---| | Bluebird Health | Priya Shah, priya@bluebird.example | Net 30 | 8% | | Acme Logistics | Tom Reyes, tom@acme.example | Net 30 | 8% | | Ridgeway Partners | Hannah Kim, hannah@ridgeway.example | Net 45 | 8% | | Olsen & Co | Erik Olsen, erik@olsen.example | Net 15 | 8% | | Harbor Foods | Grace Liu, grace@harborfoods.example | Net 30 | 8% | | Kestrel Schools | Mark Bell, mark@kestrel.example | Net 30 | 0% (tax exempt) | | Pine Labs | Noor Haddad, noor@pinelabs.example | Net 30 | 8% | | SKU | Product | Unit | Price | |---|---|---|---| | SEAT | Team seat | per seat / year | $150 | | ONB | Onboarding package | each | $2,400 | | MIG | Data migration | each | $1,200 | | TRN | Training session (2 hours, remote) | per session | $400 | | SUP | Premium support | per year | $1,800 | | INT | Integration work | per hour | $175 | | API | API access add-on | per year | $600 | | OLD | Legacy setup fee (archived) | each | $500 | Templates: Starter package (Team seat × 10, Onboarding, Training × 1: $4,300), Growth package (Team seat × 25, Onboarding, Data migration, Training × 2: $8,150), Enterprise rollout (Team seat × 100, Onboarding, Data migration, Training × 4, Premium support, Integration work × 20: $25,500). | Quote | Client | Owner | Status | Lines | Total | |---|---|---|---|---|---| | Q-1036 | Olsen & Co | Lee | accepted T-28 by Erik Olsen; invoiced as INV-2001 | Starter package | $4,644 | | Q-1037 | Harbor Foods | Maya | declined T-15 ("We went with a cheaper option for this year.") | Team seat × 30, Onboarding, 5% quote discount | $7,079.40 | | Q-1038 | Pine Labs | Jo | expired (valid until T-5), never viewed | Team seat × 15, Training × 1 | $2,862 | | Q-1039 | Acme Logistics | Jo | accepted T-2 by Tom Reyes, not invoiced | worked example in section 6 | $9,849.60 | | Q-1040 | Ridgeway Partners | Lee | awaiting approval (requested 5 hours ago), valid until T+25 | worked example in section 6 | $15,552 | | Q-1041 | Bluebird Health | Jo | viewed 3 times, valid until T+2 | Team seat × 50 at 10%, Onboarding, Training × 2 | $10,746 | | Q-1042 | Kestrel Schools | Jo | sent T-2, not viewed, valid until T+28 | Growth package | $8,150 | | Q-1043 | Pine Labs | Maya | approved by Dana T-1 ("OK for a 60-seat commitment."), valid until T+29 | Team seat × 60 at 18%, Data migration | $9,266.40 | | Q-1044 | Acme Logistics | Jo | draft, valid until T+30 | API access add-on, Training × 2 | $1,512 | Next quote number 1045; next invoice number 2002. With this seed the home tiles show Open quotes 2 ($18,896), Awaiting approval 1, Accepted in 30 days 2 ($14,493.60), Win rate 50%. ## 15. Build plan Send these prompts one at a time after pasting this file. Each phase ends with checks you can run by clicking through the app. ### Phase 1: Foundation ``` Using the Quote Builder blueprint above, build Phase 1 only. 1. Set up the data model from section 5 exactly (Person, Settings, Client, Product, Template, Quote, QuoteLine, Approval, Invoice, Event, Notification) with a real database. Store money as integer cents and percentages as integer basis points. 2. Add sign-in with the platform's built-in auth or Google sign-in limited to our company email domain. A signed-in email must match an active Person; anyone else sees "Ask a manager to add you." 3. Build Clients, Price list and Settings (section 9, screens 8, 9 and 12) with validation from section 5. Reps see the price list and settings read-only; enforce this on the server. 4. Load the seed data from section 14 (people, settings, clients, products, templates; quotes come in Phase 2). 5. Add an "Act as" switcher visible only in development, so I can test as different people. When you're done, list what you built and anything in the spec you had to interpret. ``` **Acceptance checks** - [ ] The Act as switcher lists Dana Ruiz and Ana Lima as managers and Jo Park, Lee Park and Maya Chen as reps. - [ ] The price list shows 7 active products and Legacy setup fee as archived. Acting as Jo, there are no edit controls. - [ ] Acting as Dana, changing API access add-on to $650 saves and shows $650. A price of -5 is refused with an error. - [ ] Adding a client with email "ivy@" is refused. Adding Lumen Studio (Ivy Moss, ivy@lumen.example) works and it shows Net 30 and 8% tax by default. - [ ] Acting as Jo, Settings has no Save button. Acting as Dana, an approval threshold of 120% is refused with an error. ### Phase 2: Quotes and pricing ``` Build Phase 2 of the Quote Builder blueprint: quotes, the editor and templates. 1. Implement the pricing math in section 6 as a pure function with unit tests for every row of the worked-examples table, including the boundary and rounding rows. 2. Build screens 1 to 4 and 10 from section 9: the quote list with tiles and filters, New quote, the editor with live totals, the internal quote page, and Templates. 3. Implement Create, Edit and Duplicate from section 7 and rules R1 to R3, R7 to R9, R15 and R16. Recompute totals on the server on every save. 4. Write one Event per action (R14) and show the activity log on the quote page. 5. Load the seed quotes from section 14 with their history. Do not build approvals, sending or the client page yet. ``` **Acceptance checks** - [ ] Acting as Jo, a new quote for Acme Logistics with Team seat × 40 at 10%, Onboarding package, Premium support and a 5% quote discount shows list $10,200, discounts $1,080, tax $729.60, total $9,849.60 and "10.6% total discount" before saving. - [ ] Saving it creates Q-1045 in Draft, owned by Jo. - [ ] In the editor, a price-list line's unit price is not editable. Adding a custom line "Rush setup" at $300 to Q-1044 raises its total from $1,512 to $1,836. - [ ] A quantity of 0 or a discount of 120% shows an error and nothing is saved. - [ ] A new quote for Kestrel Schools from the Growth package template has 4 lines and a total of $8,150. - [ ] Duplicating Q-1039 creates a draft owned by Jo with the same 3 lines, a total of $9,849.60 and a valid-until date 30 days from today. - [ ] Acting as Jo, Lee's Q-1040 has no Edit button. - [ ] The Open filter shows exactly Q-1041 and Q-1042. ### Phase 3: Approval, sending and the client page ``` Build Phase 3 of the Quote Builder blueprint: discount approval, sending and the client page. 1. Implement Request approval, Approve, Reject, Send, client view, Accept and Decline from section 7, with rules R4 to R6, R11 and R12. Enforce every permission on the server. 2. Build the Approvals screen (screen 7) and the approval box and actions on the quote page. 3. Build the client page (screen 5) exactly as section 11 describes: token lookup, no sign-in, noindex, view counting, and Accept / Decline as POSTs that re-check status inside a transaction. 4. Send the notifications in section 10 for these actions through the outbox. If no email provider is configured, log emails instead of sending. ``` **Acceptance checks** - [ ] On Q-1045, changing the Team seat discount to 20% shows a warning that the 16.2% total discount needs manager approval. On the quote page there is no Send button, only Request approval. - [ ] Jo requests approval. The status is Awaiting approval and, acting as Dana, Approvals lists Q-1040 and Q-1045. - [ ] Dana rejects Q-1045 without a comment and is blocked. She rejects with "Keep seats at 10% max" and Jo sees the quote back in Draft with Dana's comment. - [ ] Acting as Ana, approve Q-1040 from Approvals. Acting as Lee, send it: the status is Sent and a client link is shown. - [ ] Acting as Dana, a quote she owns for Harbor Foods with Team seat × 10 at 50% goes to approval, and her Approvals list shows it as waiting on another manager with no Approve button. Acting as Ana, it can be approved. - [ ] Adding a 15% quote discount to the Kestrel Schools draft gives exactly 15.0% total discount, and Jo can send it without approval. - [ ] Jo sends Q-1044. Its status is Sent and it has a client link. - [ ] Opening Q-1042's client link changes it to Viewed with 1 view. Opening it again shows 2 views and still Viewed, and Jo has exactly one "opened Q-1042" update. - [ ] On Q-1042's client page, Accept without a name is refused. With "Mark Bell" and the box ticked, the page says "Accepted by Mark Bell" with the date and time, and the quote page shows Accepted with no Edit or Back to draft. - [ ] On Q-1044's client page, Decline with "Budget moved to next quarter". The quote page shows Declined and the reason. ### Phase 4: Expiry, print and invoices ``` Build Phase 4 of the Quote Builder blueprint. 1. The daily job in section 12 at /api/cron/daily, protected by a CRON_SECRET header, with a "now" override in development. Show the countdowns on the quote list. 2. Extend and Back to draft from section 7, with R6 and R10. 3. The print view (screen 6) with print CSS for the quote page, the client page and the invoice. 4. Create invoice (R13) and the Invoices screens (screen 11), with Download CSV: one row per line with invoice number, issued, due, client, item, qty, unit price, discount %, line total, and the invoice totals on every row. 5. Managers: Save as template on the quote page. ``` **Acceptance checks** - [ ] Q-1041 shows "Expires in 2 days". After the job runs for tomorrow, Jo has a "Q-1041 for Bluebird Health expires tomorrow" update. The next day it shows "Expires today". The day after, it is Expired and its client page says it expired, with no Accept button. - [ ] Extending Q-1040 to a date 60 days ahead shows the new valid-until date. Extending it to yesterday is refused. - [ ] Back to draft on the sent Kestrel Schools quote makes it a Draft, and its old client link shows "This quote link is no longer valid." - [ ] Create invoice on Q-1039 creates INV-2002 with a total of $9,849.60, due 30 days after today. The Create invoice button is then gone and the quote links to the invoice. - [ ] The invoice's Download CSV gives a file with a header row and one row per line (3 rows). - [ ] Print on a quote opens the browser print dialog, and the printed page shows only the quote document (no demo bar, navigation or side panel). ## 16. Variations - Optional line items the client can tick on the client page (Proposify and Qwilr call this interactive quoting). - Recurring fees shown separately from one-time fees, with a first-year total. - A second approval rule: any single line discount over a limit, or a deal size over an amount. - Volume pricing tiers on a product (1 to 49 seats at $150, 50+ at $130). - E-signature with a signing certificate instead of a typed name. - Collect a deposit with Stripe Checkout on acceptance. - Push the invoice to QuickBooks or Xero instead of a CSV. - Multiple currencies, with the currency fixed per client. --- ## Can you build your own content calendar instead of paying for CoSchedule? Yes. A marketing team of 2 to 15 people can build its own content calendar from this blueprint: a month calendar colored by channel, one record per piece with owner, status, publish date and draft link, a five-step workflow where only an editor can approve a piece into Scheduled, an idea backlog, campaigns you can move as a block, due-soon reminders, overdue flags and a weekly digest. It is built in four phases with 27 acceptance checks. It deliberately leaves out publishing to social networks, AI writing, social analytics and custom statuses. CoSchedule pricing: CoSchedule Starter is $29 per month billed annually for 1 user. Professional is $49 per month billed annually with 2 users included (3 maximum, $29 per extra seat). Campaigns, custom statuses, the table view of unscheduled projects and the team performance report are listed only on the Suite plan, which has custom pricing from $69 per month. (source: coschedule.com/pricing, checked October 2026) **What should a content calendar include?** One record per piece of content with its title, channel, owner, status, publish date and a link to the draft; a month view colored by channel; a backlog of undated ideas; and a clear status workflow. This blueprint uses five fixed statuses (Idea, Writing, In review, Scheduled, Published), campaigns that group pieces, and an activity log on every piece. **How does content approval work?** A piece is sent for review once it has a publish date of today or later and a draft link. Only an editor who does not own the piece can approve it into Scheduled or request changes (with a comment). There is no way to set Scheduled directly, so everything on the calendar marked Scheduled has been approved by someone other than its writer. **What happens when a publish date is missed?** A piece is overdue when its publish date has passed and it is not Published, including Scheduled pieces nobody marked as published. The owner gets one overdue notice per publish date, and the flag clears when the piece is published or moved to today or later. Pieces still in Idea or Writing get a due-soon reminder 0 to 2 days before their date. **Can you move a whole campaign at once?** Yes. Moving a campaign by N days moves the campaign's dates and every dated, unpublished piece in it by N days, all or nothing. Moving keeps each piece's status: a Scheduled piece stays Scheduled, because the approval covers the content, not the date. **What data model does a content calendar need?** Person (with an editor role), Channel (name and color), Campaign (name and dates), Piece (title, channel, campaign, owner, status, publish date, draft link, reviewer and approval fields), Activity (an append-only log), Notification (an outbox for in-app and email messages) and Settings (team time zone and default reviewer). **How much does CoSchedule cost?** CoSchedule Starter is $29 per month billed annually for 1 user, and Professional is $49 per month billed annually with 2 users included and a maximum of 3. Campaigns and custom statuses are listed only on the Suite plan, from $69 per month with custom pricing (checked October 2026). # Content Calendar Blueprint BP-004 · Department: Marketing · Replaces: CoSchedule, Notion and Asana content calendar templates For teams of 2 to 15 people. > How to use this file: paste this whole document into your AI builder (Claude, ChatGPT, Cursor, Lovable, Replit, Type) as context. Then send the build prompts in section 15 one at a time. Do not move to the next phase until the acceptance checks for the current phase pass. --- ## 1. The problem "Our content plan lives in four different docs and nobody trusts any of them." A small marketing team publishes on a blog, a newsletter, LinkedIn, X and YouTube. The plan is a spreadsheet, a Notion page, a Slack thread and somebody's head. Nobody can say what goes out this week, who is writing it, whether the editor has read it, or which pieces quietly slipped past their date. Ideas get posted in chat and are never seen again. ## 2. What the real product does Research basis: CoSchedule's Marketing Calendar and pricing pages (coschedule.com/marketing-calendar, coschedule.com/pricing), Asana's editorial calendar template and guide (asana.com/templates/editorial-calendar, asana.com/resources/editorial-calendar), and Notion's Basic Content Calendar template (notion.com/templates/content-calendar), October 2026. What these tools have in common: 1. **One calendar for every channel.** CoSchedule puts social, blog, email, events and podcasts on a single Marketing Calendar, with customizable color labels to tell them apart. Notion's template tracks "everything you're putting out, from blog posts to podcasts to tweets." 2. **A record per piece.** Asana's template tracks the due date, primary channel, content status (for example "not started", "in review", "ready to publish"), content type, approval status and publish date for each piece, with an owner. 3. **Rescheduling.** CoSchedule lets you drag posts to new dates, "or move a campaign's launch date and its schedule moves with it." Asana's guide stresses shifting deadlines and owners on the fly. 4. **Approval before publishing.** Asana's workback schedule includes "Get approval from editor". CoSchedule sells "Social Message Approvals" ("ensure all planned social messages get required approval before publishing") from its Professional plan up. 5. **Campaigns.** CoSchedule groups campaign projects in a focused calendar view. 6. **Unscheduled work and team visibility.** CoSchedule's table view shows "scheduled and unscheduled projects", and its team report shows each teammate's completed, due and overdue tasks. Asana suggests a weekly or biweekly check-in on the calendar. Pricing context: CoSchedule Starter is $29 per month billed annually for 1 user; Professional is $49 per month billed annually with 2 users included (3 maximum, $29 per extra seat). Campaigns, custom statuses, the table view of unscheduled projects and the team performance report are listed only on the Suite plan (custom pricing, from $69 per month). ### What this blueprint keeps A month calendar colored by channel, one record per piece (title, channel, owner, status, publish date, draft link), a fixed five-step status workflow with an editor approval step, an idea backlog anyone can add to, campaigns that group pieces and can be moved as a block, a due-soon reminder, an overdue flag, a weekly digest, and an activity log on every piece. ### What this blueprint deliberately leaves out Publishing to social networks, AI writing, best-time scheduling, social inboxes and analytics, custom statuses and fields, per-client calendars, a document editor (v1 links to your Google Doc or Notion page), and drag and drop (v1 uses a "Move to date" control that works the same on a phone). A team of 2 to 15 people mainly needs to know what is going out, who owns it, and whether it has been approved. ## 3. Scope of v1 - One team, one calendar, one time zone (set once; publish dates are calendar dates in that zone). - Five fixed statuses: Idea, Writing, In review, Scheduled, Published. - Channels are editable (name and color). Campaigns are optional. - Notifications in the app plus email. If no email provider is configured, log emails instead of sending. - Web app that works on a phone. ## 4. Roles | Role | Who | Can do | |---|---|---| | Writer | Everyone on the team | Add ideas to the backlog. Own pieces. On pieces they own: edit, move to a date, start writing, send for review, withdraw from review, mark published, reopen. Comment on any piece. Delete ideas they added. | | Editor | One or more people | Everything a writer can do, on any piece. Approve or request changes on pieces in review that they do not own. Assign pieces to other people. Manage channels and campaigns. | Everyone can see every piece. Only the actions above are restricted, and they are enforced on the server, not only by hiding buttons. ## 5. Data model All timestamps are UTC. "Date" means a calendar date (YYYY-MM-DD) in the team time zone. All IDs are strings. **Person** - id - name (required) - email (required, unique) - role: writer | editor - active (boolean, default true) **Channel** - id - name (required, max 30 chars, unique ignoring case) - color (hex, "#rrggbb") - active (boolean). Inactive channels keep their pieces but are hidden from pickers. **Campaign** - id - name (required, max 80 chars, unique ignoring case) - starts_on, ends_on (dates; ends_on >= starts_on) - goal (optional, max 200 chars) - created_by, created_at **Piece** - id - title (required, max 120 chars) - channel_id (required) - owner_id (required, an active Person) - campaign_id (nullable) - status: idea | writing | in_review | scheduled | published - publish_on (nullable date; null means unscheduled, shown in the backlog) - draft_url (nullable, http or https URL) - notes (optional, max 2,000 chars) - reviewer_id (nullable; the editor asked to review, set on submit) - submitted_at, approved_by, approved_at, published_at (nullable) - reminded_for (nullable date; the publish_on value the due-soon reminder was sent for) - overdue_notified_for (nullable date; the publish_on value the overdue notice was sent for) - created_by, created_at, updated_at **Activity** (append-only log per piece; never edited or deleted) - id, piece_id, actor_id (null for the scheduled job) - type: created | edited | moved | started_writing | parked | submitted | withdrawn | approved | changes_requested | reopened | published | commented | reminded | overdue | deleted - comment (nullable), data (JSON, nullable; for example the from and to dates of a move), created_at **Notification** (outbox) - id, person_id, kind (review_requested | approved | changes_requested | assigned | commented | due_soon | overdue | digest), piece_id (nullable), text, created_at, read (boolean), emailed_at (nullable), error (nullable) **Settings** (one row) - time_zone (IANA name, for example "America/Chicago") - default_reviewer_id (the editor who gets review requests by default, usually the managing editor) - last_digest_on (nullable date) Overdue is not a column. It is computed from publish_on, today and status (R12). ## 6. Status workflow The normal path is Idea → Writing → In review → Scheduled → Published. Pieces can go back: Writing → Idea (park), In review → Writing (withdraw or request changes), Scheduled → Writing (reopen). | Action | Who | From | To | Side effects | |---|---|---|---|---| | Add | Anyone | (none) | Idea | Activity `created`. If the owner is someone else, notify them (`assigned`) | | Start writing | Owner or editor | Idea | Writing | Activity `started_writing` | | Park as idea | Owner or editor | Writing | Idea | Activity `parked` | | Send for review | Owner or editor | Writing | In review | Needs a publish date of today or later and a draft link (R4). Sets reviewer_id and submitted_at. Notify the reviewer | | Withdraw | Owner | In review | Writing | Clears reviewer and submitted_at | | Request changes | Editor who is not the owner | In review | Writing | Comment of 3+ characters required. Notify the owner with the comment | | Approve | Editor who is not the owner | In review | Scheduled | Publish date must be today or later. Sets approved_by and approved_at. Notify the owner | | Reopen | Owner or editor | Scheduled | Writing | Clears the approval. The piece must be reviewed again | | Mark published | Owner or editor | Scheduled | Published | Only on or after the publish date (R9). Sets published_at | | Move to date | Owner or editor | Idea, Writing, In review, Scheduled | same | R7, R8, R10. Activity `moved` with from and to | | Delete | Whoever added it, or an editor | Idea | (removed) | Activity `deleted` with the title | | Comment | Anyone | any | same | Activity `commented`. Notify the owner unless they wrote it | Any other transition is refused. Published is final: a published piece can only receive comments. ## 7. Business rules - **R1.** Every piece starts as Idea. The only way into Scheduled is an editor's approval from In review. There is no button, API or bulk action that sets Scheduled directly. - **R2.** The transitions in section 6 are the only ones allowed. Anything else is refused with a message that names the current status ("Only pieces in review can be approved. This piece is Writing."). - **R3.** Nobody approves their own piece. Only editors approve or request changes, and never on a piece they own. On submit, the reviewer is the editor the sender picks. If they pick nobody, it is the default reviewer from Settings, unless that person owns the piece or is inactive; then it is the first active editor by name who is not the owner. If there is no such editor, submitting is refused: "No other editor can review this piece. Ask an editor to take it over, or add an editor." Any editor who is not the owner may decide; reviewer_id only says who was asked. - **R4.** Sending for review needs a publish date that is today or later and a draft link. Messages: "Pick a publish date before sending for review.", "The publish date has passed. Move it to today or later first.", "Add a draft link before sending for review." - **R5.** Requesting changes needs a comment of at least 3 characters. The piece returns to Writing and any approval is cleared. - **R6.** Approving needs a publish date of today or later ("The publish date has passed. Move it to today or later before approving."). - **R7.** Moving a piece to a date before today is refused: "Pick today or a later date." Published pieces cannot be moved or edited. - **R8.** Only Idea and Writing pieces can be unscheduled (moved back to the backlog). For In review and Scheduled: "A piece in review or scheduled needs a publish date. Send it back to Writing first." - **R9.** Mark published is refused before the publish date: "This piece is scheduled for Oct 24. If it went out early, move it to today first." This keeps the calendar truthful. - **R10.** Moving a piece keeps its status. A Scheduled piece stays Scheduled: the approval covers the content, not the date. Moving clears reminded_for and overdue_notified_for, so reminders apply again to the new date. - **R11.** Due-soon reminder: a piece in Idea or Writing whose publish date is 0, 1 or 2 days away gets one reminder to its owner, once per publish date (reminded_for). - **R12.** Overdue: a piece is overdue when its publish date is before today and it is not Published. This includes Scheduled pieces nobody marked published. The owner gets one overdue notice per publish date (overdue_notified_for). The flag disappears as soon as the piece is published or moved to today or later. - **R13.** Who can change a piece: its owner or an editor ("Only the owner or an editor can change this piece."). Anyone can add an idea. Only editors can set an owner other than themselves ("Only editors can assign a piece to someone else."). - **R14.** Only ideas can be deleted, by the person who added them or an editor. Everything else keeps its history; park a piece as an idea first if it really should go. - **R15.** Moving a campaign by N days moves the campaign's dates and every dated, unpublished piece in it by N days, all or nothing (section 10). - **R16.** A piece dated outside its campaign's dates shows an "Outside campaign dates" warning. It is allowed. - **R17.** Every action writes exactly one Activity row (a campaign move writes one per piece moved). Activity rows are never edited or deleted. ## 8. Dates, reminders, overdue and the daily job "Today" is the current date in the team time zone. Days are counted as calendar days, so a piece due tomorrow is 1 day away whether it is 9 am or 11 pm. **Daily job** (runs every day at 08:00 team time; on Vercel use a cron hitting `/api/cron/daily` protected by a `CRON_SECRET` header). For each piece: 1. If it is due soon (R11) and reminded_for is not its publish_on: notify the owner, set reminded_for, write a `reminded` activity. 2. If it is overdue (R12) and overdue_notified_for is not its publish_on: notify the owner, set overdue_notified_for, write an `overdue` activity. Then, if today is Monday and last_digest_on is not today, send the weekly digest (section 9) and set last_digest_on. The job is safe to run twice on the same day: the second run sends nothing. Accept an optional "now" override in development so time-based behavior can be tested without waiting. ### Worked examples (seed data, today = Tuesday Oct 13) | Piece | Status | Publish on | Days away | Reminder this morning? | Overdue? | |---|---|---|---|---|---| | Ridgeway onboarding story | Writing | Thu Oct 15 | 2 | Yes, to Lee | No | | 5 reporting mistakes we see every quarter | Writing | Sat Oct 17 | 4 | No. It fires on Thu Oct 15 if still not in review | No | | New pricing FAQ | In review | Fri Oct 16 | 3 | No (already in review) | No | | A note from Dana on the pricing change | Scheduled | Wed Oct 14 | 1 | No (approved) | No | | Launch thread: new pricing | Scheduled | Tue Oct 13 | 0 | No | Not today. From Wed Oct 14 if nobody marks it published | | Acme case study video | Writing | Mon Oct 12 | -1 | No (past dates get the overdue notice instead) | Yes. Sam is notified once | | Kestrel customer interview | Idea | Mon Nov 2 | 20 | No | No | | Pine Labs integration walkthrough | Idea | none | none | Never while unscheduled | Never | More cases: - Lee moves Ridgeway from Oct 15 to Oct 16 on Oct 13. reminded_for is cleared, so the job reminds Lee again on Oct 14 (2 days away from the new date). - Lee sends Ridgeway for review on Oct 14. No further reminders: In review pieces are not "due soon". - Sam moves Acme to Oct 20. The overdue flag disappears immediately; nothing more is sent until Oct 20 is in the past. ## 9. Weekly digest Sent every Monday by the daily job to every active person (in the app and by email). A preview screen shows the digest as it would look if sent today. The digest covers the 7 days starting on its date and has these sections, each left out when empty except the first: 1. **Publishing in the next 7 days**, grouped by day: title, channel, status, owner. Anything not Scheduled or Published is marked "not approved yet". 2. **Overdue**: every overdue piece with its original date. 3. **Waiting for review**: pieces in review, oldest first, with whole days waiting and the reviewer. 4. **Due soon, not in review yet**: the R11 set. 5. **Backlog**: number of unscheduled pieces (ideas and writing), plus titles added in the last 7 days. 6. **Published in the last 7 days**: pieces with status Published and a publish date in the 7 days before the digest date. ### Worked example (preview on Tue Oct 13, covers Oct 13 to Oct 19) - Publishing: Tue Oct 13 Launch thread (X, Scheduled); Wed Oct 14 A note from Dana (LinkedIn, Scheduled); Thu Oct 15 Ridgeway onboarding story (Blog, Writing, not approved yet); Fri Oct 16 New pricing FAQ (Blog, In review, not approved yet); Sat Oct 17 5 reporting mistakes (Blog, Writing, not approved yet); Sun Oct 18 October newsletter (Newsletter, In review, not approved yet); Mon Oct 19 Olsen & Co (LinkedIn, Scheduled). - Overdue: Acme case study video, was due Mon Oct 12, Writing, Sam. - Waiting for review: October newsletter (2 days, Maya), New pricing FAQ (1 day, Maya). - Due soon: Ridgeway onboarding story (in 2 days, Lee). - Backlog: 5 unscheduled (4 ideas, 1 in writing). New this week: Year in review thread. - Published in the last 7 days: Sun Oct 11 Pricing teardown (LinkedIn). How Bluebird cut reporting time in half (Oct 5) is 8 days back and is not listed. ## 10. Campaigns A campaign is a name, a date range and an optional goal. Pieces join a campaign through campaign_id. The campaign page lists its pieces by date with counts per status ("2 of 5 published"). **Moving a campaign (R15).** An editor enters a number of days (whole number, not 0, between -365 and 365). The app computes the new date of every piece in the campaign that has a publish date and is not Published. If any new date would be before today, nothing changes and the error names the first such piece: '"Launch thread: new pricing" would move to Oct 10, which is in the past. Nothing was moved.' Otherwise the campaign's start and end move by N days and each of those pieces moves by N days (status kept, reminders re-armed, one `moved` activity each). Published and unscheduled pieces stay where they are. Worked example, today Tue Oct 13, "Q4 pricing launch" runs Oct 10 to Oct 31: - Move by -3: Launch thread (Oct 13) would land on Oct 10. Refused; nothing moves. - Move by +7: the campaign becomes Oct 17 to Nov 7. Launch thread Oct 13 → Oct 20 (still Scheduled), A note from Dana Oct 14 → Oct 21, New pricing FAQ Oct 16 → Oct 23, Pricing explainer video Oct 22 → Oct 29. Pricing teardown (Published, Oct 11) stays and now shows "Outside campaign dates". ## 11. Screens 1. **Calendar** (home). Month grid, Monday first, with previous, next and today buttons. Each day lists its pieces as chips: a channel-colored edge and dot, the title, and a small status label; overdue pieces show an "Overdue" flag. Filters: channel chips (click to hide or show a channel) and "Only mine". Clicking a chip selects it and opens a **Move to date** panel: a date field, a Move button, Back to backlog (Idea and Writing only), and Open. Clicking an empty part of a day opens New piece with that date filled in. On a phone, the grid is replaced by an agenda list of the month's days that have pieces. 2. **Pipeline.** A toggle between Board and List. Board: five columns by status with counts; cards show title, channel, owner, date and flags. List: a table sorted by date (unscheduled last) with filters for status, channel, owner and campaign. 3. **Backlog.** A quick-add form at the top (title, channel) that anyone can use; the new idea is owned by its author. Below, all unscheduled pieces, newest first, each with a date field and a Schedule button. 4. **Review queue** (editors). Pieces in review that the viewer does not own, oldest submission first: title, owner, publish date, days waiting, draft link, Approve button. Request changes opens the piece because it needs a comment. 5. **Piece detail.** Title, channel, status, flags (Overdue, Due soon, Outside campaign dates), owner, reviewer, approval, publish date, campaign, draft link, notes. Workflow buttons only for actions the viewer may take (section 6). Move to date. Edit form. Comments and the activity log, newest last. 6. **New piece.** Title, channel, owner (writers see only themselves), publish date (optional; empty means backlog), campaign, draft link, notes. Always created as Idea. 7. **Campaigns.** List with dates, goal, piece counts and progress. Campaign page with its pieces and, for editors, Edit and Move campaign. 8. **Inbox.** The viewer's notifications, newest first, with an unread count in the navigation and Mark all read. 9. **Weekly digest.** The digest as it would go out today (section 9) and the date of the next Monday send. 10. **Settings.** Channels (name and color; editors can add, rename, recolor and deactivate) and the default reviewer. Writers see this screen read-only. ## 12. Notifications | Trigger | Recipient | Content | |---|---|---| | Sent for review | Reviewer | "Jo Park sent "New pricing FAQ" for review. It publishes Fri Oct 16." | | Approved | Owner | "Maya Chen approved "New pricing FAQ". It is scheduled for Fri Oct 16." | | Changes requested | Owner | Who, and the comment | | Assigned | New owner | "Maya Chen gave you "Kestrel customer interview"." | | Comment | Owner (not the author) | Who, and the comment | | Due soon (daily job) | Owner | ""Ridgeway onboarding story" publishes in 2 days (Thu Oct 15) and is not in review yet." | | Overdue (daily job) | Owner | ""Acme case study video" was due Mon Oct 12 and is not published (Writing). Move it to a new date or finish it." | | Weekly digest (Mondays) | Every active person | Section 9 | Write every notification to the outbox first, then send the email. A failed email keeps its error and is retried on the next run of the daily job. A notification failure never blocks an action. ## 13. Edge cases - The only editor owns the piece: sending for review is refused (R3). Another editor must be added, or an editor must take the piece over. - An editor reassigns a piece in review to themselves: they can no longer approve it; another editor can. - A Scheduled piece is moved to a later date: it stays Scheduled (R10). - A piece is moved to a new date after its reminder was sent: the reminder applies again to the new date (R10). - A Scheduled piece's date passes without anyone marking it published: it shows as overdue (R12) until someone marks it published (allowed, since the date has passed) or moves it. - An overdue piece in review: the editor cannot approve it until someone moves it to today or later (R6). - A channel is deactivated: its pieces keep it and stay on the calendar; it disappears from pickers. The last active channel cannot be deactivated. - A campaign move that would put any piece in the past changes nothing (R15). - Two people act on the same piece from stale screens: check the status inside the same transaction as the update; the second action gets the R2 message. ## 14. Seed data | Person | Role | |---|---| | Maya Chen | Editor (default reviewer) | | Dana Ruiz | Editor | | Jo Park | Writer | | Lee Park | Writer | | Sam Okafor | Writer | | Ana Lima | Writer | Channels: Blog (#2c5ac9), Newsletter (#1f7a4d), LinkedIn (#0e7490), X (#3f3f46), YouTube (#c2410c). Campaigns (T = today): Q4 pricing launch, T-3 to T+18, goal "Explain the new pricing to existing customers before renewals."; Customer stories, T-12 to T+33, goal "One customer story every week." | Piece | Channel | Owner | Status | Publish on | Campaign | |---|---|---|---|---|---| | How Bluebird cut reporting time in half | Blog | Jo | Published | T-8 | Customer stories | | September newsletter | Newsletter | Ana | Published | T-12 | | | Pricing teardown: what we changed and why | LinkedIn | Dana | Published | T-2 | Q4 pricing launch | | Acme case study video | YouTube | Sam | Writing | T-1 (overdue) | Customer stories | | Launch thread: new pricing | X | Lee | Scheduled (approved by Maya) | T | Q4 pricing launch | | A note from Dana on the pricing change | LinkedIn | Dana | Scheduled (approved by Maya) | T+1 | Q4 pricing launch | | Ridgeway onboarding story | Blog | Lee | Writing | T+2 | Customer stories | | New pricing FAQ | Blog | Jo | In review (Maya, sent 1 day ago) | T+3 | Q4 pricing launch | | 5 reporting mistakes we see every quarter | Blog | Jo | Writing, no draft link | T+4 | | | October newsletter | Newsletter | Ana | In review (Maya, sent 2 days ago) | T+5 | | | Olsen & Co: from spreadsheets to one dashboard | LinkedIn | Maya | Scheduled (approved by Dana) | T+6 | Customer stories | | Pricing explainer video | YouTube | Sam | Writing | T+9 | Q4 pricing launch | | Harbor webinar recap | Blog | Ana | Scheduled (approved by Maya) | T+11 | | | Kestrel customer interview | YouTube | Sam | Idea | T+20 | Customer stories | | Pine Labs integration walkthrough | Blog | Lee | Idea | none | | | Year in review thread (added yesterday) | X | Jo | Idea | none | | | Reporting templates roundup | Newsletter | Ana | Idea | none | | | Behind the scenes: how we plan content | LinkedIn | Maya | Idea | none | | | Data glossary for new customers | Blog | Jo | Writing | none | | Counts: 5 Idea, 5 Writing, 2 In review, 4 Scheduled, 3 Published. Every piece except "5 reporting mistakes" and the ideas has a draft link. Run the daily job once after seeding so Lee's due-soon reminder and Sam's overdue notice exist. ## 15. Build plan Send these prompts one at a time after pasting this file. Each phase ends with checks you can run by clicking through the app. ### Phase 1: Foundation ``` Using the Content Calendar blueprint above, build Phase 1 only. 1. Set up the data model from section 5 exactly (Person, Channel, Campaign, Piece, Activity, Notification, Settings) with a real database. 2. Add sign-in with the platform's built-in auth or Google sign-in limited to our company email domain. A signed-in email must match an active Person; anyone else sees "Ask an editor to add you." 3. Build the Settings screen (channels and default reviewer) and the Campaigns list and form (section 11, screens 7 and 10). Only editors can change them; writers see them read-only. 4. Load the seed data from section 14, with dates relative to today. 5. Add an "Act as" switcher visible only in development, so I can test as different people. Do not build pieces or the calendar yet. When you're done, list what you built and anything in the spec you had to interpret. ``` **Acceptance checks** - [ ] Six people exist, and the switcher marks Maya Chen and Dana Ruiz as editors. - [ ] As Jo, Settings shows the five channels read-only with "Only editors can change channels." - [ ] As Maya, adding a channel called "blog" is refused with "A channel called Blog already exists.", and adding "Podcast" with a color works. - [ ] As Maya, a campaign "Holiday gift guide" that ends before it starts is refused with "The end date must be on or after the start date."; with valid dates it appears in Campaigns. - [ ] As Jo, Campaigns has no New campaign form and says "Only editors can manage campaigns." ### Phase 2: Pieces, calendar, backlog and pipeline ``` Build Phase 2 of the Content Calendar blueprint: pieces and the views of them. 1. Build screens 1, 2, 3, 5 and 6 from section 11: Calendar, Pipeline (board and list), Backlog, Piece detail and New piece. 2. Implement Add, Edit, Move to date, Back to backlog and Delete from section 6, with rules R7, R8, R10, R13, R14, R16 and R17. Enforce permissions on the server, not only by hiding buttons. 3. Color every piece by its channel. Show the Overdue flag from R12 everywhere a piece appears. 4. Write one Activity row for every action and show the activity log on the piece page. No drag and drop: use the Move to date control so it works the same on a phone. ``` **Acceptance checks** - [ ] The calendar opens on the current month with each seeded piece on its date in its channel color, and "Acme case study video" shows an Overdue flag. - [ ] Clicking an empty day opens New piece with that date filled in. - [ ] As Jo, adding the idea "Customer FAQ roundup" (Blog) on the Backlog page with no date puts it in the Backlog as an Idea owned by Jo, and not on the calendar. - [ ] Scheduling it from the Backlog for 5 days from today removes it from the Backlog and shows it on the calendar on that day. - [ ] Moving it to yesterday is refused with "Pick today or a later date." - [ ] As Jo, Lee's "Ridgeway onboarding story" shows no workflow buttons, and moving it is refused with "Only the owner or an editor can change this piece." - [ ] As Jo, the New piece owner field offers only Jo; as Maya it lists everyone. - [ ] Pipeline board shows Idea 5, Writing 5, In review 2, Scheduled 4, Published 3, and the list filtered to Blog shows only Blog pieces. ### Phase 3: Review and publishing ``` Build Phase 3 of the Content Calendar blueprint: the status workflow. 1. Implement every transition in section 6 and rules R1 to R6 and R9. Put the transition rules in one pure function module with unit tests for each row of the section 6 table. 2. Show only the workflow buttons the viewer may use on the piece page, and refuse anything else on the server. 3. Build the Review queue (section 11, screen 4) for editors, with Approve on each row. 4. Build the Inbox (screen 8) and write notifications from section 12 for review requests, approvals, requested changes, assignments and comments. Email can be logged instead of sent for now. 5. Do the status check and the update in one transaction. ``` **Acceptance checks** - [ ] As Jo, sending "5 reporting mistakes we see every quarter" for review is refused with "Add a draft link before sending for review." After adding a draft link it goes to In review with Maya as reviewer, and Maya's inbox shows the request. - [ ] As Jo, her piece in review shows Withdraw and no Approve button. - [ ] As Maya, Request changes with an empty comment is refused; with "Tighten the intro" the piece goes back to Writing and Jo sees the comment in her inbox. - [ ] Jo sends it again and Maya approves it from the Review queue. It is Scheduled and shows "Approved by Maya Chen". - [ ] As Maya, her own "Behind the scenes: how we plan content", once dated, linked and sent for review, goes to Dana; Maya sees no Approve button on it, and Dana can approve it. - [ ] Marking "Harbor webinar recap" published is refused because its date is in the future; marking "Launch thread: new pricing" published works and the calendar shows it as Published. - [ ] The activity log of "5 reporting mistakes we see every quarter" lists sent for review, changes requested (with the comment), sent for review and approved, each with who and when. ### Phase 4: Reminders, overdue, digest and campaigns ``` Build Phase 4 of the Content Calendar blueprint. 1. The daily job in section 8 (due-soon reminders, overdue notices, Monday digest) at /api/cron/daily, protected by a CRON_SECRET header. Accept an optional "now" override in development, and add a development-only "+1 day" button that moves the app clock forward one day and runs the job. 2. The Weekly digest preview screen (section 9) and the digest email. 3. Campaign pages with Move campaign (section 10, R15) and the "Outside campaign dates" warning (R16). 4. Send emails from the Notification outbox. If no email provider is configured, log them. ``` **Acceptance checks** (start from fresh seed data) - [ ] Right after seeding, Lee's inbox has the due-soon reminder for "Ridgeway onboarding story" and Sam's has the overdue notice for "Acme case study video". - [ ] The Weekly digest preview for today lists 7 pieces publishing in the next 7 days, Acme as overdue, October newsletter (2 days) and New pricing FAQ (1 day) waiting for review, Ridgeway as due soon, and 5 unscheduled pieces in the backlog. - [ ] After "+1 day", "Launch thread: new pricing" shows as Overdue and Lee gets one overdue notice. - [ ] After a second "+1 day", Jo gets a due-soon reminder for "5 reporting mistakes we see every quarter"; a third "+1 day" does not send it again. - [ ] As Sam, moving "Acme case study video" to a date next week clears its Overdue flag. - [ ] As Maya, moving "Q4 pricing launch" by -3 days is refused because "Launch thread: new pricing" would land in the past; by +7 days, its four unpublished pieces move 7 days later and "Pricing teardown: what we changed and why" stays put and shows "Outside campaign dates". - [ ] Pressing "+1 day" until the next Monday puts the weekly digest in every person's inbox, once. ## 16. Variations - Slack reminder two days before something is due, and the digest posted to a channel. - A read-only "coming soon" page or iCal feed for the rest of the company. - Recurring pieces (the newsletter every first Tuesday) generated a few weeks ahead. - A second approval step for legal or a client on selected channels. - Drag and drop on desktop, calling the same Move to date action. - Per-channel publishing cadence with a warning when a week has a gap. --- # Testimonial Wall Blueprint BP-005 · Marketing · For teams of 1–10 · Replaces a small slice of Senja > Give this document to your builder as context, then send the four phase prompts in order. The included demo implements the local workflow, not a hosted collection service. Acceptance checkboxes below are a verification plan, not recorded passes. ## 1. The problem and the smallest useful outcome “Our best customer quotes are buried in emails and call notes.” A marketing operator needs a repeatable path from a customer's own words to an approved website quote. Copying a flattering sentence is not enough: did the customer agree to public attribution, did someone review it, and can it be removed quickly? The useful outcome is a modest collection form, a permission-aware review queue and a clean wall—not a general-purpose content management system. The demo is for the fictional Northwind team. It offers three workspaces: Collect, Moderate and Wall. Nothing leaves the browser. Customer names and stories in the seed are fictional examples, not endorsements of this website. ## 2. Research basis Official public sources consulted on 2026-10-13: - [Senja: testimonial collection](https://senja.io/features/collect-testimonials). The fetched official page describes collecting, managing and sharing testimonials, including text and video. We keep the text collection → management → display loop. We do not reproduce its video, automation or hosted embed capabilities. - [ICO: what is valid consent?](https://ico.org.uk/for-organisations/uk-gdpr-guidance-and-resources/lawful-basis/consent/what-is-valid-consent/). Its guidance emphasizes genuine choice, specific informed affirmative action, records of consent and easy withdrawal. This informs the unchecked optional permission box, preserved permission wording and withdrawal workflow. It does not establish legal compliance for this implementation; have the actual organization review its wording, lawful basis and retention policy. - [MDN: Window.localStorage](https://developer.mozilla.org/en-US/docs/Web/API/Window/localStorage). Browser storage persists across sessions but is origin-specific and may be blocked. It is not a shared database or a secret store. An attempted fetch of help.senja.io failed DNS resolution; that unavailable page is not a research source. The rules below are explicit design choices, not claims that Senja implements the same state machine. ## 3. Scope and non-goals Included: original text, name, optional company/role, optional integer rating, explicit publishing permission, pending queue, approval or rejection, separate publication, one featured quote, minimum-rating wall filters, withdrawal simulation, history, browser persistence and sample reset. Not included in this demo: photo uploads or remote photo URLs, video, imports from social networks, email invitations, incentives, automated requests, authentication, real customer withdrawal links, public routes, copyable embed code, server persistence or cross-device collaboration. Initials/photos are not necessary to validate this workflow. Do not display fake “link copied” or “email sent” success messages. The wall's optional positive-rating filter is labeled visibly. Keep honest customer wording and do not describe this curated marketing wall as an unfiltered review directory. No mutation edits a submitted quote; materially changed wording requires a new customer submission and permission record. ## 4. Data model IDs are opaque strings. Timestamps are UTC ISO strings. Browser demo state is `{ testimonials: Testimonial[] }` under the versioned key `psf-testimonial-wall-v1`. | Field | Rule | |---|---| | id | Unique, generated on collection | | name | Trimmed, 2–80 characters | | company, role | Optional, trimmed, at most 100 characters each | | quote | Trimmed, 20–1,000 characters; plain text, never rendered as HTML | | rating | null or an integer 1–5; null is not zero stars | | consent | Boolean, false unless explicitly checked | | consentText | Exact permission text accepted; empty if no permission given | | consentAt | Acceptance timestamp or null | | withdrawnAt | Withdrawal timestamp or null | | status | pending, approved, rejected, published or withdrawn | | featured | Boolean; true only for an eligible published quote | | createdAt | Collection timestamp | | history | Ordered action, timestamp and note entries | Permission wording in this example: “I give Northwind permission to publish this quote, name, role and company on its website. I can withdraw permission at any time.” Configure the real organization's name and purpose before production. Changing future form wording must not overwrite historical consent text. ## 5. Domain rules 1. Every submission begins pending, including submissions without publishing permission. Private feedback is valid and must not be penalized by a required consent checkbox. 2. Only pending quotes may be moderated. Approval requires permission; rejection requires a trimmed 5–300-character note. Approval notes are optional but limited to 300 characters. 3. Approval is not publication. Only approved quotes with current permission and no withdrawal timestamp can become published. Pending and rejected quotes cannot bypass this gate. 4. Unpublish moves published → approved and clears featured. The original approval and consent remain, so deliberate republication is possible. 5. Feature only a published, consented, non-withdrawn quote. Featuring a different quote atomically clears the prior flag. Clicking the active feature again removes the flag. At most one quote is featured. 6. Withdrawal may occur before or after publication wherever consent is active. Set consent false, withdrawnAt, status withdrawn, and featured false in one mutation. Retain the original consent evidence in the private history. There is no moderator “restore consent” shortcut. 7. The wall independently requires published AND consent AND no withdrawal timestamp. Do not rely only on status. Feature sorts first; otherwise use newest creation time. The default view includes unrated quotes; minimum-rating views exclude them. 8. Validate before changing any state. Failed mutations leave the draft unchanged. Storage writes happen before the UI reports success, so denied/quota-exceeded writes surface an error instead of a false saved state. ## 6. Screens and interaction details **Collect:** labeled form with name, company, role, rating, quote and an unchecked permission checkbox. Explain why the story is requested, which fields become public, and what happens if permission is declined. Keep unsent text on error. Reset the form only after a successful save; show that the quote is awaiting review rather than claiming it is published. **Moderate:** default pending queue; status filter includes approved, published, rejected, withdrawn and all. Search name, company and quote. Each card shows original wording, attribution, rating, status, permission state and expandable history. Disable approval for private feedback and explain why. Show only actions valid for the current state. After approving, use the approved filter to publish. Rejection needs a reason. Withdrawal uses an explicit confirmation naming the customer. **Wall:** heading, featured story spanning the grid, ordinary quote cards, visible rating filter and a useful empty state. Show only public fields; omit moderation notes and consent audit data. Label it a same-browser preview, not a public URL or embed. Quote text remains selectable and wraps on narrow screens. **Global:** shared DemoBar, sample-data notice and confirmed Reset. Saving uses shared useDemoState. Reload should retain records; Reset overwrites only this demo's key. Show errors with role=alert and successful writes with role=status. All controls need labels and visible keyboard focus. Never depend on color alone for permission or status. Match the site's warm-paper, ink and orange tokens and inherited IBM Plex font; use plain app names, square controls and restrained borders. No visual eyebrows, slogans, italic headings, decorative serif type or separate green/blue palette. ## 7. Seed scenario - Ana at Harbor Studio: published, five stars, featured; month-end approval story. - Ben at Bluebird: published, five stars; one-afternoon adoption story. - Cy at Acme: pending, four stars, consented; useful feedback including a mobile-filter limitation. - Lee at Ridgeway: pending, unrated, no publishing permission; explicitly requests private use only. This seed lets a reviewer test both the happy path and the permission boundary immediately. Reset constructs a fresh state; IDs may differ, so automated tests should select by scenario rather than hardcoded generated IDs. ## 8. Build plan ### Phase 1 — Collection and private storage ```text Build the text-only Testimonial Wall collection model and form described here. Implement a pure TypeScript engine with explicit validation and a fresh seed factory. Use the shared demo persistence hook with a dedicated versioned key, not a server or external connector. Preserve exact consent wording and timestamp only when the optional unchecked box is accepted. Render plain text and keep successful collection private. Do not implement uploads or public routes. ``` Acceptance checks to run: - [ ] Submitting valid text creates exactly one pending quote with normalized attribution. - [ ] Leaving consent unchecked and rating blank succeeds but records neither permission nor a rating. - [ ] Short quotes, excessive lengths and invalid ratings are rejected without adding records. - [ ] Successful collection updates the in-memory demo and clears the form. If browser storage is unavailable, a persistent warning explains that changes may be lost on reload. Validation errors preserve input. - [ ] Reload retains a submission in this browser; confirmed Reset restores the four sample scenarios. - [ ] Labels, keyboard focus and the success announcement are usable without a mouse. ### Phase 2 — Review and permission boundaries ```text Add a filterable/searchable moderation queue backed by engine mutations. Keep customer wording immutable. Make approval and rejection explicit, require a reason for rejection, and prohibit approving private feedback. Add history and a customer-withdrawal simulation with confirmation. Do not add a control that silently gives consent on behalf of a customer. ``` Acceptance checks to run: - [ ] Cy can be approved but does not appear on the wall yet. - [ ] Lee cannot be approved through either the UI or direct engine call. - [ ] Rejection with an empty reason fails and leaves the pending quote intact. - [ ] Successful review records the decision, time and note; duplicate review fails. - [ ] Withdrawal preserves the historical permission text but disables all publication paths. - [ ] Filters and search have useful no-result states; archived records remain inspectable. ### Phase 3 — Publication and the curated wall ```text Implement separate publish, unpublish and feature mutations. Build a responsive wall using a defensive public selector. At most one eligible quote is featured. Keep all private moderation and permission history outside the wall. Include clearly labeled all/4+/5-star filters and an honest no-publication/embedding limitation notice. ``` Acceptance checks to run: - [ ] Pending or rejected records cannot publish; approved, consented records can. - [ ] Featuring Ben removes Ana's featured flag and moves Ben to the first position. - [ ] Unpublishing a featured quote removes it from the wall and clears its flag. - [ ] Withdrawing permission removes a published quote immediately, including from the featured slot. - [ ] Unrated published quotes appear in All but not a minimum-rating view. - [ ] Wall cards wrap at phone width and expose no review notes or permission timestamps. ### Phase 4 — Reliability and release evidence ```text Test all domain boundaries and invalid mutations, including inconsistent persisted status/consent combinations. Verify a JSON round-trip of the seed. Check TypeScript, then perform and record browser acceptance separately. Keep tested metadata null until genuine acceptance evidence exists. Do not claim a deploy, hosted collection link or public embed based on a local demo. ``` Acceptance checks to run: - [ ] Automated tests pass for validation, moderation, publishing, featuring and withdrawal. - [ ] A corrupted published flag cannot override missing or withdrawn consent in the wall selector. - [ ] Missing record IDs produce clear errors without mutating unrelated records. - [ ] Storage-denied behavior and reset cancellation are checked in a real browser. - [ ] Phone-width, keyboard and reload/reset checks have recorded evidence. - [ ] Report exact automated and browser counts separately; no inferred browser passes. ## 9. Taking it beyond the demo A real deployment needs a shared database and server-enforced roles: anonymous submitter, authenticated moderator and read-only public viewer. The role switch in a demo is not authorization. Server creation must force pending status regardless of client input. Moderation endpoints need authentication, CSRF protection, rate limits and transactional feature uniqueness. Store actor IDs alongside history; demo entries are not a tamper-resistant audit trail. Provide a verified withdrawal mechanism (for example a customer-specific, revocable token) that does not expose other records. A moderator may process a documented customer request, never manufacture renewed consent. Define retention/deletion policy, minimize personal data and restrict the full consent record to authorized staff. Rate-limit public collection and add spam controls. For a public wall/embed, expose a minimal public response and make withdrawal invalidate every cache/CDN/embed promptly; a cached copy is still publication. An embed should not have access to moderation data or credentials. Browser storage can be edited, cleared or overwritten by another tab. This demo is single-browser, single-user and does not synchronize tabs. Invalid JSON falls back to seed through the shared hook; arbitrary schema-corrupt JSON is not migrated. Versioned storage keys isolate future schema changes. Do not put real customer data here. ## 10. Verification status Implemented local demo with 19 passing engine tests in `tests/engines/testimonial-wall.test.ts`; scoped TypeScript validation passes for both demos, their dependencies and tests. The final repository-wide TypeScript check also passes. Browser acceptance was not performed in this task. The checkboxes above remain unverified, screenshots were added during the integration pass, and `tested` remains null. `demoReady` describes a functional local demo, not production readiness or a tested hosted build. ### Integration verification update: October 13, 2026 After the implementation checkpoint, the hosted preview was smoke-checked in Chromium: approving and publishing a consented story. Two screenshots from the actual demo are now included on its blueprint page. A 390px viewport check found no page-level horizontal overflow in the checked view after fixes. The repository also has a DOM interaction smoke test for each of the eleven new apps, separate from these 19 app-specific automated tests. This is a focused smoke check, not completion of every acceptance item above. The app and spec were developed together, not independently reproduced from the prompts. `tested` stays null; `demoReady` exposes the reference app and full spec without a production-verification claim. No hosted collection form or shareable wall embed. There is no production authentication or shared backend. --- # Client Onboarding Tracker ## 1. The problem Onboarding work is spread across personal notes and chat. Nobody knows which client is ready to move on, who owns the next step, or which promise is overdue. Build a small team workspace that turns a repeatable implementation checklist into a visible, owned project for each client. This specification describes the implemented browser demo, not a production SaaS platform. The spec and code were developed together; an independent build from these prompts has not been verified. ## 2. Scope and boundaries Keep four stages: **Kickoff → Setup → Training → Live**. Each client project copies a reusable checklist template. Every task has an owner and due date. A stage advances only when its checklist is complete. Each handoff changes the project owner and records an in-app handoff entry. Included: multiple projects per client, project search, four-column overview, task completion/reopening within the current stage, task reassignment/rescheduling, derived completion and overdue counts, template creation/add/remove, stage gates, local handoff feed, simulated actor selection, browser persistence and reset. Not included: sign-in, authorization roles, a database, cross-device collaboration, custom stages, deleting projects, backward stage transitions, attachments, client-facing links, delivered notifications, email/Slack, background jobs or stage-duration analytics. The catalog’s notification concept is represented only by the local Handoffs feed. Do not label a feed entry as an email or Slack delivery. ## 3. Technical choices and documentation Use React/TypeScript in the existing Next.js client-app registry. No new dependency or route is required. The entry point is `components/apps/client-onboarding-tracker/App.tsx`; pure domain functions are in `lib/demo/client-onboarding-tracker/engine.ts`. Use a CSS module scoped to this app. Reuse `components/demo/DemoBar.tsx`, `components/demo/useDemoState.ts` and `lib/demo/kit.ts` rather than editing them. Match the site’s technical warm-paper/ink/orange design with its `--paper`, `--paper-hi`, `--ink`, `--signal`, `--sans` and `--mono` tokens. The fonts resolve to IBM Plex Sans/Mono. Use square restrained controls, thin rules and plain app/project headings. No visual eyebrows, italic display headings, slogan copy, soft green panel theme or decorative rounded cards. Official docs consulted during implementation, 2026-10-13: - [React: Updating objects in state](https://react.dev/learn/updating-objects-in-state): treat React state as immutable. The shared hook clones the current state, applies an engine mutation to the clone and saves only on success. - [MDN: localStorage](https://developer.mozilla.org/en-US/docs/Web/API/Window/localStorage): storage is origin-scoped and persists across browser sessions; access can throw when storage is blocked. Store JSON, not methods or Date objects. Catch write/reset failures and show an error rather than claiming a save. - [MDN: date input](https://developer.mozilla.org/en-US/docs/Web/HTML/Reference/Elements/input/date): input values use `yyyy-mm-dd` even when the visible picker is localized. Validate date strings in the engine too, including impossible days such as February 30. The shared hook uses `psf-client-onboarding-v1` as its storage key. It seeds when no value is stored and falls back to a seed if reading or parsing throws. It does not provide schema migration, server backups, multi-tab conflict resolution or robust recovery from valid JSON with an invalid shape. Reset replaces only this app’s state. Do not store real client information in this sample workspace. ## 4. Actors and data model Use the shared seven-person team: Dana Ruiz, Ana Lima, Maya Chen, Sam Okafor, Ben Cho, Jo Park and Lee Park. Initial actor: Dana. “Act as” is a simulation, not an authenticated session. Every simulated teammate can manage projects/templates/tasks and hand off a stage. Record the selected actor on task completion and handoff. All IDs are opaque strings generated through `uid`. Start and due dates are calendar strings, compared in ISO order. The demo uses the UTC calendar day for “today.” Completion and handoff timestamps are ISO UTC strings. Formatted timestamps may display in the viewer’s local timezone. | Record | Fields | |---|---| | State | clients[], templates[], handoffs[], me | | Template | id, name, steps[] | | Step | id, title, stage, owner, days | | Client project | id, name, project, owner, started, stage, templateName, tasks[] | | Task | id, title, stage, owner, due, doneAt nullable, doneBy nullable | | Handoff | id, clientId, destination stage, destination owner, by, at | `days` is a whole-number offset from project start, not from the start of each stage. Template snapshots intentionally contain a template name and task copies rather than a live relationship. Later template edits must not rewrite existing projects. ## 5. Business rules 1. Trim required text. Client and template names: 1–80 characters; project name: 1–100; checklist title: 1–160. 2. Reject case-insensitive duplicate client-name/project-name pairs. The same client may have a differently named project. Template names are unique case-insensitively. 3. Owners must be members of the shared team. Unknown record IDs throw explicit not-found errors. 4. Dates must match `YYYY-MM-DD` and round-trip through a real calendar date. Past starts are allowed. 5. A usable template has at least one step in each working stage: Kickoff, Setup and Training. Live has no checklist steps. Empty templates can be saved for drafting but cannot start a project. 6. Step offsets are integers from 0 through 365. Calculate due dates by adding UTC days to project start. Create independent task IDs for every snapshot. 7. New projects always start at Kickoff with every task incomplete. All stage tasks are visible immediately. 8. Only tasks in the current stage can be completed or reopened. Completion records actor/time; reopening clears both fields. Earlier stages lock after handoff. Future stages cannot be checked early. 9. Any incomplete task can be reassigned/rescheduled, including a future-stage task. Its deadline cannot precede project start. Completed tasks must be reopened before changing assignment. Completed tasks in past stages remain locked. 10. Handoff requires a known next owner, a nonempty current checklist, and every current task complete. Advance exactly one stage, change the project owner, prepend one feed entry. Handoff does not alter task owners or their deadlines. It never skips stages or sends externally. 11. Live is terminal. No further handoff or checklist toggles are allowed. 12. Progress is completed tasks / all tasks, rounded to an integer percent. A zero-task collection produces 0%, not NaN. “Overdue” means incomplete with due date strictly before today, across all stages. Due today is not overdue. 13. Mutations through the UI operate on a deep copy. A domain error must leave saved state unchanged and render an alert. Show save feedback only after storage succeeds. ## 6. Seed and worked example The standard “Implementation · standard” template contains: | Stage | Task | Owner | Offset days | |---|---|---|---:| | Kickoff | Confirm success criteria | Dana | 1 | | Kickoff | Run kickoff workshop | Maya | 2 | | Setup | Import client data | Sam | 5 | | Setup | Verify access and permissions | Ben | 6 | | Training | Train client champions | Maya | 9 | | Training | Approve go-live checklist | Dana | 10 | Seed relative to the current UTC day: | Client | Project | Owner | Started | Stage | Complete | |---|---|---|---|---|---| | Northwind | Customer portal | Dana | 3 days ago | Kickoff | 0/6 | | Bluebird | Analytics rollout | Sam | 8 days ago | Setup | 2/6 | | Harbor Studio | Team workspace | Maya | 9 days ago | Training | 4/6 | | Kestrel | Support launch | Dana | 14 days ago | Live | 6/6 | There are 24 tasks and six seeded handoffs. Sample completion/handoff timestamps are generated at seeding time; they are demonstration records, not reconstructed operational history. For deterministic tests, call `seed(new Date('2026-10-13T10:00:00Z'))`. Northwind starts October 10 and its first deadlines are October 11 and 12: two overdue tasks. Complete both; progress is 2/6 (33%). Hand off to Sam: stage becomes Setup, project owner becomes Sam, original setup task owners stay Sam and Ben, and one new feed entry appears. Trying to hand off again fails until setup tasks are complete. Create Ridgeway / Partner portal starting October 13 from the standard template. Its deadlines are October 14, 15, 18, 19, 22 and 23. Adding a template step later leaves Ridgeway’s six task snapshots untouched; only the next created project gets the new step. ## 7. Screens and interaction contract - **Demo bar:** blueprint link, sample-data warning, actor selector, confirmed Reset. Reset restores the seed relative to today and clears the selected project. - **Clients:** active-project and overdue-task totals; a collapsible new-project form; case-insensitive client/project search; four columns with owner, start, progress and overdue labels. Search filters cards only, not the independent selected-project detail. - **Project detail:** all stage checklists, completion attribution, labeled owner/date controls, Save per task, and next-owner handoff form. Disable toggles outside the current stage and assignment fields for completed tasks. Keep the handoff action available so a premature attempt explains the gate in an alert. - **Templates:** create named templates, inspect stage/owner/day-offset steps, remove a step, add a replacement step. This is add/remove editing, not in-place reordering or changing the template name. Explain snapshot isolation and stage coverage requirements. - **Handoffs:** newest-first local entries with client, destination stage, new owner, actor/time and an Open project action. Show a useful empty state. - **Accessibility/responsiveness:** native labeled inputs, semantic headings, buttons and checkboxes; status/error live regions; visible focus; text overdue labels. Four columns collapse to two and then one. No drag-and-drop requirement. ## 8. Reproducible build plan Paste this whole specification as context, then execute the phase prompts in sequence. The checklists below are acceptance instructions, **not claims of completed browser verification**. ### Phase 1: Domain and sample workspace ```text Build Phase 1 of Client Onboarding Tracker from this specification. Implement the exported State/Template/Step/Client/Task/Handoff types and the STAGES constant in lib/demo/client-onboarding-tracker/engine.ts. Reuse TEAM, uid and DomainError. Implement date/text/person validation, UTC dayOffset, addTemplate, addStep, removeStep, createClient, progress and overdue. Seed the four documented projects relative to an injectable clock. Keep engine logic free of React, browser globals and external APIs. Add deterministic node:test cases for required fields, dates, uniqueness, template coverage, offset boundaries, snapshot isolation and progress/overdue calculations. ``` **Acceptance checks** - [ ] Fixed-clock seed has four projects, six tasks each and the documented stages. - [ ] Invalid owner, impossible date and duplicate client/project pair are rejected without mutation. - [ ] An empty template exists as a draft but cannot create a client project. - [ ] A template edit affects a newly created project, never an existing snapshot. ### Phase 2: Checklist actions and stage gates ```text Build Phase 2. Implement updateTask, toggleTask and handoff exactly as rules 8–11. Validate before mutation; record the simulated actor and injected timestamp. Build the Clients overview, search, create-project form and project detail using native labeled controls. Derive progress and overdue counts from tasks rather than storing cached totals. Make future/past stage checkboxes read-only and explain unsuccessful handoffs via alerts. ``` **Acceptance checks** - [ ] Completing/reopening a current task sets/clears actor and timestamp. - [ ] A handoff with any incomplete task fails; completing all tasks permits exactly one stage advance. - [ ] Handoff changes the project owner and locks prior-stage completion controls. - [ ] A rescheduled task cannot be due before project start, and completed tasks cannot be reassigned. - [ ] Kestrel is 100% complete and cannot advance beyond Live. ### Phase 3: Templates, feed and persistence ```text Build Phase 3. Add the Templates and Handoffs views described in section 7. Use DemoBar and useDemoState with psf-client-onboarding-v1. Every action runs against the hook's clone; catch failures and show an alert. Reset requires confirmation and restores the fresh seed. Do not add network notifications or pretend the actor selector authenticates a user. Scope responsive styles inside this app's CSS module and provide empty states and focus indicators. ``` **Acceptance checks** - [ ] Add and remove template steps, then verify existing project tasks do not change. - [ ] Open a handed-off project directly from the local feed. - [ ] Reload after an edit and confirm it persists on this origin/device. - [ ] Cancel Reset and keep edits; confirm Reset and restore sample projects/templates/feed. - [ ] A blocked storage write displays failure instead of save success. ### Phase 4: Verification and honest metadata ```text Build Phase 4. Run node --test --import tsx tests/engines/client-onboarding-tracker.test.ts and npx tsc --noEmit --incremental false. Cover every lifecycle gate, missing IDs, leap dates, JSON round-trip and fresh-seed behavior. Manually verify the prior phase checklists on desktop and mobile only when browser testing is authorized. Keep meta.tested null until browser verification and the site's independent build verification requirements are met. Set demoReady only once this app and its domain tests are complete. Record the actual domain test count and state explicitly that persistence and handoffs are local simulations. ``` **Acceptance checks** - [ ] The targeted domain test command passes all 18 tests. - [ ] TypeScript accepts the app, engine and metadata. - [ ] Keyboard navigation and narrow-screen layout are manually checked before any browser acceptance claim. - [ ] Metadata has four concrete tryThis actions, no invented screenshots or acceptance counts. ## 9. Verification record and limitations Implementation verification: **18 domain tests passed** with the command above on 2026-10-13. Repository TypeScript checking also passed at implementation time. Tests exercise pure logic and JSON serialization, not actual browser storage, visual layout, download behavior or real collaborative sessions. Browser acceptance was not run for this implementation; screenshots were added during the subsequent integration pass. `tested: null` is intentional. `demoReady: true` means implemented demo plus passing domain tests, not production certification or independent prompt reproduction. For production, add authenticated identities, server-side permission checks, a transactional database, concurrency control and a durable notification outbox before allowing real customer data. These are future extensions, not hidden completed features. ### Integration verification update: October 13, 2026 After the implementation checkpoint, the hosted preview was smoke-checked in Chromium: completing kickoff tasks and handing a project to setup. Two screenshots from the actual demo are now included on its blueprint page. A 390px viewport check found no page-level horizontal overflow in the checked view after fixes. The repository also has a DOM interaction smoke test for each of the eleven new apps, separate from these 18 app-specific automated tests. This is a focused smoke check, not completion of every acceptance item above. The app and spec were developed together, not independently reproduced from the prompts. `tested` stays null; `demoReady` exposes the reference app and full spec without a production-verification claim. The handoff feed is local; no client portal or delivered notifications. There is no production authentication or shared backend. --- ## Can you build your own approval workflow instead of paying for Kissflow? Yes. A team of 5 to 100 people can build its own purchase-approval workflow with an AI app builder from this blueprint: a request form, routing rules by amount and category, a sequential approval chain, Slack approvals, reminders, escalation and an append-only audit log. We built it by sending the blueprint's 4 phase prompts to Claude in order, and all 34 acceptance checks passed in a real browser. What it leaves out is the enterprise layer: a visual workflow designer, parallel branches and ERP integrations. Kissflow pricing: Kissflow does not publish prices. Its pricing page asks you to request a quote or book a consultation, describes fixed annual agreements, and is aimed at enterprises. (source: kissflow.com/pricing, checked October 2026) **What is an approval workflow?** An approval workflow is the path a request takes from the person asking to the people who decide. It has a structured request form, routing rules that pick the approvers (for example by amount and category), a sequence of decisions (approve, reject or send back), reminders and escalation when an approver does not act, and an audit trail of every action. **How should purchase approvals be routed?** In this blueprint every request goes to the requester's manager first. Then each active rule is checked in order: if the amount is at least the rule's minimum and the category matches (or the rule has no category), that rule's approver is added. With the sample data, a $2,400 hardware request goes Manager then Finance, and a $12,000 software request goes Manager, IT, Finance, then Executive. The same request and rules always produce the same chain. **How do you stop someone approving their own request?** Segregation of duties is enforced twice. When the chain is built, any step that would be assigned to the requester is reassigned to their manager (or an admin fallback), and the reason is written to the audit log. When someone clicks Approve, the server checks again that they are the assignee of the active step and not the requester. Admins have no special power to approve steps that are not theirs. **What data model does an approval workflow need?** Six tables: Person (with manager, admin flag, Slack ID, out-of-office and delegate), Rule (the approval matrix: approver, minimum amount, optional category, position), Request (amount in integer cents, category, justification, status), Step (one row per approver per round, with status and timestamps), Event (an append-only audit log that is never edited or deleted) and Notification (an outbox, so email and Slack messages can be tested and retried). **How do reminders and escalation work?** A scheduled job runs every hour. When a step has been active for 24 hours it sends the approver a reminder, at most once every 24 hours. After 72 hours the step escalates to the approver's manager (or an admin who is not the requester), both people are notified, and the clock restarts. Out-of-office delegation sends new steps to the delegate and shows who it was delegated from. **Can approvals happen in Slack?** Yes. Phase 3 of the build plan sends the approver a Slack direct message with Approve and Open buttons. The interactions endpoint verifies Slack's request signature, rejects requests older than five minutes, checks the person is the assignee of the active step, and updates the message to say who decided. Our test build verified this with correctly signed requests and Slack's documented example, not by clicking in a live workspace. **How much does Kissflow cost compared with building your own?** Kissflow does not publish prices; its pricing page offers a quote or consultation and describes fixed annual agreements aimed at enterprises (checked October 2026). Building from this blueprint costs the time to send four phase prompts and run the checks, plus your AI builder and hosting. The blueprint itself is free. Build test (2026-10-12): 34/34 browser acceptance checks passed, 38 automated tests, 5 spec gaps found and fixed. # Approval Workflow Blueprint BP-007 · Department: Ops · Replaces: Kissflow (approvals), Slack threads, email chains For teams of 5 to 100 people. > How to use this file: paste this whole document into your AI builder (Claude, ChatGPT, Cursor, Lovable, Replit, Type) as context. Then send the build prompts in section 15 one at a time. Do not move to the next phase until the acceptance checks for the current phase pass. --- ## 1. The problem "Purchase requests get lost in Slack, and nobody knows who approved what." A person needs to spend money (a laptop, a software subscription, a contractor). Today they post in Slack or send an email. The request sits in someone's inbox, the right people are not asked in the right order, and three months later nobody can say who approved it. ## 2. What the real product does Research basis: Kissflow's approval workflow documentation and product pages (kissflow.com/workflow/approval-workflow-software, kissflow.com/workflow/approval-process, kissflow.com/workflow/workflow-audit-trail), October 2026. Enterprise approval tools run every request through the same steps: 1. **Submission.** A structured form captures everything needed to decide. 2. **Validation.** Required fields and pre-approval checks. 3. **Routing.** The request goes to approvers based on rules: amount, department, request type. 4. **Review and decision.** Approvers see the full context and can approve, reject, or send back for revision. Every action is logged with a timestamp. 5. **Exception handling.** If an approver does not act within a deadline, the request escalates automatically. 6. **Audit trail.** An attributed, timestamped record of every action, including proof that the same person did not both submit and approve (segregation of duties). ### What this blueprint keeps Structured request form, rule-based routing by amount and category, sequential approval chain, approve / reject / send back, segregation of duties, out-of-office delegation, reminders and escalation, an append-only audit log, Slack approvals, CSV export for finance. ### What this blueprint deliberately leaves out No-code form builder, visual workflow designer, parallel approval branches, GL/cost-centre coding, ERP integrations, field-level access logs, file uploads (v1 accepts a link instead). These matter to enterprises with thousands of monthly approvals. A team of 5 to 100 people does not need them. ## 3. Scope of v1 - One request type: spend requests. - One currency (set once by an admin; default USD). Amounts are stored as integer cents. - Sequential approval chain. One approver per step. - Web app that works on a phone, plus Slack direct messages with an Approve button. ## 4. Roles | Role | Who | Can do | |---|---|---| | Requester | Anyone on the team | Create, edit (only in draft or changes requested), submit, resubmit, cancel their own requests. Comment. | | Approver | Whoever is assigned the active step | Approve, reject, or send back the request they are assigned. Comment. | | Admin | One or more people | Manage people and routing rules, reassign an active step, export CSV. Admins have no special approval power: they cannot approve a step that is not assigned to them. | A person can hold several roles. Everyone can see all requests (transparency is the point); only the actions above are restricted. ## 5. Data model All timestamps are UTC. All IDs are strings (UUID or similar). **Person** - id - name (required) - email (required, unique) - manager_id (nullable, references Person) - is_admin (boolean, default false) - slack_user_id (nullable; can be filled automatically from email, see section 11) - out_of_office_until (nullable timestamp) - delegate_id (nullable, references Person) - active (boolean, default true) **Rule** (the approval matrix) - id - name (e.g. "Finance", "IT", "Executive") - approver_id (references Person) - min_amount_cents (integer, 0 means any amount) - category (nullable; when set, the rule only applies to that category) - position (integer; rules are evaluated in ascending position) - active (boolean) **Request** - id - number (integer, human-friendly, sequential: #201, #202 …) - requester_id - title (required, max 120 chars) - category (one of: Hardware, Software, Services, Travel, Other) - amount_cents (integer, > 0) - vendor (optional) - justification (required, min 10 chars) - needed_by (optional date) - link (optional URL to a quote, invoice, or product page) - status: draft | pending | changes_requested | approved | rejected | cancelled - current_step_id (nullable) - created_at, submitted_at, decided_at **Step** (one row per approval step; regenerated on every submission) - id - request_id - round (integer; starts at 1, increments each time the request is resubmitted) - position (1, 2, 3 …) - name (e.g. "Manager", "IT", "Finance") - assignee_id (who must act now) - original_assignee_id (who the rules picked, before delegation/escalation/reassignment) - status: waiting | active | approved | rejected | changes_requested | skipped | cancelled - skip_reason (nullable text) - activated_at, decided_at, last_reminded_at, escalated_at (nullable timestamps) **Event** (the audit log; append-only, never updated or deleted) - id - request_id - step_id (nullable) - actor_id (nullable for system actions) - type: created | edited | submitted | step_activated | approved | rejected | changes_requested | resubmitted | cancelled | commented | delegated | reminded | escalated | reassigned | skipped | completed - comment (nullable) - data (JSON, nullable; e.g. old/new amount on edit, from/to assignee) - created_at **Notification** (outbox; makes notifications testable and retryable) - id, person_id, channel (email | slack | in_app), kind, request_id, payload (JSON), created_at, sent_at, error ## 6. Routing algorithm Run this when a request is submitted or resubmitted. It must be deterministic: the same request and the same rules always produce the same chain. 1. **Manager step.** Add a step named "Manager" assigned to the requester's manager. - If the requester has no manager, assign it to an admin who is not the requester (first by name). - If no such admin exists, block submission with the error: "No one can approve this request. Ask an admin to set your manager." 2. **Rule steps.** For each active rule in ascending position: if `amount_cents >= min_amount_cents` and (`rule.category` is empty or equals the request category), add a step named after the rule, assigned to `rule.approver_id`. 3. **Segregation of duties.** If any step's assignee is the requester, reassign that step to the requester's manager (or the admin fallback from step 1). If a rule's approver is inactive, reassign that step to the inactive person's manager (or the admin fallback). Keep `original_assignee_id` and record the reason in the event data so the audit log shows why. 4. **Duplicates.** If the same person is assigned to more than one step, keep the earliest step and mark later ones `skipped` with skip_reason "Same approver as step N". Skipped steps stay visible in the chain. 5. Set the first non-skipped step to `active` (see delegation in section 12) and the rest to `waiting`. ### Worked examples (with the seed data in section 14) | Requester | Request | Resulting chain | |---|---|---| | Jo | $2,400, Hardware | Manager: Maya → Finance: Ana | | Lee | $300, Software | Manager: Sam → IT: Ben | | Lee | $12,000, Software | Manager: Sam → IT: Ben → Finance: Ana → Executive: Dana | | Ana | $2,000, Services | Manager: Dana → Finance: reassigned from Ana to Dana, then skipped (same approver as step 1) | | Dana | $500, Other | Manager: Ana (Dana has no manager; Ana is the other admin) | ## 7. Lifecycle ### Request status transitions | Action | Who | From | To | Side effects | |---|---|---|---|---| | Create | Anyone | (none) | draft | Event `created` | | Edit | Requester | draft, changes_requested | same | Event `edited` with changed fields | | Submit | Requester | draft | pending | Build chain (section 6), activate first step, events `submitted` + `step_activated`, notify assignee | | Approve | Active step's assignee | pending | pending, or approved if it was the last non-skipped step | Step `approved`; next step activated and its assignee notified; on final approval: `decided_at`, event `completed`, notify requester | | Reject | Active step's assignee | pending | rejected | Comment required. Step `rejected`, remaining steps `cancelled`, notify requester | | Send back | Active step's assignee | pending | changes_requested | Comment required. Step `changes_requested`, remaining steps `cancelled`, notify requester | | Resubmit | Requester | changes_requested | pending | New round: the chain is rebuilt from scratch and all approvals happen again. Event `resubmitted` | | Cancel | Requester | draft, pending, changes_requested | cancelled | Active and waiting steps `cancelled`; notify current assignee | | Reassign | Admin | pending | pending | Active step's assignee changes; comment required; event `reassigned`; notify new assignee | | Comment | Anyone | any | same | Event `commented`; notify requester and current assignee (not the author) | **Final states:** approved, rejected, cancelled. Nothing can change a request in a final state except comments. ## 8. Business rules - **R1.** Only the assignee of the active step can approve, reject, or send back. Not admins, not earlier approvers. - **R2.** A requester can never approve their own request (enforced by routing and checked again when the action happens). - **R3.** Reject and send back require a comment of at least 3 characters. - **R4.** Editing is only possible in draft or changes_requested. Resubmitting always starts a new round from step 1. - **R5.** Every action writes exactly one event row. Events are never edited or deleted. There is no API or UI to do so. - **R6.** Amounts are integers in cents. Display with thousands separators and two decimals only when cents are non-zero ($2,400 / $2,400.50). - **R7.** Request numbers are sequential and never reused. - **R8.** Inactive people cannot be assigned. If an assignee is deactivated while a step is active, the step is reassigned to their manager (or the admin fallback) automatically, with an event. - **R9.** Changing rules affects only future submissions, never chains already in progress. - **R10.** Nobody approves the same request twice. When the next step is about to activate, if the person who would act (after delegation) already approved an earlier step in this round, mark the step `skipped` with skip_reason "Already approved at step N", write a `skipped` event, and move on. This happens after escalation, delegation or reassignment hands a step to someone who also owns a later step. - **R11.** Reporting lines cannot loop. Admin → People rejects a manager change that would make someone their own manager, directly or indirectly. - **R12.** Reassignment, escalation and deactivation all restart the step's reminder clock (`activated_at` = now, `last_reminded_at` = empty). ## 9. Screens 1. **My requests** (home). Table of the signed-in person's requests: number, title, amount, status badge, current step ("Waiting on Ana · Finance · 2d"), last updated. Button: New request. Empty state: "No requests yet." 2. **New / edit request.** Fields from section 5 with inline validation. Buttons: Save draft, Submit. Before submitting, show a preview of the approval chain the rules will produce ("This will go to: Maya (Manager) → Ana (Finance)"). This preview is the single most useful feature; build it. 3. **Request detail.** Header: number, title, amount, status. Body: justification, vendor, link, needed-by. **Approval chain** as a vertical timeline: each step with name, assignee (and "delegated from / escalated from" when relevant), status, time taken. Action bar appropriate to the viewer (Approve / Reject / Send back for the assignee; Edit / Resubmit / Cancel for the requester; Reassign for admins). Comments thread. **Audit log** (collapsed by default): every event, actor, time, comment. 4. **Approvals inbox.** Requests where the signed-in person is the active assignee, oldest first, showing age. Approve directly from the row for small requests; others open the detail. 5. **All requests.** Filterable list (status, category, requester, date range) for everyone. Admins get an Export CSV button. 6. **Admin: people.** Name, email, manager, admin flag, Slack status (linked / not found), out-of-office and delegate, active. 7. **Admin: rules.** Ordered list of rules with name, approver, minimum amount, category, active. Shows a live example: "A $2,400 Hardware request from Jo goes to …". 8. **My settings.** Out-of-office until date and delegate. All screens must work on a phone. Approving from a phone must take one tap from the notification. ## 10. Notifications | Trigger | Recipient | Channels | Content | |---|---|---|---| | Step activated | Assignee | Slack DM (with buttons), email | "#208 New laptop · $2,400 from Jo Park needs your approval (Manager step)." Buttons: Approve, Open | | Request approved | Requester | Slack DM, email | "#208 was approved." Show the chain | | Rejected / sent back | Requester | Slack DM, email | Who, which step, the comment | | Reminder | Assignee | Slack DM | "#208 has been waiting on you for 1 day." | | Escalated | New assignee and previous assignee | Slack DM, email | Why and from whom | | Cancelled | Assignee of the active step | Slack DM | FYI | Write every notification to the Notification outbox first, then send. If sending fails, keep the row with the error and retry on the next scheduled run. A notification failure must never block an approval. ## 11. Slack integration **Setup (once, by an admin):** 1. Create a Slack app at api.slack.com/apps. 2. Bot token scopes: `chat:write`, `im:write`, `users:read`, `users:read.email`. 3. Turn on Interactivity and set the Request URL to `https:///api/slack/interactions`. 4. Install the app to the workspace. Store `SLACK_BOT_TOKEN` and `SLACK_SIGNING_SECRET` as environment variables. 5. Link people: for each person without a `slack_user_id`, call `users.lookupByEmail` and store the ID. Show "not found" in Admin → People when the lookup fails. **Sending a DM:** `conversations.open` with the user's ID, then `chat.postMessage` to the returned channel with Block Kit blocks: a section with the request summary and an actions block with two buttons: - Approve (`action_id: "approve"`, `value: `, style primary) - Open (URL button to the request detail page) Reject and Send back stay in the web app because they require a comment. **Handling a button click** (`POST /api/slack/interactions`): 1. Verify the request. Build `v0:{X-Slack-Request-Timestamp}:{raw body}`, compute HMAC-SHA256 with the signing secret, compare to `X-Slack-Signature` using a constant-time compare. Reject (401) if it does not match or if the timestamp is more than 5 minutes from now. 2. The body is `application/x-www-form-urlencoded` with a `payload` field containing JSON of `type: "block_actions"`. 3. Find the step from the button `value`. Find the person whose `slack_user_id` equals `payload.user.id`. 4. Apply the same rules as the web app (R1, R2). Never trust the button alone. 5. Respond 200 within 3 seconds. Then update the original message by POSTing to `payload.response_url` with `replace_original: true`, replacing the buttons with the outcome ("Approved by Maya Chen at 09:14" or "Already approved by Maya at 09:14" or "You're not the approver for this step"). `response_url` works up to 5 times within 30 minutes. 6. Write times with Slack's date token, e.g. ``, so every viewer sees their own time zone. 7. If the Slack user isn't linked to anyone, reply "Your Slack account isn't linked to the approvals app. Ask an admin to check Admin → People." ## 12. Delegation, reminders, escalation **Delegation.** When a step becomes active and its assignee has `out_of_office_until` in the future and a `delegate_id`, assign the step to the delegate instead (unless the delegate is the requester; then use the assignee's manager). Keep `original_assignee_id`, write a `delegated` event, and show "delegated from Maya (out of office)" in the chain. **Scheduled job** (runs every hour; on Vercel use a cron hitting `/api/cron/tick` protected by a `CRON_SECRET` header): - **Reminder.** For each active step: if it has been active for 24 hours or more and has not been reminded in the last 24 hours, send a reminder and set `last_reminded_at`. - **Escalation.** If a step has been active for 72 hours or more since activation (or since its last escalation), reassign it to the assignee's manager. If there is no manager, reassign it to an admin other than the requester. Skip escalation if the only candidate would be the requester. Write an `escalated` event and notify both people. Reset the 72-hour clock. - **Retry** unsent notifications. Use real elapsed time in v1. Business-hours calendars are a variation, not v1. ## 13. Edge cases - The requester's manager is the requester's own delegate target: handled by the delegation rule. - An approver is deactivated mid-flight: R8. - Two approvers click at the same time on stale screens: the second action fails with "This step was already decided" (check step status inside the same transaction as the update). - An amount is edited after send-back: the chain is rebuilt, so a request that drops below a threshold skips that step next round. - A rule's approver leaves: deactivating the person shows a warning in Admin → Rules until the rule is updated. - Slack button clicked after the request was cancelled: respond with "This request was cancelled" and update the message. ## 14. Seed data | Person | Manager | Admin | Notes | |---|---|---|---| | Dana Ruiz (Head of Ops) | none | yes | Executive approver | | Ana Lima (Finance lead) | Dana | yes | Finance approver | | Maya Chen (Design lead) | Dana | no | | | Sam Okafor (Engineering lead) | Dana | no | | | Ben Cho (IT) | Sam | no | IT approver for Software | | Jo Park (Designer) | Maya | no | | | Lee Park (Engineer) | Sam | no | | | Position | Rule | Approver | Minimum | Category | |---|---|---|---|---| | 1 | IT | Ben | $0 | Software | | 2 | Finance | Ana | $1,000 | any | | 3 | Executive | Dana | $10,000 | any | Seed one example request in each status so every screen has something to show. ## 15. Build plan Send these prompts one at a time after pasting this file. Each phase ends with checks you can run by clicking through the app. ### Phase 1: Foundation ``` Using the Approval Workflow blueprint above, build Phase 1 only. 1. Set up the data model from section 5 exactly (Person, Rule, Request, Step, Event, Notification) with a real database. 2. Add sign-in. Use the platform's built-in auth or Google sign-in limited to our company email domain. A signed-in email must match an active Person; anyone else sees "Ask an admin to add you." 3. Build Admin → People and Admin → Rules (section 9, screens 6 and 7) with create/edit/deactivate. Only admins can open them. 4. Load the seed data from section 14. 5. Add a small "sign in as" switcher visible only in development, so I can test as different people. Do not build requests or approvals yet. When you're done, list what you built and anything in the spec you had to interpret. ``` **Acceptance checks** - [ ] Seven people exist with the manager chain from section 14. - [ ] Jo cannot open Admin pages; Dana and Ana can. - [ ] Rules show in order IT → Finance → Executive. - [ ] Deactivating a person removes them from approver pickers. ### Phase 2: Requests and routing ``` Build Phase 2 of the Approval Workflow blueprint: requests, routing and decisions. 1. Implement the routing algorithm in section 6 as a pure function with unit tests for every row of the worked-examples table. 2. Build screens 1 to 5 from section 9, including the approval-chain preview on the request form. 3. Implement every transition in section 7 and every rule R1 to R9 in section 8. Enforce permissions on the server, not only by hiding buttons. 4. Write one Event row for every action (R5). Show the audit log on the request detail page. 5. Do the status check and the update in one database transaction so two people cannot decide the same step. Skip notifications for now; just write rows to the Notification table. ``` **Acceptance checks** (sign in as each person with the switcher) - [ ] Jo submits "New laptop", $2,400, Hardware. The preview and the chain both show Maya → Ana. - [ ] Signed in as Ana while Maya's step is active, there is no Approve button and a direct API call is refused. - [ ] Maya approves. Ana's step becomes active and Ana sees it in her inbox. - [ ] Ana tries to reject without a comment and is blocked. She sends back with a comment instead. Jo sees "Changes requested" and the comment. - [ ] Jo edits the amount to $800 and resubmits. The new round's chain is Maya only. Maya approves and the request is approved. - [ ] Jo cannot cancel the approved request. - [ ] Ana submits $2,000 Services. The chain is Dana, with Finance shown as skipped ("Same approver as step 1"). - [ ] Every action above appears in the audit log with actor and time. ### Phase 3: Notifications and Slack ``` Build Phase 3 of the Approval Workflow blueprint: notifications and Slack. 1. Send the notifications in section 10 from the Notification outbox. Email can use any provider we have; if none is configured, log emails instead of sending. 2. Implement the Slack integration in section 11 exactly: user lookup by email, DM with Approve and Open buttons, and the interactions endpoint with signature verification, permission checks and response_url message updates. 3. Add tests for the interactions endpoint: valid signature approves the step; wrong signature returns 401; timestamp older than 5 minutes returns 401; a Slack user who is not the assignee gets "You're not the approver for this step"; clicking after the step was decided updates the message to say who decided it. 4. Show Slack status per person in Admin → People. Tell me the exact Slack app settings I need to enter. ``` **Acceptance checks** - [ ] With Slack connected, submitting Jo's request DMs Maya within a few seconds. - [ ] Maya taps Approve in Slack. The message changes to "Approved by Maya Chen at …" and the web app shows the step approved. - [ ] Tapping Approve again on an old message says it was already approved. - [ ] Without Slack configured, nothing breaks; notifications show as logged. ### Phase 4: Delegation, reminders, escalation, export ``` Build Phase 4 of the Approval Workflow blueprint. 1. My settings: out-of-office until date and delegate (section 9, screen 8). 2. Delegation, reminders and escalation exactly as section 12, run by a scheduled job at /api/cron/tick protected by a CRON_SECRET header. Make the job accept an optional "now" override in development so I can test time-based behaviour without waiting. 3. Admin reassign action (section 7) with required comment. 4. Export CSV on the All requests page for admins: number, title, requester, category, amount, status, submitted_at, decided_at, approvers in order. ``` **Acceptance checks** - [ ] Maya sets out of office until next week with Sam as delegate. Jo's new request goes to Sam, shown as "delegated from Maya". - [ ] Running the job with now = 25 hours after activation sends one reminder; running it again at 26 hours sends none. - [ ] Running it at 73 hours escalates the step to the assignee's manager and both people are notified. - [ ] The CSV opens in a spreadsheet with one row per request. ## 16. Variations - Parallel steps (IT and Finance at the same time) for faster approvals. - Business-hours clock for reminders and escalation. - Budgets per category with "remaining budget" shown to approvers. - Reject and send back from Slack using a modal for the comment. - Other request types (time off, discounts, contract sign-off) using the same engine with a different form. - File uploads instead of a link. ## 17. Build notes from our test build We built this blueprint by sending the four phase prompts above, in order, to an AI builder (Claude, working in Type), then checked every acceptance item in a real browser. **Result:** 34 of 34 acceptance checks passed, plus 38 automated tests (routing for every worked example, every lifecycle rule, the Slack endpoint, and notification delivery). The Slack signature check also passes against the example request in Slack's own documentation. **Gaps the build found, now fixed in this spec:** 1. Escalation could hand a step to someone who owned a later step, so they would be asked to approve twice. Added R10. 2. The spec didn't say what happens when a rule's approver is inactive at submission. Added to section 6, step 3. 3. Admin → People allowed reporting loops (A manages B manages A), which breaks routing. Added R11. 4. Reassignment and escalation didn't say whether the reminder clock restarts. Added R12. 5. Slack times showed in UTC. Added Slack's date token (section 11, step 6). **What we did not test:** - Production sign-in. We used the development "sign in as" switcher only. - A live Slack workspace. We tested with correctly signed requests and Slack's documented example, not by clicking in a real workspace. - Sending email. Emails were written to the outbox and logged, not delivered. - Hosting. The test build ran on SQLite on one server. For a hosted deploy, use a managed Postgres (or your platform's database) and the same schema. --- # Project Status Board ## 1. The problem The Monday status report is rebuilt by hand because task detail and project-owner context live in different places. Keep a compact weekly health grid and a useful task board together. Let the owner publish the assessment; build the leadership report from the saved update and visible delivery risks. This is the specification of the implemented local browser demo. Code and specification were developed together. Independent prompt-to-app reproduction and browser acceptance have not been verified. ## 2. Scope Included: project creation/editing, owners and deadlines, reversible archiving, project-name search, four-week health grid, task creation/editing with To do/In progress/Blocked/Done statuses, named task owners, due dates, required blocker explanations, owner-only weekly updates, update history, missing-update counts, read-only leadership report text, text-file download, simulated actor selection, local persistence and reset. The “owner-only” rule is enforced by the domain engine against a simulated selected teammate. It is not security or authentication. All simulated teammates can manage projects/tasks; only the current project owner can publish that project’s weekly assessment. An admin flag does not bypass this publication rule. Not included: database, sign-in, actual user permissions, public sharing links, scheduled Friday nudges, Slack/email delivery, external project imports, dependencies, subtasks, task deletion, drag-and-drop, immutable audit revisions, capacity planning or automatic health calculation. The catalog’s leadership view is represented by a local read-only report and downloadable text, not a hosted share page. ## 3. Implementation and official references Entry point: `components/apps/project-status-board/App.tsx`. Pure domain engine: `lib/demo/project-status-board/engine.ts`. Use a CSS module inside this app, reuse the existing dynamic registry, `DemoBar`, `useDemoState`, `TEAM`, `uid` and `DomainError`. Do not require new dependencies or edit shared components. Use the site’s warm-paper/ink/orange tokens (`--paper`, `--paper-hi`, `--ink`, `--signal`) and IBM Plex font families (`--sans`, `--mono`). Controls and panels are square with restrained borders; headings are plain app/project names. Do not introduce visual eyebrows, italic display headings, slogans or a soft green rounded template theme. Use text and narrow status markers, not decorative colored panels, for health. Official references reviewed 2026-10-13: - [React: Updating objects in state](https://react.dev/learn/updating-objects-in-state). Update a clone, never mutate the live React object. The shared hook supplies a cloned draft and commits only after the action returns successfully. - [MDN: localStorage](https://developer.mozilla.org/en-US/docs/Web/API/Window/localStorage). Persist JSON under a dedicated origin-scoped key; storage can be unavailable or blocked. Catch write/reset failures. Persistence is on this browser/device only. - [MDN: date input](https://developer.mozilla.org/en-US/docs/Web/HTML/Reference/Elements/input/date). Native date values are normalized calendar strings, not locale-formatted display text. Validate both format and actual day existence in the engine. Use the key `psf-project-status-v1`. Seed on missing data; the shared hook also seeds if read/JSON parsing fails. It does not implement schema migrations or validation of arbitrary well-formed but structurally invalid stored JSON. Do not promise multi-tab conflict resolution, server backups or synchronization. Each saved mutation writes the full local state. Reset restores a new relative-date seed and today’s reporting selection after confirmation. ## 4. Data model and date semantics | Record | Fields | |---|---| | State | projects[], tasks[], updates[], me | | Project | id, name, owner, due, archived | | Task | id, projectId, title, owner, due, status, blocker | | Update | projectId, week, health, note, by, at | IDs are generated opaque strings. All owners and actors reference the shared seven-person team. Initial actor is Dana Ruiz. Task status values: **To do, In progress, Blocked, Done**. Project health values: **On track, At risk, Blocked**. Keep these separate types; a task status is not a project health assessment. Project/task dates use `YYYY-MM-DD`. The engine verifies round-trip validity and computes UTC day arithmetic. Reporting weeks begin Monday UTC. Normalize any selected day to that Monday: October 13 and October 18, 2026 both identify the week beginning October 12. January 1, 2027 belongs to December 28, 2026. Update timestamps are ISO UTC strings; UI display may use the viewer’s local timezone. Updates are unique by `(projectId, week)`. Publishing another update for that pair replaces the existing entry and its author/time. Updates for other weeks remain unchanged. This is weekly history, not an immutable edit audit. ## 5. Domain rules 1. Trim required text. Project names: 1–100 characters; task titles: 1–160; blocker reasons: 1–500 when blocked; weekly notes: 1–1000. 2. Project names are case-insensitively unique, including archived projects. Owner IDs must be on the shared team. Invalid dates and unknown record IDs fail explicitly. 3. Any teammate can create/edit project details. Changing an owner transfers weekly publication authority without rewriting historical update attribution or task owners. 4. Project deadlines can be past dates, but may not be shortened to precede any existing task deadline, including a completed task. Extend/reschedule task dates deliberately instead of hiding the conflict. 5. Task deadlines must be on or before their project deadline. No project start date is modeled. Task due dates can be in the past to represent overdue work. 6. Tasks may move directly between any valid statuses; no sequential gate. Reopening Done is allowed. Editing a task cannot move it to another project. 7. Blocked requires a nonblank explanation. Leaving Blocked clears its blocker reason so old risks do not linger in the report. Status and reason are validated together before mutation. 8. Only the current project owner can post weekly health. Reject an unknown actor, missing note, invalid health or future reporting week. A day later in the current week still normalizes to the same week and is not a future week. 9. Health is manually assessed. Do not silently turn it green when tasks finish or red when a blocker is added. Show owner assessment alongside live task risks so disagreement is visible. 10. Archive preserves tasks/history but excludes the project from active counts, default grid and all reports. Archived project details/tasks/updates are read-only in the engine. Restore permits editing again. “Include archived” only affects the grid. 11. Derived task progress is Done / all tasks, rounded to an integer percent; an empty project is 0%. Count overdue tasks only if not Done and due strictly before the chosen comparison day. Due today is not overdue. 12. Weekly grids/reports must not carry an older update forward into a missing week. Render “No update” or a clearly explained dash and show a missing-update count for the selected week. 13. UI actions mutate a cloned draft through the shared hook. Domain or persistence failure leaves the saved state untouched and displays an alert, not a success message. ## 6. Report contract `statusReport(state, day)` returns plain text for every active project, ignoring the grid search and include-archived setting. Normalize the selected day to a Monday for the header and exact-week update lookup. For each project include name, exact-week health or “No update,” current project owner and deadline, current Done/total task count, current blocked count, overdue count measured against the selected day, and the saved weekly note or an explicit request for an owner update. Follow with one line for each currently Blocked task: title, reason, owner, deadline. Projects retain creation order. The report must say **“Owner-reported health; task counts reflect current state.”** A past reporting week is not a historical snapshot of tasks, deadlines or blockers. Archived projects disappear even from past-week reports. Do not suggest otherwise. Display the text in a labeled read-only textarea so it can be selected/copied. Download a UTF-8 text Blob named `project-status-YYYY-MM-DD.txt` using the normalized Monday. Revoke the temporary object URL after triggering the download. Show “download requested,” not an unverifiable claim that the user saved the file. Offer text selection as a fallback if the download throws. No report is published externally. ## 7. Seed and deterministic examples Seed four projects relative to the current UTC day: | Project | Owner | Deadline | Current health | |---|---|---|---| | Website relaunch | Dana | Today +14 | On track | | CRM migration | Sam | Today +21 | Blocked | | Q4 pricing | Ana | Today +10 | At risk | | Hiring plan | Maya | Today +28 | No current update | Every project has a completed “Approve scope and success criteria” task due three days ago, a delivery task owned by the project owner, and a “Run acceptance checks” task owned by Lee due two days before the project deadline. Delivery tasks are In progress due in five days, except Q4 pricing is due two days ago and CRM migration’s “Import legacy accounts” is Blocked. Its reason is “Vendor export credentials not yet received; Sam is following up.” All four projects have a previous-week On track update. All except Hiring plan have a current-week update. Seed totals: four projects, twelve tasks, seven weekly entries and one blocked task. Seed timestamps describe sample creation, not real historical activity. With `seed(new Date('2026-10-13T10:00:00Z'))`, the current Monday is October 12. Website relaunch is due October 27; CRM migration November 3; Q4 pricing October 23; Hiring plan November 10. Q4 pricing has 1/3 done (33%) and one overdue task. CRM migration has 1/3 done and one blocked task. Worked flow: as Dana, try publishing CRM’s update and receive an ownership error. Switch to Sam, change the blocked import to In progress, and publish At risk with “Export access restored; validating imported accounts.” The blocker disappears from the live report; CRM’s weekly assessment becomes At risk. Publish again in the same week: still seven total entries, no duplicate square. Archive CRM: tasks/history remain, but it disappears from the active report. Restore: all prior data returns. ## 8. Screen contract - **Header and DemoBar:** title, active-project count, missing-update count for selected week, local-only disclaimer, simulated actor selector and confirmed Reset. - **Reporting selector:** pick any calendar day; normalize to Monday; render that week and its three predecessors. Native input limits future days, while engine rejects future weeks for posting. - **Project grid:** four weekly health cells with text/color and note tooltip, current owner/deadline and task counts; project-name search; include-archived checkbox; clear empty state. A missing update is a dash explained in the caption. The selected project detail remains independent of grid filtering. - **Project workspace:** edit name/owner/deadline, archive/restore, owner-only weekly form, add-task form, four status columns and per-task edit controls. Blocked edit exposes a required explanation. Done tasks can be reopened. Show overdue labels as text, not color alone. - **History:** descending week order with note, author and timestamp. Revising one week replaces that week only. - **Status report:** all-active-project read-only text and Download report. No mutation controls or fake share-link button. - **Accessibility/responsiveness:** semantic table/caption and form labels, visible focus, textual health/status, error alert and success live region. Wide table scrolls horizontally; task columns collapse four → two → one. Use native select-based status changes rather than drag-and-drop. ## 9. Phased build prompts Give the builder this full spec, then the phase prompts in order. The unchecked acceptance items are a reproducible checklist, not evidence that browser testing has occurred. ### Phase 1: Projects, tasks and pure domain logic ```text Build Phase 1 of Project Status Board. Implement the section 4 types and STATUSES/HEALTH constants in lib/demo/project-status-board/engine.ts. Reuse TEAM, uid and DomainError. Implement date, shift, weekOf, saveProject, saveTask, archiveProject and summary with validation before mutation. Enforce project/task deadline consistency, unique names, required blocked reasons, clearing resolved blockers and read-only archived records. Add injectable seed time and node:test coverage. Keep this engine free of browser APIs. ``` **Acceptance checks** - [ ] Fixed-clock seed has four projects and twelve tasks with documented owners/deadlines. - [ ] Invalid calendar days, unknown owners and blank names/titles are rejected. - [ ] A task cannot exceed its project deadline; a project cannot shrink below existing tasks. - [ ] Blocked requires a reason; resolving it clears that reason; Done can reopen. - [ ] Archive freezes mutations and restore preserves all task/history data. ### Phase 2: Weekly assessment and report ```text Build Phase 2. Implement postUpdate and statusReport per sections 5–6. Normalize Monday weeks in UTC. Enforce current-owner publication, no future weeks, required notes and one update per project/week. Keep task status separate from project health. Generate a plain-text all-active-project report with exact-week health, explicit missing updates, current task metrics and named blocker details. Document that old-week reports are not task snapshots. ``` **Acceptance checks** - [ ] Dana cannot publish CRM migration; Sam can. - [ ] Republish in the same week replaces one entry; previous weeks remain intact. - [ ] Monday/Sunday and year-boundary normalization are deterministic. - [ ] A future reporting week and blank note are rejected without changing history. - [ ] Hiring plan says No update; no earlier green health is carried forward. - [ ] The report includes blocker title/reason/owner/deadline and excludes archived projects. ### Phase 3: Usable local app ```text Build Phase 3. Implement the screens in section 8 in the existing App entry point and an app-local CSS module. Reuse DemoBar and useDemoState with key psf-project-status-v1. Catch action/storage failures; display alerts and only announce success after a successful save. Build native labeled forms for project/task edits, weekly publishing, search, include-archived and reporting date selection. Add current-project task columns, history, read-only report and a text Blob download. Confirm Reset; never invoke external services. ``` **Acceptance checks** - [ ] Select any project and edit/add tasks; status changes move cards between columns. - [ ] Changing project owner changes who sees the publication form. - [ ] Search filters only the grid; report still includes every active project. - [ ] Selecting a prior reporting week changes health cells and report header, not task history. - [ ] Download requests a week-named text file; report text can be selected as a fallback. - [ ] Reload retains edits; cancel Reset retains edits; confirm Reset restores samples. - [ ] Storage failures show an error instead of a success message. ### Phase 4: Validation and disclosure ```text Build Phase 4. Run node --test --import tsx tests/engines/project-status-board.test.ts and npx tsc --noEmit --incremental false. Add coverage for ownership transfers, missing records, atomic rejected actions, empty/all-archived reports and JSON round-tripping. When browser testing is authorized, execute every acceptance checklist on desktop and mobile and record actual results separately. Keep tested:null until the site's browser and independent-build requirements are met. Set demoReady and actual demoTests only after implementation and passing tests. Use four actionable tryThis examples and an honest local-only demoNote. ``` **Acceptance checks** - [ ] All 21 targeted domain tests pass. - [ ] TypeScript reports no app/engine/metadata errors. - [ ] Keyboard, narrow-screen layout and download behavior receive manual checks before being described as browser-tested. - [ ] No metadata claims authentication, public sharing, scheduled nudges or unperformed acceptance results. ## 10. Verification record and production gaps On 2026-10-13, **21 domain tests passed** using the targeted command above. Repository TypeScript checking also passed at implementation time. This covers domain logic, report text and JSON round-tripping. Actual browser rendering, persistence, file download and mobile/keyboard acceptance were not tested in this implementation pass. Screenshots were added in the subsequent integration pass described below. Metadata intentionally has `tested: null`; demo readiness does not mean independently reproduced or production-ready. A production version needs authenticated server-side identities, authorization, transactional persistence and concurrency control. A public leadership link needs an explicit data-access policy. Friday reminders and Slack summaries need scheduled jobs and verified delivery. None of those capabilities are implemented here. ### Integration verification update: October 13, 2026 After the implementation checkpoint, the hosted preview was smoke-checked in Chromium: saving a weekly update and opening its assembled report. Two screenshots from the actual demo are now included on its blueprint page. A 390px viewport check found no page-level horizontal overflow in the checked view after fixes. The repository also has a DOM interaction smoke test for each of the eleven new apps, separate from these 21 app-specific automated tests. This is a focused smoke check, not completion of every acceptance item above. The app and spec were developed together, not independently reproduced from the prompts. `tested` stays null; `demoReady` exposes the reference app and full spec without a production-verification claim. No public sharing, Slack delivery or scheduled reminders. There is no production authentication or shared backend. --- # SaaS Spend Tracker Blueprint BP-009 · Department: Finance · Replaces: a small team's manual Zylo-style inventory For teams of 10–200 people. > How to use this file: Give the whole file to your AI builder first. Then send the prompts in the Build plan one at a time. Verify each phase's acceptance checks before moving on. The demo is a browser-local reference, not a production billing system. ## 1. The problem Software invoices arrive in different inboxes. Nobody owns the inventory or knows when a contract's cancellation notice period ends. A renewal date alone is not enough: a contract can become unavoidable weeks before the next charge. ## 2. Research basis and scope [Zylo's public product overview](https://zylo.com/) describes a system of record for software spend, usage and contracts, reclaiming unused licences and prioritising renewals. Its linked use cases include [renewal savings](https://zylo.com/solutions/renewal-savings) and [licence waste](https://zylo.com/solutions/license-waste). The homepage was reviewed on October 13, 2026; the linked use cases are further reading, not separately verified API contracts. This blueprint keeps a manually maintained inventory, annualized costs, seat counts, owners, cancellation deadlines and a local reminder log. It does not reproduce Zylo's automated discovery, integrations, benchmarking, procurement or enterprise access controls. The reference costs are fictional, not vendor quotes. ## 3. Scope of v1 - Add, edit, search and export subscription records. - Convert monthly contract totals to annualized run rate. - Estimate unused capacity from manual active-user counts. - Sort renewal work by cancellation deadline. - Notify the owner 30 days before the cancellation deadline, with deduplication. - Record a renewal or mark a subscription cancelled locally without deleting its history. No bank connection, actual vendor cancellation, email delivery, usage discovery, historical-spend chart or currency conversion is included. Browser storage is per origin and browser profile, not per authenticated user. Do not enter real confidential contracts into this public demo. ## 4. Roles All seven sample team members can read the inventory. Ana Lima and Dana Ruiz represent finance administrators. Only administrators add tools or reassign owners. Administrators and the current owner can edit an active subscription, record renewal or change its local status. Other staff receive a clear permission error for attempted writes. The demo role picker is a simulator, not authentication. ## 5. Data model ### Tool | Field | Type | Rule | |---|---|---| | id | string | Stable identifier | | name | string | Trimmed, 2–100 characters; unique among active tools ignoring case | | owner | person ID | Existing team member | | team | string | Nonempty, at most 80 characters | | cost | integer cents | Total contract charge per billing cycle; 0 through 100,000,000 | | cycle | monthly or annual | Never store a per-seat price here | | seats | integer | 0–100,000 | | activeUsers | integer | 0 through paid seats; entered manually | | renewsOn | YYYY-MM-DD | Real calendar date; past dates allowed to track missed renewals | | noticeDays | integer | 0–365 calendar days | | status | active or cancelled | Cancelled records remain available | ### State and history State stores tools, append-only events, reminders, a deterministic sequence counter and offsetDays. An event stores ID, tool ID, actor ID, date and message. Reminders also store the recipient ID. The reference clock starts October 13, 2026 UTC. Advancing it adds one calendar day and runs reminder discovery. ## 6. Calculations and rules R1. Annualized cost = total cycle cost × 12 for monthly contracts, otherwise total cycle cost. Keep integer cents throughout. R2. Active run rate excludes cancelled tools. It is not money already spent. Do not fabricate a historical trend from today's inventory. R3. Estimated unused capacity = round(annualized cost × (seats − active users) / seats). With zero seats, return zero. Equal per-seat allocation is an estimate, not a promise of recoverable savings. R4. Cancellation deadline = renewal date minus noticeDays in UTC calendar days. Display both dates. Never assume noticeDays means a reminder lead time. R5. Alert eligibility starts 30 days before the cancellation deadline, including that day. If a record is added after its eligibility date, create its reminder immediately. Keep overdue contracts visible until explicitly updated. R6. Deduplicate reminders by tool ID, renewal date, notice period and owner. Re-running the job on the same state does not repeat delivery. Reassigning a tool can create a new reminder for its new owner. Existing reminders remain historical records. R7. Cancelled tools generate no new reminders and cannot be edited or renewed until restored. Restoring is refused if it would duplicate an active tool's name. R8. Recording a renewal requires an active subscription and a valid next date after both today and the previous renewal date. It leaves price and seat counts unchanged. There is no automatic renewal that silently advances dates. R9. Validate permissions and all inputs before mutation. A failed action must not save a half-edited record. The React adapter applies mutations to a cloned state and commits only successful operations. R10. Every successful subscription edit, status change and renewal appends an event. Changing local status does not call a vendor API. ### Worked example Figma is a fictional $9,600 annual contract with 20 paid seats, 14 active users and renewal on November 12, 2026. Its unused capacity estimate is $9,600 × 6 / 20 = $2,880/year. With zero notice days, October 13 is the first reminder day. Zoom costs a fictional $7,200/year and renews December 1 with 30 days' notice. The cancellation deadline is November 1, so its reminder eligibility starts October 2. A system first opened October 13 generates the reminder immediately. ## 7. Lifecycle | Action | From | To | Guard | |---|---|---|---| | Add | None | Active | Finance, valid fields, unique active name | | Edit | Active | Active | Finance or owner; only finance reassigns | | Mark cancelled | Active | Cancelled | Finance or owner | | Restore | Cancelled | Active | Finance or owner; name still available | | Record renewal | Active | Active | Finance or owner; strictly later future date | No delete action is exposed. Reset affects only the sample dataset in this browser. ## 8. Screens 1. Inventory: annual run rate, estimated unused capacity and active count; search by tool, team or owner; active/cancelled/all filter; current-view CSV export. 2. Add or edit subscription: all fields with explicit total cost and billing cycle labels. Error states retain entered values. 3. Detail: costs, owner, seats, cancellation deadline, local cancel/restore, next-renewal input and event history. 4. Renewal calendar: active tools sorted by cancellation deadline. Past deadlines are labelled. 5. Alerts: finance sees all reminders; other members see their own. Each links to its subscription. Use tables with a contained horizontal scroll region on narrow screens; no page-wide overflow. Keep the shared demo toolbar with role switching, +1 day and Reset. ## 9. Notifications and production boundary The demo writes reminder records only. It does not send mail, schedule a server job or run while closed. The +1 day control demonstrates time logic. For a production build, replace local storage with a database and authenticated server actions. Enforce owner/administrator authorization server-side. Run a UTC daily job, use a unique reminder key and an outbox with provider idempotency where available. Track queued/sent/failed separately; retry failed transport without creating a new logical reminder. Protect credentials server-side. Those integrations need their own tests and are not proven by this reference. ## 10. Edge cases - Reject impossible dates such as February 30, fractional seat counts, negative money and active users exceeding seats. - A free or non-seat-based subscription is allowed with cost or seats equal to zero. - Past renewal dates are allowed at entry so overdue work is not hidden; recording a new renewal must use a future date. - Changing ownership is an administrator action; former reminders retain their original recipient. - Cancelled names may be reused; restoring the old record then requires resolving the active-name conflict. - CSV exports are snapshots of the filtered view, not backups of the event history. ## 11. Seed data The demo includes seven people from the shared sample team. Ana is the default viewer. | Tool | Owner | Cycle total | Paid / active | Renewal | Notice | Status | |---|---|---|---|---|---|---| | Figma | Maya | $9,600 annual | 20 / 14 | 2026-11-12 | 0 | Active | | Notion | Dana | $1,000 monthly | 100 / 72 | 2026-10-20 | 0 | Active | | Zoom | Ben | $7,200 annual | 40 / 25 | 2026-12-01 | 30 | Active | | GitHub | Sam | $210 monthly | 30 / 30 | 2026-11-01 | 0 | Active | | Monday.com | Dana | $2,400 annual | 20 / 0 | 2026-10-25 | 14 | Cancelled | Initial active annual run rate: $31,320. Estimated unused capacity: $8,940/year. Four active tools, 190 seats and four local reminders. Cancelled Monday.com contributes nothing to these totals. ## 12. Build plan ### Phase 1: inventory and money ```text Build a browser-local SaaS inventory from sections 3–5 and 11. Use TypeScript pure functions and integer cents, plus a React UI. Include realistic seed records, an inventory table, search and status filters, add/edit forms, validation, local persistence and reset. Implement R1–R3. Annualized cost must use the total cycle price, not price times seat count. Keep cancelled records out of active totals. Do not add charts with invented historical data. ``` **Acceptance checks** - [ ] Reset shows $31,320 annualized run rate, $8,940 estimated unused capacity and four active tools. - [ ] Search for Maya shows Figma; switching to Cancelled shows Monday.com. - [ ] Add Linear at $100 monthly, 10 seats and 8 active users; run rate increases by $1,200. - [ ] Active users above paid seats produces an error with no saved record. ### Phase 2: permissions and history ```text Implement roles and R7–R10. Finance administrators can add and reassign; owners can edit only their own active tools. Add detail, cancel/restore and append-only event history. Apply mutations atomically. Keep the role picker explicitly marked as demo simulation. Never imply that marking a record cancelled actually cancels a contract. ``` **Acceptance checks** - [ ] As Maya, edit Figma but reject a write to Zoom. - [ ] As Maya, changing Figma's owner fails; as Ana it succeeds. - [ ] Mark Figma cancelled locally: run rate becomes $21,720. Restore it: $31,320. - [ ] History contains both status actions after restoring. ### Phase 3: renewal dates and alerts ```text Implement R4–R6 and R8 using UTC calendar dates. Build the renewal calendar, alert log and next-renewal form. Start the sample clock on 2026-10-13. A +1 day button runs the daily discovery job; re-running is idempotent. Do not send real emails or automatically advance contract dates. ``` **Acceptance checks** - [ ] Zoom's cancellation deadline is November 1, not December 1. - [ ] The initial four reminders do not multiply after advancing one day. - [ ] Cancelled Monday.com creates no reminder. - [ ] Recording Figma's next renewal as November 1, 2026 fails; November 12, 2027 succeeds. ### Phase 4: export and verification ```text Export only the filtered inventory as CSV with proper quoting and spreadsheet-formula protection. Add tests for every rule, invalid dates, zero seats, duplicate names, permissions and reminder deduplication. Test navigation and successful and rejected mutations in a browser, reload persistence, reset and a 390px viewport. Document what is actually verified separately from any proposed production integrations. ``` **Acceptance checks** - [ ] Filter to Figma and export: only Figma and the header appear. - [ ] Reload after an edit: the edit persists in this browser. - [ ] Reset restores the sample run rate and records. - [ ] At 390px, only the table scrolls horizontally, not the page. ## 13. Variations Add multiple currencies only with explicit exchange-rate dates and source. Add historical spend only from dated invoices. Automated discovery, contract uploads, email reminders and SSO should each get a separate integration phase with access and failure-path tests. ## 14. Build notes from the reference The reference engine has 30 passing automated tests covering calculations, permissions, validation, cancellation/restoration and renewal alerts. Browser acceptance checks above remain a checklist, not a claim that every item has been independently completed. This app and spec were developed together, not in an independent clean-room reproduction. Production authentication, shared persistence, email delivery and vendor integrations remain outside the reference build. ### Integration verification update: October 13, 2026 After the implementation checkpoint, the hosted preview was smoke-checked in Chromium: cancelling and restoring a subscription with history. Two screenshots from the actual demo are now included on its blueprint page. A 390px viewport check found no page-level horizontal overflow in the checked view after fixes. The repository also has a DOM interaction smoke test for each of the eleven new apps, separate from these 30 app-specific automated tests. This is a focused smoke check, not completion of every acceptance item above. The app and spec were developed together, not independently reproduced from the prompts. `tested` stays null; `demoReady` exposes the reference app and full spec without a production-verification claim. No billing connection, vendor cancellation or email delivery. There is no production authentication or shared backend. --- # Time-Off Tracker BP-010 · People · A focused browser-only alternative to a leave spreadsheet. ## 1. Purpose and delivery status “I can never tell who’s out next week until they’re already gone.” Build a useful small-team leave planner: a month calendar, visible reservations, manager decisions, and a balance that never silently double-charges leave. This specification describes the implemented local demo, not a production HR system or every feature of Timetastic. The demo has a domain engine, React interface, 23 automated domain tests, sample data, browser-local persistence, and reset. Browser acceptance verification is **pending**. Metadata must retain `tested: null`; `demoReady` indicates implementation availability, not browser verification. Do not add screenshots or acceptance totals until actual verification. Use the complete document as build context, then send the five phase prompts in order. Do not mark a phase accepted merely because code exists. ## 2. Research and deliberate simplification Official sources consulted October 13, 2026: - [Timetastic features](https://timetastic.co.uk/features/): team leave visibility, remaining allowance, manager approve/decline, pending requests, overlapping availability, and regional public holidays. The demo keeps the core request/decision/calendar loop. It does **not** reproduce regional holiday coverage, email approval, staffing policies, or multiple absence types. - [MDN Date.UTC](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Date/UTC): UTC arithmetic avoids timezone and daylight-saving drift. Calendar inputs must also be round-trip validated because JavaScript can normalize impossible dates. - [MDN localStorage](https://developer.mozilla.org/en-US/docs/Web/API/Window/localStorage): local data survives normal reloads, is origin-specific, and storage access can fail under browser policies. This is not a database or shared team storage. - [React: updating arrays in state](https://react.dev/learn/updating-arrays-in-state): do not mutate rendered state. The shared hook passes a structured clone to domain actions and only saves successful changes. Catalog aspirations intentionally narrowed: the calendar shows **two sample company holidays**, not a public-holiday service; allowances are a single fixed year, not configurable annual policies; there is no calendar feed. Those larger capabilities are future work, not hidden implementation claims. ## 3. Exact scope Included: - Four fictional people in a Design team; actor switcher simulates three requesters and their manager. - Fixed demo today **2026-10-13**, year **2026**, no advancing clock. - Full-day PTO, inclusive start/end dates, Monday–Friday working week. - Company holidays January 1 and December 25, 2026, excluded from charges. - New requests, approve, decline with a reason, cancel own not-yet-started leave. - Pending reservations, approved totals, available balances, request history. - Own pending/approved overlap rejection; advisory coverage warnings for other people. - Month selector, approved/pending calendar labels, out today/next week, all/mine/awaiting-my-decision filters. - Local persistence under `psf-demo-pto-tracker-v1` and confirmed sample reset. Excluded: login, server authorization, synchronized storage, real HR records, database, notifications, exports, uploads, half-days, hourly leave, accrual, carryover, sick leave, multiple countries, configurable weekends, cross-year splitting, balance editing, manager reassignment, request editing/resubmission, partial cancellation, retroactive requests. Alex has no configured manager and cannot request leave. Fixed sample actors are not an authorization boundary. ## 4. Reproducible data model All day values are strict `YYYY-MM-DD` strings. Action timestamps are ISO UTC strings injected by the caller. Browser actions pass `new Date().toISOString()`; tests use `2026-10-13T12:00:00.000Z`. Actual audit timestamps and fixed policy “today” are deliberately separate. ```text State { people[], holidays[], requests[] } Person { id, name, team, managerId: string|null, allowance: integer } Holiday { date, name } Leave { id, personId, start, end, note, status: pending|approved|declined|cancelled, history: [{ actor, action, at, note }] } ``` Seed people: Maya Rivera (`maya`, 20 days), Tom Bennett (`tom`, 25), Jo Park (`jo`, 20), Alex Morgan (`alex`, 25). The first three report to Alex; Alex’s manager is null. Only Maya, Tom, and Jo can submit. Seed requests: | Person | Dates | Status | Charge | |---|---|---|---:| | Maya | Oct 19–23 | Pending | 5 | | Tom | Oct 13–16 | Approved | 4 | | Jo | Oct 22–23 | Approved | 2 | | Maya | Aug 3–7 | Approved | 5 | Maya starts at allowance 20, approved 5, pending 5, available 10. Tom is out today; Jo is out next week. Maya is not counted as out until approved. Preserve handoff notes and request/decision history in the seed. ## 5. Rules and invariants 1. Validate the date format, parse at UTC midnight, and compare the ISO date back to the original. Reject invalid days instead of normalizing February 30 into March. 2. Reject end before start and ranges longer than 366 elapsed days before iteration. Request boundaries must both be in 2026. Reject a new start earlier than fixed today. 3. Count inclusive dates using UTC milliseconds in 86,400,000 increments. Remove Saturday, Sunday, and configured holidays. Reject zero-working-day requests. 4. Notes are optional, trimmed, at most 500 characters. IDs must be nonempty and unique among requests. The actor must exist and have a manager. 5. An overlap is a shared charged working day with the same person’s pending or approved leave. Declined and cancelled requests do not block new leave. Different people may overlap; warn rather than block. 6. `approved = sum(charged days for approved requests)`; `pending = sum(charged days for pending requests)`; `available = allowance - approved - pending`. Approved includes past and future bookings; do not label it “days already taken.” 7. Request charge must fit available days. Exact exhaustion to zero is allowed. Never maintain a second mutable balance counter. 8. Only the assigned manager, never requester or colleague, can approve/decline pending leave. Approval rechecks overlap and available balance. Approving replaces the reservation with approved days without reducing available again. 9. Decline requires a nonblank reason. Approval note is optional. Decisions append actor, action, actual timestamp, and trimmed note. 10. Only the owner may cancel pending/approved leave when `start >= today`. Cancellation releases days. Started/past/declined/cancelled requests cannot be cancelled. No partial cancellation or repeated decision. 11. Every validation happens before mutation. Actions operate on the shared hook’s clone; thrown validation/storage errors leave the visible original unchanged. Transition table: | From | To | Actor and condition | |---|---|---| | New | Pending | Employee with manager; valid range, allowance, no own overlap | | Pending | Approved | Assigned manager; balance/overlap rechecked | | Pending | Declined | Assigned manager; required reason | | Pending or Approved | Cancelled | Owner; start not before demo today | | Declined or Cancelled | — | Terminal; create a new request instead | ## 6. Interface contract Use shared `DemoBar` and `useDemoState`. The page title is “Time off.” Use straightforward technical headings consistent with the existing `.aw` app family; no visual eyebrows or slogan headlines. Show the fixed clock, local-data warning, full-day/year policy, and meaning of pending reservations. All fields have visible labels; errors use `role="alert"`; success uses `role="status"`. The overview shows current actor’s balance, approved absences on today, approved absences intersecting Oct 19–23, and waiting decisions for the actor. Switching actors changes permissions and “mine” but does not change records. UI permissions complement engine validation rather than replace it. The calendar starts Monday, has a month selector for 2026, shades weekends, identifies today, and shows text status labels so color is not the only cue. It shows leave only on charged days. Use a scrollable calendar on narrow screens rather than illegible cells. A separate allowance table remains available. Request form defaults to Nov 2–6, previews working days, and collects an optional handoff note. Clear dates/note after the in-memory action succeeds; preserve input on validation errors. Alex sees an explanation instead of a request form. Request cards show owner, dates, charge, status, note, advisory overlaps, decision fields when authorized, cancellation when allowed, and expandable history. Filters have explicit empty states. Cancel asks for confirmation. Reset confirms replacing local records and restores actor, month, filter, form state, and seed. ## 7. Persistence and safety boundaries The hook starts with null state until client hydration, loads localStorage, falls back to seed on a read/JSON parse failure, and attempts to persist serialized state. If a write fails, retain the successful action in memory and display the shared persistent storage warning: changes may be lost on reload. Validation errors must leave domain state unchanged. A successful demo action is not confirmation of durable storage. Do not put real employee information in this demo. LocalStorage can be edited or cleared by anyone with access to the browser and has no cross-tab concurrency control or encryption. Actor, month, filter, open history, and unsubmitted form contents are session UI state; only the domain records persist. A JSON roundtrip unit test is not a browser persistence acceptance test. Structurally incompatible manually edited storage is outside the demo contract; remove the demo key to recover if needed. No schema migration is implemented. ## 8. Files and execution ```text components/apps/pto-tracker/App.tsx components/apps/pto-tracker/app.css components/apps/pto-tracker/meta.ts lib/demo/pto-tracker/engine.ts tests/engines/pto-tracker.test.ts content/blueprints/pto-tracker.md ``` Existing shared integration owns `/blueprints/pto-tracker/demo`, the catalog entry, DemoBar, persistence hook, and AppMeta type. Do not change those to implement this app. No new dependencies or credentials are needed. From the repository root: ```sh node --test --import tsx tests/engines/pto-tracker.test.ts npx tsc --noEmit --incremental false ``` An authorized browser-verification session can use the host app’s demo route. Do not launch a browser or server merely to claim acceptance while the task excludes that work. ## 9. Five phased build prompts ### Phase 1 — Policy and seed ```text Implement the exact types, fixed dates, four people, two holidays, and four seeded requests above in `lib/demo/pto-tracker/engine.ts`. Add strict date parsing, bounded inclusive UTC iteration, working-day counting, balance selectors, and working-day overlap selectors. Make seed return fresh independent objects. Do not add accrual, a server, or mutable balances. ``` Gate: automated date, DST, weekend, holiday, range-order, and seed balance checks pass; Maya’s starting available balance equals 10. ### Phase 2 — Request and decision engine ```text Add requestLeave and decide actions implementing all validations and transitions in section 5. Inject IDs and timestamps, reject missing actors and request IDs, and append readable history. Validate before mutation. Add tests for allowance exhaustion, own overlap, cross-person overlap, duplicate IDs, terminal decisions, manager permissions, required decline reasons, and cancellation. ``` Gate: invalid actions leave the state unchanged; approval does not double-charge; decline and cancellation release days; no unauthorized decisions succeed. ### Phase 3 — Working interface ```text Replace the placeholder with the specified overview, calendar, request form, allowance table, filters, and request cards. Reuse DemoBar and the persistence hook. Bind every action to the tested engine, wrap failures in alerts, and make actor switching change available actions. Style only the owned app folder with scoped selectors. ``` Gate: type-check passes. Queue, do not claim, browser checks for labeled controls, empty states, calendar scrolling, and permissions. ### Phase 4 — Persistence and reset ```text Persist domain state under the versioned key, retain forms on validation errors, display storage failures, and confirm reset/cancellation. Reset all relevant local UI state. Add serialization and independent-seed tests. Clearly mark fixed time, sample holidays, and local-only data. ``` Gate: JSON roundtrip test passes. Browser reload/reset/blocked-storage behavior remains a separate acceptance task. ### Phase 5 — Documentation and verification ```text Run the domain tests and TypeScript check. Record actual test totals, add exactly four tryThis actions, set demoReady only once implementation/domain tests complete, and leave tested null until independent browser acceptance. Preserve scope exclusions and the acceptance checklist below. ``` Gate: 23 domain tests pass; implementation metadata is honest. No screenshots, production-security claims, or browser-tested badges without evidence. ## 10. Browser acceptance checklist — not yet executed 1. Reset. Maya has 10 available, 5 approved, 5 pending. Tom is out today and Jo next week. 2. As Maya submit Nov 2–6. Five working days are reserved; available becomes 5. Reload and confirm the record remains. 3. Submit Nov 5–6 again: overlap error and unchanged balance. Nov 7–8 alone cannot submit as charged leave. 4. Switch to Alex, approve Maya’s Oct 19–23 request. Approved becomes 10; pending becomes 5 after step 2; available stays 5. Calendar and next-week overview update. 5. Decline Maya’s November request with blank reason: fail without changes. Enter “Coverage gap”: decline succeeds; available returns to 10. 6. Switch to Maya and cancel the future approved October request. Available becomes 15. Past August leave has no cancel action. 7. Reset, then submit Dec 24–28 as Maya. Preview and charge are 2 because Dec 25 is a sample holiday and Dec 26–27 are weekend days. 8. Submit a long November range above available allowance: fail. A range whose end precedes start also fails. Cross-year dates cannot be accepted. 9. Switch between Maya, Jo, and Alex; only Alex gets decisions on team requests. “Awaiting my decision” has an honest empty state for requesters. 10. Reset confirmation cancel leaves changes intact; confirm restores exact seed. Check mobile calendar/table scroll, keyboard controls, status text, errors, and no horizontal page overflow. ## 11. Production upgrade boundary Before use with real people: replace localStorage with an authenticated server, authorize every operation, use transactional constraints for overlap/balance races, define real leave policies and timezone ownership, validate/migrate persisted schemas, add retention/access controls and privacy review, and integrate actual calendars/holidays/notifications. These are separate projects, not hidden switches in this demo. ### Integration verification update: October 13, 2026 After the implementation checkpoint, the hosted preview was smoke-checked in Chromium: approving a pending request as its manager. Two screenshots from the actual demo are now included on its blueprint page. A 390px viewport check found no page-level horizontal overflow in the checked view after fixes. The repository also has a DOM interaction smoke test for each of the eleven new apps, separate from these 23 app-specific automated tests. This is a focused smoke check, not completion of every acceptance item above. The app and spec were developed together, not independently reproduced from the prompts. `tested` stays null; `demoReady` exposes the reference app and full spec without a production-verification claim. Fixed sample leave policy, year and holidays; no calendar integration. There is no production authentication or shared backend. --- # Hiring Pipeline BP-011 · People · A focused candidate board for a small hiring team. ## 1. Purpose and delivery status “We’re hiring for three roles and tracking candidates in a spreadsheet.” Build a useful record of who is being considered, the next stage, and the evidence behind a decision. This specification describes the implemented browser-local recruiting demo. It is not a production applicant tracking system, automated decision tool, or feature-complete Greenhouse replacement. Implementation includes a domain engine, board and detail interface, 24 automated domain tests, fictional sample candidates, persistence, and reset. Browser acceptance is **pending**. Keep `tested: null`; `demoReady` means the demo implementation and domain tests are complete, not that browser checks have run. Do not fabricate screenshot or acceptance metadata. Pass the whole document to a builder, then execute the five phase prompts sequentially. Each phase has an explicit verification gate. ## 2. Research and scoped choices Official sources consulted October 13, 2026: - [Greenhouse platform](https://www.greenhouse.com/platform): its official platform description emphasizes scalable recruiting workflows, candidate experience, and structured interviewing and decision-making. The demo retains candidate stages and human-entered evidence; it does not claim structured scorecard parity or integrated recruitment workflows. Older support-article URLs returned 404 and are not used as evidence. - [React: updating arrays in state](https://react.dev/learn/updating-arrays-in-state): rendered state is immutable. Use the shared hook’s cloned draft for mutations. - [MDN localStorage](https://developer.mozilla.org/en-US/docs/Web/API/Window/localStorage): browser-local storage is origin-specific, persistent across ordinary reloads, and can throw under browser storage policies. It is not suitable for real confidential applicant records. Catalog aspirations intentionally narrowed: stages are fixed across roles, moves use explicit buttons rather than drag-and-drop, no one is notified, and “rating” is a single recruiter assessment rather than a multi-question or multi-interviewer scorecard. These differences must remain visible in the UI and documentation. ## 3. Exact scope Included: - Three initial roles with hiring manager names; create, archive, and restore roles. - Seven fictional candidates across Applied, Screen, Interview, and Offer; Hired is the terminal board column with reversible correction moves. - Create candidate with name, normalized email, role, source, optional HTTPS CV link. - Filter by role, active/archive status, and search by name/email/source. - Select a card for complete details, stage movement, rating, attributed notes, activity, and reversible archiving. - Move exactly one stage forward or backward with a recorded transition. - Overall integer rating 1–5 or unrated, with history; no automated recommendations. - Append-only interview notes with interviewer name and timestamp. - Browser-local domain state under `psf-demo-hiring-pipeline-v1`, storage errors, confirmed reset. Excluded: authentication, real candidate data, public jobs/applications, CV upload/parser/fetching, email, interview scheduling, notifications, drag-and-drop, stage customization, candidate profile editing after creation, role title/manager editing after creation, role transfer, multiple scorecard questions, per-interviewer ratings, AI ranking, bulk actions, rejection emails, export, shared backend, retention automation, cross-tab synchronization. Rejected/withdrawn people can be archived, but archive is not an explicit rejection status and no message is sent. ## 4. Reproducible data model ```text State { roles[], candidates[] } Role { id, title, manager, archived } Stage = Applied | Screen | Interview | Offer | Hired Candidate { id, name, email, roleId, source, cvUrl, stage, rating: integer 1..5 | null, archived, notes: [{ author, text, at }], history: [{ action, at }] } ``` IDs are nonempty strings, generated with `crypto.randomUUID()` in the UI and injected in tests. Timestamps are ISO UTC strings supplied by the caller, using actual browser time for actions and a fixed timestamp in domain tests. Seed timestamps are October 12, 2026. Candidate histories are local demonstration records, not tamper-proof compliance logs. Notes have free-text author attribution; stage/rating/archive activity is not user-authenticated. Seed roles: | ID | Title | Manager | |---|---|---| | design | Product Designer | Maya Rivera | | sales | Account Executive | Sam Okafor | | ops | Operations Lead | Jo Park | Seed candidates: | ID | Name | Role | Stage | Source | Rating | |---|---|---|---|---|---| | mei | Mei Chen | design | Applied | Portfolio referral | Unrated | | leo | Leo Gray | design | Screen | Careers page | 4 | | ravi | Ravi Patel | sales | Applied | LinkedIn | Unrated | | sora | Sora Ito | sales | Interview | Team referral | 4 | | taylor | Taylor Moss | sales | Offer | Recruiter | 5 | | ari | Ari Diaz | ops | Interview | Careers page | 4 | | jamie | Jamie Kim | ops | Applied | Community | Unrated | Emails use `@example.com`. Sample CV links are blank, avoiding invented documents. Rated candidates start with an attributed evidence/next-step note. All candidates and roles start active. Each seed call returns fresh objects. ## 5. Validation and transitions ### Roles - Title: trimmed, required, maximum 100 characters. Manager: trimmed, required, maximum 80. - Role IDs are unique. Titles are unique case-insensitively across active **and archived** roles. Restore a matching archived role instead of duplicating it. - A role can be archived only when all its candidates are archived, including hired candidates. Reject repeated archive/restore status and missing role IDs. - Restore a role before restoring its candidates. No archive cascade or deletion. ### Candidate creation - Existing nonarchived role is mandatory. - Name: trimmed, required, maximum 100. Source: trimmed, required, maximum 100. - Email: trimmed, lowercased, required, maximum 254. Require one nonwhitespace local part, an `@`, and a dotted nonwhitespace domain. This is pragmatic syntax validation, not deliverability verification. - Same normalized email cannot appear twice for one role, even in the archive. The same email may apply to a different role. - Optional CV URL: trim; if present require a parseable `https:` URL, no embedded username/password, maximum 2000 characters. Do not fetch or upload it. Render links with a new tab and `noopener noreferrer`. Never accept `javascript:` links. - Candidate ID must be nonempty and unique. New candidates always start Applied, active, unrated, with no notes and an initial history entry. - Validate every field before inserting; failures must leave state unchanged. ### Stages Exact order: **Applied → Screen → Interview → Offer → Hired**. Allow only adjacent forward/backward moves. This keeps the demo’s progression explicit while allowing corrections, including Hired back to Offer. Reject skips, unknown stages, and no-op moves. Record old and new stage plus timestamp. Hired means present in that column; it does not create an employee record or trigger onboarding. ### Notes and ratings - Append notes only for active candidates in open roles. Author is required, trimmed, maximum 80; evidence/next step is required, trimmed, maximum 2000. Preserve timestamp and entered text; render as text, not HTML. Existing notes are not editable/deletable in this version. - Rating is a single overall integer 1–5 or null to clear. Reject fractions, NaN, infinity, out-of-range values, and unchanged ratings. Record a rating-change history entry. - Display guide: 1 weak evidence, 3 mixed evidence, 5 strong evidence against role requirements. The app does not calculate suitability or use protected characteristics. ### Archive and restore Archiving is reversible and preserves stage, notes, rating, and activity. Archived candidates are read-only except restore. Do not permit moves, notes, or rating changes while candidate or role is archived. Restore requires an open role. Preserve records rather than deleting them; reject repeated status operations. Activity records archive/restore timestamps. ## 6. UI contract Page heading: “Hiring pipeline.” Use straightforward technical headings consistent with the existing `.aw` app family; no visual eyebrows or slogan headlines. Show a clear browser-only, fictional-data notice. Use shared DemoBar with reset; no actor switcher because this is a shared recruiter simulation, not a permissions demo. Summary metrics count all active records: open roles, candidates in non-Hired stages, candidates at Offer, and candidates at Hired. They are workspace totals, not filtered totals. Show the matching count separately above the board. Candidate board has five columns with per-column counts. Each card shows name, role, source, overall rating/unrated, and note count. Clicking selects a record. Role, search, and active/archive filters combine. Never keep displaying a selected detail outside the current filter; show a select-a-candidate empty state instead. Board columns horizontally scroll on small screens. Do not falsely imply drag-and-drop. Candidate detail shows name, email, role, manager, source, optional CV link, stage, adjacent move buttons, rating selector, note form/list, reverse-chronological activity, and archive/restore. Disable boundary stage controls and actions on archived records. Note authors default to the role’s manager but are editable. Clear a successful note submission; retain input on errors. Add-candidate form collects only fields in section 5. On success select the new candidate, filter to its role and active records, clear search, and close the form. If no roles are open, explain that a role must be created/restored first. Manage roles has a creation form and list of manager, active-candidate count, archive/restore action. Failed archive explains that candidates must be archived first. Domain constraints remain authoritative even when buttons are available. Controls have labels and keyboard access. Use `role="alert"` for errors, `role="status"` for successes, `aria-pressed` for selected cards/views, and visible text instead of color-only states. Reset requires confirmation because it replaces all demo records. Candidate/role archive is reversible and does not delete data. ## 7. Persistence and failure behavior Reuse `useDemoState`: initial loading state, localStorage hydration, fallback seed on storage read/JSON parse failure, clone before domain actions, and attempt local persistence. Validation failures show an error without changing records. A storage write failure retains the successful action in memory and displays the shared persistent warning that changes may be lost on reload; do not represent it as durable storage. Reset returns all domain records and main filters/panel/selection to the original demo state. Only domain records persist. Filters, panel selection, selected candidate, and unsubmitted forms do not survive reload. JSON serialization tests do not verify browser localStorage behavior. No migration or validation of manually edited incompatible storage shapes is implemented; removing the versioned key recovers from such unsupported tampering. No encryption, multi-user consistency, permissions, or protection against browser access. Do not store real resumes, evaluations, or applicant contact details. Use a server with privacy and retention controls before adapting this for real hiring. ## 8. Files and execution ```text components/apps/hiring-pipeline/App.tsx components/apps/hiring-pipeline/app.css components/apps/hiring-pipeline/meta.ts lib/demo/hiring-pipeline/engine.ts tests/engines/hiring-pipeline.test.ts content/blueprints/hiring-pipeline.md ``` The shared host already owns catalog integration, AppMeta, DemoBar, persistence, and route `/blueprints/hiring-pipeline/demo`. Implement only the app-owned files. No dependencies, credentials, server, or external integration are required. ```sh node --test --import tsx tests/engines/hiring-pipeline.test.ts npx tsc --noEmit --incremental false ``` Run these from the repository root. Browser verification must be separately authorized when the implementation task explicitly excludes browser/server launches. ## 9. Five phased build prompts ### Phase 1 — Domain and fixtures ```text Create the exact State, Role, Candidate, and Stage definitions above. Implement the three-role/seven-candidate fixture with realistic notes, fictional emails, no invented CV URLs, and independent seed objects. Keep all data JSON-serializable and all action IDs/timestamps injectable. ``` Gate: seed count, referential integrity, and serialization tests pass; no dependencies or network requests are introduced. ### Phase 2 — Validated actions ```text Implement addRole, archiveRole, addCandidate, moveCandidate, addNote, rateCandidate, and archiveCandidate. Enforce every rule in section 5 before mutation. Store history for candidate creation, transitions, ratings, and archive/restore. Notes retain their own author/time. Test normalization, duplicates, HTTPS safety, missing IDs, transitions, archive locks, and parent-child restore ordering. ``` Gate: invalid actions leave state unchanged. Every allowed transition has a passing automated test. Neither UI nor engine can bypass archive locks. ### Phase 3 — Board and detail ```text Replace the placeholder with the board, summary, combined filters, selected detail, evidence notes, ratings, stage buttons, and visible activity. Bind controls to the engine through cloned draft updates. Use only app-scoped CSS and shared DemoBar/useDemoState. Clearly label adjacent button moves and single overall assessment, not scorecards or notification workflows. ``` Gate: TypeScript passes; filtered records and detail share the same source state. Queue browser checks without claiming them. ### Phase 4 — Creation, roles, persistence, reset ```text Add candidate and role forms, reversible archive/restore controls, required empty states, versioned localStorage persistence, and confirmed reset. On validation failure retain the original record/form and show an alert. If storage fails, keep the in-memory action and display the shared persistent warning that reload may lose changes. On candidate creation select the new record. Preserve archived evidence and enforce restore ordering. Add serialization and independent reset-seed tests. ``` Gate: domain tests cover both creation flows, archive/restore, and serialization. Browser reload/reset remains a separate pending check. ### Phase 5 — Honest metadata and acceptance ```text Run all 24 hiring domain tests and the TypeScript check. Add four tryThis actions and honest demoReady/demoTests/demoNote metadata. Leave tested null and screenshots empty until browser acceptance is performed. Match this scope precisely; list unsupported production capabilities instead of implying them. ``` Gate: test counts reflect actual results; no deployment, browser, auth, scheduling, or email claims without evidence. ## 10. Browser acceptance checklist — not yet executed 1. Reset. See 3 open roles, 7 in pipeline, 1 at offer, 0 hired; seeded Sora detail is selected. 2. Filter to Product Designer: see Mei and Leo. Search “leo” and verify only Leo remains. Clear filters. 3. Add Casey Williams, `casey@example.com`, Product Designer, source Referral. See Casey in Applied and selected. Try a duplicate with uppercase email: reject without inserting a record. 4. Try a malformed email or non-HTTPS CV link: reject. Valid optional HTTPS link renders with safe new-tab behavior; no automatic fetch occurs. 5. Move Casey Applied → Screen → Interview. Move back to Screen. Activity contains every transition. The UI offers no stage skipping. 6. Add an attributed evidence note and rate Casey 4. Reload: note, stage, and rating persist. Clear rating: show Not rated and a new activity entry. 7. Archive Casey. Active board no longer shows Casey. Choose Archived candidates, select Casey, inspect preserved evidence, restore, and return to active view. 8. Create a Customer Success Manager role owned by Alex Morgan. Duplicate title with case changes is rejected. Add a candidate for it; archiving the role fails until that candidate is archived. 9. Archive the role after archiving its candidate. Candidate restore is blocked until role restore. Restore role, then candidate; evidence remains intact. 10. Move Taylor Offer → Hired and verify Hired increments and in-pipeline decrements. Move back to Offer and verify both reverse. 11. Cancel reset confirmation: records remain. Confirm reset: all created roles/candidates/notes are removed and exact sample data returns. 12. Verify keyboard labels, disabled archive/boundary controls, empty search result, mobile board scrolling, storage error messaging, and no page-level horizontal overflow. ## 11. Production upgrade boundary Real hiring needs authenticated role-based access, a transactional database, server-side validation, candidate consent/privacy processes, retention/deletion workflows, audit attribution, secure document handling, and reviewable job-specific scorecards. Design these separately before adding real applicants. Do not use this demo’s overall rating as an automated hiring decision or infer that free-text attribution is authenticated identity. ### Integration verification update: October 13, 2026 After the implementation checkpoint, the hosted preview was smoke-checked in Chromium: moving a candidate from Interview to Offer. Two screenshots from the actual demo are now included on its blueprint page. A 390px viewport check found no page-level horizontal overflow in the checked view after fixes. The repository also has a DOM interaction smoke test for each of the eleven new apps, separate from these 24 app-specific automated tests. This is a focused smoke check, not completion of every acceptance item above. The app and spec were developed together, not independently reproduced from the prompts. `tested` stays null; `demoReady` exposes the reference app and full spec without a production-verification claim. No emails, uploads or authenticated recruiter accounts. There is no production authentication or shared backend. --- # Customer Feedback Inbox Blueprint BP-012 · Support / Product · For teams of 3–50 · Replaces a focused slice of Productboard > Paste this spec into your builder, then send the four phase prompts one at a time. The included implementation is a browser-local demo. The production extension is explicitly separate. Unchecked acceptance items are a test plan, not claimed results. ## 1. Start with the work “Customer feedback is scattered across email, calls and support tickets.” An account manager hears the same request twice, a support agent hears it again, and product sees three unrelated messages. The goal is not another voting portal. It is to preserve what was said, connect evidence to a theme, make a considered product decision and remember who to tell when something ships. The fictional Northwind workspace has four customer accounts, their monthly recurring revenue, five pieces of feedback, three themes and two roadmap items. A small support/product team can capture a quote, triage it, rank themes, promote a theme with an owner, update delivery status and record manual customer follow-up in one place. ## 2. Official research and design choices Sources consulted on 2026-10-13: - [Canny API reference](https://developers.canny.io/api-reference). Official documentation describes boards as places to post and vote on ideas, along with public/private board metadata, posts, API authentication and integration concerns. It supports the basic distinction between raw feedback, requests and explicit voting. It is a related product reference, not evidence that this demo duplicates Productboard or implements Canny's API. - [MDN: Window.localStorage](https://developer.mozilla.org/en-US/docs/Web/API/Window/localStorage). Local storage is per origin and persists across browser sessions, but storage access may be unavailable. It does not provide a team database, authorization or concurrency control. The account-level vote limit, revenue calculation, promotion gate and status vocabulary below are deliberate choices for this small-team workflow. They are not represented as vendor-mandated behavior. No external API, account, key, email service or integration is needed for the demo. ## 3. Scope and boundaries Included: manual capture from a known sample account; Email, Call, Support ticket and In-app source labels; untriaged/triaged/dismissed queues; customer/text search; reversible dismissal; one theme per piece of evidence; custom themes; evidence, unique-account MRR and vote ranking; account vote toggling; promotion with an owner; status decision history; internal roadmap; a shipped follow-up checklist; browser persistence and sample reset. Deferred: real inbox ingestion, support connectors, customer imports/editing, automatic sentiment analysis, AI clustering, attachments, public portal, customer authentication, merge/duplicate resolution, release dates, scoring frameworks, cross-workspace tenancy and real notification delivery. Source labels are manually selected provenance, not proof an integration ran. Customers and their MRR are seeded read-only for this focused demo. The roadmap is internal. It intentionally shows private customer evidence and account value. Do not embed it publicly. A future public roadmap must use a separate projection without customer names, quotes, MRR or staff-only decisions. ## 4. Data model Demo state is `{ customers, feedback, themes }` at `psf-feedback-inbox-v1`. All dates are UTC ISO strings; IDs are opaque strings. **Customer:** id, name, mrr. MRR is USD integer cents per month, never floating-point dollars. Seed accounts are Acme Corp ($2,400), Ridgeway ($890), Olsen & Co ($149), and Harbor Studio ($490). Currency conversion and historic revenue snapshots are outside scope. **Feedback:** id, customerId (must exist), source (enumeration), quote (trimmed, 10–1,500 characters), receivedAt, themeId (nullable existing theme), dismissed (boolean), triageNote (empty unless dismissed). Preserve original evidence; reassign its relationship instead of duplicating its contents. **Theme:** id, name (trimmed, 3–80 characters; case-insensitive unique), owner (blank before promotion or a supported owner), status, promoted, voters (unique customer IDs), notified (customer IDs with manually recorded contact), and history entries of timestamp/status/note. Owners are Maya Chen, Sam Okafor and Dana Ruiz in this demo. Status vocabulary: Under review, Planned, In progress, Shipped, Not planned. A theme starts Under review but stays off the roadmap until explicitly promoted. The roadmap and themes read the same object, so evidence does not go stale in a copied request. ## 5. Business rules and exact calculations 1. Capture validates customer, source and quote before creating anything. Every new item is untriaged, not dismissed and has no theme. A valid quote is not automatically a vote. 2. Triage links to one existing theme or clears the theme back to the inbox. Reassignment removes it from the old theme's derived metrics. It does not create a second piece of feedback. 3. Dismissal requires a 5–300-character reason, clears the theme and removes the item from evidence totals. Restore clears dismissal and reason and returns it to untriaged. This is reversible, not a destructive delete. 4. Theme names are unique ignoring case and surrounding whitespace. Do not silently merge records with similar names. A theme without evidence is valid, but cannot be promoted. 5. Evidence count is the number of active feedback records linked to the theme. Unique customers are deduplicated by customerId. MRR is the sum of current MRR of those unique customers, counted once per theme. The same customer may legitimately contribute to several themes; never add theme MRR totals and call it company revenue. 6. Votes are independent expressions of interest: one per customer account per theme. Voting again removes that vote. Votes do not create evidence or increase MRR. The visible “Simulate voting as” selector is not authenticated identity. 7. Promotion requires at least one linked non-dismissed feedback item and a valid product owner. It sets promoted=true, records the owner and adds an Under review decision entry. Duplicate promotion fails. Promotion is not a commitment to deliver. 8. Only promoted themes can change status. Reject unknown or unchanged statuses and require a 5–500-character explanation. Deliberately allow moving between any different supported statuses: a team can document work already shipped or reverse a decision without traversing fictional intermediate steps. Each change appends history; do not erase earlier decisions. 9. A shipped request's follow-up recipients are the unique union of customers with current linked evidence and current voters. A voter with no captured quote can still need an update. Non-shipped requests have no active follow-up list. 10. Mark contacted only for an eligible customer on a shipped request. Repeating it is idempotent. It records a manual action; it does not send email or prove delivery. On leaving Shipped, clear the release checklist so shipping again creates a fresh follow-up obligation. Earlier status history remains, but this demo does not maintain an immutable contact audit trail. 11. Validate before mutation. Missing references throw explicit errors. No silent success on unknown IDs. Persist the cloned draft before displaying a saved-state notification. ## 6. Workflow and screens **Inbox:** default untriaged queue with the count in the tab. Expand Capture to select a customer/source and write the original quote. Successful save returns the list to an unfiltered untriaged view so the new item is visible. Keep input on failure. Queue, channel and text filters combine. Cards show account, MRR, source, received date, quote and a theme selector. A dismissal disclosure asks for a reason; dismissed cards offer Restore. No-result states explain how to add or find records. **Themes:** create a name, then return to the inbox to attach evidence. Rank by evidence count, customer MRR or votes (descending; tie-break alphabetically). Explain the revenue denominator. The sample has two SSO messages from Acme: two evidence records, one account and $2,400 MRR—not $4,800. Each card shows metrics and a customer vote button. Expand Evidence & decisions to read raw quotes and assign an owner before Send to product. **Roadmap:** grouped internal lanes for the five statuses. Promoted themes only. Keep theme titles, owner, metrics, evidence and decision controls available; do not duplicate request records. Use a narrow-screen layout that wraps vertically rather than forcing a wide kanban canvas. A status change requires an explanatory note. Show an empty state per lane. **Close the loop:** on Shipped cards, list interested accounts with Mark contacted. The control is disabled once recorded. Show “No message is sent.” Reopening then shipping starts a fresh checklist. If evidence is reassigned, the derived list changes to reflect current links; that is a demo simplification, not a frozen production release recipient list. **Global:** shared DemoBar and confirmed Reset of this demo only. Success and error live regions. Labeled native controls, visible focus, semantic headings and text statuses. Quotes render as text, not HTML. A browser-local/no-integrations notice is always visible. Match the site's warm-paper, ink and orange tokens and inherited IBM Plex font. Use square, restrained controls and plain app names; no visual eyebrows, slogans, italic headings, decorative serif type or separate green/blue palette. ## 7. Seed walkthrough - Acme's support ticket and follow-up call both support Single sign-on. Acme and Harbor have voted. The theme is Planned, owned by Sam. MRR comes only from Acme's linked evidence, not Harbor's vote. - Ridgeway asked for CSV exports by email. The request is Shipped, owned by Maya, with one open manual follow-up. - Olsen asked for Slack alerts in-app. It has a theme and evidence but no roadmap promotion, so the owner/promotion gate can be tried immediately. - Harbor's call asks for a client progress link. It is untriaged. Create a theme, link this message, assign an owner and promote it. ## 8. Build plan ### Phase 1 — Capture evidence and triage safely ```text Build a browser-local feedback inbox for the Northwind scenarios in this document. Implement pure domain functions, a seeded read-only customer list and manual capture with validated source labels. Add untriaged/triaged/dismissed filters, search, theme reassignment and reversible dismissal. Persist with the shared demo hook and a dedicated versioned key. Do not add real ingestion integrations or claim email was received automatically. ``` Acceptance checks to run: - [ ] A valid captured quote appears untriaged with its customer, source and received time. - [ ] Unknown customers, unsupported sources and invalid quote lengths fail without partial changes. - [ ] Search and source/queue filters combine correctly and produce a helpful empty state. - [ ] Assigning then reassigning a quote updates the relationship without duplicating feedback. - [ ] Dismissal requires a reason; Restore returns the quote to untriaged. - [ ] Reload keeps saved data; confirmed Reset restores the sample dataset without changing other demos. ### Phase 2 — Themes, account value and votes ```text Add theme creation, evidence inspection and descending ranking by evidence, unique-customer MRR or votes. Make the MRR calculation explicit in the interface. Treat account votes independently of raw feedback and allow one toggleable vote per account/theme. Implement a simulated customer selector without pretending it is secure authentication. Keep the evidence data intact. ``` Acceptance checks to run: - [ ] Empty/overlong theme names fail; differently cased duplicates are rejected. - [ ] Acme's two SSO messages show two evidence records, one customer and $2,400 MRR. - [ ] Moving an account's last piece of evidence to another theme removes its prior MRR contribution. - [ ] A vote changes the vote count but not evidence count or MRR. - [ ] Repeating a vote removes it; switching simulated account permits a separate vote. - [ ] All three ranking options produce descending totals with stable alphabetical tie ordering. ### Phase 3 — Product decisions and internal roadmap ```text Add Send to product, requiring an owner and evidence. The roadmap must reference the same theme, not copy customer quotes. Provide five internal status lanes and status updates with required explanations. Support documented reversals as well as forward movement. Show owner, evidence and decision history together; do not build a public portal in this phase. ``` Acceptance checks to run: - [ ] An empty theme cannot promote, even if it has votes. - [ ] A theme with evidence still needs a valid owner; duplicate promotion is rejected. - [ ] Promotion adds one Under review roadmap item with the original evidence intact. - [ ] Only promoted themes accept status changes; invalid or repeated statuses fail. - [ ] A status update without a substantive note changes neither status nor history. - [ ] Planned, In progress, Shipped, Not planned and reopening are reflected in the correct lanes and history. ### Phase 4 — Follow-up, persistence and evidence of correctness ```text Add a shipped follow-up checklist derived from unique evidence customers plus voters. Record manual contact idempotently and never call it sent email. Reset contact state when a shipped request reopens. Test domain invariants, invalid mutations and JSON persistence. Run TypeScript and record browser checks separately. Keep tested metadata null until actual acceptance evidence is collected. ``` Acceptance checks to run: - [ ] A customer who supplied evidence and voted appears once in the shipped checklist. - [ ] Interested voters without evidence still appear; unrelated accounts cannot be marked contacted. - [ ] Mark contacted is idempotent and explicitly says no message was sent. - [ ] Reopening removes follow-ups; shipping again starts a fresh checklist without erasing status history. - [ ] Storage failures, keyboard operation, phone layout and reload/reset are checked in a real browser. - [ ] Report automated tests separately from browser checks, leaving unperformed checks unclaimed. ## 9. Production extension, not implemented here Use a shared database with foreign keys and transactional mutations. Introduce support and product roles on the server: support captures/triages evidence; product owners promote and change roadmap status. Enforce customer vote identity server-side with a unique customer/theme constraint. The demo's account selector must never become a production authorization mechanism. Model votes as rows rather than mutable arrays and use idempotent add/remove endpoints; replaying a toggle request is not safe under network retries. Replace read-only seed customers with an authorized account source. Validate integer nonnegative MRR and currency; decide whether financial context is current or snapshotted at capture. Limit account value visibility to staff who need it. For real ingestion, verify webhook signatures, deduplicate by source/externalId, retain provenance, bound input and attachments, and honor deletion/retention policy. Never expose support quotes in a public API by accident. For actual release notifications, create a release record and snapshot a recipient list at shipping; store notification attempts, idempotency keys, consent/preferences, delivery status and retry policy. A sent API request is not delivered mail. Preserve per-release contact history instead of clearing the prior checklist. Keep notes and account revenue out of public release messages. API credentials belong only on the server. LocalStorage is editable and origin-specific; no authentication, multi-tab conflict resolution, shared team access or tamper-proof audit exists here. The shared hook recovers from invalid JSON, but arbitrary schema-corrupt JSON has no migration path. Use versioned keys for demo schema changes; do not use real customer information in this sample app. ## 10. Verification status The implementation has 21 passing domain tests in `tests/engines/feedback-inbox.test.ts`. Scoped TypeScript validation passes for both demos, their dependencies and tests. The final repository-wide TypeScript check also passes. Browser acceptance, delivery integrations and deployment were not performed in this task. Checkboxes remain unverified, screenshots were added during the integration pass and `tested` stays null. `demoReady` means the local app and its engine tests are implemented, not that the blueprint has passed hosted acceptance testing. ### Integration verification update: October 13, 2026 After the implementation checkpoint, the hosted preview was smoke-checked in Chromium: triaging feedback and voting for its theme. Two screenshots from the actual demo are now included on its blueprint page. A 390px viewport check found no page-level horizontal overflow in the checked view after fixes. The repository also has a DOM interaction smoke test for each of the eleven new apps, separate from these 21 app-specific automated tests. This is a focused smoke check, not completion of every acceptance item above. The app and spec were developed together, not independently reproduced from the prompts. `tested` stays null; `demoReady` exposes the reference app and full spec without a production-verification claim. Customer accounts and votes are simulated; no ingestion or email delivery. There is no production authentication or shared backend. --- # How to build an internal tool with AI that your team can rely on Source: https://personalsoftwarefactory.com/guides/how-to-build-internal-tools-with-ai Updated: 2026-10-15 > A practical method for building internal business tools with Claude, ChatGPT, Cursor, Lovable, Replit or Type: write the spec first, build in phases, and check every phase in a browser. With real examples from our build tests. ## The short answer Write the spec before you write a prompt. Describe the problem in one sentence, decide what you will leave out, define the data model and the rules with worked examples, then build in three to five phases and run acceptance checks after each one. AI app builders are good at turning precise instructions into working code. They are poor at guessing rules nobody wrote down: who may approve what, what happens when someone is on holiday, how a refund changes a commission that was already paid. A one-line prompt gets you a convincing demo. A spec gets you a tool your team can rely on. This is the method every blueprint on this site follows. The examples below come from building the [Approval Workflow](/blueprints/approval-workflow) from its own blueprint. ## Which tools are a good fit Good fits share three traits: a clear workflow, a small data model and a small team. Examples: - **Requests and approvals:** purchase approvals, time off, access requests. - **Records with a next step:** a CRM where every contact has a follow-up date, a hiring pipeline, client onboarding. - **Calendars and status:** a content calendar, a weekly project status report. - **Calculations with rules:** sales commissions, quotes with discount limits, software spend. These need extra care in the spec: permissions, money, dates and time zones, notifications, imports, anything that runs on a schedule. These are usually better bought than built: payroll and tax, anything with regulatory certification requirements, deep two-way integrations with many systems, real-time collaborative editing, and public products at scale. ## Step 1: Write the problem in one sentence Use the words of the person who has the problem, not the name of a product category. > "Purchase requests get lost in Slack, and nobody knows who approved what." That sentence tells you what success looks like (nothing gets lost, every decision is recorded) and what does not matter (forms, branding, dashboards). Every later decision can be checked against it. ## Step 2: Decide what you will leave out The product you are replacing does far more than your team uses. Read its product pages and help center, list what it does, then write two short lists: what you keep and what you deliberately leave out. For the approval workflow we kept a structured request form, routing by amount and category, a sequential approval chain, approve, reject or send back, segregation of duties, delegation, reminders, escalation, an append-only audit log, Slack approvals and CSV export. We left out a no-code form builder, a visual workflow designer, parallel branches, cost-centre coding, ERP integrations and file uploads. Writing the "leave out" list matters as much as the "keep" list. It stops the AI from inventing features, and it tells the reader when they should buy the product instead. ## Step 3: Define the data model before any screens List every table and every field, with its type and constraints. A few rules prevent most bugs: - **Money is an integer number of cents.** Never a floating-point number. - **Dates and timestamps are different things.** A due date is a calendar date; "approved at" is a UTC timestamp. Say which time zone "today" is in. - **History is append-only.** An audit log that can be edited is not an audit log. Say so, and say there is no API to change it. - **Notifications go through an outbox.** Write each message to a table first, then send it. This makes email and Slack testable without real accounts, and retryable when a provider fails. ## Step 4: Write the rules as numbered statements, with worked examples Rules written as prose get interpreted. Rules written as numbered statements get implemented. "R2. A requester can never approve their own request (enforced by routing and checked again when the action happens)" leaves little room to guess. For anything that computes a result, add worked examples with your sample data. The approval workflow spec includes a table like this: | Requester | Request | Resulting chain | |---|---|---| | Jo | $2,400, Hardware | Manager: Maya → Finance: Ana | | Lee | $12,000, Software | Manager: Sam → IT: Ben → Finance: Ana → Executive: Dana | | Dana | $500, Other | Manager: Ana (Dana has no manager; Ana is the other admin) | Worked examples do two jobs. They show the AI exactly what the rule means, and they become your tests. ## Step 5: Write seed data that exercises every rule Name the people, their roles and their reporting lines, and create records that hit each rule: someone with no manager, an approver who is out of office, a request just over a threshold. With realistic seed data, every acceptance check can name a person and an expected result instead of saying "a user" and "it works". ## Step 6: Split the build into phases Send the whole spec as context, then build in three to five phases, one prompt per phase. A typical split: 1. **Foundation:** the data model with a real database, sign-in, admin screens, seed data, and a development-only "sign in as" switcher so you can test as different people. 2. **The core flow:** the main screens and the main rules, enforced on the server. 3. **Notifications and integrations:** email and Slack through the outbox. 4. **Time and export:** reminders, escalation, scheduled jobs (with a development-only way to move the clock forward), CSV export. Each prompt should say what to build, which sections and rules to follow, what not to build yet, and end with: "When you're done, list what you built and anything in the spec you had to interpret." That last line is where most problems surface. ## Step 7: Run acceptance checks after every phase Each phase ends with checks you can run by clicking through the app. Good checks name the person, the action and the visible result: - "Signed in as Ana while Maya's step is active, there is no Approve button and a direct API call is refused." - "Ana tries to reject without a comment and is blocked. She sends back with a comment instead. Jo sees 'Changes requested' and the comment." Do not move to the next phase until the current phase passes. Problems found in Phase 1 are cheap; the same problems found in Phase 4 mean rework across everything built on top. ## Step 8: When the AI has to guess, fix the spec Every time the builder interprets something, ask whether the spec should have said it. When we built the approval workflow from its blueprint, the build passed all 34 acceptance checks, and it also exposed five gaps in the spec: 1. Escalation could hand a step to someone who owned a later step, so they would be asked to approve the same request twice. 2. The spec did not say what happens when a rule's approver is inactive at the moment a request is submitted. 3. The admin screen allowed reporting loops (A manages B, B manages A), which breaks routing. 4. Reassignment and escalation did not say whether the reminder clock restarts. 5. Times in Slack messages showed in UTC instead of each person's local time. None of these were exotic. They are the edges every real team hits within a month: people change roles, go on holiday, leave. Each one became a numbered rule in the published blueprint. Gaps cluster in the same places in most tools: changes to people and permissions, time, and how things are displayed outside the app. ## Before you put it in front of your team A tool that passes its checks on a development server still needs: - **Real sign-in.** Use your platform's built-in auth or Google sign-in limited to your company domain. Remove the "sign in as" switcher from production. - **Permissions on the server.** Hiding a button is not a permission check. Every rule about who can do what must be enforced in the API. - **A managed database with backups.** Test builds often use SQLite on one machine; production should use your platform's managed database with the same schema. - **A real email provider** connected to the outbox, with failures retried. - **An owner.** Someone who will fix it when it breaks and change it when the process changes. - **An export.** Your data should leave as a CSV in one click, so the tool never traps you. ## Which AI builder should you use? Any builder that accepts a long document as context and can create a web app with a database will work with a good spec. The trade-off is mostly about where the code lives: - **Hosted app builders** (for example Lovable, Replit or Type) host the app for you and are the fastest way to get something your team can open. - **Coding assistants** (for example Claude, ChatGPT, Cursor or Claude Code) produce code you keep in your own repository and deploy wherever you like. Our build tests use Claude. The acceptance checks are how you find out whether your builder read the spec the same way. ## Questions people ask ### Can AI really build business software? Yes, for internal tools with a clear workflow and a modest data model. The limiting factor is the precision of the instructions, not the AI's ability to write code. Our build of the approval workflow from its blueprint passed all 34 browser acceptance checks and has 38 automated tests. ### How long does it take? The coding is no longer the slow part. Most of the time goes into deciding the rules and checking each phase. Each blueprint on this site shows an estimate; the four-phase approval workflow is estimated at one to two hours for someone working with an AI builder from the finished spec. ### Do I need to know how to code? Not to build the first version with a hosted builder. You do need to read what the builder tells you it interpreted, run the acceptance checks, and own the tool afterwards. For production sign-in, permissions and backups, it helps to have someone who has run software before review the setup. ### Where do I start? Pick the tool where your team pays per seat for a large product and uses a small part of it. Then start from a [blueprint](/blueprints) or write your own spec using the steps above. --- # Build or buy: when to replace a SaaS tool with one you build with AI Source: https://personalsoftwarefactory.com/guides/build-vs-buy-saas Updated: 2026-10-15 > A decision guide for small teams. The cost math with real per-seat prices, the questions that decide it, and the cases where paying for the product is still the better choice. ## The short answer Build when your team uses a small, well-defined part of a large product, pays per seat for it, and wants the tool to follow its own process. Buy when you need what only a vendor's scale provides: compliance certifications, deep integrations, support with guaranteed uptime, and a stream of features you would otherwise have to maintain yourself. AI app builders changed one side of this trade-off. A small internal tool used to take weeks of a developer's time; with a precise spec it can now be built and checked in hours. The other side did not change: someone still has to own what you build. ## The cost math Per-seat software is easy to price and easy to underestimate, because the cost grows with the team. **Annual cost of the product** = price per seat per month × seats × 12 Some list prices we checked on the vendors' own pages in October 2026: | Product | Plan | List price | 10 people for a year | |---|---|---|---| | Pipedrive (CRM) | Growth | $39 per seat per month, billed annually | $4,680 | | Pipedrive (CRM) | Premium | $59 per seat per month, billed annually | $7,080 | | HubSpot (CRM) | Starter seats | from $15 per seat per month | $1,800 | | PandaDoc (quotes) | Business | $49 per seat per month | $5,880 | | Proposify (quotes) | Grow | $325 per month for 5 seats | Depends on seats beyond 5 | Free tiers exist and matter: HubSpot's free CRM covers up to 2 users. Feature gating matters too. PandaDoc's Starter plan allows up to 5 templates; CoSchedule lists campaigns and custom statuses only on its Suite plan. Teams often pay for a higher tier to get one feature. **Cost of building** = build time + hosting + maintenance over the year - **Build time:** the hours to send the phase prompts, read what the builder interpreted and run the acceptance checks, plus your AI builder's subscription. - **Hosting:** a small internal app with a managed database is often within a hosting platform's free or entry tier, but check your platform's pricing for your usage. - **Maintenance:** the hours each month someone spends on fixes, changes and updates. This is the cost most teams forget, and it is the one that decides whether building was worth it. As an illustration, not a quote: if the first version takes one working day to build and check, and the tool then needs two hours of someone's time a month, building costs about 4 working days a year. Compare that with the product's annual price for your team, and with what the product gives you that your build will not. ## Seven questions that decide it 1. **How much of the product do you actually use?** If the answer is "contacts, deals and follow-up dates" from a CRM that also does email sequences, scoring and forecasting, building wins more often. 2. **Is the workflow specific to how your team works?** Products force a process; built tools follow yours. Custom approval rules, commission plans and onboarding checklists are where this pays off. 3. **Who will own it?** If no one will fix it when it breaks or change it when the process changes, buy. 4. **What happens if it is down for a day?** If the answer is "payroll is late" or "customers can't buy", buy, or build with serious operational care. 5. **Does it touch regulated data?** Payroll, tax, health records and anything requiring a certification you would need to prove to an auditor usually belong with a vendor. 6. **How many systems does it need to talk to?** One CSV export is easy. Two-way sync with your CRM, accounting system and HR system is a product in itself. 7. **How many people use it, and how fast is that growing?** Per-seat prices that are trivial at 5 people can be significant at 50. ## When building usually wins - A small team pays per seat for a large product and uses a fraction of it. - The workflow is simple to describe but specific to you: who approves what, how commission tiers work, which steps a new client goes through. - The data is internal and not regulated. - You want an audit trail and an export you control. Examples on this site: an [approval workflow](/blueprints/approval-workflow) instead of an enterprise workflow platform, a [simple CRM](/blueprints/simple-crm) built around follow-up dates, a [commission calculator](/blueprints/commission-calculator) with your actual plan rules. ## When buying usually wins - You need certifications (such as SOC 2 or ISO 27001) and contracts your customers or auditors will ask about. - The product's value is its network or data: payments, payroll, enrichment, marketplaces. - You need many deep integrations maintained by someone else. - Non-technical staff need to build new workflows themselves without asking anyone. - Nobody on the team can own a piece of software. Every blueprint on this site lists what it deliberately leaves out compared with the product it replaces, so you can check whether any of those are must-haves for you. ## Hidden costs on both sides **Products:** price increases at renewal, seat creep as the team grows, features locked behind higher tiers, and the cost of leaving when your data is hard to export. **Building:** maintenance, security updates, onboarding new people to a tool nobody else uses, the risk that the one person who understands it leaves, and backups you must set up yourself. ## A middle path You do not have to replace the whole product. Common middle paths: - Keep the product's free tier for the core and build the one workflow it does not support. - Build the internal tool and keep the vendor only for the part with network value (for example, e-signature). - Build a version for the next year, and revisit when the team doubles. ## Questions people ask ### Is it cheaper to build software with AI than to buy SaaS? For a small, well-defined internal tool used by a team that pays per seat, often yes, as long as someone owns it afterwards. For products whose value is scale, data, integrations or certifications, usually no. Do the cost math above with your own team size and include maintenance. ### What is the biggest risk of building internal tools with AI? Not the code: the unwritten rules and the lack of an owner. A tool built from a vague prompt works in the demo and fails on the first real edge case. Write the rules down, check every phase, and decide who maintains it before you start. ### How do I know if my team only uses a small part of a product? List what people did in the product last month. If it fits in a few sentences ("logged calls, set follow-ups, moved deals"), you are a candidate for building. If it takes a page, you are probably using what you pay for. --- # A spec template for AI app builders Source: https://personalsoftwarefactory.com/guides/ai-app-spec-template Updated: 2026-10-15 > A free, copyable Markdown template for specifying an internal tool before you build it with Claude, ChatGPT, Cursor, Lovable, Replit or Type. The same structure every blueprint on this site uses, with notes on what goes in each section. ## The short answer A spec an AI app builder can follow has the problem in one sentence, what you are deliberately leaving out, the roles, a complete data model, numbered rules with worked examples, the screens, notifications, edge cases, seed data, and a build plan split into phases with acceptance checks. The template below has all of those sections. Copy it, fill it in, paste the whole file into your builder, then send the phase prompts one at a time. It is the same structure as every [blueprint](/blueprints) on this site. If one of them is close to what you need, start from it instead of a blank page. ## The template Copy this into a file called `spec.md` and replace everything in angle brackets. ```markdown # Department: · Replaces: For teams of to people. > How to use this file: paste this whole document into your AI builder as context. > Then send the build prompts in the Build plan section one at a time. Do not move > to the next phase until the acceptance checks for the current phase pass. ## 1. The problem "" ## 2. What the real product does Research basis: . 1. 2. <...> ### What this tool keeps ### What this tool deliberately leaves out ## 3. Scope of v1 - ## 4. Roles | Role | Who | Can do | |---|---|---| | | | <...> | | | <...> | <...> | ## 5. Data model All timestamps are UTC. Money is integer cents. IDs are strings. **** - id - (, required/optional, constraints, unique?) ## 6. Rules - **R1.** - **R2.** <...> ### Worked examples (with the seed data in section N) | Input | Expected result | |---|---| | <...> | <...> | ## 7. Lifecycle | Action | Who | From | To | Side effects | |---|---|---|---|---| | | | | | | ## 8. Screens 1. ****: ## 9. Notifications ## 10. Scheduled jobs and time ## 11. Edge cases - ## 12. Seed data ## 13. Build plan ### Phase 1: Foundation Using the spec above, build Phase 1 only. 1. Set up the data model from section 5 with a real database. 2. Add sign-in with the platform's built-in auth or Google sign-in limited to our company email domain. A signed-in email must match an active person; anyone else sees "Ask an admin to add you." 3. Build the admin screens from section 8. 4. Load the seed data from section 12. 5. Add a "sign in as" switcher visible only in development. Do not build yet. When you're done, list what you built and anything in the spec you had to interpret. **Acceptance checks** - [ ] can and sees . - [ ] cannot ; the button is hidden and a direct API call is refused. ### Phase 2: ### Phase 3: ### Phase 4: ## 14. Variations - ``` ## What goes in each section, and why ### The problem One sentence in the words of the person who has it, not a product category. "Purchase requests get lost in Slack, and nobody knows who approved what" tells the builder what matters (nothing lost, every decision recorded) and what does not (dashboards, theming). ### What the real product does, and what you leave out Read the product you are replacing and write down what it does. Then name what you keep and what you leave out. The "leave out" list is as important as the "keep" list: it stops the builder from adding features nobody asked for, and it tells future readers when the paid product is the better choice. ### Roles A table of who can do what. Say explicitly that permissions are enforced on the server, not only by hiding buttons. Most security bugs in AI-built apps are a hidden button with an open API behind it. ### Data model Every entity, every field, its type and constraints. Three rules prevent most bugs: money as integer cents, dates and timestamps kept separate (with the time zone stated), and history that is append-only. ### Rules with worked examples Number the rules so prompts and checks can refer to them ("enforce R1 to R4 on the server"). For every calculation, routing decision or sort order, add a table of worked examples using the seed data. The examples show the builder exactly what the rule means and become your unit tests. ### Lifecycle A table of every status change: who can do it, from which status, to which status, and what else happens (events written, notifications sent). Anything not in the table is refused. This one table prevents a whole class of "how did it get into that state" bugs. ### Seed data Named people and records that exercise every rule: someone with no manager, an approver who is away, a value just over a threshold. Good seed data is what makes good acceptance checks possible. ### Build plan and acceptance checks Three to five phases: foundation, the core flow, notifications and integrations, then time-based jobs and export. Each prompt says what to build, which sections to follow, and what not to build yet, and ends by asking the builder to list what it had to interpret. Each phase ends with checks that name a person, an action and the visible result. Run them before moving on. ## Common mistakes - **No "leave out" list.** The builder fills the gaps with features you did not want. - **Rules in prose.** "Managers approve big purchases" leaves "big", "managers" and "approve" to interpretation. "R3. Requests of $1,000 or more add a Finance step assigned to the Finance rule's approver" does not. - **Checks without names.** "Users can approve requests" passes in every demo. "Signed in as Ana while Maya's step is active, there is no Approve button and a direct API call is refused" does not pass by accident. - **No people changes.** Specs describe a stable team. Real teams have people on holiday, changing managers and leaving. Our [approval workflow build test](/blueprints/approval-workflow) found that most spec gaps were here. - **Building everything in one prompt.** Long single prompts produce code that is hard to check. Phases keep each step small enough to verify. ## Questions people ask ### Is this the same as a PRD? It overlaps with a product requirements document, but it is written for a builder, not for stakeholders. A PRD explains why and for whom; this spec adds the exact data model, numbered rules, worked examples and acceptance checks a builder needs to produce working software without guessing. ### How long should a spec be? Long enough that the builder never has to guess a rule. The blueprints on this site run from about 250 to 450 lines of Markdown. A small tool with no money or permissions can be shorter. ### Does this work with any AI builder? Yes. The template is plain Markdown and builder-agnostic. Paste the whole file as context first, then send each phase prompt separately. The acceptance checks are how you find out whether your builder read the spec the way you meant it. ---