DIY Rental Platform — Slice 5: Move-in Handover and AI-Assisted Condition Report

Slice 5 creates one immutable, party-authorized move-in condition report per tenancy: the landlord submits a structured baseline, the tenant records disputes and omissions, and the platform freezes both positions into canonical evidence and a deterministic PDF. AI may draft neutral photo descriptions only after uploader-specific consent, but the complete manual workflow ships first and remains fully functional without AI.

  • Predecessor Slices 3 and 4 — agreements, operational tenancy, payments, and Rental AI
  • ADRs 0056 · 0059 · 0062
  • Status Approved

View source markdown ↗ generated by codex · diagrams mermaid

Summary

Slice 5 turns a landlord's move-in walkthrough and a tenant's review into one immutable evidence package without asking the platform to decide fault. The complete manual path ships first; optional AI only drafts neutral photo descriptions after the uploader consents and never controls report state.

  • StatusDraft for written review
  • Core outcomeOne trusted move-in baseline per tenancy
  • Party flowLandlord drafts; tenant reviews and accepts
  • Review windowFixed seven calendar days
  • AI featurerental:condition_photo_describe
  • Authenticated UIfrontend/solidstart/apps/panel
  • Delivery5a manual end-to-end; 5b AI assistant
  • Next sliceMove-out comparison and deposit workflow
Depends on
2026-08-26-rental-platform-slice3-agreements-design.md and 2026-08-28-rental-platform-slice4-tenancy-payments-ai-design.md
Source brief
/Users/waynecheah/Downloads/DIY_Rental_Platform_Malaysia.md
Module
internal/modules/rental
Existing decisions
ADR-0056, ADR-0059, ADR-0062

Outcome and scope

Success criteria

  • At most one move-in Handover Report exists for a Tenancy.
  • The landlord drafts areas, condition, inventory, keys/access devices, and initial meter readings.
  • Creation and submission are allowed only from seven calendar days before through three calendar days after starts_on, using Malaysia time.
  • The tenant gets exactly seven calendar days to review; neither party chooses the length.
  • With the uploader's per-report consent and available Rental AI, each image may receive editable, unconfirmed area/item and neutral-condition suggestions.
  • The tenant disputes entries, reports omissions, attaches optional evidence, and accepts the complete report once; there is no per-entry acceptance.
  • The landlord may accept a correction or reply without rewriting the submitted baseline.
  • Completion or deadline expiry finalizes the report. Silence is explicitly no_tenant_response, never implicit acceptance.
  • Final structured evidence and its deterministic PDF cannot change after finalization.
  • Media remains private and party-authorized; APIs disclose neither object keys nor public URLs.

Included evidence and surfaces

Structured baseline

landlord
  • Room/area condition
  • Furnishings and appliances
  • Keys, cards, remotes, access devices
  • Initial electricity and water meter readings

Review evidence

both parties
  • Entry disputes and area omissions
  • Alternating replies and either-party resolution
  • Append-only comments and image evidence
  • Explicit report-level tenant acceptance

Private media

JPEG/PNG
  • Immutable originals
  • Display thumbnails
  • Metadata-stripped AI derivatives
  • Duplicate and low-resolution warnings

Party UI

SolidStart

Landlord drafting and tenant review live beside the tenancy ledger and review queue in frontend/solidstart/apps/panel. No new app is created.

Non-goals

Rejected capabilityWhy it stays out
Retrospective baseline or admin overrideA late report would look authoritative without proving move-in condition. A missed window permanently means “move-in report not completed.”
Mandatory dispute resolutionAn uncooperative party must not block finalization; the deadline freezes unresolved positions side by side.
AI adjudicationThe model cannot accept, resolve, score damage, identify fault, estimate cost, classify wear, or recommend deposit action.
VideoIt needs upload, transcoding, streaming, presentation, and AI pipelines. No media_kind or video enum is reserved; non-JPEG/PNG signatures are rejected.
Platform-admin evidence or per-report stateCase scope, purpose, access auditing, and retention are undesigned. Even status, dates, counts, and notification rows remain unavailable to staff.
Trusted capture timeEXIF/client timestamps are party-supplied evidence; authoritative server upload time is stored separately.

Decisions and tradeoffs

Structured records first

Canonical structured snapshots are the evidence source of truth; PDF is a rendering.

Areas, entries, media, AI drafts, review threads, and future move-out comparison need addressable records. Baseline and final states are server-canonicalized and SHA-256 hashed.

