Skip to content

Reconcile displayed roles after the writes, not between them - #10

Open
DJAscendance wants to merge 2 commits into
fix/deputy-reconciliationfrom
fix/primary-role-atomicity
Open

Reconcile displayed roles after the writes, not between them#10
DJAscendance wants to merge 2 commits into
fix/deputy-reconciliationfrom
fix/primary-role-atomicity

Conversation

@DJAscendance

@DJAscendance DJAscendance commented Jul 30, 2026

Copy link
Copy Markdown
Owner

Stacked on #9 (fix/deputy-reconciliation) — review that first; the diff here is only the last commit.

Closes the fifth finding from the CodeRabbit App review of #6, which was deliberately deferred there because it changes when every caller reconciles.

The bug

reconcilePrimaryRole reads role_assignment to decide whether a member's displayed role is still one they hold. The callers ran it between the remove and the add that replaced it, so it observed a state that never settled.

The simplest failing case involves no transfer at all — just re-saving an access page without touching the owner:

step role_assignment effect
remove owner's assignment (gone)
reconcilePrimaryRole(owner) (gone) reads "not held" → clears primary_role_id
re-add owner's assignment restored role is back; the badge is not

The owner keeps the role and silently loses their displayed role. Editing a place's deputies was enough to strip that place's owner of their badge, with nothing in the request to explain it.

The fix

Every mutation lands first; reconciliation happens once at the end.

  • syncDeputies collects the members it removed rather than reconciling inside the remove loop, and reconciles after the add loop.
  • Given a collector, it defers to that instead — so a caller changing both the owner and the deputies reconciles the whole set once at the very end rather than once per axis.
  • reconcilePrimaryRoles is that final pass. It deduplicates, because one member can be touched on more than one axis of a single update (losing a deputy slot while also being the outgoing owner), and skips the 0 sentinel.
  • block, hood, colony and place collect the outgoing owner into that set instead of reconciling inline.

place.service's deputy-less guard became a conditional rather than an early return. jail and cityhall have an owner role and no deputy role, and returning there would have skipped the owner's reconciliation along with the deputy sync — reintroducing this exact bug for precisely the two places the guard exists for.

Verification

7 new tests, 24 on this service. The ordering guarantee is asserted directly via mock.invocationCallOrder: the first getPrimaryRoleId read must come after the last addIdToAssignment. That assertion fails on the previous code, where reconciliation ran inside the remove loop before any add — so it is a real regression test, not a restatement of the new implementation.

Also covered: the collector path, a member who merely changed slot position not being collected at all, dedupe, sentinel skipping, and continuing past a member that throws.

tsc clean apart from the pre-existing missing sharp module. eslint 0 errors. Full suite compared against the base test-name by test-name rather than by count: identical failures, no regressions, 55 → 62 passing.

Not done here

This fixes ordering, not atomicity. The writes are still separate statements, so a concurrent request interleaving between them can still observe a gap. Closing that needs a knex transaction threaded from the service through RoleAssignmentRepository, which today takes no trx and reaches for this.db.knex directly — a repository-wide signature change well beyond this fix.

Worth being precise about the severity split: the ordering bug fired on a single request with no concurrency at all, which is why it was worth separating.

Summary by CodeRabbit

  • Bug Fixes
    • Improved consistency when updating owners and deputy assignments across access-controlled areas.
    • Ensured primary roles are correctly reconciled after all related access changes are complete.
    • Fixed updates for places without deputy roles so owner changes are still processed correctly.
    • Improved resilience when reconciling multiple role assignments, preventing one issue from blocking others.

reconcilePrimaryRole reads role_assignment to decide whether a member's
displayed role is still one they hold. The callers ran it between the
remove and the add that replaced it, so it observed a state that never
settled.

The simplest case needs no transfer at all. postAccessInfo removed the
owner's assignment, reconciled, and only then re-added it -- so re-saving
an access page WITHOUT changing the owner cleared that owner's
primary_role_id, because at the moment of the read they genuinely held
nothing. They kept the role and lost the badge, and nothing in the
request explained why. Editing a place's deputies was enough to strip its
owner's displayed role.

Every mutation now lands first and reconciliation happens once at the
end:

- syncDeputies collects the members it removed instead of reconciling
  inside the remove loop, and reconciles them after the add loop.
