Skip to content

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_id on the active association, which may be NULL

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_manager at creation.
  • sa_manager is exempt for seed SAs.
  • Branch containment: a child SA's company_id must match its parent's.
  • A stronger integrity rule: an SA<x> branch SA must be created only from a res.partner currently under the corresponding OV<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

  1. JWT validated → Jean's abs.employee resolved
  2. X-SA-ID: 42 validated → Jean is an active member of SA 42
  3. V2 compatibility stamps applied to contact_vals:
  4. x_sa_id = 42
  5. x_actor_id = Jean.partner_id
  6. x_assigned_employee_id = Jean (abs.employee)
  7. res.partner created with all contact fields
  8. Portal res.users auto-created for Marie (login = email, random password)
  9. Invitation email sent to marie@client.com
  10. MQTT customer.created event published
  11. V3 primary governance writeov.sa_customer_assignment created:
    account_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_id
    
    Failure at this step is logged at ERROR level. The contact is still returned successfully but a governance inconsistency is recorded. Run POST /api/migration/backfill-customer-assignments to 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)

  1. V3 visibility confirms Alice (staff, sa_wide) can see contact 101
  2. Kwame's abs.employee.partner_id resolved (auto-created if missing)
  3. Active assignment record found: ov.sa_customer_assignment id=1
  4. assignment.reassign(new_actor=Kwame.partner_id, assigned_by=Alice.partner_id):
  5. Closes old row: id=1state=expired, date_to=now
  6. Opens new row: id=2state=active, actor_id=Kwame.partner_id, date_from=now
  7. V2 compat stamps updated on res.partner:
  8. x_actor_id = Kwame.partner_id
  9. x_assigned_employee_id = Kwame (abs.employee)
  10. 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 than expire(). 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):

  1. V3 visibility guard confirms Alice can see contact 101
  2. Linked portal res.users archived (active=False) first
  3. res.partner.write({'active': False})
  4. V3: all active assignments expiredactive_assignments.expire()
  5. 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