Rejected: a PDF-first form whose annotations and model output become unstructured blobs.

One landlord baseline

The landlord submits one baseline; the tenant reviews that exact frozen state.

Item disputes, omitted-condition reports, and deadline outcomes protect the tenant without requiring a report-merging engine.

Rejected: two independent inspections that must be reconciled before either side understands the record.

Fixed seven-day review

review_due_on = max(starts_on, Malaysia submission date) + 7 calendar days.

The tenant bears the deadline's consequence, so the counterparty cannot shorten it. A platform constant protects the tenant across holidays and deletes an unused form knob, column, constraint, and bounds tests.

Rejected: landlord-selected 3–14 days and tenant-selected windows. If demand appears, only extension-only configurability should be considered.

Append-only review and one complete-report acceptance

Disputes, omissions, alternating replies, resolutions, and withdrawals append immutable events; the tenant accepts the full hash-bound state once.

Fifty to seventy individual entries make per-entry tapping busywork and weak evidence. One explicit acceptance of the exact review hash is clearer and stronger.

Rejected: mutable comments, per-entry acceptance, and synthetic acceptance after silence.

AI provenance without vendor details in evidence

Applying AI text permanently sets ai_assisted=true; provider/model/prompt/request/consent data remains internal.

The marker was visible when the party reviewed the text, so it belongs in snapshots and PDF. Vendor configuration does not describe the property and adds disclosure cost.

Area-name highlighting or prefilling alone does not set the marker. Editing applied AI text does not clear it.

Finalize before rendering

One database transaction stores the final structured snapshot/hash; PDF rendering is lazy and content-addressed.

Renderer or object-store failures cannot reopen evidence state or erase an acceptance.

Rejected: blocking legal state on synchronous PDF creation.

Lifecycle and invariants

Report state machine. AI status never authorizes a transition, and finalized is terminal
Report state machine. AI status never authorizes a transition, and finalized is terminal.
Creation late boundary
Allowed through starts_on + 3 in Asia/Kuala_Lumpur; later creation is refused so a user cannot start a permanently un-submittable draft.
Submission window
starts_on - 7 through starts_on + 3 calendar days, inclusive.
Review deadline
Tenant may act through 23:59:59 Malaysia time on review_due_on; the sweep finalizes only after the date ends.
Late agreement
A tenancy established after its own window gets no report; the window is never re-anchored to establishment time.
  • Only the tenancy landlord mutates the draft baseline.
  • draft → tenant_review validates the window, entry requirements, ready evidence, and complete deterministic baseline snapshot in one transaction.
  • Areas, entries, and attached baseline media are immutable after submission.
  • Responses append only during tenant_review, through the end of review_due_on.
  • Early finalization requires every dispute and omission thread resolved plus one explicit tenant acceptance. It scans no per-entry review state.
  • Deadline finalization preserves open disputes, labels undisputed entries no_tenant_response, and creates no acceptance row.
  • The same undisputed entry is labelled accepted_by_report_acceptance after explicit acceptance.
  • No transition leaves finalized; AI has no transition authority.

Architecture and dependencies

Slice 5 remains inside the Rental hexagonal module and consumes shared infrastructure through narrow ports
Slice 5 remains inside the Rental hexagonal module and consumes shared infrastructure through narrow ports.
LayerResponsibility
domain/entity/State machine, submission/review windows, entry validation, response reducer, finalization eligibility.
application/port/Repositories, media store/processor, renderer, clock, narrow vision/prompt/gate ports.
application/usecase/Create/edit, entries/media, submit, review events, accept/finalize, deadline sweep.
adapter/inbound/http/Party-authorized report, media, response, acceptance, and document endpoints.
adapter/outbound/persistence/postgresql/Repositories and transaction extensions.
adapter/outbound/storage/Original, thumbnail, AI derivative, and final-document objects in the private bucket.
adapter/outbound/image/Decode safety, orientation, resize, metadata stripping, thumbnail, resolution warning.
adapter/outbound/pdf/Deterministic final-report rendering.

Reused seams

  • Rental's hexagonal layout and functional-option wiring.
  • Private-bucket Go streaming already used for ownership/payment proofs.
  • Slice 3 deterministic, content-addressed PDF pattern.
  • Slice 4 product-scoped AI authorization, vision runner, prompt service, capability filtering, kill-switch, caps, access records, and usage records.
  • Existing Rental scheduler pattern and Slice 4's shared notification claim/retry worker.

