Skip to content

Association Tables

Every governed Odoo object gets two tables with separate lifecycles:

ov.sa_<object>_assignment          ←  "Which SA owns / can see this object?"  (SA-scope layer)
ov.actor_sa_<object>_assignment    ←  "Who in that SA is actively working it?"  (actor-scope layer)

An SA can hold an object with no actor yet. An actor can be removed without the SA losing its claim. These two facts never interfere with each other.

Naming convention (ADR-0003 §6): The actor-layer model carries the actor_ prefix and mirrors the assignment model name: ov.actor_sa_<domain>_assignment. The actor_ prefix signals "child of the SA association".


Table Schemas

ov.sa_<object>_assignment

SA-level claim on the governed object.

Field Type Description
account_id Many2one → ov.serviced_account The SA that owns / sees this object
<object>_id Many2one → native Odoo model The governed record (e.g. partner_id, lead_id)
state Selection active / expired Active = SA currently holds this object
access Selection access / assignment / binding What this SA is permitted to do with the object (default: binding)
date_from Datetime When the SA claimed the object
date_to Datetime When the SA released it (set on expire)
assigned_by_id Many2one → res.partner Who created this SA-level claim

Uniqueness constraint:

UNIQUE (object_id, account_id) WHERE state = 'active'
One SA can hold an active claim on an object only once. The same object can have active claims in multiple SAs simultaneously — each with its own access level.

access field — Restriction Levels

Value Read Update Create related Delete / archive Transfer / expire
access
assignment
binding
  • access — The SA can see the object for reference/reporting only. Another SA holds primary governance.
  • assignment — The SA can do day-to-day operational work (update, create related records) but cannot delete or transfer governance.
  • binding — The SA fully owns and governs the object. Full CRUD plus governance actions (expire, transfer). This is the default when an SA creates an object.

Methods on every assignment model:

Method What it does
expire() Cascade-inactivates all child actor rows (ov.actor_sa_*_assignment), then sets state='expired', date_to=now on the assignment. ADR-0003 §7: the SA association is the governing container — expiring it must retire all actor rows.

Removed (ADR-0003 §5): actor_id field, clear_actor(), and reassign() are no longer on assignment models. Actor lifecycle is managed exclusively through the actor-layer model (ov.actor_sa_*_assignment).


ov.actor_sa_<object>_assignment

Actor-scope layer: a specific agent's involvement within one SA-object assignment. The actor never bypasses the SA container — if the parent assignment is expired, this row loses effective reach.

Field Type Description
assignment_id Many2one → ov.sa_<object>_assignment The SA-object relationship this actor belongs to (subset rule: ADR-0003 §6)
actor_id Many2one → res.partner The agent / employee working this object
is_primary Boolean True = primary responsible person
state Selection active / inactive Whether this actor is currently active
access Selection access / assignment / binding What this actor is permitted to do — cannot exceed the parent SA's access level
date_from Datetime When this actor was assigned
date_to Datetime When this actor was removed
assigned_by_id Many2one → res.partner Who assigned this actor

Uniqueness constraint:

UNIQUE (assignment_id, actor_id)
An actor appears once per SA-object assignment. Multiple actors are allowed per assignment (team assignment).

access hierarchy rule

An actor's access level can never exceed the parent SA assignment's access level. The SA level is the ceiling.

SA access = 'binding'    → actor can be binding, assignment, or access
SA access = 'assignment' → actor can be assignment or access  (not binding)
SA access = 'access'     → actor can only be access

A constraint on the actor model enforces this at save time:

_RANK = {'access': 0, 'assignment': 1, 'binding': 2}

@api.constrains('access', 'assignment_id')
def _check_access_not_exceed_sa(self):
    for rec in self:
        if _RANK[rec.access] > _RANK[rec.assignment_id.access]:
            raise ValidationError(
                f'Actor access "{rec.access}" cannot exceed '
                f'SA access "{rec.assignment_id.access}".'
            )

Methods on every actor model:

Method What it does
deactivate() Sets state='inactive', date_to=now. Actor loses visibility of the object.

Note: ov.sa_invoice_assignment and ov.sa_payment_assignment intentionally have no actor model. Actor context for invoices and payments is inherited from the originating sale order.


