Search projects, hackathons, sponsors and tech

    ← All projects

    consent-ledger

    A consent record for body-scan data, so an AI pipeline has to ask before it generates.

    Demo

    About

    A model's body data does not only get used for the shoot they were paid for. Sometimes it is used to build an AI model of them — and not on the internet, but in the studio, on the day. Today neither the model nor their agency can do anything about it. consent-ledger is the record that makes the question answerable. A generation pipeline asks before it produces anything, and gets one of four answers, each with a reason written to be shown unchanged: allow, deny, ask, revoked. The line the product is drawn along is not media type. The images from a shoot — print, social, a lookbook — an agency has handled for decades, and an enquiry about them goes to the agency. What gets generated from someone's body data is different: not part of the shoot, not priced in the booking, usually not in the contract at all. So that stays with the person. That split decides who answers. A scope the agency holds and has not agreed to returns deny, and the reason says to ask them. A scope the person kept has no desk to ask — so the request goes to them, on their phone, and nothing is generated until they answer or the deadline passes. Who may speak for whom lives on ENSv2 (Sepolia), one role per scope on the person's own name. Removing a role refuses the next request without anything being written in our database. Built for the agency, not against it. Representation — the calls, the scheduling, the negotiation — stays with people and is growing. We take the one part a contract can hold.

    How it's made

    Cloudflare Workers with Hono JSX, server-rendered with no client bundle, and D1 for the ledger. Nothing stores an image or scan data — the subject is a World ID pairwise subject, so the record says "this human" without saying who. World ID for Agents: the authorization-code flow returned invalid_request for every combination I tried, including a deliberately bogus client_id, which ruled out missing parameters. Switching to the OIDC device authorization flow with HTTP Basic worked first try, and it is the better fit anyway — the caller is a pipeline with no redirect. The ID token is verified against the issuer's JWKS before anything is written. One undocumented thing cost me an hour: /api/v2/verify rejects requests with no User-Agent using an nginx 403 HTML page, and Workers' fetch sends no UA by default, so res.json() fails with "Unexpected token '<'" and hides the cause. IDKit gates the one action nobody may do on the person's behalf. A dedicated action was created through POST /api/v2/create-action so the verification ledger separates it from the approval flow. ENSv2 on Sepolia: app.ens.dev never emitted a transaction for me, so I read the verified contract source from Blockscout and called the ETHRegistrar directly — mint MockUSDC, approve, makeCommitment, commit, wait MIN_COMMITMENT_AGE, register. makeCommitment takes no paymentToken and register does, which is not obvious from the docs. The resolver is a PermissionedResolver deployed through VerifiableFactory. The blog post describes grantSetterRoles, but the deployed contract has authorizeTextRoles/DataRoles/AddrRoles/ NameRoles instead — and the real thing is better, because per-text-key scoping expresses partial delegation with no contract of my own: consent.campaign-print is a role the agency holds, consent.ai-generation is one they do not. The running service reads that role on every /check, and the chain wins over the database. If the chain cannot be read, the verdict falls to "ask" rather than "allow" — the failure this project exists to prevent is not a crash, it is a silent yes, so every failure path is shaped as "do not return allow here". That is what the tests hold: bun test against real SQLite with the real migrations, run in CI on every push. One measurement worth passing on: a load-balanced public RPC served a stale block right after a write, so a revocation appeared not to take effect for a few seconds. Pinning a single node brought it to 0.4s.