- Given a collector, it defers to that instead, so a caller changing both
  the owner and the deputies reconciles the whole set once at the very
  end rather than once per axis.
- reconcilePrimaryRoles is that final pass. It deduplicates, because the
  same member can be touched on more than one axis of one update --
  losing a deputy slot while also being the outgoing owner -- and skips
  the 0 sentinel.
- block, hood, colony and place collect the outgoing owner into that set
  rather than reconciling inline.

place.service's deputy-less guard became a conditional instead of an
early return. 'jail' and 'cityhall' have an owner role and no deputy
role, and returning there would have skipped the owner's reconciliation
along with the deputy sync -- reintroducing the same bug for exactly the
two places the guard exists for.

Verified: 7 new tests, 24 total on this service. The ordering guarantee
is asserted directly via mock invocationCallOrder -- the first
getPrimaryRoleId read must come after the last addIdToAssignment -- which
fails on the previous code, where reconciliation ran inside the remove
loop before any add. Plus the collector path, a member who merely changed
position not being collected, dedupe, sentinel skipping, and continuing
past a member that throws. tsc clean apart from the pre-existing missing
'sharp' module. eslint 0 errors. Full suite compared against a stashed
baseline test-name by test-name: identical failures, no regressions, 55
-> 62 passing.

Deliberately not done: this fixes ORDERING, not atomicity. The writes are
still separate statements, so a concurrent request interleaving between
them can still observe a gap. Closing that needs a knex transaction
threaded from the service through RoleAssignmentRepository, which today
takes no trx and reaches for this.db.knex directly -- a repository-wide
signature change well beyond this fix. The ordering bug was the one that
fired on a single request with no concurrency at all.
@coderabbitai

coderabbitai Bot commented Jul 30, 2026

Copy link
Copy Markdown

Review Change Stack

Important

Review skipped

Auto reviews are disabled on base/target branches other than the default branch.

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: dcf25c27-f2cc-4316-a766-a39c9489dbe8

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review
📝 Walkthrough

Walkthrough

Access-information updates now defer primary-role reconciliation until owner and deputy assignment changes finish. Role assignment synchronization collects affected members, deduplicates reconciliation, isolates errors, and supports places without deputy roles.

Changes

Primary-role reconciliation

