DIY Rental Platform — Slice 4: Tenancy Establishment, Rent Ledger, and AI Payment Proof Matching

Slice 4 turns a completed tenancy agreement into an operational tenancy with a fixed-term rent ledger, human-controlled payment confirmation, durable reminders, and immutable late notices. Optional platform-funded vision AI extracts receipt details under explicit consent, while deterministic checks and landlord decisions remain authoritative.

  • Predecessor Slices 1–3 — listings, viewings, and tenancy agreements
  • ADRs 0060 · 0061
  • Status Approved

View source markdown ↗ generated by codex · diagrams mermaid

Executive summary

Slice 4 turns the signed agreement into a trustworthy operational ledger without making Gremlin a payment intermediary. Optional vision AI removes receipt transcription work, but deterministic rules validate the claim and only the landlord can decide that money was received.

  • StatusReviewed draft · assumptions A1–A3 remain
  • Core proofAgreement → tenancy → full fixed-term ledger
  • Money boundaryExternal payments only; platform never holds funds
  • Human gateLandlord confirmation is authoritative
  • AI featurerental:payment_proof_extract
  • AI fundingProduct-scoped platform key and caps
  • CalendarAsia/Kuala_Lumpur
  • Primary surfacesTenant ledger/upload + landlord review/notice

Ledger invariant

locked

A charge becomes paid only through an active allocation created by a landlord decision.

AI boundary

assistive

AI extracts visible fields. Code compares them. The tenant attests to final values. The landlord decides.

Launch gates

A1–A3

System-void authority, retention period/job, and counsel-reviewed notice wording require confirmation.

Outcomes and dependencies

Success criteria

  • Agreement completion creates exactly one tenancy and the complete fixed-term charge schedule.
  • One property cannot have overlapping completed tenancies, including through different listings.
  • Landlord and tenant see the same upcoming, due, overdue, paid, and cancelled charges.
  • Tenant manual or JPEG/PNG-proof payments and landlord-recorded cash/unreported transfers are complete without AI.
  • With consent and available platform AI, receipt fields pre-fill; every unavailable or failed AI path preserves the manual flow.
  • Ledger state changes only when the landlord confirms a tenant claim or records a receipt themselves.
  • Every Day 1, Day 3, and Day 7 stage ends durably as sent, superseded, or failed; Day 7 offers a fixed notice to the landlord.
  • Confirmations, rejections, reversals, reminders, charge transitions, and notices stay auditable.

Delivery dependencies

Slice 1
2026-08-24-rental-platform-slice1-listings-design.md · merged
Slice 2
2026-08-24-rental-platform-slice2-viewings-design.md · merged
Slice 3
2026-08-26-rental-platform-slice3-agreements-design.md · design approved, implementation must land first
Source brief
/Users/waynecheah/Downloads/DIY_Rental_Platform_Malaysia.md

Existing seams to reuse: the organization-less B2C Rental module and its domain/entity, application/{port,usecase}, adapter/{inbound,outbound} shape; optional WithPhotoStore, WithProofStore, and WithMailer adapters; private storage and authorized streaming; the existing reminder scheduler; and the AI module's provider construction, credential resolution, kill switch, prompts, access events, and usage accounting.

Scope

In scope

slice 4
  • Operational tenancy creation and full security-deposit, utility-deposit, and monthly-rent schedule.
  • Shared ledger; single-charge cancellation/reopen with reason and audit.
  • Tenant records with optional private JPEG/PNG proof; landlord records born confirmed.
  • Optional extraction of amount, date, reference, sender, and recipient display details.
  • ai_agreement and duplicate signals from deterministic code on every submitted record.
  • Tenant correction; landlord confirm/reject/reverse; Day 1/3 reminders and Day 7 notice action.
  • Fixed, lawyer-reviewed, private late-payment-notice PDF.
  • Product-funded AI configuration/caps and SolidStart tenant/landlord surfaces.

Out of scope

by decision

Gateways, FPX/cards, escrow, split payouts, webhooks, open banking, bank-account verification, settlement, refunds, partial/overpayments, credits, payment plans, late fees, recurring cards, utilities, maintenance, renewal, early termination, deposit refunds, LHDN stamping, law-firm escalation, SMS/WhatsApp, and automatic legal demands.

Explicit rejections

RejectedWhyChosen boundary
Landlord BYOKExisting BYOK belongs to organizations; landlords are global B2C users.Platform-managed product AI.
Synthetic Rental organizationWould merge all landlords' settings, subscription, and usage into a fake tenant.product_key = 'rental'.
Forgery detectionVision cannot prove authenticity.Say “details extracted” and “possible duplicate,” never “verified” or “fraud-free.”
Automatic paid stateReceipt extraction cannot observe settlement.Only landlord confirmation creates allocations.
PDF proof extractionNeeds secure rendering, page selection, and multi-page limits.JPEG/PNG ≤ 5 MB; manual no-proof flow remains.
Recipient-account matchingNo trusted landlord receiving-account registry exists.Display extracted recipient text only.
Provider-specific Rental logicWould leak external provider contracts into the feature module.Rental asks a narrow governed vision capability.

