Notes

Rent collection is a data protection problem

Handling tenant data under the Kenya Data Protection Act 2019: why suppression rules have to be ordered rather than merely present, what the frequency caps are, and the subject-access case that breaks your login assumptions.

A property management system holds an unusually sensitive combination: where somebody lives, what they earn relative to their rent, whether they are behind, their phone number, and a complete history of messages sent to them. Under the Kenya Data Protection Act 2019 that is personal data being processed on a landlord's behalf, and the obligations are concrete rather than aspirational.

Kigs Apex is registered with the Office of the Data Protection Commissioner as a data processor. The two design decisions below are the ones that turned out to matter most in the code.

Suppression rules must be ordered, not just present

Before any SMS goes out, it passes a gate. The gate has four checks, and the order is written down because getting it wrong produces behaviour that is wrong in a way tests do not catch:

  1. Opted out. Indefinite, regulator-mandated, and it supersedes everything below it.
  2. Processing restricted. The Article 18 right, exercised through a data-subject request.
  3. Quiet hours. A timing constraint that comes with the licensed channel.
  4. Frequency caps. Hourly and daily.

An opted-out tenant must never reach the quiet-hours check. If they did, the reason recorded against the suppressed message would be the wrong one, and a reason of "quiet hours" quietly implies we would have sent it at ten in the morning. That is not a cosmetic difference. It is the difference between a record that defends you and one that convicts you.

That final comment is the whole idea. All four checks block the message, so any order produces the same outcome for the tenant. But they do not produce the same audit trail, and the audit trail is the thing a regulator reads.

A log entry saying a message to an opted-out person was suppressed for quiet hours asserts, implicitly, that you would have sent it at 10:00. It records a system that respects a timing rule and has forgotten a withdrawal of consent. The suppression reason is a statement about why you did not act, and it needs to be the strongest true reason, not the first one you happened to check.

We now treat ordering as part of the specification of any rule chain where the reasons get recorded. If your reason codes are ever read by anyone, their precedence is a requirement, not an implementation detail.

Frequency caps, and counting the right thing

The caps are deliberately low.

One message per hour, three per day, per phone number. Automated follow-up that exceeds that stops being follow-up. There is a bypass for genuinely transactional messages (one-time passwords, payment receipts), because a tenant who has just requested a login code should get it, and that is a different category from a reminder the system decided to send.

The subtle part is what the counter counts. It counts messages that were actually delivered, not messages that were attempted. Suppressed and provider-failed attempts still write audit rows, because you want those, but counting them would burn a tenant's daily allowance on messages they never received. A cap that punishes the recipient for your outage is not a protection.

Every attempt is written to the log, including the ones that were suppressed or that the provider rejected; you want all of that for audit. But the cap only counts messages with a send timestamp. Counting attempts would mean a tenant whose messages all failed at the gateway silently loses their allowance for the day, and the more your provider misbehaves the fewer messages your tenants receive. The audit log and the rate limiter read the same table and ask different questions of it.

The subject-access request that has no account

Rentisha implements the six request types the Act provides for: access, rectification, erasure, restriction, portability and objection. Restriction is the one that connects back to the gate above; a tenant exercising it sets a restriction flag, which the second check in the gate reads.

The design problem is authentication, and it is genuinely counterintuitive. A data-subject request can arrive from someone whose record has already been erased or anonymised at their own earlier request, which means the ordinary tenant-scoped path cannot be the only way in. Identity has to be established for the request itself rather than assumed from a session, and the write path has to work for a requester the system can no longer look up.

Someone who successfully exercised erasure last year has no account. If your subject-access flow requires a login, you have built a system where the people with the strongest claim (those you have already erased, or who never had portal access) are the ones who cannot ask you anything. The right to make a request cannot be gated on the state of the data the request is about.

So submission is anonymous and identity is verified out of band, which is more work and is the only version that actually satisfies the right.

Isolation is enforced by the database

One landlord's tenants must never be visible to another's. That is enforced in Postgres with row-level security rather than in application where clauses: every tenant-scoped read and write runs inside a transaction that first sets the current organisation, and the policies filter from there.

The distinction matters because application-level filtering fails open; one forgotten clause in one query leaks data across organisations, and the code looks entirely normal. Database-level policy fails closed: forget the scope and you get nothing back rather than everything. There is a dedicated integration test suite that asserts a session scoped to one organisation cannot select, insert into, or update another's rows.

What we would tell someone building this

  • If suppression reasons are logged, their order is part of the spec.
  • Rate limits should count what the person received, not what you attempted.
  • Subject-access flows cannot assume the subject still has an account.
  • Push isolation down to the database, where forgetting it fails closed.
Keep reading