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.
- ADRs 0059
- Status Approved
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.
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 screen | Backend | Frontend |
|---|---|---|
| 01 Landlord dashboard | Slices 1–4 + new summary read | None. /owner is the listing grid and stays so |
| 02 My listings | Slice 1 | Built |
| 03 Viewing requests | Slice 2 | Specced, 0/19 tasks |
| 04 Message thread | Slice 2 | Specced, not built |
| 05 Offers | Slice 3 + GET /me/offers | None |
| 06 Tenancy agreement | Slice 3 + signatures[] | None |
| 07 Rent ledger | Slice 4 | None |
| 08 Payment review | Slice 4 | None |
| 09 Handover builder | Slice 5 (PR #109, open) | None |
| 10 My tenancy | Slice 4 | None |
| 11 My viewings | Slice 2 | Specced, not built |
| 12 Sign agreement | Slice 3 | None |
| 13 Pay rent + proof | Slice 4 | None |
| 14 Handover review | Slice 5 (PR #109, open) | None |
Scope
In scope
this work- Demo screens 05–10 and 12–14
/ownernavigation grows from one item to six- New
/owner/dashboardroute - 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 exceptionOwnerHeaderkeeps 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.
| Change | Nav items added | Reached instead from |
|---|---|---|
| Slice 3 | Dashboard, Properties, Viewings, Offers | — |
| Slice 4 | Tenancies, Payments | — |
| Slice 5 | None | Handover 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.
| Conflict | Demo drew | Resolution |
|---|---|---|
| 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.
| Route | Purpose |
|---|---|
/owner/dashboard | Landlord overview. Tiles ship as their contracts land |
/owner/offers | Offer queue, oldest first, live and history |
/owner/offers/:id | Offer detail with terms comparison |
/owner/agreements/:id | Agreement, signature list, documents |
/tenant/agreements | The renter's agreements |
/tenant/agreements/:id | Read, then sign |
/tenant/requests/:id/offer | Make or counter an offer |
/owner and /owner/:id are untouched slice 1 routes.
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 term | Listing field | Treatment |
|---|---|---|
monthly_rent | monthly_rent | Compared, difference marked |
term_months | tenancy_months | Compared, difference marked |
security_deposit_months | deposit_months | Compared, difference marked |
utility_deposit_months | utility_deposit | Compared, difference marked |
start_date | available_from | Context 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_months | — | Proposed-only row, no comparison |
special_conditions | — | Proposed-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
-
ErrViewingNotConfirmedThe offer form is only reachable from a confirmed viewing 1 -
ErrMissingPartyDetailsFull name and NRIC must be collected first, viaPUT /me/contact2
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
422with 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/:id — signer_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.
| Route | Purpose |
|---|---|
/owner/tenancies · /owner/tenancies/:id | Tenancy list and rent ledger |
/owner/payments | Review queue, oldest first, with claim age |
/owner/payments/:id | Payment review detail |
/tenant/tenancies | The renter's tenancies |
/tenant/tenancies/:id | Tenancy home — next amount, next date |
/tenant/tenancies/:id/pay | Three-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
| Unit | JSON field | Where |
|---|---|---|
| Whole ringgit (integer) | monthly_rent | listing.go, offer.go, agreement.go (AgreementTerms) |
| Sen (integer) | amount_sen | Charge, PaymentRecord, PaymentAllocation, ReceiptExtraction |
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_id — two 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_agreementagrees | differs | fields_unreadable | not_applicableheadline badge 4 -
unreadable_fieldsNames 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.
-
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.
-
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.
-
Step 3 — save final values via
PUT, then submit explicitlySubmission is a distinct user action, never implied by saving.
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.
| Tile | Definition |
|---|---|
| Live listings | Count of listings with status live — listings, not properties |
| Needs your answer | Actionable 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 month | Sum of amount_sen, once per currently confirmed payment record whose reviewed_at falls in the current KL calendar month |
| Overdue | Sum 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.
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 uploading → ready → failed,
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 attachment | Satisfies area gate | Satisfies meter-entry gate |
|---|---|---|
Directly area-attached, ready | Yes | No |
Directly entry-attached, ready | No | Yes |
| Staged or unattached | No | No |
Still uploading | Not yet — "processing" affordance, not an error | Not 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
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.
-
ErrOpenThreadBlocksAcceptanceChecked first. Resolve the open thread before acceptance is possible.
-
ErrStaleReviewedSnapshotA 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,
formatRinggitandformatSen, 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.tspattern (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
| Change | Unit | Render | E2E |
|---|---|---|---|
| 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/offersand its dashboard count have no backing endpoint today offer_handler.goregisters only create, get-by-id, and the five actions; the viewing-request response embeds no offer- Party-scoped like
/me/agreementsand/me/tenancies - Server-side
statusfilter, deterministic oldest-first ordering - Counts must not be derived from a truncated page
Sanitized signatures[]
HTTP projection
GetByIDreturns the agreement alone, soawaiting_signaturescannot be distinguished from "one party has signed"SignatureRepository.ListByAgreementIDalready 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_oftimestamp
Landlord sees tenant drafts
blocking defectPaymentRecordRepository.ListByTenancyIDfilters only ontenancy_id, andGetLedgerauthorizes landlord and tenant identicallyGET /tenancies/:id/chargestherefore returns the tenant's unsubmitteddraftrecords to the landlordGetLedgermust omit tenant drafts for landlord callers, matchingGetPaymentRecord- A release prerequisite, not a client-filter workaround
Overdue timezone mismatch
blocking defectGetLedgercomputesis_overdueinAsia/Kuala_Lumpur;ListReviewQueuecomputes 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 defectUploadPaymentProofruns AI only whileAIProvideris empty- Replacing a proof updates the stored image but leaves
ai_extractionandai_agreementdescribing 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
-
Build
rental-slice2-panelFrom its existing spec, to its own acceptance criteria. Prerequisite, not part of this work. Recheck the delivered slice 2 shell when slice 3 starts.
-
Land the slice 3 backend read prerequisites
GET /me/offersand the sanitizedsignatures[]projection. -
rental-slice3-panelDashboard route, nav row, offers, agreements, signing.
-
Land the slice 4 backend prerequisites
One new capability plus three defect fixes.
-
rental-slice4-panelLedger, payments, tenant tenancies and pay wizard. Nav gains Tenancies and Payments; dashboard tiles ship as their contracts land.
-
Rebase and merge PR #109
The slice 5 backend branch.
-
rental-slice5-panelHandover 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
| # | Question | Decision |
|---|---|---|
| 1 | Where 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 |
| 2 | What 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 |
| 4 | What 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 |
| 5 | What 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 |
| 6 | Where 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 |
| 7 | When 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 |
| 8 | How 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 |
| 9 | What 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 |
| 10 | Where 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 |
| 11 | How 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 |
| 12 | What 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.
- Preserving existing URLs outranks matching the demo's navigation. Landlords already hold
/ownerand/owner/:idlinks. - "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_onwas rejected because it can be absent and is user-entered. A conservative product choice, not an accounting definition, and it adds no glossary term. - Client-side filtering of role-agnostic
/mecollections is presentation only. It is never treated as authorization. - Frontend gate mirroring contains but does not remediate the backend defects. A direct API caller still reaches the bad state.
- The sign-screen interaction is a conservative UX choice, not a claim about legal sufficiency under ADR-0059.
- The current listing is the comparison context for an offer, not a historical negotiation snapshot.
- Tenancy
scheduled/active/endedis a display label only. Not a backend lifecycle and not a permission gate. An ended tenancy may still hold open obligations.