Appearance
ADR-0026 · Vendor verification & governance
Status: 🟡 Proposed — authored 2026-08-09, awaiting owner approval · Depends on:ADR-0021 (the grant model this uses unchanged), ADR-0014 · Unblocks:QRS-465 (jewellery schemes), QRS-454 (RERA authority-to-market) · Tracker: QRS-468
Context
The owner's principle, accepted verbatim and the reason this ADR exists:
"Don't exclude a valuable business use case because governance is harder; design the governance layer to support it safely."
Two industries assessed within a single day both stopped at the same wall. A jeweller publishing a monthly savings scheme is advertising a regulated financial arrangement (QRS-466); an estate agent listing a property must be RERA-registered and authorised to market it (QRS-454 R2). Neither is a schema problem. Both are the same missing control: the platform has no way to know, or to record, that a vendor is who they claim to be.
Measured, 2026-08-09: there is no verification, KYC or document-review concept anywhere in the v2 schema. The single related column is workspaces.gstin text — nullable, unvalidated, and never read.
Without this, the only two available answers to a high-value industry are refuse it or accept it unverified. Both are bad, and the second is worse.
Alignment
- Three user categories. Verification is a workspace concern, never a user concern. A consumer (category 3) has no workspace and must never encounter any of this. An enterprise employee's workspace is verified by the organization, not by them personally.
- Anonymous-first is a hard requirement. CLAUDE.md: a vendor's card must be usable with no account, and "a signup wall in front of a scanned card destroys the platform's entire growth mechanic." This ADR must not become a wall one step further back.
- ADR-0021's control plane already exists. Eight scopes × three axes. This ADR deliberately adds no fourth axis and no new gating mechanism (D3).
- ADR-0004's
compliance_profilegap. That column does not exist, which is why the third-party ad seam fails closed forever. Verification is the other half of the same problem, arriving with first-party content instead.
What already exists, so this is smaller than it looks
| Piece | State |
|---|---|
| Document storage | ✅ media.purpose already includes 'document', with a workspace-scoped RLS story and the pending → ready two-phase upload already designed |
| The gate | ✅ feature_grants at scope_kind = 'workspace' on the availability axis. Nothing new required — see D3 |
| Platform-admin identity | ✅ is_admin() and the Edge Function requireAdmin exist |
| An audit trail pattern | ✅ Append-only tables with COMMENT ON are the established idiom |
| The review record | ❌ New: one append-only table plus a status on the workspace |
Decision
D1 — Verification gates CAPABILITIES, never SIGNUP or PUBLISHING
This is the load-bearing decision and everything else follows from it.
A vendor signs up, onboards, builds a card and publishes it with no verification whatsoever. Only specific, named capabilities require it. Verification is a key that unlocks doors, never a lock on the front door.
Why this and not the intuitive alternative:
- Most vendors need none of it. A festival-stall vendor, a boutique, a tutor, a salon — the great majority of category-1 merchants — have no capability requiring verification. Gating signup would impose a document upload on every one of them to serve a minority.
- A document wall at signup destroys the growth mechanic one layer behind the one CLAUDE.md already protects. The whole funnel is scan → look → sign up in two minutes.
- It is the only version that stays proportionate as industries are added. Each new vertical declares which capabilities it needs and therefore what evidence it must produce; nothing changes for anyone else.
⚠ Consequence, stated rather than hidden: an unverified vendor can publish a card that misrepresents them. That is already true today, it is the same exposure every self-serve platform carries, and the mitigations are the report-abuse path and takedown runbook that R1 already owes (QRS-320) — not a signup wall.
D2 — verification_status lives on the WORKSPACE, and the evidence in an append-only ledger
workspaces.verification_status text not null default 'unverified'
check (verification_status in ('unverified','submitted','under_review','verified','rejected','revoked'))Plus workspace_verification_events — append-only, one row per state transition, carrying the actor (vendor or platform admin), the evidence referenced by media.id, a reason, and the timestamp.
Three reasons for the split rather than one mutable table:
- The current status is read on every capability check, so it must be a cheap column, not an aggregate over history.
- The history is the audit, and an audit that can be updated is not an audit.
revokedexists precisely because verification can be withdrawn — a certificate expires, a registration lapses — and a mutable row would erase the fact that it was ever granted. rejectedandrevokedare different facts. Rejected never qualified; revoked did and no longer does. Collapsing them loses the distinction that matters to both support and the vendor.
⚠ Evidence is referenced, never duplicated. The uploaded certificate is a media row like any other; this table stores its id. Copying file bytes or metadata here would be a second source of truth (QRS-249/284/287) and would also duplicate the DPDP retention obligation.
D3 — ⚠ THE GATING NEEDS NO NEW MECHANISM. It is an ops-written grant at workspace scope
This is the part worth reading twice, because the intuitive design is wrong.
The instinct is a fourth axis on feature_grants — applicability, entitlement, availability, and verification. Reject that. ADR-0021 already has eight scopes, one of which is workspace. So:
"This workspace may publish a savings scheme" is a
feature_grantsrow withscope_kind = 'workspace',axis = 'availability', written by a platform admin when verification succeeds and expired when it is revoked.
What that buys, for free:
- No new resolver.
resolve_features()already ANDs the three axes across the eight scopes; a verification-derived grant is indistinguishable from any other and needs no special case. - It is already auditable and already time-bounded — grants carry their scope and their effective window.
- No client ever branches on verification status, which keeps ADR-0021 D4 intact: the app asks for a feature, exactly as it does for everything else. A screen containing
if (verification_status === 'verified')would be the same defect as branching on archetype.
⚠ A fourth axis would have been a genuine architectural regression: it would need a new column on every grant, a new term in the resolver, and a new concept in every client — to express something the existing scope model already expresses exactly.
D4 — Required evidence is declared PER INDUSTRY, as data
industries gains verification_requirements jsonb not null default '{}' — the same shape of decision as item_attribute_schema (QRS-452), and it must be industry-grained for the same reason: a jeweller's BIS/HUID registration and an estate agent's RERA number are not the same evidence, and both are goods/expertise archetypes shared with industries needing neither.
Illustrative, to be confirmed per vertical during its G-D discovery:
| Industry | Evidence |
|---|---|
jewellery | Business registration · GSTIN · BIS/HUID registration (mandatory for gold in India) |
real_estate | Business registration · RERA registration number · authority-to-market for a listed property |
| everything else | None. The default is the empty object, and that is the point |
⚠ The default must be "nothing required". A schema whose default demands documents would silently gate every future industry until someone remembered to empty it.
D5 — A public "verified" badge is DEFERRED, and the reason is not caution
A badge on the public card is a genuine trust asset and an obvious growth lever. It is deferred because a trust signal is a liability the moment it is wrong: a badge that survives a lapsed registration, or that a visitor reads as "QRSETU vouches for this business" when it means "we saw a certificate once", is worse than no badge. It needs its own decision about what exactly is being asserted, how staleness is handled, and how a revocation propagates through the edge cache — which is QRS-467 again.
Verification ships as a capability key first, and as a public claim only once we can say precisely what the claim means.
D6 — Documents are the most sensitive files on the platform, and carry the shortest retention
A registration certificate contains a name, an address, a registration number and often a signature. Consequences that are cheaper to decide now than to retrofit:
bucket = 'private', never'media'. Themediabucket is CDN-delivered; these must never be.- Never referenced from any public projection.
get_public_setu_cardandget_public_cataloguemust not gain a document path, and pgTAP should assertanoncannot reach these rows. - Retention is bounded by decision, not by convenience. Once a review reaches
verifiedorrejected, the outcome is the durable record and the file itself has a defined lifetime. DPDP makes indefinite retention of an identity document a liability rather than a precaution. scrubPiiin@qrsetu/observabilitymust never carry a document path into Sentry.
Options considered and rejected
| Option | Why rejected |
|---|---|
| Verify at signup, for everyone | Imposes a document upload on the majority who need none, and puts a wall one step behind the one CLAUDE.md already forbids. D1 |
A fourth axis on feature_grants | A new column on every grant, a new term in the resolver and a new concept in every client, to express what scope_kind = 'workspace' already expresses. D3 |
verification_requirements on business_archetypes | Archetype-grained is the wrong granularity for the same reason it is wrong for item_attribute_schema: a jeweller and a boutique are both goods and need different evidence. D4 |
| Ship the public badge with the first verification | A trust signal that can be stale or misread is worse than none, and revocation propagation is unsolved. D5 |
| A third-party KYC provider now | Real option later. Today it adds a vendor, a cost per verification and a data-sharing agreement, to solve a review queue that a human can clear at current volume |
Consequences
Accepted:
- An unverified vendor can publish a misrepresenting card. Mitigated by report-abuse and takedown, not by a wall (D1).
- A human review queue exists with no SLA and no tooling in R1. At launch volume that is a person and an inbox; it does not scale, and the honest answer is that it does not need to yet.
- Verification is a manual, ops-mediated grant. That is deliberate: the first version of a trust process should be slow and human rather than fast and wrong.
Gained:
- High-value industries can be accepted rather than refused, which is the owner's principle made executable.
- The gating mechanism is already built and already tested (D3).
- Every verification decision is auditable and revocable (D2).
Recommendation
Approve D1-D6. The load-bearing decisions are D1 (capabilities, never signup) and D3 (an existing grant scope, not a new axis) — together they mean this ADR adds one column, one append-only table and one jsonb field, and no new mechanism anywhere in the control plane.
⚠ Do not implement ahead of a vertical that needs it. Verification is infrastructure whose shape is decided by its first two consumers, and both of those (jewellery schemes, RERA authority-to-market) are still awaiting their G-D discovery sessions. Decide now, build with the first consumer — the same sequencing this repo just adopted for QRS-452, and for the same reason: the decision is what is expensive to leave implicit, not the migration.