The 26 Governed Domain Objects

All 26 assignment models and 24 actor models are implemented and registered. Invoice and payment have no actor model by design.

Domain key SA-layer model (ov.sa_*) Actor-layer model (ov.actor_sa_*) Governs Object field
customer ov.sa_customer_assignment ov.actor_sa_customer_assignment res.partner partner_id
lead ov.sa_lead_assignment ov.actor_sa_lead_assignment crm.lead lead_id
sale_order ov.sa_sale_order_assignment ov.actor_sa_sale_order_assignment sale.order order_id
delivery ov.sa_delivery_assignment ov.actor_sa_delivery_assignment stock.picking picking_id
asset ov.sa_asset_assignment ov.actor_sa_asset_assignment ov.asset asset_id
ticket ov.sa_ticket_assignment ov.actor_sa_ticket_assignment helpdesk.ticket ticket_id
subscription ov.sa_subscription_assignment ov.actor_sa_subscription_assignment abs.subscription subscription_id
invoice ov.sa_invoice_assignment (none — see note) account.move
payment ov.sa_payment_assignment (none — see note) account.payment
production ov.sa_production_assignment ov.actor_sa_production_assignment mrp.production production_id
maintenance ov.sa_maintenance_assignment ov.actor_sa_maintenance_assignment maintenance.request request_id
repair ov.sa_repair_assignment ov.actor_sa_repair_assignment repair.order repair_id
pos_order ov.sa_pos_order_assignment ov.actor_sa_pos_order_assignment pos.order pos_order_id
purchase ov.sa_purchase_assignment ov.actor_sa_purchase_assignment purchase.order purchase_id
document ov.sa_document_assignment ov.actor_sa_document_assignment documents.document document_id
sign ov.sa_sign_assignment ov.actor_sa_sign_assignment sign.request sign_request_id
task ov.sa_task_assignment ov.actor_sa_task_assignment project.task task_id
quality ov.sa_quality_assignment ov.actor_sa_quality_assignment quality.check check_id
planning ov.sa_planning_assignment ov.actor_sa_planning_assignment planning.slot slot_id
equipment ov.sa_equipment_assignment ov.actor_sa_equipment_assignment maintenance.equipment equipment_id
expense ov.sa_expense_assignment ov.actor_sa_expense_assignment hr.expense expense_id
vehicle ov.sa_vehicle_assignment ov.actor_sa_vehicle_assignment fleet.vehicle vehicle_id
event ov.sa_event_assignment ov.actor_sa_event_assignment event.event event_id
campaign ov.sa_campaign_assignment ov.actor_sa_campaign_assignment utm.campaign campaign_id
attendance ov.sa_attendance_assignment ov.actor_sa_attendance_assignment hr.attendance attendance_id
applicant ov.sa_applicant_assignment ov.actor_sa_applicant_assignment hr.applicant applicant_id

Access Levels — Full Reference

The access field is stored on both association tables. It expresses what a given SA (or actor within that SA) is permitted to do with the governed object.

SA-level access control table

This is the primary control. The SA's access value is the ceiling for everything that happens to the object within that SA — including what any actor inside it can do.

Operation access assignment binding
Read the object (GET)
Update fields (PUT / PATCH)
Create related records (POST)
Delete / archive the object
Transfer to another SA
Expire this SA assignment

Plain language:

  • access — The SA can see the object for reference only. No changes of any kind. Typical use: a secondary SA that needs visibility of an object whose governance belongs to another SA.
  • assignment — The SA can do day-to-day operational work: update fields, raise tickets, create orders against the object. Cannot remove it from governance or delete it.
  • binding — The SA fully owns and governs the object. All operations including delete, expire, and transfer. This is the default when an SA creates an object.

Actor ceiling rule

An actor's access within an SA can never exceed the SA's own access level. The SA is the hard ceiling.

SA access Actor may be set to
binding access, assignment, or binding
assignment access or assignment only
access access only

Attempting to set an actor's access higher than the SA's level raises a ValidationError at save time. The enforcement is on the actor model constraint.

Enforcement point

The access check fires in the controller before any write operation:

Request arrives → resolve SA assignment row
                → read assignment.access
                → if operation requires higher level → 403 Forbidden
                → else → proceed

Default value

binding is the default for both tables. When v3_write_assignment creates a new SA assignment row the object-creating SA receives full governance ownership. Secondary SAs that are given a reference copy (e.g. via a cross-SA share) are assigned access or assignment explicitly at creation time.

Example — same customer in two SAs

ov.sa_customer_assignment  id=1
  account_id = 26  (OV Global Root SA)
  partner_id = 4501  (John Kamau)
  access     = 'binding'      ← Global Root fully governs this customer

ov.sa_customer_assignment  id=2
  account_id = 11  (Test Company SA)
  partner_id = 4501  (John Kamau)
  access     = 'assignment'   ← Test Company can operate but not govern

An agent in Test Company can update John Kamau's phone number and create a sale order against him. They cannot delete him or transfer his governance to another SA. OV Global Root SA retains binding ownership.


Visibility Rules (scope_policy)

Enforced in build_v3_visibility_ids() in helpers.py. Every read query goes through this.

The SA assignment row (Layer 2) is always the starting point. The actor child table (Layer 3) provides the per-agent filter.

scope_policy Who can see the object Query logic
sa_wide Everyone in the SA All active ov.sa_*_assignment records for this SA — no actor filter
assigned_plus_unassigned My records + unassigned objects ov.actor_sa_*_assignment.actor_id = me OR parent SA row has no active actor child
assigned_only Only objects explicitly assigned to me Parent SA row has an active ov.actor_sa_*_assignment row for me

Role defaults (when no explicit scope_policy on the membership):

ov.membership.role_code Default scope
admin sa_wide
staff (SAM) sa_wide
agent (activator / attendant) assigned_plus_unassigned

Governance REST API

The governance API (governance_api.py) provides generic assign / release / actor endpoints for all 24 domains in _MODEL_MAP (all except invoice and payment):

Endpoint Method Action
/api/governance/<domain>/<id>/assign POST Create assignment + actor row
/api/governance/<domain>/<id>/release POST Expire assignment, deactivate all actors
/api/governance/<domain>/<id>/actors GET List active actors on this assignment
/api/governance/<domain>/<id>/actors POST Add an actor to this assignment
/api/governance/<domain>/<id>/actors/<actor_id> DELETE Deactivate an actor
/api/governance/<domain>/<id>/actors/<actor_id>/promote POST Set is_primary=True for this actor

Controller Dual-Write

When domain CRUD controllers create an object, they write both tables atomically using v3_write_assignment() from helpers.py:

Controller Domain(s)
contacts.py customer (direct create, not via helper)
order_controller.py sale_order, invoice, payment
stock_api.py delivery
asset_api.py asset
crm.py lead
tickets.py ticket
subscription_api.py subscription (direct create)

All other domains (production, purchase, task, expense, etc.) have models and governance API support, but their CRUD controllers do not yet call v3_write_assignment — assignment rows for those must be created via the governance API explicitly.


v3_write_assignment Helper

The shared function used by CRUD controllers to create both association rows atomically:

def v3_write_assignment(env, assignment_model, vals, actor_partner_id=None):
    # Back-compat: if caller passed actor_id inside vals, pull it out
    actor_id = actor_partner_id or vals.pop('actor_id', None)

    # Detect the object field (everything that is not a standard governance field)
    _GOVERNANCE_FIELDS = {'account_id', 'state', 'date_from', 'date_to', 'assigned_by_id'}
    obj_field = next((k for k in vals if k not in _GOVERNANCE_FIELDS), None)

    Assignment = env[assignment_model].sudo()

    # Step 1: Get-or-create the SA-object assignment (Layer 2)
    assignment = Assignment.search([
        (obj_field, '=', vals[obj_field]),
        ('account_id', '=', vals['account_id']),
        ('state', '=', 'active'),
    ], limit=1)
    if not assignment:
        assignment = Assignment.create(dict(vals, state='active'))

    # Step 2: Get-or-create the actor row in the child table (Layer 3)
    # Actor model name is derived by convention: ov.sa_x → ov.actor_sa_x
    if actor_id:
        actor_model = assignment_model.replace('ov.sa_', 'ov.actor_sa_')
        if actor_model in env:
            Actor = env[actor_model].sudo()
            existing = Actor.search([
                ('assignment_id', '=', assignment.id),
                ('actor_id', '=', actor_id),
            ], limit=1)
            if not existing:
                no_primary = not Actor.search([
                    ('assignment_id', '=', assignment.id),
                    ('is_primary', '=', True),
                    ('state', '=', 'active'),
                ], limit=1)
                Actor.create({
                    'assignment_id': assignment.id,
                    'actor_id': actor_id,
                    'is_primary': no_primary,   # first actor becomes primary automatically
                    'state': 'active',
                })
    return assignment