The application container supplies a present-or-absent AICapability. Tests replace AI, image processing, storage, rendering, mail, and clock through functional options. Report use cases know no provider names and import no external AI adapter.

Data model

All tables are Rental-owned and created through migrator_rental.go. Action times are TIMESTAMPTZ; user-facing lifecycle boundaries are Malaysian calendar DATE values.

Reports, areas, and entries

TableColumns and constraintsSemantics
rental_handover_reportsid, tenancy_id, report_type, status, submitted_at, submitted_ip, submitted_user_agent, review_due_on, baseline_snapshot, baseline_sha256, final_snapshot, final_snapshot_sha256, finalization_reason, finalized_at, document_key, document_sha256, created_at, updated_atreport_type=move_in for Slice 5; unique (tenancy_id, report_type). Status: draft, tenant_review, finalized. Reason: tenant_accepted or deadline_elapsed. Snapshots are server-produced; keys and submission IP/user-agent are never serialized.
rental_handover_areasid, report_id, name, sort_order, created_atLandlord-defined names, required and unique per report after trim/case-fold. Immutable after submission. Every area needs one ready baseline image.
rental_handover_entriesid, report_id, area_id, entry_type, label, condition_note, quantity, meter_value, meter_unit, ai_assisted, sort_order, created_atImmutable baseline records. Type-specific validation controls which quantity, condition, and meter fields are allowed.
Entry typeRequiredForbidden or extra rule
conditionLabel + condition noteNo quantity or meter fields.
inventoryLabel + quantity + condition noteNo meter fields.
access_deviceLabel + quantityCondition optional; no meter fields.
meter_readingLabel + value + unit + directly attached ready imageNo quantity. Value is non-negative NUMERIC(12,3); unit is kwh or m3.

Media and AI tables

rental_handover_media — lifecycle, targets, and idempotency

id, report_id, area_id, entry_id, response_id, uploader_user_id, upload_request_id, status, original_key, thumbnail_key, ai_derivative_key, mime_type, byte_size, pixel_width, pixel_height, original_sha256, thumbnail_sha256, reported_captured_at, uploaded_at, attached_at, quality_warnings, created_at, updated_at

  • No media_kind: every row is an image. Status is uploading, ready, or failed.
  • A ready attached row targets exactly one area, entry, or response. Staged rows may have no target during bulk upload.
  • Only the uploader may attach staged media through PATCH /handover-media/:id, to a same-report target while its workflow is editable.
  • Unattached staged uploads are omitted from snapshots and swept after 24 hours. Retry before cleanup resumes the lifecycle; after cleanup it returns 410 and requires a new upload.
  • (uploader_user_id, upload_request_id) is unique. Quality warnings contain deterministic codes, never AI prose.
rental_handover_ai_suggestions — immutable attempts

id, media_id, request_id, status, suggested_area, suggested_item, condition_note, provider, model, prompt_version, consent_version, consent_provider, consent_config_version, consented_at, processed_at, created_at

Status is succeeded, unavailable, failed, or invalid. request_id is unique and reused by the AI Access Event. Repeating the request returns the attempt; an explicit retry uses a new ID. Active means the latest success by processed_at, id. Consent columns are copied at call time so later withdrawal or re-consent cannot rewrite history.

rental_handover_ai_consents — one current authorization per uploader/report

id, report_id, user_id, disclosure_version, provider, config_version, consented_at, withdrawn_at, created_at, updated_at

Unique on (report_id, user_id). The row is updated in place; immutable provenance is copied to suggestion attempts. Each analyze call checks the media's uploader, active consent, provider, and config version. Withdrawal stops future calls without deleting history; re-consent clears withdrawn_at and updates the disclosure/config.

Review and acceptance tables

rental_handover_responses — append-only thread events

id, report_id, thread_id, target_entry_id, target_area_id, actor_user_id, actor_role, action, comment, ai_assisted, created_at

ActionWho and effect
dispute_entryTenant opens a thread against exactly one baseline entry.
report_omissionTenant opens a thread against exactly one baseline area; the system invents no entry.
replyEither party, required comment, optional ready media; never twice consecutively by the same actor.
resolveOnly the party who did not speak last accepts the other side's current position.
withdrawTenant retracts their own dispute or omission.

