DIY Rental Platform — Slice 3: Offers, Tenancy Agreements, and E-Signature
Slice 3 is where the Malaysian rental platform stops being a noticeboard and becomes the place the deal closes: a tenant with a confirmed viewing offers terms, the landlord counters or accepts, and the platform issues a lawyer-reviewed tenancy agreement that both parties sign inside the app. A grilling session on 2026-08-26 settled 23 open questions and produced three ADRs — documents frozen and content-addressed at issue, click-to-sign under the Electronic Commerce Act 2006, and one blank-filling template under the Legal Profession Act 1976 s.37.
Slice 3 is where the rental platform stops being a noticeboard and becomes the place the deal actually closes: a tenant who has viewed a property offers terms, the landlord counters or accepts, and the platform issues a lawyer-reviewed tenancy agreement that both parties sign inside the app. No agent, no lawyer for the ordinary case, and no money moving through the platform.
At a glance
- Depends on
- Slice 1 (listings, merged as
58c47ceb) and slice 2 (viewings, designed only) - New tables
rental_offers,rental_tenancy_agreements,rental_agreement_signatures- Modified tables
rental_contactsgainsfull_name,nric,address_line- New dependency
- None —
github.com/phpdave11/gofpdfis already used by the invoices module - New role token
- None — row-level checks only, as in slices 1 and 2
- Glossary
CONTEXT.md— Offer, Tenancy Agreement, Issue, Frozen Document, Void, Special Conditions, NRIC
Success criteria
- A tenant with a confirmed viewing can offer terms, and the landlord can counter or accept.
- On acceptance, the platform produces a tenancy agreement PDF filled from real data.
- Both parties sign inside the platform, and the signed PDF is downloadable by both.
- The signed document is tamper-evident: each signature records the hash of what was signed.
- The listing leaves the public marketplace as
rented. - No money moves through the platform.
Scope and non-goals
In scope
slice 3- Offer and counter-offer on a confirmed viewing request
- Party details: full name as per NRIC, NRIC, correspondence address
- Agreement generated from one fixed, lawyer-reviewed template
- Click-to-sign by both parties, with an evidence trail
- Frozen and signed documents, downloadable by the two parties only
- A new terminal
rentedlisting state
Out of scope
by decision- Stamp duty and LHDN stamping
- Money of any kind — deposits, gateways, escrow
- Tenant screening and credit checks
- Clause picker, renewal, early termination
- Handover inventory lists
- Drawn signature images; DSA 1997 certified signatures
- Third-party e-sign providers
Known gaps
accepted- Agreements are unstamped, so not court-admissible until stamped
- NRIC is self-declared and unverified
- No edit — a change means void and re-issue
- No offer expiry, no auto-decline
- One signer per side; joint tenants deferred
- Records are retained after account deletion
Why each non-goal was cut
- Stamping
- The agreement is valid between the parties but not admissible in court until stamped. Automating it means an LHDN integration with its own approvals — a project, not a feature. The platform states this plainly on screen.
- Identity verification
- NRIC is self-declared; a tenant can type any number. Screening was already out of scope in slice 1, and verification is heavy friction at this volume. This is the first thing to add if fraud appears.
- Editing an issued agreement
- There is no edit. A change means void and issue a new one, which is also how paper works.
- Offer expiry
- No timer, no auto-decline. The one-live-offer rule already bounds the mess, and a stale offer is a product problem before it is a data problem.
- Sight-unseen offers
- An offer requires a viewing request that reached confirmation — this reuses slice 2's exact predicate rather than inventing a rule.
- Joint tenants
- One signer per side. Signatures already live in a separate table keyed
(agreement_id, signer_user_id)with asigner_role, and completion derives its expected signers from the agreement row rather than counting to two — so adding co-tenants later changes that derivation and needs no migration. - Retention and account deletion
- Agreements, signatures, and both PDFs are legal records: retained, and not deleted when a user deletes their account. Only
rental_contactsis a deletable profile row. A real retention policy belongs with whichever slice first handles account deletion platform-wide.
Key decisions
Freeze the document at issue, do not render it on demand
When the landlord accepts, the PDF is rendered once, stored, and hashed. That object never changes; both parties sign that exact file. ADR-0058.
Two parties sign at different times — that is the normal case. If the template or the rendering code changes between the first and second signature, the two parties signed different documents, and "what exactly did I sign?" has no reliable answer. The freeze is what makes the audit trail worth anything; without it, the trail records signatures against a moving target.
Rejected: render on demand from terms (cheaper, but loses the guarantee the moment
the renderer changes); render on demand with a template registry (fixes correctness, at the cost
of a registry and rendering code that must stay bug-compatible with its own past).
Click-to-sign, not drawn signatures and not PKI
Each party types their full name and NRIC, ticks agree, and submits. The platform records user_id, timestamp, IP, user-agent, and the SHA-256 of the document they saw. ADR-0059.
This is valid under the Electronic Commerce Act 2006, which does not exclude tenancy agreements. What makes the trail persuasive is not the look of the signature but the ability to show what document was signed, by which account, and when.
Rejected: drawn signature images (familiar, but no legal weight and real frontend cost); DSA 1997 certified signatures (stronger evidence, but every party needs a CA certificate, which would kill sign-up); a third-party provider such as DocuSign or Zoho Sign (fast to build, but charges per tenancy and moves the document flow — and both NRICs — off the platform).
One fixed template plus free-text special conditions
The platform fills blanks in a form reviewed once by a lawyer. It does not draft bespoke terms. ADR-0060.
Under the Legal Profession Act 1976 s.37, filling blanks in a standard form is a materially different position from drafting, and it is the safer one. A clause picker looks like a feature but reads like drafting: the moment the platform selects between legal wordings on a customer's behalf, the character of what it is doing changes.
Rejected for now: a clause picker over a reviewed clause library — a natural later addition once
special_conditions shows what landlords keep typing. The free-text field is the
research instrument.
The offer hangs off the viewing request
rental_offers.request_id is a foreign key with a partial unique index — unique only while status IN ('pending','countered') — and the request must have confirmed_time IS NOT NULL.
This buys three things at once: the rental_messages conversation simply continues, so
there is no second thread concept; both parties already hold each other's contacts; and "no
offers without a viewing" is enforced without writing a rule for it.
Rejected: a fully unique request_id. It would allow exactly one offer per viewing,
ever — so a landlord who declines RM 1,600 as too low would permanently block the tenant from
coming back at RM 1,750, and the only escape would be waiting out slice 2's 24-hour sweep,
booking a second viewing for a property already visited, and asking the landlord to confirm it
again.
Architecture
The module at internal/modules/rental/ is real code, not a plan. Slice 1 shipped the
hexagonal layout, the functional-option wiring in module.go, the
fn_audit_trail() trigger registration (ADR-0002), and a
storage.Storage that deliberately exposes no signed URLs. Slice 3
fills in more of the same shape and invents nothing new at the wiring layer.
| Layer | Files |
|---|---|
domain/entity/ | offer.go, agreement.go — transition functions and validation, pure |
application/port/ | AgreementStore and AgreementRenderer added to ports.go; Mailer gains nine methods |
application/usecase/ | offer.go, agreement.go, signing.go |
adapter/inbound/http/ | offer_handler.go, agreement_handler.go |
adapter/outbound/pdf/ | renderer.go — gofpdf, mirroring the invoices module's PDF adapter |
HTTP surface
POST /api/rental/viewing-requests/:id/offers tenant creates an offer
GET /api/rental/offers/:id either party
POST /api/rental/offers/:id/counter landlord
POST /api/rental/offers/:id/reoffer tenant
POST /api/rental/offers/:id/accept landlord → issues the agreement
POST /api/rental/offers/:id/decline landlord
POST /api/rental/offers/:id/withdraw tenant
GET /api/rental/agreements/:id either party, NRIC masked
GET /api/rental/agreements/:id/document frozen PDF, streamed
GET /api/rental/agreements/:id/signed-document composite, lazily generated
POST /api/rental/agreements/:id/sign either party
POST /api/rental/agreements/:id/void either party, reason required
GET /api/rental/me/agreements list, for the panel's landing view
counter and reoffer are separate routes rather than one PATCH,
because they carry different authorization and different transitions; collapsing them produces one
handler branching on who called it. Offers are always reached through their viewing
request and have no list endpoint of their own, which keeps "the offer hangs off the
viewing request" true in the API shape and not only in the schema.
Emails
Nine methods on Mailer. Every one carries a link to the authorizing Go handler — never a PDF attachment.
| Event | Recipient |
|---|---|
| Offer created | Landlord |
| Counter-offer | The other party |
| Re-offer after a counter | Landlord |
| Offer declined | Tenant, carrying decline_reason |
| Agreement issued | Both parties |
| First signature recorded | The party who has not signed |
| Agreement completed | Both parties |
| Agreement voided | Both parties, naming who voided and why |
| Losing offer auto-declined | Each losing tenant |
Withdrawing an offer sends nothing — the thread already shows it, and the landlord lost nothing.
Data model
Three new tables, three new columns on an existing one. All in migrator_rental.go.
rental_offers
id, request_id, listing_id, tenant_user_id,
landlord_user_id, monthly_rent, start_date,
term_months, security_deposit_months,
utility_deposit_months, advance_rent_months,
special_conditions, status, decline_reason,
last_actor_user_id, created_at, updated_at
CREATE UNIQUE INDEX idx_rental_offers_live
ON rental_offers (request_id)
WHERE status IN ('pending', 'countered');
landlord_user_id- Denormalized so row-level authorization needs no join. Slice 2's
rental_viewing_requestsuses the same name for the same person; slice 1's shippedrental_properties.owner_user_idkeeps its own name, because it records who owns the property — a different relation from who is the landlord on a tenancy. special_conditions- Free text, a negotiated term like any other. Whoever counters may change it, and every change lands in the message thread.
decline_reason- Nullable: optional when a landlord declines, and set to a server-formatted string when the completion sweep auto-declines losing offers. There is deliberately no
withdraw_reason. - Deposit defaults
- The Malaysian norm — 2 months security, 0.5 utility, 1 advance — negotiable like rent. Ringgit amounts are never stored: they are
monthly_rent × months, computed at render time. - Validation
start_datenot in the past;term_monthsbetween 1 and 36.
rental_tenancy_agreements
id, offer_id (unique), listing_id, property_id,
landlord_user_id, tenant_user_id, template_version,
terms (jsonb), document_key, document_sha256,
signed_document_key, status, issued_at,
completed_at, voided_at, void_reason,
created_at, updated_at
CREATE UNIQUE INDEX idx_rental_agreements_live_listing
ON rental_tenancy_agreements (listing_id)
WHERE status <> 'voided';
document_key points at the frozen unsigned PDF and is the SHA-256 of its bytes.
signed_document_key points at the final composite, is likewise content-addressed, and
is null until someone first downloads it.
rental_agreement_signatures
id, agreement_id, signer_user_id, signer_role
(landlord or tenant), full_name, nric,
document_sha256, signed_at, ip, user_agent
- Unique on
(agreement_id, signer_user_id)— that constraint is the whole double-sign guard, not application logic. document_sha256is stored per signature because it records the hash the signer actually saw. If the two ever disagree, the document moved, and that becomes detectable rather than deniable.full_nameandnricare what the signer attested at the moment of signing — a different fact from what their profile holds now.full_namestores exactly what was typed, not the normalized form used to compare it.ipanduser_agentlive here and only here. The composite PDF does not print them.
rental_contacts and NRIC handling
Three new columns: full_name, nric, address_line.
full_name is the name as printed on the NRIC, which is not always
users.name. This is a real column, not a copy of Better Auth data, and ADR-0006
forbids writing back to users in any case.
NRIC is sensitive personal data under the PDPA 2010. It is stored in plain text —
the whole database is one Postgres and nothing else in rental is encrypted — but every API response
masks it as ******-**-**34. The birth date is masked along with everything else: an
NRIC is YYMMDD-PB-###G, so leaving the first six digits visible would publish a full
date of birth, which is itself personal data. The last two digits are enough for a person to
confirm the number is theirs.
The full value appears only inside the generated PDF, and the PDF is never emailed as an attachment. Masking the API and then mailing the same number to two inboxes would throw the whole mitigation away.
Audit triggers
rental_offers and rental_tenancy_agreements get
fn_audit_trail() triggers, registered beside the existing
trg_audit_rental_properties (ADR-0002). rental_agreement_signatures does
not: those rows are append-only, so a trigger would only duplicate them.
State machines
Offer
countered overwrites the terms in place and status says whose terms are currently on the table.
Every counter and re-offer also inserts a rental_messages row carrying the new terms,
so the thread keeps the full negotiation history even though the columns hold only the current
position.
Agreement
Listing — one modification to slice 1
rented state, entered when an agreement completes. EventComplete is accepted from three sources, not just live.Why rented rather than reusing withdrawn, and why no reserved state
withdrawn would technically work and cost nothing, since the public marketplace
filters on live and both values drop out. It is rejected because a landlord's own
panel would then show "withdrawn" for a property they successfully rented out. One enum value
buys honest data.
The listing also stays live while signatures are pending — no reserved
state is added. Other tenants can keep booking viewings and making offers on a property that is
90% gone, and the landlord simply gets a 409 if they try to accept one; the double-let guard
already covers correctness. A second enum value would buy a reversible state and two more
transitions to make a panel label honest, and the panel can say "agreement out for signature"
for free.
The public marketplace needs no change: it already 404s anything that is not live
with a "no longer available" page, so rented drops out of the public surface for
free. Re-letting later is not blocked either — slice 1 already treats a property as re-listed
every time a tenancy ends, and rented counts as terminal for the
one-non-terminal-listing rule, so the owner creates a new listing rather than reusing the old one.
Authorization
| Action | Check |
|---|---|
| Create or withdraw an offer | Signed in, and session user is tenant_user_id on a request with confirmed_time IS NOT NULL |
| Accept, counter, or decline an offer | Signed in, and session user is landlord_user_id |
| Read an offer or agreement, download either PDF | Signed in, and session user is one of the two parties |
| Sign | The above, and no existing signature row for this user |
| Void | Either party, only while awaiting_signatures |
| Admin | None — no admin endpoint in slice 3, same as slice 2 |
| Public listing API | Unchanged — slice 1's golden test still guards it |
No new role token, no RequireOrgRole, no Better Auth change. Row-level checks only, exactly as in slices 1 and 2.
Flows
A — Tenant offers
- From a confirmed viewing request, the tenant opens Make an Offer.
- If the tenant has no
full_name,nric, oraddress_lineon file, a short form collects them first. - The form is pre-filled with the listing's asking rent and the 2 / 0.5 / 1 deposit norm. Special conditions start empty.
- Submit creates a
rental_offersrow with statuspending. - A
rental_messagesrow describing the terms — including special conditions — goes into the existing thread. - The landlord is emailed.
Counters and re-offers repeat steps 4–6 against the same row, with last_actor_user_id recording whose terms are on the table.
B — Landlord accepts, agreement is issued
| Source of drift | Pinned to |
|---|---|
| PDF creation date | issued_at — never now(), which gofpdf stamps by default |
| Date formatting | A fixed Asia/Kuala_Lumpur, so the host's TZ cannot change the bytes |
| Fonts | A font file committed to the repo, never a system font path |
| Signature ordering (composite) | signed_at, with signer_role as the tie-break — Go map iteration order is random |
C — Signing
Either party may sign first; order does not matter.
-
Download the PDF
Served by a Go handler that checks the caller is one of the two parties — there is no public URL, because
storage.Storagehas no signed-URL surface. -
Type, tick, submit
The signer types their full name and NRIC and ticks agree.
-
Match against the snapshot, then insert
The use case checks the typed values, then inserts a signature row carrying
document_sha256, IP, and user-agent. -
The unique index rejects a second attempt
(agreement_id, signer_user_id)is the guard, not application logic.
D — Completion
A signature insert completes the agreement when every expected signer has a row.
The expected set is derived from the agreement's own landlord_user_id and
tenant_user_id — not from a hardcoded count of two — so adding joint tenants later
changes that derivation and nothing else. The completing insert also sets completed_at,
sweeps the losing offers, and moves the listing to rented, in the same transaction.
The signature page prints each party's name, the NRIC they attested, the timestamp with timezone, and the document SHA-256. It does not print IP addresses or user-agents. Those stay in the database, to be produced if there is ever a dispute. The evidence is there either way; printing an IP in a file both parties may forward to an agent or a group chat hands each of them a piece of the other's network identity for no legal gain.
Error handling
| Situation | Behaviour |
|---|---|
| Offer on a request that never confirmed | 403 — the same predicate that gates contact reveal |
| Offer with no party details on the tenant | 422 naming the missing fields |
| Second live offer on the same request | 409, with the existing offer in the body — the partial unique index |
| Offer after a terminal offer on the same request | Allowed — a new row, which is how a declined offer is recovered |
| Malformed NRIC, or first six digits are not a real date | 422 at write time on rental_contacts |
start_date in the past, or term_months outside 1–36 | 422 |
| Accept with no party details on the landlord | 422 naming the missing fields |
| Accept with the tenant's details missing (raced) | 422 from the defensive re-check inside issue |
| Accept on a listing that already has a non-voided agreement | 409 — the double-let index, hit inside the issue transaction |
| PDF render or storage write fails at issue | Nothing is written; offer unchanged; 502 |
| Issue transaction fails after upload | Rolls back; the orphan object is content-addressed, so the retry reuses the same key |
| Withdraw a listing while a non-voided agreement exists | 409 |
Complete while the listing sits at pending_review or suspended | Succeeds — EventComplete is accepted from all three sources |
| Second signature arrives twice concurrently | Unique index rejects one; the loser gets 409, not a duplicate completion |
| Typed name or NRIC does not match the snapshot | 422 after normalization; no signature row written |
| Sign a voided or completed agreement | 409 |
| Void without a reason once any signature exists | 422 |
| Both parties download the composite at the same moment | Both generate; identical bytes, identical key; no error |
| Composite PDF generation fails on download | 502; the agreement stays complete and the next download retries |
Testing
- State machines
- Table-driven tests over every valid and invalid transition on both offers and agreements, including the
acceptedterminal rule and symmetric void. - Offer recovery
- Decline an offer, assert a new offer on the same
request_idinserts; assert a second live offer is rejected by the partial unique index. - Golden PDF, two timezones
- Render a fixed
termssnapshot twice under two differentTZvalues and assert one SHA-256. A single-render test would pass while the thing most likely to break went undetected. - Composite determinism
- The same test for the composite, with its creation date pinned to
completed_atand signatures insigned_atorder. - Concurrency
- Fire both signatures simultaneously; assert exactly one completion, one listing transition, and one 409.
- Double-let
- Accept two offers on one listing concurrently; assert exactly one agreement is issued and the other caller gets 409.
- Issue atomicity
- Inject a storage failure at step 4 of Flow B; assert no agreement row and an unchanged offer, then assert the retry writes the same storage key.
- Listing transitions at completion
- Complete an agreement whose listing sits at
live,pending_review, andsuspended; assertrentedin all three. AssertEventWithdrawreturns 409 while a non-voided agreement exists. - Signature matching
- A name differing only in case or whitespace signs successfully; an NRIC differing only in dashes signs successfully; a genuinely different value is refused with no row written.
- NRIC masking
- A golden test asserting no endpoint ever returns an unmasked NRIC, including any digit of the birth date, in the same spirit as slice 1's public-listing-API golden test.
- No attachments
- Assert every mailer call carries a link and no PDF body.
- Void evidence
- Void after one signature; assert the reason is required, the signature row survives, and both parties are notified.
- Authorization
- A third signed-in user is refused on every endpoint.
Delivery order
rental_contactscolumns, NRIC normalize-and-validate on write, NRIC maskingrental_offerstable, the partial unique index, and the offer state machine- Offer endpoints, the tenant party-details gate, thread messages, special conditions
- PDF renderer, the deterministic pins in Flow B, and the two-timezone golden test
- Agreement issue — render and upload, then the short transaction in Flow B
- Signing, normalized matching, completion by derived signer set, losing-offer sweep
- Listing
rentedstate,EventCompletefrom three sources, withdraw block (modifies slice 1) - Composite PDF on first download
- Emails — the nine methods, links only
- Panel UI
Risks
| Risk | Mitigation |
|---|---|
| Legal Profession Act 1976 s.37 — drafting for reward | One lawyer-reviewed template; the platform fills blanks and never drafts bespoke terms |
| NRIC is self-declared and unverified | Accepted at this volume; identity verification is the first addition if fraud appears |
| The agreement is unstamped and not court-admissible | Stated plainly on screen at issue and on the completed agreement, not buried in fine print |
| NRIC stored in plain text | Masked in every API response and covered by a golden test; the full value lives only in the PDF |
Email is still a log.Printf stub | Slice 2 owns the real transport; if it slips, every notification here is silent |
| Two objects per agreement | Accepted — the frozen object is the legal record and the composite is a convenience that can always be regenerated |
| Double-let across two listings for one property | Open. Closing it needs tenancy end dates, which slice 4 owns |
A typo in full_name or nric is frozen at issue | Validated and normalized at write time; signing compares normalized values so formatting alone never locks anyone out. A genuine typo still means void and re-issue |
| Agreements and NRIC are retained after account deletion | Deliberate — they are legal records. A platform-wide retention policy is owned by whichever slice first handles account deletion |
| The renderer must stay byte-deterministic or storage keys fragment | Four drift sources pinned and asserted by a two-timezone golden test |
What this unblocks
Slice 4 — rent payment tracking, the reminder ladder, late-notice PDFs — needs a tenancy with known
parties, a known rent, a known start date, and a known term. A completed
rental_tenancy_agreements row is exactly that record. The PDF renderer built here is
also what slice 4's late notices reuse, and the per-property overlap gap left open in this slice is
the first thing tenancy end dates will close.