Core domain decisions

Tenancy is operational state

Create one rental_tenancies row from immutable agreement terms in the same transaction that completes the agreement.

The signed agreement remains the frozen legal record; the tenancy owns mutable schedules, payments, reminders, and future move-in/out state. Same-transaction creation avoids a visible “complete agreement, missing ledger” interval.

Rejected: mutable payment facts on rental_tenancy_agreements; background establishment consumer inside one module.

Generate the complete schedule once

Write deposits and every monthly charge at establishment.

The agreement fixes all inputs, the maximum 36-month term is bounded, and a complete schedule is stable and reviewable immediately. Unique agreement_id and deterministic charge business keys make retry safe.

Rejected: monthly charge-generation job.

Late is a projection; decisions are facts

Persist charge status as open, paid, or cancelled; derive upcoming, due, and overdue from KL date and due_on.

Time-derived display state needs no midnight rewrite. Confirmations, rejections, reversals, reminders, cancellations, and notices are historical facts and are stored.

Rejected: nightly status mutation across open charges.

One Payment Record, two meanings

Tenant claim

Draft → review

Charges
Whole, open, exact total
Proof
Optional private JPEG/PNG
Decision
Landlord confirms or rejects whole record

The landlord cannot edit the tenant's claim. Wrong claims are rejected and recreated.

Landlord receipt

Born confirmed

Use
Cash or unreported transfer
Proof / AI
Never
Transaction
Record + allocations + paid charges

The author is already the reviewer; the record remains reversible and notifies the tenant.

A mistaken confirmation is reversed, never deleted. Allocations remain as evidence with reversed_at, charges reopen atomically, and a reason is mandatory. “Void” remains reserved for ending an agreement before completion.

Charge cancellation stops the ledger

A landlord may cancel one open charge with a reason and later reopen it with another reason. The charge stays visible, cannot be selected or reminded, and every transition is appended to rental_charge_cancellation_events.

  • Cancellation is allowed only from open, with no active allocation and no selecting pending_review record.
  • Cancellation supersedes every remaining unsent reminder stage in the same transaction.
  • Reopening never resurrects superseded stages; the next sweep sends only the highest applicable stage without an outcome.
  • This is not tenancy termination, settlement, or deposit return; Slice 5 will reuse this action for remaining charges.

Payment creation survives storage failure

Every create uses a caller-supplied UUIDv7 record_id, a server-computed create_fingerprint_sha256, and a deterministic private proof key. Same ID + same fingerprint returns or completes the existing draft; same ID + different fingerprint returns 409 idempotency_conflict. A retry cannot create a second draft, AI attempt, or allocation.

Pending review suppresses escalation

A selected charge under pending_review receives no reminders and cannot generate a notice until the landlord decides. There is no clock-based expiry or automatic rejection because elapsed time cannot establish whether an external transfer occurred. The review queue is oldest-first and displays age.

AI matching and governance

Extract, match, decide

Payment-proof flow: AI is optional assistance before deterministic validation and the landlord's decision
Payment-proof flow: AI is optional assistance before deterministic validation and the landlord's decision.

The model returns candidate fields only:

{
  "amount_sen": 175000,
  "paid_on": "2026-09-01",
  "reference": "MYBANK-8H3K2",
  "sender_name": "string or null",
  "sender_account_tail": "string or null",
  "recipient_name": "string or null",
  "recipient_account_tail": "string or null",
  "unreadable_fields": []
}

All extracted values except unreadable_fields may be null. The parser rejects prose, extra top-level fields, floating money, impossible dates, and overlong strings.

Three separate answers

Does the claim add up?

validation

Final tenant amount must equal selected open charges. A mismatch is HTTP 422 and is never stored as a review result.

Does receipt match typed values?

ai_agreement

Compare validated extracted amount/date/reference with the tenant's final values. Recompute on submit.

Has this been seen before?

duplicate_signals

Run deterministic reference/hash checks for every submitted record, even manual records with no AI or proof.

ai_agreementRule
not_applicableNo AI ran: absent, disabled, capped, failed, invalid, no proof, or no consent.
differsAny readable extracted comparison field disagrees; this wins even when another field is unreadable.
fields_unreadableNo readable field differs, but a required comparison field is absent or named unreadable.
agreesEvery required comparison field is readable and matches.

duplicate_signals is a set containing neither, either, or both of same_reference and same_proof_hash. Reference normalization trims outer whitespace, collapses internal whitespace, and uppercases Unicode without removing punctuation. A match requires normalized reference + amount + tenancy on another submitted record; a proof match is exact SHA-256. Signals reveal nothing about the other record and never block submission.

