Disclosure:I'm the founder of CollectSocials. This is an engineering write-up of a data-modeling problem I ran into while evaluating UGC-rights tools, including building our own. I've kept it to what I could reproduce and to schema-level reasoning, not vendor bashing.
This is a rewritten, developer-focused version of an article originally published on our blog. Canonical source:[collectsocials.com/blog/ugc-rights-consent-deletion-test].
If you display customer photos on a website or an event wall, you need the creator's permission — and, more importantly for this post, you need a record of that permission that survives the day someone disputes it. That record is the entire product. A rights tool that collects approvals but can't prove them later is storing a feeling, not evidence.
So this is a post about a data model. Specifically: the most common way the UGC industry records consent has a silent failure mode baked into its schema, and no amount of good UI fixes it, because the problem is where the source of truth lives.
Let me show you the failure first, then the model that closes it.
The dominant pattern for "get UGC rights" works like this:
rights
table. Each row starts unrequested
.#yesagree
) and to @mention your brand account.approved
and stores the reply text and a couple of dates.Reduced to a schema, the stored consent record is roughly:
-- The "consent record" in a comment-based flow
CREATE TABLE rights (
id bigint PRIMARY KEY,
post_ref text, -- pointer to the IG post
status text, -- 'unrequested' | 'pending' | 'approved'
reply_text text, -- e.g. "@brand #yesagree"
requested_at timestamptz,
approved_at timestamptz
);
Look at what status = 'approved'
is actually backed by. It's backed by the continued existence of a public comment on a platform you don't control, owned by an account that isn't yours. The row is a pointer. The thing it points at is mutable and externally owned.
There is no column that can represent "the creator took it back," because in this model there is no server-side event when they do. The consent lives in a comment; the comment's deletion generates no webhook to your system.
This is the test you can't run by reading a marketing page, because you need both sides of the conversation. I controlled a brand account and a creator account, so I could approve a request and then act as the creator who changes their mind.
approved
.Back in the dashboard, the row still said Approved. Nothing re-checked, nothing flagged. There was no revoked
state to move to.
The record outlived its own evidence.
I want to be precise about what this is and isn't, because it's easy to overstate:
The deeper point for engineers: a status field is only as trustworthy as the thing that can flip it. If the source of truth for approved
is an object outside your system that a third party can delete without telling you, your status column is a cache with no invalidation.
(One more architectural note from the same test, since it's the same lesson: the comment gets posted by a browser extension acting as your own logged-in account, not a vendor API app. So whatever platform-automation risk that carries lands on your account, not the vendor's. Different failure, same theme — know which system state is authoritative and who carries it.)
The correction is to stop pointing at an external mutable object and instead capture the consent event, server-side, at the moment it happens, into a log that only ever grows.
Two tables. One is a mutable projection of current state (convenient to query). The real source of truth is the second: an append-only event log.
-- Current-state projection (fast to read; NOT the source of truth)
CREATE TABLE rights_requests (
id uuid PRIMARY KEY,
post_id uuid,
post_snapshot jsonb, -- the post FROZEN at request time
request_token text, -- 256-bit capability; the signed link
terms_version text, -- which version of the terms was shown
status text, -- pending | approved | declined | revoked
evidence_level text, -- attested | verified | email_confirmed
responder_handle text,
responder_email text,
requested_at timestamptz,
responded_at timestamptz,
revoked_at timestamptz
);
-- Source of truth: append-only. Nothing here is ever UPDATEd or DELETEd.
CREATE TABLE rights_events (
id uuid PRIMARY KEY,
request_id uuid REFERENCES rights_requests(id),
event_type text, -- requested | approved | declined | revoked
-- | email_confirmed | copy_emailed
evidence jsonb, -- server-captured proof for THIS event
created_at timestamptz DEFAULT now()
);
Every transition is an INSERT
into rights_events
. The status
column on rights_requests
is just a denormalized read of the latest event — a convenience, never the authority. You can rebuild current state by folding the events; you can never silently rewrite history, because there's no UPDATE
/DELETE
path on the log. That "including by us" property is the whole point: the vendor can't quietly edit the trail either.
The evidence has to be captured at the moment of consent, on the server, never reconstructed later and never trusted from the client. Here's the shape of an approved
event's evidence, and why each field is there:
{
"method": "consent_link",
"terms_version": "2026-07-18",
"terms_sha256": "9f2c…", // hash of the EXACT terms text the creator saw
"ip_hash": "sha256(salt + ip)", // salted; "same responder" proof, not IP recovery
"user_agent": "Mozilla/5.0 …",
"handle": "@creator",
"email": "creator@example.com"
}
terms_sha256
ip_hash
copy_emailed
The bug that started this post is now structurally impossible. Revocation isn't the disappearance of evidence — it's a new row:
INSERT INTO rights_events (request_id, event_type, evidence)
VALUES ($1, 'revoked', '{ "method": "consent_link", ... }');
The creator reopens the same signed link they approved on and taps withdraw. status
moves to revoked
, a revoked
event is appended, the serving path (a posts.rights_status
mirror the public feed filters on) drops the post immediately, and — if they left an email — they get a copy confirming the withdrawal. The audit trail gains information when consent is withdrawn instead of silently going stale.
Anyone holding the capability link can respond, so in principle a brand user could approve their own request. We don't block it — blocking creates false negatives at live events (shared Wi-Fi, etc.). Instead we make it tamper-evident: the requester's ip_hash
/user-agent are captured at create time, and if an approval arrives from the same ip_hash
or within a few minutes of the link being minted, the event records self_response_suspected
with the reasons. A brand that fakes consent against itself just writes documented evidence of doing so. Honesty over tidiness.
Strip away the specifics and here's the checklist I'd apply to any consent/audit system, not just UGC:
UPDATE
d in place, the "audit trail" can be rewritten — by the vendor, or by anyone with DB access.The comment-based model fails 1, 3, 4, and 6 by construction. That's not a bug you can patch in the UI. It's the schema.
You don't have to take my word for any of this. On a free trial of whatever UGC-rights tool you're weighing, run the one test that tells you the most: approve something, then remove the approval the way a real creator would, and watch whether the record notices. Runs in five minutes. It's the fastest way to find out whether you're buying evidence or a green checkmark.
If you want the version built around an append-only ledger with hashed versioned terms, a real withdrawal event, and a copy in the creator's own inbox, that's what we shipped at CollectSocials — and the full write-up with the screenshots walks the same test end to end.