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'
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_idfield,clear_actor(), andreassign()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)
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_assignmentandov.sa_payment_assignmentintentionally 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
invoiceandpayment,actor_modelresolves to a name not inenv, 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