Optional AI and bounded spend

  • per_recordOne extraction attempt; PATCH never reruns vision1
  • per_tenancy_dayAsia/Kuala_Lumpur day, atomically reserved before provider call10
  • product_monthCalendar-month call/token caps in ai_product_configscap

Absent capability, kill switch, disabled config, cap, missing credentials, timeout, provider error, or invalid JSON never turns stored-proof success into HTTP 5xx. The caller receives the usable draft/manual form plus a separate AI status. Daily-cap copy is explicit: “AI assistance limit reached for this tenancy today. Your proof is saved; enter the details manually or try again tomorrow.”

Product-scoped platform AI

Authorize Rental as a product

Add AuthorizeProduct for product_key = 'rental', retaining mode = platform.

Gremlin pays, but Rental has no organization. This reuses the kill switch, model catalog, platform credential resolver, prompts, provider runner, access events, and usage rollups without corrupting B2C identity.

Rejected: landlord BYOK, lazy organization provisioning, or a new provider route outside Access Gate. Boundary recorded by ADR-0061.

ai_product_configs controls enabled state, vision-capable provider/model, config_version, monthly caps/counters, period start, updater, and timestamps. Enabling requires platform eligibility, vision support, and available platform credentials. Access and usage rows have exactly one scope: organization_id xor product_key.

  • Inline, versioned checkbox text states the data, named provider, possible processing outside Malaysia, sole extraction purpose, human payment decision, manual alternative, and landlord visibility of validated fields.
  • The availability response provides provider + ai_config_version; create echoes them and stores ai_consent_provider + ai_consent_config_version.
  • Immediately before sending bytes, authorization's chosen config must match the echo. A mismatch stores the proof, sends no image, sets AI unavailable, and asks the tenant to review new disclosure or continue manually.
  • ai_provider, ai_model, and prompt version record what actually processed that upload.
  • The provider receives image bytes, true media type, and extraction prompt only—never user IDs, NRIC, agreement PDF, messages, or unrelated tenancy data.
  • Raw provider response is discarded; only validated extraction and processing metadata remain.

Drafts and unsubmitted proofs may be deleted by the tenant. Once submitted, proof and ai_result are shared review evidence and live exactly as long as the Payment Record under the platform retention schedule.

Privacy basis: PDPC Cross-Border Personal Data Transfer Guideline 3/2025 and Malaysia's PDPA principles, including retention.

Property overlap: prevent early, resolve terminally

Property-level overlap is rejected before signing and rechecked under the same serialization lock at completion
Property-level overlap is rejected before signing and rechecked under the same serialization lock at completion.

Primary guard at Issue

Slice 3's issue transaction locks rental_properties, derives [starts_on, ends_on) from the accepted Offer, and rejects overlap with either an established Tenancy or a non-voided Agreement for the property. This closes the cross-listing gap created when an admin-suspended listing can be replaced while an older agreement remains in signing.

Completion defence in depth

Completion locks the property and rechecks established tenancies. If the residual race fires, rolling back is forbidden because it would discard the triggering signature and strand a fully signed agreement at awaiting_signatures. Instead, the transaction keeps the signature, moves the losing agreement to voided with property_let_under_another_agreement, commits, notifies both parties, and returns terminal 409 with the agreement reported voided.

Architecture

Slice 4 stays in the Rental module and depends narrowly on governed vision capability from the shared AI module
Slice 4 stays in the Rental module and depends narrowly on governed vision capability from the shared AI module.
LayerResponsibility
rental/domain/entity/Dates, charge construction, payment transitions, ai_agreement, duplicate rules.
rental/application/port/Tenancy/charge/payment/reminder repositories, proof store, notice renderer, thirteen mail methods, narrow AI ports.
rental/application/usecase/Establish/list/create/submit/review/reverse, reminder sweep, cancellation, notice generation.
rental/adapter/inbound/http/Party-authorized tenancy, payment, proof, charge, and notice endpoints.
rental/adapter/outbound/persistence/postgresql/Repositories and transaction-runner extensions.
rental/adapter/outbound/storage/Private payment-proof and late-notice objects.
rental/adapter/outbound/pdf/Fixed deterministic notice renderer following Slice 3.
ai/application/port/Product authorization, RunVision, RunVisionAndRecord.
ai/adapter/outbound/external/Media-type-correct requests through existing adapters.

rental/module.go obtains an optional AICapability from the container and adapts it to local interfaces. Rental never imports external provider adapters or constructs providers; tests inject fakes through the established functional-option pattern.

