中文版

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.

  • Predecessor Slice 2 — Viewings, Messaging, and Reminders (2026-08-24-rental-platform-slice2-viewings-design)
  • ADRs 0002 · 0006
  • New ADRs 0058 · 0059 · 0060
  • Status Approved

View source markdown ↗ generated by claude-opus-5 · diagrams mermaid

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

  • Slice 3 — offers, tenancy agreements, e-signature
  • Status Design approved, not implemented
  • Blocked by Slice 2 (viewings) must merge first
  • Unblocks Slice 4 — rent payment tracking
  • Jurisdiction Malaysia — ECA 2006, LPA 1976 s.37, PDPA 2010
  • New ADRs 0058, 0059, 0060
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_contacts gains full_name, nric, address_line
New dependency
None — github.com/phpdave11/gofpdf is 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 rented listing 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 a signer_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_contacts is 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.

LayerFiles
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.gogofpdf, 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.

EventRecipient
Offer createdLandlord
Counter-offerThe other party
Re-offer after a counterLandlord
Offer declinedTenant, carrying decline_reason
Agreement issuedBoth parties
First signature recordedThe party who has not signed
Agreement completedBoth parties
Agreement voidedBoth parties, naming who voided and why
Losing offer auto-declinedEach 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_requests uses the same name for the same person; slice 1's shipped rental_properties.owner_user_id keeps 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_date not in the past; term_months between 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_sha256 is 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_name and nric are what the signer attested at the moment of signing — a different fact from what their profile holds now. full_name stores exactly what was typed, not the normalized form used to compare it.
  • ip and user_agent live 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

Offer state machine — deliberately the same shape as slice 2's request machine, minus the parts that do not apply. countered overwrites the terms in place and status says whose terms are currently on the table
Offer state machine — deliberately the same shape as slice 2's request machine, minus the parts that do not apply. 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

Agreement state machine — three statuses. "Who has signed" is answered by the signatures table, not by more enum values. Void is symmetric: either party can pull out before the agreement is complete
Agreement state machine — three statuses. "Who has signed" is answered by the signatures table, not by more enum values. Void is symmetric: either party can pull out before the agreement is complete.

Listing — one modification to slice 1

The listing machine gains a terminal rented state, entered when an agreement completes. EventComplete is accepted from three sources, not just live
The listing machine gains a terminal 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

ActionCheck
Create or withdraw an offerSigned in, and session user is tenant_user_id on a request with confirmed_time IS NOT NULL
Accept, counter, or decline an offerSigned in, and session user is landlord_user_id
Read an offer or agreement, download either PDFSigned in, and session user is one of the two parties
SignThe above, and no existing signature row for this user
VoidEither party, only while awaiting_signatures
AdminNone — no admin endpoint in slice 3, same as slice 2
Public listing APIUnchanged — 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

  1. From a confirmed viewing request, the tenant opens Make an Offer.
  2. If the tenant has no full_name, nric, or address_line on file, a short form collects them first.
  3. The form is pre-filled with the listing's asking rent and the 2 / 0.5 / 1 deposit norm. Special conditions start empty.
  4. Submit creates a rental_offers row with status pending.
  5. A rental_messages row describing the terms — including special conditions — goes into the existing thread.
  6. 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

Flow B. Steps 1–4 happen outside any transaction; steps 5–6 are one short transaction. Because the storage key is the content hash, a retry writes the same key and the orphan heals itself
Flow B. Steps 1–4 happen outside any transaction; steps 5–6 are one short transaction. Because the storage key is the content hash, a retry writes the same key and the orphan heals itself.
Source of driftPinned to
PDF creation dateissued_at — never now(), which gofpdf stamps by default
Date formattingA fixed Asia/Kuala_Lumpur, so the host's TZ cannot change the bytes
FontsA 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.

  1. 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.Storage has no signed-URL surface.

  2. Type, tick, submit

    The signer types their full name and NRIC and ticks agree.

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

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

SituationBehaviour
Offer on a request that never confirmed403 — the same predicate that gates contact reveal
Offer with no party details on the tenant422 naming the missing fields
Second live offer on the same request409, with the existing offer in the body — the partial unique index
Offer after a terminal offer on the same requestAllowed — a new row, which is how a declined offer is recovered
Malformed NRIC, or first six digits are not a real date422 at write time on rental_contacts
start_date in the past, or term_months outside 1–36422
Accept with no party details on the landlord422 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 agreement409 — the double-let index, hit inside the issue transaction
PDF render or storage write fails at issueNothing is written; offer unchanged; 502
Issue transaction fails after uploadRolls back; the orphan object is content-addressed, so the retry reuses the same key
Withdraw a listing while a non-voided agreement exists409
Complete while the listing sits at pending_review or suspendedSucceeds — EventComplete is accepted from all three sources
Second signature arrives twice concurrentlyUnique index rejects one; the loser gets 409, not a duplicate completion
Typed name or NRIC does not match the snapshot422 after normalization; no signature row written
Sign a voided or completed agreement409
Void without a reason once any signature exists422
Both parties download the composite at the same momentBoth generate; identical bytes, identical key; no error
Composite PDF generation fails on download502; 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 accepted terminal rule and symmetric void.
Offer recovery
Decline an offer, assert a new offer on the same request_id inserts; assert a second live offer is rejected by the partial unique index.
Golden PDF, two timezones
Render a fixed terms snapshot twice under two different TZ values 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_at and signatures in signed_at order.
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, and suspended; assert rented in all three. Assert EventWithdraw returns 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

  1. rental_contacts columns, NRIC normalize-and-validate on write, NRIC masking
  2. rental_offers table, the partial unique index, and the offer state machine
  3. Offer endpoints, the tenant party-details gate, thread messages, special conditions
  4. PDF renderer, the deterministic pins in Flow B, and the two-timezone golden test
  5. Agreement issue — render and upload, then the short transaction in Flow B
  6. Signing, normalized matching, completion by derived signer set, losing-offer sweep
  7. Listing rented state, EventComplete from three sources, withdraw block (modifies slice 1)
  8. Composite PDF on first download
  9. Emails — the nine methods, links only
  10. Panel UI

Risks

RiskMitigation
Legal Profession Act 1976 s.37 — drafting for rewardOne lawyer-reviewed template; the platform fills blanks and never drafts bespoke terms
NRIC is self-declared and unverifiedAccepted at this volume; identity verification is the first addition if fraud appears
The agreement is unstamped and not court-admissibleStated plainly on screen at issue and on the completed agreement, not buried in fine print
NRIC stored in plain textMasked in every API response and covered by a golden test; the full value lives only in the PDF
Email is still a log.Printf stubSlice 2 owns the real transport; if it slips, every notification here is silent
Two objects per agreementAccepted — 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 propertyOpen. Closing it needs tenancy end dates, which slice 4 owns
A typo in full_name or nric is frozen at issueValidated 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 deletionDeliberate — 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 fragmentFour 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.