Every row belongs to a thread. The application reduces current state before insert; no row is updated or deleted. Alternation preserves the tenant's right to answer a landlord rebuttal without turning the feature into unlimited chat: the seven-day deadline ends the exchange and per-party media budgets bound it.

rental_handover_acceptances — explicit tenant acceptance only

id, report_id, user_id, accepted_snapshot_sha256, accepted_at, ip_address, user_agent, created_at

Unique on report_id. The accept command echoes the rendered review_state_sha256; under the report lock the server recomputes it, refuses mismatch, and stores the identical hash. Baseline submission creates no acceptance. There is no party_role, no server-manufactured landlord acceptance, and audit IP/user-agent appears in no API, snapshot, or PDF.

Shared notification table

Slice 5 creates no notification table. It uses Slice 4's ADR-0062 table:

rental_notification_events(id, subject_type, subject_id, kind, recipient_user_id, status, attempt_count, next_attempt_at, last_error, provider_message_id, sent_at, created_at, updated_at)

The natural key is unique on (subject_type, subject_id, kind, recipient_user_id). Report-grain events use subject_type=handover_report; each response-grain event uses handover_response with the append-only response ID. Slice 5 does not write Slice 4's superseded status.

Canonical evidence

Canonicalization is server-owned, versioned with schema_version, and defined independently of Go map iteration, host timezone, storage keys, or database-driver numeric formatting.

Baseline snapshot
  • Report/tenancy identifiers, party display names, property display address, start date.
  • Literal seven-day review rule.
  • Sorted areas and entries, attached original SHA-256 values, uploader, authoritative upload times.
  • Landlord submission time and every entry's ai_assisted value.
Final snapshot adds
  • Every response in deterministic order, including ai_assisted and media hashes.
  • Landlord submission act and baseline hash.
  • Tenant acceptance timestamp/hash if present.
  • Reduced resolved, unresolved, and no-response outcomes plus finalization reason/time.
Area and entry ordering
sort_order, then ID
Media ordering
Authoritative upload time, then ID
Response ordering
Server timestamp, then ID
Meter serialization
Exactly three decimal places: 88.400, never 88.4
Other fixed rules
JSON key order, timestamp format, and null handling

Party-authored labels, notes, comments, and replies are stored and rendered exactly as typed in UI, snapshots, PDF, and future comparisons. Only surrounding chrome is English; translating evidence would put words into a party's mouth.

AI boundary

Strict call contract

Feature
rental:condition_photo_describe
Product and mode
rental, platform mode; no landlord or tenant BYOK
Locale
en-MY; English-only chrome, PDF labels, and model output
Input
One consented metadata-stripped normalized derivative plus prompt; no object URL or user/NRIC/address/agreement/message data
Governance
Slice 4 vision-capability filter, global kill-switch, product caps, access event, usage record
{
  "suggested_area": "Kitchen",
  "suggested_item": "Lower cabinet door",
  "condition_note": "Visible horizontal scratch near the lower-left edge"
}

Every field is nullable, and null is the only uncertainty signal. The parser rejects prose, extra fields, overlong strings, and prohibited categories. An earlier unreadable_or_uncertain array was removed because it duplicated null, had no consumer, and invited editorialization.

Prohibited and permitted language

  • moneyRM, ringgit, cost, price, quote, estimate, currency-adjacent digitsreject
  • liabilityfault, blame, negligent, responsible, liable, caused byreject
  • depositdeposit, deduct, forfeit, withholdreject
  • adjudicationwear and tear, normal wear, pre-existing, tenant caused, landlord causedreject
  • repairneeds repair, must be replaced, should be fixedreject
  • severitysevere, serious, major, minor, badly, slightreject

Multi-word items are phrase tests. Visible-defect nouns such as damaged, scratch, stain, crack, chip, dent, mould, missing, and discoloured are permitted: they describe visible condition; severity grading is the first step toward a verdict. Party-authored text is never filtered.

Graceful absence and human authority

If AI is absent, disabled, capped, timed out, or invalid, the stored media remains ready and the uploader types manually. The media API reports AI status separately; a provider failure never turns successful evidence storage into HTTP 5xx.

Media flow and budgets