Minimum shared AI extension

  1. Add SupportsVision bool / supports_vision to model metadata.
  2. Add MediaType to VisionRequest; Rental sends base64, never ImageURL.
  3. Add RunVision(ctx, authz, request) to Runner / AuthzRunner.
  4. Add vision RunAndRecord parity so latency, total usage, and provider failure book exactly once. Like gated_call.go:46, record provider TokensUsed as output tokens; caps remain correct because adapters already sum totals (anthropic_provider.go:290), while input/output reporting remains imprecise and is out of scope.
  5. Add AuthorizeProduct(...) and product configuration to the concrete Gate.
  6. Support exactly one organization/product scope in access and usage persistence, including partial indexes, two upserts, product decisions, and report indexes.
  7. Fix OpenAI, Anthropic, and Google adapters to use actual media type rather than hard-coded image/jpeg; reject non-vision provider/models before construction. Groq currently rejects vision.

Prompt contract

Load Prompt.GetActive for feature rental:payment_proof_extract, locale en-MY. It must transcribe visible information only, output exactly the schema, use integer Malaysian sen and ISO dates, return null + unreadable names rather than infer, and never judge authenticity, fraud, settlement, or payment state.

Email contract and reliability

Event / methodRecipientDispatch
Ledger available · TenancyEstablishedBothAfter completion commit
Claim submitted · PaymentSubmittedLandlordAfter submit
Confirmed · PaymentConfirmedTenantAfter commit
Rejected · PaymentRejectedTenantAfter commit, with reason
Reversed · PaymentReversedTenantAfter commit, with reason
Landlord recorded · PaymentRecordedByLandlordTenantAfter commit
Charge cancelled · ChargeCancelledTenantAfter commit, with reason
Charge reopened · ChargeReopenedTenantAfter commit, with reason
Day 1 · PaymentReminderDay1TenantDurable sweep
Day 3 · PaymentReminderDay3TenantDurable sweep
Notice available · LateNoticeAvailableLandlord onlyDurable Day 7 sweep
Notice generated · LateNoticeGeneratedTenantAfter explicit generation
Agreement system-voided · AgreementVoidedPropertyLetBothAfter losing completion commit

Every email links to an authorized panel route and has no attachment. Time-triggered Day 1/3/7 mail uses durable retry events. Action-triggered mail is after-commit fire-and-forget; failure is logged and never changes a committed command. SMTP inside a transaction is forbidden.

Data model

All Rental tables are owned by migrator_rental.go. Money is integer sen, dates are DATE, and decision timestamps are TIMESTAMPTZ.

rental_tenancies — operational aggregate

id, agreement_id, property_id, listing_id, landlord_user_id, tenant_user_id, starts_on, ends_on, monthly_rent_sen, established_at, created_at.

  • agreement_id unique; start inclusive and end exclusive.
  • Party/property IDs denormalized for stable authorization and overlap checks.
  • No status column; scheduled/active/ended are KL-date projections.
  • Issue and completion lock the property row; completion serialization closes cross-listing double-let without a PostgreSQL extension.
rental_charges — fixed schedule

id, tenancy_id, kind, period_start, period_end, due_on, reminder_anchor_on, amount_sen, status, paid_at, created_at, updated_at.

  • Kinds: security_deposit, utility_deposit, rent; statuses: open, paid, cancelled.
  • Deposit rows have no rental period; rent periods are inclusive/exclusive.
  • Unique indexes permit one deposit of each kind and one rent per period start; zero deposits create no row.
  • reminder_anchor_on = max(due_on, establishment date in Asia/Kuala_Lumpur).
rental_payment_records — claim or receipt

id, tenancy_id, recorded_by, recorded_by_user_id, create_fingerprint_sha256, status, payment_method, amount_sen, paid_on, bank_reference, proof_key, proof_media_type, proof_sha256, proof_ready_at, ai_consent_at, ai_consent_version, ai_consent_provider, ai_consent_config_version, ai_status, ai_result, ai_provider, ai_model, ai_prompt_version, ai_agreement, duplicate_signals, submitted_at, confirmation/rejection/reversal actor/time/reason fields, created_at, updated_at.

  • recorded_by: tenant or landlord; actor must equal tenancy party. payment_method always required.
  • Status: draft, pending_review, confirmed, rejected, reversed. Landlord rows are directly confirmed and have null proof/AI fields.
  • AI status: not_requested, unavailable, succeeded, failed, invalid; result is validated structure only.
  • ai_agreement: not_applicable, agrees, differs, fields_unreadable. Duplicate set contains same_reference and/or same_proof_hash.
  • Tenant amount/date/reference may be null only while draft; consent metadata binds disclosure to displayed config. proof_key is never serialized.
rental_payment_record_charges — immutable whole-charge selection

payment_record_id, charge_id, amount_sen, created_at. Sum must equal the record amount before tenant submit or at landlord creation; rows freeze after draft.

rental_payment_allocations — authoritative paid link

