- Shipped
- September 8, 2026 at 6:38 PM UTC
- Author
- Kamo
- Commit
- 7d728d6
The companion to the kamo-internal commit, and the half that actually mattered. The browser gate was cosmetic: ChessController, HoldemController and CheddarWalletController each asked only for a RIGHT — PLAY_CHESS, PLAY_HOLDEM, ACCESS_KAMOWORLD — or for organization ownership, and neither of those answers "does this organization have the app". - Rights are not entitlement-filtered. The game rights are seeded onto org roles wholesale, so a member of a tenant whose plan never included Games, and whose applied child-org security model marks it NOT_AVAILABLE, still holds them and auth.hasRight still answers true. - Ownership is not a purchase. The owner branch exists so a right granted to nobody on its first day is still reachable by the person who grants it; it was written as a blanket fallback on the whole endpoint, which let every owner of every organization play games their tenant does not have. So a member of an organization with no Games app could play chess, sit at a Hold'em table and read a Cheddar balance, through endpoints that answered them cheerfully — while the nav icon that led there was the only thing anybody reported. GamesEntitlement routes the question through **************** which is the canonical "is this app on" check and the same one the browser's organization projection is built from: the applied model's availability, the org_features row, and the active subscription's feature matrix. Reading any one of the three alone gives a different — and wrong — answer. Asked per CHILD app (CHESS, HOLDEM, KAMO_UNIVERSE), never on the GAMES parent, because each child is separately entitled. It runs BEFORE the right in all three controllers, and neither the right nor ownership may stand in for it. The wallet now pairs each game with its own right rather than accepting any games right for any game. Refuses with 404 rather than 403: an app this tenant was never given is not a thing that exists for them, and 403 would advertise a product through an error message. Fails closed — an unresolvable organization is not entitled — and carries @RetryOnDbConflict, since these are the tables Yugabyte returns 40001 on in bursts and a conflict would otherwise be a 500 on an endpoint whose real answer was yes. 625 tests pass, including 7 new ones for the gate.