[{"data":1,"prerenderedAt":120},["ShallowReactive",2],{"journal-\u002Fjournal\u002Fa-five-star-rating-cant-tell-you":3},{"id":4,"title":5,"body":6,"date":106,"description":107,"draft":108,"extension":109,"meta":110,"navigation":111,"path":112,"seo":113,"stem":114,"tags":115,"__hash__":119},"journal\u002Fjournal\u002Fa-five-star-rating-cant-tell-you.md","A Five-Star Rating Can't Tell You Whether You Can Work There",{"type":7,"value":8,"toc":98},"minimark",[9,13,16,21,24,27,30,34,37,40,43,46,49,52,55,59,62,70,73,76,79,82,85,89,92,95],[10,11,12],"p",{},"Upfront, so nobody has to guess: this is a design exercise, not a product. No users, no deployment, and the database layer has never had a real write executed against it. What follows is the reasoning and the schema. Thinking made concrete, not something that shipped.",[10,14,15],{},"The premise. People in Indonesia increasingly work out of cafes, and no existing rating helps them choose one. A cafe with 4.5 stars might have beautiful coffee and one power outlet.",[17,18,20],"h2",{"id":19},"the-rating-is-the-product","The rating is the product",[10,22,23],{},"A single averaged score destroys exactly the information you need. \"Good cafe\" and \"good cafe to work from\" are different questions, and the second breaks into three specifics: does the wifi hold up, can you sit comfortably for three hours, is there power.",[10,25,26],{},"Wifi matters most, and it has a property that breaks conventional rating design. It's conditional on how busy the place is. Wifi that's excellent at 10am and unusable at 1pm is, for someone planning an afternoon of work, bad wifi. An average across both describes a cafe that doesn't exist.",[10,28,29],{},"So the schema records wifi separately for normal, busy and peak conditions.",[17,31,33],{"id":32},"nullable-on-purpose","Nullable on purpose",[10,35,36],{},"Here's the decision I'd defend hardest.",[10,38,39],{},"Only the normal-conditions rating is required. Busy and peak are nullable, because you can only honestly rate a crowd level you sat through. Someone who visited on a quiet Tuesday morning has no idea what Friday lunchtime looks like, and inviting them to guess produces confident data that's wrong.",[10,41,42],{},"So a place with no crowded-hours reports shows as unknown. Never as fine.",[10,44,45],{},"That propagates into filtering, which is where it stops being a schema detail and becomes a product position. Filter for \"wifi stays fast when crowded\" and the query matches the crowded reading specifically, so places with no crowded data are excluded rather than included on the strength of their quiet-hours score.",[10,47,48],{},"That's a deliberate choice to show fewer results. The alternative, treating missing data as acceptable, is how you send someone across town to a cafe nobody has ever tried to work in during a rush. Missing data is not good news, and most rating systems quietly treat it as though it were.",[10,50,51],{},"The summary view exposes how many crowd-level reports back the headline number, so the interface can say how much evidence there is instead of presenting one report and forty identically.",[10,53,54],{},"Two smaller decisions in the same spirit. Power is a tier, plenty through none, rather than a star rating, because \"3.5 stars for power\" means nothing while \"limited\" means something. It converts to a number only for averaging. And seating is a set of tags, solo through groups of five plus, sofa, high table, outdoor, rather than a comfort score alone, because a perfect single bar seat is useless to a team of four and no scalar captures that.",[17,56,58],{"id":57},"the-authorization-problems-were-the-interesting-part","The authorization problems were the interesting part",[10,60,61],{},"Two rules turned out not to be expressible the obvious way.",[10,63,64,65,69],{},"Roles can't live on the profile. The natural place for a ",[66,67,68],"code",{},"role"," column is the user's profile row. But members legitimately need to update their own profile, name and avatar, and any policy permitting that permits writing every column on the row, including the one saying whether you're staff. Self-service profile editing becomes self-service promotion.",[10,71,72],{},"So roles live in a separate table with a read policy and no insert or update policy at all, meaning nothing writes it through the API regardless of who's asking. Changing a role is deliberately out-of-band. The helper checking staff status runs with elevated rights so it can read a table normal users can't, with a note to itself never to call that helper from the role table's own policy, since a policy invoking a function that reads the table the policy protects is recursion waiting to happen.",[10,74,75],{},"Capacity limits are not expressible in a policy. This is the one I'd point at if someone asked what the exercise was worth.",[10,77,78],{},"An event has a capacity. The intuitive rule is a policy permitting an insert while the current attendee count is below it. That rule is wrong, and it's wrong in a way that reads as correct. Two people requesting the last seat at the same moment both evaluate the count before either has inserted, both see space, both pass, both get in.",[10,80,81],{},"Row-level security evaluates per row against a snapshot. It can't serialize a decision depending on a count that includes the row being inserted. The fix isn't a cleverer policy, it's accepting the rule doesn't belong in one. Joining goes through a function that locks the event row first, so concurrent joins queue and the second correctly fails. The function runs elevated but derives the user from the session rather than trusting an id passed in by the caller, which is the difference between a controlled escalation and a hole.",[10,83,84],{},"Similar case for submission status. A client can send whatever status it likes, so a trigger overwrites it and lands non-staff submissions as pending regardless. Validation you can bypass by editing a request isn't validation.",[17,86,88],{"id":87},"the-architecture-bet","The architecture bet",[10,90,91],{},"There are no server routes. Every read and write goes from the client straight to the database, with the database's own policies as the only enforcement layer.",[10,93,94],{},"Real constraint, and it's why capacity needed a locking function rather than a few lines in a controller. The reason for accepting it is that a client-only application can be packaged as a desktop or mobile shell later with no backend to host or operate. For a project with no revenue and no users, nothing to run is worth more than convenience.",[10,96,97],{},"Whether that trade survives contact with real usage I don't know. It never got there. But the thinking transfers, and \"some rules aren't expressible where you first reach for them\" is a lesson I've since used on work that does have users.",{"title":99,"searchDepth":100,"depth":100,"links":101},"",2,[102,103,104,105],{"id":19,"depth":100,"text":20},{"id":32,"depth":100,"text":33},{"id":57,"depth":100,"text":58},{"id":87,"depth":100,"text":88},"2026-08-23","A design exercise for a work-from-cafe app. The interesting decisions were making the important ratings nullable, and finding the rules a database's row-level security genuinely cannot express.",false,"md",{},true,"\u002Fjournal\u002Fa-five-star-rating-cant-tell-you",{"title":5,"description":107},"journal\u002Fa-five-star-rating-cant-tell-you",[116,117,118],"product","modelling","postgres","GZy53G-FdgQrIJ445hf6G3GKSDSxtJ4CHzj1dHLO4Sk",1787915616665]