M1: complete the data model and the isolation model - #1
Merged
Conversation
Completes the 11-object model in DESIGN.md §02. validate + lint + typecheck green, 11 objects / 123 fields, lint clean with no warnings. Candidate domain: - ats_candidate — private, owned by the candidate. Contact and expected-salary fields are the ones §03 masks from employer roles. - ats_candidate_credential — controlled_by_parent. `is_expiring` compares against daysFromNow(90) rather than doing date arithmetic, which faults the build; it drives the F5 re-certification reminder. Transaction domain: - ats_application — the pipeline spine. Unique index on (job, candidate), since there is no `unique` validation type. `employer` is denormalised from job.employer because row-level predicates compare a field to the caller and cannot traverse a lookup (ADR-0055) — the write hook that stamps it is card 06. - ats_interview — master-detail on the application, no inlineEdit: rounds are a related list on the detail page, not line items on a form. - ats_offer — the employer's own internal approval lives in `status`. - ats_report — polymorphic target_type + target_ref rather than four nullable lookups: the queue is worked by target kind, and a fifth reportable object should not need a schema change. Every stateful object carries its state machine from §02, so an illegal transition is refused at the write path rather than hidden in the UI. Added an interview_count roll-up on ats_application (lint rollup/missing-summary). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PbJ5Cy9KDAzeQHo8bsMadG
…(card 06)
Completes M1. validate + lint + typecheck green; 11 objects, 129 fields,
5 positions, 6 permission sets.
## The ruling this card was blocked on
DESIGN.md said an employer-side row is visible when the caller is an active
ats_employer_member of that row's employer. RLS cannot express it: predicates
are canonical CEL comparing a FIELD to a current_user.* placeholder, and
cross-object traversal is a compile error (ADR-0055).
Resolved as **employer = platform organization** (maintainer decision, recorded
in DESIGN.md §03). Employer-side objects carry a denormalised `employer_org`
scalar and the policies read:
employer_org IN (current_user.accessible_org_ids)
`accessible_org_ids` (ADR-0105 D2) is the caller's whole org access set, so a
recruiter placed at two employers sees both without switching context; no
membership resolves to the empty set, which fails the policy closed.
The objects deliberately do NOT set tenancy.enabled. That wall is absolute and
org-equality based, so it would also hide a job seeker's own application from
them. Business RLS lets the two audiences carry different policies over the
same rows, which is the marketplace shape.
## Two gate findings worth reading
- `security-owd-*`: an allowRead grant on a `private` object without a
`readScope` is owner-only — a recruiter would not have seen an application a
colleague created. Fixed with `readScope: 'org'` (+ `writeScope` where the set
edits): OWD sets the baseline, the scope widens it, RLS is the boundary that
holds.
- `hook-body/not-lowerable`: sharing helper functions across handlers stops the
hooks lowering to metadata-only bodies, so the app ships a bundled closure
instead of pure metadata — a change of deployment shape. Every handler is now
self-contained; the repetition is the cheaper side of that trade.
## Also
The stamp hooks are load-bearing for security, not convenience: an unstamped
row makes its policy drop out and the pipeline reads as empty. `employer` and
`employer_org` on an application are taken from the JOB, never from the incoming
payload — otherwise a caller could file into another employer's scope.
Org provisioning on employer onboarding (sys_organization + sys_member) is
seeded in M1 and belongs to flow F1; noted in DESIGN.md §03 and card 11.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PbJ5Cy9KDAzeQHo8bsMadG
os-sam
marked this pull request as ready for review
September 6, 2026 02:39
This was referenced Sep 6, 2026
Cards 07-14 are now issues #2-#9, labelled pm:queue. The index records the mapping and the real Blocked-by edges, so the dispatch loop reads dependencies off GitHub rather than off placeholder card numbers. Cards 01-06 are struck: M1 was the serial bottleneck and landed directly. Once it merges, five cards unblock at once (#2, #3, #5, #6, #7). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PbJ5Cy9KDAzeQHo8bsMadG
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Lands backlog cards 04 (candidate domain), 05 (transaction domain) and 06 (security), completing M1. These three are the serial bottleneck of the backlog — nothing else could be dispatched in parallel until they landed.
Gates
The decision this PR settles
DESIGN.mdsaid an employer-side row is visible when the caller is an activeats_employer_memberof that row's employer. RLS cannot express that: predicates are canonical CEL comparing a field to acurrent_user.*placeholder, and cross-object traversal is a compile error (ADR-0055).Resolved as employer = platform organization. Employer-side objects carry a denormalised
employer_orgscalar, and the policies read:accessible_org_ids(ADR-0105 D2) is the caller's whole org access set rather than the single active org, so a recruiter placed at two employers sees both without switching context. A caller with no membership resolves to the empty set, which fails the policy closed — zero rows, never fail-open.The objects deliberately do not set
tenancy: { enabled: true }. That wall is absolute and org-equality based, so it would also hide a job seeker's own application from them — the seeker is not a member of the employer's organization. Business RLS lets the two audiences carry different policies over the same rows, which is exactly the marketplace shape.Recorded in
DESIGN.md§03 with the rejected alternatives and why.Two gate findings worth reading
security-owd-*— anallowReadgrant on aprivateobject without areadScopeis owner-only. A recruiter would not have seen an application a colleague created; the pipeline would have been a set of private inboxes rather than a shared board. Fixed withreadScope: 'org'(pluswriteScopewhere the set edits). Composition order is the point: OWD sets the baseline, the scope widens it, RLS is the boundary that actually holds.hook-body/not-lowerable— sharing helper functions across handlers stops the hooks from lowering to metadata-only bodies, so the app would ship a bundled closure instead of pure metadata. That is a change of deployment shape, and this app's whole claim is that it is metadata. Every handler is now self-contained; the repetition is the cheaper side of that trade.Neither was visible by reading the diff. Both were caught by the gates.
What changed
Candidate domain —
ats_candidate(owned by the candidate; contact and salary-expectation fields are the ones §03 masks) andats_candidate_credential(is_expiringcompares againstdaysFromNow(90)rather than doing date arithmetic, which faults the build).Transaction domain —
ats_application(pipeline spine; unique index on(job, candidate), roll-upinterview_count),ats_interview(related list, not inline line items),ats_offer(the employer's own internal approval lives instatus),ats_report(polymorphic target rather than four nullable lookups).Security — 5 positions, 6 permission sets, RLS and FLS per §03, position↔set bindings on
kernel:bootstrapped(they cannot be a seed: the seed loader runs before the security bootstrap creates the rows it would reference), and the stamp hooks.The stamps are load-bearing for security, not convenience: an unstamped row makes its policy drop out, and the pipeline reads as empty.
employerandemployer_orgon an application come from the job, never from the incoming payload — otherwise a caller could file an application into another employer's scope.Follow-up, not in this PR
Org provisioning on employer onboarding (
sys_organization+sys_member) is seeded in M1 and belongs to flow F1; noted inDESIGN.md§03 and on card 11.🤖 Generated with Claude Code
https://claude.ai/code/session_01PbJ5Cy9KDAzeQHo8bsMadG