Data Model ExplorerOntology ExplorerArchitecture ExplorerPrototypes

GIK Marketplace · data model

This is a map of the GIK data: what gets stored and how the pieces connect. Start with the spine, then pick a domain to see its tables.

V3draft Latest iteration: the standing networks, events, needs, sharing and impact R1–R38 · 2026-10-10 — full series under Version history →

The spine — seven stages

  1. 1. Onboardwho is this orgWho is this organization, and which side of the exchange are they on?organization, profile, organization_member, onboarding_submission, join_policy
  2. 2. Declareneed and supplyWhat does an NPO need, and what can a supplier give?need, resource, need_resource, resource_import, disaster
  3. 3. Qualifywho is eligibleWho is eligible for what, under which constraints?organization_restriction, resource_restriction, restriction_type
  4. 4. Matchwhat answers whatWhich resources answer which needs, and how well?match_run, match_score, search gate, grade
  5. 5. Offera supplier commitsWhich supplier commits to which need?offer, offer_item, offer_status, offer_source
  6. 6. Fulfillgoods moveThe promise becomes goods in motion.cart, order, order_item, order_transport_details
  7. 7. Accountwhat happenedWhat actually happened, and who saw it?activity_event, notification, activity_visibility
StageThe question it answersCore concepts
1OnboardWho is this organization, and which side of the exchange are they on?organization, profile, organization_member, onboarding_submission, join_policy
2DeclareWhat does an NPO need, and what can a supplier give?need, resource, need_resource, resource_import, disaster
3QualifyWho is eligible for what, under which constraints?organization_restriction, resource_restriction, restriction_type
4MatchWhich resources answer which needs, and how well?match_run, match_score, search gate, grade
5OfferWhich supplier commits to which need?offer, offer_item, offer_status, offer_source
6FulfillThe promise becomes goods in motion.cart, order, order_item, order_transport_details
7AccountWhat actually happened, and who saw it?activity_event, notification, activity_visibility

The thirteen domains — one Postgres schema (public); domains are a decided grouping, not a database boundary

identity2 tables
The people who use GIK, and how they like to be contacted. Each person's profile and their notification settings.
View ERD →
organizations8 tables
The nonprofits and suppliers that take part, where they operate, and who belongs to each. Each organization, its locations, members, invitations, partners, followers, verified web domains, and any limits on who it may deal with.
View ERD →
resources5 tables
What a supplier can give away, and on what terms. Each listed item, its photos, who may claim it, and the spreadsheets suppliers upload to list many items at once.
View ERD →
needs3 tables
What a nonprofit is asking for, and how urgently. Each request, the items and quantities on it, its photos, and the disaster it answers.
View ERD →
matching2 tables
GIK's automatic search for supplies that could answer a need. Each search run and the scores it gave; a good score is not yet an offer.
View ERD →
offers2 tables
A supplier's promise to give specific items toward a nonprofit's need. Each offer, the items on it, and whether it is waiting, accepted, declined, or cancelled.
View ERD →
orders5 tables
Goods on the move, what was agreed, who ships it, and where it is going. Carts, orders, the items on them, and the pickup and transport details for each.
View ERD →
disasters2 tables
The events that make a need urgent, such as a hurricane or a flood. Each disaster, when it happened, and the FEMA details GIK keeps up to date.
View ERD →
auth6 tables
How a partner's software proves who it is when it talks to GIK. API keys, connected apps, their sign-in tokens, and a log of every call they make.
View ERD →
webhooks2 tables
The messages GIK sends to a partner's software when something happens. What each partner asked to be told about, and every delivery attempt and whether it landed.
View ERD →
notifications2 tables
What GIK tells a person, and by which channel. Each notice, who received it, how it was sent, and whether it has been read.
View ERD →
platform4 tables
Shared record-keeping the rest of GIK leans on. The activity log, uploaded files, sign-up requests from people without an account yet, and app settings.
View ERD →
networks9 tables
Groups that organizations form around one event to share goods and work together. Proposed, not built yet. Each network, who belongs to it, the invitations to join, and what members have chosen to share.
View ERD →

Notes and recommendations

Findings from reading the schema as decided, not the database as deployed. Each one was verified against the migration SQL this explorer is generated from, and the constraint — or its absence — is cited so you can check the claim rather than take it on trust. Re-verified 2026-09-30 against gik-platform develop at bbacfcde, the snapshot master schema 1.2 reads; every count below is from that SQL.

Findings 1 and 3 have since been fixed on develop, both substantially as recommended here, and are struck through rather than deleted — the reasoning is the record of why the change was made. Finding 4's membership half was settled by taking this document's fallback option (rename, don't scope). main (31262c9f) still carries the 1.1 shapes, so all three fixes are merged and unreleased.

Ordered by what we would fix first. Of the three we would have raised in a meeting, two are now closed; the rest are worth a ticket each.

1. ~~Every profile, including email, is readable by the anonymous role~~ — fixed on develop

> Resolved in master schema 1.2 (gik-platform develop bbacfcde, carried by > migration 20260918180100_profiles_column_grants.sql). anon is revoked > outright, the SELECT policy is now TO "authenticated", and authenticated > holds a column-scoped GRANT SELECT omitting deleted_metadata — which closes > the deleted-user half of this finding as well. The fix went further than the > recommendation below: a column grant rather than a view, so select("*") on > this table now fails with 42501 and every caller must name its columns. > Unreleased — main still carries the old grants.

profiles granted full table privileges to anon, and its SELECT policy carried no role restriction and no predicate. The two statements, as they stood at 1.1:

  • GRANT ALL ON TABLE "public"."profiles" TO "anon";
  • CREATE POLICY "auth" ON "public"."profiles" FOR SELECT USING (true);

A policy written without a TO clause applies to PUBLIC — every role, including anon. Combined with USING (true), any caller holding the project's anonymous key can read every profile row, email included. Nothing about this requires an account.

It is worse for deleted users. profiles uses tombstones rather than deletion: the visible PII columns are anonymised at delete time, and the original values are stashed in deleted_metadata on the same row. RLS is row-level, not column-level, so that column is readable under the very same policy. A user who deletes their account keeps their real name and email in a field the anonymous role can read.

Recommendation. Decide what a profile is meant to expose publicly. If the answer is a display name and avatar — which is all the "resolve a name for attribution" argument actually needs — expose those columns through a view, or scope the policy TO authenticated and serve public attribution from a narrower surface. deleted_metadata should not be reachable through a row policy at all; move it somewhere only service_role can read.

Taken, in the second form: the policy was scoped TO authenticated, and deleted_metadata was put out of reach with a column grant instead of being moved.

2. Eligibility is not enforced anywhere in the database

organization_restrictions and resource_restrictions describe who may receive what. Nothing in the database makes those rows binding: there is no CHECK constraint, trigger, RLS policy, or function anywhere in the schema whose definition mentions eligibility. The restriction tables do carry RLS, but it governs who may read and write restriction rows — not whether a restriction is honoured when an offer or order is created.

The only enforcement is client-side, in application code, and it checks a single field. A direct API call, a service-role write, or a bug in one screen bypasses the rules entirely, and the database will accept the row.

Recommendation. Decide deliberately whether eligibility is a rule or a hint. If it is a rule it needs a database-layer gate — a trigger, or an RPC all writes go through — because a client-side check is unenforceable by construction. If it is a hint, say so in the schema documentation so the next engineer does not assume protection that is not there. Either answer is fine; the current state is that the model implies enforcement it does not have.

The 1.1 schema shows what the alternative looks like in this same codebase. The unlisted rule on resources (GIK-336) is held by two triggers that refuse or correct the write wherever it comes from, and create_offer_with_items re-validates inside its transaction what RLS cannot see, because RLS gates rows and not the relationships between them. Eligibility has none of that, and the new partner reads widen the gap: the category, state, disaster and faith filters that search_need_items_by_embedding and portfolio_matches_for_org accept are the partner's own choices, not the restriction rows anyone declared, so a supplier's portfolio can rank a need from an NPO its own resource_restrictions say may not receive from it.

3. ~~Six SECURITY DEFINER functions do not pin search_path~~ — fixed on develop

> Resolved in master schema 1.2 (gik-platform develop bbacfcde). All six > now carry SET search_path TO 'public'. Unreleased — main is still unpinned.

Of 39 SECURITY DEFINER functions at 1.1, 33 set search_path and six did not:

  • matching.enqueue_match_run
  • matching.process_pending_match_runs
  • needs.create_need_with_items
  • onboarding_rpc.stamp_email_on_session
  • onboarding_rpc.lookup_organization_by_domain
  • profiles.handle_auth_user_update

The three added since 1.0 — ensure_match_worker_secret, close_unlisted_resource_on_offer_close and search_need_items_by_embedding — all pin it, so these are the same six as before.

Such a function runs with its owner's privileges and bypasses the caller's RLS. Without a pinned search_path, an unqualified name inside it resolves against whatever the caller's search path happens to be — the standard privilege- escalation shape for this class of function.

Recommendation. Add SET search_path = '', or an explicit schema list, to all six, matching the thirty-three that already do it. Additive and low risk.

Taken, as an explicit SET search_path TO 'public' on all six.

4. Three tables are fully readable by every signed-in user

Each of these is TO authenticated USING (true) — any authenticated account reads every row, not only the rows relating to them:

  • organizations — the full organisation directory.
  • organization_restrictions — every organisation's eligibility rules.
  • organization_members — every membership in the system. The policy was named Users can view their own memberships, which is not what it does; the name described an intent the predicate does not implement. Renamed organization_members_select_authenticated in 1.2 — the misdescription is gone, the platform-wide read is not.

This may be deliberate for a marketplace where participants need to see one another. It is worth confirming, because the membership policy's name suggests at least one of the three was not.

Recommendation. Confirm each is intended, and rename the membership policy to match its behaviour whichever way you decide. A policy whose name contradicts its predicate is a trap for the next person auditing this.

Half taken: the rename landed in 1.2. Whether the platform-wide read across all three tables is intended is still unconfirmed — and platform-recommendations.md §2 frames the rename as the fallback option, taken instead of scoping the policy. That is a live decision that appears to have been made by implementation rather than by a call, so it is worth recording deliberately rather than leaving it to be re-litigated.

5. organization_partners.status defaults to a value its own CHECK rejects

The column defaults to 'active'. Its CHECK admits only pending, accepted, and rejected. A plain INSERT relying on the default therefore fails outright.

This stays invisible only because the INSERT policy independently forces every application-written row to carry an explicit pending or accepted. It surfaces the moment anything inserts without naming a status — a migration, a backfill, a service-role script.

Recommendation. Change the default to 'pending', matching the sibling organization_invites.status, which already defaults that way.

6. Two foreign keys point at Supabase auth where every sibling points at profiles

The naming is inverted in both directions, which makes this easy to miss:

columnactually references
oauth_authorization_codes.user_idpublic.profiles
activity_events.user_idpublic.profiles
organization_partners.created_by_profile_idauth.users
organization_partners.responded_by_profile_idauth.users

Every other actor column targets public.profiles — created_by (seven of them), uploaded_by, triggered_by, invited_by_profile_id, deleted_by, converted_by_profile_id, and a created_by_profile_id on a different table that resolves correctly.

The join still works, because a profile's primary key is its auth user's id. But the constraint does not require a profile row to exist, and its ON DELETE SET NULL fires when the auth user is deleted — while profiles deliberately uses tombstones rather than deletion. So these two columns follow a different lifecycle from the rest of the schema.

Recommendation. Repoint both at public.profiles. The column names already say that is what they meant.

7. Three tables permit duplicates the model treats as impossible

None has a unique index, so the database will accept a second row where the domain expects one:

  • organization_followers — nothing prevents the same follow twice. organizations.followers_count, which this table is meant to summarise, is only as accurate as whatever recomputes it.
  • organization_partners — nothing prevents two organisations accumulating more than one partnership row. A CHECK bars an org from partnering with itself; nothing bars a duplicate pair.
  • organization_locations.is_primary — a plain boolean DEFAULT false NOT NULL with no guard. Two locations on one organisation can both claim primary.

Recommendation. Add a unique index to each: the follow pair, the partner pair, and a partial unique on (organization_id) WHERE is_primary. All three are additive and ship without application changes — though each needs a duplicate sweep first.

What the schema gets right

Worth stating, because these are the parts most often wrong:

  • RLS is enabled on all 43 tables. No table is left ungoverned.
  • api_keys uses column-level grants rather than the project-wide GRANT ALL, deliberately and with a comment explaining why, so the secret material is not reachable through the table grant.
  • profiles now does the same (1.2), for the same reason and in the same shape: REVOKE first, then a column-scoped GRANT SELECT that omits deleted_metadata. What was a one-table exception is becoming the house pattern for any table holding a column that RLS, being row-level, cannot hide.
  • Membership and admin checks are re-evaluated per query through helper functions rather than read from a cached permission, so a stale role row cannot grant access on its own.
  • The unlisted invariant is enforced at the database boundary (1.1). Two triggers on resources refuse an unlisted row from anything but the offer path, refuse a catalog row becoming unlisted or an unlisted one becoming public, and hold a closed one closed; create_offer_with_items is SECURITY INVOKER, so RLS still governs every row it writes, and it re-checks membership, need ownership, self-offer and resource ownership inside the transaction. This is the pattern item 2 asks for.

A note on what this document is

These are observations about the schema, drawn from the migrations. They are not a penetration test, and they say nothing about the application code above the database or the project's Supabase settings, where some of these may already be handled. Items 1 and 2 are the ones we would ask about first: item 1 because it exposes data today without an account, item 2 because the model implies an enforcement it does not have.

Data model · gik-master-schema.md v1.4 · gik-ontology.md v1.2 · generated 2026-11-04