Skip to content

0.7.0: a resume is public, the way to reach the person is not - #38

Merged
ralyodio merged 1 commit into
masterfrom
worktree-contact-info-auth-gate
Sep 9, 2026
Merged

0.7.0: a resume is public, the way to reach the person is not#38
ralyodio merged 1 commit into
masterfrom
worktree-contact-info-auth-gate

Conversation

@ralyodio

@ralyodio ralyodio commented Sep 9, 2026

Copy link
Copy Markdown
Contributor

Anyone could read a published resume and take the candidate's email address and phone number out of it. Walking /candidates and following each link produced a mailing list with phone numbers attached, which is not what somebody agreed to by publishing a resume in order to be hired.

Contact channels now need a session or a token. Everything else about the resume stays public.

What counts as a channel

It falls out of the parse rather than out of a list of key names, so it does not go stale as people invent new fields:

  • a contact bullet that produced an href is a way to reach someone (mailto, tel, a profile) and is withheld
  • one that did not is a plain fact and stays

So Location: Lisbon and Work Authorization: EU Citizen survive. The directory already prints the location on every card and filtering on it is the point, so withholding it there would be theatre.

Saying so, rather than going quiet

The withheld bullets are replaced by - **Contact**: shared with signed-in members instead of removed. A caller that cannot tell a redacted document from a resume with no contact details will report the second as the first. The JSON also carries contactRedacted: true so an agent does not have to guess, and the OpenAPI description and llms.txt both say it.

Redaction is Markdown in, Markdown out, and the routes re-parse the result rather than being handed a doctored parse, so the document and the parse of it can never disagree about what was withheld.

Every representation, not just the page

They all render the same Markdown, so they all go through one helper. Gating the page and forgetting the PDF is how the address stays public on a board that looks careful.

Surface Anonymous
/candidates/:slug withheld, with a sign-in link in the Contact card
/api/v1/candidates/:slug withheld, contactRedacted: true
resume.md, .html, .pdf, .docx withheld
/candidates, /candidates.md, /candidates/feed unchanged; they never carried contact

The one thing that cannot be redacted is an original upload, since those bytes are whatever the candidate typeset. An anonymous caller asking for .pdf or .docx no longer gets that shortcut and gets a copy generated from the redacted Markdown. Worth knowing: that path needs a PDF engine, and the Docker image installs weasyprint, so this is only a change on an instance running without one.

The gate is "signed in", nothing more

Deliberately not "signed in and approved". A person with a session and an agent with a device token are the same caller here, since an agent reading resumes for its owner is the traffic this board exists to serve. What changes is that there is now an account behind the read, which is the thing a scraper does not want to have.

Testing

161 pass, including a new end-to-end test against a real Postgres that publishes a resume with an address and a phone number and checks the page, the JSON, resume.md and resume.html all withhold them anonymously and all return them with a token. Six unit tests cover the redaction rule itself, including that a link under a ## Links heading is content and is left alone.

🤖 Generated with Claude Code

https://claude.ai/code/session_015xXMzJf85q87oeG3VEKHdJ

Anyone could read a published resume and get the candidate's email address
and phone number out of it, on the page, in the JSON, in the RSS-adjacent
Markdown and in all four downloads. Walking /candidates and following each
link produced a mailing list with phone numbers attached, which is not what
somebody agreed to by publishing a resume in order to be hired.

Contact channels now need a session or a token. Everything else about the
resume stays public, because a candidate directory nobody can read is a
candidate directory nobody uses.

What counts as a channel falls out of the parse rather than out of a list of
key names, so it does not go stale as people invent new fields: a contact
bullet that produced an href is a way to reach someone (mailto, tel, a
profile), and one that did not is a plain fact. "Location: Lisbon" and "Work
Authorization: EU Citizen" stay. The directory already prints the location on
every card and filtering on it is the point, so withholding it there would be
theatre.

The withheld bullets are replaced by one that says so, rather than removed.
A caller that cannot tell a redacted document from a resume with no contact
details will report the second as the first, and the JSON carries
contactRedacted so an agent can tell without guessing.

Redaction is Markdown in, Markdown out, and the routes re-parse the result
instead of being handed a doctored parse. The document and the parse of it
can never disagree about what was withheld, which is the same rule the rest
of OpenResume runs on.

Every representation goes through one helper, because they all render the
same Markdown and gating the page while forgetting the PDF is how the address
stays public on a board that looks careful. The one that cannot be redacted
is an original upload: those bytes are whatever the candidate typeset, so an
anonymous caller asking for .pdf or .docx no longer gets the shortcut and
gets a copy generated from the redacted Markdown instead.

Signed in is the whole test, deliberately not "signed in and approved". A
person with a session and an agent with a device token are the same caller
here, since an agent reading resumes for its owner is the traffic this board
exists to serve. What changes is that there is an account behind the read,
which is the thing a scraper does not want to have.

Tested end to end against a real Postgres: the page, the JSON, resume.md and
resume.html all withhold the address anonymously and all return it with a
token.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015xXMzJf85q87oeG3VEKHdJ
@ralyodio
ralyodio merged commit 8546bea into master Sep 9, 2026
4 checks passed
ralyodio added a commit that referenced this pull request Sep 9, 2026
0.7.0 was tagged and published to npm before #38 merged, so the release
and the package named 0.7.0 contain the employer decision flow but not
the contact gate. Two PRs hand-bumped to the same number and both merged
cleanly, which is how a version stops identifying a build.

No behaviour change. This is the number the contact gate ships under.


Claude-Session: https://claude.ai/code/session_015xXMzJf85q87oeG3VEKHdJ

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant