Vibecode LeaseLineage
track this build4 phases, 12 steps, beginner friendly0%A solo broker can build the CRM, CSV prospecting, draft sequences, and pipeline in a weekend, but reliable contact data, Gmail and Outlook sync, deliverability, compliance operations, and brokerage collaboration are ongoing products.
You are building a lean indie version of LeaseLineage. Create the following project files first, then implement the application by following them. Keep the files updated as decisions change. Do not collapse this into a single README or prompt. ===== README.md ===== # LeaseLineage · indie build A broker's workspace whose hard problems are computing statutory and contractual notice windows that a person can audit, reminding far enough ahead that a twelve-to-six-month window is still open, and keeping outreach as drafts a human approves. Estimated effort: **multi-day**. Work `BUILD_PLAN.md` top to bottom · every phase ends in a check that has to pass before the next one starts. ## Stack | Part | Choice | Why | | --- | --- | --- | | Runtime | Node with Express, better-sqlite3 and server-rendered templates, bound to localhost | one broker, one machine, and no reason for anyone else's server to hold these leases | | Dates | Plain calendar dates with an explicit basis, and windows stored as two dates | a notice window is not an instant and squeezing it into one loses the whole point | | Rules | A dated rules file per jurisdiction, referenced by id from every computed window | statutes differ across the UK and change, and a rule with no version is a guess | | Money | Integer minor units with a currency code, never floating point | rent, service charge and deposit all get multiplied and compared | | Sending | Local drafts, approval before any send, a suppression list checked at send time | outreach you did not read is outreach you cannot defend | ## Before you start Have every one of these ready. The plan assumes them from step one. - [ ] **Node.js 22 or newer** · free - Why: Runs the workspace and the mailbox poller. - Get it: nodejs.org or your version manager. - Verify: node --version prints v22 or higher - [ ] **A written rules file for your jurisdiction** · free - Why: Every computed window points at a row in it, and a rule with no statute reference and no version date is a guess wearing a date's clothing. - Get it: Work through the provisions that apply to the leases you actually handle, record statute, section, version date and the computation, and cite the source for each. - Verify: Every rule row has a statute, a section and a version date, and a test asserts it - [ ] **Two or three real lease summaries** · free - Why: Window computation looks obvious until it meets a lease whose commencement and term end disagree with the rent review cycle. - Get it: Take leases you already manage and reduce each to the dates and the clause references. - Verify: Each produces windows whose 'why this date' panel matches what you would have worked out by hand - [ ] **A mailbox with application-password access** (optional) · free - Why: Reply detection is what stops a sequence, and without it the software keeps emailing a person who already answered. - Get it: Enable an application password on the mailbox you send from. - Verify: A reply to a sent message stops the sequence within one polling interval - [ ] **Where your professional judgement takes over** · free - Why: A tool that computes legal dates invites reliance, and the boundary needs to be written down before the first reminder fires rather than after a missed one. - Get it: Write down that the app computes windows from a dated rules file, that it never advises, and what the user is expected to confirm independently. - Verify: The same text appears on every reminder and in the README ## Quick start Follow Phase 1 in `BUILD_PLAN.md`; it creates the project. Then copy `.env.example` to `.env` and fill in the values it documents. ## Honest limits This build deliberately does not replace: - Commercial contact data enrichment. - Drafting or serving notices. - Mailbox provider authorisation at scale. - Any claim that a computed date is correct in law. - Apollo and Hunter prospect data - Gmail and Outlook OAuth and reply sync - managed sending and deliverability - brokerage team workspace - custom sending domains - hosted compliance and suppression operations If one of those is essential to you, that is the reason to keep paying for LeaseLineage, and the README should say so rather than pretend. ===== BRIEF.md ===== # Build brief · LeaseLineage The one-shot brief this plan expands. `BUILD_PLAN.md` (or `MILESTONES.md`) is the same sequence broken into steps and checks; where the two disagree, the plan wins. Build me a local commercial property workspace that tracks prospects, lease dates and notice windows and shows its working for every date it computes to replace LeaseLineage. Build it in phases, in the order below. Do not write the whole thing in one pass. Finish a phase, run its "Done when" check, fix what fails, and only then start the next phase. ### Rule zero This holds dates with legal consequences, and that changes what the software is allowed to say. Section 25 of the Landlord and Tenant Act 1954 provides that a landlord's notice 'shall not have effect unless it is given not more than twelve nor less than six months before the date of the termination specified therein'. Checked at legislation.gov.uk on 15 September 2026. Read that again: the opportunity is a window that opens a year out and closes six months out. A reminder system whose longest horizon is ninety days has already missed it every single time. So the reminder horizon must exceed the longest window the app knows about, and a window is stored and displayed as two dates, never as one. Break clauses are construed strictly. A notice in the wrong form, sent by the wrong person or a day late simply fails, and the term runs on. This application therefore never drafts a notice, never serves one and never sends one on a timer. It shows a window, the clause it came from, the inputs it used and the rule it applied. A computed date that cannot show its working is not displayed at all. Nothing here is legal advice, the jurisdiction and statute version are recorded on every rule, and money is stored as integer minor units with a currency code. A rent figure held as a floating point number is a rounding error waiting for a rent review. ### Stack (fixed, do not substitute) - Runtime: Node with Express, better-sqlite3 and server-rendered templates, bound to localhost · one broker, one machine, and no reason for anyone else's server to hold these leases - Dates: Plain calendar dates with an explicit basis, and windows stored as two dates · a notice window is not an instant and squeezing it into one loses the whole point - Rules: A dated rules file per jurisdiction, referenced by id from every computed window · statutes differ across the UK and change, and a rule with no version is a guess - Money: Integer minor units with a currency code, never floating point · rent, service charge and deposit all get multiplied and compared - Sending: Local drafts, approval before any send, a suppression list checked at send time · outreach you did not read is outreach you cannot defend ### Data model (create this before Phase 1) - organisations(id, name, kind, market, notes) and contacts(id, organisation_id, name, title, email, phone, source, unsubscribed_at) - properties(id, address, asset_class, owner_org_id, area_sqft, notes) - leases(id, property_id, tenant_org_id, jurisdiction, commencement, term_end, contracted_out, rent_minor, rent_currency, rent_period, notes) - lease_events(id, lease_id, kind, target_date, basis, clause_ref, rule_id, inputs_json, window_opens, window_closes) · kind covers break, rent review, expiry and statutory notice - rules(id, jurisdiction, statute, section, version_date, description) · every computed window names one of these rows - reminders(id, lease_event_id, fire_on, tier, acknowledged_at, acknowledged_by, note) · acknowledgement is recorded because 'nobody told me' is the argument you are preventing - campaigns, sequence_steps and messages(id, contact_id, step_id, state, body, approved_by, approved_at, sent_at, provider_id) · state is draft until a person approves it - No lease event stores a single deadline. It stores an opening date, a closing date, the rule that produced them and the inputs, so a disagreement is a lookup rather than an argument. ### Phase 1 · Organisations, contacts and an import that does not duplicate Build: Import contacts and organisations from CSV with a saved column mapping. Deduplicate contacts on lowercased email and organisations on a normalised name plus market. Record the source file and row number on every imported row. Show a table with search, filters and a detail page carrying notes and an activity log. Trap: Deduplicating on the raw email string lets the same person in three times because one export capitalised the local part and another added a trailing space, and by the time you notice, all three are in a campaign and have had the same message twice. Normalise before comparing, keep the original string as well, and make the second import of an identical file assert zero new rows in a test. Done when: Importing the same file twice creates no new rows the second time, tested. Every imported row records its source file and row number and that appears on the detail page. Search and filter work on an imported set of a few thousand rows without a visible pause. A contact with no email imports and is marked as not contactable rather than being dropped. Do not build yet: No leases, windows, reminders or outreach yet. ### Phase 2 · Leases, and dates that show their working Build: Model the lease with commencement, term end, contracting-out status and rent in minor units. Compute windows for break, rent review, expiry and statutory notice from a dated rules file, and store each as an opening date, a closing date, the rule id, the clause reference and a JSON blob of the inputs. Render a 'why this date' panel that reads those fields back. Trap: Storing a single deadline date is the mistake the whole application is built around avoiding, because the legally interesting thing is a window with two ends, and a user who sees one date assumes it is the last day when it may well be the first. Store both ends, label them in the interface, and refuse to render any computed date that has no rule id attached. Done when: Every lease event row has an opening date, a closing date, a rule id and an inputs blob, enforced by a not-null constraint and a test. The 'why this date' panel reproduces the computation from stored fields alone, with no recomputation, tested. Changing a lease's commencement recomputes windows and records the previous values rather than overwriting them. A jurisdiction with no rule for an event kind produces no window and says so, rather than defaulting to a rule from elsewhere. Do not build yet: No reminders, campaigns or exports yet. ### Phase 3 · Reminders that fire while the window is still open Build: Generate reminder tiers from each window's opening date backwards, with the longest tier further out than the longest window in the rules file. Escalate as the closing date approaches. Record who acknowledged a reminder and when. Never send a notice, never draft one, and put the disclaimer on the reminder itself rather than in a settings page nobody opens. Trap: Defaulting the reminder horizon to thirty or ninety days, which is what every other reminder feature does, guarantees that a window opening twelve months before a termination date has already closed by the time anything fires, and the software will have been quietly useless for the one thing it existed to do. Derive the longest tier from the rules file rather than from a constant, and assert in a test that no window can close before its first reminder fires. Done when: A test asserts that for every rule in the file, the first reminder tier fires before the window opens. Acknowledging a reminder stores the person and the time and stops escalation for that tier only. No code path composes, addresses or sends a statutory notice, verified by review and named in the README. A reminder displays the window, the clause reference and the disclaimer together. Do not build yet: No outreach campaigns, sending or export yet. ### Phase 4 · Outreach that stays a draft until you read it Build: Build campaigns from a filtered prospect list with a multi-step sequence. Generated bodies are drafts. Approval is per message or per batch, by a person, and is recorded. Check the suppression list and the unsubscribed flag at send time, not at draft time. Poll one mailbox, match replies by message identifier and address, and stop the remaining steps for that contact. Trap: Checking the suppression list when the sequence is generated means a person who unsubscribes on Tuesday still gets Wednesday's step, because the message was approved on Monday and nothing looked again. Check at the moment of sending, against the live list, and write a test that unsubscribes a contact between approval and send and asserts nothing left the building. Done when: A contact unsubscribed after approval but before sending receives nothing, tested. Every sent message records who approved it and when. A reply stops the remaining steps for that contact within one polling interval, tested with a stubbed mailbox. The daily send cap is enforced across campaigns, not per campaign, tested by queuing more than the cap across two. Do not build yet: No export, retention or backup work yet. ### Phase 5 · The record you will be asked for Build: Export organisations, contacts, properties, leases, lease events with their inputs, reminders with their acknowledgements, and messages, as CSV plus a JSON bundle. Soft delete only. Take a dated backup on a schedule and restore one in the test suite. Record the rules file version in every export so an old window can be explained under the rule that produced it. Trap: Exporting the computed window without the rules file version means that a year later, after the rules file has been corrected, nobody can reproduce the date the software actually showed, and the only remaining evidence of what the broker was told is gone. Stamp the rule id and the rules file version into both the stored event and the export, and keep old rule versions in the file rather than editing them in place. Done when: The export contains the rule id and rules file version for every lease event, checked in a fixture. Restoring a backup into an empty database reproduces every window and every acknowledgement exactly, exercised in the test suite. Deleting a contact marks it deleted and keeps its messages and acknowledgements, tested. A rules file change adds a new version rather than editing an old one, enforced by a test on the file's shape. Do not build yet: Nothing after this. ### Out of scope (and why) - Contact data enrichment from a commercial database, which is a licence and a subscription rather than code, and brings its own lawful-basis question about people who never gave you their details. - Drafting or serving any statutory or contractual notice, because a notice in the wrong form or a day late fails outright and no timer should be near that decision. - Mailbox provider authorisation and reply threading at scale, which is a verification process and an ongoing integration rather than a weekend. - Any statement that a date is correct in law. The application shows a rule, its inputs and a window, and the professional advice is somebody else's job. ### README must contain - That section 25 of the Landlord and Tenant Act 1954 sets a window of not more than twelve nor less than six months, quoted with the source and the date checked, and that this is why the reminder horizon is what it is - That every computed window stores both ends, the rule id, the clause reference and the inputs, and that a date without those is never displayed - That the application never drafts, serves or sends a notice of any kind - That the rules file is dated and versioned per jurisdiction, and that old versions are kept rather than edited - That this is not legal advice, and that rent and other money are stored as integer minor units ===== AGENTS.md ===== # Agent instructions · LeaseLineage indie build - Read `README.md` and `BUILD_PLAN.md` before writing code. The stack is fixed: Node with Express, better-sqlite3 and server-rendered templates, bound to localhost, Plain calendar dates with an explicit basis, and windows stored as two dates, A dated rules file per jurisdiction, referenced by id from every computed window, Integer minor units with a currency code, never floating point, Local drafts, approval before any send, a suppression list checked at send time. Do not substitute. - Work one phase at a time, in order. Do not start a phase until every "Done when" item of the previous one passes. - Prefer the fewest moving parts that satisfy the step. No frameworks, services or dependencies the plan does not name. - Secrets live in `.env`, never in source or logs. Keep `.env.example` current when a variable is introduced. - Do not invent cryptography, security guarantees, APIs or compliance claims. - Add a focused test for every destructive, security-sensitive or data-loss path the plan names. - Run the project checks before declaring a phase complete, and record any deliberate shortcut in the README under "Tradeoffs". ## Known traps - Deduplicating on the raw email string lets the same person in three times because one export capitalised the local part and another added a trailing space, and by the time you notice, all three are in a campaign and have had the same message twice. Normalise before comparing, keep the original string as well, and make the second import of an identical file assert zero new rows in a test. - Storing a single deadline date is the mistake the whole application is built around avoiding, because the legally interesting thing is a window with two ends, and a user who sees one date assumes it is the last day when it may well be the first. Store both ends, label them in the interface, and refuse to render any computed date that has no rule id attached. - Defaulting the reminder horizon to thirty or ninety days, which is what every other reminder feature does, guarantees that a window opening twelve months before a termination date has already closed by the time anything fires, and the software will have been quietly useless for the one thing it existed to do. Derive the longest tier from the rules file rather than from a constant, and assert in a test that no window can close before its first reminder fires. - Checking the suppression list when the sequence is generated means a person who unsubscribes on Tuesday still gets Wednesday's step, because the message was approved on Monday and nothing looked again. Check at the moment of sending, against the live list, and write a test that unsubscribes a contact between approval and send and asserts nothing left the building. ===== BUILD_PLAN.md ===== # Build plan · LeaseLineage A broker's workspace whose hard problems are computing statutory and contractual notice windows that a person can audit, reminding far enough ahead that a twelve-to-six-month window is still open, and keeping outreach as drafts a human approves. Phases are in dependency order. Each ends in a "Done when" list; treat an unticked item as a blocker, not a note. ## Phase 1 · Organisations, contacts and an import that does not duplicate A CSV of prospects lands as clean rows and a second import of the same file changes nothing. ### Steps 1. Normalise before comparing, keep the original The original is evidence; the normalised form is the key. Files: `src/import/normalise.js`, `tests/dedupe.test.js` 2. Record source file and row on every row Someone will ask where a number came from. Files: `src/import/run.js` 3. Import a contact with no email rather than dropping it A name and a phone number is still a prospect. Files: `src/import/run.js`, `src/views/contact.ejs` ### Done when - [ ] A repeated import adds nothing - [ ] Provenance is stored and displayed - [ ] Search stays responsive on a few thousand rows - [ ] Contacts without email are kept and flagged ### Watch out - Deduplicating on the raw email string lets the same person in three times because one export capitalised the local part and another added a trailing space, and by the time you notice, all three are in a campaign and have had the same message twice. Normalise before comparing, keep the original string as well, and make the second import of an identical file assert zero new rows in a test. ## Phase 2 · Leases, and dates that show their working Every computed notice window carries the rule, the clause and the inputs that produced it. ### Steps 1. Store both ends of the window and the rule that made them One date hides which end of the window it is. Files: `src/leases/events.js`, `src/db/schema.sql` 2. Keep a dated rules file with jurisdiction and statute version Provisions differ across the UK and they change. Files: `data/rules/uk.json`, `docs/rules.md` 3. Render the working from stored fields only If it has to recompute to explain itself, it cannot explain an old answer. Files: `src/views/why.ejs`, `tests/why.test.js` ### Done when - [ ] No lease event exists without both ends and a rule id - [ ] The explanation reads stored fields, not a recomputation - [ ] Editing a lease keeps the previous window values - [ ] A missing rule yields no window and says so ### Watch out - Storing a single deadline date is the mistake the whole application is built around avoiding, because the legally interesting thing is a window with two ends, and a user who sees one date assumes it is the last day when it may well be the first. Store both ends, label them in the interface, and refuse to render any computed date that has no rule id attached. ## Phase 3 · Reminders that fire while the window is still open The horizon is longer than the longest statutory window and acknowledgement is recorded. ### Steps 1. Derive the horizon from the rules, not from a constant The law sets the horizon; your default does not. Files: `src/reminders/schedule.js`, `tests/horizon.test.js` 2. Record acknowledgement with a person and a time It is the answer to the only question that gets asked afterwards. Files: `src/reminders/ack.js` 3. Put the disclaimer on the reminder itself That is where the person is when they decide to act. Files: `src/views/reminder.ejs` ### Done when - [ ] Every rule's first tier fires before its window opens - [ ] Acknowledgement records person and time - [ ] No path composes or sends a notice - [ ] The reminder shows window, clause and disclaimer ### Watch out - Defaulting the reminder horizon to thirty or ninety days, which is what every other reminder feature does, guarantees that a window opening twelve months before a termination date has already closed by the time anything fires, and the software will have been quietly useless for the one thing it existed to do. Derive the longest tier from the rules file rather than from a constant, and assert in a test that no window can close before its first reminder fires. ## Phase 4 · Outreach that stays a draft until you read it Sequences are generated, approved by a person, throttled, and stopped by a reply. ### Steps 1. Check suppression at send time against the live list Approval happened in the past; the list is now. Files: `src/send/guard.js`, `tests/suppression.test.js` 2. Record the approver on every message Nobody should be able to say the machine sent it. Files: `src/campaigns/approve.js` 3. Cap sending across all campaigns together Per-campaign caps add up to a volume you did not intend. Files: `src/send/limits.js` ### Done when - [ ] A late unsubscribe blocks the send - [ ] Every sent message names its approver - [ ] A reply stops the sequence within one interval - [ ] The daily cap spans campaigns ### Watch out - Checking the suppression list when the sequence is generated means a person who unsubscribes on Tuesday still gets Wednesday's step, because the message was approved on Monday and nothing looked again. Check at the moment of sending, against the live list, and write a test that unsubscribes a contact between approval and send and asserts nothing left the building. ## Not in this build - Commercial contact data enrichment. - Drafting or serving notices. - Mailbox provider authorisation at scale. - Any claim that a computed date is correct in law. ## After v1, if you want it - A portfolio calendar view of every open window across all leases, sorted by closing date - A second jurisdiction's rules file, which is the real test of whether the rule abstraction works - A printable lease summary that carries the clause references and the inputs alongside each date ===== .env.example ===== # Copy to .env and fill in. Never commit .env; this file documents it. # Required. Everything. Back it up; the acknowledgement log is not reconstructable. DATABASE_PATH=./data/leaselineage.db # Required. Dated rules files per jurisdiction. Versions are added, never edited. RULES_PATH=./data/rules # Required. Used only when a lease does not state one, and recorded on the lease either way. DEFAULT_JURISDICTION=england-and-wales # Required. Must exceed the longest window in the rules file; a test asserts it does. REMINDER_MAX_HORIZON_DAYS=540 # Optional · secret. Drafting outreach copy only. Never used for anything a date depends on. MODEL_API_KEY=sk-...redacted # Optional · secret. Sending approved messages. Without it the app still drafts and tracks. SMTP_URL=smtps://user:redacted@mail.example.com:465 # Optional · secret. Reply polling. Without it sequences must be stopped by hand. IMAP_URL=imaps://user:redacted@mail.example.com:993 # Required. Across all campaigns together, not per campaign. DAILY_SEND_CAP=40
You are building a lean indie version of LeaseLineage. Create the following project files first, then implement the application by following them. Keep the files updated as decisions change. Do not collapse this into a single README or prompt. ===== README.md ===== # LeaseLineage · indie build A broker's workspace whose hard problems are computing statutory and contractual notice windows that a person can audit, reminding far enough ahead that a twelve-to-six-month window is still open, and keeping outreach as drafts a human approves. Estimated effort: **multi-day**. Work `BUILD_PLAN.md` top to bottom · every phase ends in a check that has to pass before the next one starts. ## Stack | Part | Choice | Why | | --- | --- | --- | | Runtime | Node with Express, better-sqlite3 and server-rendered templates, bound to localhost | one broker, one machine, and no reason for anyone else's server to hold these leases | | Dates | Plain calendar dates with an explicit basis, and windows stored as two dates | a notice window is not an instant and squeezing it into one loses the whole point | | Rules | A dated rules file per jurisdiction, referenced by id from every computed window | statutes differ across the UK and change, and a rule with no version is a guess | | Money | Integer minor units with a currency code, never floating point | rent, service charge and deposit all get multiplied and compared | | Sending | Local drafts, approval before any send, a suppression list checked at send time | outreach you did not read is outreach you cannot defend | ## Before you start Have every one of these ready. The plan assumes them from step one. - [ ] **Node.js 22 or newer** · free - Why: Runs the workspace and the mailbox poller. - Get it: nodejs.org or your version manager. - Verify: node --version prints v22 or higher - [ ] **A written rules file for your jurisdiction** · free - Why: Every computed window points at a row in it, and a rule with no statute reference and no version date is a guess wearing a date's clothing. - Get it: Work through the provisions that apply to the leases you actually handle, record statute, section, version date and the computation, and cite the source for each. - Verify: Every rule row has a statute, a section and a version date, and a test asserts it - [ ] **Two or three real lease summaries** · free - Why: Window computation looks obvious until it meets a lease whose commencement and term end disagree with the rent review cycle. - Get it: Take leases you already manage and reduce each to the dates and the clause references. - Verify: Each produces windows whose 'why this date' panel matches what you would have worked out by hand - [ ] **A mailbox with application-password access** (optional) · free - Why: Reply detection is what stops a sequence, and without it the software keeps emailing a person who already answered. - Get it: Enable an application password on the mailbox you send from. - Verify: A reply to a sent message stops the sequence within one polling interval - [ ] **Where your professional judgement takes over** · free - Why: A tool that computes legal dates invites reliance, and the boundary needs to be written down before the first reminder fires rather than after a missed one. - Get it: Write down that the app computes windows from a dated rules file, that it never advises, and what the user is expected to confirm independently. - Verify: The same text appears on every reminder and in the README ## Quick start Follow Phase 1 in `BUILD_PLAN.md`; it creates the project. Then copy `.env.example` to `.env` and fill in the values it documents. ## Honest limits This build deliberately does not replace: - Commercial contact data enrichment. - Drafting or serving notices. - Mailbox provider authorisation at scale. - Any claim that a computed date is correct in law. - Apollo and Hunter prospect data - Gmail and Outlook OAuth and reply sync - managed sending and deliverability - brokerage team workspace - custom sending domains - hosted compliance and suppression operations If one of those is essential to you, that is the reason to keep paying for LeaseLineage, and the README should say so rather than pretend. ===== BRIEF.md ===== # Build brief · LeaseLineage The one-shot brief this plan expands. `BUILD_PLAN.md` (or `MILESTONES.md`) is the same sequence broken into steps and checks; where the two disagree, the plan wins. Build me a local commercial property workspace that tracks prospects, lease dates and notice windows and shows its working for every date it computes to replace LeaseLineage. Build it in phases, in the order below. Do not write the whole thing in one pass. Finish a phase, run its "Done when" check, fix what fails, and only then start the next phase. ### Rule zero This holds dates with legal consequences, and that changes what the software is allowed to say. Section 25 of the Landlord and Tenant Act 1954 provides that a landlord's notice 'shall not have effect unless it is given not more than twelve nor less than six months before the date of the termination specified therein'. Checked at legislation.gov.uk on 15 September 2026. Read that again: the opportunity is a window that opens a year out and closes six months out. A reminder system whose longest horizon is ninety days has already missed it every single time. So the reminder horizon must exceed the longest window the app knows about, and a window is stored and displayed as two dates, never as one. Break clauses are construed strictly. A notice in the wrong form, sent by the wrong person or a day late simply fails, and the term runs on. This application therefore never drafts a notice, never serves one and never sends one on a timer. It shows a window, the clause it came from, the inputs it used and the rule it applied. A computed date that cannot show its working is not displayed at all. Nothing here is legal advice, the jurisdiction and statute version are recorded on every rule, and money is stored as integer minor units with a currency code. A rent figure held as a floating point number is a rounding error waiting for a rent review. ### Stack (fixed, do not substitute) - Runtime: Node with Express, better-sqlite3 and server-rendered templates, bound to localhost · one broker, one machine, and no reason for anyone else's server to hold these leases - Dates: Plain calendar dates with an explicit basis, and windows stored as two dates · a notice window is not an instant and squeezing it into one loses the whole point - Rules: A dated rules file per jurisdiction, referenced by id from every computed window · statutes differ across the UK and change, and a rule with no version is a guess - Money: Integer minor units with a currency code, never floating point · rent, service charge and deposit all get multiplied and compared - Sending: Local drafts, approval before any send, a suppression list checked at send time · outreach you did not read is outreach you cannot defend ### Data model (create this before Phase 1) - organisations(id, name, kind, market, notes) and contacts(id, organisation_id, name, title, email, phone, source, unsubscribed_at) - properties(id, address, asset_class, owner_org_id, area_sqft, notes) - leases(id, property_id, tenant_org_id, jurisdiction, commencement, term_end, contracted_out, rent_minor, rent_currency, rent_period, notes) - lease_events(id, lease_id, kind, target_date, basis, clause_ref, rule_id, inputs_json, window_opens, window_closes) · kind covers break, rent review, expiry and statutory notice - rules(id, jurisdiction, statute, section, version_date, description) · every computed window names one of these rows - reminders(id, lease_event_id, fire_on, tier, acknowledged_at, acknowledged_by, note) · acknowledgement is recorded because 'nobody told me' is the argument you are preventing - campaigns, sequence_steps and messages(id, contact_id, step_id, state, body, approved_by, approved_at, sent_at, provider_id) · state is draft until a person approves it - No lease event stores a single deadline. It stores an opening date, a closing date, the rule that produced them and the inputs, so a disagreement is a lookup rather than an argument. ### Phase 1 · Organisations, contacts and an import that does not duplicate Build: Import contacts and organisations from CSV with a saved column mapping. Deduplicate contacts on lowercased email and organisations on a normalised name plus market. Record the source file and row number on every imported row. Show a table with search, filters and a detail page carrying notes and an activity log. Trap: Deduplicating on the raw email string lets the same person in three times because one export capitalised the local part and another added a trailing space, and by the time you notice, all three are in a campaign and have had the same message twice. Normalise before comparing, keep the original string as well, and make the second import of an identical file assert zero new rows in a test. Done when: Importing the same file twice creates no new rows the second time, tested. Every imported row records its source file and row number and that appears on the detail page. Search and filter work on an imported set of a few thousand rows without a visible pause. A contact with no email imports and is marked as not contactable rather than being dropped. Do not build yet: No leases, windows, reminders or outreach yet. ### Phase 2 · Leases, and dates that show their working Build: Model the lease with commencement, term end, contracting-out status and rent in minor units. Compute windows for break, rent review, expiry and statutory notice from a dated rules file, and store each as an opening date, a closing date, the rule id, the clause reference and a JSON blob of the inputs. Render a 'why this date' panel that reads those fields back. Trap: Storing a single deadline date is the mistake the whole application is built around avoiding, because the legally interesting thing is a window with two ends, and a user who sees one date assumes it is the last day when it may well be the first. Store both ends, label them in the interface, and refuse to render any computed date that has no rule id attached. Done when: Every lease event row has an opening date, a closing date, a rule id and an inputs blob, enforced by a not-null constraint and a test. The 'why this date' panel reproduces the computation from stored fields alone, with no recomputation, tested. Changing a lease's commencement recomputes windows and records the previous values rather than overwriting them. A jurisdiction with no rule for an event kind produces no window and says so, rather than defaulting to a rule from elsewhere. Do not build yet: No reminders, campaigns or exports yet. ### Phase 3 · Reminders that fire while the window is still open Build: Generate reminder tiers from each window's opening date backwards, with the longest tier further out than the longest window in the rules file. Escalate as the closing date approaches. Record who acknowledged a reminder and when. Never send a notice, never draft one, and put the disclaimer on the reminder itself rather than in a settings page nobody opens. Trap: Defaulting the reminder horizon to thirty or ninety days, which is what every other reminder feature does, guarantees that a window opening twelve months before a termination date has already closed by the time anything fires, and the software will have been quietly useless for the one thing it existed to do. Derive the longest tier from the rules file rather than from a constant, and assert in a test that no window can close before its first reminder fires. Done when: A test asserts that for every rule in the file, the first reminder tier fires before the window opens. Acknowledging a reminder stores the person and the time and stops escalation for that tier only. No code path composes, addresses or sends a statutory notice, verified by review and named in the README. A reminder displays the window, the clause reference and the disclaimer together. Do not build yet: No outreach campaigns, sending or export yet. ### Phase 4 · Outreach that stays a draft until you read it Build: Build campaigns from a filtered prospect list with a multi-step sequence. Generated bodies are drafts. Approval is per message or per batch, by a person, and is recorded. Check the suppression list and the unsubscribed flag at send time, not at draft time. Poll one mailbox, match replies by message identifier and address, and stop the remaining steps for that contact. Trap: Checking the suppression list when the sequence is generated means a person who unsubscribes on Tuesday still gets Wednesday's step, because the message was approved on Monday and nothing looked again. Check at the moment of sending, against the live list, and write a test that unsubscribes a contact between approval and send and asserts nothing left the building. Done when: A contact unsubscribed after approval but before sending receives nothing, tested. Every sent message records who approved it and when. A reply stops the remaining steps for that contact within one polling interval, tested with a stubbed mailbox. The daily send cap is enforced across campaigns, not per campaign, tested by queuing more than the cap across two. Do not build yet: No export, retention or backup work yet. ### Phase 5 · The record you will be asked for Build: Export organisations, contacts, properties, leases, lease events with their inputs, reminders with their acknowledgements, and messages, as CSV plus a JSON bundle. Soft delete only. Take a dated backup on a schedule and restore one in the test suite. Record the rules file version in every export so an old window can be explained under the rule that produced it. Trap: Exporting the computed window without the rules file version means that a year later, after the rules file has been corrected, nobody can reproduce the date the software actually showed, and the only remaining evidence of what the broker was told is gone. Stamp the rule id and the rules file version into both the stored event and the export, and keep old rule versions in the file rather than editing them in place. Done when: The export contains the rule id and rules file version for every lease event, checked in a fixture. Restoring a backup into an empty database reproduces every window and every acknowledgement exactly, exercised in the test suite. Deleting a contact marks it deleted and keeps its messages and acknowledgements, tested. A rules file change adds a new version rather than editing an old one, enforced by a test on the file's shape. Do not build yet: Nothing after this. ### Out of scope (and why) - Contact data enrichment from a commercial database, which is a licence and a subscription rather than code, and brings its own lawful-basis question about people who never gave you their details. - Drafting or serving any statutory or contractual notice, because a notice in the wrong form or a day late fails outright and no timer should be near that decision. - Mailbox provider authorisation and reply threading at scale, which is a verification process and an ongoing integration rather than a weekend. - Any statement that a date is correct in law. The application shows a rule, its inputs and a window, and the professional advice is somebody else's job. ### README must contain - That section 25 of the Landlord and Tenant Act 1954 sets a window of not more than twelve nor less than six months, quoted with the source and the date checked, and that this is why the reminder horizon is what it is - That every computed window stores both ends, the rule id, the clause reference and the inputs, and that a date without those is never displayed - That the application never drafts, serves or sends a notice of any kind - That the rules file is dated and versioned per jurisdiction, and that old versions are kept rather than edited - That this is not legal advice, and that rent and other money are stored as integer minor units ===== AGENTS.md ===== # Agent instructions · LeaseLineage indie build - Read `README.md` and `BUILD_PLAN.md` before writing code. The stack is fixed: Node with Express, better-sqlite3 and server-rendered templates, bound to localhost, Plain calendar dates with an explicit basis, and windows stored as two dates, A dated rules file per jurisdiction, referenced by id from every computed window, Integer minor units with a currency code, never floating point, Local drafts, approval before any send, a suppression list checked at send time. Do not substitute. - Work one phase at a time, in order. Do not start a phase until every "Done when" item of the previous one passes. - Prefer the fewest moving parts that satisfy the step. No frameworks, services or dependencies the plan does not name. - Secrets live in `.env`, never in source or logs. Keep `.env.example` current when a variable is introduced. - Do not invent cryptography, security guarantees, APIs or compliance claims. - Add a focused test for every destructive, security-sensitive or data-loss path the plan names. - Run the project checks before declaring a phase complete, and record any deliberate shortcut in the README under "Tradeoffs". ## Known traps - Deduplicating on the raw email string lets the same person in three times because one export capitalised the local part and another added a trailing space, and by the time you notice, all three are in a campaign and have had the same message twice. Normalise before comparing, keep the original string as well, and make the second import of an identical file assert zero new rows in a test. - Storing a single deadline date is the mistake the whole application is built around avoiding, because the legally interesting thing is a window with two ends, and a user who sees one date assumes it is the last day when it may well be the first. Store both ends, label them in the interface, and refuse to render any computed date that has no rule id attached. - Defaulting the reminder horizon to thirty or ninety days, which is what every other reminder feature does, guarantees that a window opening twelve months before a termination date has already closed by the time anything fires, and the software will have been quietly useless for the one thing it existed to do. Derive the longest tier from the rules file rather than from a constant, and assert in a test that no window can close before its first reminder fires. - Checking the suppression list when the sequence is generated means a person who unsubscribes on Tuesday still gets Wednesday's step, because the message was approved on Monday and nothing looked again. Check at the moment of sending, against the live list, and write a test that unsubscribes a contact between approval and send and asserts nothing left the building. ===== BUILD_PLAN.md ===== # Build plan · LeaseLineage A broker's workspace whose hard problems are computing statutory and contractual notice windows that a person can audit, reminding far enough ahead that a twelve-to-six-month window is still open, and keeping outreach as drafts a human approves. Phases are in dependency order. Each ends in a "Done when" list; treat an unticked item as a blocker, not a note. ## Phase 1 · Organisations, contacts and an import that does not duplicate A CSV of prospects lands as clean rows and a second import of the same file changes nothing. ### Steps 1. Normalise before comparing, keep the original The original is evidence; the normalised form is the key. Files: `src/import/normalise.js`, `tests/dedupe.test.js` 2. Record source file and row on every row Someone will ask where a number came from. Files: `src/import/run.js` 3. Import a contact with no email rather than dropping it A name and a phone number is still a prospect. Files: `src/import/run.js`, `src/views/contact.ejs` ### Done when - [ ] A repeated import adds nothing - [ ] Provenance is stored and displayed - [ ] Search stays responsive on a few thousand rows - [ ] Contacts without email are kept and flagged ### Watch out - Deduplicating on the raw email string lets the same person in three times because one export capitalised the local part and another added a trailing space, and by the time you notice, all three are in a campaign and have had the same message twice. Normalise before comparing, keep the original string as well, and make the second import of an identical file assert zero new rows in a test. ## Phase 2 · Leases, and dates that show their working Every computed notice window carries the rule, the clause and the inputs that produced it. ### Steps 1. Store both ends of the window and the rule that made them One date hides which end of the window it is. Files: `src/leases/events.js`, `src/db/schema.sql` 2. Keep a dated rules file with jurisdiction and statute version Provisions differ across the UK and they change. Files: `data/rules/uk.json`, `docs/rules.md` 3. Render the working from stored fields only If it has to recompute to explain itself, it cannot explain an old answer. Files: `src/views/why.ejs`, `tests/why.test.js` ### Done when - [ ] No lease event exists without both ends and a rule id - [ ] The explanation reads stored fields, not a recomputation - [ ] Editing a lease keeps the previous window values - [ ] A missing rule yields no window and says so ### Watch out - Storing a single deadline date is the mistake the whole application is built around avoiding, because the legally interesting thing is a window with two ends, and a user who sees one date assumes it is the last day when it may well be the first. Store both ends, label them in the interface, and refuse to render any computed date that has no rule id attached. ## Phase 3 · Reminders that fire while the window is still open The horizon is longer than the longest statutory window and acknowledgement is recorded. ### Steps 1. Derive the horizon from the rules, not from a constant The law sets the horizon; your default does not. Files: `src/reminders/schedule.js`, `tests/horizon.test.js` 2. Record acknowledgement with a person and a time It is the answer to the only question that gets asked afterwards. Files: `src/reminders/ack.js` 3. Put the disclaimer on the reminder itself That is where the person is when they decide to act. Files: `src/views/reminder.ejs` ### Done when - [ ] Every rule's first tier fires before its window opens - [ ] Acknowledgement records person and time - [ ] No path composes or sends a notice - [ ] The reminder shows window, clause and disclaimer ### Watch out - Defaulting the reminder horizon to thirty or ninety days, which is what every other reminder feature does, guarantees that a window opening twelve months before a termination date has already closed by the time anything fires, and the software will have been quietly useless for the one thing it existed to do. Derive the longest tier from the rules file rather than from a constant, and assert in a test that no window can close before its first reminder fires. ## Phase 4 · Outreach that stays a draft until you read it Sequences are generated, approved by a person, throttled, and stopped by a reply. ### Steps 1. Check suppression at send time against the live list Approval happened in the past; the list is now. Files: `src/send/guard.js`, `tests/suppression.test.js` 2. Record the approver on every message Nobody should be able to say the machine sent it. Files: `src/campaigns/approve.js` 3. Cap sending across all campaigns together Per-campaign caps add up to a volume you did not intend. Files: `src/send/limits.js` ### Done when - [ ] A late unsubscribe blocks the send - [ ] Every sent message names its approver - [ ] A reply stops the sequence within one interval - [ ] The daily cap spans campaigns ### Watch out - Checking the suppression list when the sequence is generated means a person who unsubscribes on Tuesday still gets Wednesday's step, because the message was approved on Monday and nothing looked again. Check at the moment of sending, against the live list, and write a test that unsubscribes a contact between approval and send and asserts nothing left the building. ## Not in this build - Commercial contact data enrichment. - Drafting or serving notices. - Mailbox provider authorisation at scale. - Any claim that a computed date is correct in law. ## After v1, if you want it - A portfolio calendar view of every open window across all leases, sorted by closing date - A second jurisdiction's rules file, which is the real test of whether the rule abstraction works - A printable lease summary that carries the clause references and the inputs alongside each date ===== .env.example ===== # Copy to .env and fill in. Never commit .env; this file documents it. # Required. Everything. Back it up; the acknowledgement log is not reconstructable. DATABASE_PATH=./data/leaselineage.db # Required. Dated rules files per jurisdiction. Versions are added, never edited. RULES_PATH=./data/rules # Required. Used only when a lease does not state one, and recorded on the lease either way. DEFAULT_JURISDICTION=england-and-wales # Required. Must exceed the longest window in the rules file; a test asserts it does. REMINDER_MAX_HORIZON_DAYS=540 # Optional · secret. Drafting outreach copy only. Never used for anything a date depends on. MODEL_API_KEY=sk-...redacted # Optional · secret. Sending approved messages. Without it the app still drafts and tracks. SMTP_URL=smtps://user:redacted@mail.example.com:465 # Optional · secret. Reply polling. Without it sequences must be stopped by hand. IMAP_URL=imaps://user:redacted@mail.example.com:993 # Required. Across all campaigns together, not per campaign. DAILY_SEND_CAP=40
You are building a production product version of LeaseLineage. Create the following project files first, then implement the application by following them. Keep the files updated as decisions change. Do not collapse this into a single README or prompt. ===== PRODUCT.md ===== # LeaseLineage · product brief ## Problem A solo broker can build the CRM, CSV prospecting, draft sequences, and pipeline in a weekend, but reliable contact data, Gmail and Outlook sync, deliverability, compliance operations, and brokerage collaboration are ongoing products. ## Product outcome A workspace that computes notice windows a person can audit line by line, reminds far enough ahead that a twelve-month window is still open, never sends a notice, and keeps the record of who was told what and when. ## Target user A builder who needs a maintainable product foundation, not a one-off demo. ## Required capabilities - local web app - SQLite - CSV import - OpenAI API key - optional SMTP credentials ## Explicit non-goals for v1 - Commercial contact data enrichment. - Drafting or serving notices. - Mailbox provider authorisation at scale. - Any claim that a computed date is correct in law. - Apollo and Hunter prospect data - Gmail and Outlook OAuth and reply sync - managed sending and deliverability - brokerage team workspace - custom sending domains - hosted compliance and suppression operations ## Success criteria - Every rule's first reminder tier is confirmed to fire before its window opens - No lease event is confirmed to exist without both window ends and a rule id - No code path is confirmed to compose, address or send a notice - A late unsubscribe is confirmed to block a send - A restore is confirmed to reproduce windows and acknowledgements exactly ===== BRIEF.md ===== # Build brief · LeaseLineage The one-shot brief this plan expands. `BUILD_PLAN.md` (or `MILESTONES.md`) is the same sequence broken into steps and checks; where the two disagree, the plan wins. Build me a local commercial property workspace that tracks prospects, lease dates and notice windows and shows its working for every date it computes to replace LeaseLineage. Build it in phases, in the order below. Do not write the whole thing in one pass. Finish a phase, run its "Done when" check, fix what fails, and only then start the next phase. ### Rule zero This holds dates with legal consequences, and that changes what the software is allowed to say. Section 25 of the Landlord and Tenant Act 1954 provides that a landlord's notice 'shall not have effect unless it is given not more than twelve nor less than six months before the date of the termination specified therein'. Checked at legislation.gov.uk on 15 September 2026. Read that again: the opportunity is a window that opens a year out and closes six months out. A reminder system whose longest horizon is ninety days has already missed it every single time. So the reminder horizon must exceed the longest window the app knows about, and a window is stored and displayed as two dates, never as one. Break clauses are construed strictly. A notice in the wrong form, sent by the wrong person or a day late simply fails, and the term runs on. This application therefore never drafts a notice, never serves one and never sends one on a timer. It shows a window, the clause it came from, the inputs it used and the rule it applied. A computed date that cannot show its working is not displayed at all. Nothing here is legal advice, the jurisdiction and statute version are recorded on every rule, and money is stored as integer minor units with a currency code. A rent figure held as a floating point number is a rounding error waiting for a rent review. ### Stack (fixed, do not substitute) - Runtime: Node with Express, better-sqlite3 and server-rendered templates, bound to localhost · one broker, one machine, and no reason for anyone else's server to hold these leases - Dates: Plain calendar dates with an explicit basis, and windows stored as two dates · a notice window is not an instant and squeezing it into one loses the whole point - Rules: A dated rules file per jurisdiction, referenced by id from every computed window · statutes differ across the UK and change, and a rule with no version is a guess - Money: Integer minor units with a currency code, never floating point · rent, service charge and deposit all get multiplied and compared - Sending: Local drafts, approval before any send, a suppression list checked at send time · outreach you did not read is outreach you cannot defend ### Data model (create this before Phase 1) - organisations(id, name, kind, market, notes) and contacts(id, organisation_id, name, title, email, phone, source, unsubscribed_at) - properties(id, address, asset_class, owner_org_id, area_sqft, notes) - leases(id, property_id, tenant_org_id, jurisdiction, commencement, term_end, contracted_out, rent_minor, rent_currency, rent_period, notes) - lease_events(id, lease_id, kind, target_date, basis, clause_ref, rule_id, inputs_json, window_opens, window_closes) · kind covers break, rent review, expiry and statutory notice - rules(id, jurisdiction, statute, section, version_date, description) · every computed window names one of these rows - reminders(id, lease_event_id, fire_on, tier, acknowledged_at, acknowledged_by, note) · acknowledgement is recorded because 'nobody told me' is the argument you are preventing - campaigns, sequence_steps and messages(id, contact_id, step_id, state, body, approved_by, approved_at, sent_at, provider_id) · state is draft until a person approves it - No lease event stores a single deadline. It stores an opening date, a closing date, the rule that produced them and the inputs, so a disagreement is a lookup rather than an argument. ### Phase 1 · Organisations, contacts and an import that does not duplicate Build: Import contacts and organisations from CSV with a saved column mapping. Deduplicate contacts on lowercased email and organisations on a normalised name plus market. Record the source file and row number on every imported row. Show a table with search, filters and a detail page carrying notes and an activity log. Trap: Deduplicating on the raw email string lets the same person in three times because one export capitalised the local part and another added a trailing space, and by the time you notice, all three are in a campaign and have had the same message twice. Normalise before comparing, keep the original string as well, and make the second import of an identical file assert zero new rows in a test. Done when: Importing the same file twice creates no new rows the second time, tested. Every imported row records its source file and row number and that appears on the detail page. Search and filter work on an imported set of a few thousand rows without a visible pause. A contact with no email imports and is marked as not contactable rather than being dropped. Do not build yet: No leases, windows, reminders or outreach yet. ### Phase 2 · Leases, and dates that show their working Build: Model the lease with commencement, term end, contracting-out status and rent in minor units. Compute windows for break, rent review, expiry and statutory notice from a dated rules file, and store each as an opening date, a closing date, the rule id, the clause reference and a JSON blob of the inputs. Render a 'why this date' panel that reads those fields back. Trap: Storing a single deadline date is the mistake the whole application is built around avoiding, because the legally interesting thing is a window with two ends, and a user who sees one date assumes it is the last day when it may well be the first. Store both ends, label them in the interface, and refuse to render any computed date that has no rule id attached. Done when: Every lease event row has an opening date, a closing date, a rule id and an inputs blob, enforced by a not-null constraint and a test. The 'why this date' panel reproduces the computation from stored fields alone, with no recomputation, tested. Changing a lease's commencement recomputes windows and records the previous values rather than overwriting them. A jurisdiction with no rule for an event kind produces no window and says so, rather than defaulting to a rule from elsewhere. Do not build yet: No reminders, campaigns or exports yet. ### Phase 3 · Reminders that fire while the window is still open Build: Generate reminder tiers from each window's opening date backwards, with the longest tier further out than the longest window in the rules file. Escalate as the closing date approaches. Record who acknowledged a reminder and when. Never send a notice, never draft one, and put the disclaimer on the reminder itself rather than in a settings page nobody opens. Trap: Defaulting the reminder horizon to thirty or ninety days, which is what every other reminder feature does, guarantees that a window opening twelve months before a termination date has already closed by the time anything fires, and the software will have been quietly useless for the one thing it existed to do. Derive the longest tier from the rules file rather than from a constant, and assert in a test that no window can close before its first reminder fires. Done when: A test asserts that for every rule in the file, the first reminder tier fires before the window opens. Acknowledging a reminder stores the person and the time and stops escalation for that tier only. No code path composes, addresses or sends a statutory notice, verified by review and named in the README. A reminder displays the window, the clause reference and the disclaimer together. Do not build yet: No outreach campaigns, sending or export yet. ### Phase 4 · Outreach that stays a draft until you read it Build: Build campaigns from a filtered prospect list with a multi-step sequence. Generated bodies are drafts. Approval is per message or per batch, by a person, and is recorded. Check the suppression list and the unsubscribed flag at send time, not at draft time. Poll one mailbox, match replies by message identifier and address, and stop the remaining steps for that contact. Trap: Checking the suppression list when the sequence is generated means a person who unsubscribes on Tuesday still gets Wednesday's step, because the message was approved on Monday and nothing looked again. Check at the moment of sending, against the live list, and write a test that unsubscribes a contact between approval and send and asserts nothing left the building. Done when: A contact unsubscribed after approval but before sending receives nothing, tested. Every sent message records who approved it and when. A reply stops the remaining steps for that contact within one polling interval, tested with a stubbed mailbox. The daily send cap is enforced across campaigns, not per campaign, tested by queuing more than the cap across two. Do not build yet: No export, retention or backup work yet. ### Phase 5 · The record you will be asked for Build: Export organisations, contacts, properties, leases, lease events with their inputs, reminders with their acknowledgements, and messages, as CSV plus a JSON bundle. Soft delete only. Take a dated backup on a schedule and restore one in the test suite. Record the rules file version in every export so an old window can be explained under the rule that produced it. Trap: Exporting the computed window without the rules file version means that a year later, after the rules file has been corrected, nobody can reproduce the date the software actually showed, and the only remaining evidence of what the broker was told is gone. Stamp the rule id and the rules file version into both the stored event and the export, and keep old rule versions in the file rather than editing them in place. Done when: The export contains the rule id and rules file version for every lease event, checked in a fixture. Restoring a backup into an empty database reproduces every window and every acknowledgement exactly, exercised in the test suite. Deleting a contact marks it deleted and keeps its messages and acknowledgements, tested. A rules file change adds a new version rather than editing an old one, enforced by a test on the file's shape. Do not build yet: Nothing after this. ### Out of scope (and why) - Contact data enrichment from a commercial database, which is a licence and a subscription rather than code, and brings its own lawful-basis question about people who never gave you their details. - Drafting or serving any statutory or contractual notice, because a notice in the wrong form or a day late fails outright and no timer should be near that decision. - Mailbox provider authorisation and reply threading at scale, which is a verification process and an ongoing integration rather than a weekend. - Any statement that a date is correct in law. The application shows a rule, its inputs and a window, and the professional advice is somebody else's job. ### README must contain - That section 25 of the Landlord and Tenant Act 1954 sets a window of not more than twelve nor less than six months, quoted with the source and the date checked, and that this is why the reminder horizon is what it is - That every computed window stores both ends, the rule id, the clause reference and the inputs, and that a date without those is never displayed - That the application never drafts, serves or sends a notice of any kind - That the rules file is dated and versioned per jurisdiction, and that old versions are kept rather than edited - That this is not legal advice, and that rent and other money are stored as integer minor units ===== ARCHITECTURE.md ===== # Architecture · LeaseLineage ## Stack | Part | Choice | Why | | --- | --- | --- | | Runtime | Node with Express, better-sqlite3 and server-rendered templates, bound to localhost | one broker, one machine, and no reason for anyone else's server to hold these leases | | Dates | Plain calendar dates with an explicit basis, and windows stored as two dates | a notice window is not an instant and squeezing it into one loses the whole point | | Rules | A dated rules file per jurisdiction, referenced by id from every computed window | statutes differ across the UK and change, and a rule with no version is a guess | | Money | Integer minor units with a currency code, never floating point | rent, service charge and deposit all get multiplied and compared | | Sending | Local drafts, approval before any send, a suppression list checked at send time | outreach you did not read is outreach you cannot defend | ## Modules Each module has one owner concern and a documented way to replace it. | Module | Owns | How to replace it | | --- | --- | --- | | Import | CSV mapping, normalisation and provenance. | Any source, provided normalisation happens before comparison. | | Leases | Lease records and computed windows with their inputs. | Not swappable for anything that stores a single deadline date. | | Rules | Dated, versioned, jurisdiction-scoped rule rows. | Any format, provided versions accumulate rather than being edited. | | Reminders | Tiers, escalation and acknowledgement. | Any scheduler, provided the horizon comes from the rules file. | | Outreach | Drafts, approval, suppression and reply detection. | Any transport, provided suppression is checked at send time. | ## Configuration Every runtime setting is an environment variable documented in `.env.example`, validated at startup, with a safe local default wherever one exists. - `DATABASE_PATH` · required · Everything. Back it up; the acknowledgement log is not reconstructable. - `RULES_PATH` · required · Dated rules files per jurisdiction. Versions are added, never edited. - `DEFAULT_JURISDICTION` · required · Used only when a lease does not state one, and recorded on the lease either way. - `REMINDER_MAX_HORIZON_DAYS` · required · Must exceed the longest window in the rules file; a test asserts it does. - `MODEL_API_KEY` · optional, secret · Drafting outreach copy only. Never used for anything a date depends on. - `SMTP_URL` · optional, secret · Sending approved messages. Without it the app still drafts and tracks. - `IMAP_URL` · optional, secret · Reply polling. Without it sequences must be stopped by hand. - `DAILY_SEND_CAP` · required · Across all campaigns together, not per campaign. ## Production baseline - Security: least privilege, input validation at every boundary, secret redaction in logs, rate limits on abuse-prone paths, no invented security primitives. - Data: explicit schema and migrations, transactional writes where integrity matters, backup and restore procedures that have been exercised. - Integrations: adapters around third-party providers, idempotent webhook or job processing, bounded retries, timeouts. - Observability: structured logs with request or operation ids, an error-tracking hook, and health and readiness checks where a server exists. - Quality: unit tests for domain rules, integration tests at module boundaries, one end-to-end test of the critical path. ## Decision records For each dependency in the stack table, keep a short note: why it was chosen, its failure mode, and how it is replaced. Do not add infrastructure until a requirement in `PRODUCT.md` justifies it. ===== AGENTS.md ===== # Agent instructions · LeaseLineage product build - Read `PRODUCT.md` and `ARCHITECTURE.md` before changing code. The stack is fixed: Node with Express, better-sqlite3 and server-rendered templates, bound to localhost, Plain calendar dates with an explicit basis, and windows stored as two dates, A dated rules file per jurisdiction, referenced by id from every computed window, Integer minor units with a currency code, never floating point, Local drafts, approval before any send, a suppression list checked at send time. - Implement milestone by milestone from `MILESTONES.md`; keep each change reviewable and leave the application runnable at every commit. - Treat authentication, payments, encryption, imports, webhooks and destructive actions as high-risk boundaries when present. - Never invent cryptography or silently weaken a requirement to make a check pass. - Put every external service behind an interface with a deterministic fake for tests. - Add migrations and rollback or recovery notes for every persistent data change. - Log useful operational context without credentials, tokens, passwords or personal data. - Update documentation and run every check before completing a milestone. ## Known traps - Deduplicating on the raw email string lets the same person in three times because one export capitalised the local part and another added a trailing space, and by the time you notice, all three are in a campaign and have had the same message twice. Normalise before comparing, keep the original string as well, and make the second import of an identical file assert zero new rows in a test. - Storing a single deadline date is the mistake the whole application is built around avoiding, because the legally interesting thing is a window with two ends, and a user who sees one date assumes it is the last day when it may well be the first. Store both ends, label them in the interface, and refuse to render any computed date that has no rule id attached. - Defaulting the reminder horizon to thirty or ninety days, which is what every other reminder feature does, guarantees that a window opening twelve months before a termination date has already closed by the time anything fires, and the software will have been quietly useless for the one thing it existed to do. Derive the longest tier from the rules file rather than from a constant, and assert in a test that no window can close before its first reminder fires. - Checking the suppression list when the sequence is generated means a person who unsubscribes on Tuesday still gets Wednesday's step, because the message was approved on Monday and nothing looked again. Check at the moment of sending, against the live list, and write a test that unsubscribes a contact between approval and send and asserts nothing left the building. - Exporting the computed window without the rules file version means that a year later, after the rules file has been corrected, nobody can reproduce the date the software actually showed, and the only remaining evidence of what the broker was told is gone. Stamp the rule id and the rules file version into both the stored event and the export, and keep old rule versions in the file rather than editing them in place. ===== MILESTONES.md ===== # Delivery milestones · LeaseLineage Estimated effort: **multi-day** for the indie phases; the production-only milestones add the trust and operability layer. ## M1 · Organisations, contacts and an import that does not duplicate A CSV of prospects lands as clean rows and a second import of the same file changes nothing. ### Steps 1. Normalise before comparing, keep the original The original is evidence; the normalised form is the key. Files: `src/import/normalise.js`, `tests/dedupe.test.js` 2. Record source file and row on every row Someone will ask where a number came from. Files: `src/import/run.js` 3. Import a contact with no email rather than dropping it A name and a phone number is still a prospect. Files: `src/import/run.js`, `src/views/contact.ejs` ### Done when - [ ] A repeated import adds nothing - [ ] Provenance is stored and displayed - [ ] Search stays responsive on a few thousand rows - [ ] Contacts without email are kept and flagged ### Watch out - Deduplicating on the raw email string lets the same person in three times because one export capitalised the local part and another added a trailing space, and by the time you notice, all three are in a campaign and have had the same message twice. Normalise before comparing, keep the original string as well, and make the second import of an identical file assert zero new rows in a test. ## M2 · Leases, and dates that show their working Every computed notice window carries the rule, the clause and the inputs that produced it. ### Steps 1. Store both ends of the window and the rule that made them One date hides which end of the window it is. Files: `src/leases/events.js`, `src/db/schema.sql` 2. Keep a dated rules file with jurisdiction and statute version Provisions differ across the UK and they change. Files: `data/rules/uk.json`, `docs/rules.md` 3. Render the working from stored fields only If it has to recompute to explain itself, it cannot explain an old answer. Files: `src/views/why.ejs`, `tests/why.test.js` ### Done when - [ ] No lease event exists without both ends and a rule id - [ ] The explanation reads stored fields, not a recomputation - [ ] Editing a lease keeps the previous window values - [ ] A missing rule yields no window and says so ### Watch out - Storing a single deadline date is the mistake the whole application is built around avoiding, because the legally interesting thing is a window with two ends, and a user who sees one date assumes it is the last day when it may well be the first. Store both ends, label them in the interface, and refuse to render any computed date that has no rule id attached. ## M3 · Reminders that fire while the window is still open The horizon is longer than the longest statutory window and acknowledgement is recorded. ### Steps 1. Derive the horizon from the rules, not from a constant The law sets the horizon; your default does not. Files: `src/reminders/schedule.js`, `tests/horizon.test.js` 2. Record acknowledgement with a person and a time It is the answer to the only question that gets asked afterwards. Files: `src/reminders/ack.js` 3. Put the disclaimer on the reminder itself That is where the person is when they decide to act. Files: `src/views/reminder.ejs` ### Done when - [ ] Every rule's first tier fires before its window opens - [ ] Acknowledgement records person and time - [ ] No path composes or sends a notice - [ ] The reminder shows window, clause and disclaimer ### Watch out - Defaulting the reminder horizon to thirty or ninety days, which is what every other reminder feature does, guarantees that a window opening twelve months before a termination date has already closed by the time anything fires, and the software will have been quietly useless for the one thing it existed to do. Derive the longest tier from the rules file rather than from a constant, and assert in a test that no window can close before its first reminder fires. ## M4 · Outreach that stays a draft until you read it Sequences are generated, approved by a person, throttled, and stopped by a reply. ### Steps 1. Check suppression at send time against the live list Approval happened in the past; the list is now. Files: `src/send/guard.js`, `tests/suppression.test.js` 2. Record the approver on every message Nobody should be able to say the machine sent it. Files: `src/campaigns/approve.js` 3. Cap sending across all campaigns together Per-campaign caps add up to a volume you did not intend. Files: `src/send/limits.js` ### Done when - [ ] A late unsubscribe blocks the send - [ ] Every sent message names its approver - [ ] A reply stops the sequence within one interval - [ ] The daily cap spans campaigns ### Watch out - Checking the suppression list when the sequence is generated means a person who unsubscribes on Tuesday still gets Wednesday's step, because the message was approved on Monday and nothing looked again. Check at the moment of sending, against the live list, and write a test that unsubscribes a contact between approval and send and asserts nothing left the building. ## M5 · The record you will be asked for (production only) Everything exports, nothing important is deleted, and a restore is something you have done. ### Steps 1. Stamp rule id and rules version into stored events and exports It is the only way an old date can be explained later. Files: `src/export.js`, `data/rules/uk.json` 2. Soft delete, always The messages and acknowledgements outlive the contact record. Files: `src/db/delete.js` 3. Restore a backup in the suite, not in the README A backup nobody has restored is a hope. Files: `tests/restore.test.js`, `scripts/backup.sh` ### Done when - [ ] Exports carry rule id and rules version - [ ] A restore reproduces windows and acknowledgements - [ ] Deleting a contact keeps its history - [ ] Rules versions are added, never edited in place ### Watch out - Exporting the computed window without the rules file version means that a year later, after the rules file has been corrected, nobody can reproduce the date the software actually showed, and the only remaining evidence of what the broker was told is gone. Stamp the rule id and the rules file version into both the stored event and the export, and keep old rule versions in the file rather than editing them in place. ===== OPERATIONS.md ===== # Operations · LeaseLineage ## Backup Nightly dated copies of the database and the rules directory together, because a window can only be explained by the rules version that produced it. Keep enough generations to cover a lease cycle, not a week. ## Restore Restore both, then run the suite's restore test against the copy: it should reproduce every window and every acknowledgement. A restore that recomputes windows under today's rules has quietly rewritten history. Do a restore drill before the first real user, and write the date here when it passes. ## Monitoring Reminders due and unacknowledged, the gap between the longest rule window and the configured horizon, mailbox polling failures, and messages stuck awaiting approval. The first two are the ones that matter. ## Incident checklist The incident is a missed window, and it will be discovered months late by somebody else. The stored inputs, the rule version and the acknowledgement log are what turn that into a factual account rather than an argument about what the software showed. 1. Contain the issue without destroying evidence or user data. 2. Record the timeline and affected scope. 3. Rotate exposed secrets and revoke compromised sessions or credentials. 4. Restore from a verified backup when needed. 5. Document the root cause, the remediation and the regression test. ## Release gate - [ ] Every rule's first reminder tier is confirmed to fire before its window opens - [ ] No lease event is confirmed to exist without both window ends and a rule id - [ ] No code path is confirmed to compose, address or send a notice - [ ] A late unsubscribe is confirmed to block a send - [ ] A restore is confirmed to reproduce windows and acknowledgements exactly ## Launch constraint Do not market omitted LeaseLineage capabilities as implemented. The non-goals in `PRODUCT.md` remain user-visible limitations until they are deliberately delivered. ===== .env.example ===== # Copy to .env and fill in. Never commit .env; this file documents it. # Required. Everything. Back it up; the acknowledgement log is not reconstructable. DATABASE_PATH=./data/leaselineage.db # Required. Dated rules files per jurisdiction. Versions are added, never edited. RULES_PATH=./data/rules # Required. Used only when a lease does not state one, and recorded on the lease either way. DEFAULT_JURISDICTION=england-and-wales # Required. Must exceed the longest window in the rules file; a test asserts it does. REMINDER_MAX_HORIZON_DAYS=540 # Optional · secret. Drafting outreach copy only. Never used for anything a date depends on. MODEL_API_KEY=sk-...redacted # Optional · secret. Sending approved messages. Without it the app still drafts and tracks. SMTP_URL=smtps://user:redacted@mail.example.com:465 # Optional · secret. Reply polling. Without it sequences must be stopped by hand. IMAP_URL=imaps://user:redacted@mail.example.com:993 # Required. Across all campaigns together, not per campaign. DAILY_SEND_CAP=40
# LeaseLineage · indie build A broker's workspace whose hard problems are computing statutory and contractual notice windows that a person can audit, reminding far enough ahead that a twelve-to-six-month window is still open, and keeping outreach as drafts a human approves. Estimated effort: **multi-day**. Work `BUILD_PLAN.md` top to bottom · every phase ends in a check that has to pass before the next one starts. ## Stack | Part | Choice | Why | | --- | --- | --- | | Runtime | Node with Express, better-sqlite3 and server-rendered templates, bound to localhost | one broker, one machine, and no reason for anyone else's server to hold these leases | | Dates | Plain calendar dates with an explicit basis, and windows stored as two dates | a notice window is not an instant and squeezing it into one loses the whole point | | Rules | A dated rules file per jurisdiction, referenced by id from every computed window | statutes differ across the UK and change, and a rule with no version is a guess | | Money | Integer minor units with a currency code, never floating point | rent, service charge and deposit all get multiplied and compared | | Sending | Local drafts, approval before any send, a suppression list checked at send time | outreach you did not read is outreach you cannot defend | ## Before you start Have every one of these ready. The plan assumes them from step one. - [ ] **Node.js 22 or newer** · free - Why: Runs the workspace and the mailbox poller. - Get it: nodejs.org or your version manager. - Verify: node --version prints v22 or higher - [ ] **A written rules file for your jurisdiction** · free - Why: Every computed window points at a row in it, and a rule with no statute reference and no version date is a guess wearing a date's clothing. - Get it: Work through the provisions that apply to the leases you actually handle, record statute, section, version date and the computation, and cite the source for each. - Verify: Every rule row has a statute, a section and a version date, and a test asserts it - [ ] **Two or three real lease summaries** · free - Why: Window computation looks obvious until it meets a lease whose commencement and term end disagree with the rent review cycle. - Get it: Take leases you already manage and reduce each to the dates and the clause references. - Verify: Each produces windows whose 'why this date' panel matches what you would have worked out by hand - [ ] **A mailbox with application-password access** (optional) · free - Why: Reply detection is what stops a sequence, and without it the software keeps emailing a person who already answered. - Get it: Enable an application password on the mailbox you send from. - Verify: A reply to a sent message stops the sequence within one polling interval - [ ] **Where your professional judgement takes over** · free - Why: A tool that computes legal dates invites reliance, and the boundary needs to be written down before the first reminder fires rather than after a missed one. - Get it: Write down that the app computes windows from a dated rules file, that it never advises, and what the user is expected to confirm independently. - Verify: The same text appears on every reminder and in the README ## Quick start Follow Phase 1 in `BUILD_PLAN.md`; it creates the project. Then copy `.env.example` to `.env` and fill in the values it documents. ## Honest limits This build deliberately does not replace: - Commercial contact data enrichment. - Drafting or serving notices. - Mailbox provider authorisation at scale. - Any claim that a computed date is correct in law. - Apollo and Hunter prospect data - Gmail and Outlook OAuth and reply sync - managed sending and deliverability - brokerage team workspace - custom sending domains - hosted compliance and suppression operations If one of those is essential to you, that is the reason to keep paying for LeaseLineage, and the README should say so rather than pretend.
# Build brief · LeaseLineage The one-shot brief this plan expands. `BUILD_PLAN.md` (or `MILESTONES.md`) is the same sequence broken into steps and checks; where the two disagree, the plan wins. Build me a local commercial property workspace that tracks prospects, lease dates and notice windows and shows its working for every date it computes to replace LeaseLineage. Build it in phases, in the order below. Do not write the whole thing in one pass. Finish a phase, run its "Done when" check, fix what fails, and only then start the next phase. ### Rule zero This holds dates with legal consequences, and that changes what the software is allowed to say. Section 25 of the Landlord and Tenant Act 1954 provides that a landlord's notice 'shall not have effect unless it is given not more than twelve nor less than six months before the date of the termination specified therein'. Checked at legislation.gov.uk on 15 September 2026. Read that again: the opportunity is a window that opens a year out and closes six months out. A reminder system whose longest horizon is ninety days has already missed it every single time. So the reminder horizon must exceed the longest window the app knows about, and a window is stored and displayed as two dates, never as one. Break clauses are construed strictly. A notice in the wrong form, sent by the wrong person or a day late simply fails, and the term runs on. This application therefore never drafts a notice, never serves one and never sends one on a timer. It shows a window, the clause it came from, the inputs it used and the rule it applied. A computed date that cannot show its working is not displayed at all. Nothing here is legal advice, the jurisdiction and statute version are recorded on every rule, and money is stored as integer minor units with a currency code. A rent figure held as a floating point number is a rounding error waiting for a rent review. ### Stack (fixed, do not substitute) - Runtime: Node with Express, better-sqlite3 and server-rendered templates, bound to localhost · one broker, one machine, and no reason for anyone else's server to hold these leases - Dates: Plain calendar dates with an explicit basis, and windows stored as two dates · a notice window is not an instant and squeezing it into one loses the whole point - Rules: A dated rules file per jurisdiction, referenced by id from every computed window · statutes differ across the UK and change, and a rule with no version is a guess - Money: Integer minor units with a currency code, never floating point · rent, service charge and deposit all get multiplied and compared - Sending: Local drafts, approval before any send, a suppression list checked at send time · outreach you did not read is outreach you cannot defend ### Data model (create this before Phase 1) - organisations(id, name, kind, market, notes) and contacts(id, organisation_id, name, title, email, phone, source, unsubscribed_at) - properties(id, address, asset_class, owner_org_id, area_sqft, notes) - leases(id, property_id, tenant_org_id, jurisdiction, commencement, term_end, contracted_out, rent_minor, rent_currency, rent_period, notes) - lease_events(id, lease_id, kind, target_date, basis, clause_ref, rule_id, inputs_json, window_opens, window_closes) · kind covers break, rent review, expiry and statutory notice - rules(id, jurisdiction, statute, section, version_date, description) · every computed window names one of these rows - reminders(id, lease_event_id, fire_on, tier, acknowledged_at, acknowledged_by, note) · acknowledgement is recorded because 'nobody told me' is the argument you are preventing - campaigns, sequence_steps and messages(id, contact_id, step_id, state, body, approved_by, approved_at, sent_at, provider_id) · state is draft until a person approves it - No lease event stores a single deadline. It stores an opening date, a closing date, the rule that produced them and the inputs, so a disagreement is a lookup rather than an argument. ### Phase 1 · Organisations, contacts and an import that does not duplicate Build: Import contacts and organisations from CSV with a saved column mapping. Deduplicate contacts on lowercased email and organisations on a normalised name plus market. Record the source file and row number on every imported row. Show a table with search, filters and a detail page carrying notes and an activity log. Trap: Deduplicating on the raw email string lets the same person in three times because one export capitalised the local part and another added a trailing space, and by the time you notice, all three are in a campaign and have had the same message twice. Normalise before comparing, keep the original string as well, and make the second import of an identical file assert zero new rows in a test. Done when: Importing the same file twice creates no new rows the second time, tested. Every imported row records its source file and row number and that appears on the detail page. Search and filter work on an imported set of a few thousand rows without a visible pause. A contact with no email imports and is marked as not contactable rather than being dropped. Do not build yet: No leases, windows, reminders or outreach yet. ### Phase 2 · Leases, and dates that show their working Build: Model the lease with commencement, term end, contracting-out status and rent in minor units. Compute windows for break, rent review, expiry and statutory notice from a dated rules file, and store each as an opening date, a closing date, the rule id, the clause reference and a JSON blob of the inputs. Render a 'why this date' panel that reads those fields back. Trap: Storing a single deadline date is the mistake the whole application is built around avoiding, because the legally interesting thing is a window with two ends, and a user who sees one date assumes it is the last day when it may well be the first. Store both ends, label them in the interface, and refuse to render any computed date that has no rule id attached. Done when: Every lease event row has an opening date, a closing date, a rule id and an inputs blob, enforced by a not-null constraint and a test. The 'why this date' panel reproduces the computation from stored fields alone, with no recomputation, tested. Changing a lease's commencement recomputes windows and records the previous values rather than overwriting them. A jurisdiction with no rule for an event kind produces no window and says so, rather than defaulting to a rule from elsewhere. Do not build yet: No reminders, campaigns or exports yet. ### Phase 3 · Reminders that fire while the window is still open Build: Generate reminder tiers from each window's opening date backwards, with the longest tier further out than the longest window in the rules file. Escalate as the closing date approaches. Record who acknowledged a reminder and when. Never send a notice, never draft one, and put the disclaimer on the reminder itself rather than in a settings page nobody opens. Trap: Defaulting the reminder horizon to thirty or ninety days, which is what every other reminder feature does, guarantees that a window opening twelve months before a termination date has already closed by the time anything fires, and the software will have been quietly useless for the one thing it existed to do. Derive the longest tier from the rules file rather than from a constant, and assert in a test that no window can close before its first reminder fires. Done when: A test asserts that for every rule in the file, the first reminder tier fires before the window opens. Acknowledging a reminder stores the person and the time and stops escalation for that tier only. No code path composes, addresses or sends a statutory notice, verified by review and named in the README. A reminder displays the window, the clause reference and the disclaimer together. Do not build yet: No outreach campaigns, sending or export yet. ### Phase 4 · Outreach that stays a draft until you read it Build: Build campaigns from a filtered prospect list with a multi-step sequence. Generated bodies are drafts. Approval is per message or per batch, by a person, and is recorded. Check the suppression list and the unsubscribed flag at send time, not at draft time. Poll one mailbox, match replies by message identifier and address, and stop the remaining steps for that contact. Trap: Checking the suppression list when the sequence is generated means a person who unsubscribes on Tuesday still gets Wednesday's step, because the message was approved on Monday and nothing looked again. Check at the moment of sending, against the live list, and write a test that unsubscribes a contact between approval and send and asserts nothing left the building. Done when: A contact unsubscribed after approval but before sending receives nothing, tested. Every sent message records who approved it and when. A reply stops the remaining steps for that contact within one polling interval, tested with a stubbed mailbox. The daily send cap is enforced across campaigns, not per campaign, tested by queuing more than the cap across two. Do not build yet: No export, retention or backup work yet. ### Phase 5 · The record you will be asked for Build: Export organisations, contacts, properties, leases, lease events with their inputs, reminders with their acknowledgements, and messages, as CSV plus a JSON bundle. Soft delete only. Take a dated backup on a schedule and restore one in the test suite. Record the rules file version in every export so an old window can be explained under the rule that produced it. Trap: Exporting the computed window without the rules file version means that a year later, after the rules file has been corrected, nobody can reproduce the date the software actually showed, and the only remaining evidence of what the broker was told is gone. Stamp the rule id and the rules file version into both the stored event and the export, and keep old rule versions in the file rather than editing them in place. Done when: The export contains the rule id and rules file version for every lease event, checked in a fixture. Restoring a backup into an empty database reproduces every window and every acknowledgement exactly, exercised in the test suite. Deleting a contact marks it deleted and keeps its messages and acknowledgements, tested. A rules file change adds a new version rather than editing an old one, enforced by a test on the file's shape. Do not build yet: Nothing after this. ### Out of scope (and why) - Contact data enrichment from a commercial database, which is a licence and a subscription rather than code, and brings its own lawful-basis question about people who never gave you their details. - Drafting or serving any statutory or contractual notice, because a notice in the wrong form or a day late fails outright and no timer should be near that decision. - Mailbox provider authorisation and reply threading at scale, which is a verification process and an ongoing integration rather than a weekend. - Any statement that a date is correct in law. The application shows a rule, its inputs and a window, and the professional advice is somebody else's job. ### README must contain - That section 25 of the Landlord and Tenant Act 1954 sets a window of not more than twelve nor less than six months, quoted with the source and the date checked, and that this is why the reminder horizon is what it is - That every computed window stores both ends, the rule id, the clause reference and the inputs, and that a date without those is never displayed - That the application never drafts, serves or sends a notice of any kind - That the rules file is dated and versioned per jurisdiction, and that old versions are kept rather than edited - That this is not legal advice, and that rent and other money are stored as integer minor units
# Agent instructions · LeaseLineage indie build - Read `README.md` and `BUILD_PLAN.md` before writing code. The stack is fixed: Node with Express, better-sqlite3 and server-rendered templates, bound to localhost, Plain calendar dates with an explicit basis, and windows stored as two dates, A dated rules file per jurisdiction, referenced by id from every computed window, Integer minor units with a currency code, never floating point, Local drafts, approval before any send, a suppression list checked at send time. Do not substitute. - Work one phase at a time, in order. Do not start a phase until every "Done when" item of the previous one passes. - Prefer the fewest moving parts that satisfy the step. No frameworks, services or dependencies the plan does not name. - Secrets live in `.env`, never in source or logs. Keep `.env.example` current when a variable is introduced. - Do not invent cryptography, security guarantees, APIs or compliance claims. - Add a focused test for every destructive, security-sensitive or data-loss path the plan names. - Run the project checks before declaring a phase complete, and record any deliberate shortcut in the README under "Tradeoffs". ## Known traps - Deduplicating on the raw email string lets the same person in three times because one export capitalised the local part and another added a trailing space, and by the time you notice, all three are in a campaign and have had the same message twice. Normalise before comparing, keep the original string as well, and make the second import of an identical file assert zero new rows in a test. - Storing a single deadline date is the mistake the whole application is built around avoiding, because the legally interesting thing is a window with two ends, and a user who sees one date assumes it is the last day when it may well be the first. Store both ends, label them in the interface, and refuse to render any computed date that has no rule id attached. - Defaulting the reminder horizon to thirty or ninety days, which is what every other reminder feature does, guarantees that a window opening twelve months before a termination date has already closed by the time anything fires, and the software will have been quietly useless for the one thing it existed to do. Derive the longest tier from the rules file rather than from a constant, and assert in a test that no window can close before its first reminder fires. - Checking the suppression list when the sequence is generated means a person who unsubscribes on Tuesday still gets Wednesday's step, because the message was approved on Monday and nothing looked again. Check at the moment of sending, against the live list, and write a test that unsubscribes a contact between approval and send and asserts nothing left the building.
# Build plan · LeaseLineage A broker's workspace whose hard problems are computing statutory and contractual notice windows that a person can audit, reminding far enough ahead that a twelve-to-six-month window is still open, and keeping outreach as drafts a human approves. Phases are in dependency order. Each ends in a "Done when" list; treat an unticked item as a blocker, not a note. ## Phase 1 · Organisations, contacts and an import that does not duplicate A CSV of prospects lands as clean rows and a second import of the same file changes nothing. ### Steps 1. Normalise before comparing, keep the original The original is evidence; the normalised form is the key. Files: `src/import/normalise.js`, `tests/dedupe.test.js` 2. Record source file and row on every row Someone will ask where a number came from. Files: `src/import/run.js` 3. Import a contact with no email rather than dropping it A name and a phone number is still a prospect. Files: `src/import/run.js`, `src/views/contact.ejs` ### Done when - [ ] A repeated import adds nothing - [ ] Provenance is stored and displayed - [ ] Search stays responsive on a few thousand rows - [ ] Contacts without email are kept and flagged ### Watch out - Deduplicating on the raw email string lets the same person in three times because one export capitalised the local part and another added a trailing space, and by the time you notice, all three are in a campaign and have had the same message twice. Normalise before comparing, keep the original string as well, and make the second import of an identical file assert zero new rows in a test. ## Phase 2 · Leases, and dates that show their working Every computed notice window carries the rule, the clause and the inputs that produced it. ### Steps 1. Store both ends of the window and the rule that made them One date hides which end of the window it is. Files: `src/leases/events.js`, `src/db/schema.sql` 2. Keep a dated rules file with jurisdiction and statute version Provisions differ across the UK and they change. Files: `data/rules/uk.json`, `docs/rules.md` 3. Render the working from stored fields only If it has to recompute to explain itself, it cannot explain an old answer. Files: `src/views/why.ejs`, `tests/why.test.js` ### Done when - [ ] No lease event exists without both ends and a rule id - [ ] The explanation reads stored fields, not a recomputation - [ ] Editing a lease keeps the previous window values - [ ] A missing rule yields no window and says so ### Watch out - Storing a single deadline date is the mistake the whole application is built around avoiding, because the legally interesting thing is a window with two ends, and a user who sees one date assumes it is the last day when it may well be the first. Store both ends, label them in the interface, and refuse to render any computed date that has no rule id attached. ## Phase 3 · Reminders that fire while the window is still open The horizon is longer than the longest statutory window and acknowledgement is recorded. ### Steps 1. Derive the horizon from the rules, not from a constant The law sets the horizon; your default does not. Files: `src/reminders/schedule.js`, `tests/horizon.test.js` 2. Record acknowledgement with a person and a time It is the answer to the only question that gets asked afterwards. Files: `src/reminders/ack.js` 3. Put the disclaimer on the reminder itself That is where the person is when they decide to act. Files: `src/views/reminder.ejs` ### Done when - [ ] Every rule's first tier fires before its window opens - [ ] Acknowledgement records person and time - [ ] No path composes or sends a notice - [ ] The reminder shows window, clause and disclaimer ### Watch out - Defaulting the reminder horizon to thirty or ninety days, which is what every other reminder feature does, guarantees that a window opening twelve months before a termination date has already closed by the time anything fires, and the software will have been quietly useless for the one thing it existed to do. Derive the longest tier from the rules file rather than from a constant, and assert in a test that no window can close before its first reminder fires. ## Phase 4 · Outreach that stays a draft until you read it Sequences are generated, approved by a person, throttled, and stopped by a reply. ### Steps 1. Check suppression at send time against the live list Approval happened in the past; the list is now. Files: `src/send/guard.js`, `tests/suppression.test.js` 2. Record the approver on every message Nobody should be able to say the machine sent it. Files: `src/campaigns/approve.js` 3. Cap sending across all campaigns together Per-campaign caps add up to a volume you did not intend. Files: `src/send/limits.js` ### Done when - [ ] A late unsubscribe blocks the send - [ ] Every sent message names its approver - [ ] A reply stops the sequence within one interval - [ ] The daily cap spans campaigns ### Watch out - Checking the suppression list when the sequence is generated means a person who unsubscribes on Tuesday still gets Wednesday's step, because the message was approved on Monday and nothing looked again. Check at the moment of sending, against the live list, and write a test that unsubscribes a contact between approval and send and asserts nothing left the building. ## Not in this build - Commercial contact data enrichment. - Drafting or serving notices. - Mailbox provider authorisation at scale. - Any claim that a computed date is correct in law. ## After v1, if you want it - A portfolio calendar view of every open window across all leases, sorted by closing date - A second jurisdiction's rules file, which is the real test of whether the rule abstraction works - A printable lease summary that carries the clause references and the inputs alongside each date
# Copy to .env and fill in. Never commit .env; this file documents it. # Required. Everything. Back it up; the acknowledgement log is not reconstructable. DATABASE_PATH=./data/leaselineage.db # Required. Dated rules files per jurisdiction. Versions are added, never edited. RULES_PATH=./data/rules # Required. Used only when a lease does not state one, and recorded on the lease either way. DEFAULT_JURISDICTION=england-and-wales # Required. Must exceed the longest window in the rules file; a test asserts it does. REMINDER_MAX_HORIZON_DAYS=540 # Optional · secret. Drafting outreach copy only. Never used for anything a date depends on. MODEL_API_KEY=sk-...redacted # Optional · secret. Sending approved messages. Without it the app still drafts and tracks. SMTP_URL=smtps://user:redacted@mail.example.com:465 # Optional · secret. Reply polling. Without it sequences must be stopped by hand. IMAP_URL=imaps://user:redacted@mail.example.com:993 # Required. Across all campaigns together, not per campaign. DAILY_SEND_CAP=40
# LeaseLineage · product brief ## Problem A solo broker can build the CRM, CSV prospecting, draft sequences, and pipeline in a weekend, but reliable contact data, Gmail and Outlook sync, deliverability, compliance operations, and brokerage collaboration are ongoing products. ## Product outcome A workspace that computes notice windows a person can audit line by line, reminds far enough ahead that a twelve-month window is still open, never sends a notice, and keeps the record of who was told what and when. ## Target user A builder who needs a maintainable product foundation, not a one-off demo. ## Required capabilities - local web app - SQLite - CSV import - OpenAI API key - optional SMTP credentials ## Explicit non-goals for v1 - Commercial contact data enrichment. - Drafting or serving notices. - Mailbox provider authorisation at scale. - Any claim that a computed date is correct in law. - Apollo and Hunter prospect data - Gmail and Outlook OAuth and reply sync - managed sending and deliverability - brokerage team workspace - custom sending domains - hosted compliance and suppression operations ## Success criteria - Every rule's first reminder tier is confirmed to fire before its window opens - No lease event is confirmed to exist without both window ends and a rule id - No code path is confirmed to compose, address or send a notice - A late unsubscribe is confirmed to block a send - A restore is confirmed to reproduce windows and acknowledgements exactly
# Build brief · LeaseLineage The one-shot brief this plan expands. `BUILD_PLAN.md` (or `MILESTONES.md`) is the same sequence broken into steps and checks; where the two disagree, the plan wins. Build me a local commercial property workspace that tracks prospects, lease dates and notice windows and shows its working for every date it computes to replace LeaseLineage. Build it in phases, in the order below. Do not write the whole thing in one pass. Finish a phase, run its "Done when" check, fix what fails, and only then start the next phase. ### Rule zero This holds dates with legal consequences, and that changes what the software is allowed to say. Section 25 of the Landlord and Tenant Act 1954 provides that a landlord's notice 'shall not have effect unless it is given not more than twelve nor less than six months before the date of the termination specified therein'. Checked at legislation.gov.uk on 15 September 2026. Read that again: the opportunity is a window that opens a year out and closes six months out. A reminder system whose longest horizon is ninety days has already missed it every single time. So the reminder horizon must exceed the longest window the app knows about, and a window is stored and displayed as two dates, never as one. Break clauses are construed strictly. A notice in the wrong form, sent by the wrong person or a day late simply fails, and the term runs on. This application therefore never drafts a notice, never serves one and never sends one on a timer. It shows a window, the clause it came from, the inputs it used and the rule it applied. A computed date that cannot show its working is not displayed at all. Nothing here is legal advice, the jurisdiction and statute version are recorded on every rule, and money is stored as integer minor units with a currency code. A rent figure held as a floating point number is a rounding error waiting for a rent review. ### Stack (fixed, do not substitute) - Runtime: Node with Express, better-sqlite3 and server-rendered templates, bound to localhost · one broker, one machine, and no reason for anyone else's server to hold these leases - Dates: Plain calendar dates with an explicit basis, and windows stored as two dates · a notice window is not an instant and squeezing it into one loses the whole point - Rules: A dated rules file per jurisdiction, referenced by id from every computed window · statutes differ across the UK and change, and a rule with no version is a guess - Money: Integer minor units with a currency code, never floating point · rent, service charge and deposit all get multiplied and compared - Sending: Local drafts, approval before any send, a suppression list checked at send time · outreach you did not read is outreach you cannot defend ### Data model (create this before Phase 1) - organisations(id, name, kind, market, notes) and contacts(id, organisation_id, name, title, email, phone, source, unsubscribed_at) - properties(id, address, asset_class, owner_org_id, area_sqft, notes) - leases(id, property_id, tenant_org_id, jurisdiction, commencement, term_end, contracted_out, rent_minor, rent_currency, rent_period, notes) - lease_events(id, lease_id, kind, target_date, basis, clause_ref, rule_id, inputs_json, window_opens, window_closes) · kind covers break, rent review, expiry and statutory notice - rules(id, jurisdiction, statute, section, version_date, description) · every computed window names one of these rows - reminders(id, lease_event_id, fire_on, tier, acknowledged_at, acknowledged_by, note) · acknowledgement is recorded because 'nobody told me' is the argument you are preventing - campaigns, sequence_steps and messages(id, contact_id, step_id, state, body, approved_by, approved_at, sent_at, provider_id) · state is draft until a person approves it - No lease event stores a single deadline. It stores an opening date, a closing date, the rule that produced them and the inputs, so a disagreement is a lookup rather than an argument. ### Phase 1 · Organisations, contacts and an import that does not duplicate Build: Import contacts and organisations from CSV with a saved column mapping. Deduplicate contacts on lowercased email and organisations on a normalised name plus market. Record the source file and row number on every imported row. Show a table with search, filters and a detail page carrying notes and an activity log. Trap: Deduplicating on the raw email string lets the same person in three times because one export capitalised the local part and another added a trailing space, and by the time you notice, all three are in a campaign and have had the same message twice. Normalise before comparing, keep the original string as well, and make the second import of an identical file assert zero new rows in a test. Done when: Importing the same file twice creates no new rows the second time, tested. Every imported row records its source file and row number and that appears on the detail page. Search and filter work on an imported set of a few thousand rows without a visible pause. A contact with no email imports and is marked as not contactable rather than being dropped. Do not build yet: No leases, windows, reminders or outreach yet. ### Phase 2 · Leases, and dates that show their working Build: Model the lease with commencement, term end, contracting-out status and rent in minor units. Compute windows for break, rent review, expiry and statutory notice from a dated rules file, and store each as an opening date, a closing date, the rule id, the clause reference and a JSON blob of the inputs. Render a 'why this date' panel that reads those fields back. Trap: Storing a single deadline date is the mistake the whole application is built around avoiding, because the legally interesting thing is a window with two ends, and a user who sees one date assumes it is the last day when it may well be the first. Store both ends, label them in the interface, and refuse to render any computed date that has no rule id attached. Done when: Every lease event row has an opening date, a closing date, a rule id and an inputs blob, enforced by a not-null constraint and a test. The 'why this date' panel reproduces the computation from stored fields alone, with no recomputation, tested. Changing a lease's commencement recomputes windows and records the previous values rather than overwriting them. A jurisdiction with no rule for an event kind produces no window and says so, rather than defaulting to a rule from elsewhere. Do not build yet: No reminders, campaigns or exports yet. ### Phase 3 · Reminders that fire while the window is still open Build: Generate reminder tiers from each window's opening date backwards, with the longest tier further out than the longest window in the rules file. Escalate as the closing date approaches. Record who acknowledged a reminder and when. Never send a notice, never draft one, and put the disclaimer on the reminder itself rather than in a settings page nobody opens. Trap: Defaulting the reminder horizon to thirty or ninety days, which is what every other reminder feature does, guarantees that a window opening twelve months before a termination date has already closed by the time anything fires, and the software will have been quietly useless for the one thing it existed to do. Derive the longest tier from the rules file rather than from a constant, and assert in a test that no window can close before its first reminder fires. Done when: A test asserts that for every rule in the file, the first reminder tier fires before the window opens. Acknowledging a reminder stores the person and the time and stops escalation for that tier only. No code path composes, addresses or sends a statutory notice, verified by review and named in the README. A reminder displays the window, the clause reference and the disclaimer together. Do not build yet: No outreach campaigns, sending or export yet. ### Phase 4 · Outreach that stays a draft until you read it Build: Build campaigns from a filtered prospect list with a multi-step sequence. Generated bodies are drafts. Approval is per message or per batch, by a person, and is recorded. Check the suppression list and the unsubscribed flag at send time, not at draft time. Poll one mailbox, match replies by message identifier and address, and stop the remaining steps for that contact. Trap: Checking the suppression list when the sequence is generated means a person who unsubscribes on Tuesday still gets Wednesday's step, because the message was approved on Monday and nothing looked again. Check at the moment of sending, against the live list, and write a test that unsubscribes a contact between approval and send and asserts nothing left the building. Done when: A contact unsubscribed after approval but before sending receives nothing, tested. Every sent message records who approved it and when. A reply stops the remaining steps for that contact within one polling interval, tested with a stubbed mailbox. The daily send cap is enforced across campaigns, not per campaign, tested by queuing more than the cap across two. Do not build yet: No export, retention or backup work yet. ### Phase 5 · The record you will be asked for Build: Export organisations, contacts, properties, leases, lease events with their inputs, reminders with their acknowledgements, and messages, as CSV plus a JSON bundle. Soft delete only. Take a dated backup on a schedule and restore one in the test suite. Record the rules file version in every export so an old window can be explained under the rule that produced it. Trap: Exporting the computed window without the rules file version means that a year later, after the rules file has been corrected, nobody can reproduce the date the software actually showed, and the only remaining evidence of what the broker was told is gone. Stamp the rule id and the rules file version into both the stored event and the export, and keep old rule versions in the file rather than editing them in place. Done when: The export contains the rule id and rules file version for every lease event, checked in a fixture. Restoring a backup into an empty database reproduces every window and every acknowledgement exactly, exercised in the test suite. Deleting a contact marks it deleted and keeps its messages and acknowledgements, tested. A rules file change adds a new version rather than editing an old one, enforced by a test on the file's shape. Do not build yet: Nothing after this. ### Out of scope (and why) - Contact data enrichment from a commercial database, which is a licence and a subscription rather than code, and brings its own lawful-basis question about people who never gave you their details. - Drafting or serving any statutory or contractual notice, because a notice in the wrong form or a day late fails outright and no timer should be near that decision. - Mailbox provider authorisation and reply threading at scale, which is a verification process and an ongoing integration rather than a weekend. - Any statement that a date is correct in law. The application shows a rule, its inputs and a window, and the professional advice is somebody else's job. ### README must contain - That section 25 of the Landlord and Tenant Act 1954 sets a window of not more than twelve nor less than six months, quoted with the source and the date checked, and that this is why the reminder horizon is what it is - That every computed window stores both ends, the rule id, the clause reference and the inputs, and that a date without those is never displayed - That the application never drafts, serves or sends a notice of any kind - That the rules file is dated and versioned per jurisdiction, and that old versions are kept rather than edited - That this is not legal advice, and that rent and other money are stored as integer minor units
# Architecture · LeaseLineage ## Stack | Part | Choice | Why | | --- | --- | --- | | Runtime | Node with Express, better-sqlite3 and server-rendered templates, bound to localhost | one broker, one machine, and no reason for anyone else's server to hold these leases | | Dates | Plain calendar dates with an explicit basis, and windows stored as two dates | a notice window is not an instant and squeezing it into one loses the whole point | | Rules | A dated rules file per jurisdiction, referenced by id from every computed window | statutes differ across the UK and change, and a rule with no version is a guess | | Money | Integer minor units with a currency code, never floating point | rent, service charge and deposit all get multiplied and compared | | Sending | Local drafts, approval before any send, a suppression list checked at send time | outreach you did not read is outreach you cannot defend | ## Modules Each module has one owner concern and a documented way to replace it. | Module | Owns | How to replace it | | --- | --- | --- | | Import | CSV mapping, normalisation and provenance. | Any source, provided normalisation happens before comparison. | | Leases | Lease records and computed windows with their inputs. | Not swappable for anything that stores a single deadline date. | | Rules | Dated, versioned, jurisdiction-scoped rule rows. | Any format, provided versions accumulate rather than being edited. | | Reminders | Tiers, escalation and acknowledgement. | Any scheduler, provided the horizon comes from the rules file. | | Outreach | Drafts, approval, suppression and reply detection. | Any transport, provided suppression is checked at send time. | ## Configuration Every runtime setting is an environment variable documented in `.env.example`, validated at startup, with a safe local default wherever one exists. - `DATABASE_PATH` · required · Everything. Back it up; the acknowledgement log is not reconstructable. - `RULES_PATH` · required · Dated rules files per jurisdiction. Versions are added, never edited. - `DEFAULT_JURISDICTION` · required · Used only when a lease does not state one, and recorded on the lease either way. - `REMINDER_MAX_HORIZON_DAYS` · required · Must exceed the longest window in the rules file; a test asserts it does. - `MODEL_API_KEY` · optional, secret · Drafting outreach copy only. Never used for anything a date depends on. - `SMTP_URL` · optional, secret · Sending approved messages. Without it the app still drafts and tracks. - `IMAP_URL` · optional, secret · Reply polling. Without it sequences must be stopped by hand. - `DAILY_SEND_CAP` · required · Across all campaigns together, not per campaign. ## Production baseline - Security: least privilege, input validation at every boundary, secret redaction in logs, rate limits on abuse-prone paths, no invented security primitives. - Data: explicit schema and migrations, transactional writes where integrity matters, backup and restore procedures that have been exercised. - Integrations: adapters around third-party providers, idempotent webhook or job processing, bounded retries, timeouts. - Observability: structured logs with request or operation ids, an error-tracking hook, and health and readiness checks where a server exists. - Quality: unit tests for domain rules, integration tests at module boundaries, one end-to-end test of the critical path. ## Decision records For each dependency in the stack table, keep a short note: why it was chosen, its failure mode, and how it is replaced. Do not add infrastructure until a requirement in `PRODUCT.md` justifies it.
# Agent instructions · LeaseLineage product build - Read `PRODUCT.md` and `ARCHITECTURE.md` before changing code. The stack is fixed: Node with Express, better-sqlite3 and server-rendered templates, bound to localhost, Plain calendar dates with an explicit basis, and windows stored as two dates, A dated rules file per jurisdiction, referenced by id from every computed window, Integer minor units with a currency code, never floating point, Local drafts, approval before any send, a suppression list checked at send time. - Implement milestone by milestone from `MILESTONES.md`; keep each change reviewable and leave the application runnable at every commit. - Treat authentication, payments, encryption, imports, webhooks and destructive actions as high-risk boundaries when present. - Never invent cryptography or silently weaken a requirement to make a check pass. - Put every external service behind an interface with a deterministic fake for tests. - Add migrations and rollback or recovery notes for every persistent data change. - Log useful operational context without credentials, tokens, passwords or personal data. - Update documentation and run every check before completing a milestone. ## Known traps - Deduplicating on the raw email string lets the same person in three times because one export capitalised the local part and another added a trailing space, and by the time you notice, all three are in a campaign and have had the same message twice. Normalise before comparing, keep the original string as well, and make the second import of an identical file assert zero new rows in a test. - Storing a single deadline date is the mistake the whole application is built around avoiding, because the legally interesting thing is a window with two ends, and a user who sees one date assumes it is the last day when it may well be the first. Store both ends, label them in the interface, and refuse to render any computed date that has no rule id attached. - Defaulting the reminder horizon to thirty or ninety days, which is what every other reminder feature does, guarantees that a window opening twelve months before a termination date has already closed by the time anything fires, and the software will have been quietly useless for the one thing it existed to do. Derive the longest tier from the rules file rather than from a constant, and assert in a test that no window can close before its first reminder fires. - Checking the suppression list when the sequence is generated means a person who unsubscribes on Tuesday still gets Wednesday's step, because the message was approved on Monday and nothing looked again. Check at the moment of sending, against the live list, and write a test that unsubscribes a contact between approval and send and asserts nothing left the building. - Exporting the computed window without the rules file version means that a year later, after the rules file has been corrected, nobody can reproduce the date the software actually showed, and the only remaining evidence of what the broker was told is gone. Stamp the rule id and the rules file version into both the stored event and the export, and keep old rule versions in the file rather than editing them in place.
# Delivery milestones · LeaseLineage Estimated effort: **multi-day** for the indie phases; the production-only milestones add the trust and operability layer. ## M1 · Organisations, contacts and an import that does not duplicate A CSV of prospects lands as clean rows and a second import of the same file changes nothing. ### Steps 1. Normalise before comparing, keep the original The original is evidence; the normalised form is the key. Files: `src/import/normalise.js`, `tests/dedupe.test.js` 2. Record source file and row on every row Someone will ask where a number came from. Files: `src/import/run.js` 3. Import a contact with no email rather than dropping it A name and a phone number is still a prospect. Files: `src/import/run.js`, `src/views/contact.ejs` ### Done when - [ ] A repeated import adds nothing - [ ] Provenance is stored and displayed - [ ] Search stays responsive on a few thousand rows - [ ] Contacts without email are kept and flagged ### Watch out - Deduplicating on the raw email string lets the same person in three times because one export capitalised the local part and another added a trailing space, and by the time you notice, all three are in a campaign and have had the same message twice. Normalise before comparing, keep the original string as well, and make the second import of an identical file assert zero new rows in a test. ## M2 · Leases, and dates that show their working Every computed notice window carries the rule, the clause and the inputs that produced it. ### Steps 1. Store both ends of the window and the rule that made them One date hides which end of the window it is. Files: `src/leases/events.js`, `src/db/schema.sql` 2. Keep a dated rules file with jurisdiction and statute version Provisions differ across the UK and they change. Files: `data/rules/uk.json`, `docs/rules.md` 3. Render the working from stored fields only If it has to recompute to explain itself, it cannot explain an old answer. Files: `src/views/why.ejs`, `tests/why.test.js` ### Done when - [ ] No lease event exists without both ends and a rule id - [ ] The explanation reads stored fields, not a recomputation - [ ] Editing a lease keeps the previous window values - [ ] A missing rule yields no window and says so ### Watch out - Storing a single deadline date is the mistake the whole application is built around avoiding, because the legally interesting thing is a window with two ends, and a user who sees one date assumes it is the last day when it may well be the first. Store both ends, label them in the interface, and refuse to render any computed date that has no rule id attached. ## M3 · Reminders that fire while the window is still open The horizon is longer than the longest statutory window and acknowledgement is recorded. ### Steps 1. Derive the horizon from the rules, not from a constant The law sets the horizon; your default does not. Files: `src/reminders/schedule.js`, `tests/horizon.test.js` 2. Record acknowledgement with a person and a time It is the answer to the only question that gets asked afterwards. Files: `src/reminders/ack.js` 3. Put the disclaimer on the reminder itself That is where the person is when they decide to act. Files: `src/views/reminder.ejs` ### Done when - [ ] Every rule's first tier fires before its window opens - [ ] Acknowledgement records person and time - [ ] No path composes or sends a notice - [ ] The reminder shows window, clause and disclaimer ### Watch out - Defaulting the reminder horizon to thirty or ninety days, which is what every other reminder feature does, guarantees that a window opening twelve months before a termination date has already closed by the time anything fires, and the software will have been quietly useless for the one thing it existed to do. Derive the longest tier from the rules file rather than from a constant, and assert in a test that no window can close before its first reminder fires. ## M4 · Outreach that stays a draft until you read it Sequences are generated, approved by a person, throttled, and stopped by a reply. ### Steps 1. Check suppression at send time against the live list Approval happened in the past; the list is now. Files: `src/send/guard.js`, `tests/suppression.test.js` 2. Record the approver on every message Nobody should be able to say the machine sent it. Files: `src/campaigns/approve.js` 3. Cap sending across all campaigns together Per-campaign caps add up to a volume you did not intend. Files: `src/send/limits.js` ### Done when - [ ] A late unsubscribe blocks the send - [ ] Every sent message names its approver - [ ] A reply stops the sequence within one interval - [ ] The daily cap spans campaigns ### Watch out - Checking the suppression list when the sequence is generated means a person who unsubscribes on Tuesday still gets Wednesday's step, because the message was approved on Monday and nothing looked again. Check at the moment of sending, against the live list, and write a test that unsubscribes a contact between approval and send and asserts nothing left the building. ## M5 · The record you will be asked for (production only) Everything exports, nothing important is deleted, and a restore is something you have done. ### Steps 1. Stamp rule id and rules version into stored events and exports It is the only way an old date can be explained later. Files: `src/export.js`, `data/rules/uk.json` 2. Soft delete, always The messages and acknowledgements outlive the contact record. Files: `src/db/delete.js` 3. Restore a backup in the suite, not in the README A backup nobody has restored is a hope. Files: `tests/restore.test.js`, `scripts/backup.sh` ### Done when - [ ] Exports carry rule id and rules version - [ ] A restore reproduces windows and acknowledgements - [ ] Deleting a contact keeps its history - [ ] Rules versions are added, never edited in place ### Watch out - Exporting the computed window without the rules file version means that a year later, after the rules file has been corrected, nobody can reproduce the date the software actually showed, and the only remaining evidence of what the broker was told is gone. Stamp the rule id and the rules file version into both the stored event and the export, and keep old rule versions in the file rather than editing them in place.
# Operations · LeaseLineage ## Backup Nightly dated copies of the database and the rules directory together, because a window can only be explained by the rules version that produced it. Keep enough generations to cover a lease cycle, not a week. ## Restore Restore both, then run the suite's restore test against the copy: it should reproduce every window and every acknowledgement. A restore that recomputes windows under today's rules has quietly rewritten history. Do a restore drill before the first real user, and write the date here when it passes. ## Monitoring Reminders due and unacknowledged, the gap between the longest rule window and the configured horizon, mailbox polling failures, and messages stuck awaiting approval. The first two are the ones that matter. ## Incident checklist The incident is a missed window, and it will be discovered months late by somebody else. The stored inputs, the rule version and the acknowledgement log are what turn that into a factual account rather than an argument about what the software showed. 1. Contain the issue without destroying evidence or user data. 2. Record the timeline and affected scope. 3. Rotate exposed secrets and revoke compromised sessions or credentials. 4. Restore from a verified backup when needed. 5. Document the root cause, the remediation and the regression test. ## Release gate - [ ] Every rule's first reminder tier is confirmed to fire before its window opens - [ ] No lease event is confirmed to exist without both window ends and a rule id - [ ] No code path is confirmed to compose, address or send a notice - [ ] A late unsubscribe is confirmed to block a send - [ ] A restore is confirmed to reproduce windows and acknowledgements exactly ## Launch constraint Do not market omitted LeaseLineage capabilities as implemented. The non-goals in `PRODUCT.md` remain user-visible limitations until they are deliberately delivered.
# Copy to .env and fill in. Never commit .env; this file documents it. # Required. Everything. Back it up; the acknowledgement log is not reconstructable. DATABASE_PATH=./data/leaselineage.db # Required. Dated rules files per jurisdiction. Versions are added, never edited. RULES_PATH=./data/rules # Required. Used only when a lease does not state one, and recorded on the lease either way. DEFAULT_JURISDICTION=england-and-wales # Required. Must exceed the longest window in the rules file; a test asserts it does. REMINDER_MAX_HORIZON_DAYS=540 # Optional · secret. Drafting outreach copy only. Never used for anything a date depends on. MODEL_API_KEY=sk-...redacted # Optional · secret. Sending approved messages. Without it the app still drafts and tracks. SMTP_URL=smtps://user:redacted@mail.example.com:465 # Optional · secret. Reply polling. Without it sequences must be stopped by hand. IMAP_URL=imaps://user:redacted@mail.example.com:993 # Required. Across all campaigns together, not per campaign. DAILY_SEND_CAP=40
$ choose a build depth, inspect the files, then open the complete pack in your agent
They pay to keep contact discovery, inbox connections, reply attribution, sending controls, suppression, reporting, and team access working while they focus on brokerage.
xApollo and Hunter prospect data
xGmail and Outlook OAuth and reply sync
xmanaged sending and deliverability
xbrokerage team workspace
xcustom sending domains
xhosted compliance and suppression operations
LeaseLineage pricing
| plan | monthly | annual (per mo) | what you get |
|---|---|---|---|
| pro | $79 | $69 | Unlimited campaigns and contacts; single-broker plan. |
| brokerage | $249 | $199 | 3 broker seats; unlimited campaigns and contacts. |
free tierno free broker tier; tenant users can participate without a paid broker seat
billingmonthly + annual; no setup fee or long-term contract; cancel anytime
verified 2026-08-11 · source ↗
Vibecode LeaseLineage
Kinda. The core of LeaseLineage is buildable in a weekend with the prompt on this page, but there are real gaps: Apollo and Hunter prospect data, Gmail and Outlook OAuth and reply sync. Read the honest list above before committing.
How much does LeaseLineage cost?
LeaseLineage costs about $79/month (Pro, checked 2026-07-30), which is $948 per year.
What do I lose by replacing LeaseLineage?
Honestly: Apollo and Hunter prospect data; Gmail and Outlook OAuth and reply sync; managed sending and deliverability; brokerage team workspace; custom sending domains; hosted compliance and suppression operations. If any of those are load-bearing for you, keep paying.
Is there an open-source alternative to LeaseLineage?
Yes: Mautic (Open-source marketing automation platform that covers campaigns and contact management, without the commercial real estate workflow.). Using prior art is also vibecoding; the prompt is for when you want it exactly your way.