# Why comment-based UGC consent tracking fails silently — and the append-only ledger that fixes it

> Source: <https://dev.to/collectsocials/why-comment-based-ugc-consent-tracking-fails-silently-and-the-append-only-ledger-that-fixes-it-1ek9>
> Published: 2026-07-24 17:38:15+00:00

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](https://app.collectsocials.com/signup) — and the [full write-up with the screenshots](https://collectsocials.com/blog/ugc-rights-consent-deletion-test) walks the same test end to end.
