Found during PM review of #11 (card 07). Two defects in card 06's security surface, discovered together because the talent-pool view #11 delivers is the thing they break. Not fixed in #11 — src/security/ is card 06's surface, outside that card.
1. The employer-side grant on ats_candidate resolves to zero rows
ats_candidate is sharingModel: 'private' (src/objects/candidate.object.ts:15) and carries a user field but no organization field.
Both employer sets grant it as:
ats_candidate: { allowRead: true, readScope: 'org' }, // EmployerAdminSet:97, EmployerRecruiterSet
readScope: 'org' widens a private object from owner-only to "rows whose owning organization is in the reader's accessible_org_ids". A candidate row is owned by the job seeker and has no organization at all, so no employer org can ever match it. The only RLS rule anywhere on ats_candidate is seeker_own_profile (using: 'user == current_user.id', permission-sets.ts:212), which belongs to the seeker set.
Consequence: ats_candidate.talent_pool and ats_candidate.gallery — the two views #11 just added — render empty for every employer, as does any candidate lookup from the pipeline. The views themselves are correct; there is no readable row behind them.
Note the contrast with ats_job (public_read), where the employer grant deliberately carries no readScope and relies on the OWD — that one works.
2. profile_visibility is a declared consent control that nothing enforces
profile_visibility: Field.select({
required: true,
defaultValue: 'limited',
description: 'How much of this profile employers may see before the candidate agrees.',
options: [ public, limited, hidden ],
})
grep -rn profile_visibility src/security/ returns nothing. No permission set, RLS rule or FLS entry reads it. The field's own description states an access rule that no code implements.
Today this is masked by defect 1 — employers see no candidates at all, so nobody is over-exposed. Fixing defect 1 without fixing this one turns a broken view into a privacy hole, because limited is the default and hidden would still be fully readable. They must be fixed together, in that order, in one change.
Why a view filter is the wrong fix
The reviewing agent asked whether talent_pool should filter out profile_visibility == 'hidden'. It should not, and its own reasoning was right: a view filter is presentation, not security — any REST call bypasses it. A filter here would create the appearance of enforcement while leaving the API open, which is worse than the current honest emptiness. The enforcement belongs in the permission set.
Suggested shape
Not pinned — whoever takes this should propose and get it ruled on, because it decides what "limited" means as a contract:
- Employers reach a candidate through the application, not through a global pool: an RLS rule on
ats_candidate admitting rows that have an ats_application to one of the reader's jobs. ⛔ Cross-object traversal in RLS is a hard compile error (ADR-0055) — this needs a stamped scalar on ats_candidate, the way employer_org was stamped elsewhere, or the talent pool has to be re-thought.
public / limited / hidden then select which fields are readable, i.e. FLS, alongside the existing expected_salary_* seals — not whether the row exists.
Both halves touch DESIGN.md §03's isolation model, so this likely needs a needs-user-decision before implementation rather than an agent's judgement call.
Verification is currently blocked
Neither defect can be confirmed at runtime while #10 stands — no employer-side rows can be written at all. Confirmed by reading the tree, not by a live request.
Found during PM review of #11 (card 07). Two defects in card 06's security surface, discovered together because the talent-pool view #11 delivers is the thing they break. Not fixed in #11 —
src/security/is card 06's surface, outside that card.1. The employer-side grant on
ats_candidateresolves to zero rowsats_candidateissharingModel: 'private'(src/objects/candidate.object.ts:15) and carries auserfield but noorganizationfield.Both employer sets grant it as:
readScope: 'org'widens aprivateobject from owner-only to "rows whose owning organization is in the reader'saccessible_org_ids". A candidate row is owned by the job seeker and has no organization at all, so no employer org can ever match it. The only RLS rule anywhere onats_candidateisseeker_own_profile(using: 'user == current_user.id',permission-sets.ts:212), which belongs to the seeker set.Consequence:
ats_candidate.talent_poolandats_candidate.gallery— the two views #11 just added — render empty for every employer, as does any candidate lookup from the pipeline. The views themselves are correct; there is no readable row behind them.Note the contrast with
ats_job(public_read), where the employer grant deliberately carries noreadScopeand relies on the OWD — that one works.2.
profile_visibilityis a declared consent control that nothing enforcesgrep -rn profile_visibility src/security/returns nothing. No permission set, RLS rule or FLS entry reads it. The field's own description states an access rule that no code implements.Today this is masked by defect 1 — employers see no candidates at all, so nobody is over-exposed. Fixing defect 1 without fixing this one turns a broken view into a privacy hole, because
limitedis the default andhiddenwould still be fully readable. They must be fixed together, in that order, in one change.Why a view filter is the wrong fix
The reviewing agent asked whether
talent_poolshould filter outprofile_visibility == 'hidden'. It should not, and its own reasoning was right: a view filter is presentation, not security — any REST call bypasses it. A filter here would create the appearance of enforcement while leaving the API open, which is worse than the current honest emptiness. The enforcement belongs in the permission set.Suggested shape
Not pinned — whoever takes this should propose and get it ruled on, because it decides what "limited" means as a contract:
ats_candidateadmitting rows that have anats_applicationto one of the reader's jobs. ⛔ Cross-object traversal in RLS is a hard compile error (ADR-0055) — this needs a stamped scalar onats_candidate, the wayemployer_orgwas stamped elsewhere, or the talent pool has to be re-thought.public/limited/hiddenthen select which fields are readable, i.e. FLS, alongside the existingexpected_salary_*seals — not whether the row exists.Both halves touch
DESIGN.md§03's isolation model, so this likely needs aneeds-user-decisionbefore implementation rather than an agent's judgement call.Verification is currently blocked
Neither defect can be confirmed at runtime while #10 stands — no employer-side rows can be written at all. Confirmed by reading the tree, not by a live request.