Skip to content

feat(core): add inert organization structure (units + memberships) - #549

Open
abdulrafey1 wants to merge 8 commits into
mainfrom
rafey/feat/org-tree-core
Open

feat(core): add inert organization structure (units + memberships)#549
abdulrafey1 wants to merge 8 commits into
mainfrom
rafey/feat/org-tree-core

Conversation

@abdulrafey1

@abdulrafey1 abdulrafey1 commented Jul 27, 2026

Copy link
Copy Markdown
Contributor

Closes: Organizational structure has no representation

Part 1 of 3 stacked PRs (core → management surface → docs); stacked on the #519 groups stack (#546). Does not close the issue on its own.

What

Adds the organizational structure as inert people-classification data — an OrganizationalUnit hierarchy and OrganizationMembership user↔unit seats — so the org chart finally has a home; it grants nothing and no permission check reads it.

Changes

  • feat(core): new sparkth/core/organization/ domain — OrganizationalUnit (adjacency + materialized id path /1/7/42/, sibling-unique names; table organizational_unit) and OrganizationMembership (soft-deleted history, partial-unique active seat; table organization_membership) with migration 94c26cf76059
  • feat(core): unit CRUD engine — create derives the path from the parent; move_organizational_unit re-parents with a one-comparison cycle check and rewrites the whole subtree in a single portable UPDATE (SQLite + PostgreSQL); delete refuses with OrganizationalUnitInUse while children or active members exist
  • refactor(core): the bulk path-rewrite's in-session staleness is handled at the write site (targeted awaited refresh), keeping get_organizational_unit on the shared bare session.get convention
  • feat(core): membership engine — idempotent race-safe add_organization_member, soft-delete remove_organization_member, get_organization_members; membership deliberately does NOT inherit across the tree

How to Test

  1. make test.backend.pytest — full suite green; coverage in tests/organization/ (models, path maintenance, moves, cycle prevention, delete guards, memberships).
  2. make mypy && make lint.backend — clean.
  3. Migration 94c26cf76059 verified up and down against PostgreSQL.
  4. Inert invariant: grep -rn "core.organization" sparkth/core/permissions/ — no engine coupling in either direction.

Notes

  • Migration required: yes — 94c26cf76059 (two new tables, no data changes).
  • The tree is deliberately inert this phase; rule-driven group membership (the phase that connects it to access) comes later, per the Permission system needs groups to grant roles to users in bulk #519 follow-up phasing.
  • Management surface (permissions, REST, CLI) arrives in the next PR of the stack.

This PR description was written with the assistance of an LLM (Claude).

@abdulrafey1 abdulrafey1 self-assigned this Jul 27, 2026
@abdulrafey1
abdulrafey1 marked this pull request as draft July 27, 2026 07:23
@abdulrafey1 abdulrafey1 added the enhancement New feature or request label Jul 27, 2026
@abdulrafey1
abdulrafey1 marked this pull request as ready for review July 27, 2026 08:27
@abdulrafey1 abdulrafey1 reopened this Jul 27, 2026
@abdulrafey1
abdulrafey1 marked this pull request as draft July 27, 2026 10:02
@abdulrafey1 abdulrafey1 changed the title feat(core): add inert organisation tree (units + memberships) feat(core): add inert organization structure (units + memberships) Jul 27, 2026
@abdulrafey1
abdulrafey1 marked this pull request as ready for review July 27, 2026 10:39

@hamza-56 hamza-56 left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Great work overall!

Comment thread sparkth/core/organization/models.py Outdated
Comment thread sparkth/core/organization/units.py Outdated
# expired_attributes so this never itself reads a possibly-expired column, and never
# disturbing unrelated objects (like the new parent) that the rewrite didn't touch.
# Session-wide by design: fetch-sync already limited expiry to UPDATE-touched rows, so filtering more is redundant.
for loaded in session.identity_map.values():

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This loop is one SELECT per stale in-session unit, and it takes a 15-line comment to explain why it's needed. Running the UPDATE with execution_options(synchronize_session=False) and doing a single session.expire_all() before the commit gets the same safety in constant time, and the comment can go with it.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pushing back on this one: synchronize_session=False + expire_all() isn't equivalent under AsyncSession. With no fetch-sync the UPDATE expires nothing (in-session units keep silently stale paths), and expire_all() then expires every object in the session — so the next plain attribute access on any previously loaded unit raises MissingGreenlet in the caller instead of lazy-loading. That's exactly the failure this loop absorbs: it refreshes only the UPDATE-touched units (typically 0–2 objects — only ones already in the session), so callers never inherit expired state. We did try the expire-all variant first and it broke callers in exactly this way. What I did take from the comment: the 15-line essay is now 5 lines (44c6b6d), including a note on why expire_all isn't a substitute.