id, payment_record_id, charge_id, amount_sen, reversed_at, created_at. A partial unique index on charge_id WHERE reversed_at IS NULL prevents double payment. Reversal timestamps rather than deletes.

rental_payment_reminder_events — durable ladder

id, charge_id, kind, recipient_user_id, status, attempt_count, next_attempt_at, last_error, provider_message_id, sent_at, created_at, updated_at.

  • Kinds: day_1_friendly, day_3_firm, day_7_notice_available; first two go to tenant, Day 7 to landlord.
  • Statuses: pending, sent, superseded, failed; unique by charge/kind/recipient.
  • Skipped lower stages are inserted/updated as superseded in the same transaction. Retry workers read pending only.
  • At most five attempts with bounded backoff; then terminal failed. Event ID is provider idempotency key when supported, but network ambiguity can still duplicate mail without provider support.
rental_late_payment_notices — immutable evidence

id, charge_id, generated_by_user_id, template_version, document_key, document_sha256, created_at. Unique by charge. The notice remains downloadable after later payment, cancellation, reopening, or reversal. template_version follows ADR-0060.

AI schema additions — product scope

ai_product_configs: product_key, enabled, provider, model, config_version, monthly limits/counters, period_start, updated_by, updated_at. Rental starts disabled.

ai_access_events and ai_usage make organization_id nullable, add product_key, and CHECK exactly one scope. Add decisions denied_product_disabled and denied_product_cap_exceeded; do not reuse organization-specific denied_platform_cap_exceeded.

Replace UNIQUE (organization_id, usage_date, provider, model, feature) from migrator.go:1546 and matching upsert at access_repo.go:744 with two partial unique indexes and predicate-matching upserts:

  • (organization_id, usage_date, provider, model, feature) WHERE organization_id IS NOT NULL
  • (product_key, usage_date, provider, model, feature) WHERE product_key IS NOT NULL

Add report indexes (product_key, feature, usage_date DESC) on usage and (product_key, created_at DESC) on access events; retain organization indexes and behavior.

rental_ai_daily_usage — per-tenancy attempt floor

tenancy_id, usage_date, attempt_count, created_at, updated_at; primary key (tenancy_id, usage_date). Date is KL calendar date. Atomic reservation before provider call:

INSERT INTO rental_ai_daily_usage (tenancy_id, usage_date, attempt_count)
VALUES ($1, $2, 1)
ON CONFLICT (tenancy_id, usage_date) DO UPDATE
   SET attempt_count = rental_ai_daily_usage.attempt_count + 1,
       updated_at    = now()
 WHERE rental_ai_daily_usage.attempt_count < $3
RETURNING attempt_count;

No row means capped. Concurrent attempts serialize. This counts attempts, while ai_usage remains the billing/actual-spend record; old daily rows are disposable.

rental_charge_cancellation_events — append-only reason history

id, charge_id, action, actor_user_id, reason, created_at. Action is cancelled or reopened; current state stays on the charge, while every who/why transition remains here.

State machines

Payment Record states. Rejected and reversed are terminal; conflicting terminal commands return 409, while repeated already-achieved commands are idempotent
Payment Record states. Rejected and reversed are terminal; conflicting terminal commands return 409, while repeated already-achieved commands are idempotent.
Charge states. AI, proof upload, tenant submission, and reminder delivery never mark a charge paid
Charge states. AI, proof upload, tenant submission, and reminder delivery never mark a charge paid.

Charge schedule

  1. Start from frozen terms

    starts_on is the agreement start date; ends_on is the independently calculated term_months offset.

  2. Anchor every boundary

    For offset n, use the original start day in month n, clamping only if that target month is shorter. Never add a month to the prior clamped date; raw Go time.AddDate is also wrong for 31 January.

  3. Create deposits

    Security and utility deposits are monthly rent × agreed multiple, due on start. Zero-value deposits create no rows.

  4. Create monthly rent

    One ordinary charge per month. Advance-rent months are the first N ordinary charges due on start, not extra rows; later rent is due on its period start.

  5. Calculate in sen

    Convert whole-ringgit rent to sen before multiplying by allowed NUMERIC(4,1) deposit months, producing exact integer sen.

Anchoring every boundary to the original start day keeps month 3 on 31 March and ends_on on 31 January 2028, while iterating from the prior clamped boundary drops to 28 March and never recovers, ending on 28 January 2028. Month 3 ends_on Anchored 31 Mar 31 Jan 2028 Iterated · wrong 28 Mar 28 Jan 2028
Months 1–2 agree. From month 3 the iterated path clamps to 28 and never returns — the same three-day error is still there twelve months later at ends_on.
BoundaryAnchored from 31 Jan 2027Iterated from prior boundary—wrong
Month 131 Jan31 Jan
Month 228 Feb28 Feb
Month 331 Mar28 Mar
Month 430 Apr28 Apr
ends_on31 Jan 202828 Jan 2028

