Skip to content

M1: complete the data model and the isolation model - #1

Merged
os-sam merged 3 commits into
mainfrom
claude/bootstrap-m1-objects-security
Sep 6, 2026
Merged

M1: complete the data model and the isolation model#1
os-sam merged 3 commits into
mainfrom
claude/bootstrap-m1-objects-security

Conversation

@os-sam

@os-sam os-sam commented Sep 6, 2026

Copy link
Copy Markdown
Collaborator

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

pnpm validate   ✓  11 Objects · 129 Fields · 5 Positions · 6 Permissions
pnpm lint       ✓  All checks passed — no errors, warnings or suggestions
pnpm typecheck  ✓  tsc --noEmit

The decision this PR settles

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 that: 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. 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 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-* — an allowRead grant on a private object without a readScope is 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 with readScope: 'org' (plus writeScope where 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 domainats_candidate (owned by the candidate; contact and salary-expectation fields are the ones §03 masks) and ats_candidate_credential (is_expiring compares against daysFromNow(90) rather than doing date arithmetic, which faults the build).

Transaction domainats_application (pipeline spine; unique index on (job, candidate), roll-up interview_count), ats_interview (related list, not inline line items), ats_offer (the employer's own internal approval lives in status), 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. employer and employer_org on 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 in DESIGN.md §03 and on card 11.

🤖 Generated with Claude Code

https://claude.ai/code/session_01PbJ5Cy9KDAzeQHo8bsMadG

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 os-sam changed the title M1: candidate and transaction domains, then security M1: complete the data model and the isolation model Sep 6, 2026
@os-sam
os-sam marked this pull request as ready for review September 6, 2026 02:39
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
@os-sam
os-sam merged commit 8293e45 into main Sep 6, 2026
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.

2 participants