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