Upload and processing

  1. Create or resume lifecycle

    An authenticated party supplies an idempotency key inside an editable workflow. Repeating it returns the existing lifecycle.

  2. Preflight

    Validate declared type and size; create uploading row and private report/media key.

  3. Store original

    Stream immutable bytes to the private store.

  4. Decode safely

    Verify actual JPEG/PNG signature and safe pixel dimensions; fix orientation; reject decompression hazards.

  5. Derive

    Create thumbnail and bounded, reoriented, EXIF/location-free AI copy; compute original SHA-256 and low-resolution warning.

  6. Commit readiness

    Write all derivatives before marking ready. Failed rows stay out of snapshots and remain retryable/removable while editable.

Format or conditionOutcome
JPEG or PNG within limitsAccepted after signature/decode checks.
HEICSpecific 422 naming HEIC and asking the user to re-save as JPEG. Mobile Safari often converts, but not every share path does.
WebPRejected; Go's available decoder cannot encode it, creating unnecessary format asymmetry.
Video or renamed/invalid bytes422 before storage and before AI; no reserved schema path.
Duplicate bytesAccepted as ready with a private warning; never deleted or treated as fraud.
Low resolutionWarning only; uploader decides whether to keep it.

Separate party budgets

  • image bytesHard limit per JPEG/PNG before storage or AI5 MB
  • baseline/reportLandlord-owned total100
  • baseline/targetLandlord-owned per area or entry10
  • response/reportCounted separately for each party50
  • response/threadCounted separately for each party10

A shared pool is rejected because a thorough landlord baseline could consume the tenant's ability to prove an omission, and one party could crowd out the other's thread reply. These are conservative sizing assumptions, not validated market facts. Drafting UI must show remaining baseline capacity before the walkthrough becomes impossible to repeat.

Optional analysis

  1. Verify uploader consent

    Check active per-report consent for the media's uploader and the active provider/config version. Mismatch or withdrawal sends no image, records unavailable, and prompts that uploader once.

  2. Authorize governed feature

    Load the prompt and authorize rental:condition_photo_describe under product rental.

  3. Send minimal derivative

    Pass only the metadata-stripped normalized bytes and prompt, with no object URL or unrelated tenancy data.

  4. Record attempt

    Persist usage/error through the shared capability, parse strict nullable output, and copy consent provenance into the immutable suggestion row.

  5. Let the human author evidence

    Show editable draft text. Area matching is client-side trim/case-fold highlighting or prefilling only; the server does no fuzzy model-driven assignment.

Party workflows

Landlord draft and submit

  1. Create idempotently

    From the tenancy dashboard, require an existing Tenancy, the tenancy landlord, and Malaysian date no later than starts_on + 3.

  2. Build the draft

    Suggested common areas are UI conveniences. The landlord adds, renames, reorders, or removes areas and records condition, inventory, access, and meter entries.

  3. Attach evidence

    Upload images, optionally apply/edit AI labels and notes, and see missing fields, areas without images, meters without direct evidence, and remaining capacity.

  4. Submit atomically

    Lock report/tenancy; validate party, status, window, entries, ready media, per-area images, and meter images; canonicalize and hash baseline; derive review_due_on; store submit audit data; enter tenant_review; enqueue tenant notification.

The submission transaction calls no AI, storage, image processor, mail transport, or PDF renderer.

Tenant review and thread exchange

  1. Review the exact baseline

    The tenant sees the frozen snapshot and remaining time. Undisputed entries need no action.

  2. Open specific claims

    Dispute any entry with required comment and optional evidence, or report an omitted condition against an area.

  3. Alternate positions

    Either party replies with required text and optional ready evidence; the actor who spoke last cannot reply again.

  4. Resolve or withdraw

    The other actor may resolve by accepting the current position; the tenant may withdraw their own dispute or omission. All prior events remain visible.

  5. Freeze disagreement if necessary

    The deadline closes any still-open thread with both positions side by side. Nothing adjudicates it.

Early and deadline finalization

Explicit

Tenant acceptance

Precondition
Every thread resolved
Request
reviewed_snapshot_sha256
Reason
tenant_accepted

Under the report lock, recompute the review state. A stale hash returns 409 with current state; a match inserts the sole acceptance, stores final snapshot/hash, and enqueues both final notices.

Automatic deadline sweep

Precondition
Malaysia review date has ended
Acceptance
None created
Reason
deadline_elapsed

The sweep reduces responses, preserves open disputes, derives no-response outcomes, and stores the same canonical final evidence shape.

Acceptance and the sweep lock the same report row. Whichever commits first finalizes; the loser receives 409 with the finalized representation.

Final document

