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.
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.
Ledger invariant
lockedA charge becomes paid only through an active allocation created by a landlord decision.
AI boundary
assistiveAI extracts visible fields. Code compares them. The tenant attests to final values. The landlord decides.
Launch gates
A1–A3System-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, orfailed; 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_agreementand 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 decisionGateways, 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
| Rejected | Why | Chosen boundary |
|---|---|---|
| Landlord BYOK | Existing BYOK belongs to organizations; landlords are global B2C users. | Platform-managed product AI. |
| Synthetic Rental organization | Would merge all landlords' settings, subscription, and usage into a fake tenant. | product_key = 'rental'. |
| Forgery detection | Vision cannot prove authenticity. | Say “details extracted” and “possible duplicate,” never “verified” or “fraud-free.” |
| Automatic paid state | Receipt extraction cannot observe settlement. | Only landlord confirmation creates allocations. |
| PDF proof extraction | Needs secure rendering, page selection, and multi-page limits. | JPEG/PNG ≤ 5 MB; manual no-proof flow remains. |
| Recipient-account matching | No trusted landlord receiving-account registry exists. | Display extracted recipient text only. |
| Provider-specific Rental logic | Would 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
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.
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 selectingpending_reviewrecord. - 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
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?
validationFinal 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_agreementCompare validated extracted amount/date/reference with the tenant's final values. Recompute on submit.
Has this been seen before?
duplicate_signalsRun deterministic reference/hash checks for every submitted record, even manual records with no AI or proof.
ai_agreement | Rule |
|---|---|
not_applicable | No AI ran: absent, disabled, capped, failed, invalid, no proof, or no consent. |
differs | Any readable extracted comparison field disagrees; this wins even when another field is unreadable. |
fields_unreadable | No readable field differs, but a required comparison field is absent or named unreadable. |
agrees | Every 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;PATCHnever reruns vision1per_tenancy_dayAsia/Kuala_Lumpurday, atomically reserved before provider call10product_monthCalendar-month call/token caps inai_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.
Private proof and informed consent
- 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 storesai_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
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
| Layer | Responsibility |
|---|---|
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
- Add
SupportsVision bool/supports_visionto model metadata. - Add
MediaTypetoVisionRequest; Rental sends base64, neverImageURL. - Add
RunVision(ctx, authz, request)toRunner/AuthzRunner. - Add vision
RunAndRecordparity so latency, total usage, and provider failure book exactly once. Likegated_call.go:46, record providerTokensUsedas 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. - Add
AuthorizeProduct(...)and product configuration to the concrete Gate. - Support exactly one organization/product scope in access and usage persistence, including partial indexes, two upserts, product decisions, and report indexes.
- 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 / method | Recipient | Dispatch |
|---|---|---|
Ledger available · TenancyEstablished | Both | After completion commit |
Claim submitted · PaymentSubmitted | Landlord | After submit |
Confirmed · PaymentConfirmed | Tenant | After commit |
Rejected · PaymentRejected | Tenant | After commit, with reason |
Reversed · PaymentReversed | Tenant | After commit, with reason |
Landlord recorded · PaymentRecordedByLandlord | Tenant | After commit |
Charge cancelled · ChargeCancelled | Tenant | After commit, with reason |
Charge reopened · ChargeReopened | Tenant | After commit, with reason |
Day 1 · PaymentReminderDay1 | Tenant | Durable sweep |
Day 3 · PaymentReminderDay3 | Tenant | Durable sweep |
Notice available · LateNoticeAvailable | Landlord only | Durable Day 7 sweep |
Notice generated · LateNoticeGenerated | Tenant | After explicit generation |
Agreement system-voided · AgreementVoidedPropertyLet | Both | After 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_idunique; 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_methodalways 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 containssame_referenceand/orsame_proof_hash.- Tenant amount/date/reference may be null only while draft; consent metadata binds disclosure to displayed config.
proof_keyis 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
Charge schedule
- Start from frozen terms
starts_onis the agreement start date;ends_onis the independently calculatedterm_monthsoffset. - 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.AddDateis also wrong for 31 January. - Create deposits
Security and utility deposits are
monthly rent × agreed multiple, due on start. Zero-value deposits create no rows. - 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.
- Calculate in sen
Convert whole-ringgit rent to sen before multiplying by allowed
NUMERIC(4,1)deposit months, producing exact integer sen.
ends_on.| Boundary | Anchored from 31 Jan 2027 | Iterated from prior boundary—wrong |
|---|---|---|
| Month 1 | 31 Jan | 31 Jan |
| Month 2 | 28 Feb | 28 Feb |
| Month 3 | 31 Mar | 28 Mar |
| Month 4 | 30 Apr | 28 Apr |
ends_on | 31 Jan 2028 | 28 Jan 2028 |
Authorization and privacy
| Action | Authorized caller / conditions |
|---|---|
| View tenancy and ledger | Agreement landlord or tenant |
| Create/edit/submit tenant record | Tenancy tenant |
| Delete draft + proof | Tenancy tenant; draft only |
| Record received payment | Tenancy landlord; born confirmed |
| View proof | Tenancy landlord or tenant |
| Confirm/reject | Tenancy landlord |
| Reverse confirmed record | Tenancy landlord; reason required |
| Cancel/reopen charge | Tenancy landlord; reason required each way |
| Generate late notice | Tenancy landlord; explicit action; charge open, ≥7 KL days late, not under pending review |
| Download notice | Tenancy landlord or tenant for record lifetime |
| Configure Rental AI | Existing platform-admin AI configuration authority |
- The generic
landlordrole 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
- At Issue: lock property, derive interval, reject any tenancy/non-voided-agreement overlap with neutral 409.
- At completion: lock property and recheck established tenancies.
- On conflict: keep signature, system-void with fixed reason, commit, notify both, return terminal 409.
- Otherwise complete agreement and set listing
rentedper Slice 3. - Insert tenancy from frozen terms and generate all deposit/rent charges.
- Commit once, then notify both parties. Unique agreement makes retry return existing tenancy.
B · Tenant creates draft
- Select open charges; optionally upload one JPEG/PNG ≤5 MB and choose AI assistance.
- Require UUIDv7
record_id; validate, fingerprint, create/recover draft, stream deterministic private object, then setproof_ready_at. Proof-key-without-ready rows are hidden and recoverable; fully manual null/null drafts are valid. - Without proof, consent, or capability, return manual entry.
- Load prompt and authorize product vision before reading proof bytes.
- Reject consent/config race without sending bytes; preserve proof and manual draft.
- Reserve daily attempt; cap returns explicit unavailable/manual response.
- Read bytes, run vision once, record usage/error once, validate JSON, return candidate fields. Recompute warnings again at submit.
C · Tenant submits
- Require amount, paid date, and ≥1 selected open charge; reference may be absent.
- 422 unless selected total equals amount exactly.
- Recompute duplicate signals and
ai_agreementserver-side; warnings never block. - Freeze fields/selection and move
draft → pending_review. - 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_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
| Situation | Required behavior |
|---|---|
| Completion retry | Return existing tenancy; no duplicate schedule. |
| Overlap at Issue | 409 property_tenancy_overlap; no agreement; disclose only conflicting_ends_on. |
| Overlap at completion | Keep signature, system-void fixed reason, notify both, terminal 409 reporting voided. |
| Bad proof type / >5 MB | 422; no AI call. |
| Private object write failure | Hidden incomplete draft; 502; retry same ID/fingerprint/key. |
| Same ID, different content | 409 idempotency_conflict; existing record unchanged. |
| AI absent/disabled/capped/provider failure | Draft/proof succeeds; unavailable or failed; manual form. |
| Tenancy's ten daily attempts used | Proof/draft succeed; unavailable plus explicit daily-limit message. |
| Second extraction on record | Refuse; PATCH never reruns. |
| Invalid AI JSON/value | Draft succeeds; ai_status = invalid; manual form. |
| No consent | Proof stored; no Gate/Runner; not_requested. |
| Consent/config race | No provider call; proof stored; unavailable; refresh disclosure or continue manually. |
| Selection total mismatch | 422; tenant draft stays editable; landlord record not created. |
| Charge paid concurrently | 409; tenant claim stays pending for explicit decision; landlord record not created. |
| Duplicate or AI disagreement | Warning only; submit allowed; reveal no other-record details. |
| Landlord missing paid date/method; or sends proof/consent | 422. |
| Delete submitted/reviewed record | 409; proof/result remain evidence. Draft delete removes draft + object. |
| Third-party proof/notice request | 403 without object metadata or bytes. |
| Repeat confirm | Return confirmed result; no duplicate allocation. |
| Confirm/reject race | Lock permits one terminal action; loser 409. |
| Missing reversal/cancel/reopen reason | 422. |
| Cancel paid/cancelled/pending-selected charge | 409; reject or reverse claim first. |
| Select cancelled charge | 422. |
| Notice on paid/cancelled/<7-day/pending-selected charge | 409. |
| Download notice after later ledger changes | Serve immutable historical bytes. |
| Reminder delivery failure | Retry same pending row with backoff through five attempts, then terminal failed and privacy-safe marker. |
| Stage jump after suppression | Write lower stages superseded atomically; pending-only workers never send them. |
| Pending review ages | No expiry/rejection; suppression stays; queue age increases. |
| Notice render/store failure | No 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_agreementprecedence 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 · Land Slice 3
Approved agreement completion transaction first.
- 2–3 · Tenancy ledger
Entities, migrations, schedule/overlap tests, atomic establishment, repositories, authorized reads.
- 4–5 · Payments
Manual tenant/proof/duplicates, review/reversal, landlord receipts, after-commit mail.
- 6–7 · Dunning
Durable reminders, cancellation/reopen, reviewed deterministic notice and private download.
- 8–9 · AI enhancement
Shared vision/product scope first; then prompt, parser, comparison, consent, daily cap, fallback.
- 10 · Panel UI
Tenant ledger/upload and landlord review/notice flows.
Risks and mitigations
| Risk | Mitigation |
|---|---|
| AI invents/misreads details or looks like bank verification | Strict nullable schema, tenant correction, deterministic warnings, prohibited authenticity language, landlord confirmation. |
| Receipt discloses financial personal data cross-border | Private bucket/stream, versioned inline informed consent naming provider and purpose, minimal payload, per-upload processor metadata. |
| Provider config changes after disclosure | Echo provider/config version; mismatch sends no image and falls back manually. |
| Proof/result retained indefinitely | Share Payment Record lifetime under a launch-gated platform schedule and enforcement job. |
| AI spend runaway or one tenancy exhausts month | One attempt/record, ten/tenancy/KL day, product monthly caps, global kill switch. |
| Organization AI regression or product usage not accumulating | Separate ADR-0061 path, unchanged org keys/tests, scoped partial unique indexes and predicate-matching upserts. |
| Former tenant chased after early end | Audited single-charge cancellation stops selection, reminders, and notices. |
| Cash/unreported transfer leaves ledger wrong | Landlord may record received payment directly as confirmed. |
| Wrong confirmation or duplicate proof/reference | Audited reversal; hash/reference warnings without fraud claims. |
| Agreement begins before signing | Contractual due date remains, but reminder anchor begins no earlier than establishment. |
| Multipart retry after row but before object | UUIDv7, fingerprint, deterministic object key, resumable incomplete draft. |
| Unreviewed claim ages | Intentional indefinite suppression; oldest-first queue shows age. |
| Cross-listing double-let / losing signature | Issue guard plus completion lock/recheck; loser signature persists and agreement is system-voided. |
| Repeated, skipped, or forever-retrying reminders | Unique event, provider idempotency where available, explicit superseded rows, five-attempt terminal failure. |
| Notice mistaken for legal advice | ADR-0060 posture: one counsel-reviewed fixed template, version, structured blanks, disclaimer, landlord explicit action. |
| Slice 3 implementation differs from design | Reconcile 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.