Repository navigation
feat(llc): one shared people directory, with roles scoping involvement - #14464
Conversation
✅ SSOT Configuration Compliance: Passing🎉 No hardcoded values detected that have SSOT config equivalents! |
Auto-regenerated (py3.14) to match the backend and/or SLM backend schema so the required verify-generated-types gate(s) pass. Triggered by auto-fix-generated-types.yml.
Codecov Report✅ All modified and coverable lines are covered by tests. 📢 Thoughts on this report? Let us know! |
Review — BLOCK, one critical itemThe design is right and I'm not relitigating it: an installation-wide directory with roles scoping involvement rather than visibility is the recorded owner decision on #14466, and the module docstring already said companies here are organisational units, not tenants. The unscoped What blocks is that this PR's headline safety property is bypassable through a route the PR didn't touch.
Concretely: a contact holds an active role in company B. A plain member of company A — the contact's legacy The guard isn't wrong; it's just not on every door. Two ways to close it, either is fine:
A test must cover the cross-endpoint path. The nine new tests all discriminate, but every one of them enters through Two documentation items, non-blocking
Noted for later, no action here: Confirmed clean: no migration touched (no data-loss risk), |
DELETE /{company_id}/{contact_id} still called ContactService.delete, filtered
only on (id, company_id) and gated only by assert_company_access — a bare
tenant-membership check, not admin. It never consulted companies_for_contact,
so a plain member of the contact's legacy company could hard-delete a contact
who still held an active role in another company, bypassing the
ContactInUseError guard this PR adds and leaving
LLCRoleAssignment.holder_contact_id an unresolvable reference. Both delete
routes now share one guard via _delete_via_directory, which delegates to
ContactDirectoryService.delete.
…ss (#14464) The nine existing ContactDirectoryService tests all enter through the service, never HTTP, which is why the suite stayed green while the legacy route's bypass was open. Adds two tests through the real FastAPI route (httpx over ASGI, real in-memory-SQLite-backed session, no mocked service call): a company admin deleting a contact who holds a role elsewhere now gets 409, and a plain member of that company gets 403 — both leave the contact in place. Against the PR head before the companion fix both returned 204 and deleted the row. test_contacts_idor.py updated to patch ContactDirectoryService.delete instead of ContactService.delete for the delete-route cases, matching the new delegation.
Auto-regenerated (py3.14) to match the backend and/or SLM backend schema so the required verify-generated-types gate(s) pass. Triggered by auto-fix-generated-types.yml.
Applied Black, isort, and autoflake to match code-quality checks. Triggered by workflow auto-fix-formatting.yml.
The red here is inherited from the base — do not "fix" it in this PR
This PR did not cause it. Do not raise Fix in flight: PR #14497 (#14495) extracts the six Related: #14498 records a gap in the ratchet itself — its header says "raising a ceiling must fail" while the test only blocks raises above a frozen baseline, so a shrink can be spent back one compliant-looking PR at a time. |
The People tab now reads the shared model — and states what it cannot explainThe directory work created an inconsistency I had to resolve rather than leave: the People tab read Neither obvious fix was right:
Two groups, labelled
They are returned and rendered separately. Merging asserts an involvement nobody recorded; A bug the new test caught immediatelyThe first placement of the label put it inside Verification266 frontend tests pass (27 in the People suite, 14 backend directory tests). Mutation-proved on
The 18 existing People tests that failed were a deliberate contract change, not a regression:
|
Closes #13998
Thinking Path
Owner's model, which is what this implements:
So there is one real company and Company OS "companies" are its departments. People belong to the
business; departments are internal divisions of it. One installation-wide directory of humans
follows directly — a department never re-creates a person the business already employs or deals with.
What is department-scoped is the role a person holds and the work it touches. That is already
modelled:
llc_role_assignmentscarriesholder_type = contact(#14221 step 2).What Changed From The First Draft
The first version of this PR added
llc_contact_company_linksplus a migration, gating directoryvisibility per company. The owner's correction removed the need for it entirely, so the table and
its migration are gone. Net effect of this PR: no schema change, no migration.
Worth stating plainly because it is the substantive point: the correction did not add work, it
deleted a table I had proposed. Involvement was already expressible through role tenures, and having
two ways to say "this person is involved with this department" would have let them drift apart.
Consistency with #13992
A shared directory is not shared access. Knowing a supplier exists is not permission to see the
work they do — that still requires a role in the department, and role permissions still gate what a
holder may do. Need-to-know is unchanged.
What Changed
ContactDirectoryService(llc/services/contact_directory.py, new):list_directory()— installation-wide, and deliberately takes nocompany_id, so a filtercannot creep back in and quietly re-introduce the duplication this removes.
companies_for_contact()— derived from open role tenures, not stored.delete()— refuses while the contact holds a role anywhere, and the error names whichdepartments to look at.
merge()— moves role tenures to the survivor, then deletes the duplicate.find_duplicate_candidates()— suggests groups sharing a mailbox, case-insensitively.Four new routes on
llc/api/contacts.py:GET /contacts/directory— the shared directory, nocompany_id.GET /contacts/directory/duplicates— duplicate candidates, ids only.POST /contacts/{company_id}/merge— fold one contact into another; company-scoped because theauthority to merge is a department admin's, even though the directory itself is shared.
DELETE /contacts/{company_id}/directory/{contact_id}— remove a person from the shared directory;409 (not 400) while they still hold a role, naming which departments depend on them.
Review fix: the legacy
DELETE /contacts/{company_id}/{contact_id}route still calledContactService.delete, filtered only on(id, company_id)and gated only byassert_company_access(bare tenant-membership, not admin) — it never consulted
companies_for_contact, so it bypassed thenew guard entirely. A plain member of the contact's legacy company could hard-delete a contact who
still held an active role in a company they weren't even a member of, leaving
LLCRoleAssignment.holder_contact_idan unresolvable reference (no FK/cascade, by design). Fixed bydelegating
delete_contacttoContactDirectoryService.deletethrough the same_delete_via_directoryhelper the new directory-delete route uses, so both doors share one guard.Deletion is the sharp edge
In a shared directory a delete is global. Without a guard, an admin of the marketing department could
empty the IT department's org chart without ever seeing it happen.
delete()therefore refuses whileany open tenure exists — from either delete route, since both now call the same guard.
Merging stays explicit
Deciding two rows are the same human is a judgement. Matching on email would fuse two people's PII on
a guess — irreversible and invisible afterwards; two people legitimately share
info@supplier. Sodetection suggests and merge acts, only on ids a caller named.
Verification
9 service-level tests, every guard mutation-proved:
test_delete_is_refused_while_the_contact_holds_a_roletest_merge_moves_role_tenures_to_the_survivortest_an_ended_tenure_no_longer_counts_as_involvementThose nine all entered through
ContactDirectoryServicedirectly, which is exactly why the suitestayed green while the legacy route's bypass was open — a guard tested only through the door it's
mounted on says nothing about the other door. Two router-level tests close that
(
llc/tests/test_contacts_delete_route_guard.py), seeded from real rows and driven through the realFastAPI route via
httpx.AsyncClient, not a mocked service call:test_legacy_delete_route_refuses_when_the_contact_holds_a_role_elsewhere— an admin of thecontact's own company, contact holds a role elsewhere → 409, contact untouched. Against the PR head
before this fix this returned 204 and deleted the row.
test_legacy_delete_route_refuses_a_plain_member_regardless_of_roles— the review's concretescenario verbatim: a plain member (not admin) of the contact's own company → 403, contact untouched.
Before this fix,
assert_company_accessdoesn't check membership role, so this also returned 204.llc/tests/test_contacts_idor.pyupdated to patchContactDirectoryService.delete(notContactService.delete) for the delete-route tests, matching the new delegation.find_duplicate_candidatesis plain SQL rather thanarray_agg: that is Postgres-only, and a helperthat cannot run on the SQLite harness is one whose behaviour is never verified.
alembic headsunchanged from base, since this PR adds no migration.Not in this PR
The frontend UI for the directory, merge and duplicate-review flows, and migrating
ContactService's existing per-company readers (list_by_company,get,update,create) ontothe directory service.
LLCContact.company_idis untouched here — no reader is broken, and droppingthe column is a follow-up once nothing depends on it.
Model Used
Claude Opus 5 (1M context)