For invoice and payment, actor_model resolves to a name not in env, so the Step 2 block is silently skipped. Those domains have no actor layer by design.


Security

All 26 _assignment models have ir.model.access.csv entries:

  • base.group_user — full CRUD (1,1,1,1)
  • base.group_portal — read-only (1,0,0,0)

Actor models have no access rules — they are written exclusively via sudo() in controllers and the governance API. No direct ORM access from portal or standard user contexts.


Migration Backfill

Migration What it backfills
18.0.1.1.0/post-migrate.py res.partner stamps → ov_sa_customer_assignment
18.0.1.1.3/post-migrate.py sale.order, stock.picking, ov.asset, account.move, account.payment, helpdesk.ticket stamps → their respective assignment tables

Scenarios

Scenario 1 — SA Claims Object, No Actor Yet

SAM imports Customer X into SA-Kenya. No agent assigned yet.

ov_sa_customer_assignment:
  id=1 | account_id=SA-Kenya | partner_id=CustomerX | state=active

ov_actor_sa_customer_assignment:
  (empty)
Person Role Sees Customer X? Why
SAM of SA-Kenya staff / sa_wide ✓ Yes assignment row exists for SA-Kenya
Any agent in SA-Kenya agent / assigned_plus_unassigned ✓ Yes no actor rows → treated as unassigned
SAM of SA-Togo staff / sa_wide ✗ No no assignment row for SA-Togo
Any agent in SA-Togo agent ✗ No no assignment row for SA-Togo

Scenario 2 — One SA, One Actor (Standard Case)

SAM assigns Alice to work Customer X in SA-Kenya.

ov_sa_customer_assignment:
  id=1 | account_id=SA-Kenya | partner_id=CustomerX | state=active

ov_actor_sa_customer_assignment:
  id=1 | assignment_id=1 | actor_id=Alice | is_primary=True | state=active
Person Role Sees Customer X? Why
SAM of SA-Kenya staff / sa_wide ✓ Yes sa_wide sees all assignments
Alice agent / assigned_plus_unassigned ✓ Yes actor row exists for her
Bob (in SA-Kenya, no actor row) agent / assigned_plus_unassigned ✗ No Alice's actor row exists → object is no longer "unassigned"
Bob with sa_wide override agent / sa_wide ✓ Yes sa_wide ignores the actor table

Scenario 3 — One SA, Two Actors (Team Assignment)

SAM adds Bob as secondary actor alongside Alice.

ov_sa_customer_assignment:
  id=1 | account_id=SA-Kenya | partner_id=CustomerX | state=active

ov_actor_sa_customer_assignment:
  id=1 | assignment_id=1 | actor_id=Alice | is_primary=True  | state=active
  id=2 | assignment_id=1 | actor_id=Bob   | is_primary=False | state=active
Person Role Sees Customer X? Why
SAM of SA-Kenya staff / sa_wide ✓ Yes sa_wide
Alice agent / assigned_plus_unassigned ✓ Yes actor row exists
Bob agent / assigned_plus_unassigned ✓ Yes actor row exists
Carol (no actor row) agent / assigned_plus_unassigned ✗ No actor rows exist → not unassigned

Primary actor (is_primary=True) → Alice. Used for commission routing, escalation, notifications.


Scenario 4 — Same Object in Two SAs (Roaming Customer)

ov_sa_customer_assignment:
  id=1 | account_id=SA-Kenya | partner_id=CustomerX | state=active | date_from=Jan-1
  id=2 | account_id=SA-Togo  | partner_id=CustomerX | state=active | date_from=Jan-5

