SA-Governed Customer Lifecycle¶
Module version: 18.0.1.1.2
Governance layers: V1 (foundation) · V2 (stamps) · V3 (association models)
Last updated: 2026-04-23
Overview¶
A customer (res.partner) may exist in Odoo without any Service Account (SA)
association and without any actor assignment.
SA governance is a separate layer applied only when a customer enters an SA-governed operating scope.
Under V3, keep these concerns separate:
- customer existence in Odoo: plain
res.partner - SA governance: active
ov.sa_customer_assignment - actor assignment inside that SA:
actor_idon the active association, which may beNULL
This document therefore describes the lifecycle of an SA-governed customer,
not the lifecycle of every res.partner in the system.
SA Hierarchy — The Container Structure¶
Before any customer can exist under SA governance, the SA hierarchy must be in place.
[Company A Seed SA] is_root=True source_company_id=Company A parent_id=NULL
└── [Branch SA] is_root=False parent_id=Seed SA
[Company B Seed SA] is_root=True source_company_id=Company B parent_id=NULL
└── [Branch SA] is_root=False parent_id=Seed SA
| SA Type | parent_id |
source_company_id |
is_root |
Created by |
|---|---|---|---|---|
| Company Seed SA | NULL | SET | True | Migration 18.0.1.0.2 |
| Branch SA | Any SA | NULL | False | API / admin |
Key Rules¶
- There is exactly one seed SA per
res.company(auto-created by migration). - Every branch SA must have a parent and a
sa_managerat creation. sa_manageris exempt for seed SAs.- Branch containment: a child SA's
company_idmust match its parent's. - A stronger integrity rule: an
SA<x>branch SA must be created only from ares.partnercurrently under the correspondingOV<x>domain.
Stage 0 — SA and Agent Setup¶
0a. Company Seed SA (auto-created)¶
Created automatically by migration 18.0.1.0.2 for each res.company. No API call needed.
ov.serviced_account
name = <company name>
source_company_id = <res.company id>
company_id = <res.company id>
parent_id = NULL
state = active
0b. Branch SA (API-created)¶
POST /api/service-accounts
X-API-KEY: <system key>
{
"name": "Togo Field Operations",
"parent_id": <seed_sa_id>,
"partner_id": <org_partner_id>,
"initial_admin_partner_id": <manager_partner_id>
}
Creates:
- ov.serviced_account (state='active', account_class='EXTC')
- ov.membership for the manager (role_code='staff', manager_member_id=NULL)
- SA.sa_manager → that membership
0c. Agent Enrollment¶
POST /api/service-accounts/<sa_id>/members/enroll
Authorization: Bearer <manager_jwt>
X-SA-ID: <sa_id>
{
"name": "Jean Koffi",
"email": "jean@example.com",
"role_code": "agent"
}
Creates:
- res.partner for Jean (if not existing)
- abs.employee (free-seat, no Odoo user seat) — with partner_id linked to the res.partner
- ov.membership (role_code='agent', membership_state='active')
Stage 1 — Customer Creation Inside SA Governance¶
POST /api/contacts
Authorization: Bearer <jean_jwt>
X-SA-ID: 42
{
"name": "Marie Dupont",
"email": "marie@client.com",
"phone": "+228 90 000 001"
}
What happens inside¶
- JWT validated → Jean's
abs.employeeresolved X-SA-ID: 42validated → Jean is an active member of SA 42- V2 compatibility stamps applied to
contact_vals: x_sa_id = 42x_actor_id = Jean.partner_idx_assigned_employee_id = Jean (abs.employee)res.partnercreated with all contact fields- Portal
res.usersauto-created for Marie (login = email, random password) - Invitation email sent to marie@client.com
- MQTT
customer.createdevent published - V3 primary governance write —
ov.sa_customer_assignmentcreated:Failure at this step is logged at ERROR level. The contact is still returned successfully but a governance inconsistency is recorded. Runaccount_id = 42 partner_id = Marie.id actor_id = Jean.partner_id (res.partner id=88) state = active date_from = now assigned_by_id = Jean.partner_idPOST /api/migration/backfill-customer-assignmentsto recover.
Important boundary¶
This endpoint is intentionally the SA-governed creation path.
It should not be read as meaning that every customer in Odoo must always have: - an SA association - an actor assignment
Valid states are:
- plain res.partner, no SA association at all
- SA-governed customer with active association and actor_id = NULL
- SA-governed customer with active association and assigned actor
Stage 2 — Customer Visibility¶
List all customers (agent view)¶
GET /api/contacts
Authorization: Bearer <jean_jwt>
X-SA-ID: 42
Resolved via resolve_partner_sa_visibility_domain() — reads only ov.sa_customer_assignment:
Assignment.search([
('account_id', '=', 42),
('state', '=', 'active'),
# policy-based actor filter:
# sa_wide → no further filter
# assigned_plus_unassigned → ('actor_id', 'in', [Jean.partner_id, False])
# assigned_only → ('actor_id', '=', Jean.partner_id)
])
)->('id', 'in', partner_ids)]
The V2 x_sa_id stamp is not used for any read on res.partner — V3 is the sole read path.
Visibility policies¶
| Policy | Domain addition | Default for |
|---|---|---|
sa_wide |
None — all active in SA | staff role |
assigned_plus_unassigned |
actor_id IN (me, NULL) |
agent role |
assigned_only |
actor_id = me |
Manual override |
Stage 3 — Customer Update¶
PUT /api/contacts/101
Authorization: Bearer <jean_jwt>
X-SA-ID: 42
{
"phone": "+228 90 000 002",
"city": "Lomé"
}
V3 visibility guard runs first — confirms Jean can see contact 101 via the assignment table — then applies the field updates to res.partner.
Stage 4 — Actor Reassignment (V3-Canonical)¶
Reassign Marie from Jean to Kwame:
POST /api/contacts/101/assign
Authorization: Bearer <alice_jwt>
X-SA-ID: 42
{
"employee_id": <kwame_employee_id>
}
What happens inside (fully V3-canonical since 18.0.1.1.2)¶
- V3 visibility confirms Alice (staff,
sa_wide) can see contact 101 - Kwame's
abs.employee.partner_idresolved (auto-created if missing) - Active assignment record found:
ov.sa_customer_assignment id=1 assignment.reassign(new_actor=Kwame.partner_id, assigned_by=Alice.partner_id):- Closes old row:
id=1→state=expired,date_to=now - Opens new row:
id=2→state=active,actor_id=Kwame.partner_id,date_from=now - V2 compat stamps updated on
res.partner: x_actor_id = Kwame.partner_idx_assigned_employee_id = Kwame (abs.employee)user_id = Kwame.user_id(if set)
Stage 5 — Agent Membership Revocation¶
Jean leaves. Alice revokes his membership.
DELETE /api/service-accounts/42/members/<jean_membership_id>
Authorization: Bearer <alice_jwt>
X-SA-ID: 42
OvMembership.write({'membership_state': 'revoked'}) triggers two side-effects:
V2 side-effect (legacy compat)¶
# Clears x_actor_id on all legacy-stamped records Jean owned in SA 42
res.partner.search([x_sa_id=42, x_actor_id=Jean]).write({x_actor_id: False})
sale.order.search([x_sa_id=42, x_actor_id=Jean]).write({x_actor_id: False})
# ... all _GOVERNED_MODELS
V3 side-effect¶
# Clears actor_id on Jean's active assignment records in SA 42
# Does NOT expire the assignment — customer stays in SA as unassigned, not lost
ov.sa_customer_assignment.search([
account_id=42, actor_id=Jean.partner_id, state='active'
]).clear_actor()
# → actor_id = NULL, state stays 'active', date_to stays NULL
Note: Membership revocation uses
clear_actor()(keeps assignment active, removes actor) rather thanexpire(). This is intentional — the customer remains reachable in the SA's unassigned pool. Only explicit archival or reassignment changes the assignment state.
Stage 6 — Customer Archival¶
DELETE /api/contacts/101
Authorization: Bearer <alice_jwt>
X-SA-ID: 42
What happens (V3-correct since 18.0.1.1.2):
- V3 visibility guard confirms Alice can see contact 101
- Linked portal
res.usersarchived (active=False) first res.partner.write({'active': False})- V3: all active assignments expired —
active_assignments.expire() state → expired,date_to → now
Marie no longer appears in GET /api/contacts (filtered by active=True) and her assignment is expired. The history rows (id=1, id=2) are permanently preserved for audit.
Constraint: One Active Assignment Per Customer Per SA¶
Since version 18.0.1.1.2, uniqueness is enforced by a partial unique index:
-- Migration 18.0.1.1.2
CREATE UNIQUE INDEX ov_sa_customer_assignment_one_active_per_sa
ON ov_sa_customer_assignment (partner_id, account_id)
WHERE state = 'active';
This allows: - Any number of expired rows per customer per SA (full history) - Exactly one active row per customer per SA at any time
Attempting to create a second active assignment for the same customer/SA combination raises a ValidationError at the ORM level (Python constraint) before hitting the database index.
Backfill — Migrating Legacy Customers¶
For customers created before V3 (stamps but no assignment records):
Via Odoo module upgrade¶
Migration 18.0.1.1.0 runs automatically on odoo -u abs_connector:
INSERT INTO ov_sa_customer_assignment
(account_id, partner_id, actor_id, state, date_from, create_date, write_date)
SELECT
rp.x_sa_id, rp.id, rp.x_actor_id, 'active',
COALESCE(rp.create_date, NOW()), NOW(), NOW()
FROM res_partner rp
WHERE rp.x_sa_id IS NOT NULL
AND NOT EXISTS (
SELECT 1 FROM ov_sa_customer_assignment a
WHERE a.partner_id = rp.id AND a.account_id = rp.x_sa_id
AND a.state = 'active'
)
Via API (dev/test)¶
POST /api/migration/backfill-customer-assignments
X-API-KEY: <key>
{
"company_id": 1,
"dry_run": true
}
Returns a preview of what will be created before committing.
Data Model Reference¶
ov.sa_customer_assignment¶
| Field | Type | Description |
|---|---|---|
account_id |
Many2one ov.serviced_account |
The SA that owns this customer |
partner_id |
Many2one res.partner |
The customer |
actor_id |
Many2one res.partner |
Assigned agent's res.partner (nullable = unassigned) |
state |
Selection active/expired |
Lifecycle state |
date_from |
Datetime | When assignment became effective |
date_to |
Datetime | When assignment expired (null if active) |
assigned_by_id |
Many2one res.partner |
Who created/changed this assignment |
Constraint: Partial unique index on (partner_id, account_id) WHERE state='active' — exactly one active assignment per customer per SA. Unlimited expired rows allowed (history).
Helpers:
- expire() — sets state=expired, date_to=now
- clear_actor() — sets actor_id=NULL, keeps state active (used on membership revocation)
- reassign(new_actor_partner_id, assigned_by_partner_id) — expires self, returns new active row
Compatibility stamps on res.partner (V2, transitional)¶
| Field | Description | Status |
|---|---|---|
x_sa_id |
SA context stamp | Kept as compatibility layer — read only by non-V3-migrated controllers |
x_actor_id |
Actor stamp | Kept in sync with active assignment's actor_id |
x_assigned_employee_id |
abs.employee link | Operational assignment (free-seat employee) |
assignment_ids |
V3 inverse One2many | V3 canonical read path — use this |
Customer states under V3¶
| State | res.partner exists |
Active SA association | actor_id |
|---|---|---|---|
| Plain Odoo customer | Yes | No | N/A |
| SA-governed unassigned customer | Yes | Yes | NULL |
| SA-governed assigned customer | Yes | Yes | Set |
API Surface Summary¶
| Method | Endpoint | Auth | Governance Layer | Description |
|---|---|---|---|---|
POST |
/api/service-accounts |
API key | V1 | Create branch SA |
POST |
/api/service-accounts/<id>/members/enroll |
JWT | V1 | Enroll agent |
GET |
/api/me/service-accounts |
JWT | V1 | My SAs + role |
POST |
/api/contacts |
JWT | V2 stamp + V3 primary write | Create customer |
GET |
/api/contacts |
JWT | V3 only | List customers (SA-scoped via assignment table) |
GET |
/api/contacts/<id> |
JWT | V3 only | Get single customer |
PUT |
/api/contacts/<id> |
JWT | V3 only | Update customer |
DELETE |
/api/contacts/<id> |
JWT | V3 only + expire assignment | Archive customer |
POST |
/api/contacts/<id>/assign |
JWT / API key | V3-canonical + V2 compat sync | Reassign (close old row + open new) |
DELETE |
/api/service-accounts/<id>/members/<mid> |
JWT | V1 + V2 clear + V3 clear_actor | Revoke membership |
POST |
/api/migration/backfill-customer-assignments |
API key | V3 | Backfill assignment records |
V1 / V2 / V3 Coexistence¶
Every request:
JWT → resolve_sa_context() → ov.membership lookup (V1 always)
GET /api/contacts:
→ resolve_partner_sa_visibility_domain() ← V3 (reads ov.sa_customer_assignment)
GET /api/orders:
→ build_sa_visibility_domain() ← V2 (reads x_sa_id stamp, not yet V3)
POST /api/contacts:
→ stamp x_sa_id / x_actor_id ← V2 compat (kept for non-migrated consumers)
→ create ov.sa_customer_assignment ← V3 primary write (ERROR logged if this fails)
Plain Odoo customer outside SA governance:
→ create res.partner only ← no SA association required
POST /api/contacts/<id>/assign:
→ ov.sa_customer_assignment.reassign() ← V3 canonical (expires old, creates new)
→ write x_actor_id / x_assigned_employee ← V2 compat sync
DELETE /api/contacts/<id>:
→ res.partner.write(active=False) ← Odoo archive
→ ov.sa_customer_assignment.expire() ← V3 lifecycle close
Membership revoked:
→ clear x_actor_id on stamped records ← V2 compat clear
→ clear actor_id on assignment records ← V3 clear_actor (assignment stays active, unassigned)
Roadmap — Next V3 Phases¶
| Phase | Object | New model | Controller | Notes |
|---|---|---|---|---|
| Phase 1 ✅ | res.partner |
ov.sa_customer_assignment |
contacts.py |
Complete, all gaps resolved |
| Phase 2 | sale.order |
ov.sa_sale_order_assignment |
order_controller.py |
Next sprint |
| Phase 2 | stock.picking |
ov.sa_delivery_assignment |
New controller | Paired with orders |
| Phase 4 | ov.asset |
ov.sa_asset_assignment |
asset_api.py |
|
| Phase 5 | account.move |
ov.sa_invoice_assignment |
New controller | V3-first, no stamp |
| Phase 5 | account.payment |
ov.sa_payment_assignment |
payments.py |
Paired with invoice |
| Phase 6 | helpdesk.ticket |
ov.sa_ticket_assignment |
tickets.py |
|
| Future | Retire stamps | Remove x_sa_id/x_actor_id from res.partner |
After all consumers migrate | Final migration cleanup |