This comment was written with the assistance of an LLM (Claude).

Comment thread sparkth/core/organization/units.py Outdated
@abdulrafey1
abdulrafey1 requested a review from hamza-56 July 29, 2026 18:39
An error occurred while trying to automatically change base from rafey/docs/permission-groups to rafey/feat/permission-groups-api July 31, 2026 10:32
abdulrafey1 and others added 8 commits July 31, 2026 15:37
OrgUnit encodes the organisation hierarchy as adjacency plus a materialized id path;
OrgMembership seats users in units (soft-deleted history). Inert by design: no permission
check reads the tree — its permission effect arrives later via rule-driven group
membership (see #519 follow-up phases).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
create derives path from the parent; move re-parents with a cycle check (one path
prefix comparison) and rewrites the subtree in a single portable UPDATE; delete refuses
while children or active members exist (OrgUnitInUse).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
get_org_unit's populate_existing=True fixed a real bug (a partially-expired path
after move_org_unit's bulk-UPDATE subtree rewrite) but in the wrong place: it forced
a fresh SELECT on every call forever and diverged from the get_role/get_group
bare-session.get convention.

The bulk UPDATE's SET clause is SQL-side (func.substr), so SQLAlchemy's
synchronize_session="fetch" fallback only partially expires the path attribute on
matched in-session objects; session.get() only re-queries on a whole-instance expire,
so a bare read after the move raised MissingGreenlet under AsyncSession.

Scope the fix to move_org_unit instead: after the bulk UPDATE, refresh (awaited)
exactly the in-session objects the rewrite touched, identified via
InstanceState.expired_attributes so unrelated objects (like the new parent) are left
untouched. get_org_unit is restored to a bare session.get, matching its siblings.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Seats users in org units: idempotent race-safe add, soft-deleted removal history.
Membership is classification data only — no inheritance across the tree and no
permission effect.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Add a test proving two roots with the same name collide via the
coalesce(parent_id, 0) unique index, since SQL's NULL-is-distinct
semantics would otherwise let duplicate roots slip through untested.

Also clarify, with one comment line, that move_org_unit's identity-map
refresh loop is intentionally session-wide: fetch-sync already scoped
expiry to the rows the UPDATE touched, so filtering further is
redundant.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Naming now follows the Organizational Structure design document: the package is
sparkth/core/organization, OrgUnit is OrganizationalUnit (table organizational_unit),
OrgMembership is OrganizationMembership (table organization_membership). The tables
migration is regenerated with the new names — it existed only on this unmerged branch,
so no applied environment is affected.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- give ix_organizational_unit_path varchar_pattern_ops so the Postgres
  btree can serve the LIKE-prefix descendant lookups; a plain btree
  under a non-C collation cannot, and every subtree query would
  seq-scan (the migration is edited in place: it is unreleased, this
  branch is its only consumer)
- delete historical organization membership rows in one bulk statement
  instead of one DELETE per loaded row
- compress the move_organizational_unit refresh-loop comment to the
  essentials

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
patch_organizational_unit applies rename/re-kind/re-parent in one
transaction: validation runs against the final state (the name is
checked against the destination's siblings) and nothing commits until
every check passes, so a combined change can never half-apply.
update_organizational_unit and move_organizational_unit now delegate
to it, keeping their signatures and behaviour.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@abdulrafey1
abdulrafey1 force-pushed the rafey/feat/org-tree-core branch from b92b0a9 to 7b72814 Compare July 31, 2026 10:41
@abdulrafey1
abdulrafey1 deleted the branch main July 31, 2026 10:41
@abdulrafey1 abdulrafey1 reopened this Jul 31, 2026
@abdulrafey1
abdulrafey1 changed the base branch from rafey/docs/permission-groups to main July 31, 2026 10:42
@abdulrafey1 abdulrafey1 reopened this Jul 31, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

enhancement New feature or request

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Organizational structure has no representation

2 participants