Chickadee Bandit

Apps run inside your family hub and still can't read your family's data

How a declared manifest, row policies and SQL rejected at admission keep an installed app inside the rows it is entitled to.

Engineering › App permission model

Chickadee Bandit is a hub with an app library. A household installs Chores, Calendar, Medication Tracker and a dozen others, and each one stores its own data — tasks, doses, RSVPs — in the household's own database. That is the useful part. It is also the part that should worry you, because "an app in your family hub" is exactly the shape of thing that historically reads your contacts.

The usual answer is a permission prompt: the app asks, you tap Allow, and after that you are trusting the app. We did not want that answer. What follows is what we built instead, and the one design decision in it that is genuinely unusual.

Every app ships a manifest, and the manifest is a contract

An app is a bundle plus a `manifest.json`. The manifest names the hub data the app may read and write, the tables it owns, and the access rules on those tables. The hub validates it against a schema at publish time and again at install.

{
  "id": "chores",
  "runtime": "static",
  "storage": "db",
  "data_access": {
    "reads": ["family.members", "family.app_members"],
    "writes": []
  },
  "permissions": { "default_audience": "everyone", "requires_approval": false },
  "min_age": 0
}

`data_access.reads` is drawn from a closed vocabulary — `family.members`, `family.calendar`, `family.health.medications`, `family.locations`, and a handful more — plus cross-app keys of the form `app.{appId}.{key}` that the other app has to have exported. An app that did not ask for `family.locations` cannot obtain locations, because the endpoint that would serve them checks the installed manifest, not the request.

Note what the Chores manifest above does not ask for. `family.app_members` is the subset of the household who can actually open this app; `family.members` is everyone who lives here. Most apps want the first and ask for the second out of habit, and reviewing that difference is most of what app review is.

The manifest is a declaration. Row policies are the enforcement

Declaring intent is not enforcement, and a manifest is written by the same person who wrote the app. So the part that matters is not the data the app asked for — it is what happens to the SQL the app runs against its own tables.

Each table an app owns carries a row policy, also declared in the manifest, also in a closed vocabulary. A policy says how rows on that table relate to the people in the household:

{
  "row_policies": {
    "entries": {
      "kind": "owner_or_visibility",
      "owner_column": "member_id",
      "visibility_column": "visibility"
    }
  }
}

`owner_or_visibility` means: a row belongs to a member, and carries its own visibility. There are others — `couple_scoped`, `party_scoped`, `channel_scoped`, `inherit_visibility` for rows whose audience comes from a parent row. Each one is a shape the hub knows how to enforce, not a predicate the app supplies. That distinction is the whole design: an app chooses from policies the platform implements, and cannot express a policy the platform cannot check.

The hub then rewrites every statement the app sends. A `SELECT` gets the policy's WHERE guard appended. An `UPDATE` or `DELETE` gets the same guard, so a write that would have touched another member's row matches zero rows instead of being refused with an error that tells the app the row exists. An `INSERT` has its owner column pinned to the calling member rather than read from the app's parameters.

SQL that the hub cannot prove safe is rejected, not filtered

Here is the part people argue about.

The obvious way to build the previous section is a runtime filter: run the app's query, then drop the rows the caller should not see. That is how a lot of row-level security is done, and it is wrong in a way that only shows up under adversarial SQL, because "drop the rows" assumes you know which rows came from where.

Instead, the hub parses the app's statement into an AST before it runs, and any statement whose safety it cannot establish is refused outright:

select * from app_chores__entries where 1 = 0
union all
select * from app_chores__entries

A filter-afterwards design has a real problem with that query. A rewriter that guards `FROM` clauses has a worse one: our SQL parser hangs the second and later arms of a `UNION` off a separate node, so the first arm is the only one a naive walk sees. The trailing arm would read straight through the policy and return every member's rows.

So the admission step enumerates every table named in every arm, CTE and subquery, and refuses the statement when a governed table is referenced in a shape it cannot guard. It also refuses multiple statements in one string, statement types outside `SELECT`/`INSERT`/`UPDATE`/`DELETE`, and anything the parser could not parse at all. If the table-enumeration step itself fails, the statement is refused — the failure mode is a rejected query, never an unguarded one.

The app author feels this as a constraint: some SQL that is perfectly ordinary gets a "not supported" error and has to be rewritten as something the hub can reason about. That trade is deliberate. An unsupported query is an app bug someone fixes in an afternoon. An unguarded query is a data leak nobody notices.

Two locks that fail differently

Table and column names in a manifest must match `[a-z][a-z0-9_]*`, at most 64 characters. The hub also quotes every identifier at every interpolation site, and it is the quoting that is the actual defence.

Keeping both is not redundancy for its own sake. They fail differently: quoting contains a hostile name at the moment it is used, while the schema refuses to admit one at all. If a new interpolation site is ever added without quoting — and eventually one will be — it inherits a name that was never dangerous in the first place.

The same principle is why the enforcement layer never trusts the manifest. The manifest says what the app intends; the hub decides what the app gets. An app that lies about its own `required_capabilities` still gets a 402 from the endpoint, because the endpoint checks the household's entitlements rather than the app's claim about them.

What this does not do

Being specific about the limits is the only way the rest of it is worth reading.

Apps in one household share that household's database, isolated from each other by a table-name prefix (`app_chores__entries`) rather than by database-level roles. Physical per-household separation — every household gets its own database — is what makes that acceptable, and it means the blast radius of a hub bug in this layer is one household, not all of them. It is not the same as each app having its own database.

Row policies govern rows. They do not govern bytes: a file an app stores is authorized at the point of upload and download, on a different path with its own rules. And nothing here defends against an app the household explicitly grants broad access to. The model constrains what an app can reach without being asked; it cannot constrain what someone chooses to hand over.

The last limit is the honest one. Everything above is code we wrote, and the argument for it is that it is structural rather than a promise — a query the hub cannot prove safe does not run, whatever anybody intended. That is a better guarantee than a permission prompt. It is not the same thing as a proof.

Read next

Written at Chickadee Bandit, a private hub for running a household — the thing all of this is load-bearing for. More engineering posts.