Authorization and privacy

ActionAuthorized caller / conditions
View tenancy and ledgerAgreement landlord or tenant
Create/edit/submit tenant recordTenancy tenant
Delete draft + proofTenancy tenant; draft only
Record received paymentTenancy landlord; born confirmed
View proofTenancy landlord or tenant
Confirm/rejectTenancy landlord
Reverse confirmed recordTenancy landlord; reason required
Cancel/reopen chargeTenancy landlord; reason required each way
Generate late noticeTenancy landlord; explicit action; charge open, ≥7 KL days late, not under pending review
Download noticeTenancy landlord or tenant for record lifetime
Configure Rental AIExisting platform-admin AI configuration authority
  • The generic landlord role grants no access to another landlord's tenancy; tenant access remains a row relationship, not an auth role.
  • Authorize against the loaded tenancy before returning proof, AI metadata, notice metadata, or bytes.
  • Reminder delivery exposes only a status word to the landlord; address, last_error, attempt count, and provider message ID remain server-side.
  • Proof response is an authorized stream endpoint, never an object key or signed URL. AI evidence is visible to both parties; provider credentials are never exposed.

Operational flows

A · Agreement completion establishes tenancy

  1. At Issue: lock property, derive interval, reject any tenancy/non-voided-agreement overlap with neutral 409.
  2. At completion: lock property and recheck established tenancies.
  3. On conflict: keep signature, system-void with fixed reason, commit, notify both, return terminal 409.
  4. Otherwise complete agreement and set listing rented per Slice 3.
  5. Insert tenancy from frozen terms and generate all deposit/rent charges.
  6. Commit once, then notify both parties. Unique agreement makes retry return existing tenancy.

B · Tenant creates draft

  1. Select open charges; optionally upload one JPEG/PNG ≤5 MB and choose AI assistance.
  2. Require UUIDv7 record_id; validate, fingerprint, create/recover draft, stream deterministic private object, then set proof_ready_at. Proof-key-without-ready rows are hidden and recoverable; fully manual null/null drafts are valid.
  3. Without proof, consent, or capability, return manual entry.
  4. Load prompt and authorize product vision before reading proof bytes.
  5. Reject consent/config race without sending bytes; preserve proof and manual draft.
  6. Reserve daily attempt; cap returns explicit unavailable/manual response.
  7. Read bytes, run vision once, record usage/error once, validate JSON, return candidate fields. Recompute warnings again at submit.

C · Tenant submits

  1. Require amount, paid date, and ≥1 selected open charge; reference may be absent.
  2. 422 unless selected total equals amount exactly.
  3. Recompute duplicate signals and ai_agreement server-side; warnings never block.
  4. Freeze fields/selection and move draft → pending_review.
  5. Notify landlord with review link.

D · Landlord confirms or rejects

Confirm locks record and charges, rechecks pending/open state, inserts one allocation per charge, marks charges paid, timestamps with confirmation time, and commits atomically. Reject requires a reason, changes no charge, and notifies the tenant.

E · Landlord records received payment

One transaction requires paid date, method, open whole charges, and exact total; locks charges; inserts confirmed landlord record with ai_agreement = not_applicable, applicable reference duplicate warning, allocations, and paid charge state; commits and notifies tenant. No proof or AI is accepted.

F · Landlord reverses confirmation

With mandatory reason, lock record/allocations/charges, timestamp active allocations reversed, reopen charges and clear current paid_at, move to reversed, preserve actor/time/reason, commit, and notify tenant.

G · Cancel or reopen charge

Cancel requires open status, no allocation, no pending selector, reason, lock, append-only event, and superseding all remaining reminder outcomes in one transaction. Reopen requires reason and event; it does not resurrect prior reminder stages.

H · Reminder and notice ladder

Reminder ladder drawn to scale from the reminder anchor day: a friendly tenant reminder on day 1, a firm tenant reminder two days later on day 3, then four days later on day 7 the landlord alone is told that an explicit late-notice action is available. A band across the whole span marks that a pending review suppresses every stage. review pauses all friendly · tenant firm · tenant landlord only anchor Day 1 Day 3 Day 7
Days counted from reminder_anchor_on, drawn to scale: escalation slows from a two-day gap to a four-day gap, and day 7 goes to the landlord alone. Pending review suppresses every stage.

Pending review suppresses all stages. When suppression ends, send only the highest applicable unsent stage and mark all lower missing/pending stages superseded atomically. Paid/cancelled charges are ignored. Mail retries five times, then landlord sees “reminder delivery failed” without diagnostics.

