Skip to content

Jobs: Per-posting exclusion list for job offers (hide a posting from competitors, your own staff, or specific people) #939

Description

@wintermeyer

TL;DR Hiring is a two-way street: a poster wants to hide a job offer from specific viewers just as a member (#938) hides "I'm looking" from their employer. Add a per-posting exclusion list — members, organization pages (via #929), and email domains — subtracted from whoever would otherwise see the posting on the board, its detail page, agent formats, and job alerts. Verified organizations also get a standing default list applied to all their postings. Shares #938's exclusion seam.

Where: the job-posting resource (#932) gains the exclusion list + a small editor and a base everyone/members visibility (added to #932); the standing organization default rides on organization settings (#929/#930); enforced at the public board (#933), the /jobs/:slug detail page + its .md/.txt/.json/.xml siblings, saved-search alerts (#935), and the /api/2.0 jobs endpoints (#936). Reuses the per-subject viewer-exclusion predicate #938 introduces.

Now: #932/#933 make every posting maximally reachable — the point of a board — with only per-posting seo?/geo? crawl toggles. There is no way to keep a posting on the board for the world except a chosen few. Real hiring frequently needs exactly that:

  • Your own staff — backfilling a role before the incumbent is told, or hiring a layer above an existing team. Exclude @ourorganization.example and/or your own organization page (which also covers staff whose linked work experience points at it, see the organization dimension below) so employees don't stumble over the posting.
  • Competitors — a job ad leaks strategy (the team you're building, the market you're entering). Exclude a rival organization / its domain so their people can't read your plans off the board.
  • Specific people — a known bad-faith applicant, a harasser, or the very person being quietly replaced.

Base visibility (resolved — was the open question): #932 gains a posting visibility: everyone | members (default everyone, so current reach is unchanged). members shows the posting only to signed-in members and forces it out of the crawlable/JSON-LD/sitemap surfaces regardless of seo?. This is what makes exclusion airtight against a logged-out competitor: pair members with the exclusion list to hide a posting from a rival even when they aren't signed in. everyone keeps today's public behavior, and then exclusion can still only reduce the signed-in on-platform audience — helper text says so plainly.

Want: a per-posting exclusion list subtracted as the last step of the posting's visibility check, on the same three dimensions as #938 (subtract never adds):

  • Members: exclude a specific member account.
  • Organizations (via Jobs 2/9: Verified company pages at /companies (domain-proof only, no unverified pages) #929 verified pages): exclude an organization — covers its verified domains (with subdomains, per the domain rule), its role-holder accounts, and members whose current work experience is linked to it (Jobs 4/9: Link work experiences to verified company pages (optional, free text stays) #931). Members usually do confirm their organization e-mail, so the domain rule carries most of the weight; the work-experience link is belt-and-braces for the rest — staff who registered with a personal address only, or a contractor without an organization mailbox. Self-linking only ever reduces what the linker sees, so this dimension cannot be gamed.
  • Domains: exclude an email domain. A signed-in viewer is excluded when any of their confirmed emails is at that domain or any subdomain of it (host suffix match: example.com also matches @eu.example.com — no public-suffix list needed), regardless of the email's public? flag; unconfirmed emails never match. Logged-out/anonymous viewers have no email, so a domain rule only narrows the signed-in on-platform audience.
  • The poster, and (if organization-attributed) the owning organization, always see their own posting; neither can be excluded from it.

Organization default exclusions (in scope): a verified organization sets a standing exclusion list once in its organization settings (#929/#930) — e.g. competitor domains it never wants to advertise vacancies to. Every posting attributed to that organization inherits it: the effective exclusion set for an organization posting is the organization default ∪ the posting's own list (union; the per-posting editor can add but not un-inherit an organization default). Personal postings have no organization default. The default is editable by organization role-holders and takes effect immediately on all live postings.

Silent by design (mirrors Social.block_user): an excluded viewer gets the same not-found/hidden result as any non-permitted viewer — no signal they were singled out. Exclusion is visibility-only.

Full block implies exclusion: if the posting's responsible member has blocked a viewer via Social.block_user, that viewer is automatically excluded from the posting too.

Enforcement surfaces (each hides an excluded signed-in viewer):

Reusable seam: generalize #938's per-member viewer-exclusion into a per-subject one — subject = a member's job-search fields or a job posting — behind one excluded?(subject, viewer) predicate (viewer is a listed member, belongs to a listed organization — role or current linked work experience — has a confirmed email at a listed domain/subdomain, or is blocked by the subject's owner) and one three-dimension entry model. A posting resolves its effective set as own-rows ∪ organization-default rows; no bespoke matching logic per side.

Defaults: empty list = harmless default (posting fully visible per its base visibility), per the milestone's harmless-default rule.

Cleanup: exclusion rows (per-posting and organization-default) are removed when the posting, the owning member, an excluded member, or an excluded/owning organization is deleted; the Accounts.delete_user/1 deletion chokepoint is extended for member rows on either side.

Validation: domain shape (lowercase host, no scheme/path/@), dedupe, a per-posting and per-organization cap (e.g. 200 entries each), cannot exclude the poster or the owning organization.

Docs: the postings subsystem doc created by #932 — base visibility, exclusion + organization-default semantics, silent/visibility-only rule, block integration, and the honest on-platform-only limit for everyone postings.

Smoke test (browser, per CLAUDE.md): publish a posting (visibility everyone); from a second signed-in account whose confirmed e-mail is at eu.x.example, it appears on /jobs, in search, and on /jobs/:slug, and (with a matching saved search) would alert; add domain x.example to the posting's exclusion list → gone from that account's board, search, and detail (/jobs/:slug → not found), and no alert fires, while a third account on another domain still sees it; a logged-out /jobs/:slug.md still shows it (public) — then flip the posting to members and confirm it leaves the public/crawl surfaces entirely; set the same domain as a organization default and confirm a second organization posting inherits it; swap the domain rule for the second account added by @handle, and separately by blocking it → same result each time; exclude a verified organization and give a fourth account (no organization e-mail) a current work experience linked to it → posting hidden for that account too; check the German render (Accept-Language: de).

Depends on: #932 (the posting resource + the new base visibility it attaches to) and #933 (the board, the primary read surface); #929/#930 for the organization dimension and the organization-default settings UI (members + domains enforce without them). Best sequenced right after #933; shares the seam #938 introduces.

Metadata

Metadata

Assignees

Projects

No projects

Relationships

None yet

Development

No branches or pull requests

Issue actions