← rooo.pro

Building "a system that listens properly" with Claude — karaha.org's photo consent system

2026-05-09 / Vol. 5 / draft at time of publishing

I published Vol. 4, "Writing karaha.org's roadmap with a five-year horizon", and on the same day wrote "the site work is done for now."
But while writing that roadmap, there was one thing that kept nagging at me.

About 27 photos taken at past events. We had been keeping them for SNS posts to record our activities, but to the question "have we actually gotten everyone's consent, properly?" — I had no clear answer.

"I think I asked about it." "I'm pretty sure they said OK in person." — That was all I had: vague memory.
For a community group dealing with sensitive information, this was not enough.

This article is a record of the two days spent building, from scratch, a system that asks each person individually for consent on photo use — to fill that gap. The technology, the operations, and finally the story of one typo that stopped everything overnight.


Why we needed "a system that can actually listen"

karaha.org runs a place (Yorimichi Cafe Kawasaki) where people involved with mental health welfare — those affected, supporters, family, and locals — gather across roles.
Photos taken there carry context that, for the people in them, can be read as "this person is affected by mental illness".

Under Japan's Personal Information Protection Act, this kind of information is called "sensitive personal information". Because it can lead to discrimination or prejudice, more careful handling is required at every step — collection, use, third-party transfer.

And photos aren't only about the subject. Family members, accompanying friends, people walking through the venue can all be in frame. Once posted to social media, screenshots get taken in places we don't see, and search engines cache the images. When someone says "actually, please take it down," how far can the operator track and remove it?

Up to now the practice was "I think I got their okay in person." That's a state where only the operator side feels reassured — the highest-risk pattern.
It needed to switch to "protected by structure."

Four stages of the structure

With Claude, we first wrote down "the design." Before any code, we sorted out who, when, what, and how would be decided.

  1. Pre-event notice: Make "photos: yes / no" clear at announcement and signup. People can join without consenting.
  2. Day-of: Identify "OK / not OK" with stickers etc. Announce it from the host.
  3. Pre-publish individual confirmation: Send each person a unique URL, let them choose the use range per photo. This is the core.
  4. Post-publication: Withdrawable any time. Once a year, automatic refresh check.

The use range per photo has 5 levels:

And "if we lose contact in the future" is also chosen in advance from 3 options: continue as is / OK with extra processing / don't publish.
Realistically, contact does get lost. The choice should be made in advance by the person, not on the spot by the operator.

Face anonymization is "flower stamps"

Before consent, anonymize the photos themselves. Using Python with OpenCV and YuNet (face detection), I wrote a process that places flower stamps over every face across all 19 photos.

Face blur, mosaic, painterly (cv2.stylization), frosted glass, light veil, pencil sketch — Claude generated several style candidates, and I compared them in a contact sheet. The one that fit the warmth of the group was flower stamps.
A little cute, less mechanical than blur, and each person gets a different color.

Detection used YuNet (OpenCV's official ONNX model). Front-facing, downcast, masked, side-profile — 7 to 9 faces per photo, almost no misses. The few remaining ones weren't fully facing the camera, so I judged "they're already low-identifiability" and left them.

I also generated a "full-body anonymized" version for the contact-lost case. Silhouette plus heavy blur drops identifiability not just of the face but of clothing and body shape. We don't use it now, but for long-term operation it'll absolutely be needed.

System architecture

The consent page should be "lightweight, maintainable by one operator, and not disappear over time."

Person → URL via email (karaha.org/consent/?t=...)
  ↓
Cloudflare Pages (HTML/JS static)
  ↓ JS fetch with token
Apps Script Web App (API)
  ↓
Google Sheets (People / Photos / Consents / Audit / Events / EmailHistory)
  ↓ image display
Cloudinary (anonymized images)

Every service fits in the free tier. The per-person URL is an irreversible token (two UUIDs concatenated). All consent history and audit logs land in Sheets. Apps Script triggers handle weekly reminders, monthly backups, and daily mail-quota monitoring.

If scale grows, there's room to migrate to Cloudflare D1 + Workers, but the current setup will hold for one or two years. The most important thing is not exceeding "a scale that two people, me and Claude, can run".

The hard parts

I got stuck in many places over two days. Writing them down in case anyone else hits the same.

① Cloudinary's Unique filename

After uploading 19 photos, I checked one image's Public ID and found a random 6-character suffix appended (something like vkd8hc).
Cause: Upload Preset's "Append a unique suffix". On by default.

Turned it off, deleted all 19, re-uploaded. Re-built the cloudinary_id column in the Photos sheet too.
Going back to fix this after "I already uploaded and wrote the sheet" took a small but real amount of willpower.

② Cloudinary's dynamic-folder mode

"Place in folder karaha/consent/" and "include the path in the Public ID" turned out to be different things.
In the new dynamic folder mode, the Public ID is just 料理教室_集合写真. The folder karaha/consent/ exists only as metadata.

Meaning the image URL is accessed via 料理教室_集合写真, not karaha/consent/料理教室_集合写真.
I built URLs with the folder path included at first — all 19 returned 404.

③ CSP (Content Security Policy) was blocking the API

karaha.org's security headers (_headers) include CSP. I added that during Vol. 3's audit.
But connect-src only allowed 'self' https://cloudflareinsights.com https://static.cloudflareinsights.com.
All fetches from the consent page to script.google.com (Apps Script) were blocked, with the only error being "Failed to fetch."

The fix was one line: add https://script.google.com https://script.googleusercontent.com to connect-src.
But it took 30 minutes to figure out. "A security setting I added myself" stopping "a feature I built myself" — that was an ironic experience.

④ Spaces and parentheses in filenames

One out of 19 photos wasn't displaying.
Cause: the original filename 料理教室_食事会_いただきます (1).jpg's space and (1). Cloudinary had auto-sanitized it to 料理教室_食事会_いただきます_1.

That wasn't reflected in the sheet's cloudinary_id. As a fix, I added the same sanitization rule used by Cloudinary into the CSV generator script. Future uploads will be handled automatically.

⑤ And in the end, a typo stopped everything

The confirmation email to the person arrived. But the notification email to the operator wouldn't arrive. Apps Script execution log showed no errors. MailApp had plenty of quota left.

I had Claude write a debug function. Running it produced one log line:

Send failed: Exception: Invalid email: jp

"jp"? What "jp"?

Looking at the line above, CONTACT_EMAIL was set to rooo@esynet,jp. Not . — a , (comma).
Apps Script interpreted the comma as "separator for multiple addresses" and tried to send to rooo@esynet and jp separately, failing.

When I'd hand-typed the value into Script Properties, I'd hit , instead of ..
One character. Fixed it, and the email arrived instantly.

My code knowledge is "I can roughly read it." I had no idea Apps Script parses commas as multi-address separators. A one-character typo stops everything — these things happen most often in the areas where you have the thinnest knowledge.

Showing "you can't submit until everything is selected"

After my test submission, I got one piece of UI feedback.
"You can't tell, just by looking, that you have to choose for every photo before sending."

If the submit button just goes disabled, the user is left thinking "why can't I press it?"
So I added:

Functionally nothing changed. But communicating to the person why they can't do this right now is foundational to trust.

What's still left

In two days a working system was built. Technically complete.
But there are still things left before real operation.

・Withdrawal tests (full and partial)
・Member registration before broadcast send
Securing a co-administrator (right now everything runs on my single account)
Lawyer review (since we handle sensitive information, this should really be looked at by a specialist. Planning to commission it together at the time of incorporation)

And I won't post the existing 27 photos on SNS until consent is secured.
I'd been using them on hand for these articles, but having set the operations rules, I apply them to myself too.

What does "listening properly" mean

The system's contents are technical. But what I really wanted to build was a structure that can listen to a person's intent.

Until now, the operator side was just thinking "I think I got their OK in person." The person didn't know where their consent was recorded, when they could withdraw it, or what was actually published.
Without structure, "not said, not asked, forgotten" happens.

If the email with their unique URL stays with them, they "can come back any time."
Withdrawal is "one email" — it says so right there.
"If contact is lost" is also already chosen in advance.
This isn't really technology. It's culture.

While building with Claude, multiple times I got the line drawn for me: "this is technically possible, but operationally the person should be the one to decide." For example, the "send broadcast email to everyone" function was kept off auto-triggers to prevent accidental fires. Claude itself proposed: "this should be manual-only."

The more AI advances automation, the clearer the territory humans should decide becomes. In running a community, this is the most reassuring thing.

The hard part of build-in-public

While writing this article, I'm drawing the same line again.

The process of building the consent system itself is writable as a tech article. CSP, Cloudinary specs, the typo. That's all fair.
But the test "person" I registered was one of the multiple email addresses I own. So the test was a real one, doable as a real person. But the specifics tied to photo content, and the consent choices that real people will make in production, won't be written here. Once production runs, those records belong to the people who actually consented. From the writing side, it's better not to mix them with my test records.

build-in-public isn't "show everything."
It's "show the outline of what can't be shown," and write down the reason for that judgment. As an operator handling sensitive information, that's the form of publication I can do on rooo.pro.

What's next

The next post might come when consent collection is complete and the first SNS post goes out.
Or it might be that another wall hits before then, and I write another stuck-on-something story.

"The process of becoming decided" is easiest to write in the moment when something is becoming decided.
That's probably what now is.