Rental Frontend — Slices 3, 4, and 5 (Panel)

Design for three sequential OpenSpec changes (rental-slice3-panel, rental-slice4-panel, rental-slice5-panel) that build the offers/agreements, ledger/payments, and move-in handover screens in the SolidStart panel at frontend/solidstart/apps/panel. It resolves demo-versus-backend conflicts, keeps all slice 1 routes untouched, and records four missing backend capabilities plus three blocking backend defects. Release is blocked until rental-slice2-panel is built, PR #109 lands, and the backend prerequisites merge.

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

Summary

Three sequential SolidStart panel changes — rental-slice3-panel, rental-slice4-panel, rental-slice5-panel — build the twelve rental screens that already have backend support but no frontend code. Together they take the platform from "a renter can ask to view a property" through offers, e-signature, rent ledger, payment proof, and a finalized move-in report. The design is settled; release is blocked on four missing backend capabilities and three backend defects.

  • Area frontend/solidstart/apps/panel
  • Delivery Three OpenSpec changes, merged in order 3 → 4 → 5
  • Surface Landlord /owner/* plus renter /tenant/*, one responsive panel
  • Reference 14-screen demo — landlord 01–09, tenant 10–14
  • Reviewed 2026-09-05 grill session, twelve settled decisions
  • Release state Blocked — 4 backend capabilities, 3 backend defects

What exists today

Backend slices 1–4 are merged on main; slice 5 is complete on the still-open PR #109 branch. Twelve of the fourteen demo screens have no frontend code at all.

Demo screenBackendFrontend
01 Landlord dashboardSlices 1–4 + new summary readNone. /owner is the listing grid and stays so
02 My listingsSlice 1Built
03 Viewing requestsSlice 2Specced, 0/19 tasks
04 Message threadSlice 2Specced, not built
05 OffersSlice 3 + GET /me/offersNone
06 Tenancy agreementSlice 3 + signatures[]None
07 Rent ledgerSlice 4None
08 Payment reviewSlice 4None
09 Handover builderSlice 5 (PR #109, open)None
10 My tenancySlice 4None
11 My viewingsSlice 2Specced, not built
12 Sign agreementSlice 3None
13 Pay rent + proofSlice 4None
14 Handover reviewSlice 5 (PR #109, open)None

Scope

In scope

this work
  • Demo screens 05–10 and 12–14
  • /owner navigation grows from one item to six
  • New /owner/dashboard route
  • The tenant tenancy surface under /tenant/*

Out of scope

explicitly excluded
  • Building rental-slice2-panel — a prerequisite, not part of this work
  • The Astro public marketplace
  • A Flutter tenant app
  • In-app notifications
  • Any change to slice 1's built listing screens

Scoped shell adjustment

the one exception
  • OwnerHeader keeps its identity and logout
  • Its single Properties link moves into the new navigation row so the link is not duplicated
  • No slice 1 route moves and no slice 1 URL changes

Core decisions

Where does the tenant surface live?

The panel, responsive. Demo screens 10–14 are drawn as phones but ship at /tenant/* in the SolidStart panel, mobile-first.

mobile/flutter has no rental package and frontend/astro has no rental pages, so the panel is the only existing home. One codebase, one session, one proxy. This continues what rental-slice2-panel already decided.

Rejected: a Flutter tenant app, an Astro renter surface.

Whose design system wins — the demo's or the panel's?

The panel's. Screens use @monorepo/solid-pkg-ui and the glass system, exactly as slice 1's built screens do.

The demo's warm paper palette (sand #F7F5F1, deep green #0E6B57, serif headings) would make rental pages look unlike every other panel page and force a restyle of slice 1.

What is taken from the demo: information architecture, screen flow, wording, and state badges. Where the demo and the backend disagree, the backend wins.

Where does the landlord dashboard live, given /owner is the listing grid?

A new /owner/dashboard route. /owner stays the listing grid and /owner/:id stays the property detail.

Preserving working URLs outranks matching the demo's root URL. Landlords already hold /owner and /owner/:id links.

Rejected: displacing the grid; amending slice 2 to absorb the dashboard.

What shape does the /owner navigation take?

A separate responsive horizontal row inside the existing owner shell, below OwnerHeader — mirroring the demo's appnav. No sidebar, no hamburger.

Slice 1 deliberately chose a lighter consumer-facing shell over the org sidebar. Adopting packages/ui/src/components/layout/Sidebar.tsx now would restyle every built slice 1 screen.

The nav grows slice by slice and is complete after slice 4 — no change ships a nav item that goes nowhere.

ChangeNav items addedReached instead from
Slice 3Dashboard, Properties, Viewings, Offers
Slice 4Tenancies, Payments
Slice 5NoneHandover from tenancy detail; agreements from offers and /tenant/agreements

How is the API client organised?

Split per domain: api/offers.ts, api/agreements.ts, api/tenancies.ts, api/payments.ts, api/handover.ts, over a shared fetch helper.

features/rental/api.ts is already 381 lines for slice 1 alone, and slices 3–5 add roughly 45 endpoints. Because the three changes merge sequentially, each owning its own files means they never conflict.

Slice 1's file is left alone.

Demo versus backend, resolved

The demo was drawn before the backend settled. Seven conflicts are resolved in the backend's favour, and anyone comparing the built product against the mockup will find these differences by design rather than by regression.

Four demo depictions replaced by their backend-backed equivalents: a signature canvas becomes a typed attestation, per-entry agreement becomes one report-level acceptance, the 92 percent confidence figure becomes a verdict badge, and a seven-term comparison becomes four compared terms. Demo drew Ships as Signature canvas Typed attestation Per-entry agree One acceptance 92% confidence Verdict badge 7-term compare 4 terms compared
Four of the seven demo-versus-backend conflicts, before and after resolution.
ConflictDemo drewResolution
Signature A canvas — "draw your signature with your finger", with a Clear button The backend stores no signature image. POST /agreements/:id/sign takes four fields — full_name, nric, address_line, agree. agree is an explicit unchecked consent checkbox with its own sentinel ErrConsentRequired
Handover agreement Agree or dispute, entry by entry Five actions and no per-entry acceptance: dispute_entry, report_omission, reply, resolve, withdraw. Each entry gets a Dispute action; the report gets one Accept button, disabled with a stated reason while any thread is open. The demo omits omission-reporting entirely; the UI adds it
Unread badge "Messages 3" on the dashboard Dropped. There is no in-app notification or unread system — slice 3 and 4 notifications are email-only. The dashboard shows a needs-your-answer count instead
AI consent An AI on/off toggle on the handover screen, no consent step in the pay flow The pay wizard gains a consent disclosure, because the pay flow is exactly where the tenant's slip image is uploaded and the backend requires versioned informed consent (知情同意) before any image is sent
Confidence "92% confidence" on screens 01 and 08 Demoted, not deleted. ReceiptExtraction.Confidence is the model's own self-reported number, defaulted to 0.5 when omitted, and never mentioned by the OpenSpec specs. It is not rendered at all. The trustworthy signals are ai_agreement and unreadable_fields
Tenancy sub-nav Five tabs — Overview · Ledger · Payment records · Handover · Documents Not reproduced. GET /tenancies/:id/charges returns a combined TenancyLedger — tenancy, charges, records, and allocations in one payload — so record history is built from that response. Tenancy detail carries the ledger and the handover entry point
Offer terms Seven terms compared against the listing Four are compared. The listing does not carry seven asking values

Slice 3 — offers and agreements

Demo screens 05, 06, 12.

RoutePurpose
/owner/dashboardLandlord overview. Tiles ship as their contracts land
/owner/offersOffer queue, oldest first, live and history
/owner/offers/:idOffer detail with terms comparison
/owner/agreements/:idAgreement, signature list, documents
/tenant/agreementsThe renter's agreements
/tenant/agreements/:idRead, then sign
/tenant/requests/:id/offerMake or counter an offer

/owner and /owner/:id are untouched slice 1 routes.

Offer states through to agreement completion. Acceptance issues the agreement; the competing-offer sweep happens only when signatures complete
Offer states through to agreement completion. Acceptance issues the agreement; the competing-offer sweep happens only when signatures complete.

Offer queue

Fed by GET /me/offers, a backend prerequisite. The endpoint is party-scoped and role-agnostic like /me/agreements and /me/tenancies: the owner queue filters landlord_user_id == me, the tenant view filters tenant_user_id == me, and that filtering is presentation, not authorization. Ordering is oldest first and deterministic. Status filtering is server-side so live and history are separate reads. Counts are never derived from a truncated page.

Offer terms comparison

Offer termListing fieldTreatment
monthly_rentmonthly_rentCompared, difference marked
term_monthstenancy_monthsCompared, difference marked
security_deposit_monthsdeposit_monthsCompared, difference marked
utility_deposit_monthsutility_depositCompared, difference marked
start_dateavailable_fromContext only. available_from is the earliest date the property can be occupied; a later proposed start is normal, not a deviation. No difference badge
advance_rent_monthsProposed-only row, no comparison
special_conditionsProposed-only row, no comparison

An absent or unspecified asking value renders as "Not specified", never as a zero that would imply a negotiated term. The comparison context is the current listing, not a historical negotiation snapshot; the screen never fabricates a past asking value from a present one. Offers have five statuses and five actions mapping 1:1 to endpoints, and no expiry, so there is no countdown.

Two gates before an offer can be created

  • ErrViewingNotConfirmed The offer form is only reachable from a confirmed viewing 1
  • ErrMissingPartyDetails Full name and NRIC must be collected first, via PUT /me/contact 2

Sign screen

Money terms first, legal text second, then the attestation. The frozen party name and address render as an agreement summary. full_name and nric are typed into blank attestation inputs. address_line is displayed and submitted read-only from the matching party snapshot. agree is an explicit unchecked checkbox. The raw NRIC is never prefilled — it is masked everywhere it appears — and a later /me/contact address is never used, because the snapshot is frozen at issue and a newer address would fail the comparison with no in-UI remedy.

Name normalization
Server-side: uppercased and space-collapsed
NRIC normalization
Server-side: reduced to digits
Address normalization
Server-side: whitespace-collapsed but case-sensitive
Mismatch transport
422 with a flat message — the existing transport, unchanged

The panel shows a 422 failure inline at the attestation form with the server's explanation, and attaches it to a specific field only when the field is unambiguously identified. It does not treat every 422 as an NRIC error and does not parse arbitrary error prose as though it were a stable structured API. There is no blanket promise of per-field errors and no new backend error-shape endpoint.

If the frozen details are wrong, the screen explains that editing the contact will not change this agreement. Either party may void while awaiting_signatures, with a reason; the user then corrects their own contact and a fresh offer produces a fresh agreement. There is no reissue endpoint and none is invented.

Agreement detail and signatures

Three agreement states only: awaiting_signatures, completed, voided. Who has signed is answered by a sanitized signatures[] array embedded on GET /agreements/:idsigner_role, user_id, signed_at, and the document hash, with no raw NRIC, IP address, or user agent. Signer names come from the snapshot (terms.landlord.full_name, terms.tenant.full_name) joined on signer_role.

Who signed and when is the primary information. The document hash is secondary evidence, shown in an expandable details area if shown at all. There is no mismatch state and no unexplained truncated hash badge. After signing or voiding, the screen refetches the detail rather than inferring state from a mutation response that carries no signature summary.

Slice 3 endpoints

POST /requests/:id/offers, GET /offers/:id, POST /offers/:id/{counter,reoffer,accept,decline,withdraw}, GET /me/offers (new), GET /me/agreements, GET /agreements/:id with signatures[] (new), GET /agreements/:id/document, GET /agreements/:id/signed-document, POST /agreements/:id/sign, POST /agreements/:id/void, GET|PUT /me/contact.

Slice 4 — ledger and payments

Demo screens 07, 08, 10, 13. The largest of the three changes.

RoutePurpose
/owner/tenancies · /owner/tenancies/:idTenancy list and rent ledger
/owner/paymentsReview queue, oldest first, with claim age
/owner/payments/:idPayment review detail
/tenant/tenanciesThe renter's tenancies
/tenant/tenancies/:idTenancy home — next amount, next date
/tenant/tenancies/:id/payThree-step payment wizard

The slice 2 /tenant root and /tenant/requests are preserved. Every tenancy-scoped route names the tenancy in the path, and every fetched record is verified to belong to it. Zero tenancies shows a clear empty state linking to viewings and agreements. Several tenancies stay as separate cards; the UI never auto-picks one.

Two money units are on the wire, not one

UnitJSON fieldWhere
Whole ringgit (integer)monthly_rentlisting.go, offer.go, agreement.go (AgreementTerms)
Sen (integer)amount_senCharge, PaymentRecord, PaymentAllocation, ReceiptExtraction
The same wire value 2800 rendered two ways: passing a whole-ringgit value through the sen formatter yields RM 28.00, a hundred times too small, while the ringgit formatter yields the correct RM 2,800.00. Wrong Right formatSen 2800 formatRinggit 2800 RM 28.00 RM 2,800.00
The 100× trap. monthly_rent arrives as whole ringgit; only formatRinggit reads it correctly.

Conversion happens exactly once, server-side, when the charge schedule is generated. The panel preserves wire units and never converts on ingest or on a mutation round trip. Two explicit formatters, formatRinggit and formatSen, with unit-labelled API types and field names. Regression examples: formatRinggit(2800) and formatSen(280000) must both render RM 2,800.00. Branded money types are deliberately not used — the slice 1 API is unbranded and consistency is worth more here.

Selected-charge sums and payment inputs stay integer sen. Decimal RM input is parsed explicitly, without floating-point rounding drift. Deposit months (security_deposit_months, utility_deposit_months, advance_rent_months) are decimal multipliers displayed as months, not as currency, at the server contract's precision and defaults. term_months stays an integer. The panel adds no deposit rule stricter than the backend's.

Ledger

GET /tenancies/:id/charges returns the combined TenancyLedger: tenancy, charges, records, and allocations. Charges have three types (security_deposit, utility_deposit, rent) and three statuses (open, paid, cancelled). The whole fixed-term schedule is generated once at establishment, so the landlord sees the entire tenancy on one page.

A landlord can cancel a charge with a reason, reopen it with a reason, and generate a late notice (POST /charges/:id/late-notice, streamed from /late-notices/:id). Cancellation stops reminders; reopening does not resurrect them.

Tenancy list labelling. GET /me/tenancies returns bare tenancies with property_id but no address. The address is obtained by joining the frozen property snapshots in GET /me/agreements to GET /me/tenancies on agreement_idtwo collection reads, not one read per row. This applies to both the owner and renter lists. Next-payment amount and date live on tenancy detail, fetched from its ledger; the lists do not make one ledger call per card to decorate themselves.

Payment review

Five record statuses — draft, pending_review, confirmed, rejected, reversed — presented separately. The demo's "every reason to hesitate" is genuinely backed by three separate signals:

  • ai_agreement agrees | differs | fields_unreadable | not_applicable headline badge 4
  • unreadable_fields Names each field the model could not read, rather than inferring it n
  • duplicate_signals[] {signal_type, detail} — deterministic duplicate detection, rendered as a list n

Confirm and reject act on the record as a whole. A confirmed record can be reversed with a reason, never deleted. Terminal states are conflict-safe and repeat-safe, so a stale tab that double-confirms gets a clean conflict message rather than an error page. Only a landlord decision marks a charge paid — the AI never blocks or approves.

Tenant pay wizard and the consent handshake

The wizard is server-backed from step 1, because proof upload requires an existing record.

  1. Step 1 — create a validated draft

    The panel mints one UUIDv7 before the create call and keeps it for the life of that logical operation. Only the draft and tenancy identifiers live in route state, for reload recovery; the server ledger owns saved values and the proof. On an uncertain create or submit outcome, the panel GETs the same identifier before attempting another mutation, and never mints a fresh identifier merely because a response was lost.

  2. Step 2 — optionally upload a JPEG or PNG proof

    Sent with the current consent choice. Selected charges must be whole and open, and their sum must equal the amount exactly; there are no partial charges. A stale-charge conflict is handled by refreshing and reselecting.

  3. Step 3 — save final values via PUT, then submit explicitly

    Submission is a distinct user action, never implied by saving.

Tenant pay wizard with the AI consent handshake. The panel never picks or deletes a draft on the user's behalf
Tenant pay wizard with the AI consent handshake. The panel never picks or deletes a draft on the user's behalf.

If AI is unavailable, the tenant declines, the configuration is stale, or extraction fails, the wizard runs fully manual with a neutral "AI not used" message. The backend treats that as not_applicable, not an error. Images are never silently resubmitted to a newly configured provider.

Dashboard

Fed by GET /landlord/dashboard-summary, which returns aggregates and counts with a server as_of timestamp and without N ledger reads.

TileDefinition
Live listingsCount of listings with status live — listings, not properties
Needs your answerActionable viewing requests, owner-turn offers, and pending payment reviews, each with a clear breakdown and no entity counted twice. Owner-turn is decided by last_actor_user_id and the use-case authorization rules, not by counting every pending plus countered offer. Signing actions are included only if shown as their own line
Confirmed this monthSum of amount_sen, once per currently confirmed payment record whose reviewed_at falls in the current KL calendar month
OverdueSum of amount_sen plus a charge count, over open overdue charges, using the same KL rule as the ledger

Slice 5 — move-in handover

Demo screens 09 and 14. Blocked on PR #109 landing. The spec is written against the branch; the build waits for the merge. The branch's drift from main is not recorded here because any such figure goes stale.

Routes: /owner/tenancies/:id/handover (builder) and /tenant/handover/:id (review). Neither adds a top-level nav item.

Report lifecycle. Deadline finalization freezes unresolved and no-response positions as they stand; the UI never labels silence as acceptance
Report lifecycle. Deadline finalization freezes unresolved and no-response positions as they stand; the UI never labels silence as acceptance.

Builder

Areas hold entries of four types: condition, inventory, access_device, and meter_reading.

Media: images only, no video. Formats and limits follow the slice 5 contract, with distinct guidance for HEIC. Thumbnails render by default; the original downloads only on an explicit request. Media runs uploadingreadyfailed, staged then attached. upload_request_id is stable across retries of the same upload; persisted media is recovered through the report detail; a 410 after staged cleanup requires a fresh upload. low_resolution_warning is informational and never blocks submission.

Two submission gates the builder mirrors

SubmitBaseline enforces ErrAreaRequiresImage and ErrMeterRequiresImage. The builder shows named area and meter completeness with links to the offender, and disables submit while required media are absent or still processing. The mapping matches the code exactly:

Image attachmentSatisfies area gateSatisfies meter-entry gate
Directly area-attached, readyYesNo
Directly entry-attached, readyNoYes
Staged or unattachedNoNo
Still uploadingNot yet — "processing" affordance, not an errorNot yet — "processing" affordance, not an error

The backend still enforces every gate, and a conflict is surfaced inline after a refresh. This mirrors the server predicate early; it does not replace it. After submit the baseline is immutable, and review responses are append-only.

AI drafts; users author

Consent is per report and per party and versioned per uploader (POST / GET / DELETE /handover-reports/:id/ai-consent), so the demo's AI on/off toggle maps cleanly onto grant and withdraw. POST /handover-media/:id/analyze returns a suggestion with status succeeded, unavailable, failed, or invalid — four states the UI handles, not just success. Suggested fields arrive as separate nullable records and nothing is copied into the report until the user applies or edits it. ai_assisted marks applied text and stays set after editing, which is exactly the demo's "AI draft, edited by you · original kept". Withdrawal stops future calls and deletes nothing. Handover owns its withdraw action; payments have none.

Thread rules and acceptance

Dispute threads. Accept is enabled only when no thread has status open ; threads may be opened until finalization and never after
Dispute threads. Accept is enabled only when no thread has status open; threads may be opened until finalization and never after.

The server ships the reduction: detail.threads[] carries thread_id, target_type, target_id, status, opened_by, last_actor_id, last_action, and events[]. The panel derives button availability only from those shipped fields — whose turn it is from last_actor_id plus the no-self-resolve rule, and can-accept from "no thread has status open". It renders events[] for display and never re-reduces them.

  1. ErrOpenThreadBlocksAcceptance

    Checked first. Resolve the open thread before acceptance is possible.

  2. ErrStaleReviewedSnapshot

    A stale 409. The screen shows the refreshed state and requires a new explicit acceptance. Acceptance is never retried automatically.

The two blockers must not share a message. Expired and finalized reports get their own distinct messages. Refresh policy: refetch after a mutation and on window focus. No live-update infrastructure is required.

Cross-cutting

Shared primitives

Money
Two formatters, formatRinggit and formatSen, each unit-tested
Dates
One date and deadline formatter pinned to Asia/Kuala_Lumpur — the finalization sweep runs on the Malaysian date ending, so rendering a deadline in the browser's timezone would show the wrong day to anyone travelling
Status labels
Slice 1's status.ts pattern (STATUS_LABEL / STATUS_VARIANT) extends to each new state machine, including the computed tenancy label
AI consent
One shared component, disclosure only. Payments and handover share the disclosure and the explicit accept-or-decline choice; each feature owns its own endpoint and echo lifecycle. Withdrawal is not part of the shared component — handover owns a separate withdraw action, payments have none

Proxy — verified, nothing to do

src/routes/api/rental/[...path].ts forwards the raw request body and streams the upstream response back, so multipart uploads (proof slips, handover images) and binary downloads (agreement PDFs, late notices) already work. No new proxy route, no new environment variable.

i18n

Testing

ChangeUnitRenderE2E
Slice 3 Four-term diff mapping; NRIC and name normalization Five offer states Tenant offers → landlord accepts → both sign → completed
Slice 4 formatRinggit / formatSen; late-day label; verdict/unreadable/duplicate matrix Five record states Tenant pays with AI declined → landlord confirms → charge paid
Slice 5 Button-availability derivation from ReducedThread; four suggestion states; meter-string normalization Three report states Landlord submits → tenant disputes → landlord resolves → tenant accepts → finalized

Per frontend/AGENTS.md: headless pnpm e2e is the committed Playwright suite and is allowed. Anything exploratory — finding selectors, reproducing a bug, confirming a fix visually — goes through ego lite via /ego-browser. Chrome, --headed, and Playwright MCP are banned. The frontend OpenSpec schema requires a per-task verification method, so every task in tasks.md declares TDD, render-test, e2e, or browser.

Documentation

No change is needed to CONTEXT.md, to any ADR, or to any other spec. The existing glossary terms — Offer, Tenancy Agreement, Tenancy, Issue, Void, Charge Cancellation, Payment Record, Reversal — already cover every concept used here. This review introduced no new domain term and reinterpreted none.

Backend prerequisites

Slice 3 — two read capabilities

GET /me/offers

new capability
  • The offer queue at /owner/offers and its dashboard count have no backing endpoint today
  • offer_handler.go registers only create, get-by-id, and the five actions; the viewing-request response embeds no offer
  • Party-scoped like /me/agreements and /me/tenancies
  • Server-side status filter, deterministic oldest-first ordering
  • Counts must not be derived from a truncated page

Sanitized signatures[]

HTTP projection
  • GetByID returns the agreement alone, so awaiting_signatures cannot be distinguished from "one party has signed"
  • SignatureRepository.ListByAgreementID already exists — only the HTTP projection is missing
  • Expose signer_role, user_id, signed_at, and the document hash
  • Never expose raw NRIC, IP address, or user agent

Slice 4 — one capability and three blocking defects

GET /landlord/dashboard-summary

new capability
  • The rental module has no dashboard, summary, or aggregate endpoint
  • The money tiles cannot be computed without one read per tenancy
  • A browser-side aggregate would become a further authority on "overdue"
  • Needs authenticated landlord scope and a server as_of timestamp

Landlord sees tenant drafts

blocking defect
  • PaymentRecordRepository.ListByTenancyID filters only on tenancy_id, and GetLedger authorizes landlord and tenant identically
  • GET /tenancies/:id/charges therefore returns the tenant's unsubmitted draft records to the landlord
  • GetLedger must omit tenant drafts for landlord callers, matching GetPaymentRecord
  • A release prerequisite, not a client-filter workaround

Overdue timezone mismatch

blocking defect
  • GetLedger computes is_overdue in Asia/Kuala_Lumpur; ListReviewQueue computes it in UTC
  • The same charge can read late on one screen and not on another
  • Align before the panel shows late status across both views
  • The frontend must not silently invent a reconciling third rule

Proof/extraction inconsistency

blocking defect
  • UploadPaymentProof runs AI only while AIProvider is empty
  • Replacing a proof updates the stored image but leaves ai_extraction and ai_agreement describing the previous image
  • Submit recomputes the verdict from that stale extraction, so the landlord reviews one image against an AI reading of another
  • The backend must invalidate prior AI results on different bytes, or reject the replacement — the exact approach belongs to that change
  • The Discard-and-restart rule contains the damage in the UI; it is not remediation, because a direct API caller still reaches the inconsistent state

Slice 5

PR #109 must land. No other backend item is outstanding for handover.

Delivery order

  1. Build rental-slice2-panel

    From its existing spec, to its own acceptance criteria. Prerequisite, not part of this work. Recheck the delivered slice 2 shell when slice 3 starts.

  2. Land the slice 3 backend read prerequisites

    GET /me/offers and the sanitized signatures[] projection.

  3. rental-slice3-panel

    Dashboard route, nav row, offers, agreements, signing.

  4. Land the slice 4 backend prerequisites

    One new capability plus three defect fixes.

  5. rental-slice4-panel

    Ledger, payments, tenant tenancies and pay wizard. Nav gains Tenancies and Payments; dashboard tiles ship as their contracts land.

  6. Rebase and merge PR #109

    The slice 5 backend branch.

  7. rental-slice5-panel

    Handover builder and review.

What this unblocks. With all three merged, the rental platform is usable end to end in one product: a tenant finds a listing, asks to view it, offers terms, signs, pays, and reviews their move-in report; a landlord answers, accepts, countersigns, confirms payment, and files the baseline. The remaining gaps after this are the Astro public marketplace and any move-out handover, neither of which has a frontend change yet.

Risks and trade-offs

Reviewed decision record

Twelve decisions from the 2026-09-05 grill session. Each is settled; none is an open question.

All twelve questions and their decisions
#QuestionDecision
1Where does the landlord dashboard live, given /owner is the listing grid?Keep /owner and all slice 1 property routes. Add /owner/dashboard. Preserving working URLs outranks matching demo root URLs. Do not amend slice 2 to absorb it
2What shape does the /owner nav take, and where does it live?A separate responsive horizontal row in the existing owner shell. Slice 3 introduces it with Dashboard, Properties, Viewings, Offers. Slice 4 adds Tenancies and Payments. Slice 5 adds none. No sidebar, no hamburger. Moving the header's Properties link into the row is a scoped shell adjustment
3/owner/offers has no backing endpoint. What fills it?GET /me/offers as an explicit backend prerequisite in a new small backend change. Party-scoped, status-filterable, oldest-first. No payment-records list endpoint is needed — TenancyLedger already carries records
4What is the shared money primitive, given two units on the wire?Explicit formatRinggit and formatSen plus unit-labelled types and field names. No branded types, no ingest conversion. Deposit months are multipliers shown as months
5What does the sign screen prefill, and from where?Type full_name and nric into blank inputs. Display the frozen name and address as summary; submit address_line read-only from the party snapshot. agree is an explicit unchecked checkbox. Never prefill a raw NRIC or use a later /me/contact address. Keep the existing 422 transport
6Where does the sanitized signature summary hang?Embedded signatures[] on GET /agreements/:id. Add /tenant/agreements; the owner reaches agreements by matching offer_id in GET /me/agreements. No owner agreement-list route
7When is the payment draft created, and how is an abandoned one recovered?Explicit Resume or Discard over all the user's own drafts. One UUIDv7 per logical create, retained across retries. GET the same identifier before re-mutating after a lost response
8How does the tenant surface more than one tenancy?Explicit /tenant/tenancies, /:id, /:id/pay. Preserve the slice 2 tenant root. Label rows by joining /me/agreements snapshots to /me/tenancies on agreement_id — two collection reads
9What does the wizard do when the tenant replaces the proof?Changing stored proof, or an already-granted consent choice, requires Discard and a fresh draft. No payment withdraw toggle. The backend defect stays a release prerequisite
10Where do the two money tiles get their numbers?GET /landlord/dashboard-summary as a slice 4 backend prerequisite. "Confirmed this month" uses reviewed_at in the KL month. Overdue is a sum plus a count. Live listings, not properties. No placeholder zeros
11How does the builder handle the two submission gates?Mirror both gates with named completeness and links; disable submit while required media are absent or processing. Area-attached and entry-attached media are counted separately. Decimal-string meter input. Acceptance echoes review_state_sha256
12What does the offer terms comparison compare against?Four mapped terms are diffed. Advance rent and special conditions are proposed-only. available_from is contextual, not a difference. "Not specified" for absent asking values. No listing fields added

Assumptions

These are stated choices, not derived facts. A reader who disagrees should reopen the relevant decision rather than assume the code proves it.

  1. Preserving existing URLs outranks matching the demo's navigation. Landlords already hold /owner and /owner/:id links.
  2. "Confirmed this month" measures confirmation activity. Not cashflow, accounting income, or rent earned. It includes deposits and landlord receipts, spans ended tenancies, and can change after a reversal. paid_on was rejected because it can be absent and is user-entered. A conservative product choice, not an accounting definition, and it adds no glossary term.
  3. Client-side filtering of role-agnostic /me collections is presentation only. It is never treated as authorization.
  4. Frontend gate mirroring contains but does not remediate the backend defects. A direct API caller still reaches the bad state.
  5. The sign-screen interaction is a conservative UX choice, not a claim about legal sufficiency under ADR-0059.
  6. The current listing is the comparison context for an offer, not a historical negotiation snapshot.
  7. Tenancy scheduled/active/ended is a display label only. Not a backend lifecycle and not a permission gate. An ended tenancy may still hold open obligations.