First authorized download renders final_snapshot, pins creation date to finalized_at, and caches deterministic bytes under the content hash. It groups by area and prints baseline observations, thumbnails, dispute/omission evidence, alternating replies/resolutions, resolved/unresolved/no-response labels, meter values, access quantities, separately labelled submission and tenant-acceptance timestamps/hashes, and the non-adjudication statement.

Authorization, privacy, and retention

ActionAuthorized caller
Create/edit/submit baseline; baseline uploadTenancy landlord, while draft and within applicable window.
View draft and mediaLandlord only; tenant discovery is empty and every direct report/media/document route is 404.
View submitted report and attached mediaVerified tenancy landlord or tenant.
Dispute entry/report omissionTenancy tenant during review.
Upload thread evidence; reply/resolveEither tenancy party when it is their turn on an open thread.
Accept complete reportTenancy tenant when every thread is resolved.
Download final PDF/originalsTenancy landlord or tenant.
Give/withdraw AI consentEach party for their own uploads on that report.
Configure/inspect Rental AI usageExisting platform-admin AI authority, without handover evidence or report state.

Object keys and AI credentials never enter API responses or snapshots. The other tenancy party sees the ai_assisted marker, but provider/model/prompt/request/consent/raw-suggestion provenance remains internal. Original evidence may expose uploader-supplied metadata to the counterparty after explicit upload disclosure; only stripped derivatives leave the platform for AI.

Provisional retention position

  • Malaysia PDPA 2010's Retention Principle argues against indefinite storage of home photos, IP addresses, and user agents.
  • Limitation Act 1953 section 6 motivates six years for contract actions, but time runs from cause-of-action accrual, not automatically ends_on. The chosen anchor is a product simplification because it is the date this module owns.
  • A later lifecycle design must revisit the anchor if deposit or other claims accrue later and implement legal/dispute holds.
  • One party cannot unilaterally erase shared evidence while its purpose remains; proper request handling belongs to the deferred case-scoped capability.

HTTP contract

POST   /api/rental/tenancies/:id/handover-reports/move-in
GET    /api/rental/tenancies/:id/handover-reports
GET    /api/rental/handover-reports/:id

POST   /api/rental/handover-reports/:id/areas
PATCH  /api/rental/handover-areas/:id
DELETE /api/rental/handover-areas/:id

POST   /api/rental/handover-reports/:id/entries
PATCH  /api/rental/handover-entries/:id
DELETE /api/rental/handover-entries/:id

POST   /api/rental/handover-reports/:id/ai-consent
DELETE /api/rental/handover-reports/:id/ai-consent

POST   /api/rental/handover-reports/:id/media
PATCH  /api/rental/handover-media/:id
GET    /api/rental/handover-media/:id/content?variant=thumbnail|original
POST   /api/rental/handover-media/:id/analyze
DELETE /api/rental/handover-media/:id

POST   /api/rental/handover-reports/:id/submit
POST   /api/rental/handover-reports/:id/responses
POST   /api/rental/handover-reports/:id/accept
GET    /api/rental/handover-reports/:id/document
No report PATCH
After removing configurable review length, every report field is server-owned; route absence communicates that contract.
Media content default
variant=thumbnail; omission must not stream up to 500 MB across 100 drafting tiles.
Forbidden variant
ai_derivative is explicitly rejected; it is a provider-processing artifact, not evidence or a party rendering.
Variant privacy/cache
Thumbnail and original share identical party authorization and Cache-Control: private. Each validator matches returned bytes; never validate a thumbnail with original_sha256.
Review-state echo
GET /handover-reports/:id returns exactly the review_state_sha256 the client must echo on acceptance.
Discovery summary
id, status, review_due_on, finalized_at, finalization_reason; never entries, comments, or media.

Discovery is state-aware: a draft exists only for the landlord. From submission onward both parties discover and read the same frozen baseline. After the late boundary, an empty list becomes the permanent “move-in report not completed” state visible to either party.

PATCH /handover-media/:id accepts exactly one of area_id, entry_id, or response_id, from the uploader, within the same report and editable workflow. It patches no bytes, hashes, or warnings. Delete applies only to caller-uploaded staged or draft-baseline media. Responses are validated commands; no generic response PATCH/DELETE exists.

Notifications and errors

Durable notifications

