File size: 26,937 Bytes
bf8519f f546440 bf8519f f546440 bf8519f f546440 bf8519f f546440 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 | """Module registry β the single source of truth for the dashboards.
`app.py` builds the sidebar nav from this, and decides whether the global Brand (DBA) filter
applies to a page. `validate.py` runs each module's validate() from this. Adding a module =
add one row here + a page function in app.py + a module in modules/.
Fields:
key stable id (matches the modules/<key>.py file and the app page_<key> function)
label sidebar/title text (no emojis)
brand True -> DBA-filterable (honours the Fisch/Royal selector)
False -> consolidated/company-level (ignores the brand selector)
hq True -> HQ / company-level (cash flow, balance sheet, AR/AP, consolidated)
validate True -> module exposes validate() and is included in validate.py
parent set -> this row is a SUB-MODULE of <parent key>: it leaves the Workflows dropdown and
renders as a sub-nav button when its parent (or a sibling) is the active module; a
parent grant covers its sub-modules (owner nav redesign 2026-07-23)
archived True -> RETIRED from the UI for EVERYONE (owner 2026-07-27, supersedes the
2026-07-23 per-user-restore semantics): no nav entry, no Settings>Modules checkbox,
prewarm and validate.py skip it. Code and data layer stay in the repo but are NOT
maintained β spend no effort on archived modules until the owner un-archives.
2026-07-23 pass: backorders, pricecomp, o2c, bookings, spend, expenses.
2026-07-27 pass (keep = Sales + Customers family + Agents + Procurement +
Collections + Analyst/plumbing): products, assortment, financial, management,
pricing, inventory, vendor, warehouse, returns, outreach, complexity, health.
subject set -> the REAL-WORLD POPULATION this row presents, as a source-qualified id
('odoo:res.partner'). β W33-T41 / item 12a: it is what makes "two databases present
the same thing" a statement code can refuse, and it exists because NO OTHER FIELD
CAN CARRY THAT TEST. Not the key (`product_data` and `ut_odoo_products` are different
keys by construction β that is the bug, not the check), and NOT THE LABEL: the owner's
own complaint is "why do we have 'Odoo products' ALREADY with the current Products
database?", i.e. the twin was already carrying the label the legacy row is about to
take (W33-T42). Between T42 and T44 the two rows legitimately share a label, so a
label-keyed check would refuse the rename that fixes the bug.
β A row without `subject` claims nothing and conflicts with nothing β silence is not
a claim, so adding this field cannot retro-refuse anything already shipped.
topic set -> this row is a GOVERNABLE DATABASE and this is the word the rest of the codebase
already calls it by (`routes_grid._SCOPES`, `routes_alerts._TOPICS`, `scope=` on the
grid routes). β W33-T46 / contract C3: it is what `governable_modules()` keys on, and
it exists so `routes_admin`'s permission list can be DERIVED from the registry instead
of being the hardcoded two-element tuple that made every tenant see Customer + Product.
note one-line scope note shown in the UI / docs
"""
# WAVE-9 I8 β `'source'`: which connected system a module's data comes from.
#
# The owner's model: every nav entry is "a piece of database from a source", so the nav shows the
# source beside the name ("Sales Β· Odoo"). It is a REGISTRY FACT rather than a string in the nav
# code precisely so the nav needs no edit when a second connector lands β a user-created blank
# table simply carries a different source, and a module with no `source` (Metric Dictionary,
# Settings, the Analyst) renders no badge because it is not a database at all.
# See [[connector-onboarding]]: the plug-in seam is datastore.py, and Odoo has no delegated
# OAuth, so the "+ New" connect flow is the credential-form shape, not an OAuth redirect.
REGISTRY = [
# ARCHIVED wave 16 (owner item 7, R3/R11, 2026-08-02): the PAGE went on the strength of
# C-CHARTCAP β every Sales block shape (YoY compare series, KPI delta tiles, group-by
# tables) is now assemblable BY HAND from the grid's chart/dashboard views, which is what
# R3 required before deletion. `modules/sales.py` (the data layer) STAYS β drawers and the
# briefing still read it, and pages_sales.py survives UNREGISTERED as the Y1 envelope's
# template + verify_api's fixture.
{'key': 'sales', 'label': 'Sales', 'brand': True, 'hq': False, 'validate': True, 'source': 'Odoo', 'archived': True,
'note': 'Revenue, YoY, seasonality, reps, customers, SKUs β by BU. Retired wave 16: '
'rebuild as grid chart/dashboard views (compare series, KPI deltas, tables).'},
# ARCHIVED AS A PAGE (owner item 14, wave 8): "archive the current form of the Customer
# dashboard completely, i want to redesign it, exactly with the backend we have". So the
# PAGE goes and the DATA LAYER stays β modules/customers.py still powers the Customer table's
# metrics and still validates against Odoo, which is what the redesign will be built on.
#
# β NOT 'archived': True. Archived means invisible to everyone, and this row is the parent of
# customer_data + cohort β archiving it would take the whole family out of the nav and strand
# its children. 'group_only' says exactly what is true: this key names a FOLDER, never a
# destination. It has no PAGE_FUNCS entry, the nav never renders it as a leaf, and a stale
# deep link to it redirects to the family's first visible member.
{'key': 'customers', 'label': 'Customers', 'brand': True, 'hq': False, 'validate': True,
'group_only': True,
'note': 'Folder for the customer surfaces (Customer table, Cohort). The old Customers '
'dashboard was retired for redesign (owner item 14, 2026-07-28); its metric layer '
'(modules/customers.py) still backs the table and still validates against Odoo.'},
# LABEL renamed 'Customer List' -> 'Data' (owner 2026-07-25) -> 'Customer' (owner item 5,
# 2026-07-27); KEY renamed 'customer_list' -> 'customer_data' (owner 2026-07-26). The key
# rename is safe ONLY because _LEGACY_KEYS (ui/session.py) migrates grants, Library prefs
# and ?page= deep links ON READ β stored user records are never edited, exactly as
# myday->customer_list was done. The STORE keys are deliberately NOT renamed:
# 'customer_table_workspace' and 'customer_lists' hold real user data, and renaming them
# would orphan every saved view. Label-only changes need none of that machinery.
# LABEL renamed 'Customer' -> 'Odoo customers' (owner item 12, 2026-08-14 / W33-T42): the
# naming convention of every other database from this connector ("Odoo orders", "Odoo vendor
# bills"). β STILL LABEL-ONLY, and the note above is the precedent β the KEY does not move, so
# no grant, saved view, cohort, formula, shared column or `?page=` deep link is touched.
# β `nav_meta` could not have done this: `routes_nav.py` raises `400 name_not_allowed` for any
# key that does not start with `ut_`, so a tenant rename cannot reach a registry module at all.
{'key': 'customer_data', 'label': 'Odoo customers', 'brand': True, 'hq': False, 'validate': False, 'source': 'Odoo', 'parent': 'customers',
'subject': 'odoo:res.partner', 'topic': 'customer',
'note': 'Build, filter and save customer lists: the whole scoped book with per-customer '
'metrics; saved lists are visible, editable formulas (Call list and Win-back ship '
'as templates) plus hand-picked members. Sub-module of Customers (owner nav redesign 2026-07-23; replaced My Day).'},
# Cohort (owner item 6, 2026-07-26): the SAME table as Data over a FIXED set. The
# difference is membership semantics, not layout β a saved view re-populates as the
# data moves, a cohort only changes when a person edits it. validate:False because
# there is no Odoo aggregate to reconcile a hand-picked list against; its metrics are
# the customers module's, already validated there.
# ARCHIVED wave 16 (owner item 5, R10 β the fold-in): every cohort now projects as a
# LOCKED VIEW in the Customer rail's "Cohorts" section (wave-15 C-LOCK), so the separate
# nav destination is gone. ALL cohort machinery stays: `scope=cohort` still works
# (routes_grid._SCOPES, storage keys, events), page_cohort remains in app.py, and
# _LEGACY_KEYS maps cohortβcustomer_data so grants + ?page= deep links land on the grid
# that now hosts the cohorts.
{'key': 'cohort', 'label': 'Cohort', 'brand': True, 'hq': False, 'validate': False, 'source': 'Odoo', 'parent': 'customers', 'archived': True,
'note': 'Hand-curated, unchanging customer lists β folded into the Customer rail as '
'locked views (wave 16). The set is fixed: it changes only when someone adds '
'or removes a member. Open them from Customer > Views > Cohorts.'},
# ARCHIVED wave 16 (owner item 7, R11, 2026-08-02) beside Sales. The agent DRAWER and the
# data layer (modules/agent.py, agent_* caches) STAY β agent entities still open from
# customer surfaces; an own-book agent login's home is the Customer grid narrowed by the
# R1 permanent filter ("Agent is X"), not this page.
{'key': 'agent', 'label': 'Agents', 'brand': True, 'hq': False, 'validate': True, 'source': 'Odoo', 'archived': True,
'note': 'Per-agent book: sales (custom dates), returns, top SKUs, full customer list incl. inactive. '
'Retired wave 16: agent drawers + the R1 own-book filter carry the use case.'},
# Wave 16 C-TOPIC (owner items 9+10, R4): the PRODUCT table family β the second object on
# the table-page factory. `products_family` is the folder head (the `customers` pattern:
# group_only, never a destination); `product_data` is the grid over the SKU dataset, with
# its OWN workspace bucket ('product_table_workspace') and the /products + scope=product
# seams. The archived Streamlit `products` (SKU) row below stays archived and untouched,
# exactly as R4 rules.
{'key': 'products_family', 'label': 'Products', 'brand': True, 'hq': False, 'validate': False,
'group_only': True,
'note': 'Folder for the product surfaces (Product table). The archived SKU dashboard is '
'not part of this family.'},
# LABEL renamed 'Product' -> 'Odoo products' (owner item 12, 2026-08-14 / W33-T42) β see the
# customer_data note above; label-only, the key stays `product_data`.
{'key': 'product_data', 'label': 'Odoo products', 'brand': True, 'hq': False, 'validate': True, 'source': 'Odoo', 'parent': 'products_family',
'subject': 'odoo:product.product', 'topic': 'product',
'note': 'The SKU catalogue as a grid: per-product revenue, units and (consolidated) '
'stock columns, with saved views and custom fields. Identity = the SKU code.'},
{'key': 'products', 'label': 'SKU', 'brand': True, 'hq': False, 'validate': True, 'archived': True,
'note': 'SKU movers, zombies, velocity, coverage, drawers β by BU (sales-derived).'},
{'key': 'assortment', 'label': 'Assortment', 'brand': True, 'hq': False, 'validate': True, 'archived': True,
'note': 'Facet-level performance (category/color/occasion/collection/season) + season readiness β by BU.'},
{'key': 'financial', 'label': 'Financial', 'brand': True, 'hq': False, 'validate': True, 'archived': True,
'note': 'Gross margin by BU/category/SKU. Cash-conversion cycle is HQ-consolidated.'},
{'key': 'pricing', 'label': 'Pricing', 'brand': True, 'hq': False, 'validate': True, 'archived': True,
'note': 'Per-SKU margin/markup/LTM sales + allocated net P&L (channel-rate cost-to-SKU) for pricing decisions.'},
{'key': 'management', 'label': 'Management P&L', 'brand': False, 'hq': True, 'validate': True, 'archived': True,
'nav': False, # rendered INSIDE the Financial module (view toggle) β archived WITH financial
'note': 'Operating-model management income statements (FFS/RI/GD/BU-M) reproduced from raw Odoo, Sep-2024+.'},
{'key': 'inventory', 'label': 'Inventory', 'brand': False, 'hq': False, 'validate': True, 'archived': True,
'note': 'On-hand stock is physically consolidated (shared warehouse); not DBA-split.'},
{'key': 'procurement', 'label': 'Procurement', 'brand': False, 'hq': False, 'validate': True, 'source': 'Odoo',
'validate_only': True,
'note': 'PAGE RETIRED 2026-08-03 (owner wave-17 item 13: "We should be able to replace '
'Procurement completely, and add it as part of the Product database"). The buy '
'list is now a saved VIEW on the Product grid, filtered on a FORMULA field over '
'the supplier/lead-time columns and the demand measure β the owner\'s ruling R3: '
'"Buy list is just a View, with a Filter from a Formula field that taps into '
'Metrics Fields... the 8-month demand baseline etc. is just math in a Formula '
'field." The ROW STAYS validate_only: `modules/procurement.py` still owns the '
'supplier master map (procurement_suppliers.json, 3,963 SKUs) that seeds those '
'columns, and its validate() is the proof they reconcile.'},
{'key': 'vendor', 'label': 'Vendors', 'brand': False, 'hq': False, 'validate': False, 'archived': True,
'note': 'Vendor master data + the SKUs each supplies; multiple-vendor / cheapest-price view.'},
{'key': 'warehouse', 'label': 'Warehouse', 'brand': False, 'hq': False, 'validate': True, 'archived': True,
'note': 'Movement & efficiency: on-time ship, Pick-Pack-Ship cycle times, throughput, backlog age, shrinkage. Physical ops (consolidated).'},
{'key': 'ar', 'label': 'Collections', 'brand': False, 'hq': True, 'validate': True,
'source': 'Odoo', 'api_surface': False, 'admin_only': True,
'note': 'PAGE RETIRED 2026-08-03 (owner wave-17 item 15: "Collections should also be '
'entirely replicable as just a View under Customer"). The worklist is the shared '
'"Collections" view on the Customer grid β same numbers, from this module\'s own '
'reconciled blocks (ar_open/ar_overdue/ar_exposure/days_to_pay + the four aging '
'buckets). The ROW STAYS validate_only so validate.py keeps running ar.validate(), '
'which is the proof those columns rest on; archiving it would have skipped the '
'reconciliation while the numbers kept shipping. β NOT `validate_only`, and the '
'difference is a LIVE WORKFLOW: this page also mounts the admin-gated STATEMENTS '
'sender (app._collections_statements -> modules/collections_send), the ONE '
'sanctioned Odoo writer, which the same ruling says stays untouched. So the row '
'keeps a Streamlit page for ADMINS ONLY (`admin_only`) and leaves the API payload '
'entirely (`api_surface: False`) β no "Collections" in the React nav, no second '
'worklist, and the biweekly send keeps its door.'},
{'key': 'returns', 'label': 'Returns', 'brand': False, 'hq': True, 'validate': True, 'archived': True,
'note': 'Credit-note lens: refund concentration by SKU (quality) and customer (behavior). Company-level.'},
{'key': 'outreach', 'label': 'Outreach', 'brand': True, 'hq': False, 'validate': False, 'archived': True,
'note': 'Campaign emails to customer segments: templates, suppression, send log, revenue attribution. Sending is admin-gated behind SAFE_MODE.'},
{'key': 'backorders', 'label': 'Backorders', 'brand': True, 'hq': False, 'validate': True, 'archived': True,
'note': 'Confirmed-undelivered order lines aged vs promise date, valued, with a supply-aware next action per row (ship / expedite / call). Wholesale scope.'},
{'key': 'pricecomp', 'label': 'Price Compliance', 'brand': True, 'hq': False, 'validate': True, 'archived': True,
'note': 'Selling below the customer pricelist tier (LTM, per customer x SKU): the pocket-price floor worklist with annualized leak $. Sub-30% ratios usually mean a stale or pack-basis RULE β fix the rule, not the rep.'},
{'key': 'o2c', 'label': 'Cash Timing', 'brand': True, 'hq': False, 'validate': True, 'archived': True,
'note': 'Order-to-cash stage decomposition (order-to-ship / ship-to-invoice / invoice-to-paid, each with its owner) + the terms-gap rollup: contractual vs actual days per payment term with the free-credit $ it strands.'},
{'key': 'bookings', 'label': 'Order Book', 'brand': True, 'hq': False, 'validate': True, 'archived': True,
'note': 'Pre-season booking coverage: cumulative booked $ for Aug-Dec delivery vs last year same-week; category TY-vs-LY-as-of-date. The container-program decision, months ahead.'},
{'key': 'spend', 'label': 'Spend & Payables', 'brand': False, 'hq': True, 'validate': True, 'archived': True,
'note': 'Vendor-bill side: payment-terms capture (early-pay cash surrendered), duplicate-bill review, the opex spend cube (fragmentation, consolidate+rebid) and freight recovery. Company-level.'},
{'key': 'complexity', 'label': 'SKU Complexity', 'brand': False, 'hq': False, 'validate': True, 'archived': True,
'note': 'BCG tail rationalization: every SKU re-costed by ACTIVITY (lines, picks, returns) + 25%/yr carrying; KILL candidates rest on hard math (GM minus carrying), REVIEW on the pooled activity estimate. All-channel.'},
{'key': 'expenses', 'label': 'Expenses', 'brand': False, 'hq': True, 'validate': True, 'archived': True,
'note': 'Operating expense from the GL (all expense-type accounts; COGS excluded): trend, operating leverage (opex % of revenue), the YoY cost bridge, XmR control-limit spike watch list, fixed/variable split and a drill-to-ledger category directory. Company-level.'},
{'key': 'health', 'label': 'Data Health', 'brand': False, 'hq': True, 'validate': True, 'archived': True,
'note': 'Close/reconciliation scan; period-filtered, mostly company-level. ARCHITECTURE '
'(owner 2026-07-23): every discrepancy / potential-error marker lives here β '
'procurement mapping gaps, count-trust (unverified on-hand counts), untracked-on-'
'order β so operational workflows stay clean for doing the work.'},
{'key': 'dictionary', 'label': 'Metric Dictionary', 'brand': False, 'hq': True, 'validate': True,
'nav': False, 'validate_only': True,
'note': 'PAGE RETIRED 2026-07-23 (owner: broken/not customer-facing) β row kept ONLY so '
'validate.py keeps running the semantic-layer contracts (the Analyst grounding '
'proof). No nav, no page. Wave 17 R8 ("I don\'t even know what it does β delete '
'them") made that literal: `validate_only` takes it out of the account menu too, '
'which is the last place it was still visible. The proof survives; the door does not.'},
{'key': 'automation', 'label': 'Automation', 'brand': False, 'hq': True, 'validate': False,
'nav': True,
'note': 'Wave 18 (C-AUTONAV): the Automation surface β scheduled jobs that create and '
'refresh user databases (website scrape-to-DB, the Instagram field). React-only '
'surface (no PAGE_FUNCS entry, the no-new-Streamlit rule); admins hold it via '
'"all", other users need the explicit grant β fail-closed default.'},
{'key': 'settings', 'label': 'Settings', 'brand': False, 'hq': True, 'validate': False,
'nav': False, # sidebar sentinel (account group) β owner IA 2026-07-12
'note': 'User scope settings: the Business Unit toggle (strict isolation) and the Data basis '
'toggle (Orders vs Invoiced) moved here from the sidebar.'},
{'key': 'analyst', 'label': 'AIOS Analyst', 'brand': False, 'hq': True, 'validate': False,
'nav': False, # sidebar sentinel button; eval gate = harness/evals.py (run pre-ship, NOT in validate.py β live LLM cost)
'note': 'Ask the business a question in plain language: a small AI model calls governed tools over the semantic layer β answers carry their query trace and drill links. AI-generated output (Art. 50 labeled).'},
]
BY_KEY = {m['key']: m for m in REGISTRY}
def brand_filterable(key) -> bool:
return bool(BY_KEY.get(key, {}).get('brand'))
# βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
# ββ W33-T41 / item 12a β ONE SUBJECT, ONE DATABASE
# βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
#
# The owner: *"why do we have 'Odoo products' already with the current Products database? The
# Unique ID is redundant, make sure this never happens. One unique ID per database always."*
#
# β THE CHECK HAD TO GO WHERE THE BUG WAS MADE, AND THAT IS NOT WHERE ANYONE WOULD LOOK.
# `core.user_tables.create` β the UI door β HAS had a collision loop all along: it slugs the
# label to a key and appends `_2 β¦ _49` until the key is free. It never fired here for two
# reasons, and both matter. (1) It compares KEYS inside ONE tenant's `user_tables` document,
# and `customer_data` / `product_data` are compiled REGISTRY rows that never enter that
# document at all β the two halves of the duplicate live in different namespaces and only meet
# in `routes_nav.py::nav`, where the registry pages and `user_tables.nav_entries` are merged
# for display. (2) The connector path never calls `create()`: `odoo_relational.
# _ensure_table_inplace` writes `cur[key] = {...}` straight into the document. So a check bolted
# onto `create()` would have been green through the entire life of this defect.
#
# β THE SUFFIX LOOP IS ALSO WHY A RELABEL ALONE WOULD HAVE MADE THIS WORSE, MECHANICALLY: a
# naive "just rename Product to Odoo products" mints `ut_odoo_products_2` β the owner's
# redundant id again, from the fix for it.
#
#: subject -> the ONE registry key entitled to present it. Derived, never typed twice.
SUBJECT_CLAIMS = {m['subject']: m['key'] for m in REGISTRY if m.get('subject')}
# βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
# ββ W33-T46 / CONTRACT C3 β THE GOVERNABLE MODULE LIST IS DERIVED, NOT A LITERAL
# βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
#
# The owner, on his FOURTH flag of the same thing: *"its an error that ALL fucking tenant can see
# both Customer and Product database under Manage User, this is only for Royal Imports tenant.
# Make sure per tenant configuration always work."*
#
# β THE CAUSE IS A TWO-ELEMENT TUPLE. `routes_admin._PERM_MODULES = ("customer_data",
# "product_data")` β a compiled literal that `get_perms` builds its whole list from without ever
# touching `session.runtime`. It is tenant-blind BY CONSTRUCTION, which is why no amount of
# per-tenant work elsewhere ever fixed it, and why the answer is not "filter the tuple" but
# "delete the tuple": a second hand-typed copy of a fact the registry already holds is a thing
# that CAN go stale, and this one did, four times.
#
# β THE LINE BETWEEN THE TWO HALVES, stated because both sides need it: this map is
# REGISTRY-SIDE and TENANT-BLIND β it answers *"which compiled modules are governable, and what
# are they called"*, nothing more. Applying the tenant's own `config["modules"]` filter and
# merging that tenant's `ut_*` databases is the CALLER's half (`routes_admin`, D's fence), which
# is exactly what `routes_nav.py` already does correctly and `routes_admin` never did.
def governable_modules():
"""`{topic: {'key','label','subject'}}` β every registry row that is a governable DATABASE.
A fresh dict per call, deliberately: a module-level mapping handed to a route is one
`.pop()` away from being a different list for every request after the first.
"""
return {m['topic']: {'key': m['key'], 'label': m['label'], 'subject': m.get('subject')}
for m in REGISTRY
if m.get('topic') and not m.get('archived') and not m.get('group_only')}
def subject_of(key):
"""The subject a REGISTRY row claims, or None. (`ut_*` databases carry theirs on the stored
definition instead β they are per-tenant data, not compiled rows.)"""
return (BY_KEY.get(key) or {}).get('subject')
def subject_conflict(subject, key, claimed=None):
"""The OTHER key already presenting `subject`, or None.
`claimed` is an optional {subject: key} of claims outside the registry β in practice the
caller's own tenant document, whose stored definitions carry a `subject` stamp. It is a
PARAMETER rather than a lookup on purpose: the store is per-tenant, two tenants both holding
an "Odoo customers" is entirely legitimate, and a module-level cache of somebody's tables is
how D-169 happened. This function knows nothing about which tenant it is serving.
β THE TEST IS "A DIFFERENT KEY", NOT "IS THIS SUBJECT TAKEN". `_ensure_table_inplace` and
`ut_ensure` re-run on every boot and every 1800 s resync, adopting their table BY KEY β so a
check phrased "is this subject already claimed?" goes red on the second boot against the very
table it created on the first, and the container fails to start. Idempotency is not an
exception to the rule here; it is the common case.
"""
if not subject:
return None # silence is not a claim
for holder in (SUBJECT_CLAIMS, claimed or {}):
other = holder.get(subject)
if other and other != key:
return other
return None
|