The landlord explicitly generates one deterministic PDF from a lawyer-reviewed fixed template containing structured blanks only: names, property address, agreement reference, charge period, amount, due date, and outstanding duration. It excludes NRIC, labels itself a platform payment reminder rather than legal advice or a law-firm demand, stores SHA-256 and template_version, and emails the tenant a link. No automatic generation, clause selection, conditional wording, or free text.

HTTP surface

GET    /api/rental/me/tenancies
GET    /api/rental/tenancies/:id
GET    /api/rental/tenancies/:id/charges

POST   /api/rental/tenancies/:id/payment-records
GET    /api/rental/payment-records/:id
PATCH  /api/rental/payment-records/:id
DELETE /api/rental/payment-records/:id          (draft only, tenant)
POST   /api/rental/payment-records/:id/submit
POST   /api/rental/payment-records/:id/confirm
POST   /api/rental/payment-records/:id/reject
POST   /api/rental/payment-records/:id/reverse
GET    /api/rental/payment-records/:id/proof

POST   /api/rental/charges/:id/cancel
POST   /api/rental/charges/:id/reopen
POST   /api/rental/charges/:id/late-payment-notice
GET    /api/rental/late-payment-notices/:id/document

GET    /api/v1/admin/ai-config/products/rental
PUT    /api/v1/admin/ai-config/products/rental

One create route serves exactly one tenancy party: tenant gets draft; landlord gets confirmed receipt; others get 403. Multipart is used only with proof; JSON serves manual and landlord paths. Every form requires UUIDv7 record_id. Party-inapplicable fields such as landlord proof/AI consent return 422. PATCH is draft-only and review actions remain separate commands.

Error contract

SituationRequired behavior
Completion retryReturn existing tenancy; no duplicate schedule.
Overlap at Issue409 property_tenancy_overlap; no agreement; disclose only conflicting_ends_on.
Overlap at completionKeep signature, system-void fixed reason, notify both, terminal 409 reporting voided.
Bad proof type / >5 MB422; no AI call.
Private object write failureHidden incomplete draft; 502; retry same ID/fingerprint/key.
Same ID, different content409 idempotency_conflict; existing record unchanged.
AI absent/disabled/capped/provider failureDraft/proof succeeds; unavailable or failed; manual form.
Tenancy's ten daily attempts usedProof/draft succeed; unavailable plus explicit daily-limit message.
Second extraction on recordRefuse; PATCH never reruns.
Invalid AI JSON/valueDraft succeeds; ai_status = invalid; manual form.
No consentProof stored; no Gate/Runner; not_requested.
Consent/config raceNo provider call; proof stored; unavailable; refresh disclosure or continue manually.
Selection total mismatch422; tenant draft stays editable; landlord record not created.
Charge paid concurrently409; tenant claim stays pending for explicit decision; landlord record not created.
Duplicate or AI disagreementWarning only; submit allowed; reveal no other-record details.
Landlord missing paid date/method; or sends proof/consent422.
Delete submitted/reviewed record409; proof/result remain evidence. Draft delete removes draft + object.
Third-party proof/notice request403 without object metadata or bytes.
Repeat confirmReturn confirmed result; no duplicate allocation.
Confirm/reject raceLock permits one terminal action; loser 409.
Missing reversal/cancel/reopen reason422.
Cancel paid/cancelled/pending-selected charge409; reject or reverse claim first.
Select cancelled charge422.
Notice on paid/cancelled/<7-day/pending-selected charge409.
Download notice after later ledger changesServe immutable historical bytes.
Reminder delivery failureRetry same pending row with backoff through five attempts, then terminal failed and privacy-safe marker.
Stage jump after suppressionWrite lower stages superseded atomically; pending-only workers never send them.
Pending review agesNo expiry/rejection; suppression stays; queue age increases.
Notice render/store failureNo notice row; charge/reminder history unchanged; 502.

Testing strategy

Tenancy and schedule

domain + DB
  • Month ends, leap years, 1–36 months, advance rent, half deposits, exact sen; explicit 31 Jan 2027 and 29 Feb 2028 cases.
  • Atomic rollback on charge insert failure.
  • Idempotent establishment.
  • Cross-listing concurrent completion: exactly one wins.
  • Issue overlap against tenancy and non-voided agreement has source-indistinguishable neutral response.
  • Losing completion preserves signature, voids fixed reason, notifies both, and remains terminal on retry.

Payments and charges

human gate
  • Table-driven state transitions and allocation races, including landlord record versus tenant confirm.
  • Landlord record is atomic, confirmed, no AI/proof, reversible; invalid totals/method/caller fail.
  • Reversal preserves allocations/actor/reason and reopens charges.
  • Cancel/reopen/cancel produces three ordered events; cancellation suppresses stages and blocks selection; preconditions enforced.
  • Full manual flow passes with no AI capability/proof.
  • Human-gate golden test: only a landlord decision creates active allocations; no paid charge exists without one.
  • Create idempotency recovers object failure, attempts AI once, rejects fingerprint conflict, and prevents duplicate landlord allocation.