KindRecipient and triggerSubject grain
review_openedTenant after baseline submissionHandover report
review_due_soonTenant two calendar days before due date if not finalizedHandover report
thread_responseThe party who did not act, after each reply or resolutionAppend-only response
report_finalizedBoth parties after either pathHandover report

Every kind is durable: each has a collision-free natural key and is harmful to lose. Slice 4's single worker claims, retries, and backs off the same row. Delivery failure never moves a deadline. Provider idempotency uses the event ID when available, but ambiguous network failures may still duplicate mail.

Error contract

SituationBehaviour
Duplicate createReturn existing report idempotently.
Missing Tenancy / wrong caller404 for missing tenancy; 403 for non-party.
Create after starts_on + 3409; no draft; permanent not-completed state.
Submit outside window409; draft remains editable but cannot become a baseline after the late edge.
Incomplete/invalid baseline422 with itemized missing area/image/entry/meter problems.
Baseline mutation after submission409.
Unsupported/renamed/oversize/unsafe media422 before AI; HEIC gets a specific re-save-as-JPEG message.
Object write failureMedia unavailable; report unchanged; retry/remove while editable.
AI absent, denied, capped, failed, or invalidMedia succeeds; AI status reports fallback; manual entry works.
No/mismatched/withdrawn uploader consentNo Gate/Runner/provider payload; immutable attempt is unavailable; prompt correct uploader once.
Cross-party consent attemptRefused by (report_id, user_id) uploader check.
Any media budget + 1422 before storage/AI naming the exhausted budget.
Tenant action outside review409.
Accept with open thread409 listing unresolved targets.
Missing/stale review hash422 if absent; 409 plus current representation if stale; write no acceptance.
Invalid thread action409 for missing/resolved thread, consecutive same-actor replies, or accepting one's own position.
Acceptance/sweep raceOne row lock winner; loser receives finalized state/409.
Final snapshot failureStay in tenant_review; retry later.
PDF render/store failureRemain finalized; download returns 502; later request regenerates.
Third-party report/media/document request403 without object metadata or bytes.

Test strategy

State, windows, snapshots, and finalization
  • All valid/invalid state transitions and no exit from finalized.
  • Month/year boundaries, leap day, Malaysia conversion, both window edges, instants outside, creation on +3, refusal on +4, and a tenancy established after its window with no draft row.
  • Fixed seven-day due date with no request override and literal rule in the snapshot.
  • Baseline atomicity under injected snapshot-write failure and immutability on every baseline mutation endpoint.
  • Semantic-equivalent insertion/map order, host timezone, JSON implementation, and numeric formatting converge on one SHA-256.
  • No manufactured acceptance; only tenant accept writes the sole row. Audit IP/user-agent appears in no API, snapshot, or PDF.
  • Early acceptance with zero or resolved disputes, refusal for open threads, stale/absent echo writes nothing, and stored/echoed/recomputed hashes match.
  • Acceptance/sweep concurrency produces one final snapshot and finalization notification set.
  • Outcome labels distinguish explicit acceptance from deadline no-response, with no synthetic deadline acceptance.
Entries, responses, and media
  • All type-specific required/forbidden entry fields; area and meter image requirements.
  • Meter unit outside kwh/m3, negative values, NUMERIC(12,3) round-trip, and identical 88.4/88.400 hashes.
  • Reducer coverage for dispute, omission, alternating replies, either-party resolution, withdrawal, invalid repeats, own-position resolution, unresolved deadline, and absence of per-entry acceptance.
  • Thumbnail default, original streaming, AI-derivative rejection, same authorization, no object keys, and per-representation validators.
  • JPEG/PNG signatures, specific HEIC and WebP rejection, 5 MB edge, decoded-dimension safety, orientation, thumbnail/derivative creation, EXIF removal, and original hash.
  • Every media budget at limit and limit+1 before storage/AI; one party's capacity never reduces the other's.
  • Attachment requires exactly one same-report target and uploader; refuses zero/two targets, cross-report, non-uploader, or frozen workflow; patches no immutable fields.
