Skip to content

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_profile gap. 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 ​

PieceState
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:

  1. The current status is read on every capability check, so it must be a cheap column, not an aggregate over history.
  2. The history is the audit, and an audit that can be updated is not an audit. revoked exists 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.
  3. rejected and revoked are 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_grants row with scope_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:

IndustryEvidence
jewelleryBusiness registration · GSTIN · BIS/HUID registration (mandatory for gold in India)
real_estateBusiness registration · RERA registration number · authority-to-market for a listed property
everything elseNone. 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'. The media bucket is CDN-delivered; these must never be.
  • Never referenced from any public projection. get_public_setu_card and get_public_catalogue must not gain a document path, and pgTAP should assert anon cannot reach these rows.
  • Retention is bounded by decision, not by convenience. Once a review reaches verified or rejected, 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.
  • scrubPii in @qrsetu/observability must never carry a document path into Sentry.

Options considered and rejected ​

OptionWhy rejected
Verify at signup, for everyoneImposes 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_grantsA 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_archetypesArchetype-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 verificationA trust signal that can be stale or misread is worse than none, and revocation propagation is unsolved. D5
A third-party KYC provider nowReal 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.