AI, consent, and privacy

governed fallback
  • One gate, provider call, and usage/error record; JPEG/PNG actual media types.
  • Strict output parser invalid cases.
  • All ai_agreement precedence cases and third-party sender non-effect.
  • Both duplicate signals together/alone/manual/absent; no information leak; warnings never block.
  • No proof key; third user denied; request fixtures omit user ID, NRIC, agreement PDF, messages.
  • No-consent never calls Gate/Runner; provider/config versions persist; config race sends no bytes; later config change does not rewrite history.
  • Draft deletion removes object; submitted deletion and standalone result deletion fail.
  • Product disabled/non-vision/missing-key/cap/reset/kill-switch; 11th daily attempt denied, concurrency permits exactly ten, draft deletion does not refund, KL not UTC resets.
  • Existing organization modes regress unchanged; scoped upsert accumulates one product row and enforces xor scope; product denial enum values insert correctly.

Reminders, mail, notices

durability
  • KL boundaries, historical due dates, one outcome per stage, same-row retry, pending suppression, no burst, tenant Day 1/3 and landlord-only Day 7.
  • All thirteen mail methods link authorized routes, never attach files, dispatch action mail after commit, and tolerate injected send failure.
  • Suppressed through Day 7 then rejected sends Day 7 only and writes Day 1/3 superseded; no absent stage.
  • Pending review has no time transition; queue remains oldest-first.
  • Five failures then no sixth; public payload hides diagnostics.
  • Notice bytes/hash deterministic across host zones; concurrent generation yields one row/key/version.
  • Notice preconditions and explicit-action-only rule; identical bytes survive payment, cancellation, reopen, and reversal.

Delivery order

  1. 1 · Land Slice 3

    Approved agreement completion transaction first.

  2. 2–3 · Tenancy ledger

    Entities, migrations, schedule/overlap tests, atomic establishment, repositories, authorized reads.

  3. 4–5 · Payments

    Manual tenant/proof/duplicates, review/reversal, landlord receipts, after-commit mail.

  4. 6–7 · Dunning

    Durable reminders, cancellation/reopen, reviewed deterministic notice and private download.

  5. 8–9 · AI enhancement

    Shared vision/product scope first; then prompt, parser, comparison, consent, daily cap, fallback.

  6. 10 · Panel UI

    Tenant ledger/upload and landlord review/notice flows.

Risks and mitigations

RiskMitigation
AI invents/misreads details or looks like bank verificationStrict nullable schema, tenant correction, deterministic warnings, prohibited authenticity language, landlord confirmation.
Receipt discloses financial personal data cross-borderPrivate bucket/stream, versioned inline informed consent naming provider and purpose, minimal payload, per-upload processor metadata.
Provider config changes after disclosureEcho provider/config version; mismatch sends no image and falls back manually.
Proof/result retained indefinitelyShare Payment Record lifetime under a launch-gated platform schedule and enforcement job.
AI spend runaway or one tenancy exhausts monthOne attempt/record, ten/tenancy/KL day, product monthly caps, global kill switch.
Organization AI regression or product usage not accumulatingSeparate ADR-0061 path, unchanged org keys/tests, scoped partial unique indexes and predicate-matching upserts.
Former tenant chased after early endAudited single-charge cancellation stops selection, reminders, and notices.
Cash/unreported transfer leaves ledger wrongLandlord may record received payment directly as confirmed.
Wrong confirmation or duplicate proof/referenceAudited reversal; hash/reference warnings without fraud claims.
Agreement begins before signingContractual due date remains, but reminder anchor begins no earlier than establishment.
Multipart retry after row but before objectUUIDv7, fingerprint, deterministic object key, resumable incomplete draft.
Unreviewed claim agesIntentional indefinite suppression; oldest-first queue shows age.
Cross-listing double-let / losing signatureIssue guard plus completion lock/recheck; loser signature persists and agreement is system-voided.
Repeated, skipped, or forever-retrying remindersUnique event, provider idempotency where available, explicit superseded rows, five-attempt terminal failure.
Notice mistaken for legal adviceADR-0060 posture: one counsel-reviewed fixed template, version, structured blanks, disclaimer, landlord explicit action.
Slice 3 implementation differs from designReconcile actual frozen terms and completion transaction before delivery step 2.

Open assumptions and launch gates

What this unblocks

Slice 4 completes the MVP's post-signature promise: shared rent tracking, reminders, and evidence without the cost and compliance surface of taking payments. The operational Tenancy aggregate supports later move-in condition reports, maintenance, utilities, renewals, termination, deposit return, and eventual gateway reconciliation. The product-scoped vision path is reusable by future B2C Rental features without inventing organizations or duplicating provider code.