ov_actor_sa_customer_assignment:
  id=1 | assignment_id=1 | actor_id=Alice | is_primary=True  | state=active  ← Kenya
  id=2 | assignment_id=1 | actor_id=Bob   | is_primary=False | state=active  ← Kenya support
  id=3 | assignment_id=2 | actor_id=Carol | is_primary=True  | state=active  ← Togo
Person X-SA-ID header Sees Customer X? Why
Alice Kenya ✓ Yes actor row on assignment-1
Carol Togo ✓ Yes actor row on assignment-2
Alice Togo ✗ No no actor row on assignment-2
SAM of SA-Cameroon Cameroon ✗ No no assignment row

SA context is always request-scoped via X-SA-ID. Alice cannot see Customer X under SA-Togo even though the customer exists in both.


Scenario 5 — Actor Handover Without Visibility Gap

Alice leaves. Bob takes over. SA-Kenya must never lose visibility.

Step 1 — Deactivate Alice:
  id=1 | actor_id=Alice | state=inactive
  id=2 | actor_id=Bob   | state=active      ← Bob already on team, SA still visible

Step 2 — Promote Bob to primary:
  id=1 | actor_id=Alice | is_primary=True  | state=inactive
  id=2 | actor_id=Bob   | is_primary=True  | state=active

Assignment-1 is untouched throughout. Zero visibility gap.


Scenario 6 — SA Transfer (Kenya → Togo)

Step 1 — Expire Kenya assignment:
  assignment id=1 | state=expired | date_to=Feb-1
  → CASCADE: all actor rows on assignment-1 → state=inactive

Step 2 — Create Togo assignment + actor:
  assignment id=2 | account_id=SA-Togo | state=active | date_from=Feb-1
  actor id=3 | assignment_id=2 | actor_id=Carol | is_primary=True | state=active

Full audit trail: SA-Kenya held Customer X Jan-1 to Feb-1. Actor history rows remain. SA-Togo owns from Feb-1.


Query Reference

Can this SA see this object?

assignment = env['ov.sa_customer_assignment'].sudo().search([
    ('partner_id', '=', customer_id),
    ('account_id', '=', sa_id),
    ('state',      '=', 'active'),
], limit=1)
visible = bool(assignment)

Which SAs currently hold this object?

assignments = env['ov.sa_customer_assignment'].sudo().search([
    ('partner_id', '=', customer_id),
    ('state',      '=', 'active'),
])
sa_ids = assignments.mapped('account_id').ids

Who are the active actors on this object in SA-X?

actors = env['ov.actor_sa_customer_assignment'].sudo().search([
    ('assignment_id.account_id', '=', sa_id),
    ('assignment_id.partner_id', '=', customer_id),
    ('state',                    '=', 'active'),
])
actor_partner_ids = actors.mapped('actor_id').ids

Who is the primary actor?

primary = env['ov.actor_sa_customer_assignment'].sudo().search([
    ('assignment_id', '=', assignment.id),
    ('is_primary',    '=', True),
    ('state',         '=', 'active'),
], limit=1)

All objects visible to Alice in SA-Kenya (assigned_plus_unassigned):

# My explicit assignments
my_actor_recs = env['ov.actor_sa_customer_assignment'].sudo().search([
    ('actor_id', '=', alice_partner_id),
    ('state',    '=', 'active'),
])
my_assignment_ids = set(
    a.id for a in my_actor_recs.mapped('assignment_id')
    if a.account_id.id == kenya_sa_id and a.state == 'active'
)

# Unassigned assignments in SA-Kenya (no active actor rows)
all_kenya = env['ov.sa_customer_assignment'].sudo().search([
    ('account_id', '=', kenya_sa_id),
    ('state',      '=', 'active'),
])
Actor = env['ov.actor_sa_customer_assignment'].sudo()
unassigned_ids = {
    a.id for a in all_kenya
    if not Actor.search([('assignment_id', '=', a.id), ('state', '=', 'active')], limit=1)
}

# Union
visible_partner_ids = env['ov.sa_customer_assignment'].sudo().browse(
    list(my_assignment_ids | unassigned_ids)
).mapped('partner_id').ids