Layer / File(s) Summary
Deferred reconciliation and validation
api/src/services/role-assignment/role-assignment.service.ts, api/src/services/role-assignment/role-assignment.service.spec.ts
syncDeputies collects removed members and optionally defers reconciliation; reconcilePrimaryRoles deduplicates IDs, skips falsy values, and continues after per-member failures. Tests cover ordering, collectors, movement-only changes, arrays, and sets.
Access-information integration
api/src/services/{block,colony,hood,place}/*.service.ts
Access-information flows collect affected owners, pass the set through deputy synchronization, and reconcile all touched primary roles afterward. Place updates skip only deputy synchronization when no deputy role exists.

Estimated code review effort: 3 (Moderate) | ~20 minutes

Sequence Diagram(s)

sequenceDiagram
  participant AccessInfoService
  participant RoleAssignmentService
  participant role_assignment
  participant primary_role_id

  AccessInfoService->>RoleAssignmentService: syncDeputies(..., touched)
  RoleAssignmentService->>role_assignment: Remove and add deputy assignments
  RoleAssignmentService->>AccessInfoService: Collect affected member IDs
  AccessInfoService->>RoleAssignmentService: reconcilePrimaryRoles(touched)
  RoleAssignmentService->>primary_role_id: Reconcile each touched member
Loading

Possibly related PRs

  • DJAscendance/ctr#6: Introduced the primary-role reconciliation logic extended by this change.
🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly captures the main change: deferring role reconciliation until after all writes complete.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/primary-role-atomicity

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@DJAscendance

Copy link
Copy Markdown
Owner Author

@coderabbitai review

@coderabbitai

coderabbitai Bot commented Jul 30, 2026

Copy link
Copy Markdown
✅ Action performed

Review finished.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🧹 Nitpick comments (1)
api/src/services/block/block.service.ts (1)

87-106: 📐 Maintainability & Code Quality | 🔵 Trivial | 🏗️ Heavy lift

Extract the shared owner-swap/deputy-sync/reconcile sequence instead of duplicating it four times. All four postAccessInfo methods independently re-implement the identical touched set + remove-old-owner + add-new-owner + syncDeputies(..., touched) + reconcilePrimaryRoles(touched) sequence. This is exactly the kind of duplication that let the original bug (and, per role-assignment.service.ts's docstring, its admin.service.ts sibling) go unfixed in one place while being fixed elsewhere. Moving this sequence into a single shared method (e.g. on RoleAssignmentService, taking placeId/ownerCode/deputyCode/oldOwner/newOwner/deputies) would make future ordering fixes apply everywhere at once.

  • api/src/services/block/block.service.ts#L87-L106: replace the inline touched/remove/add/sync sequence with a call to the shared helper.
  • api/src/services/colony/colony.service.ts#L83-L102: same.
  • api/src/services/hood/hood.service.ts#L82-L101: same.
  • api/src/services/place/place.service.ts#L257-L285: same, passing through the deputyCode-optional branch.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@api/src/services/block/block.service.ts` around lines 87 - 106, Extract the
shared owner-swap, deputy synchronization, and primary-role reconciliation
sequence into one helper on RoleAssignmentService, accepting the place ID,
owner/deputy codes, old and new owners, and deputy data. Replace the duplicated
inline sequence in api/src/services/block/block.service.ts:87-106,
api/src/services/colony/colony.service.ts:83-102, and
api/src/services/hood/hood.service.ts:82-101 with calls to that helper; update
api/src/services/place/place.service.ts:257-285 likewise while preserving its
optional deputyCode branch.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Nitpick comments:
In `@api/src/services/block/block.service.ts`:
- Around line 87-106: Extract the shared owner-swap, deputy synchronization, and
primary-role reconciliation sequence into one helper on RoleAssignmentService,
accepting the place ID, owner/deputy codes, old and new owners, and deputy data.
Replace the duplicated inline sequence in
api/src/services/block/block.service.ts:87-106,
api/src/services/colony/colony.service.ts:83-102, and
api/src/services/hood/hood.service.ts:82-101 with calls to that helper; update
api/src/services/place/place.service.ts:257-285 likewise while preserving its
optional deputyCode branch.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: adb65224-71d9-4cd5-a30c-dd862bafe780

📥 Commits

Reviewing files that changed from the base of the PR and between d1f6d02 and 2a4219b.

📒 Files selected for processing (6)
  • api/src/services/block/block.service.ts
  • api/src/services/colony/colony.service.ts
  • api/src/services/hood/hood.service.ts
  • api/src/services/place/place.service.ts
  • api/src/services/role-assignment/role-assignment.service.spec.ts
  • api/src/services/role-assignment/role-assignment.service.ts

CodeRabbit review of #10. The previous commit fixed the reconciliation
ordering but left the corrected sequence -- collect touched, remove old
owner, add new owner, syncDeputies deferring into the set, reconcile the
set -- written out four times, once per place type. As the review put it,
that is the same duplication that let the original bug go unfixed in one
place while being fixed elsewhere. It was four chances to get an
order-sensitive sequence subtly wrong, in code where getting it wrong is
invisible: the request succeeds and a member quietly loses their badge.

RoleAssignmentService.syncPlaceAccess now owns the order. Callers keep
what is genuinely theirs -- which role ids count as owner and deputy
there, and resolving submitted usernames to member ids -- and hand over
the writes.

place.service keeps a local check for whether the place has a deputy role
at all, but only to skip pointless username lookups for 'jail' and
'cityhall'. The skip-the-deputy-half decision itself is the shared
method's, so those places still get their owner swapped and reconciled.

6 new tests, 30 on this service. The headline one asserts the original
bug directly: with the owner UNCHANGED, the first getPrimaryRoleId read
must come after the last addIdToAssignment, via mock invocationCallOrder.
Also covered: the outgoing owner reconciled after the incoming one is
written, no owner writes when the place has none on either side, a
deputy-less place still swapping and reconciling its owner, a member who
is both outgoing owner and dropped deputy being reconciled exactly once,
and both halves applying together.

Verified: tsc CLEAN -- the missing 'sharp' module that failed on every
branch all session was a genuinely absent dependency, declared in
package.json and package-lock.json since the home-image work but never
installed into the shared api/node_modules. Installed with --no-save so
the tracked lockfile is untouched; every worktree symlinks that directory
so all five branches typecheck clean now. Nothing else was hiding behind
that error. eslint 0 errors. Suite 62 -> 76 passing, same 2 pre-existing
failures.
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.

1 participant