AI, privacy, documents, and notifications
  • No consent means no Gate/Runner; provider request contains only derivative and prompt, not identity/address/agreement/messages.
  • ai_assisted sets only on applied evidence text, survives subsequent edits, ignores area-name convenience, appears in snapshots/hashes/PDF, and never leaks vendor or raw-suggestion details.
  • Consent covers one uploader's report images, never crosses parties; provider/config change blocks; withdrawal preserves history; re-consent updates same row; copied provenance never changes.
  • Parser covers nullable success, prose, extra fields, length, every prohibited category and multi-word phrase, while visible-defect nouns including damaged pass and party text is never filtered.
  • Human-authority golden test proves AI cannot mutate baseline, respond, resolve, accept, or finalize; complete upload-to-final path passes with AI absent/failing.
  • Draft visibility gives tenant empty discovery and 404 direct routes until submission.
  • Privacy golden test covers every platform role against media, party text, snapshots, document, report metadata, counts, and notification rows.
  • PDF determinism across host timezones and concurrent first downloads.
  • Notification natural-key uniqueness, retry through shared worker, unchanged deadlines, and distinct opposite-party events for multiple replies.

Delivery plan

Release 5a

Complete manual handover

Steps 1–9 ship draft → submit → review → finalize → deterministic PDF with no AI tables, endpoints, calls, or true ai_assisted values.

Release 5b · Optional AI assistant

Step 10 adds consent, governed analysis, parser/provenance, and opt-in UI only after 5a passes end to end. Provider readiness cannot block 5a.

  1. Prerequisites

    Land Slices 3/4: Tenancy and product-scoped vision.

  2. Domain + draft

    Entities, migrations, rules, repositories, APIs.

  3. Private media

    Decode safety, derivatives, limits; real-device iOS Safari test.

  4. Landlord UI

    Drafting and baseline validation in panel.

  5. Submit

    Atomic snapshot/hash, audit metadata, tenant notice.

  6. Review

    Append-only reducer, omissions, alternating replies, tenant UI.

  7. Finalize

    Acceptance, deadline sweep, final snapshot/UI.

  8. Document

    Lazy deterministic PDF and concurrency tests.

  9. AI

    Prompt/parser/provenance and opt-in assistant UI.

Release acceptance

ReleaseMust prove
5aLandlord drafts/submits stable baseline in-window; tenant notification/review/disputes/omissions/evidence work; alternating replies and either-party resolution enforce turn rules; hash-bound early and no-response deadline finalization both work; snapshots and deterministic PDF are produced; notifications are durable/retryable; non-party access fails; no AI implementation exists.
5bEach uploader consents once per report and re-consents only after config/provider change or withdrawal; analysis checks the media uploader; all prohibited categories reject safely; only applied text is marked; no vendor/provenance leak reaches evidence; every 5a criterion still passes when AI is disabled, capped, or failing.

Risks and open questions

RiskMitigation or accepted consequence
AI sounds like a damage verdictStrict nullable schema, neutral prompt/copy, six prohibited categories with tests, user confirmation, and no model transition authority.
New AI locale silently bypasses English guardsEnglish-only en-MY; reviewed language-specific denylist/tests must land before any locale enablement.
Landlord omits or later changes an issueTenant area-level omission with evidence; baseline hash and immutability after submission.
Tenant silent or parties disagreeDeadline records explicit no-response or freezes both positions; never implies acceptance and never blocks indefinitely.
Late report appears credibleHard creation/submission window, no re-anchoring, no backdating/admin override.
Metadata reaches AI or counterpartyAI gets EXIF-stripped derivative only; original-sharing disclosure is explicit and server upload time is authoritative.
Malicious or huge decoded imageSignature, byte, dimensions, and decompression checks before derivatives/AI.
Storage/AI costPer-party budgets plus product caps/kill-switch. At maxima, originals can approach 1 GB per report before derivatives.
No purge shipsRetention position is explicit; later lifecycle slice implements deletion and may preserve snapshot/hashes/PDF while purging originals.
Retention anchor is legally wrongSix years after ends_on is provisional; Malaysian counsel reviews cause-of-action timing before launch; holds extend it.
PDF failure blocks evidenceStructured finalization commits first; rendering is lazy and retryable.
Video becomes accidental half-supportNo enum or column; reject signatures; future slice owns upload/transcode/stream/AI design.
Historical reports cannot support move-outVersioned canonical structured snapshot remains authoritative; PDF is only a rendering.
Support needs report accessDeliberately deferred until case scope, purpose, audited lookup, and retention are designed; aggregate monitoring only.

What this unblocks

Slice 5 provides Slice 6's trusted “before” state for AI-assisted move-out comparison, party review, deposit deduction proposals, and deposit return. It also establishes stable area, inventory, media, and append-only evidence concepts for maintenance and periodic inspection without letting later workflows mutate the move-in baseline.