Build desktop apps for Odoo.
In an afternoon.
A .vapp is a web app that runs in a real desktop window on the Vizum Window Manager,
stores its data in the customer's Odoo database and talks to Odoo through a
permission-gated SDK. Start with sdk-next: vzm new,
TypeScript, Vite and a typed ORM. Plain ES modules with no build step still work.
No Odoo module. No server deploy.
Start here
What a vApp is, your first one in TypeScript, and the tools you build it with.
Concepts
The manifest, permissions, where data lives and how a vApp stays compatible.
SDK & server
The browser SDK, server-side code, routes, and porting an Odoo module to a vApp.
- The browser SDK
- Server-side code
- Routes & public pages
- Paired devices
- Guest links
- Port an Odoo module
- vzm CLI
- Odoo introspection (IDE)
- Emulator, vzm test & dev
- Webhooks, limits & retries
- Live data
- Action consent
- OAuth configuration
- Signing an action
- Data feeds (BI)
- Server metrics
- Direct devices
- Geocoding
- Schedules & alarms
- Guarded calls
- Background jobs & limits
- Offline data
- Files & public links
- Network & images
- AI tool contracts
- AI vision (images)
Examples
Complete apps, explained file by file, from a sticky note to a realtime board.
Ship it
Package, install and update a vApp, publish it on the Store, and fix what goes wrong.
Start here
What is a .vapp?
One zip file. One window. The full Vizum desktop experience for free.
Drag, resize, snap-tiling, Spaces, tabs, Mission Control — your app inherits all of it.
A per-user (or shared) key-value store in the customer's own Odoo database.
Read/write exactly the models and fields your manifest declares — nothing else.
Live theme tokens: accent color and dark mode follow the user's desktop automatically.
How it works
Why a vapp instead of a native Odoo module?
A native module is the right tool when you need to change Odoo's own core behaviour. For everything else — a department tool, a customer-facing mini-app, a dashboard, an integration — a vapp gets you there in an afternoon, with none of the server risk.
| Native Odoo module | Vizum .vapp | |
|---|---|---|
| Install | Filesystem access to the server, add to the addons path, restart the service | Upload one .zip in the browser — live instantly |
| Server restart | Required on every change (a window of downtime) | Never — apply updates while users keep working |
| Risk to the database | Arbitrary Python — a bug can corrupt or take down the whole DB | Sandboxed + capability-gated; the manifest can only narrow access |
| Odoo 17 / 18 / 19 / 20 | Port & test the code against each version separately | Write once — the SDK runs it on every version |
| Updates | Redeploy files + restart | Publish to the store — open desktops pick it up |
| Uninstall / rollback | Migrations, leftover tables, manual cleanup | Tracked install → clean, reversible uninstall |
| Who can ship it | A developer with server / SSH access | Anyone — a ZERO-RISK vapp needs no admin at all |
| Distribution | Package for the Odoo Apps Store + review | The in-platform Vizum store — instant, public or private |
| Desktop UX | Build your own UI from scratch | Windows, dock, Spaces, Mission Control, mobile — for free |
HTML + JS + a manifest. No scaffolding, no server, no review queue.
Sandboxed and double-checked — the worst a vapp can do is what its approved permissions allow.
The same .vapp runs on Odoo 17, 18, 19 and 20 — no per-version ports.
Publish and the desktops refresh. No redeploy, no restart, no maintenance window.
Start here
Quickstart — a TypeScript vApp with sdk-next SDK-next
Scaffold, build and pack a TypeScript vApp with sdk-next, the Vizum developer kit. It contains the typed SDK (@vizum/vapp-types), the UI tokens, charts, forms, the vzm CLI and the templates, as one pnpm workspace. You need Node 22.13+ and pnpm.
1. Scaffold from a template
From the root of the kit (sdk-next/):
node packages/vapp-cli/bin/vzm.mjs new vanilla-ts examples/my-app
| Template | What you get |
|---|---|
vanilla-ts | Vite + TypeScript, no framework. The smallest starting point. |
react-ts | React 18 + TypeScript + Tailwind (Vizum preset) + Zustand + TanStack Query. |
vanilla-multi | No build step at all: ES modules across js/ and css/. |
A template's manifest.json already declares "min_platform": "5.67", the highest. Lower it only to install on an older platform, and only if your app uses nothing above that version: vzm check names the version each native declaration needs (see Platform capabilities).
2. Build and pack
pnpm install --filter ./examples/my-app...
cd examples/my-app
pnpm run build
pnpm run pack
pnpm run pack runs vzm pack dist and writes <id>.vapp: a zip of your built files plus the manifest.
3. Install
On the desktop, open the dock's apps menu, choose + Install .vapp, pick the file, then Open. An app that asks only for its own private data installs for any user. Anything more (Odoo records, server-side code, routes) needs an administrator's approval.
What the template does
import { connect, model, applyTheme, V } from "@vizum/vapp-types";
interface IResPartner { id: number; name?: string; email?: string }
const ResPartner = model<IResPartner>("res.partner");
await connect(); // the handshake, before any other SDK call
applyTheme(); // follow the desktop's accent and dark mode
const rows = await ResPartner.searchRead([["is_company", "=", true]], ["name", "email"], { limit: 8 });
V.ui.notify(`Loaded ${rows.length} partners`, "success");
Next:
- The toolchain: the
vzmCLI, generating typed models from Odoo, and the packages. - Server-side code: hooks, triggers and guards.
- Quickstart without a build step: three hand-written files.
Start here
The toolchain — vzm, TypeScript, Vite and the packages
Build vApps as visual and maintainable as a modern SaaS app — TypeScript,
a typed ORM, a UI kit, charts, forms and maps — without changing how a vApp installs
or runs. A compiled vApp is still the same .vapp the
platform serves to the sandboxed iframe.
Two ways to build — same installable .vapp
"Compiled" only affects the developer's machine (a Vite build step). The output is
the same zip of static files — installed by the store / api_install
exactly as before. Hand-written and AI-generated single/multi-file vApps need no build.
TypeScript & a typed ORM
The SDK ships TypeScript types for every namespace, plus a generator that turns live Odoo metadata into typed models — autocomplete, compile-time safety, and far more reliable AI codegen.
// 1. generate typed interfaces from real Odoo fields
$ vzm introspect --from fields.json --out src/models.ts
// 2. use them — searchRead is typed; create() returns a SCALAR id (no number[] footgun)
import { SaleOrder } from "./models";
const rows = await SaleOrder.searchRead(
[["state", "=", "sale"]], ["name", "amount_total"], { limit: 10 });
const id = await SaleOrder.create({ name: "From my vApp" }); // number, not number[]
The vzm CLI
| Command | What it does |
|---|---|
vzm introspect | Odoo fields_get / Vizum.meta.fields → typed TS interfaces + accessors (the crown jewel). |
vzm pack <dir> | Zips a built (or hand-written) folder + manifest into a validated .vapp — pure JS, no deps. |
vzm new <template> | Scaffolds from a template (vanilla-ts, vanilla-multi, react-ts, offline-field-app). |
vzm check | Manifest and code against the platform contract: min_platform too low for what you use (V.tr 5.77, group_ops 5.78, V.identity 5.79, offline 5.80, x_company_id uniques and jobs with user data 5.81, V.vector.shared_namespaces 5.83), a declared language without its catalogue, untranslated native strings (--strict-i18n makes them errors), host limits, and every Odoo model, field and method the code names checked against the Odoo lines the manifest installs on (ODOO_FIELD_MISSING_ON_LINE: tax_id on 19/20 is tax_ids), the calls the bridge refuses and the grants the code lacks (--no-odoo skips them). Every code is in the CLI reference. |
vzm grants | The smallest permissions.orm the code needs (never "*"; fields only when every read names them) against the one the manifest declares: what to add, what to narrow, and which change an administrator approves again on update. --write applies the proven narrowing, --allow-widen the widening. See The grant your code needs. |
vzm i18n import-po | Turns a native module's i18n/<lang>.po into the package's i18n/<lang>.json (field labels, selections, menus, views, code strings), writing @native: keys where one English text has two translations (SDK 5.84). Never overwrites without --overwrite. |
Preview builds with source maps (feature preview-sourcemap). The Vapp Creator's preview
asks the build service for the build's source map, so an error in the built page opens the line of
src/… that threw. The map comes back beside the package, never inside it:
the .vapp you install is byte-identical to a build without it. See
Odoo introspection and preview source maps.
Multi-file — no single huge app.js, no build
A .vapp can hold as many files/folders as you like. The platform
serves every file with Access-Control-Allow-Origin: *, so
ES-module imports across files (incl. subfolders) work in the sandboxed iframe with no
build step — keep big apps tidy either way.
my-app/
index.html <!-- <script type="module" src="js/main.js"> -->
js/main.js // import { renderHome } from "./views/home.js"
js/sdk.js // local model()/connect() — no npm
js/views/home.js
css/style.css
The SDK-next packages — the full kit
Nine tree-shaken packages under the @vizum/vapp-* scope. Import
only what you use; every one bundles its assets and fetches nothing at runtime (a hard
requirement inside the opaque-origin vApp iframe), and every one reads the host --vzm-*
design tokens so it follows light/dark mode automatically.
@vizum/vapp-types — TypeScript types for every window.Vizum namespace plus a tiny typed runtime: connect(), model<T>() (generic, footgun-free ORM — create() returns a scalar id, not number[]) and applyTheme(). The foundation the rest builds on.
vzm CLI@vizum/vapp-cli — pure-Node, zero-dependency CLI: introspect (typed-ORM generator), pack (→ validated .vapp, pure-JS zip) and new (scaffold a template).
@vizum/vapp-ui — host-theme-aware CSS variables (accent + dark mode) and a Tailwind preset, so every app looks modern and consistent.
@vizum/vapp-charts — an ECharts wrapper themed from the Vizum tokens; mountChart(el, option) with auto-resize.
@vizum/vapp-forms — a declarative form built from its own compact FormSchema (not JSON Schema) (char/selection/many2one-picker/date/monetary/email…) with validation. The biggest multiplier for AI-generated apps.
@vizum/vapp-query — query-spec factories for TanStack Query over the typed ORM: searchRead, read, readGroup, count and keyFor, ready to spread into useQuery({...searchRead(…)}). Caching, refetching and optimistic updates are TanStack Query's own — this package builds the specs and the keys.
@vizum/vapp-3d — hand-rolled Three.js primitives for WOW 3D reports: createReportScene (a SceneHandle that owns its loop + dispose(), like mountChart), an extruded-GeoJSON Map3D choropleth, FlyingLines arcs, instanced DataBars, count-up KpiPanel and a ready Dashboard3D shell.
@vizum/vapp-kit — the shared look for vApps: light and dark tokens with a themable accent (setAccent), comfortable and compact density, Shell, Card, Button, inputs, Sheet, DataTable, charts, skeleton loaders, empty and error states, a Cmd/Ctrl+K CommandPalette and capture widgets (signature, photo, GPS, barcode, rating). English and Spanish are built in; pass your own translator with configureKit. It ships TypeScript source for your Vite build.
@vizum/vapp-game — the shared floor under the Vizum Arcade: fixed-timestep loop (auto-pause on tab hide), input (keyboard/pointer/touch/gamepad), procedural WebAudio audio, pooled particles, juice (shake/count-up/hit-stop), in-code pixel sprites, a token-derived theme, a Vizum.db-backed leaderboard (local fallback) and an ArcadeShell (menu, HUD, pause, results and board in dark glass; since 0.2 a per-game accent, api.run to tell a new run from a resume, shellText() for its Spanish copy, and onResize() on the 2D renderer).
@vizum/vapp-vision — MediaPipe hand-tracking processed entirely in the browser (nothing uploaded): a pure, unit-tested recognize() gesture reader (pinch/point/open-palm/fist/swipe, hand-size-relative thresholds) + createHandTracker(). Model + WASM are self-hosted via the CORS route /vizum/vision (no CDN). Needs permissions.media: ["camera"]; always ship a non-camera fallback.
Maps & external data
A vApp iframe can load CORS-enabled external resources directly — verified with
MapLibre styles + OpenStreetMap tiles + Nominatim geocoding, no platform change. For APIs
that don't send CORS headers, use the server-proxied Vizum.net.fetch.
Offline, PWA & native mobile
A vApp iframe runs on an opaque origin, so localStorage/IndexedDB
are blocked inside it — a vApp can't keep its own offline queue. That's exactly why the
host (the desktop page, with normal storage) owns the outbox behind
Vizum.offline:
// only APPROVED manifest actions can be queued — never arbitrary writes:
// "actions": [{ "name": "ticket.ack", "model": "helpdesk.ticket", "method": "action_acknowledge" }]
await Vizum.offline.status(); // { online: boolean }
Vizum.offline.on((s) => render(s.online)); // live connectivity flips
const r = await Vizum.offline.run("ticket.ack", { ids: [42] });
// online → runs live now, returns { ok, result }
// offline → the HOST queues it, returns { id, queued: true }
await Vizum.offline.pending(); // what's waiting in the host outbox
await Vizum.offline.flush(); // replay on reconnect (also automatic)
Vizum.offline.onFlushed((r) => toast(`synced ${r.sent}`));
Since 5.80 the host also keeps the datasets your manifest declares on the device and queues
Vizum.offline.create / write: read, write, and
resolve conflicts with no coverage (Offline data; the
offline-field-app template). Vizum Solo opens with no network after one online visit, and the Android
app sends queued actions, creates and changes with the app closed (sdk-next/docs/offline-pwa-mobile.md).
A service worker and install prompt for the desktop are not built; a vApp iframe can't register one.
Example gallery
15 example apps ship with the toolchain — every one verified running in the desktop.
Live revenue KPIs + an SVG revenue chart, CRM pipeline, recent orders, top customers. · vanilla · typed ORM
Revenue trend, invoices donut, top-salespeople & pipeline — real ECharts; click a bar to drill in. · React · ECharts
Search a customer, drill into orders / invoices / opportunities; click anything to open it. · vanilla · master-detail
Drag CRM opportunities between stages — optimistic write, host-confirmed. · React · DnD · TanStack
Project tasks by stage with a project switcher; drag to move. · React · DnD
Create leads/contacts from declarative JSON-Schema forms (validation + many2one pickers). · vanilla · forms
A ⌘K global search across contacts/orders/leads/invoices/products, keyboard nav. · vanilla
On-hand KPIs, top products, stock-by-location, low-stock table; bar → drill. · React · ECharts
Headcount by department/position + a searchable employee directory. · React · ECharts
Today's meetings + my activities + my open tasks, overdue in red. · vanilla
A mini-Notion notebook with a TipTap rich-text editor + autosave. · vanilla · TipTap
A real WebGL warehouse — each location a bin sized/colored by stock; orbit, hover, click. · vanilla · Three.js
Triage tickets — stage filters, list + detail, related tickets. · vanilla · master-detail
Customers plotted on a real MapLibre/OpenStreetMap map (geocoded); click a pin to open. · vanilla · MapLibre
Start here
Quickstart without a build step
Three files, one zip, one click to install.
manifest.json{
"schema": 1,
"id": "com.acme.hello",
"name": "Hello Vizum",
"version": "1.0.0",
"entry": "index.html",
"icon": "icon.png",
"window": { "w": 360, "h": 300 },
"permissions": { "data": "user" }
}
index.html
<!DOCTYPE html>
<html><head>
<meta charset="utf-8">
<link rel="stylesheet" href="/vizum_apps_platform/static/sdk/vizum-glass.css">
</head>
<body style="padding:16px">
<div class="vz-card">
<h2>Hello!</h2>
<p>Opened <b id="n">…</b> times by you.</p>
<button id="reset" class="vz-btn">Reset</button>
</div>
<script src="/vizum_apps_platform/static/sdk/vizum-sdk.js"></script>
<script src="app.js"></script>
</body></html>
app.js
(async function () {
await Vizum.connect(); // handshake with the desktop
const n = ((await Vizum.data.get("opens")) || 0) + 1;
await Vizum.data.set("opens", n); // persisted per user, in Odoo
document.getElementById("n").textContent = n;
document.getElementById("reset").onclick = async () => {
await Vizum.data.set("opens", 0);
Vizum.ui.notify("Counter reset", "success");
Vizum.window.close();
};
})();
Zip it (plus any 256×256 icon.png) and install:
cd hello-vizum/
zip -r ../com.acme.hello.vapp . -x '.*'
On the desktop: dock 🧩 → “+ Install .vapp” → pick the file → Open. Because this manifest only asks for private data, any user can install it instantly — no admin needed.
Start here
Package anatomy
A .vapp is a renamed zip with one required contract: the manifest.
my-app.vapp
├── manifest.json // REQUIRED — identity + permissions
├── index.html // REQUIRED — your entry point
├── app.js // your code, any structure you like
├── style.css assets/… // css, images, fonts — vendor everything
└── icon.png // REQUIRED — square PNG, 256×256
| Limit | Value |
|---|---|
| Zipped size | 5 MB |
| Unpacked size | 20 MB |
| File count | 200 |
| Paths | relative only — no .., no leading / |
permissions.net and call Vizum.net.fetch() —
everything else is blocked. Libraries and assets still belong inside the zip.
Concepts
The manifest, field by field
Your app's identity, window preferences and — most importantly — its permission contract.
{
"schema": 1,
"id": "com.yourcompany.appname",
"name": "App Name",
"version": "1.0.0",
"min_platform": "5.49",
"entry": "index.html",
"icon": "icon.png",
"window": { "w": 420, "h": 560, "singleton": true },
"permissions": {
"data": "user",
"orm": [
{ "model": "sale.order",
"ops": ["search", "read"],
"fields": ["name", "amount_total", "state"],
"domain": [["user_id", "=", "uid"]] }
],
"ui": ["notify", "openRecord"],
"events": { "emit": ["myapp.ping"], "listen": ["otherapp.pong"] }
}
}
| Field | Required | Meaning |
|---|---|---|
schema | yes | Manifest schema version — always 1 today. |
id | yes | Reverse-DNS, lowercase. Your app's permanent identity — never change it between versions. |
name | yes | Display name (window title, launcher, store). |
version | yes | Semver x.y.z. Re-installing the same version is rejected — bump it. |
min_platform | no, but set it | The platform capability your app needs, e.g. "5.64". The installer refuses the app on an older platform instead of letting a feature fail at runtime. Set it to the highest row of Platform capabilities whose feature you use; the vzm new templates start at "5.64", the highest; vzm check names the version each native declaration needs. |
entry | yes | The HTML file loaded into your window. |
icon | yes | PNG path inside the zip. |
window.w / h | no | Initial window size in pixels. |
window.singleton | no | true (default): opening again focuses the existing window. |
permissions | no | The contract — see the next section. |
offline | no | The offline data layer (min_platform 5.80): {datasets, writes, fallback, max_mb} — named slices of the user's own Odoo data the device keeps, and the creates and writes that may be queued with no network. Privileged; any edit asks for approval again. See Offline data. |
i18n | no | The languages the package translates, in Odoo codes (["es", "es_419"]). Each one needs its i18n/<lang>.json: vzm pack, vzm check (I18N_CATALOGUE_MISSING), the installer and the Store refuse a declared language with no file. The same file translates the UI (Vizum._t), server tr() (5.73), V.tr in hooks and jobs (5.77) and the strings your app puts in Odoo's backend (5.84). See the SDK tour. |
Concepts
Permissions & install tiers
Declare exactly what you need. Admins approve exactly what you declared. The server enforces it on every call.
| Key | What it grants |
|---|---|
data | "user" (default): a private key-value store per user.
"global": one store shared by all users of the database.
Jobs with "user" (SDK 5.81): an app with jobs may declare
"data": "user"; the platform writes no shared tick for its jobs and the job's own
V.data is the app's service user's, never another user's private data. Before 5.81 jobs
required "global". Set min_platform 5.81. |
orm on mail.template, ir.ui.view, ir.actions.report | Interim rule (2026-10-26): a vApp with code on the native path (native.methods, a field whose compute names a method, native.constraints; hooks, jobs and triggers go through the vetted V.orm and do not count) cannot hold create, write, unlink or copy on these three models while its min_platform is below 5.157, the capability that makes native writes pass through the platform's model guard. vzm check reports NATIVE_MODEL_WRITE_UNGUARDED and the Store refuses the upload. Once that capability ships, raise min_platform to it. Read access and call:send_mail are not affected. |
orm | A list of rules, one per model: model, ops
(search · read · read_group · write · create · unlink · call:<method>),
optional fields (reads return only these + id) and an optional
domain ANDed into every search — the literal "uid" becomes the
current user id server-side.
What call: never runs: a CRUD primitive (write,
search_read, update… use the op itself, so the field and domain
limits apply), and anything Odoo refuses over RPC — a _method, and every
method Odoo decorates @api.private (mapped,
filtered_domain, sudo, with_context…). The bridge reads
the decorator on the line the app runs on and answers not callable from an app;
vzm check reports it as BRIDGE_CALL_REFUSED. Server code
(methods, hooks) is module code and keeps Odoo's own rules.
Group-scoped ops (SDK 5.78): group_ops: [{groups: [...], ops: [...]}]
gives extra ops (create · write · unlink · call:<method>) only to the calling user
who is in one of the groups — "@name" (your own native.groups),
"<app-key>/@name" (an app you list in requires) or a real xmlid, never an
administrative group. Keep ops read-only for everyone and put the writes of an
owner app's administrators in group_ops: Solo builds its shared access group from
ops only, so it no longer hands every Solo user the writes. An op sits in
ops or in group_ops, not both; moving one into group_ops
narrows the grant (no new approval), adding one widens it. Set min_platform 5.78.
Your group on an Odoo model (SDK 5.101): native.access: [{model, group, ops, reason?}]
is a module's ir.model.access row on a model it does not own. group is one of
your own "@name" groups; ops (read · write · create · unlink) must be
covered by your plain orm grant on that model (no fields, write_fields, create_fields,
domain or chatter_only), and each op needs a native.rules entry of that
same group ("domain": [] for every record); write, create and
unlink need a reason the administrator reads before approving. Your
native.rules on that model narrow it as a module's record rules do, on Odoo 17–19 and on
20 alike. Identity, security, ir.* and platform models are refused
(native_access_policy in the contract). A new or wider grant needs a new approval. Set
min_platform 5.101.
Read calls (SDK 5.105): read_calls: ["catalog_images", …] on a grant of a
vApp native model (x_vzm_*) names the call: methods of that grant that only
read (each one a call:<name> of the grant, in ops or
group_ops). Only a method an app defined on that model counts: the platform runs those
in its sandbox; an Odoo model's method (sale.order.action_confirm) is refused. The desktop does not ask the user
about them (no App action dialog), so the platform runs them read-only: the same user,
grant, record rules and sandbox as before, inside a savepoint where PostgreSQL refuses every
insert, update, delete, sequence and row lock (the switch Odoo 18+'s read-only cursor uses,
the same on Odoo 17–20). A method that writes — through the ORM, a field it assigns,
a post-commit callback, a write it catches itself, the filestore, SMTP, or V.net, V.oauth,
V.ai, V.vector, V.webhooks, V.identity,
V.commit — is refused with READ_CALL_WROTE, rolled back and audited
(platform:read_call_wrote). A read call reads nothing it could not read before.
A name an update adds waits for the administrator's approval; dropping one does not. Set
min_platform 5.105.
Take access away from people (SDK 5.106): an administration app with an orm grant on
vizum.access.profile (who is restricted: user_ids, the members of
group_ids) and vizum.access.restriction (a model and
restrict_read · restrict_write · restrict_create · restrict_unlink, an optional
domain and company_ids, the companies whose records it applies to) restricts what Odoo already allows. It only narrows: Odoo’s
own record rules enforce it (a global ir.rule up to 19, a group-less ir.access on 20), so
it holds over RPC, export and import, not only in the web client. Administrators and the superuser are never
restricted, a profile cannot target one or the person making the change, and the uninstall removes everything. Models
of access_restriction_policy (credentials, vizum.*, ir.*,
res.users, res.groups, res.company) are refused; the one exception is
ir.filters (favourites): create, write and delete only, never read. A named residual: a restricted record’s
name can appear on a record the member may read, through a many2one or a composed display_name (a visible
sale order shows its hidden customer’s name; a visible contact is shown as “Hidden parent, Child”), because Odoo
renders many2one names with sudo() (odoo/orm/fields_relational.py, convert_to_read). The hidden
record’s contents, fields and actions stay denied. Set min_platform 5.106.
Block what people do (SDK 5.108): a vizum.access.operation row refuses the member’s
direct Duplicate (copy), Import, Export, message or log note, activity, button method
(button + method), report, server action, action or view type
(kind, model_id, scope all for copy, import and export).
The platform wraps only the declared (model, method) pairs; what Odoo does by itself because of an allowed action
(the copy inside a credit note, a backorder, an activity a confirmation schedules) keeps working. Administrators,
the superuser and sudo() are never judged. A message block also blocks “Send by email”
on the model’s records; an export block stops the file, not search_read. no_debug on a profile
clears the developer mode (?debug=1, assets, tests) for its members and grants no right.
A button hidden in a view stays callable over RPC; only a button row blocks it. Set min_platform 5.108.
The Access Manager’s catalogue (SDK 5.111): a vApp cannot read ir.ui.view or
ir.model.fields, so vizum.app.api_access_view_nodes(model, view_types)
(call:api_access_view_nodes, supports("access-node-catalogue")) lists, for one model,
the buttons, notebook pages, search filters and group-bys, the chatter and the fields of its primary views
(the combined arch as an administrator sees it), and for every field its facts, the nodes that name it in a modifier
(dependents: modifiers, search domains, group-bys, view attributes, kanban expressions, parent.x and the inline views of other models) and the verdict of access_field_policy per field restriction mode. The call's work is bounded; what a cap cuts is counted in truncated and budget_exceeded says the dependents may be incomplete. Every answered call is audited; a refused one is counted once per user, reason and hour.
Read-only and metadata only: no record, arch text, domain or group id. Only an administrator’s Vizum-signed
Access Manager (com.vizumapps.hide_menu) can call it; the package must be verified as published by Vizum,
the key alone is not enough. Hiding a node on screen is UI only: the server-side blocks are the operations above and the
field restrictions. Set min_platform 5.111.
Restrict single fields (SDK 5.112): a vizum.access.field row makes an Odoo field
read-only (restrict_write), required (restrict_empty) or not exportable
(restrict_export) for the members of a profile (supports("access-field-restrictions")).
It is judged on the member’s own request — write, create, web_save, an import,
a binary attachment into the field, the website form, the chatter post and edit, the composer, and a vApp call made
for the member — and never on Odoo’s consequences: with state read-only the member still confirms the
order. A vApp’s own scheduled job, trigger or webhook is not judged. Not exportable removes the field from every
level of the export field list and refuses it in any export path; it is still readable by read. Writers that
use sudo() with the member’s form values (the portal account form) cannot be judged and are a named residual. A row names the model and the field by name (model_id + field_name): the server resolves it. A read-only active also refuses the member’s direct archive buttons. An action that pre-fills a read-only field through default_<field> is refused for the members.
Set min_platform 5.112.
Not readable at all (SDK 5.113): restrict_read on a vizum.access.field row makes the field
unreadable for the members, enforced by Odoo’s own field groups (supports("access-field-no-read")):
read, search, order, grouping, export and write raise an AccessError (also in developer mode), the field
leaves fields_get and the views, and a vApp’s bridge read of it by a member is refused, never stripped silently. Because the
refusal is Odoo’s, every Odoo feature that reads the field as the member fails with it, so the server refuses the fields invoicing and
reports read (salesperson, payment terms, customer reference, …; they can still be hidden on screen), a field Odoo 20 tracks, a field
another part of a screen names in a condition that the platform cannot rewrite for the member (5.126), and an app’s own x_vzm_* column. Related copies of the field and third-party code that reads it as
the member are named residuals. Set min_platform 5.113.
Fields that go together (SDK 5.114): a field Odoo copies into another one
(product.template.standard_price from product.product.standard_price, image_512 from
image_1920) is restricted in the same profile as its copy or source, in one create([…]) of
vizum.access.field. The catalogue (supports("access-catalogue-requires-pair")) answers
requires_pair — a list of {model, field, via}, the closure of copies and sources — and
ok_with_pair_modes (restrict_read) for such a field instead of refusing it; create the row of the
field and one row per entry together, never one side alone. A pair with a member refused for another reason (a record rule,
a screen that names it, a field Odoo 20 tracks) stays refused, with that member’s code and blocked_by. Set
min_platform 5.114.
Stock valuation for a member who may not read the cost (SDK 5.115): Odoo’s valuation reads the product cost as the user of the
request, so a member of a profile that restricts standard_price could not validate a delivery or finish an MO on Odoo 18 and 19,
validate a landed cost on 19 and 20, or finish a standard-cost MO on 20. For that member only, the valuation computation now runs as the
superuser: the value is written and never returned, and the cost stays unreadable. The landed-cost line shows the member a price of
0 (they type it) instead of the cost. Anyone who is not a member, the administrator and cron jobs are unchanged
(supports("access-valuation-seams")). The fields the flows write the cost into (the valuation layers, the value of the
moves) are not part of the cost pair: restrict them with a row of their own; on Odoo 17 and 18 a landed cost needs the layers readable.
Set min_platform 5.115.
What the valuation seams never leave behind (SDK 5.117): after a seam elevates, the member’s own read of
standard_price later in the same request is judged by his own check again (Odoo 17 and 18 would have served it from the cache).
A seam does not elevate on a product the member may not read (a record rule, another company), and computing or validating a landed cost with
such a product is refused with an AccessError before anything is computed or written
(supports("access-valuation-seams-guarded")). Set min_platform 5.117.
The cost is one group (SDK 5.120): the product’s cost is shown by more fields than standard_price —
the sale line’s purchase_price, margin and margin_percent (price − margin = cost), the
product_margin figures, the stock valuation, and on Odoo 20 the project’s estimated cost. They list each
other in requires_pair with via: "cost_family"
(supports("access-cost-family")), and the server refuses restrict_read of one while another stays
readable (no_read_cost_family). Create them in one create([…]); treat any via you do not know
as “restrict together”. A screen that names one of them in a condition blocks the whole group (blocked_by). A landed cost the member computes
or validates runs as the member, not as the superuser: the read of this group is the only thing lifted, for that call and never for
the code of a vApp, so Odoo’s own access rights apply to everything else. The lift is read-only; Odoo modules an
administrator installed run inside it, like the sudo() any installed module makes. Set
min_platform 5.120.
Screens of a profile’s members (SDK 5.126): vizum.access.node rows hide a button, a tab, a search filter or
group-by or the chatter, switch off Create / Edit / Delete of a view and give a field read-only, required, invisible, options or a
cosmetic domain on the members’ screens (supports("access-view-nodes")), through one override of
ir.ui.view._postprocess_access_rights; it changes what the web client is told, never what the server accepts. The same hook
makes a screen that never names a field the member cannot read: in the member’s own copy a context name becomes False
(default_price: standard_price arrives empty), invisible, column_invisible and readonly take
1, required takes 0, a decoration-* takes False, and a domain leaf that names
the field becomes a constant leaf that selects no more than before. So restrict_read is refused as
no_read_dependents only for what the hook cannot rewrite (options, a group-by in a context, a view attribute that is a
field name, a kanban expression); the catalogue lists the rewritten ones in neutralized. With purchase installed the cost is
offered on Odoo 17, 18 and 19 (Odoo 20 still names its tracking). With the cost hidden, a member on Odoo 19 and 20 still creates, duplicates and varies a product
(stock_account reads the cost in its create; that call runs under the read-only cost lift) and opens the manufacturing overview with every cost amount at 0. Set min_platform 5.126.
An app’s column derived from the cost (SDK 5.122): a column an app adds to an Odoo model (x_vzm_*, with a
formula or a related) whose depends reach a member of the cost group, directly or through a related or
computed field in between, shows the cost like the sale line’s margin — the Price List Manager’s margin is
(list_price − standard_price) / list_price. It joins the group and is hidden with the cost: the platform finds it, gives
it the same refusal, and Odoo refuses the member on read, search, group and export while the bridge answers an AccessError as for any
other not-readable field, and the app’s other columns and rows keep working. The admin never restricts it: it is not in
requires_pair, the catalogue row of every member lists the columns in also_hidden
({model, field, string, app_key, app_name}, the owning app included), and the column’s own row says
follows_cost_family (supports("access-cost-family-apps")). The stored value is still computed for everyone,
so a restricted member’s write that triggers the recompute is not refused. What a formula reads counts as well as what it declares (depends: [] does not hide the cost); a method compute is hidden too, whatever depends it declares, on a model that carries the cost or is two relations away from it (reason says so in also_hidden). Residual: a method compute that reaches the cost through 3 or more relations and declares no such depends is not auto-joined; declare your depends — the store review checklist flags such computes. A native Odoo field that depends on the cost never joins this
way. Set min_platform 5.122.
Solo and declared roles: on a model whose native.models[].access declares
per-role access, the Solo access group keeps read and only the writes every Solo
audience group is declared to have, whatever ops says; each role writes through
its own declared rows. Declare in access exactly what each role writes.
Closed models: the platform's own models (vizum.*) take a grant only
from a short allowlist — vizum.app is read +
call:<api_*> only (use call:api_list and the other helpers,
never write); ir.actions.* is read-only (except
ir.actions.report); ir.cron accepts write on its
scheduling fields only (active, nextcall,
interval_number, interval_type, priority), never
create/unlink, and call:method_direct_trigger alone.
base.automation, ir.server.object.lines, ir.default
and ir.filters are read-only, and vizum.licence* /
vizum.store.* belong to the store's own back-office app.
The installer, the store and vzm check all refuse anything else.
Rows that are code: a create must also satisfy the grant's
domain. An app may write only its own ir.ui.view (key starting
vzm_<app id as a slug>.: every character of the id outside
a-z0-9 becomes _, so com.thelounge.lounge is
vzm_com_thelounge_lounge.) and its own ir.actions.report
(report_name under the same prefix; fields
name, model, report_type, report_name,
report_file, paperformat_id, binding_model_id,
binding_type, print_report_name only). The platform records
every view, report and mail template an app creates as that app's own, and only those can
be changed or deleted. The QWeb expressions an app writes are vetted on every write, as
they are at install (no .sudo(), .env or private attributes;
a static t-call may name only Odoo's report layouts or the app's own
templates). An inherit_id must be the app's own view or an Odoo report
template. A plain <style> (text only, no t-* attribute, no url(, @import, expression(, behavior, -moz-binding, javascript:, </ or backslash) is allowed in the app's own QWeb report templates (the view a report of the app names, or one it t-calls; create the report first), where a margin reset makes a label exact; anywhere else <style> is refused. An expression survives only if the same record already carried it (never one
with sudo, with_user or env), or if it is
byte-identical to one in a standard Odoo view or report the app READ through the bridge
in the last hour (call:get_combined_arch, or a read of
arch, arch_db, arch_base or
print_report_name): read the standard document, then write your copy. There
is no key to name the source; the platform records what you read, and which standard
record a copy came from. Odoo's own reports use o.sudo()..., so a copied
expression may too, but the copy is refused if it redefines, with
t-set or t-foreach/t-as, a name such an
expression uses (the error names the variable); a definition identical to the standard
one (<t t-set="o" t-value="o.with_context(lang=lang)"/>) is fine.
mail.template writes touch only the app's own templates, with the same
vetting; model_id may only name a model the app can read, and
attachment_ids, report_template_ids and
email_layout_xmlid are refused. A view, report or template is never created,
edited or linked through another record's one2many / many2many commands, and only
call:get_combined_arch (view), call:send_mail (template) and
call:report_action (report) run on them. ir.model.data,
website.page and website.controller.page are read-only. |
ui | Which desktop UI calls you may make: notify,
openRecord, openList, openView (one real
Odoo view, kanban, calendar, pivot, graph or a specific primary view),
setBadge, addPaletteCommand. (confirm is always
available — it only asks the user.) Adding an entry in an update widens the grant: the
update waits for an administrator's approval. |
events | Event names you may emit / listen to on the desktop bus. |
apps | App ids you may open with Vizum.apps.open() (intents). Zero-risk: it only opens apps the user already installed. |
net | External hosts Vizum.net.fetch() may reach:
exact hostnames ("api.frankfurter.app") or wildcards
("*.openweathermap.org"). Proxied server-side — redirects are not
followed, internal/IP hosts are blocked, responses cap at 2 MB. Since 5.74
"*" means any public website, download-only (GET/HEAD, never a
private address, never a $secret), and Vizum.net.fetchImage()
downloads one image with redirects re-checked — see
Network & images. A named host is reached on ports 80 and 443
only; for another port write "host:port" (5.74; an older
min_platform keeps the old behaviour and vzm check warns
NET_PORT_UNDECLARED). |
file_limits | Raises Vizum.files per app:
{"max_mb", "count", "total_mb"} (defaults 64 MB, 50 files, 128 MB;
up to 200 MB a file since 5.70, up to 512 MB since 5.71). Above
200 MB the platform derives a separate large_files grant the administrator
approves on its own, and needs an app storage cap. Privileged; raising any value asks again.
See Files. |
device_routes | Derived by the platform from
native.routes with auth: "device" (5.70): the pages and routes that
paired screens, kiosks and time clocks reach without an Odoo login. Shown on its own line of
the approval card; adding a route or changing its embeds, model or fields asks again. See
Paired devices. |
link_routes | Derived by the platform from
native.routes with auth: "link" (5.104): the pages and routes a
customer with no account reaches through a guest link the app issued (V.links).
Shown on its own line of the approval card ("Customer links"); adding a route or changing
its link block, model or fields asks again. See
Guest links. |
media | ["camera"] and/or ["microphone"] —
delegates the device to your sandboxed iframe so the standard
getUserMedia() works. The browser still prompts the user. |
secrets | Named API keys/tokens the app may use
(max 16, snake_case). Values live encrypted in Odoo, are typed by an
admin into a host dialog, can never be read back by the app, and are injected
server-side into net.fetch via $secret:name. |
providers | (5.138) Cloud providers server code may
sign in to through V.net.provider; the only value is "hvac_cloud"
(an HVAC cloud provider).
Privileged. The session stays on the server; the provider brings its own pinned hosts
(5.148): nothing goes in net for it, and it grants no
net.fetch to them. No install floor. |
geocode | (5.143) true: Vizum.map.geocode and
V.map.geocode turn an address into coordinates on the platform server, with the
provider the administrator chose (none by default). Privileged: the queries leave the instance.
Rate-limited per app, cached, every call audited with the query as sent. Needs
min_platform 5.143. |
oauth | OAuth2 providers (max 8) the app may connect
through Vizum.oauth. Providers are configured once per database
by an admin, who confirms each change in the desktop's own dialog (5.157); tokens are per-user, encrypted, refreshed automatically and
never visible to the app — requests are proxied with the token injected. |
sessions | Device/session controls the app may call via
Vizum.sessions: revoke · revokeUser (end sessions) and the
policy setters getPolicy · setLoginAlerts · setSessionTimeout · setActivityAudit ·
setActivityAuditGlobal. Admin-gated (base.group_system) with a
host confirmation; session revoke needs Odoo 18+ for res.device. |
audit | true to read the field-level change history
through Vizum.audit.activity(). Pairs with native.audit (which
models/fields to change-log) and the admin's master switch. Captures real old→new diffs;
opt-in, prunes after 180 days. |
assistant | true to use the AI Assistant bridge
(Vizum.assistant). A server-side agent runs a tool-loop as the real
user (no sudo): broad read (ACL-enforced) + confirm-gated writes, with per-user
chat history. Admin-approved, high-risk. |
methods | With logic: "script": the
native.methods your app installs on models, as "<model>.<name>"
("res.partner.write"). An override of an Odoo method calls the original with
super_(...). Admin-approved. One app owns each method of an Odoo model: the
install, an update and the approval refuse a second app that declares the same
model.name, naming both apps and the method, whether the first one is ready or still waits
for approval. The rule is per database, and your own models' methods never collide. To guard an Odoo
method another app may already override, use an enforcing trigger hook: every app's hooks run. |
The two install tiers
| Tier | Manifest asks for… | Who can install |
|---|---|---|
| ZERO-RISK | nothing beyond data: "user" |
Any desktop user, instantly. |
| PRIVILEGED | orm, net, file_limits, device_routes, link_routes or data: "global" |
Lands in “needs approval”: an administrator reviews the exact scopes. Updates that widen permissions re-enter approval — the old version keeps running meanwhile. |
fields is also marketing.Write actions ask the user
Every mutating ORM call (create, write,
unlink, call: methods other than the
grant's read_calls, 5.105) pops a
host-rendered confirmation the sandboxed app cannot fake or skip:
“AppName wants to create 1 account.move record — Allow / Deny”.
Creates, updates and method calls ask once per app + operation + model and are
then remembered; deletions ask every time. Users reset an app's remembered
grants from Vizum Apps (↺ on the card). Denied calls reject with
Error("UserDenied") — handle it gracefully.
An administrator's approval covers the declared actions (5.149)
With action consent (Vizum.supports("action-consent")), the administrator who
approves an app version also approves, for the companies they have selected, the
create, write, call:, chatter
notes, activities and named actions that version declares: users of those companies are not asked for them.
What the app may do does not change: the approved grant, Access Manager and the user's own Odoo rights decide that.
- Never covered (the user is asked, and their own 30-day «don't ask again» works as before):
every
unlink, everymail.post, a method on the fixed list (post, send, validate, confirm, done, cancel, approve, reverse, draft, reconcile, refund, pay, payment, scrap, inventory, delete, create_invoices… as a word of the name, or inside a word of four letters or more:action_postmail,invoice_repost), anything marked"sensitive", a grant on"*", uploads and offline writes. "sensitive":trueor a list of the grant's own ops on apermissions.ormgrant,trueon anactions[]entry; needsmin_platform5.149. An app built for it must mark every fixed-list action on a model it does not own:vzm checkand the installer refuse withSENSITIVE_NEEDS_PLATFORM,SENSITIVE_INVALID,SENSITIVE_REQUIRED.- Lifecycle: one approval per app, version, package and company. An update with the same server code and grants carries it (never wider); any other update drops it. Uninstall, disable or a trust change removes it. An administrator withdraws or grants it in Vizum Apps › the app › Manage, and a company keeps asking for everything with Settings › Vizum › App actions.
- The server re-checks every call the desktop ran without asking and refuses a stale one with
CONSENT_STALE; the desktop then asks the user and retries once. Each consented action is audited (consent:action, one line per action and hour with a count).
Full reference: sdk-next/docs/action-consent.md.
Your app's own groups
native.groups creates real Odoo groups for your app; access,
rules, views and team roles name one as "@name". An app declares
up to 24 (SDK 5.118; 8 before, so more than 8 needs min_platform 5.118), and
the labels of one app's groups are unique.
On Odoo 19 a heading in Settings › Users is one selection: an administrator picks one group
under it. Give each independent role a heading of its own with privilege
(SDK 5.118), and keep a chain of groups that imply each other under one:
"groups": [
{ "name": "technician", "label": "Technician", "privilege": "Access level" },
{ "name": "admin", "label": "Administrator", "privilege": "Access level", "implies": ["@technician"] },
{ "name": "role_sales", "label": "Sales", "privilege": "Sales role" },
{ "name": "role_support", "label": "Support", "privilege": "Support role" }
]
Each label becomes a heading named <app name>: <label>; groups without
privilege keep the heading named after the app. Odoo 17 and 18 have no headings of
this kind and already show groups that are not a chain as checkboxes, so the key changes nothing there.
Check for it with Vizum.supports("native-groups-24").
A rule on a user's groups
A record rule can ask whether a user still holds one of your app's roles (SDK 5.125). A Representative reads only the cases of a technician assigned to him who still holds the Distributor role:
"domain": [
["x_representative_id", "=", "uid"],
["x_technician_id.$groups", "in", {"$group": "@role_distributor"}]
]
$groups is the last hop of a path that ends on a user; {"$group": "@name"}
is one of the groups your app declares in native.groups, never an xmlid or an id; the
operator is in or not in. Groups that imply the role count. Who holds the
group is read at every search, so a role taken away (Vizum.team, the user form, Settings › Groups) is
seen by the next search, and a group that no longer exists makes the whole rule match nothing. It needs
min_platform 5.125; check for it with Vizum.supports("rule-domain-group").
Vizum.team: your app's users and roles
With permissions.team (privileged, approved by an administrator) and
native.team, the managers of your app create and edit the users who play the roles your
manifest declares, without an Odoo module. The app sends role names, never groups; the server gives
exactly the groups the approved roles name, never an administrative one.
"native": { "team": {
"managers": ["@admin"],
"roles": [
{ "name": "tech", "label": "Technician", "groups": ["base.group_user", "@tech"] },
{ "name": "sales", "label": "Sales", "groups": ["base.group_user", "@sales"] },
{ "name": "admin", "label": "Management", "groups": ["base.group_user", "@admin"], "grant": "admin" },
{ "name": "approver", "label": "Approver", "groups": ["base.group_user", "@approver"], "grant": "admin" }
] } }
| Call | What it does |
|---|---|
Vizum.team.roles() | The catalogue: [{name, label, help, grant}]. Anyone may read it. |
Vizum.team.whoami() | {uid, manager, role, roles, settings_admin} for the caller. |
Vizum.team.members({role?, has_role?}) | The users who play your roles. Each row has role (the primary role),
roles (every role, in manifest order) and role_labels. role filters on the primary role, has_role on any. |
Vizum.team.save({id?, name, login, email, phone, roles, password?}) | Creates or updates a member with exactly that
set of roles (1 to 16 declared names). The older role: "tech" still works and means roles: ["tech"]. |
setActive, setPassword, invite, remove | Only for members your team created or adopted;
remove takes away your groups only. |
Vizum.team.history({user_id?, limit?, offset?}) | The team's trail, newest first, managers only. |
The primary role. role is chosen as before 5.116: drop every held role another held role outranks
(A outranks B when B's own groups are in what A's own groups imply, and A has more); among the rest, the role declared
last wins. roles[0] is therefore not always role. Give every role one marker group of its own and let the
markers imply the shared capability groups: a member then plays exactly the roles whose markers they hold.
"grant": "admin" is given or taken only by a settings
administrator, and an account that holds one (its password, active flag, invitation and roles) is managed only by a settings
administrator. A manager may give your roles to an employee who also works elsewhere (Inventory, Sales), but for an account with any group outside
your app a manager changes only your roles: its password, login, email, name, phone, active flag and invitation are left to a settings administrator, and administrative
accounts are never touched. Mark grant: "admin" on every role that makes its
holder a manager of the team: vzm check warns when you do not, because any manager could then create peers.
The trail. Every change writes one row to your app's audit trail: platform:team.create, .update,
.roles, .activate, .deactivate, .password, .invite or .remove, with who did it,
the target, the roles before and after and whether a password changed, never the password itself. A refused change writes
platform:team.refused, kept even though the change was rolled back. Your app cannot write these actions. In
Vizum.audit.search a member who is not a manager sees only the team rows about themselves; your app's own rows stay visible as before.
Sending roles, declaring grant or calling history needs min_platform 5.116.
Concepts
Version compatibility — write once, run on every Odoo
A .vapp keeps working when the customer upgrades Odoo
(17 → 18 → 19 → 20 → …). You don't repackage it. Here's why, and the handful of rules that keep it true.
Why your app survives upgrades
- The SDK is shared, never bundled. Your package does not contain a copy of
vizum-sdk.js— it loads the platform's single shared copy. Upgrading the platform upgrades the SDK for every installed app at once; there are no stale copies. - The
window.Vizumcontract is version-stable. The transport (postMessage) and every namespace below are the same across Odoo versions. The platform absorbs the Odoo internals. - Renamed core fields are translated for you. Odoo occasionally renames a field
(e.g.
res.users.groups_idbecamegroup_idsin 19, and 20 keeps it). Theormproxy transparently maps the known renames in both directions, so an app written against either name keeps reading/writing/searching it on any version — the result comes back under the name you asked for.
Checked against the instance itself (feature odoo-introspect). Besides the atlas of
Odoo 17–20, the Vapp Creator reads the instance it runs on: every model, the fields the
user sees, the methods with the modules that define them (Community, Enterprise or custom) and
whether the bridge would run each one. It reads code, never data, and the code it hands over is
redacted of credential-looking literals on a best-effort basis. See
Odoo introspection.
What the app does to Odoo's views (feature view-inspect).
The instance answers, for the IDE apps, each native view a package touches without and with the
app, where each field lands, the other modules' views on the same nodes and the findings
(VIEW_*), simulated with the installer's own code; nothing is
written. See Odoo introspection and view inspection.
Three rules to stay future-proof
- Never read group fields off
res.usersdirectly. UseVizum.user/Vizum.roles(below) — they speak stable group XMLIDs, which never change between versions. - Stick to standard fields.
id, name, login, lang, tz, company_id, currency_id, create_date, write_date, display_nameare stable everywhere. - Branch on the version only if you truly must —
Vizum.session.odoo_versiongives the running major (17/18/19/20).
Vizum.user — version-safe identity & groups
No permission required (it only ever reveals the current user's own info).
await Vizum.connect();
Vizum.user.id; // 12 (sync, from the handshake)
Vizum.user.name; // "Jane Doe"
Vizum.user.isAdmin; // true if ERP-manager/admin
Vizum.user.isSystem; // true if in base.group_system
// is the user in an Odoo group? (stable XMLID — works on every version)
if (await Vizum.user.inGroup("sales_team.group_sale_manager")) showApproveButton();
const xmlids = await Vizum.user.groups(); // the user's OWN group XMLIDs
const full = await Vizum.user.info(); // {id,name,login,isAdmin,isSystem,company,lang,tz}
Vizum.scope — am I per-user or global?
Tells your app which storage scope it runs in, so you can label the UI honestly (“shared with everyone” vs “your data”). Derived from your approved manifest.
const s = await Vizum.scope();
// { data: "user" | "global", // your Vizum.data / key-value scope
// tables: { notes: {shared: true} }, // per Vizum.db table
// install_scope: "user" | "admin",
// default_access: "all" | "restricted" }
if (s.data === "global") banner("Notes are shared with your whole company.");
Declaring scope is done in the manifest, not at runtime:
permissions.data: "global" makes the KV store company-wide (privileged);
a tables[].shared: true table gives each row an owner + per-row sharing
(Vizum.db.share()), versus the default per-user isolation. See
Permissions & tiers and The manifest.
Vizum.roles — declarative app roles
Map your own role names to Odoo groups in the manifest; the platform evaluates them
as the real user (stable has_group(xmlid)) so the answer is trustworthy.
"roles": [{"name": "manager", "label": "Approver",
"groups": ["sales_team.group_sale_manager", "base.group_system"]}]
if (await Vizum.roles.has("manager")) enableApprovals();
await Vizum.roles.mine(); // ["manager", ...] the roles the user has
Concepts
Platform capabilities and min_platform GENERATED
Current platform capability: 5.156.
A vApp declares the capability it needs in its manifest, "min_platform": "x.y". The installer refuses the app on an older platform instead of installing it and letting the feature fail at runtime (often silently: an ignored key, a missing V.* name, a guard that never runs). Set it to the highest row whose feature your app uses; a higher value only locks out instances for nothing.
Versions compare numerically per part: 5.9 < 5.10 < 5.54.
Summary
| Capability | What it adds |
|---|---|
| 5.156 | AN ADMINISTRATOR CONFIRMS EVERY CHANGE TO A SHARED OAUTH PROVIDER CONFIGURATION (the number is PROVISIONAL, fixed at merge in merge order; 5.155 is the AR app layer + Vizum.motion, #4785). |
| 5.155 | AR APP LAYER + MOTION (the number is PROVISIONAL, fixed at merge; design: docs/superpowers/plans/ 2026-10-25-ar-app-layer-motion.md). |
| 5.154 | VIZUM AR ON THE /vizum/pwa PWA, VIZUM SOLO AND THE NATIVE MENU HOST (the number is PROVISIONAL, fixed at merge). |
| 5.153 | A ROLE REMOVAL REACHES AN APP INSIDE THE WRITING TRANSACTION (native.membership[].sync: "narrow"; the number is PROVISIONAL, fixed at merge in merge order; evolution renumbers). |
| 5.152 | A SCRIPT'S env.flush_all() WORKS. |
| 5.151 | VIZUM AR (Vizum.ar): a host-mediated camera view that reads barcodes and QR codes and draws, over each code, a text card the vApp described. |
| 5.150 | AN OFFLINE / UNREACHABLE UNIT IS A UNIT_OFFLINE RESULT, NOT A CLOUD_ERROR. The two cloud codes for "the unit is offline" and "the unit did not answer" resolve to UNIT_OFFLINE (a normal state, logged at INFO); every ot… |
| 5.149 | AN ADMINISTRATOR'S APPROVAL COVERS AN APP VERSION'S DECLARED ACTIONS (action consent, option D, Enrique 2026-10-22). |
| 5.148 | A DECLARED PROVIDER BRINGS ITS OWN PINNED HOSTS. For an app that declares permissions.providers, the platform reaches the provider's fixed host list itself and never reads the app's permissions.net for it, so no publi… |
| 5.147 | thermostat_query ALSO returns the live sensors of the cloud thermostat (current_F, humidity, a fault flag and sensors_at — the epoch of the last successful reading) over the provider's existing device channel, gated o… |
| 5.146 | A VIEW A VAPP WRITES THROUGH THE ir.ui.view BRIDGE GETS THE CHATTER MARKUP OF THE RUNNING ODOO (the number is PROVISIONAL, fixed at merge in merge order; 5.145 is #4492). 5.144 converts the form chatter at install (na… |
| 5.145 | CLOUD THERMOSTAT OPS. V.net.provider('hvac_cloud', 'thermostat_query'|'thermostat_control', args) read the status and set the setpoint/mode/power of a cloud thermostat: the op is transcoded to a device frame through a… |
| 5.144 | NATIVE.UI WRITES THE FORM CHATTER IN THE MARKUP OF THE RUNNING ODOO (the number is PROVISIONAL, fixed at merge in merge order; evolution renumbers). |
| 5.143 | SERVER-SIDE GEOCODING: Vizum.map.geocode(query) / V.map.geocode(query) (the number is PROVISIONAL, fixed at merge in merge order; 5.142 is Access Manager 9c; Enrique approved the design 2026-10-21 16:04). |
| 5.142 | ACCESS MANAGER 9c: BACKEND UI SWITCHES, SCREEN ONLY (PROVISIONAL, fixed at merge in merge order). |
| 5.141 | ACCESS MANAGER 9b: READ-ONLY EVERYWHERE (PROVISIONAL, fixed at merge in merge order). |
| 5.140 | ACCESS MANAGER 9a: a restriction profile's time window (PROVISIONAL, fixed at merge in merge order). |
| 5.139 | ANOTHER APP'S PRIVATE METHOD IS CALLABLE ONLY BY THE SAME VERIFIED PUBLISHER OR A NAMED APP (the number is PROVISIONAL, fixed at merge in merge order; issue #4387, Enrique 2026-10-20). |
| 5.138 | V.net.provider('hvac_cloud', op, args): a vApp's server side (native methods, hooks, jobs) signs in to an HVAC cloud provider through the platform's adapter. |
| 5.137 | native.adopt TAKES UP TO 120 ENTRIES (was 80) AND native.methods UP TO 1500 (was 1000) (the number is PROVISIONAL, fixed at merge). 5.132 raised native.models to 120 and left adopt at 80; a large ported vApp adopts 84… |
| 5.136 | UNINSTALLING THE WINDOW MANAGER OR THE PLATFORM IS SAFE (the number is PROVISIONAL, fixed at merge in merge order; Enrique 2026-10-19, «esto tiene que quedar resuelto y funcionando al 100% y probado»). |
| 5.135 | GUARDED ORM CALL: expect ON orm.call / callOn / V.orm.call (the number is PROVISIONAL, fixed at merge in merge order; Enrique approved the design 2026-10-18 12:35). |
| 5.134 | WEB BLUETOOTH IN A vAPP: permissions.devices accepts "bluetooth" (min_platform 5.134); when the APPROVED manifest lists it the host adds bluetooth * to the app frame's allow (same pattern as serial *; VappHost,… |
| 5.133 | ACCESS MANAGER 5d: the field mode hide is offered (access_field_policy.modes.hide.min_platform): a hidden field is also hidden on the platform's own read paths. |
| 5.132 | UP TO 120 OWN MODELS PER APP (was 80 since 5.63). |
| 5.131 | AN ADMINISTRATOR VIEWS AND SETS AN APP'S JOB USER; AN APP WITH A SERVICE USER RUNS AS IT ONLY (the number is PROVISIONAL, fixed at merge in merge order; requested for Access Manager D1, Enrique 2026-10-18, and by the… |
| 5.130 | ACCESS MANAGER 5c: a field a restriction profile hides (hide) or makes unreadable (restrict_read) is hidden from EVERY vApp the member runs, through the bridge's one hidden-field walk (_orm_hidden_scope / _orm_hid… |
| 5.129 | DIRECT ESP32 DEVICES (the number is PROVISIONAL, fixed at merge in merge order): a device that cannot show a PIN is claimed at the Vizum store's broker and enrols on the instance with a one-time token. |
| 5.128 | Vizum.schedule: AN APP'S TIMED REMINDERS FIRE AS NOTIFICATIONS WITH THE WINDOW CLOSED (the number is PROVISIONAL, fixed at merge in merge order; Enrique approved the design 2026-10-17 08:25). |
| 5.127 | MEMBERSHIP EVENTS: AN APP IS TOLD WHEN USERS GAIN OR LOSE ITS OWN GROUPS (the number is PROVISIONAL, fixed at merge in merge order). |
| 5.126 | NATIVE VIEW NODES, SCREEN ATTRIBUTES AND NEUTRALIZED SCREENS OF THE ACCESS MANAGER (the number is PROVISIONAL, fixed at merge in merge order). |
| 5.125 | A RECORD RULE COMPARES A USER'S GROUPS WITH ONE OF THE APP'S OWN GROUPS (the number is PROVISIONAL, fixed at merge in merge order). |
| 5.124 | A WRITE TRIGGER WATCHES THE FIELDS IT NAMES (the number is PROVISIONAL, fixed at merge in merge order). |
| 5.123 | native.views[].target: WHICH FORM AN ENTRY EXTENDS (the number is PROVISIONAL, fixed at merge in merge order). |
| 5.122 | AN APP'S COLUMN DERIVED FROM THE COST JOINS THE COST FAMILY (the number is PROVISIONAL, fixed at merge in merge order). |
| 5.121 | THE ORM GRANT CLOSES WHAT A SEARCH, A GROUP AND A SORT COULD STILL READ (the number is PROVISIONAL, fixed at merge in merge order). |
| 5.120 | THE COST FAMILY: ONE GROUP TO RESTRICT TOGETHER (the number is PROVISIONAL, fixed at merge in merge order). |
| 5.119 | ONE META CONSENT, TWO RELATED PROVIDERS. Vizum.oauth.connect("facebook", {shared:true, related:["instagram"]}) carries the related provider only inside the signed OAuth state and, after the Facebook Page expansion,… |
| 5.118 | UP TO 24 NATIVE GROUPS, AND A HEADING PER GROUP (the number is PROVISIONAL, fixed at merge in merge order). |
| 5.117 | THE VALUATION SEAMS CHECK THE PRODUCT AND FORGET THE COST (the number is PROVISIONAL, fixed at merge in merge order). |
| 5.116 | VIZUM.TEAM MULTI-ROLE WITH AN AUDIT TRAIL (the number is PROVISIONAL, fixed at merge in merge order). |
| 5.115 | STOCK VALUATION KEEPS WORKING FOR A MEMBER WHO MAY NOT READ THE COST (the number is PROVISIONAL, fixed at merge in merge order). |
| 5.114 | THE CATALOGUE OFFERS restrict_read WITH ITS PAIR (the number is PROVISIONAL, fixed at merge in merge order). |
| 5.113 | NATIVE FIELD no_read OF THE ACCESS MANAGER: NOT READABLE AT ALL (the number is PROVISIONAL, fixed at merge in merge order). |
| 5.112 | NATIVE FIELD RESTRICTIONS OF THE ACCESS MANAGER: READ-ONLY, REQUIRED, NOT EXPORTABLE (the number is PROVISIONAL, fixed at merge in merge order). |
| 5.111 | THE NATIVE VIEW-NODE CATALOGUE OF THE ACCESS MANAGER. vizum.app.api_access_view_nodes(model, view_types) through call: answers, for one Odoo model, the buttons (and whether a blocked operation can refuse them), note… |
| 5.110 | WHAT THE SERVER KNOWS ABOUT AN OFFLINE REPLAY (merge order: 5.110). |
| 5.109 | ONE IDEMPOTENCY KEY ACROSS AN ONLINE CALL AND ITS DECLARED ACTION (merge order: 5.109). An app with min_platform 5.109 that sends an idempotency key on an online call: gets it ledgered (exactly once), and the ledger… |
| 5.108 | BLOCKED OPERATIONS AND THE DEVELOPER-MODE GATE (the number is PROVISIONAL, fixed at merge in merge order; 5.107 native field audience). |
| 5.107 | WHAT AN APP ADDS TO ODOO'S MODELS AND VIEWS IS FOR THE APP'S PEOPLE (the number is PROVISIONAL, fixed at merge in merge order; 5.105 read calls, 5.106 restriction profiles). |
| 5.106 | RESTRICTION PROFILES AND MODEL RESTRICTIONS (the number is PROVISIONAL, fixed at merge in merge order). |
| 5.105 | READ CALLS RUN READ-ONLY, WITHOUT THE «App action» DIALOG (numbered in merge order; 5.104 is guest links). |
| 5.104 | GUEST LINKS: auth: "link" routes (numbered in merge order; 5.103 is advisory_lock). |
| 5.103 | A vAPP METHOD SERIALISES ON ITS OWN KEY (capabilities are numbered in merge order). |
| 5.102 | A DECLARED ACTION IS TOLD WHAT ITS METHOD RETURNED (the number is PROVISIONAL, fixed at merge in merge order; 5.101 is native.access). |
| 5.101 | native.access (the number is PROVISIONAL, fixed at merge in merge order): one of the app's own groups on an Odoo model the app does not own, what a module's ir.model.access row on a shared model does (psi_dsd: read on… |
| 5.100 | VIEW INSPECTION FOR THE IDE AND Vizum.ui.openView (C-IDE-2; the number is PROVISIONAL, fixed at merge in merge order). |
| 5.99 | ODOO INTROSPECTION FOR THE CREATOR IDE (C-IDE-1). An administrator's Vapp Creator or Module Converter asks the instance what its Odoo has, through call: on vizum.app: api_odoo_introspect(op, models, parts, opts) with… |
| 5.98 | A call: THAT OPENS A WIZARD IS TOLD SO (stock_sms's confirm.stock.sms; any method that returns an ir.actions.* dict). |
| 5.97 | POLICY RECONCILIATION OF INSTALLED APPS (hardening program B1, models/vizum_app_policy.py). |
| 5.96 | THE SERVER'S OWN METRICS FOR AN ADMINISTRATOR'S APP (Enrique 2026-10-08: «lo que un módulo admin puede leer, un vApp también»). |
| 5.95 | DETERMINISTIC OFFLINE VERSIONING (Master Program Batch 4, plan §8). |
| 5.94 | SEVERAL PAGES OF ONE APP ON A NATIVE FORM (contador365 puts 36 hr.employee fields on it, and one tab of 30 is not enough). |
| 5.93 | A NATIVE VIEW PLACEMENT THAT CANNOT BE MADE IS REPORTED, OR REFUSED (Enrique 2026-10-07: «sin estricto, un ancla que falta se salta en silencio»). |
| 5.92 | A SEARCH THAT ASKS FOR MORE THAN 5,000 RECORDS IS REFUSED, NOT CUT (Batch 22 phase 2 PR 3). Until now an explicit limit above 5,000 on search / search_read was cut to 5,000 without a word: the rows past it were just m… |
| 5.91 | A NATIVE FORM PAGE OF THE APP'S OWN (Enrique 2026-10-07: fields xpath-ed next to an anchor "don't always look right and break the clean view"). |
| 5.90 | HEAVY WORK GOES TO A QUEUED, CHUNKED BACKGROUND JOB, AND OVER-BUDGET EVENTS ARE TOLD (Batch 22 phase 2 PR 2, Enrique 2026-10-07). Vizum.bulk.export(model, {domain, fields, format, name}) writes the rows to one of the… |
| 5.89 | GOVERNED DATA FEEDS AND RESOURCE GOVERNANCE (Batch 22 phase 2, Enrique 2026-10-07). |
| 5.88 | RECEIPTS PRINT A LOGO, AND APPS CAN ASK FOR A TEST PRINT. A receipt document (5.87) gets an image line: {image: "data:image/png|jpeg;base64,...", align, width, dither, threshold}. |
| 5.87 | DEVICE PAGES PRINT RECEIPTS, AND print() IS SUPPORTED. A paired-device page whose route declares device: {"print": true} (a widening: approved with the route, shown on the approval card) gets Vizum.device.printRecei… |
| 5.86 | OFFLINE: A RECORD DELETED ON THE SERVER IS ITS OWN KIND OF ISSUE. A queued write (or action) on a record that no longer exists answers CONFLICT with details.deleted; the device lists it as reason: "deleted" (with… |
| 5.85 | A vApp CAN TAKE ITS WINDOW FULL SCREEN. Vizum.ui.fullscreen() / exitFullscreen() (grant "fullscreen" in permissions.ui; exitFullscreen is always allowed) on the desktop and the native Odoo host: the window is painted… |
| 5.84 | NATIVE STRINGS FROM THE CATALOGUE. The i18n/<lang>.json an app already ships (5.73, 5.77) now also translates what the app puts in Odoo's own backend: field labels and help, selection options, model labels, groups, me… |
| 5.83 | the shared vector scope of a user-scoped app. |
| 5.82 | two asks of a paired-device page. |
| 5.81 | two server-computed facts a guard could not learn, and two limits lifted. |
| 5.80 | the offline data layer. |
| 5.79 | Vizum.identity.confirm: step-up authentication. |
| 5.78 | group_ops. |
| 5.77 | V.tr. |
| 5.76 | Vizum.app.key. |
| 5.75 | V.sha256. |
| 5.74 | NETWORK REACH AND IMAGE FETCH. permissions.net may hold "*": any PUBLIC website (never a private, loopback, link-local, metadata or tunnel address; every hop of a redirect is checked again, the connection is pinned to… |
| 5.73 | SERVER-SIDE TRANSLATIONS. The i18n/<lang>.json files the browser SDK reads also answer tr() in the app's model methods: the install stores them on vizum.app.i18n_catalog (models/vapp_i18n.py), tr() reads the language… |
| 5.72 | RESUMABLE CHUNKED UPLOADS. A dropped connection no longer restarts a big Vizum.files.putBlob from zero: POST /vizum/apps/files/<key>/upload/status?token answers {received, next_part, size, sha256, expires_at} behind t… |
| 5.71 | LARGE APP FILES. Vizum.files.putBlob above 32 MB uploads in parts (begin / part / commit / abort: models/vizum_app_upload.py, the platform reserves the declared size against every quota and verifies size + sha256 befo… |
| 5.70 | PAIRED DEVICES AND LARGE FILES. A route may be auth: "device": a screen, kiosk or time clock with no Odoo login pairs with an 8-digit PIN the platform shows and an administrator claims on the desktop; it then holds… |
| 5.69 | UPDATES THAT RE-ASK, VERSIONED CONTRACTS, VERIFIED CALLERS, INSTANCE TELEMETRY. An update that changes server code (script hooks and library, model methods, routes, jobs, webhooks, fields with server logic, constraint… |
| 5.68 | A LARGE CUSTOMER MODULE AS A vAPP (its 1766 tests run on the vApp). |
| 5.67 | THE PLATFORM CONTRACT AND SDK RELIABILITY (SDK plan P0–P4). The platform contract reaches apps (Vizum.platform, Vizum.sdk, Vizum.features(), Vizum.supports()); every failed call rejects with a VizumError {code, catego… |
| 5.66 | A WHOLE ODOO MODULE AS A vAPP (psi_dsd → PSI DSD Core, its 818 tests green on the vApp). |
| 5.65 | Vizum.team COVERS WHAT A USER-PROVISIONING MODULE DID (united_insurance_team). |
| 5.64 | A vAPP CAN TAKE OVER THE DATA OF THE MODULE IT REPLACES. native.adopt: [{model, from, fields: {own field: module field}}] copies every row of an installed module's model into one of the app's own models, once (vizum.a… |
| 5.63 | THE LAST FIELD SHAPES A MODULE USES. translate: true on a char/text/html field (one value per language, as translate=True in a module); type "json" (fields.Json); a one2many from an own model to a SHARED model whose… |
| 5.62 | A SANDBOX FOR REAL LOGIC, WITH A CLOCK. Script hooks may define top-level functions (def, no decorators, no nesting — so no closures) and use while; the app may share code between its hooks in native.script.lib ({… |
| 5.61 | NATIVE DATA ACROSS VERSIONS. native.migrations: [{to, ops}] run by reconcile on the update that crosses to (vizum.app.native_schema_version remembers the version last reconciled; a first install migrates nothing): r… |
| 5.60 | Vizum.team: A MANAGER OF THE APP CREATES AND EDITS THE USERS WHO PLAY ITS ROLES. The manifest declares native.team {managers: [group refs], roles: [{name, label, help, groups}]} under the privileged grant permissions.… |
| 5.59 | A vAPP CAN DEFEND A FIELD OF ITS OWN MODELS (closes the 2026-09-01 field-ceiling concern). |
| 5.58 | WHAT A MODULE'S PYTHON GIVES ITS MODELS, DECLARED. On the app's own models: default (a literal of the field's type, or {"dynamic": today|now|user|company}) and sequence ({prefix, padding}: numbered on create from… |
| 5.57 | A vAPP CAN MODEL A DOCUMENT WITH LINES. On the app's own native models: a one2many names its inverse (a many2one declared on another of the app's own models, pointing back) and compiles to Odoo's relation_field —… |
| 5.56 | A HOOK CAN GUARD A DELETION. native.script.hooks[].trigger.on accepts "unlink" (with enforce): a base.automation on_unlink whose action calls the same dispatcher, run BEFORE the deletion — a hook that raises (V.… |
| 5.55 | PUBLIC ROUTE PAGES. A route may declare auth: "public" — a PAGE only (a public json route does not exist), with no groups — at /app/<slug>/<route>, open to anyone, signed in or not, of the company it is served in (t… |
| 5.54 | A TRIGGER KNOWS WHO WROTE, WHO OWNS, AND WHAT THE WRITE LEFT. The hook payload gains uid (env.uid of the write — 1 when server code writes as the superuser), owner (the row's create_uid) and new (post-image of t… |
| 5.53 | ROUTES A vAPP DECLARES (native.routes), portal and internal only. |
| 5.52 | Vizum.assistant.kbSearch(query, {catalogs, topK}): passages from the Knowledge (RAG) catalogs for a vApp holding permissions.assistant — the Assistant's own retrieval (hybrid, rerank, CRAG), run as the CALLING user (t… |
| 5.51 | AN EXPLICIT COMPANY ON A company_scoped NATIVE MODEL IS HONOURED, AND VETTED ON WRITE. A create used to replace an explicit x_company_id with env.company, and a write was not checked at all (Odoo re-checks record rule… |
| 5.50 | MENUS HIDDEN PER USER OR PER GROUP. vizum.menu.hide (menu, users, groups, hidden until, why, app_key) is subtracted from ir.ui.menu._visible_menu_ids — the result Odoo's menu service, the command palette and the Viz… |
| 5.49 | AN APP'S OWN NATIVE MODELS NEED NO ORM GRANT IN THE BROWSER EITHER. The server (_orm_rule) always granted an app its own native models, and the native fields it added to shared models, without permissions.orm; the… |
| 5.48 | MAP TILES THROUGH THE PLATFORM. OpenStreetMap answers a tile request with no Referer with its "Access blocked" PNG, and a vApp iframe (opaque origin) never sends one. |
| 5.47 | WORKFLOW CONTEXT KEYS. api_orm (and V.orm in hooks) accepts, besides lang, the two context keys Odoo's web client itself sends to finish a PARTIAL transfer: skip_backorder and picking_ids_not_to_backorder on stock.p… |
| 5.46 | RETIRING A COMPUTED FIELD FREEZES IT INSTEAD OF BREAKING RECOMPUTES. A vApp version that stops declaring a formula field used to archive it by rename (<name>__archived_<id>) with its compute still writing the old na… |
| 5.45 | THE PGVECTOR GUIDE CAN FINISH THE JOB. Before it, nothing a vApp could call set the RAG storage mode to pgvector: the "RAG pgvector Setup" guide sent the admin to Managed AI, and Managed AI answered "enable it from th… |
| 5.44 | RAG THAT OBEYS ODOO, AND A DOCUMENT REPLACED IN ONE PIECE. (1) vector.search / dump / indexed_docs and rag.list_chunks withhold every passage derived from an Odoo record the CALLER cannot read under record rules, or w… |
| 5.43 | COMPARE-AND-SET ON A NATIVE MODEL: orm.write(model, ids, vals, {expect}) in the JS SDK and V.orm.write(model, ids, vals, expect=…) in the script runtime. |
| 5.42 | vizum.migration.run.action_discover(): the migration plan and the source→target field map are built SERVER-SIDE from both live schemas (models/migration_engine/plan.py), and the run exposes discovery_json / `inclu… |
| 5.41 | db.share(table, id, {users: ["job_user", …]}): the literal "job_user" resolves server-side to the identity the app's jobs/hooks run as (job_user_id, default installer; never superuser/archived/portal). |
| 5.40 | V.vector.search(namespace, query, opts) / V.vector.namespaces() in server scripts (hooks and jobs): READ-ONLY, through the same api_vector the iframe uses, gated by permissions.vector, metered on its own per-r… |
| 5.39 | Vizum.db compare-and-set: db.update(table, id, values, {expect: {field: value}}) writes ONLY if the row still holds every expected value, under a row lock, and the result says _updated: true|false. |
| 5.38 | Vizum Instances pass: Vizum.instances.pass() -> {token, expiresAt} behind the new boolean grant permissions.instances_api (privileged; gaining it widens). |
| 5.37 | Report Builder 1.0 (native visual editing of Odoo reports, §3.3 of its spec): four small platform changes, all four lines. |
| 5.36 | native.bindings: a manifest may bind the app to the Action menu of a NATIVE model (native.bindings, one entry per {name, model, label, groups}, gated by the native_bindings grant). |
| 5.35 | assistant.send accepts the CALLING APP's model / reasoning effort / knowledge base ({model, effort, namespace}). |
| 5.34 | an AI call may carry ref, an opaque attribution tag naming which of the app's OWN internal consumers spent the tokens (Agent Center names the agent). |
| 5.33 | V.db — the app's own Vizum.db TABLES reached from a server-side script. |
| 5.32 | enforcing, delta-aware script triggers. |
| 5.29 | native.groups: the schema compiler creates real res.groups for a manifest's declared roles (stable xmlid), so access/rules can reference "@name" instead of every vApp with roles needing a hand-written Odoo addon just… |
| 5.28 | V.commit() in the JOB runtime + one savepoint/commit PER APP in the job cron. |
| 5.27 | Vizum.files.publicUrl — an unguessable but PUBLIC https link to one of the app's own files (permissions.files_public). |
| 5.26 | the SOCIAL OAuth rail: shared (brand) connections usable by every user AND by the unattended jobs (permissions.oauth_shared + V.oauth in the script runtime), many accounts per provider (account_ref), PKCE + provider c… |
| 5.25 | Vizum.ai.image — image GENERATION for vapps (managed mode only). |
| 5.24 | Vizum.devices.location — HOST-mediated one-shot GPS fix (devices:["location"]). |
| 5.21 | Vizum.ui.openUrl — the host opens an external http(s) URL in a new tab. |
| 5.20 | server-side vApp code: a manifest jobs[].script runs a native.script.hooks entry as job_user_id, with V.net / V.ai / V.data / V.json (and, from 5.33, V.db) and the time helpers (V.now_odoo, V.add_minutes, V.minu… |
| 5.5 | Vizum.audio (transcription — local Whisper default / managed metered-per-minute gateway). |
| 5.4 | Vizum.ocr (OCR — local Tesseract default / managed metered-per-page gateway) |
| 5.3 | AI tools — manifest ai_tools + Vizum.ai.tools.handle (client-handled) + Vizum.ui.openApp(key, payload) |
| 5.2 | Vizum.docs (raw-document store for RAG re-indexing) + Vizum.vector.pgv* (Mode-3 local-pgvector setup) |
| 5.1 | Vizum.domain (Visual Domain Builder) |
| 5.0 | Vizum.vector / Vizum.embed / Vizum.sessions |
Details
5.156
AN ADMINISTRATOR CONFIRMS EVERY CHANGE TO A SHARED OAUTH PROVIDER CONFIGURATION (the number is PROVISIONAL, fixed at merge in merge order; 5.155 is the AR app layer + Vizum.motion, #4785). oauth.configure changes one entry shared by every app and connected account of the database: the desktop host shows what the change does and stores it only once an administrator confirms it; the server refuses a new client_id or a removed scope while accounts hold tokens, and any move of a stored custom provider's endpoints (a rotated secret is allowed). Solo and native hosts reject configure. Feature oauth-configure-confirmed.
5.155
AR APP LAYER + MOTION (the number is PROVISIONAL, fixed at merge; design: docs/superpowers/plans/ 2026-10-25-ar-app-layer-motion.md). (1) Vizum.ar.start({layer: "app"}) puts the camera BEHIND the app: the iframe moves above the host's video and becomes transparent, and the app receives the ar:frame event, built by ONE whitelist function from numbers only (corners in video and CSS pixels, a pinhole projection, a field of view that is never null) at up to 30 Hz, with a window of 3 unacknowledged frames. No pixel ever reaches the app. The host chrome (the "using the camera" strip, Close, the border) stays above the app; losing it, a fullscreen, or 5 s of being covered stops the camera. capabilities().ar gains layer and layer_reason. (2) New closed permission permissions.sensors: ["motion"] (min_platform 5.155, a widening on update): Vizum.motion.start / stop / status relays the device's tilt and movement through the HOST (rounded, throttled, one stream per window); the iframe gets no sensor API and no allow= motion token. capabilities().motion reports why it is unusable. Features ar-layer and motion. See sdk-next/docs/ar.md and motion.md.
5.154
VIZUM AR ON THE /vizum/pwa PWA, VIZUM SOLO AND THE NATIVE MENU HOST (the number is PROVISIONAL, fixed at merge). Vizum.ar was served by the desktop only; now the Solo shell (solo_host.js + solo_instance.js: the same page is the browser PWA and what the Android APK loads) and the native menu host serve it too, through the one shared js/ar/ar_bridge.js: same start / stop / overlay / status, same events, same start failure reasons, the same vizum:ready.ar report {usable, camera, granted, reader, reader_kind}. The administrator's zxing-wasm switch is rendered into the runner page (absent = off) and the bundled reader is kept by the PWA's service worker, so a start also works offline after one online start. Feature ar now lists hosts desktop, native, solo. No new grant (media camera). See sdk-next/docs/ar.md.
5.153
A ROLE REMOVAL REACHES AN APP INSIDE THE WRITING TRANSACTION (native.membership[].sync: "narrow"; the number is PROVISIONAL, fixed at merge in merge order; evolution renumbers). An entry that opts in is told the users who LOST its groups from the res.users / res.groups write or unlink itself, as the app's job identity, in a savepoint with a 5 s budget: the app's audience and the role commit or roll back together, so the next search in the same request no longer shows the old audience. A handler that fails or times out aborts the write with an error naming the app; archiving or deleting a user never aborts: it records a row in vizum.app.membership.revoked, audits and notifies, and the bridge and the job runner refuse that person for that app until a handler later succeeds (rows never expire). A settings administrator can switch it off per app (reason required, audited, persistent fail-open banner). Widening stays asynchronous. Feature native-membership-sync. See sdk-next/docs/native-membership.md.
5.152
A SCRIPT'S env.flush_all() WORKS. Module code translated into a vApp calls it (a census reads its own rows with SQL right after writing them); the script env had no such method. It flushes what this transaction already changed, takes no argument, returns None and hands out no cursor. Feature script-env-flush-all.
5.151
VIZUM AR (Vizum.ar): a host-mediated camera view that reads barcodes and QR codes and draws, over each code, a text card the vApp described. Vizum.ar.start / stop / overlay / status and the events ar:codes, ar:tap and ar:state. Same grant as Vizum.vision (permissions.media ["camera"], no new approval; the approval card now says the camera also recognises codes). The video never leaves the host: the app receives code VALUES and sends a value -> card map; cards are plain text. The view never writes to Odoo. The reader is the browser's BarcodeDetector; where it is missing, a vendored zxing-wasm reader runs ONLY if the administrator switched it on (Settings > Vizum Apps, vizum_apps_platform.ar_wasm_engine, OFF by default, legal review pending; the server answers 404 for the engine files while it is off, and never serves bytes that do not match their pinned sha256). Feature ar (desktop). min_platform 5.151; vzm check AR_NEEDS_CAMERA and AR_MIN_PLATFORM. See sdk-next/docs/ar.md.
5.150
AN OFFLINE / UNREACHABLE UNIT IS A UNIT_OFFLINE RESULT, NOT A CLOUD_ERROR. The two cloud codes for "the unit is offline" and "the unit did not answer" resolve to UNIT_OFFLINE (a normal state, logged at INFO); every other cloud error keeps CLOUD_ERROR. Feature net-provider-thermostat-json-offline.
5.149
AN ADMINISTRATOR'S APPROVAL COVERS AN APP VERSION'S DECLARED ACTIONS (action consent, option D, Enrique 2026-10-22). Approving an app version records, per company, a consent covering the create / write / call: / mail.log / activity / named actions it declares (vizum.app.action.consent); the desktop stops asking users for those and flags the call, the server re-checks it (CONSENT_STALE) and audits it (consent:action). Never covered: unlink, mail.post, the fixed list of methods, actions marked "sensitive" (manifest key, needs this capability; SENSITIVE_REQUIRED for an app built for it), uploads and offline writes — the user's own 30-day memory works for them as before. A company can keep asking (res.company.vizum_ask_app_actions). Feature action-consent (desktop). See sdk-next/docs/action-consent.md.
5.148
A DECLARED PROVIDER BRINGS ITS OWN PINNED HOSTS. For an app that declares permissions.providers, the platform reaches the provider's fixed host list itself and never reads the app's permissions.net for it, so no public text or manifest names a vendor host; an app that still lists them keeps working, and declaring a provider grants no V.net.fetch to those hosts. An app that lists none sets min_platform to this capability (vzm check: PROVIDER_HOSTS_FLOOR). Feature net-provider-supplied-hosts; the adapter's constant carries the same number. See sdk-next/docs/net.md.
5.147
thermostat_query ALSO returns the live sensors of the cloud thermostat (current_F, humidity, a fault flag and sensors_at — the epoch of the last successful reading) over the provider's existing device channel, gated on this capability so a vApp tells 'the platform does not know the field' (absent) from 'the sensor read failed' (present, null). The adapter's sensor capability constant and contract feature net-provider-thermostat-json-sensors carry the same number. See sdk-next/docs/net.md.
5.146
A VIEW A VAPP WRITES THROUGH THE ir.ui.view BRIDGE GETS THE CHATTER MARKUP OF THE RUNNING ODOO (the number is PROVISIONAL, fixed at merge in merge order; 5.145 is #4492). 5.144 converts the form chatter at install (native.ui); a view the vApp creates or writes at runtime through the ORM bridge now goes through the same conversion, in the same order as install (chatter, then QWeb directives). A write that names no model or type takes them from the stored view or its parent; an xpath the platform cannot convert is refused as the bridge's normal error (AccessError), and one write to several views that need different markup is refused. Feature ui-chatter-per-version-runtime; ui-chatter-per-version keeps since 5.144. See vizum_app_orm_guard.py _orm_model_guard and sdk-next/docs/server-side.md.
5.145
CLOUD THERMOSTAT OPS. V.net.provider('hvac_cloud', 'thermostat_query'|'thermostat_control', args) read the status and set the setpoint/mode/power of a cloud thermostat: the op is transcoded to a device frame through a SECOND provider session and delivered by the provider's existing device channel. The ops fail closed (UNSUPPORTED_OP) until this capability is assigned; the adapter's capability constant and contract feature net-provider-thermostat-json carry the same number. See sdk-next/docs/net.md.
5.144
NATIVE.UI WRITES THE FORM CHATTER IN THE MARKUP OF THE RUNNING ODOO (the number is PROVISIONAL, fixed at merge in merge order; evolution renumbers). Odoo 17 compiles only <div class="oe_chatter"> and Odoo 18/19/20 only <chatter/> (mail form_compiler.js, selector "div.oe_chatter" vs "chatter"); the other form shows as raw field lists. A view of native.ui written for either goes in with the markup of this Odoo, as <list>/<tree> already does: <div class="oe_chatter"> becomes <chatter/> on 18+, <chatter/> becomes the div with the chatter fields the view's model has (mail.thread: followers and messages; mail.activity.mixin: activities) on 17; the options of those fields and the attributes of <chatter> (open_attachments, reload_on_post/follower/attachment) are carried across, and an inherited view's xpath into the chatter (//chatter / //div[hasclass('oe_chatter')]) follows. A vApp with a chatter runs on 17 to 20 with no min_odoo. No manifest key changes; an older platform leaves the arch as written. Feature ui-chatter-per-version. See models/vizum_app_records.py adapt_chatter_arch and sdk-next/docs/server-side.md.
5.143
SERVER-SIDE GEOCODING: Vizum.map.geocode(query) / V.map.geocode(query) (the number is PROVISIONAL, fixed at merge in merge order; 5.142 is Access Manager 9c; Enrique approved the design 2026-10-21 16:04). No vApp sends a customer address to a third party from the browser: the PLATFORM server resolves the query with the provider the administrator chose in Settings › Vizum › Geocoding — none (the default: the call fails with GEOCODE_NOT_CONFIGURED), a Nominatim base URL (self-hosted or compatible), or a key + URL template for a commercial service (the key stored encrypted, secret_box namespace vizum.geocode). The provider is reached by the pinned transport under the public-only policy (net_allow_private opens private addresses, never cloud metadata). permissions.geocode (a privileged boolean, min_platform 5.143); per-app rate limit (60 a minute and 2,000 a day by default, settable); a cache shared by the instance (provider + normalised query + options, 30 days by default) keyed by a hash of the query (the cached results hold the provider's formatted address); every answer audited in vizum.geocode.audit (app, user, time, provider, the query as SENT, whether it left the instance), kept 180 days. Query 2-200 characters; at most 10 results of scalars {lat, lon, display_name, category, type, importance}. V.map is refused in a declared read call. Feature geocode. See models/vizum_geocode.py, models/geocode_providers.py and sdk-next/docs/geocode.md.
5.142
ACCESS MANAGER 9c: BACKEND UI SWITCHES, SCREEN ONLY (PROVISIONAL, fixed at merge in merge order). vizum.access.profile gains ten ui_* switches (search panel, custom filter, custom group by, saving a favourite, chatter send / log / activity, «Insert in spreadsheet», «Add a property», Archive / Unarchive). session_info carries the member's switches as vzm_access_ui; the web client hides those entries. Each is screen only: Access Manager names the server guard to pair it with, where one exists. Never for an administrator. An open tab keeps the switches it loaded until it is reloaded. Feature access-profile-ui-policy. See static/src/js/access_policy/ui_policy.js and sdk-next/docs/server-side.md (9c).
5.141
ACCESS MANAGER 9b: READ-ONLY EVERYWHERE (PROVISIONAL, fixed at merge in merge order). vizum.access.profile gains readonly_user: its members read what Odoo lets them read and write only their own preferences, Discuss (the channels they are in) and «mark done» on their own activities (D3, measured on 17-20). Two layers: the request's dispatched method must be a read method or a measured D3 entry point (closes a button that writes under sudo()), and Odoo's own access check per line refuses any other write; a vApp the member runs reads Odoo records and writes none. Administrators, the superuser, OdooBot, job users and service users are never members. Feature access-readonly-user. See models/vizum_access_readonly.py and sdk-next/docs/server-side.md (9b).
5.140
ACCESS MANAGER 9a: a restriction profile's time window (PROVISIONAL, fixed at merge in merge order). vizum.access.profile gains starts_at / expires_at (UTC; a missing bound is open) and the platform-owned in_window: outside its window a profile has no members, so every restriction it holds lifts at once and comes back together. A job runs every minute (data/access_window_cron.xml) and re-syncs each profile whose window opened or closed; writing a window takes effect at once; each boundary is logged (window_opened / window_closed). A window that ends while the job is down keeps the restriction (fail closed); one that starts late is said by the app's health check access_windows when the job is more than 2 minutes behind. Feature access-profile-window. See sdk-next/docs/server-side.md (Time windows, 9a).
5.139
ANOTHER APP'S PRIVATE METHOD IS CALLABLE ONLY BY THE SAME VERIFIED PUBLISHER OR A NAMED APP (the number is PROVISIONAL, fixed at merge in merge order; issue #4387, Enrique 2026-10-20). A call:<m> grant used to reach any native method of any other app the person uses, public or not (an approved com.example app drove Remote Metering's rm_rule_save). Now a non-public method of app X is callable by app Y only when (1) both are VERIFIED as published by the same publisher key (publisher_signed_key of a package whose recorded trust still holds; the Store-named key for a store-verified one; never an id prefix; unsigned, sideloaded and unverified packages have none), or (2) X lists Y in native.methods[].callable_by (1-16 app ids, surfaced as permissions.callable_methods, widening re-approves), or (3) the method is public: true. Refused otherwise with an AccessError naming both apps, at call time on every road (bridge, callOn, V.orm.call, the action catalogue, a record method in a script) and at install, update, approval and preview when the owner is installed. Odoo's own methods, overrides and the app's own are unaffected. A vApp that declares callable_by sets min_platform 5.139. Feature native-method-callable-by. See models/vizum_app_methods.py (private_owner), tools/audit_cross_app_private_calls.py and sdk-next/docs/grants.md.
5.138
V.net.provider('hvac_cloud', op, args): a vApp's server side (native methods, hooks, jobs) signs in to an HVAC cloud provider through the platform's adapter. Server-side only; the ops are fixed: login / list_appliances / send_frame / logout. Credentials are read by declared secret name; the session is kept encrypted in vizum.app.provider.session and is never readable by scripts, the iframe, the secrets listing, exports or logs; logout, uninstall and a credential change drop it. Until the supplied-hosts capability the provider's host is named in permissions.net; every HTTP request is metered as one network call; a refused session gets one re-login and one retry. permissions.providers ["hvac_cloud"] carries an approval line and no install floor: apps feature-detect with has_attr(V.net, "provider"). vzm check rules PROVIDER_NOT_DECLARED / UNKNOWN_PROVIDER / MIN_PLATFORM_TOO_LOW -> PROVIDER_FEATURE_DETECTED. Feature net-provider. See sdk-next/docs/net.md.
5.137
native.adopt TAKES UP TO 120 ENTRIES (was 80) AND native.methods UP TO 1500 (was 1000) (the number is PROVISIONAL, fixed at merge). 5.132 raised native.models to 120 and left adopt at 80; a large ported vApp adopts 84 and has 1032 methods. One constant per list in vapp_validation.py (NATIVE_MODELS_MAX also caps adopt; METHODS_MAX), mirrored in vzm check (native.mjs): more than 80 adopt entries or more than 1000 methods sets min_platform 5.137. Feature native-adopt-120 and native-methods-1500. See docs/superpowers/progress/HANDOVER-native-adopt-cap-5137.md.
5.136
UNINSTALLING THE WINDOW MANAGER OR THE PLATFORM IS SAFE (the number is PROVISIONAL, fixed at merge in merge order; Enrique 2026-10-19, «esto tiene que quedar resuelto y funcionando al 100% y probado»). uninstall_hook raises a per-registry flag first, so every registry override answers super() while Odoo removes the module's data (no platform column dropped while its table survives, no model left behind); then it DETACHES the vApps and keeps their data: the automations and server actions that call the platform go dormant, method computes become plain columns, groups named in inherited views are rewritten, native menus are removed, access rows of platform groups go to base.group_system and record rules bound only to them are deactivated; what each app was is written to a signed DETACH LEDGER the module does not own. With vApps installed an interactive uninstall opens a wizard (uninstall them first / detach and keep the data / cancel). A reinstall re-adopts the ledger's apps as needs_approval through the same trust path their install used (a signed app is installed again from the Store). Feature safe-platform-uninstall. See models/platform_leaving.py, platform_detach.py, platform_readopt.py, platform_uninstall_guard.py and sdk-next/docs/platform-uninstall.md.
5.135
GUARDED ORM CALL: expect ON orm.call / callOn / V.orm.call (the number is PROVISIONAL, fixed at merge in merge order; Enrique approved the design 2026-10-18 12:35). expect: {field: value} plus ids makes the call run its native method only while the records still hold those values: in one transaction the platform reads the records as the user, locks the readable rows (SELECT ... FOR UPDATE, ids sorted, 5 s), re-reads the fields as the user and, if every record matches, runs the method holding the locks. Otherwise nothing runs, for any id: STATE_CHANGED (current values of the expected fields only; null for a record missing or unreadable) or BUSY (retryable, the lock wait ran out). Up to 100 ids and 10 stored fields of a comparable type, inside the grant's fields and not hidden from the user. Same grant, ACLs, allowlist and write ceiling as a plain call. It does not make the method idempotent, and a method that commits ends the locks. Feature orm-expect. See models/orm_expect.py and sdk-next/docs/guarded-call.md.
5.134
WEB BLUETOOTH IN A vAPP: permissions.devices accepts "bluetooth" (min_platform 5.134); when the APPROVED manifest lists it the host adds bluetooth * to the app frame's allow (same pattern as serial *; VappHost, Solo's computeAllow), so the standard navigator.bluetooth.requestDevice() works — the browser still shows its own chooser and needs a click. Adding it on an update is a widening (re-approval). No server op. Vizum.supports("devices.bluetooth") is true when the frame is delegated AND the browser has Web Bluetooth (the Android WebView has none).
5.133
ACCESS MANAGER 5d: the field mode hide is offered (access_field_policy.modes.hide.min_platform): a hidden field is also hidden on the platform's own read paths. native.methods / the script library raise an AccessError naming the field when a method reads it by attribute (read / search_read leave it out; a domain, dotted path, groupby or aggregate on it is refused; the REAL user decides, also inside the app's sudo()); the AI Assistant leaves it out and refuses a search or grouping on it; a dashboard shared by its owner hides a field hidden from the owner OR the viewer; the knowledge index never indexes a field any active profile hides or makes unreadable on that model, and reindexes when that set changes. vzm check warns METHOD_READS_FOREIGN_FIELD. Feature access-field-hide. See sdk-next/docs/server-side.md (5d).
5.132
UP TO 120 OWN MODELS PER APP (was 80 since 5.63). A large ported vApp has 81; RULE V: a vApp does what a module does. One constant, vapp_validation.NATIVE_MODELS_MAX (mirrored by NATIVE_MODELS_MAX in sdk-next/packages/vapp-cli/ lib/native.mjs). A vApp with more than 80 models sets min_platform 5.132 (vzm check says so). Feature native-models-120. See docs/superpowers/progress/HANDOVER-native-models-cap-5132.md.
5.131
AN ADMINISTRATOR VIEWS AND SETS AN APP'S JOB USER; AN APP WITH A SERVICE USER RUNS AS IT ONLY (the number is PROVISIONAL, fixed at merge in merge order; requested for Access Manager D1, Enrique 2026-10-18, and by the Assets audit). vizum.app.api_get_app_job_user(key, search) and api_set_app_job_user(key, choice) (administrator only, checked on the server; choice is a user id, "service" or "default"), and Settings > App Permissions > Job user. Refused with the reason: the superuser, archived and portal users, another app's service user, a user outside the app's company, an administrator when no model of the app is admin-only, a user lacking what the app declares it needs (read from native.models[].access; named, never granted). An app that declares a service user (native. service_user, or a field only its server side writes) runs as it and as nobody else: the setter and a direct write of job_user_id refuse any other user; a row that names a person is shown as degraded with a one-click fix. Audited as platform:job_user; every write of the field clears the native runtime cache. The trigger payload gains rec["svc"]: True when env.uid of the write is the app's own service user (read it as rec.get("svc") unless the app declares min_platform 5.131). Features job-user-admin and trigger-svc. See models/vizum_app_job_user.py and sdk-next/docs/server-side.md.
5.130
ACCESS MANAGER 5c: a field a restriction profile hides (hide) or makes unreadable (restrict_read) is hidden from EVERY vApp the member runs, through the bridge's one hidden-field walk (_orm_hidden_scope / _orm_hidden_of, now fed by the profiles too). A hidden field named in a read is left out with no error (D-a, Enrique 2026-10-14; restrict_read keeps Odoo's refusal); a domain, order, groupby, aggregate, having, read_progress_bar or watch hash on it is refused; fields_get / default_get / copy_data / onchange drop it; Vizum.meta.fields omits it and the new Vizum.meta.fieldAccess(model) says which fields are restricted; a feed serves its column as null; the ir.attachment rows holding a hidden binary field's bytes and the mail.tracking.value rows of a hidden field are kept out. Never hidden from apps: access_field_policy.bridge_exempt_fields. Feature access-bridge-field-visibility. See docs/superpowers/plans/2026-10-14-access-manager-bridge-field-visibility.md and sdk-next/docs/server-side.md.
5.129
DIRECT ESP32 DEVICES (the number is PROVISIONAL, fixed at merge in merge order): a device that cannot show a PIN is claimed at the Vizum store's broker and enrols on the instance with a one-time token. (1) Vizum.deviceBroker / V.device_broker (permissions.device_broker, privileged, administrators only, each mutating call confirmed by the person on the host): claim, rearm, unlink, list, rename, link — the instance uuid and token never leave the server, the enrolment token is kept only as its SHA-256; (2) POST /vizum/device/<slug>/_enrol (a route's enrol: {hook}, approved with the route): the device burns its token with one conditional UPDATE and receives a paired-device token minted as PIN pairing mints it; ONE uniform 401 for every refusal; (3) an app with auth "device" json routes and no page serves them (_pair/*, the device page, its worker, manifest and icon stay page-only). A broker device IS a vizum.app.device row of the app (same table, same revocation). An hourly cron cuts the credential of every device the broker reports revoked and delivers the queued confirm / unlink calls (an outbox, so an uninstalled app's unlink still goes out). The same device id is live on ONE app at a time. Features device-broker, device-enrol. See models/vizum_app_device_broker.py and sdk-next/docs/device-broker.md.
5.128
Vizum.schedule: AN APP'S TIMED REMINDERS FIRE AS NOTIFICATIONS WITH THE WINDOW CLOSED (the number is PROVISIONAL, fixed at merge in merge order; Enrique approved the design 2026-10-17 08:25). A schedule lives on the SERVER, per app and user (vizum.app.schedule): at (one ISO instant with its offset) or repeat (an RRULE subset, FREQ=DAILY|WEEKLY + BYDAY + BYHOUR + BYMINUTE, or {days, time}) with an IANA tz; the next fire is computed in UTC with zoneinfo, so a local time survives a DST change. A cron (every minute) turns each due row into a vizum.app.notification (inbox, click action {app, payload}, bus ping), then moves a repeat on or deactivates a one-shot. missed() returns the last 24 h of fired occurrences the app has not acknowledged (cap 20; ack acknowledges). Limits: 50 per app and user, payload 4 KB, set 120 per hour. Always the CURRENT user's. permissions.schedule (a boolean, not privileged) and min_platform 5.128. The administrator lists and clears rows in the backend; the body and payload are not readable through the ORM. Delivery: a Vizum Desktop tab open rings (toast, sound, browser notification when granted) also with the app's window closed; the browser fully closed rings nothing (Web Push and the Android APK are separate, later work). Feature schedule. See models/vizum_app_schedule.py and sdk-next/docs/schedule.md.
5.127
MEMBERSHIP EVENTS: AN APP IS TOLD WHEN USERS GAIN OR LOSE ITS OWN GROUPS (the number is PROVISIONAL, fixed at merge in merge order). native.membership: [{groups: ["@name"], method: "<own model>.<name>"}] with permissions.membership: true: the platform's own res.users / res.groups overrides (create, write, unlink; group commands, in_group_/sel_groups_/role, users / user_ids, implied_ids, archive, Vizum.team) compare the EFFECTIVE members (active, direct or implied) of the subscribed groups a write reaches (a mark in its transaction) with a ledger of what the app was last told, under the membership lock in READ COMMITTED after the commit, write ONE event per subscription (its method) and transaction (a gain and a loss cancel; the losses the writing transaction saw are reported too, so a loss then a re-grant elsewhere is lost then gained), and deliver it to the method, as the app's job identity, in queue order: {event_id, kind: delta, groups: {"@ref": {gained, lost}}, reason, actor_uid, attempt}, strictly per entry (a failed event holds the later ones). Only the app's own groups: an xmlid is refused; each "@group>method" pair is approved (permissions.membership_subs), so an update that adds one waits for the administrator. A deleted subscribed group reports its members lost (group_deleted). A failure never blocks the write: the row stays pending, retried with backoff, audited (platform:membership.refused), the administrators notified after 3 attempts. A reconcile ({kind: reconcile, members}) goes out at install/update and every hour. V.is_member("@ref", uid=None) answers membership from the current transaction. vizum.app._membership_flush() is the test helper (a commit's outbox and delivery, synchronously). A manifest with native.membership or code naming V.is_member declares min_platform 5.127. Feature native-membership. See models/vizum_app_membership.py and sdk-next/docs/native-membership.md.
5.126
NATIVE VIEW NODES, SCREEN ATTRIBUTES AND NEUTRALIZED SCREENS OF THE ACCESS MANAGER (the number is PROVISIONAL, fixed at merge in merge order). vizum.access.node rows hide a button, a notebook page, a search filter or group-by or the chatter, switch off Create / Edit / Delete / Duplicate / Import / Export of a view, or give one field read-only, required, invisible (true or a vetted condition), options and a cosmetic domain, for the members of a restriction profile; the read-only and required modes of the field rows show on screen too, and Create / Edit / Delete go where a model restriction refuses the operation for the member with no domain and no company scope. ONE mechanism: an override of ir.ui.view._postprocess_access_rights, which Odoo runs per user AFTER the view cache on a fresh parse of the cached arch (no platform views, no xpaths; one user's screen never reaches another's), cached per (uid, group set). Administrators and the superuser are never touched. SCREEN ONLY: the server enforcement stays with the operation guard, the field guard and no_read. The field mode hide is stored and applied but refused until the vApp bridge enforces it. NEUTRALIZATION: through the same hook, a member who cannot read a field (restrict_read) never receives a screen that names it. In the member's own copy of the arch a context name becomes False (default_price: standard_price arrives empty), a modifier that names it takes a fixed value (invisible and column_invisible 1, readonly 1, required 0, a decoration False), a domain leaf that names it becomes a constant leaf that selects no more than the leaf did, and the helper field node Odoo adds for the expression is removed. So restrict_read is no longer refused as no_read_dependents for those attributes (the cost on Odoo 19 and 20 with purchase installed), and the catalogue lists what the hook rewrites in neutralized and what still refuses in dependents (options, a group-by in a context, a view attribute that is a field name, a kanban expression). A computed many2one to a currency is no copy of what it depends on (stock_account's company_currency_id), so the cost group is offered with stock_account installed. Feature access-view-nodes. See models/vizum_access_node.py and models/access_neutralize.py. With the cost hidden, on Odoo 19 and 20 a member also creates, duplicates and varies a product (stock_account's create reads the cost: the create runs under the read-only cost lift of access_cost_lift) and opens the manufacturing overview (cost amounts at 0); the cost is offered on 17, 18 and 19 (20: tracked, E1).
5.125
A RECORD RULE COMPARES A USER'S GROUPS WITH ONE OF THE APP'S OWN GROUPS (the number is PROVISIONAL, fixed at merge in merge order). The leaf ["<path>.$groups", "in" | "not in", {"$group": "@name"}] reads «the user at the end of <path> holds this native group of the app»: e.g. a Representative reads only the cases of a technician who still holds the Distributor role. A vApp cannot override res.users / res.groups, so a domain is the only way a role taken away is taken away on every path. $groups is the LAST hop of a path that ends on res.users and compiles to all_group_ids (19/20) or groups_id (17/18, where Odoo writes the implied groups into the user's rows); {"$group": "@name"} compiles to vzm_group('<platform xmlid>'), which ir.rule's evaluation context resolves each time it computes the rule (models/ir_rule_group.py), and the whole domain falls to [(0, '=', 1)] when the group no longer exists. Membership is read in SQL at every search: a revocation is seen by the next search in every worker. Only in / not in; an xmlid, an id or the token anywhere else is refused at validation. Needs min_platform 5.125. Feature rule-domain-group. See models/rule_domain_group.py.
5.124
A WRITE TRIGGER WATCHES THE FIELDS IT NAMES (the number is PROVISIONAL, fixed at merge in merge order). native.script.hooks[].trigger.fields: ["state", ...] becomes the trigger fields of the app's base.automation: Odoo runs the hook only for a write that CHANGES the value of one of them, instead of for every write of the model. Odoo writes a stock move 13-21 times per operation; Location Restrictions' guards ran 28 times per move in a Validate for a change of state (20 CE, 2026-10-15). Absent = every write, as before; only with on write or create_or_write (a create still runs a create_or_write hook; a create or unlink trigger refuses fields); an unknown field refuses the install; an update applies the new list to the existing automation. Feature trigger-fields. See vapp_validation._valid_trigger_fields and vizum_app_schema._trigger_fields.
5.123
native.views[].target: WHICH FORM AN ENTRY EXTENDS (the number is PROVISIONAL, fixed at merge in merge order). A native.views entry went on Odoo's default view of its model and type (default_view: the primary view of lowest priority), which is not always the form people open: res.users resolves to base.view_users_simple_form (the new-user dialog), not Settings > Users (base.view_users_form). A form entry may now say "target": "main": the form the model's main window action opens (the first act_window a menu opens, else the first one; vizum_app_schema._main_form_id), when it is a primary form of the model. When no main form resolves the entry is skipped as no_base_view, which a strict entry refuses. "default" (or no key) is unchanged. The preview's native_views_warnings and the inspector's VIEW_NOT_MAIN_FORM stay silent for an entry with target "main". Needs min_platform 5.123 for "main". Feature native-view-target. See vizum_app_schema._native_view_base_id and vapp_validation._valid_native_view_target.
5.122
AN APP'S COLUMN DERIVED FROM THE COST JOINS THE COST FAMILY (the number is PROVISIONAL, fixed at merge in merge order). A column an app adds to an Odoo model (x_vzm_*, a native field with a formula or a related; a field of an app's own model too) whose depends reach a member of the cost family, directly or through a related or computed field in between (an Odoo field or another app's column), shows the cost like the sale line's margin: the Price List Manager's x_vzm_pricelists_margin is (list_price - standard_price) / list_price. Before this, the validator refused to restrict the cost while such a column existed (no_read_computed) and refused to restrict the column (app_column), so a customer with the app could not hide the cost. Now the column is hidden WITH the cost: the platform finds it in the registry (models/access_cost_columns.py, depends followed transitively; only an app's own manual columns, never a native field: those stay in the explicit table) and gives it, in the same pass that gives the members their groups, the negation of every profile that restricts a member it derives from, so Odoo refuses the member on read, search, group, export and the bridge answers an AccessError, fields_get and the views leave it out, and the app's other columns, rows and calls keep working. The admin never restricts it: it is not in requires_pair, the validator no longer counts it as a copy of the cost, and the catalogue row of every member lists the columns in also_hidden ({model, field, string, app_key, app_name}) while the column's own row says follows_cost_family. The stored value is still computed for everyone (computed as the superuser, measured on 17-20 with a purchase receipt that averages the cost and a sale line): no seam beyond the valuation ones. Feature access-cost-family-apps. See models/access_cost_columns.py, vizum_app_aliases.py (_vzm_apply_field_groups) and vizum_access_catalogue.py (_vzm_cost_joined_rows).
5.121
THE ORM GRANT CLOSES WHAT A SEARCH, A GROUP AND A SORT COULD STILL READ (the number is PROVISIONAL, fixed at merge in merge order). Three keys on a permissions.orm grant: ops may be EMPTY when group_ops carries every op (a placeholder op in ops is granted to every user, and Solo mints a native read ACL for the whole Solo group from any plain call:); create_fields, the field ceiling of a CREATE (and of a copy's defaults) apart from write_fields; and strict_paths, which holds every field reference — domain paths, read_group groupby and aggregates, order, having, name_search — to the fields the grant lists on each model of the path, so a search or a ranking over a column the app may not read stops being a bit-by-bit oracle. Only id is exempt. Also an exact per-app policy for platform models (PLATFORM_APP_OWN_GRANTS) that opens AboveCG's four vizum.* models to the AboveCG Panel and to no other app. A manifest that uses any of the three keys declares min_platform 5.121. Features orm-empty-ops, orm-create-fields, orm-strict-paths.
5.120
THE COST FAMILY: ONE GROUP TO RESTRICT TOGETHER (the number is PROVISIONAL, fixed at merge in merge order). Odoo shows a product's cost through more than standard_price: the sale line keeps it as purchase_price, margin and margin_percent (price - margin = cost), product_margin subtracts it (normal_cost, purchase_gap, expected_margin, expected_margin_rate), the valuation multiplies it by the quantity (value_svl, avg_cost, total_value, and on 19/20 stock.move value, remaining_value, value_manual), Odoo 20 adds project.project.estimated_cost, and the enterprise sale_account_accountant aliases it (unit_cost, total_cost). No depends links most of them to standard_price, so the copy rules of 5.113/5.114 never saw them. The table models/access_cost_family.py lists them with the Odoo source line that proves each; a member is found by presence in the registry, never by version. The validator refuses restrict_read of one member while another stays readable (no_read_cost_family, listing the missing ones), and the catalogue lists the others in requires_pair with via: "cost_family" (a tolerant reader treats any UNKNOWN via as restrict together). Editable seeds (event ticket price, purchase price_unit) and the stock-manager-only valuation layers of 17/18 and landed-cost lines are not members. The manufacturing overview report returns a restricted member its data with the cost amounts at 0 (17, 18; 19 and 20: see 5.126); a restricted cashier opens a POS session (17: the loader drops the cost field) and validates an order (the line cost is stored as the superuser, every line). Feature access-cost-family. See models/vizum_access_catalogue.py (_vzm_cost_family) and vapp_validation.py (no_read_cost_family).
5.119
ONE META CONSENT, TWO RELATED PROVIDERS. Vizum.oauth.connect("facebook", {shared:true, related:["instagram"]}) carries the related provider only inside the signed OAuth state and, after the Facebook Page expansion, materialises each linked Instagram Business account under the existing instagram provider. Both providers must be declared and configured; arbitrary provider pairing is refused. Facebook and Instagram must use the same Meta app ID (client_id): a related consent across two Meta apps is refused at connect. The consent requests the union of both providers' configured scopes (comma- or space-separated, each once). When Facebook connects but no Instagram Business account does, the callback page reports the failure ("Meta authorized Facebook, but returned no linked Instagram Business account") instead of "Connected" and connect() rejects naming Instagram; the Facebook connections are kept. Feature oauth-related-meta.
5.118
UP TO 24 NATIVE GROUPS, AND A HEADING PER GROUP (the number is PROVISIONAL, fixed at merge in merge order). native.groups holds up to 24 groups (8 before: the cap guarded only size, one res.groups row and one xmlid per group); a large field-service vApp declares 15. A group may name its heading in Settings > Users with privilege: "<label>": on Odoo 19 a res.groups.privilege is ONE selection on the user form, so independent roles under the app's single heading could not be held together. Each label becomes a res.groups.privilege «<app name>: <label>» shared by the groups that name it; groups without it keep the app's heading (made only when one needs it). Odoo 17/18 have no privilege and show groups that are not a chain as checkboxes: the key changes nothing there. Two groups of one app with the same label are refused (Odoo 17/18 refused them at install, one category). A vApp with more than 8 groups or a privilege sets min_platform 5.118. Feature native-groups-24. See vapp_validation._valid_native_groups and vizum_app_schema._group_headings.
5.117
THE VALUATION SEAMS CHECK THE PRODUCT AND FORGET THE COST (the number is PROVISIONAL, fixed at merge in merge order). Feature access-valuation-seams-guarded (it completes access-valuation-seams, 5.115). After a seam elevates (the landed-cost leaf, the scope markers compute_landed_cost / button_validate, the landed-cost line onchange) the restricted fields of the products it touched are dropped from the transaction cache, so a member's later read of standard_price in the same request is judged by his own check on Odoo 17 and 18. The landed-cost line onchange does not elevate a virtual line whose product the member may not read, and computing or validating a landed cost with a product the member may not read (a record rule, another company) is refused with an AccessError before anything is computed or written. See models/access_valuation_seams.py.
5.116
VIZUM.TEAM MULTI-ROLE WITH AN AUDIT TRAIL (the number is PROVISIONAL, fixed at merge in merge order). team.save({roles: [...]}) gives a member the FULL set of declared roles (the legacy role: "x" is roles: ["x"], replace semantics); whoami() and members() keep role, the primary role (unchanged rule), and add roles (every held role, manifest order); members({has_role}). Nobody changes their own roles. A role marked grant: "admin" in native.team.roles is given or taken by a settings administrator only, and an account holding one is managed by a settings administrator only. For an account with any group outside the app a manager's save writes only the role groups; its password, login, email, name, phone, active flag and invitation are a settings administrator's. Every change writes one platform:team.* row in vizum.app.audit (actor, target, roles before and after, never a password); a refused write writes platform:team.refused on its own cursor. Managers read the rows with team.history(); in Vizum.audit.search a member who is not a manager sees only the team rows about themselves. Feature team-multi-role. See models/vizum_app_team.py.
5.115
STOCK VALUATION KEEPS WORKING FOR A MEMBER WHO MAY NOT READ THE COST (the number is PROVISIONAL, fixed at merge in merge order). A member of a profile that made standard_price (or a field the valuation reads) unreadable (restrict_read) could not validate a delivery or mark an MO as done on Odoo 18 and 19, validate a landed cost on 19 and 20, or mark a standard-cost MO as done on 20: Odoo's valuation reads the cost AS THE MEMBER. Now, for THAT member only, the valuation computation (_prepare_out_svl_vals, _get_in_svl_vals, _get_price_unit, _set_value, _get_value_from_std_price, _cal_price) runs as the superuser: the value is computed and written, never returned to the member. The landed-cost line no longer shows the member the product's cost (Odoo 17 leaked it, 18 to 20 refused the member): its price is 0 and the member types it. A user who is not a member, the administrator, a cron and any call already run as the superuser get Odoo's own code. The platform depends on none of stock_account, mrp_account or stock_landed_costs: each method is wrapped on the registry class when the model and the method exist. Feature access-valuation-seams. See models/access_valuation_seams.py.
5.114
THE CATALOGUE OFFERS restrict_read WITH ITS PAIR (the number is PROVISIONAL, fixed at merge in merge order). api_access_view_nodes has no profile, so the copies #3960 judges together (product.template. standard_price from product.product.standard_price, image_512 from image_1920) were refused for restrict_read and left out of ok_modes: the Access Manager could not offer «Not readable» for a cost at all. A field whose ONLY obstacle is the pair rule now answers requires_pair: [{model, field, via}] (the closure of its copies and sources, the validator's own) and ok_with_pair_modes: ["restrict_read"]; a pair with a member refused for another reason stays refused, with that member's code and blocked_by. The row path does not change; TEMPORARY no_read_sibling_tracked refuses the four res.partner address fields one compute produces while one is tracked (Odoo 20). Feature access-catalogue-requires-pair. See models/vizum_access_catalogue.py (_vzm_pair_edges, _vzm_pair_group, _vzm_apply_pair).
5.113
NATIVE FIELD no_read OF THE ACCESS MANAGER: NOT READABLE AT ALL (the number is PROVISIONAL, fixed at merge in merge order). A vizum.access.field row with restrict_read appends the profile's group, negated, to the groups Odoo gave the field (a second source of field groups next to the app columns of 5.107, applied by the SAME pass and restored in place when the row goes), so Odoo itself refuses the member on read, search, order, group, export and write, hides the field from fields_get and the views, and refuses the member's create and default_<f>. Administrators and the superuser are never restricted. The refusal is an AccessError on Odoo 18, 19 and 20 also for a member in developer mode (Odoo's error builder crashed on a negated group). The validator refuses the fields Odoo's invoicing and reports read (E2), a field tracked on Odoo 20 (E1), a field another part of a screen names (M9) and an app's own column (Q6). Feature access-field-no-read. See models/vizum_access_no_read.py and vizum_app_aliases.py (_FIELD_RESTRICT, _vzm_apply_field_groups).
5.112
NATIVE FIELD RESTRICTIONS OF THE ACCESS MANAGER: READ-ONLY, REQUIRED, NOT EXPORTABLE (the number is PROVISIONAL, fixed at merge in merge order). vizum.access.field rows (profile, model, field and the booleans restrict_write / restrict_empty / restrict_export) are a second policy source of the operation guard. A read-only field refuses the value the MEMBER'S OWN request sets (create, write, web_save, web_save_multi, import through load, a binary attachment with res_field, the website form, the chatter post and message edit, the composer, and a vApp bridge call made for the member); the writes Odoo makes while it carries out an action the member may do pass. Required refuses a record the member creates without a value and a write that empties it. Not exportable refuses export_data at any level of a path and filters /web/export/get_fields. Feature access-field-restrictions. See models/vizum_access_field.py, vizum_access_field_guard.py and vizum_access_payload.py.
5.111
THE NATIVE VIEW-NODE CATALOGUE OF THE ACCESS MANAGER. vizum.app.api_access_view_nodes(model, view_types) through call: answers, for one Odoo model, the buttons (and whether a blocked operation can refuse them), notebook pages, search filters and group-bys, the chatter and the fields of the COMBINED arch of its primary views, computed as an administrator, and every field's facts with the validator's verdict per field restriction mode (access_field_policy) and the nodes that name the field in a modifier (dependents). Metadata only: no record, arch text, domain or group xmlid. Reserved to the Vizum-signed Access Manager (com.vizumapps.hide_menu, _vizum_verified), an administrator only. Feature access-node-catalogue. See models/vizum_access_catalogue.py.
5.110
WHAT THE SERVER KNOWS ABOUT AN OFFLINE REPLAY (merge order: 5.110). V.offline_replay(): None for a live call; for a call the platform accepted as an offline replay of this user's queued op, {queued, received_at, first_seen_at, attempts, queued_version}, the times on the SERVER's clock (vizum.app.offline.seen, written in its own cursor, purged with the offline ledger). It does NOT prove the device was offline. Travels as an OfflineReplayFacts in the context; a client-sent value is stripped at every entry (models/offline_facts.py). Feature offline-replay-facts.
5.109
ONE IDEMPOTENCY KEY ACROSS AN ONLINE CALL AND ITS DECLARED ACTION (merge order: 5.109). An app with min_platform 5.109 that sends an idempotency key on an online call: gets it ledgered (exactly once), and the ledger answers the same key across the online call: and the declared action whose target is that method (POD: a proof sent online whose answer was lost, re-sent through the offline queue). The match is the method fingerprint (idem_method: model, method, sorted ids, arguments bound to their names); before answering, the calling path runs every check that does not depend on doing it again (grant, approval, consent, domain, READ on the records). Feature idempotency-across-paths.
5.108
BLOCKED OPERATIONS AND THE DEVELOPER-MODE GATE (the number is PROVISIONAL, fixed at merge in merge order; 5.107 native field audience). vizum.access.operation (one row per blocked operation of a restriction profile: copy, import, export, message, note, activity, button, report, server_action, action, view_type) and vizum.access.profile.no_debug. The platform wraps only the declared (model, method) pairs when the registry is ready (_register_hook), refuses the member's DIRECT call with AccessError and lets what Odoo does by itself pass; administrators, the superuser and sudo() are never judged. no_debug clears the session's developer mode for the members (?debug=1|assets|tests). One validator (vapp_validation.access_operation_error, contract access_operation_policy). Admin-only ACL; Hide Menu administers it (grant in PLATFORM_APP_GRANTS). Feature restriction-operations. See models/vizum_access_operation.py and vizum_access_op_guard.py.
5.107
WHAT AN APP ADDS TO ODOO'S MODELS AND VIEWS IS FOR THE APP'S PEOPLE (the number is PROVISIONAL, fixed at merge in merge order; 5.105 read calls, 5.106 restriction profiles). A column an app adds to an Odoo model (native.fields) and every node it injects into an Odoo view (native.views placements and pages, the inserted nodes of an inherited native.ui view) used to be shown to every user: ABC-XYZ's classes in the product.template list, search panel and form, and through read, search and export. Now the field carries the app's audience as its groups (its native.groups, the people and groups an administrator granted the app on the Desktop, in Solo and in the native menu, and the settings administrators), Odoo enforces it everywhere, and the injected nodes carry the same groups. Opt-outs: "visibility": "all" on a field (everyone, as before) or on an inherited native.ui view (its inserted nodes stay everyone's); explicit groups keep exactly those (VZ-SEC-23); an app that replaces an Odoo module (native.replaces) defaults to "all", as the module did (RULE V). Duplicating a record keeps working for a user outside the audience. Changing who may use the app re-tags its views and rebuilds the registry; the upgrade to 19.0.10.150.274 applies it to the apps already installed (one savepoint each, an audit row, a retry at the next start). Feature native-field-audience. See models/vizum_app_native_audience.py.
5.106
RESTRICTION PROFILES AND MODEL RESTRICTIONS (the number is PROVISIONAL, fixed at merge in merge order). vizum.access.profile (who is restricted: users, and the members of groups) and vizum.access.restriction (what they may not do: read / edit / create / delete a model, except records matching a domain, only in some companies). The platform owns one res.groups per profile, kept equal to its targets minus administrators and the superuser, and compiles each restriction into a GLOBAL ir.rule (≤ 19) / a group-less ir.access restriction (20): AND-ed, so it only ever narrows, and it holds over RPC, export and import, not only the web client. One validator (vapp_validation.access_restriction_error, contract access_restriction_policy): never a deny-listed model, vizum.*, ir.* (except ir.filters: create / write / unlink, never read), res.users / res.groups / res.company. Admin-only ACL; Hide Menu is the app that administers it (grant in PLATFORM_APP_GRANTS); uninstalling the app removes its profiles, rules, accesses and groups. Feature restriction-profiles. See models/vizum_access_profile.py.
5.105
READ CALLS RUN READ-ONLY, WITHOUT THE «App action» DIALOG (numbered in merge order; 5.104 is guest links). permissions.orm[].read_calls names the call: methods of a grant that only read — on a vApp native model (x_vzm_*) only, and only an app-defined method (native.methods, not an override nor an inherited Odoo method) is run as one; a name an update adds is a widening (approval). The platform runs a declared read call in a read-only window of the request's transaction (models/read_call.py: SAVEPOINT + SET LOCAL transaction_read_only, the same PostgreSQL switch Odoo 18+'s read-only cursor uses, identical on 17–20), with exactly today's user, grant and sandbox; one that writes, queues a post-commit callback, writes the filestore, sends SMTP (models/read_call_guards.py) or reaches V.net/oauth/ai/vector/ webhooks/identity/commit is refused with READ_CALL_WROTE and audited (platform:read_call_wrote). api_list sends the approved read calls (read_calls) and the desktop does not show the «App action» dialog for them. Needs min_platform 5.105. Feature read-calls.
5.104
GUEST LINKS: auth: "link" routes (numbered in merge order; 5.103 is advisory_lock). An account-less customer opens a link the APP issued from its server code (V.links.issue/reissue/revoke/extend/close/status/list/ledger): a 256-bit token shown once and kept as its SHA-256 only, carried in the URL fragment (/app/<slug>/<page>#token, never in a log or a Referer), exchanged once for an HttpOnly SameSite=Strict guest session scoped to /vizum/link/<slug>/. Expiry 1 h - 90 days (default 30), a grace of up to 30 days the app applies when its job closes, instant revocation, re-issue revokes the old token. Link routes are page, json (64 KB body), file (a declared binary field, signed 10-minute URLs) and upload (a new kind: one declared binary field, link.max_mb <= 25, link.accept image/pdf decided by the bytes, never markup); hooks run as the app's job user with V.link {id, ref, scope, contact_hint, expires_at, verified, route}, never the token. Limits per link (60 calls, 10 uploads a minute), per source (30 failed resolutions per 10 minutes, IPv6 by /64), per app; an optional one-time code (link.verify: "code", V.links.issue_code inside the hook, 6 digits, 10 minutes, 5 attempts); a per-route kill switch and the platform link ledger for administrators (api_guest_links, Vizum Apps Admin > Guest links). Issuing is open to Vizum's own verified apps; any other app needs vizum_apps.guest_links_policy = all or its key in vizum_apps.guest_links_apps. permissions.link_routes is a privileged grant of its own. Browser: Vizum.link.info/call/fileUrl/download/upload/verify on the link page only. Feature link-routes. See models/vizum_app_link.py and sdk-next/docs/link-routes.md.
5.103
A vAPP METHOD SERIALISES ON ITS OWN KEY (capabilities are numbered in merge order). advisory_lock(key, timeout_ms=None) in native.methods and their library is a module's cr.execute("SELECT pg_advisory_xact_lock(...)") (psi_dsd: one sale per user and client reference): a transaction-scoped PostgreSQL advisory lock in the two-int form (-app id, first 4 bytes of sha256(key)) — negative and one per app, so it never meets another app's lock, the platform's (positive namespaces, or the bigint form) or Odoo's (19 ai: positive record ids). Bounded wait: SET LOCAL lock_timeout (default 5000 ms, 0 = do not wait, at most 30000), restored after; a timeout raises LockTimeout (LOCK_TIMEOUT, retryable; the transaction stays usable — the wait runs in a savepoint). Under Odoo's REPEATABLE READ a lock that only waits leaves the waiter on its old snapshot, so the call also touches one row per (app, key) in vizum_app_xact_lock: if the previous holder committed after this transaction's snapshot that write fails to serialize and Odoo replays the request on a fresh one. key: a non-empty str of at most 256 characters. Rows are dropped after a day by the daily autovacuum. A vApp whose methods call it sets min_platform 5.103 (install gate and vzm check). Feature advisory-lock. See models/vapp_xact_lock.py.
5.102
A DECLARED ACTION IS TOLD WHAT ITS METHOD RETURNED (the number is PROVISIONAL, fixed at merge in merge order; 5.101 is native.access). Vizum.actions.run (and a backend task, which runs the same _action_exec) answered {ok, result: true} for anything json.dumps refused: the wizard stock_sms opens on a validate, the return wizard of stock.picking.action_return on Odoo 20, a recordset, a datetime. For an app that declares min_platform 5.102 or later, result takes the 5.98 call: shapes (models/call_result.py, _orm_typed_result): an action dict is {type: "action", action_type, name, res_model, res_id, view_mode, target, context?}, a top-level recordset is {type: "records", model, count, ids} (ids and res_id only inside the app's own read grant), dates and decimals are normalised like a call:, and what is not plain data is refused with ACTION_RESULT_UNSERIALIZABLE (the run is rolled back and audited as failed). null keeps answering true. Apps below 5.102 keep today's result byte for byte. Feature actions-typed-result.
5.101
native.access (the number is PROVISIONAL, fixed at merge in merge order): one of the app's own groups on an Odoo model the app does not own, what a module's ir.model.access row on a shared model does (psi_dsd: read on account.move for its driver group). [{model, group, ops, reason?}]: the group is "@name" only, ops ⊆ read/write/create/unlink, within the app's plain orm grant on the model and each narrowed by a native.rules entry of that same group, a reason for write/create/unlink, never a model of NATIVE_ACCESS_POLICY's deny lists. ≤ 19: an ir.model.access row per entry, narrowed by those rules; Odoo 20: ir.access permissions carrying their domains, never a domain-less one (it would be the whole model, OR-ed with the user's other groups). Folded into native_acl: a new or wider grant re-approves the update, and the preflight lists the grants (permissions.detail.native_access). Feature native-access. See models/vizum_app_native_access.py.
5.100
VIEW INSPECTION FOR THE IDE AND Vizum.ui.openView (C-IDE-2; the number is PROVISIONAL, fixed at merge in merge order). api_view_inspect(source, views, opts) through call: on vizum.app (same gate and keys as 5.99: an administrator's store-verified Vapp Creator or Module Converter) answers, for every native view a package touches (installed app or a package not installed yet), the combined arch without and with the app, each spec's change, where each field lands (placed / skipped / invalid / pending), the other inheriting views and their conflicts, the primary siblings recombined and the findings (VIEW_*), each with its manifest path. The app's specs come from the installer's own functions and enter Odoo's own _combine as in-memory ir.ui.view records: nothing is written (an installed app's _check_xml runs in a savepoint that is always rolled back). api_update_preflight gains views_broken (a native.ui view that would abort the install). Vizum.ui.openView({model, view_type, view_xmlid?, res_id?, label?}) opens a real Odoo view (grant permissions.ui "openView"); an xmlid resolves through the HOST-ONLY api_view_ref (a primary, active view of that model and type the user's groups allow). Solo degrades form/list to its overlays and refuses the other types. Features view-inspect, ui-open-view. See models/vizum_app_view_inspect.py.
5.99
ODOO INTROSPECTION FOR THE CREATOR IDE (C-IDE-1). An administrator's Vapp Creator or Module Converter asks the instance what its Odoo has, through call: on vizum.app: api_odoo_introspect(op, models, parts, opts) with op "signature" (registry + installed modules, edition, Enterprise licensed or not), "index" (every model), "models" (1-50: fields as the user sees them, methods with signature, decorators, defining modules and origin ce/ee/custom/ third_party, the bridge's verdict per method — @api.private refused like Odoo's RPC, VZ-SEC-35 — and the model ACL; 2 MB and a time budget, the rest named in truncated) and "source" (1-20 methods of one model, every definition, Enterprise marked OEEL-1, credential-looking literals redacted). Code only: never a record, a default value, ir.default or a configuration parameter; BROKER_DENY_MODELS by name, vizum.* by name and access. Reserved (first-party, store-verified) to com.vizumapps.vappcreator and com.vizumapps.moduleconverter, which also get call:api_update_preflight. And api_build takes an 8th positional preview_sourcemap: a preview build (install false) hands back its source map, never inside the package. Features odoo-introspect, preview-sourcemap. See models/vizum_app_introspect.py.
5.98
A call: THAT OPENS A WIZARD IS TOLD SO (stock_sms's confirm.stock.sms; any method that returns an ir.actions.* dict). For an app that declares min_platform 5.98 or later: an action dict comes back as {type: "action", action_type, name, res_model, res_id, view_mode, target, context?} (plain scalars only; domain and views are never copied, context only as lang and tz; ids and res_id only for records the app may read), a top-level recordset as {type: "records", model, count, ids}, and a result that is not plain data (a nested recordset, an object, a lazy value, over the size caps) is refused with ACTION_RESULT_UNSERIALIZABLE instead of answered as true. Older apps keep true / the ids list. Feature typed-action-result. See models/call_result.py.
5.97
POLICY RECONCILIATION OF INSTALLED APPS (hardening program B1, models/vizum_app_policy.py). A rule the platform tightens used to judge only the next package. Now the install validator, the permission classification, the ORM clamp and the package trust check judge every installed app again when their code or the trust policy changes, and once a day (resumable, batched, one commit per batch, 60 s per cron run). Outcomes per kind (decisions B1-B4, 2026-10-07): report; reapprove (a permission now privileged: the app keeps running, red, until an administrator approves or disables it); hold ONE unsafe view / route / hook; needs_approval only when an administrator runs the pass; disable only for trust. A pass that would act on more than 5 apps or 20 % of the running ones stops and asks. Every decision leaves a policy:* audit row, the Apps Manager shows a policy health check and a "Re-check apps" button, and an approval is audited (policy:approved). The store re-judges the versions it serves when its gates change and revokes only on an administrator's act for a HIGH finding. Feature policy-reconcile.
5.96
THE SERVER'S OWN METRICS FOR AN ADMINISTRATOR'S APP (Enrique 2026-10-08: «lo que un módulo admin puede leer, un vApp también»). New namespace Vizum.server: info() answers the hosting (docker, podman, odoo.sh, systemd, bare), the scope of the numbers (container, process, or host only behind an administrator's switch that is refused on a shared host), prefork or threaded, the nodes, the workers configuration, the retention and, per metric family (cpu, memory, iops, workers, storage, http, db), true or {ok: false, reason}; metrics({from, to, step, node?, series?}) answers one point per step (60, 300, 900, 3600, 21600 or 86400 s, at most 1,500 points, Unix seconds) of CPU, memory, workers, Odoo's own disk I/O, HTTP rate / mean / p95 / max / concurrency / errors and this database's connections, commits, reads and cache hits, null where there is no data (never 0), the latest disk, filestore and database sizes, and top_apps (the ten apps with the most bridge requests in the range, with their mean duration). Every HTTP request is timed into per-process minute buckets (status class and route kind only: no URL, user or IP) that a per-process thread writes once a minute; one-minute data is kept 48 hours, hourly roll-ups 30 days. Collected only while a ready app holds the approved permissions.server_metrics (a privileged boolean: install_scope "admin" and min_platform 5.96); both methods are base.group_system only. Feature server-metrics.
5.95
DETERMINISTIC OFFLINE VERSIONING (Master Program Batch 4, plan §8). A device offline on app v1 queues creates and writes; the server moves to v2 (or a native module migrates); the device reconnects. Every queued create/write is stamped with the app version and the type of each field it sets (context.vizum_offline.version / .types, the types api_offline("config") now lists per write entry next to app_version). The replay re-checks the op against the server as it is NOW: valid under v2 -> applied; a removed model, a removed or renamed field, a field whose type changed, a removed action -> OfflineStale; offline writes, grant, field ceiling or the user's rights narrowed -> OfflineNarrowed (an AccessError). Both answer OFFLINE_STALE with details.reason (model_removed, field_removed, field_type_changed, action_removed, unstamped, permission_narrowed), never applied with the old meaning and never retried; the host lists them in Sync issues with the values the user entered (discard / keep for export). Each dataset carries sig (its definition: fields and types, domain, order, limit, grant): the device drops a copy whose sig changed and refetches, and the sync token is bound to the field types too. The host learns the installed version on every declaration refresh and a page still running another one gets offline:app_updated {running, installed} and a reload offer. An op without the stamp (version, and types for each field it sets) is refused unstamped before anything runs; ops queued before the upgrade (no version) keep syncing until their app is updated or a field they set changes type (compared with a snapshot taken once). Feature offline-versioned. Offline files and encryption at rest stay GATED (Batch 0 A9).
5.94
SEVERAL PAGES OF ONE APP ON A NATIVE FORM (contador365 puts 36 hr.employee fields on it, and one tab of 30 is not enough). A native.views form entry may carry pages: [{name, label, fields, after_page?, groups?}, ...] (page stays valid as a one-item alias, never both in one entry): at most 4 pages per app per form, 30 fields each and 120 in all, names unique, a field on exactly one page or placed. after_page may also name an EARLIER page of the same entry. All the pages go in the SAME inherited view per (model, type), so uninstall drops them all and an update that renames, removes, adds or reorders pages rebuilds one view. Every 5.91 guarantee holds per page: lxml-built arch, plain-text catalogue-translated labels, a page restricted to a missing group is skipped ALONE (never shown unrestricted), and a form with no notebook gets every page's fields after the main group's last field, in page order. native_views_skipped and strict report and refuse per page (kind "page", field = the page name). Needs min_platform 5.94 (an older platform ignores pages and would show nothing). Feature native-view-pages.
5.93
A NATIVE VIEW PLACEMENT THAT CANNOT BE MADE IS REPORTED, OR REFUSED (Enrique 2026-10-07: «sin estricto, un ancla que falta se salta en silencio»). api_install and api_update_preflight return a NEW key, native_views_skipped: the native.views placements skipped on this database, one {model, view, kind: "place"|"page", field, anchor, position, reason, strict} each, with reason anchor_missing (the form has no such field), anchor_in_subview (only inside an x2many's inline sub-view, which the 5.91 predicate does not count), no_base_view, page_group_missing, page_form_unreadable or page_no_place. Every old key is unchanged; the same list is kept on the installed app (api_installed_detail returns it to administrators only; anyone else gets []). A manifest has ONE native.views entry per (model, type), a second is refused. A native.views entry may carry "strict": true: a placement of that entry that would be skipped refuses the install or update BEFORE anything is written, with an error naming model / view type / field / anchor, and a failed strict update leaves the previous version installed and intact (api_update_preflight lists it in blocking). A page that falls back to the main group of a form with no notebook is not a skip. Default (no key) = skip as before. An app that uses strict sets min_platform 5.93 (an older platform ignores the key and would skip silently).
5.92
A SEARCH THAT ASKS FOR MORE THAN 5,000 RECORDS IS REFUSED, NOT CUT (Batch 22 phase 2 PR 3). Until now an explicit limit above 5,000 on search / search_read was cut to 5,000 without a word: the rows past it were just missing (about 60 calls in 16 shipped apps; those apps now page, Batch 22 PR 3a-c). For an app whose manifest declares min_platform 5.92 or later the bridge raises QUOTA_EXCEEDED with the page size and how to page, and details {kind: "limit_too_large", asked, page: 5000} (the host passes a server QuotaExceeded's details to the app). An app that declares less keeps the old cut, so nothing shipped breaks. vzm check warns about a literal limit above 5,000 (an error from 5.92). Feature search-limit-error. Hardening B8 (2026-10-10), same rule on every path that searches: call:name_search with a limit above 5,000 and the device's offline copy (api_offline("config") .search_limit_error) refuse it too for a 5.92 app; read / read_group keep the value ceiling (resource-governance, no details.kind).
5.91
A NATIVE FORM PAGE OF THE APP'S OWN (Enrique 2026-10-07: fields xpath-ed next to an anchor "don't always look right and break the clean view"). A form entry of native.views may carry page: {name, label, fields, after_page?, groups?}: the listed native.fields of that model go on the app's own tab of the form's notebook (<page name="vzm_<slug>_<name>">, after the page named by after_page when the form has it, else last), restricted to one of the app's groups with groups "@name". The label is plain text (no braces, angle brackets, "t-" or line breaks) and a msgid of the app's catalogue (5.84). At most one page per form; a field is on the page or placed, not both. The page lives in the entry's one inherited view, so uninstall drops it and an update that renames, removes or moves it leaves one view and one page. A form without a notebook gets the fields after the last field of its main group (fail-soft). The arch is built with lxml and vetted like an inherited native.ui view. An app that declares a page sets min_platform 5.91 (an older platform ignores the key and the fields stay off the form); the install gate refuses a page below 5.91, as vzm check does (hardening B8).
5.90
HEAVY WORK GOES TO A QUEUED, CHUNKED BACKGROUND JOB, AND OVER-BUDGET EVENTS ARE TOLD (Batch 22 phase 2 PR 2, Enrique 2026-10-07). Vizum.bulk.export(model, {domain, fields, format, name}) writes the rows to one of the app's files (CSV or JSON lines); Vizum.bulk.rebuild(model, {domain, fields, hook, params}) hands them a chunk at a time to the app's own server hook (logic "script"); status, list and cancel. Jobs run on the CRON worker as the user who asked, through the app's grant (api_orm), in keyset chunks of a quarter of the bridge ceiling, one commit a chunk: ONE job at a time per instance (advisory lock), first come first served, at most 3 waiting per app and 10 per instance (RATE_LIMITED beyond), resumable after a restart. When a bridge read or read_group is refused for its size, or a job fails: a desktop toast to the user (once per app and model an hour), ONE Odoo activity for the administrators per app and kind (at most hourly) on a vizum.app.rgov.event row that counts them, an e-mail only on the administrators' opt-in, and an aggregated daily telemetry count (rgov.*). Settings › Vizum › Resources shows the measured classification and sets the ceiling, the feed concurrency and the e-mail opt-in. Feature bulk-jobs. An app that uses Vizum.bulk sets min_platform 5.90 (models/vizum_app_rgov.py).
5.89
GOVERNED DATA FEEDS AND RESOURCE GOVERNANCE (Batch 22 phase 2, Enrique 2026-10-07). A BI client that refreshes OUTSIDE the vApp (Power BI's service, with its own credential) pages a dataset through GET /vizum/feed/<app>/<feed> instead of Odoo's native /jsonrpc: a per-feed key (shown once, stored as SHA-256, revocable, never the user's password or an API key) opens ONE model, the declared columns and a domain, read as the administrator who issued the key through the app's own ORM grant (record rules apply). Pages are keyset, sized to a quarter of the bridge ceiling (a non-stored computed column counts 4), cut short at a time budget below limit_time_real, streamed a chunk at a time, and at most feed_concurrency pages run at once (1 on odoo.sh/small instances, 2 otherwise): the rest get 429 + Retry-After, first come first served. Vizum.feeds.list/define/rotateKey/revoke/remove behind the new privileged boolean grant permissions.feeds (administrators only; define and rotateKey are confirmed on the host). Features data-feed and resource-governance (the 5.88 ceilings, discoverable). An app that uses Vizum.feeds sets min_platform 5.89 (models/vizum_app_feed.py).
5.88
RECEIPTS PRINT A LOGO, AND APPS CAN ASK FOR A TEST PRINT. A receipt document (5.87) gets an image line: {image: "data:image/png|jpeg;base64,...", align, width, dither, threshold}. The source is a data: URL (at most 96 KB, 4 million pixels; no remote URL, the device page fetches nothing), decoded by the browser on the device page, scaled to the PAIRED PRINTER's dot width (a person picks 58 mm = 384 dots or 80 mm = 576 dots, the default, in the setup panel), reduced to 1 bit (Floyd-Steinberg dither, or a threshold) and sent as ESC/POS GS v 0 raster in bands. At most 2 images a receipt, 800 dots tall each, 64 KB of raster; more is VALIDATION_FAILED, never a hang. The platform's encoder is ESC/POS only (no CPCL), so there is no CPCL graphics path to support. Vizum.device.printTest() prints the platform's fixed test receipt (logo, printer name, paper width, time, a ruler as wide as the paper) with the same device.print grant, rate limit and errors; the setup window's "Test print" is that call. Features device-receipt-image and device-receipt-test. An app that uses either sets min_platform 5.88.
5.87
DEVICE PAGES PRINT RECEIPTS, AND print() IS SUPPORTED. A paired-device page whose route declares device: {"print": true} (a widening: approved with the route, shown on the approval card) gets Vizum.device.printReceipt(doc, opts): the app sends a STRUCTURED document (lines with align/bold/size, QR, barcode, divider, feed, cut — never bytes) and the HOST PAGE builds the ESC/POS stream with the platform's own encoder (vizum_window_manager core/escpos.js, one encoder for every host) and writes it to a printer a person paired AT the device, once, from the page's own setup panel (WebUSB, or Web Serial for a Bluetooth/COM printer; a user gesture is what the browser requires, and the app frame's allow still excludes usb and serial). Bounded (200 lines, 120 characters a line, QR 300 bytes, 32 KB), at most 12 receipts a minute a device. An unpaired printer rejects PRINTER_NOT_PAIRED (the app falls back to print()), a printer that does not take the data PRINTER_UNAVAILABLE (both new error codes, category dependency). Vizum.device.print() is the supported window.print() of a device page: the same grant and rate limit, then the app prints its own view (Chrome started with --kiosk-printing prints it with no dialog; sdk-next/docs/device-routes.md). Features device-receipt and device-print. An app that uses either sets min_platform 5.87 (the install gate and vzm check read device.print).
5.86
OFFLINE: A RECORD DELETED ON THE SERVER IS ITS OWN KIND OF ISSUE. A queued write (or action) on a record that no longer exists answers CONFLICT with details.deleted; the device lists it as reason: "deleted" (with canRecreate) instead of a generic rejection, removes the local row (no ghost), and the user either discards the change or recreates the record (keep_mine: ONE create from what they saw, offered when the app may create the model offline). Vizum.supports("offline-deleted"). Also hardened in the same release, no contract change: a replay runs in the company it was queued under (a company the user lost is a clear refusal), an app waiting for approval holds the queue instead of rejecting it, a write naming a field that left the model is refused before anything is written, relation commands compare against the base correctly, and every replay outcome leaves a platform audit row. An app that handles reason: "deleted" sets min_platform 5.86. sdk-next/docs/offline-data.md.
5.85
A vApp CAN TAKE ITS WINDOW FULL SCREEN. Vizum.ui.fullscreen() / exitFullscreen() (grant "fullscreen" in permissions.ui; exitFullscreen is always allowed) on the desktop and the native Odoo host: the window is painted over the viewport with the dock, the Odoo navbar and the titlebar hidden (a render mode: the window's saved geometry is never touched), and the browser is asked for real fullscreen too, best effort - the click happened inside the sandboxed iframe and only some browsers hand that activation to the host document, so the kiosk layer never needs a gesture. A host Exit button, Escape and the browser's own exit end it (Vizum.ui.onFullscreenChange). Vizum.supports("ui-fullscreen"); Solo and device pages answer {active:false, supported:false} (device pages keep Vizum.device.fullscreen). An app that calls it sets min_platform 5.85 (vzm check). sdk-next/docs/ui-fullscreen.md.
5.84
NATIVE STRINGS FROM THE CATALOGUE. The i18n/<lang>.json an app already ships (5.73, 5.77) now also translates what the app puts in Odoo's own backend: field labels and help, selection options, model labels, groups, menus, window/server actions, view and report arch terms, the translatable values of native.records, a binding's label and a public page's website-menu entry. A key is the plain English text (an existing catalogue works at once) or an optional context key "@native:<ref>" (model, model.field, model.field#help, model.field=value, @group, <record id>.<field>, app, route:<name>; models/vapp_native_i18n.py). The strings are written at install, update, approval and language install/reactivation (and by vizum.app.action_native_i18n_reload) into the jsonb columns Odoo reads, computed from the stored en_US every time; en_US is never written and a missing string stays English. An administrator's own translation of one of these strings is kept (a hash of what the platform last wrote lives on native_i18n_state). The schema compiler now compares and writes the source columns in en_US, so a Spanish installer no longer rewrites them. Not a permission change: a translations-only update still asks for no approval and is audited (i18n:update, i18n:native). Keys do nothing on an older platform, so no min_platform is required. vzm check warns about untranslated native strings and dangling @native: keys (--strict-i18n makes them errors). sdk-next/docs/server-side.md.
5.83
the shared vector scope of a user-scoped app. Vizum.vector.sharedNamespaces() lists the namespaces (name, count, model, dim) the app owns in the SHARED (global) scope, and Vizum.vector.moveScope(ns, {from, to, user_id?, doc_id?, batch?}) moves a namespace's rows between the shared scope and a user's scope WITHOUT re-embedding: the owner of the existing rows changes, so embedding, text, metadata and chunk order are the stored bytes, nothing is embedded and nothing is metered. Administrator or the app's service user only (the store does not record who wrote a shared row, so a plain user cannot be given "the shared rows that are theirs"); user to global is administrator only; batched, re-runnable, never overwrites a row the destination already holds. V.vector.shared_namespaces() (server code) reads the list. An app that calls V.vector.shared_namespaces sets min_platform 5.83. Managed RAG storage lists but cannot move.
5.82
two asks of a paired-device page. (1) Player control: the app cannot reach the YouTube / Vimeo player the PAGE framed for it, so it names the handle embed() returned and the page posts the player's own postMessage command (setVolume 0-100, mute, unmute, play, pause) to that iframe, targetOrigin the provider's exact origin (never "*"), only for players this page created. No new host, no CSP change. Vizum.device.embed(...) handles gain setVolume/mute/unmute/play/pause; feature device-embed-control. (2) Per-device storage, Vizum.device.store get/set/delete/all: a frame with an opaque origin has no localStorage, so a screen's settings (volume) live server side in vizum.app.device.kv, keyed by the paired device (so by app + device), reached only with that device's own token through POST /vizum/device/<slug>/_store: at most 64 keys of at most 4 KB of JSON each (256 KB a device), dropped when the device is revoked. Feature device-store. Device pages only.
5.81
two server-computed facts a guard could not learn, and two limits lifted. (1) The trigger payload carries su: True when the write that fired the trigger ran in a superuser environment (env.su: a vApp method's sudo(), platform-internal code, uid 1), False for the user's own write. sudo() keeps the uid, so uid alone could not tell them apart; it is read from the write's records in base.automation._process, never from the app, and cannot be forged. (2) V.is_service_user(): True when the code runs as the app's own service user (the passwordless job user that jobs, triggers and device routes run as), False for any human. A vApp that reads rec["su"] or names V.is_service_user sets min_platform 5.81 (the install gate refuses it below). (3) A unique of a company_scoped model may list x_company_id, the synthesized company column (a prefix unique per company, one settings row per company); nulls_not_distinct works with it as for any column. A unique that names it sets min_platform 5.81. (4) A job may run with permissions.data "user": the platform writes no shared tick for it, and the job's own V.data is the scope of the user it runs as (the app's service user), never another user's private data; before 5.81 jobs required "global". An app with jobs and data "user" sets min_platform 5.81.
5.80
the offline data layer. A manifest may declare offline {datasets, writes, fallback, max_mb}: named, approved slices of the user's own Odoo data (model, fields, domain with {uid}/{company_ids}/{today±N}, order, limit) that the HOST keeps on the device, and the writes (create / write, with the fields each may touch) that may be queued with no network. The device sends a dataset NAME; the server (api_offline: datasets, config, sync) reads model, fields and domain from the approved snapshot and runs the read as the user, so grants, field ceilings and record rules apply as to a live call. A queued write carries the values the user saw (base) and a queued create or action the computed fields the user saw (expect); a difference answers CONFLICT, and a replay for another user answers OFFLINE_USER_MISMATCH and is kept. The host owns the outbox in IndexedDB (per database and user), applies a queued change to the local copy at once (rollback when the server refuses it), swaps a temporary id (vzm-tmp:...) for the real one when its create replays, syncs on start, on reconnect, on app open and every 15 minutes, hides a copy older than 7 days, and removes the rows (never the queue) on logout or a change of user. Vizum.offline gains datasets, sync, query, readGroup, create, write, conflicts, resolve. A vApp that declares offline sets min_platform 5.80; every actions[].model must be granted or owned. sdk-next/docs/offline-data.md.
5.79
Vizum.identity.confirm: step-up authentication. A vApp asks the HOST to re-confirm the user; the host shows its own dialog (dimming the whole desktop), the user types the account password there and it goes over a dedicated route to the closed model vizum.app.stepup, never through the bridge or to the iframe. The app gets a single-use token (120 s, bound to the user, app, action and session) and spends it in server code with V.identity.consume(token, action), which returns a receipt (id, user, action, meaning, reason, confirmed_at). Not on Solo (UNSUPPORTED_CALL); a device page gets PERMISSION_DENIED. A vApp that names V.identity sets min_platform 5.79 (the install gate refuses it below). sdk-next/docs/identity-step-up.md.
5.78
group_ops. An orm grant may carry group_ops: [{groups, ops}], extra ops (create, write, unlink, call:<method>) for the CALLING user who is in at least one of the groups: "@name" (the app's own native.groups), "<app-key>/@name" (an app in requires) or a real xmlid, never an administrative group. They are computed in _orm_rule, so api_orm, call:, actions and read_group all honour them, and Solo's access group is minted from ops only: a UI app can keep ops read-only for every Solo user and give the owner app's administrators the writes. An op is in ops or in group_ops, not both; moving an op from ops into group_ops narrows (no re-approval), adding one widens. A vApp that uses it sets min_platform 5.78 (the validator and vzm check refuse it below).
5.77
V.tr. The script-hook (trigger guards included), job and native-method sandboxes get V.tr(source, *args, lang=None, **kwargs): the method-side tr() on the app's own i18n catalogue (5.73), interpolated after the lookup, never raising for a missing or malformed translation. Language: lang= when installed, else the call's (a hook: the user whose write fired it), else the language of the user the code runs as; a job runs in its job user's language. A vApp that names V.tr sets min_platform 5.77 (the install gate refuses it below).
5.76
Vizum.app.key. The host already sent the app's key in vizum:ready (app) and the SDK dropped it, so vApps parsed it out of the run URL, which on a multi-database host reads "@<db>" (/vizum/apps/run/@<db>/<key>/...). The SDK now exposes it; vzm check and the store gate warn on code that parses the key from the URL. No server behaviour changes.
5.75
V.sha256. The script-hook, job and native-method sandboxes get V.sha256(data, encoding=None): the SHA-256 hex of a str (UTF-8), bytes or base64 str, at most 32 MiB a call and 256 MiB / 2000 calls a run, charged before the input is decoded. It is an integrity digest, not a signature (package signing is the authenticity check). hashlib stays in the method sandbox for now. A vApp that names V.sha256 sets min_platform 5.75 (the install gate refuses it below).
5.74
NETWORK REACH AND IMAGE FETCH. permissions.net may hold "*": any PUBLIC website (never a private, loopback, link-local, metadata or tunnel address; every hop of a redirect is checked again, the connection is pinned to the address that was verified, and only a short header allowlist is sent), download-only; V.net.fetch_image / Vizum.net.fetchImage fetch ONE image server-side, with a size cap and a deadline for the whole fetch. A host the manifest NAMES reaches ports 80 and 443 only unless the entry says host:port (an app below min_platform 5.74 keeps the old behaviour and is warned). A vApp that uses any of it sets min_platform 5.74.
5.73
SERVER-SIDE TRANSLATIONS. The i18n/<lang>.json files the browser SDK reads also answer tr() in the app's model methods: the install stores them on vizum.app.i18n_catalog (models/vapp_i18n.py), tr() reads the language of the call (context.lang through the bridge, an installed language is just lang; one the instance lacks is kept as vizum_req_lang next to the lang the ORM kept, so the ORM keeps working and a nested with_context(lang=X) still wins), tries es_419 -> es -> msgid and keeps the % formatting. The instance, the Store upload and vzm check refuse a catalogue whose placeholders differ from the msgid's, with markup, or over 512 KB (2 MB together); an update that changes only translations asks no approval and is audited (i18n:update). A vApp that uses it sets min_platform 5.73.
5.72
RESUMABLE CHUNKED UPLOADS. A dropped connection no longer restarts a big Vizum.files.putBlob from zero: POST /vizum/apps/files/<key>/upload/status?token answers {received, next_part, size, sha256, expires_at} behind the same user / app / expiry gate as the other upload routes; the host keeps the token on a network error, sends the same part again with backoff, asks status and goes on from next_part, and when it gives up it aborts explicitly (an abort that cannot reach the server is retried when the network returns) so the quota reservation and the upload slot are freed. Nothing changes for an app: the same Vizum.files.putBlob call. A vApp that wants the guarantee sets min_platform 5.72.
5.71
LARGE APP FILES. Vizum.files.putBlob above 32 MB uploads in parts (begin / part / commit / abort: models/vizum_app_upload.py, the platform reserves the declared size against every quota and verifies size + sha256 before the file reaches the filestore); Vizum.files.getBlob and getRange stream a file out with Range support. permissions.file_limits.max_mb may go up to 512 MB, but a file above 200 MB needs its own administrator approval (permissions.large_files, synthesized) and an app storage cap; the single-request routes (Vizum.files.put, putBlob up to 32 MB, orm.writeBlob) stay at 200 MB. Duplicate app files that an old race left behind are renamed (never deleted) by a one-time migration. A vApp that uses any of it sets min_platform 5.71.
5.70
PAIRED DEVICES AND LARGE FILES. A route may be auth: "device": a screen, kiosk or time clock with no Odoo login pairs with an 8-digit PIN the platform shows and an administrator claims on the desktop; it then holds a hashed, revocable token and reaches only the app's device pages, json and file routes (controllers/device.py, device-routes.md); a device page plays YouTube / Vimeo over the app (Vizum.device.embed, framed by the page, only the providers of device.embeds) and keeps an offline cache (device_sw.js, device_host.js); Vizum.pairedDevices manages the devices from the desktop. permissions.file_limits raises Vizum.files per app (up to 200 MB a file) with a streamed upload — Vizum.files.putBlob, Vizum.orm.writeBlob. A vApp that uses any of it sets min_platform 5.70.
5.69
UPDATES THAT RE-ASK, VERSIONED CONTRACTS, VERIFIED CALLERS, INSTANCE TELEMETRY. An update that changes server code (script hooks and library, model methods, routes, jobs, webhooks, fields with server logic, constraints, automations, native migrations, the evaluated parts of native.ui and manifest actions) sends the app back to needs_approval like a wider grant, and api_update_preflight names each permission and code change (lifecycle.md); Vizum.backend task rows carry status — retrying, expired, dead_letter (reliability.md); provides may name a contract version (name@x.y.z) and requires a contract in a version range (composition.md); a method another app calls through its call: grant reads that app's key with caller_app(), verified by the platform (server-side.md); the instance reports a daily technical profile (edition, active-user band, installed vApps) from 2026-11-04 per Terms 15.4 v1.2, and an administrator can switch the upload off. A vApp that uses versioned contracts, caller_app() or task status sets min_platform 5.69.
5.68
A LARGE CUSTOMER MODULE AS A vAPP (its 1766 tests run on the vApp). In the sandbox: the module's own classes as objects (new_class: public attributes, the program's own methods, a closed list of dunders), its pure modules as the app's library (up to 1 MB / 32 parts with methods; a program of 3 MB / 300k nodes), fields_of(model), with savepoint(): (the only with), base_url(), sign / consteq / verify_legacy (the module's signed links, only for an app that replaces it), format_text, a governed PIL (Image, ImageOps), hashlib, uuid, binascii, pg_errors, clock (time/monotonic), lock_read / fetched_row, with_user / with_env, and fields_get / check_access / has_access on the app's own models; self.env.cr / execute / registry / pool stay unreachable (refused at upload by the platform and the store). An app's methods share one program per process, as a module does. Models: unique entries {fields, nulls_not_distinct, name} (empty values collide; the index named <table>_<name>, as Odoo names the module's constraint), check entries {name, field, op, value} (a PostgreSQL CHECK <table>_<name>), a many2many pair (one relation from two own models), default_get and invalidation by an alias name, order, readonly on any field; native.replaces_min_version (adopt only from a module at its last migration); native.routes kind "file" (a binary behind a link the platform signs: route_file_url / route_file_token / route_file_verify); native.records match (a seeded row whose xmlid is gone is anchored again by its unique fields); the app's groups under a privilege/category named after it; native.adopt on real data: the rows are copied as data, not as events (the app's create/write overrides stay out, its constraints run once over the adopted rows), attachment binaries are shared by reference (no byte read), refs ({id_field, model_field | model_by}) and links to ir.model / ir.model.fields follow the adopted rows; an UPDATE whose requires names an app that is missing or out of range is refused, the installed version stays. A vApp that uses any of it sets min_platform 5.68.
5.67
THE PLATFORM CONTRACT AND SDK RELIABILITY (SDK plan P0–P4). The platform contract reaches apps (Vizum.platform, Vizum.sdk, Vizum.features(), Vizum.supports()); every failed call rejects with a VizumError {code, category, retryable, correlationId}, and Vizum.errors.is / normalize / catalog read them; every call carries a request id; orm.create, actions.run, backend.enqueue and offline.run take an idempotency key, so a retry or an offline replay runs once; the vApp bus no longer fans out across users and apps; packages can be signed (vzm keygen / pack --sign, package-signing.md) and every install verifies them, refusing a modified package; the store can revoke a version and every instance disables it; requires takes version ranges and contracts, Vizum.apps.status() reports them and a required app is not uninstalled by accident (composition.md); an app declares event_contracts and the desktop refuses an emit that breaks them, and Vizum.events.catalog() lists them (events.md); Vizum.apps.health() and api_update_preflight say how an app runs and what an update would change before it is installed (lifecycle.md); the CLI gains a platform emulator (vzm test, vzm dev), vzm diff and agent-ready scaffolds (emulator.md, cli.md); inbound webhooks get a delivery ledger with replay protection, ack/fail (from the browser or a job: V.webhooks) and a dead letter, the bridge a per-app rate limit and metrics, and Vizum.errors.retry (reliability.md); an AI tool declares its risk and side effects, and every host confirms an update or destructive one with the user before the app is reached (ai-tools.md); files carry a sha256, put refuses damaged or disguised bytes and public links expire (files.md); an administrator can give each app an AI-token, network and storage budget (QUOTA_EXCEEDED, reliability.md); a manifest action declares retry and timeout_minutes for its backend tasks (reliability.md). See platform-contract.md. A vApp that uses any of it sets min_platform 5.67.
5.66
A WHOLE ODOO MODULE AS A vAPP (psi_dsd → PSI DSD Core, its 818 tests green on the vApp). native.methods: Python model methods run in the sandbox and installed as real methods of the Odoo class — self/env[...] are governed recordsets (the app's orm grants, ACLs and rules; sudo() lifts ACLs, never the grants), overrides of Odoo methods call super_() (privileged permissions.methods), private helpers are p_x, another app calls them through its own call: grant; computes, defaults, constraints and jobs by method; permissions.private_calls / params / peer_data. native.aliases: the module's field names are real (related) fields of the ORM. native.records / rules / ui (views, actions, menus, reports, the vzm_geo_map widget) / groups.implies. native.replaces: while the named module is installed the app waits (no alias, override, constraint, backend or job on Odoo's models) and adopts — its rows, its columns on Odoo's models, its groups' members (groups[].adopt), and the chatter of each adopted row when the module is uninstalled; then the app goes live by itself. A vApp that uses any of it sets min_platform 5.66.
5.65
Vizum.team COVERS WHAT A USER-PROVISIONING MODULE DID (united_insurance_team). team.whoami() -> {uid, manager, role}; member rows carry phone, last_login, self, role_label and admin (an administrative account holding a role is listed, still untouchable); save takes a phone, reactivates a member the app manages and refuses to change the caller's own role. A role may let its own groups into other apps of the same vendor (role apps: [{key, solo?}], each listed in the privileged permissions.team_apps; restricted desktop access and, with solo, the phone launcher; never removed). native.team.adopt_existing "dedicated" (privileged permissions.team_adopt) takes over, on install/update, the accounts that already play the app's roles and hold nothing else, so its managers can reset their password; an account with any other group keeps only "give or take the role". A vApp that uses any of it sets min_platform 5.65.
5.64
A vAPP CAN TAKE OVER THE DATA OF THE MODULE IT REPLACES. native.adopt: [{model, from, fields: {own field: module field}}] copies every row of an installed module's model into one of the app's own models, once (vizum.app.adopt.map), on each install/update while that model exists: rows first, then the links between adopted models translated through the map (links to shared models keep their ids). Each from must be listed in the privileged grant permissions.adopt (a new one widens); platform, identity and security models and secret fields are refused. The customer uninstalls the module afterwards. A vApp that adopts sets min_platform 5.64.
5.63
THE LAST FIELD SHAPES A MODULE USES. translate: true on a char/text/html field (one value per language, as translate=True in a module); type "json" (fields.Json); a one2many from an own model to a SHARED model whose inverse is a many2one column the app adds to that model in native.fields (created after it, in _apply_shared_one2many); up to 80 own models per app (was 50). Also fixed: an app that added to res.partner a many2one pointing at its own model could not be uninstalled (Odoo reflects the column on res.users as a 'base' field, and dropping the model refused it): such a column is now dropped before the model. A vApp that uses translate, json, a one2many to a shared model or more than 50 models sets min_platform 5.63.
5.62
A SANDBOX FOR REAL LOGIC, WITH A CLOCK. Script hooks may define top-level functions (def, no decorators, no nesting — so no closures) and use while; the app may share code between its hooks in native.script.lib ({name: code}, vetted the same way, run before each hook). In exchange every run has a TIME BUDGET — 30 s, 120 s for a scheduled job — enforced by a check the platform injects into every loop and function body (no signals or threads); a run that exhausts it stops with a clear error. On a 5.61 build a def or a while is refused at install, so a vApp that uses them (or lib) sets min_platform 5.62.
5.61
NATIVE DATA ACROSS VERSIONS. native.migrations: [{to, ops}] run by reconcile on the update that crosses to (vizum.app.native_schema_version remembers the version last reconciled; a first install migrates nothing): rename_field (the column and its data keep living under the new name; the app's views are dropped first and rebuilt), copy (old column into an empty new one, both existing), hook (a script hook of the app, run as its job user with installing=True). Order: renames, new schema, data ops, then the stale columns go. native.seed: [{model, key, rows | file (a .json in the bundle), update?}] upserted by the natural key on every install/update, references {"$ref": "<model>:<key>"} to rows seeded earlier. Uninstall deletes the data, as a native module's uninstall does. A vApp that declares either sets min_platform 5.61.
5.60
Vizum.team: A MANAGER OF THE APP CREATES AND EDITS THE USERS WHO PLAY ITS ROLES. The manifest declares native.team {managers: [group refs], roles: [{name, label, help, groups}]} under the privileged grant permissions.team; api_team (vizum_app_team.py) offers roles / members / save / set_active / remove / set_password / invite. Only a manager (or a settings admin) calls it; a role gives exactly its approved groups and never one that is or implies an administrative group (checked on the implied closure at run time); administrative accounts are never touched; a user the app's team did not create (vizum.app.team.member) can only gain or lose the app's roles. It replaces the per-customer Odoo module apps needed for this (united_insurance_team, psi_dsd's manager_provision_user). A vApp that calls Vizum.team sets min_platform 5.60.
5.59
A vAPP CAN DEFEND A FIELD OF ITS OWN MODELS (closes the 2026-09-01 field-ceiling concern). An own-model field may declare writers (["@server", "@<declared group>", "<group xmlid>"]), server_only: true (= ["@server"]) or computed_by: "<hook>" (a trigger hook of the app on that model computes it; = ["@server"]). The ORM refuses a create/write that sets it unless the caller is a writer — one gate for the iframe, the invoked hooks, the backend and RPC (vizum.app.native.runtime on base). "@server" is the app's SERVICE USER — an internal user with no password, created when the app declares a server writer (or native.service_user), member of base.group_user and of the groups its models grant, made its job user (triggers and jobs run as it), archived at uninstall — or Python running as the superuser/sudo, as for a native module's field only its code writes. Administrators are writers only when listed. Apps that declare none of this keep their job user unchanged. min_platform 5.59.
5.58
WHAT A MODULE'S PYTHON GIVES ITS MODELS, DECLARED. On the app's own models: default (a literal of the field's type, or {"dynamic": today|now|user|company}) and sequence ({prefix, padding}: numbered on create from the app's own ir.sequence), both applied by the platform runtime (vizum.app.native.runtime extends default_get and create on base, acting only on a READY app's x_vzm_ models, from a cached map of the manifests); unique (real PostgreSQL unique indexes, vizum.app.native.unique, refused at install when rows collide); chatter (mail thread + activities, only ever turned on) with tracking on fields. Computed fields on own models: formula (manifest names, dependencies derived), related (from one of the model's many2one) and aggregate ({of: <one2many>, op: sum|count|min|max|avg, field}) — the totals of a document — compiled into platform-owned compute bodies, created in dependency order (Odoo checks a compute's path against the registry at create time). They hold for the vApp, the backend and RPC. A 5.57 build refuses or ignores every one of these keys, so a vApp that uses any of them sets min_platform 5.58.
5.57
A vAPP CAN MODEL A DOCUMENT WITH LINES. On the app's own native models: a one2many names its inverse (a many2one declared on another of the app's own models, pointing back) and compiles to Odoo's relation_field — before, the validator accepted a one2many the compiler could not install. A many2one takes ondelete ("set null", the default; "cascade"; "restrict"), enforced by PostgreSQL's foreign key, and any field but an x2many or a boolean takes required (a required many2one cannot "set null"). A one2many may also sit on a SHARED model to list the app's rows (res.partner -> its orders); there required and cascade are refused — they would act on records other modules own. A version may add, change or drop them: the compiler drops a one2many before its inverse (Odoo refuses the reverse outside a module uninstall), a dropped ondelete returns the field to "set null", and a one2many is never archived (it has no column). The bridge's relational guard already governs the nested commands. On a 5.56 build a one2many dies at install on Odoo's own error and ondelete/required are silently ignored — so a vApp that uses any of them sets min_platform 5.57.
5.56
A HOOK CAN GUARD A DELETION. native.script.hooks[].trigger.on accepts "unlink" (with enforce): a base.automation on_unlink whose action calls the same dispatcher, run BEFORE the deletion — a hook that raises (V.fail) aborts it, for every ORM unlink: the backend, sudo, crons, the app's own Vizum.orm.unlink. Payload: event "unlink", changed [], old = every stored content field as the row is before the deletion (same ceilings), new {}. Every trigger payload gains installing (true while the app's own install/update runs its migrations; the guard still runs and decides). Uninstall is unaffected: the automation is torn down before the model, whose table is dropped, not unlinked. NOT seen by any trigger: deletions PostgreSQL performs itself — a set-null or cascade many2one (every vApp many2one is set null), many2many link rows — so guard the record actually deleted. A vApp that declares on: "unlink" sets min_platform 5.56.
5.55
PUBLIC ROUTE PAGES. A route may declare auth: "public" — a PAGE only (a public json route does not exist), with no groups — at /app/<slug>/<route>, open to anyone, signed in or not, of the company it is served in (the website's company when website is installed, else the public user's; an app of another company answers the identical 404). The page carries the bundle credential and nothing else: NO caps and NO route run-token, so the bridge refuses every call from its iframe; an anonymous visitor's page is Cache-Control: public, max-age=300. Public page names surface as permissions.public_routes, a separate privileged grant (widening → needs_approval) shown on its own approval card with each full URL. Optional website_menu ({text <= 40, sequence}) becomes a website.menu entry per website of the app's company (all websites without one), a recorded website_menu artifact removed when the app leaves ready, drops the route, or is uninstalled; skipped without website. Optional sitemap: true lists the page in the website sitemap (part of the approved descriptor). A vApp that declares a public route sets min_platform 5.55.
5.54
A TRIGGER KNOWS WHO WROTE, WHO OWNS, AND WHAT THE WRITE LEFT. The hook payload gains uid (env.uid of the write — 1 when server code writes as the superuser), owner (the row's create_uid) and new (post-image of the written fields; every stored content field on a create), read with the platform's rights like old and under old's ceilings: the app's read grant, never a field the app hides from the hook identity (VZ-SEC-23), binaries as their size, text over TRIGGER_TEXT_CAP as its length (also in old now). A guard runs as the job user, whose record rules may hide the row it guards; before 5.54 "only the owner may share this" could not be enforced server-side (Vizum Sheets). A hook that reads uid/owner/new sets min_platform 5.54.
5.53
ROUTES A vAPP DECLARES (native.routes), portal and internal only. The platform serves GET /app/<slug>/<route>[/<sub>] (a platform page embedding the app's sandboxed iframe, with the route as its intent — Vizum.route) and POST /app/<slug>/<route> (a json route: CSRF, body <= 64 KB, rate-limited per user/app/route, audited, the declared hook run AS THE VISITOR), plus the GET alias /my/<slug>/… for a portal page and an optional card on the portal home. A route is served only while its exact descriptor is in the approved snapshot (permissions.routes; declaring or editing one needs approval, and the app needs install_scope admin). A portal (share) user reaches the bridge ONLY through the route run-token the dispatcher mints for that app, route and user — and only api_session (minimal) / api_orm / api_run_hook (the route's json hooks, with that route's rate limit, size cap and audit) / api_watch_hash / api_eval_domain / api_route_token_refresh, within the app's grant bounded by the visitor's own rules. auth: public and website_menu are refused by the validator. A vApp that declares routes sets min_platform 5.53.
5.52
Vizum.assistant.kbSearch(query, {catalogs, topK}): passages from the Knowledge (RAG) catalogs for a vApp holding permissions.assistant — the Assistant's own retrieval (hybrid, rerank, CRAG), run as the CALLING user (their scope and per-reader record filter, never the job user), catalogs the user cannot read refused by name, bounded (query 2000 chars, 20 catalogs, top-k 8, 1500 chars per passage) and traced in Knowledge → Trazas as vapp:<key>. A vApp that reads Knowledge this way (Vapp Creator) sets min_platform 5.52.
5.51
AN EXPLICIT COMPANY ON A company_scoped NATIVE MODEL IS HONOURED, AND VETTED ON WRITE. A create used to replace an explicit x_company_id with env.company, and a write was not checked at all (Odoo re-checks record rules after a create, only before a write), so an app could move a row into a company its user is not in, or make it global. Now an explicit x_company_id — on create, write, a nested line update or a copy default — must be one of env.companies (the browser's active companies; the job user's own companies for a hook), else ValidationError; False on a write is refused; a create naming none is still stamped. A vApp that files rows under another of its user's companies without re-writing x_company_id after the create sets min_platform 5.51.
5.50
MENUS HIDDEN PER USER OR PER GROUP. vizum.menu.hide (menu, users, groups, hidden until, why, app_key) is subtracted from ir.ui.menu._visible_menu_ids — the result Odoo's menu service, the command palette and the Vizum desktop all read — with its submenus. Not an ir.rule: Odoo caches visible menus by GROUP SET, so a per-user rule leaks one user's hiding to everyone with the same groups. Administrators are never subject to it, Settings and Apps cannot be hidden, an empty table costs no query, and uninstalling the vApp that created rows (app_key) deletes them. Admin-only ACL. A vApp that manages hidden menus (Hide Menu) sets min_platform 5.50.
5.49
AN APP'S OWN NATIVE MODELS NEED NO ORM GRANT IN THE BROWSER EITHER. The server (_orm_rule) always granted an app its own native models, and the native fields it added to shared models, without permissions.orm; the browser gate of orm/mail/activity/meta still demanded a non-empty permissions.orm, so a vApp whose only data was its own model got PermissionDenied. The host caps now carry a server-computed orm_native (_client_caps). A vApp that calls its own models without declaring an orm grant sets min_platform 5.49.
5.48
MAP TILES THROUGH THE PLATFORM. OpenStreetMap answers a tile request with no Referer with its "Access blocked" PNG, and a vApp iframe (opaque origin) never sends one. The platform now serves /vizum/tiles/<key>/<token>/<exp>/<z>/<x>/<y>.png (the iframe's run capability is the credential — never an open proxy) and /vizum/tiles/<z>/<x>/<y>.png for a signed-in user: it fetches from OSM identified as the tile usage policy asks, caches on disk for at least 7 days, never caches a refusal, and serves with CORS. The SDK exposes Vizum.map.tileUrl(). A vApp that uses it sets min_platform 5.48.
5.47
WORKFLOW CONTEXT KEYS. api_orm (and V.orm in hooks) accepts, besides lang, the two context keys Odoo's web client itself sends to finish a PARTIAL transfer: skip_backorder and picking_ids_not_to_backorder on stock.picking.button_validate — typed, ids restricted to the call's own (ORM_WORKFLOW_CONTEXT). A 5.46 build refuses them, so a vApp that validates partial transfers in-app (with or without a backorder) gates on 5.47 or hands the transfer to the Odoo form.
5.46
RETIRING A COMPUTED FIELD FREEZES IT INSTEAD OF BREAKING RECOMPUTES. A vApp version that stops declaring a formula field used to archive it by rename (<name>__archived_<id>) with its compute still writing the old name: every recompute of its dependencies raised KeyError(<old name>) — on res.partner no contact could be renamed and no user created (measured on dev with CRM Booster 2.1.0). From 5.46 the archive clears compute/depends first and keeps the stored data, and the 5.46 upgrade repairs fields archived before it. A vApp whose new version retires a computed field sets min_platform 5.46, so the installer refuses the update on an instance without the fix.
5.45
THE PGVECTOR GUIDE CAN FINISH THE JOB. Before it, nothing a vApp could call set the RAG storage mode to pgvector: the "RAG pgvector Setup" guide sent the admin to Managed AI, and Managed AI answered "enable it from the guide". vector.pgvActivate({active, force?}) (admin) switches Mode 3 on — refusing when the infrastructure is missing, when stored vectors are not yet in pgvector (they would drop out of search), or out of managed storage — and back to Local. vector.pgvIndex(opts) exposes the existing per-dimension hnsw op, and pgvStatus adds per-dimension counts and the table size for administrators.
5.44
RAG THAT OBEYS ODOO, AND A DOCUMENT REPLACED IN ONE PIECE. (1) vector.search / dump / indexed_docs and rag.list_chunks withhold every passage derived from an Odoo record the CALLER cannot read under record rules, or whose id now names another record (measured: a global sale.order job served order text to a user with no sales access). (2) vector.delete takes {doc_id, keep_gen} (commit a rewrite: retire every chunk of the doc not of that meta.gen) and {doc_id, gen} (roll a failed rewrite back) — a 5.43 build ignores both keys and deletes the WHOLE document, so a vApp that writes generations gates on 5.44. Also: rag jobs prune records deleted at the source, rag.update changes only the keys it is sent (and keeps the owner), and Ollama chat sends num_ctx + think:false.
5.43
COMPARE-AND-SET ON A NATIVE MODEL: orm.write(model, ids, vals, {expect}) in the JS SDK and V.orm.write(model, ids, vals, expect=…) in the script runtime. The rows are locked (SELECT … FOR UPDATE), re-read, and written only if every expected field still holds its value; the call then resolves to {updated, current} instead of Odoo's bare True. Vizum.db has had this since 5.39 — but ONLY for an app's own tables (api_update), so a native Odoo model had nothing and the only way to CLAIM a record was read-then-write. Two clients that read the same value both wrote it: two approvers of one Agent Center proposal both read pending, both wrote executing, both executed. The same shape reaches money — two registers claiming one IoT print job, two terminals redeeming one gift card. A 5.42 build refuses expect as an unsupported keyword (_orm_bind_args fails closed), so a vApp that relies on it gates on 5.43 rather than silently losing the guarantee.
5.42
vizum.migration.run.action_discover(): the migration plan and the source→target field map are built SERVER-SIDE from both live schemas (models/migration_engine/plan.py), and the run exposes discovery_json / include_master / include_vapps / extra_models. Until 5.42 the plan could only be built by the Migration Assistant's own browser code, from the destination's schema alone — a 5.41 build has no action_discover and the call raises, so a vApp that plans this way gates on 5.42.
5.41
db.share(table, id, {users: ["job_user", …]}): the literal "job_user" resolves server-side to the identity the app's jobs/hooks run as (job_user_id, default installer; never superuser/archived/portal). Lets a row reach the app's automations without being public (Agent Center memory, audit P4). A 5.40 build does int("job_user") and raises, so a vApp that shares this way gates on 5.41.
5.40
V.vector.search(namespace, query, opts) / V.vector.namespaces() in server scripts (hooks and jobs): READ-ONLY, through the same api_vector the iframe uses, gated by permissions.vector, metered on its own per-run budget (20). Lets a scheduled agent consult the knowledge base (Agent Center audit P2). A 5.39 script has no V.vector and fails with AttributeError, so a vApp whose hook uses it gates on 5.40.
5.39
Vizum.db compare-and-set: db.update(table, id, values, {expect: {field: value}}) writes ONLY if the row still holds every expected value, under a row lock, and the result says _updated: true|false. It is what lets two approvers race for the same proposal and have exactly one win (Agent Center audit P1, docs/audits/2026-09-25-agentcenter-rule-reach.md). A 5.38 build drops the fourth argument and writes unconditionally, so a vApp that relies on it gates on 5.39.
5.38
Vizum Instances pass: Vizum.instances.pass() -> {token, expiresAt} behind the new boolean grant permissions.instances_api (privileged; gaining it widens). The server asks the Vizum Store (/store/api/v1/instance/assertion) with this database's instance token — which never reaches the iframe — for a 5-minute pass signed with the Store's Ed25519 key: database, Vizum account, acting user by Odoo email. The Vizum Instances API verifies it with the Store's public key, so no key is pasted by hand into each Odoo (decision 2026-09-24). A 5.37 build has no instances namespace: the call is refused, so the vApp gates on 5.38.
5.37
Report Builder 1.0 (native visual editing of Odoo reports, §3.3 of its spec): four small platform changes, all four lines. reports.html(name, ids) -> {html} — the host fetches /report/html/... and inlines same-origin CSS, because the app iframe is an opaque origin that cannot fetch either itself. permissions.reports accepts "<prefix>.*" and "*" (Report Builder's templates are dynamic: vzm_reportbuilder.template_<id>; an exact-name-only allowlist would force re-approving the manifest per saved template) — "*" is privileged. api_orm kwargs.context now accepts ONLY {"lang": "xx"} (reading an arch without translating it); any other key is refused. _apply_bindings skips (log, don't fail) a binding whose model this base does not have installed, and reconcile() retries it on the next version change.
5.36
native.bindings: a manifest may bind the app to the Action menu of a NATIVE model (native.bindings, one entry per {name, model, label, groups}, gated by the native_bindings grant). The platform compiles one ir.actions.server per binding whose only code calls the trusted dispatcher vizum.app._open_binding, which opens the app with the selected records as Vizum.intent {binding, model, ids}. min_platform 5.36: an older build has no whitelist under native, so bindings is silently ignored — the app installs and the Action-menu entry never appears, which is the silent no-op that makes it a capability. 2026-09-12, still 5.36: a binding may add target: "window" | "modal" (modal = ActionDialog over the record). NOT a bump on purpose: _valid_native_bindings ignores unknown keys, so a 5.36 build installs a manifest that declares target and opens it in a window — a graceful degradation the author can see, not a silent no-op.
5.35
assistant.send accepts the CALLING APP's model / reasoning effort / knowledge base ({model, effort, namespace}). vizum.assistant.pref is per-USER, so before this a vertical vapp's administrator could not fix the AI behaviour for their crew: two technicians in the same app got different models. All three are REQUESTS — the server still caps effort at the user's vizum.ai.policy ceiling and filters the model through it, so an app can never exceed the policy. The forced namespace IS a hard filter over what the model asks for. min_platform 5.35: on an older build the three fields are silently dropped and the app's setting reaches nobody — a silent no-op, which is why it is a capability and not a soft add.
5.34
an AI call may carry ref, an opaque attribution tag naming which of the app's OWN internal consumers spent the tokens (Agent Center names the agent). The host pops it before the provider ever sees it and records it on the same audit row as the charge, so Managed AI can break an app's spend down one level with nothing to reconcile. An app that sends ref MUST declare min_platform 5.34: on an older build the field is not recognised as the host's and is forwarded into the provider request, which rejects the unknown field — so the app installs happily and every AI call fails.
5.33
V.db — the app's own Vizum.db TABLES reached from a server-side script. The store was browser-only: V wired orm/net/oauth/files/ai/data/json, so a SCHEDULED job could not read the very tables its app's UI writes. An app whose cron acts on app-owned state (an automation registry, a retry queue, a schedule) therefore had to mirror that state into a V.data collection and hand-sync two stores for one fact. V.db routes to api_db, the same permission-gated dispatcher the iframe uses, so the manifest schema, the declared rules and indexes, the admin-authored write gate and the acting-user row scoping all still apply. An app whose job reads its own tables MUST declare min_platform 5.33 — on an older build V.db is simply absent and the hook dies with a NameError on its first tick, having installed happily.
5.32
enforcing, delta-aware script triggers. A trigger hook may declare trigger.enforce, which makes the platform REFUSE the triggering write when the hook cannot run instead of proceeding unguarded; and every trigger hook's rec now carries the write's own change set (event, changed, old). Both are load-bearing for a guard: without enforce an archived job user silently disarms it, and without changed it can only assert properties of the post-image, every one of which the caller can satisfy by writing more fields in the same call. An app that relies on either MUST declare min_platform 5.32 — on an older build enforce is ignored and changed is absent, so the guard would install and then quietly not guard.
5.29
native.groups: the schema compiler creates real res.groups for a manifest's declared roles (stable xmlid), so access/rules can reference "@name" instead of every vApp with roles needing a hand-written Odoo addon just to get a group to bind to.
5.28
V.commit() in the JOB runtime + one savepoint/commit PER APP in the job cron. A job POSTs to Instagram (irreversible) and then writes remote_id (undoable). They used to share ONE transaction spanning every job of every app, so any later failure rolled the bookkeeping back while the post stayed published — and the next tick posted it AGAIN.
5.27
Vizum.files.publicUrl — an unguessable but PUBLIC https link to one of the app's own files (permissions.files_public). Instagram has NO byte upload: you hand Meta a URL and Meta downloads it. Without this a social vApp can generate an image and then has no way to put it on the post. revokePublic kills the link once the network has taken it.
5.26
the SOCIAL OAuth rail: shared (brand) connections usable by every user AND by the unattended jobs (permissions.oauth_shared + V.oauth in the script runtime), many accounts per provider (account_ref), PKCE + provider client-auth styles (X, TikTok), Meta's long-lived page-token dance, and a refresh cron so a token cannot lapse overnight and kill the 09:00 post.
5.25
Vizum.ai.image — image GENERATION for vapps (managed mode only). Metered PER IMAGE, not per token, so it needs its own permissions.ai_image on top of permissions.ai.
5.24
Vizum.devices.location — HOST-mediated one-shot GPS fix (devices:["location"]). navigator.geolocation is auto-denied inside the opaque-origin iframe (same reason the mic is host-mediated), so the host asks on its real origin and only {lat,lng,accuracy,ts} crosses.
5.21
Vizum.ui.openUrl — the host opens an external http(s) URL in a new tab. A sandboxed, opaque-origin iframe CANNOT window.open (it returns null and nothing happens), so any vapp that sends the user to Stripe, an OAuth consent screen or a doc link needs this. All shipped.
5.20
server-side vApp code: a manifest jobs[].script runs a native.script.hooks entry as job_user_id, with V.net / V.ai / V.data / V.json (and, from 5.33, V.db) and the time helpers (V.now_odoo, V.add_minutes, V.minutes_between, V.clock). An app that must answer a webhook with nobody's browser open declares min_platform 5.20 — on an older platform its job would install happily and then silently never run.
5.5
Vizum.audio (transcription — local Whisper default / managed metered-per-minute gateway).
5.4
Vizum.ocr (OCR — local Tesseract default / managed metered-per-page gateway)
5.3
AI tools — manifest ai_tools + Vizum.ai.tools.handle (client-handled) + Vizum.ui.openApp(key, payload)
5.2
Vizum.docs (raw-document store for RAG re-indexing) + Vizum.vector.pgv* (Mode-3 local-pgvector setup)
5.1
Vizum.domain (Visual Domain Builder)
5.0
Vizum.vector / Vizum.embed / Vizum.sessions
Concepts
The platform contract — versions, feature detection, stability and errors GENERATED
Everything a vApp can rely on — which API exists, what it needs, since which capability, how stable it is and how it fails — comes from ONE machine-readable file, vizum-platform-contract.json (served at /vizum_apps_platform/static/sdk/vizum-platform-contract.json and shipped inside @vizum/vapp-cli), which the platform, the SDK, the vzm CLI and this page are all built from. A coding agent should read that file (or run vzm context) rather than this page.
Contract 1 · capability 5.156 · SDK 5.156.0 · api version 1 · manifest schema 1.
Using it
await Vizum.connect();
// Which platform, which host — informational; never branch on `build`.
console.log(Vizum.platform.capability, Vizum.platform.host, Vizum.sdk.version);
// Feature-detect before using something newer than your min_platform, or that only
// some hosts serve. On a platform older than 5.67 supports() itself is absent.
const can = (n) => typeof Vizum.supports === "function" && Vizum.supports(n);
if (can("realtime")) { Vizum.realtime.channel("board").on("changed", reload); }
else { Vizum.refresh.on(reload); } // Solo has no realtime receive
// Branch on the error's code or category, never on its text.
try {
await Vizum.orm.write("x_vzm_board", [id], vals, { expect: { x_rev: rev } });
} catch (e) {
if (Vizum.errors.is(e, "CONFLICT")) { await reloadAndMerge(); }
else if (e.retryable) { scheduleRetry(); } // network, timeout, rate limit
else { Vizum.ui.notify(e.message, "danger"); Vizum.errors.capture(e); }
}
Six versions, six meanings
| Version | What it is | Read it from |
|---|---|---|
| capability | The feature level of the platform (PLATFORM_CAPABILITY). Identical on every maintained Odoo line. A manifest's min_platform is compared with it at install; the installer refuses an app that needs more. | Vizum.platform.capability (also Vizum.platform.version and Vizum.capabilities().platform) |
| platform_build | The build that carries the capability on one Odoo line: the installed module version (e.g. 19.0.10.150.30) and the Odoo major. Informational: never gate on it. | Vizum.platform.build {module, odoo} |
| sdk | The version of the in-iframe runtime SDK (static/sdk/vizum-sdk.js) served by the platform: <capability>.<patch>. | Vizum.sdk.version |
| api | The major version of the app-facing contract: the bridge envelope, the error model, the semantics of existing members. Raised ONLY by a breaking change, which the stability policy forbids inside one api version; a host lists the api versions it serves. | Vizum.sdk.apiVersion, Vizum.platform.apiVersions |
| manifest_schema | The manifest format ("schema": 1 in manifest.json; static/sdk/manifest.schema.json). New optional keys do not change it. | manifest.schema |
| app | The app's own semver (version in manifest.json). Updates compare it; migrations key on it. | manifest.version |
Gate an app on capability (min_platform) or on a feature (Vizum.supports(...)), never on the platform build or the Odoo version.
Stability policy
| Level | Meaning | What may change |
|---|---|---|
stable | Will not change incompatibly inside this api version. Safe for every app. | additive only (new optional arguments, new result keys) |
beta | Feature-complete and supported; its shape may still change in a minor capability, announced in the capability notes. | shape, with notice |
experimental | May change or disappear in any capability. Do not ship a store app that depends on it without a feature check (Vizum.supports). | anything |
deprecated | Still works; has a named replacement and a capability after which it may be removed (removed_after). vzm check warns where it is used. | removal after removed_after, only with an api version bump |
removed | No longer served. Calling it answers UNSUPPORTED_CALL. Kept in the contract so vzm check can name the replacement. | n/a |
Error catalogue
Every failed call rejects with an Error carrying code, category, retryable, correlationId and details (see Vizum.errors). message keeps the text it always had, so code that reads e.message keeps working; new code should test e.code.
| Code | Category | Retryable | Meaning | Was (legacy message) | |
|---|---|---|---|---|---|
PERMISSION_DENIED | permission | no | The app's approved manifest permissions do not cover this call (refused by the host gate before reaching the server). | PermissionDenied | |
UNSUPPORTED_CALL | unsupported | no | This platform does not serve that namespace or method (a newer SDK, a removed member, a typo). Check Vizum.supports() first. | UnsupportedCall, Unknown call | |
ACCESS_DENIED | authorization | no | The server refused: the user (or the app's grant) may not do this on this record or model. | — | |
AUTHENTICATION_FAILED | authentication | no | Credentials were refused. | — | |
SESSION_EXPIRED | authentication | no | The Odoo session ended; the user must sign in again. | — | |
OFFLINE_USER_MISMATCH | authentication | no | A queued offline operation was recorded for another user than the one now signed in (another tab or another person signed in on the device). The operation is kept, not run: it waits for its own user to sign in again. | — | |
OFFLINE_STALE | conflict | no | A queued offline operation was refused because the server moved while the device was offline (SDK 5.95): the app or a native module was updated. details.reason says why: model_removed, field_removed (removed or renamed), field_type_changed, action_removed, unstamped (the op carries no version/field-type stamp: queued before the device was updated, or by a client that omits it; kept syncing only until the app is updated or a field they set changes type), or permission_narrowed (the app's offline writes, its grant or field ceiling, or the user's access rights no longer allow it); details also carries model, fields, queued_version and installed_version. Never applied with the old meaning and never retried: Vizum.offline.conflicts() lists it with that reason and the values the user entered, to discard or keep for export. | — | |
VALIDATION_FAILED | validation | no | The arguments or the data are invalid. | — | |
BUSINESS_RULE | validation | no | A business rule refused the operation (an Odoo UserError, or V.fail() in a server-side hook). The message is meant for the user. | — | |
NOT_FOUND | not_found | no | The record, app or object does not exist (or no longer does). | — | |
CONFLICT | conflict | no | The data changed since it was read (compare-and-set expect, collection rev). Re-read, then decide. | rev_conflict | |
CONCURRENT_UPDATE | conflict | yes | Two transactions touched the same rows at once; retrying the same call is safe for idempotent operations. | — | |
IDEMPOTENCY_IN_PROGRESS | conflict | yes | An operation with the same idempotency key is still running. Retry later with the same key. | — | |
IDEMPOTENCY_MISMATCH | validation | no | The idempotency key was already used for a DIFFERENT operation or arguments. | — | |
USER_CANCELLED | user_cancelled | no | The user declined a confirmation or closed a picker. | UserDenied, NoFile | |
NETWORK_ERROR | network | yes | The request did not reach the server or the answer was lost. Retry only idempotent operations (or pass an idempotency key). | — | |
OFFLINE | network | yes | The device is offline. | — | |
CONNECTIVITY_CHANGED | network | yes | Connectivity changed while the user was confirming; nothing was sent. | ConnectivityChanged | |
TIMEOUT | timeout | yes | No answer within the time budget. | — | |
RATE_LIMITED | rate_limit | yes | Too many calls; wait and retry. | — | |
QUOTA_EXCEEDED | rate_limit | no | A metered quota is exhausted (AI balance, storage); retrying will not help until it is raised. | — | |
APP_NOT_INSTALLED | dependency | no | The app this call targets is not installed or not ready. | AppNotInstalled | |
PRINTER_NOT_PAIRED | dependency | no | Vizum.device.printReceipt on a device page where nobody has paired a receipt printer (or the paired one is not connected). A person pairs it from the page's setup panel; the app falls back to Vizum.device.print() or its own view. | — | |
PRINTER_UNAVAILABLE | dependency | yes | The paired receipt printer did not take the data (unplugged, busy, claimed by another driver) or this browser has neither WebUSB nor Web Serial. | — | |
LICENSE_REQUIRED | authorization | no | The app's licence does not cover this. | LicenseRequired | |
SERVER_ERROR | server | no | The server failed unexpectedly. Not retryable by default: the operation may have run. | — | |
INTERNAL_ERROR | server | no | The host failed while serving the call (a bug in the platform's browser side). Not retryable by default. | — | |
UNKNOWN_ERROR | server | no | An error an older host reported without a code, and that matches no known message. Treat like SERVER_ERROR. | — | |
ACTION_RESULT_UNSERIALIZABLE | unsupported | no | A call: ran, but its method returned something that is not plain data, an action or a recordset (a recordset nested in a dict, a lazy value, an object, a result over the size caps). The call's changes are rolled back like any failed call. Only for an app that declares min_platform 5.98 or later; older apps receive true. From 5.102 a declared action (Vizum.actions.run, a backend task) answers it too, for an app that declares min_platform 5.102 or later. | — | |
LOCK_TIMEOUT | conflict | yes | advisory_lock() in an app's server method waited its timeout_ms and another transaction of the same app still held that key (SDK 5.103). Nothing was locked; uncaught, the call's changes are rolled back. Retry in a moment: the holder is usually close to its commit. | — | |
LINK_INVALID | authentication | no | A guest link is unknown, expired, revoked or closed, or the visit ended: the same answer for every reason. Ask the sender for a new link. | — | |
VERIFY_REQUIRED | authentication | no | This link route needs the one-time code the app sent (Vizum.link.verify), once per visit. | — | |
READ_CALL_WROTE | authorization | no | A call: the manifest declares in permissions.orm[].read_calls wrote to the database, queued work for after the commit, tried to write the filestore or send an e-mail, or reached a bridge that acts outside the transaction (V.net, V.map, V.oauth, V.ai, V.vector, V.webhooks, V.identity, V.commit). The platform ran it read-only, rolled everything back and audited it (platform:read_call_wrote). Retrying does not help: the method or its declaration is wrong. Only for an app that declares read_calls (min_platform 5.105). | — | |
CONSENT_STALE | authorization | no | The desktop ran a call without asking because an administrator approved the app version's actions, and that approval does not cover it here any more: another version, package or trust state, another company, the company now asks every time, the approval was withdrawn, the grant narrowed, or the action is one that is never covered (unlink, mail.post, the fixed list, a sensitive action). Audited (consent:refused). The desktop host learns the current approval, asks the user the normal way and retries once; an app sees this code only when that retry is refused too. Action consent, platform 5.149. | — | |
STATE_CHANGED | conflict | no | A guarded call (orm.call / callOn with expect, SDK 5.133) found a record that no longer holds an expected value, is missing, or the user cannot read. Nothing ran, for any of the records. details: {model, records: [{id, current: {field: value | null}}]}, the current value of the expected fields only. Re-read and decide again. | — |
BUSY | conflict | yes | A guarded call (SDK 5.133) waited 5 s for the row locks of its records and another transaction still held them. Nothing ran and the transaction is still usable. Retry in a moment: the holder is usually close to its commit. details: {reason: "lock_timeout", wait_ms}. | — | |
GEOCODE_NOT_CONFIGURED | dependency | no | Vizum.map.geocode / V.map.geocode on an instance with no geocoding provider (Settings › Vizum › Geocoding is None, the default). Retrying does not help until an administrator chooses a provider; show the address without a map pin. | — | |
GEOCODE_PROVIDER_FAILED | dependency | yes | The geocoding provider the administrator chose could not answer: unreachable, an address the platform refuses (private or cloud metadata), an error status or an answer it cannot read. The message names no URL and no key; the call is in the geocoding audit. Retry later. | — |
Request ids, retries and idempotency
Every call carries a request id (the SDK mints it). It reaches the server, which records it on the audit rows the call produces, and it comes back on a failure as VizumError.correlationId — one id names the call from the iframe to the server's trail (Vizum.audit.search({request_id})). An AI call stores it on its charge too (vizum.ai.usage.request_id, 5.69): a billed charge names the call that caused it. It stays on the instance — it is not sent to the Store with the charge.
The same id follows the work the call starts (Batch 5): a backend task (backend.enqueue) and a bulk job (bulk.export / bulk.rebuild) store it and run with it, so the audit rows of their run, the tasks and jobs they queue in turn, and the resource events they cause carry it as well. The idempotency record of a keyed call keeps the id of the call that claimed the key. Work that starts without a call starts its own id: an inbound webhook delivery (hook-…), a scheduled job run (job-<name>-…), a task or bulk job queued from server code with none (task-…, bulk-…). A host mints one (h…) for a call from an SDK older than 5.67, which sent none, so every failed call carries correlationId.
Show e.correlationId wherever your app reports a failure the user may take to support: the administrator looks it up and sees the whole chain (below). It is a label, not a credential — the platform never decides access with it, and an id the app sends is only format-checked (8–64 of A-Z a-z 0-9 _ -).
Names, so one thing is never two: correlationId on a VizumError is the value stored as request_id on the server's rows (and the request_id filter of Vizum.audit.search). A job run's id is its *operation id* and plays the same role for the work the run does.
What Vizum.audit.search returns depends on who asks. Every line the app logged (Vizum.audit.log) and every ai:, action: or job: line reaches every user of the app. The platform's reserved lines are not the app's diary: an identity. or platform: line (a step-up confirmation, an offline replay, a resource event) is about one user, and a member of the app reads only their own; trust: and policy: lines are about the installation. The app's managers — a Settings administrator, or a manager of the app's approved team (native.team.managers) — read every line. A member tracing their own call by request_id finds all of its lines.
Support: from a correlation id to the rows. An administrator calls vizum.app.correlation_lookup(<id>) (JSON-RPC, or env["vizum.app"] in an Odoo shell). It returns {request_id, rows, truncated}: every row that names the id, oldest first, across webhook deliveries, audit rows, idempotency claims, backend tasks, bulk jobs, AI charges and resource events, each with its model (the ledger), id, app, user, at and a few fields of its own (a task's state and last_error, a delivery's status, a bulk job's or resource event's target_model, …). A resource event row counts many occurrences and keeps the latest id; each occurrence that came from a call is also an audit row (platform:rgov.<kind>) under its own id. Administrators only; it only reads; at most 50 rows per ledger (truncated says when there were more). The server log names the same id on a failed task attempt, a failed bulk job and a failed job run ([request_id=…]).
Whether a call may be repeated depends on what it does. The rule is explicit per operation:
| Operation | Retry it blindly? | With {idempotencyKey} |
|---|---|---|
Reads (orm.search/read/readGroup, data.get, meta.*, …) | yes — nothing changes | not needed |
orm.write | applies the same values again (last writer wins); use {expect} (5.43) to refuse a stale write | not needed |
orm.create | no — a second record | created at most once; the retry gets the first ids |
actions.run | no — the action runs twice | runs at most once; the retry gets the first result |
orm.call / orm.callOn (a call: method) | no — the method runs twice | with min_platform 5.109: runs at most once; the retry gets the first result (below: one key across the call and its declared action) |
backend.enqueue | no — a second task | one task per key |
offline.run | replays are keyed automatically (the queued op's id), so a reconnect, a second tab or a lost answer never runs it twice | the app's key becomes the op's id |
net.fetch, ai.*, oauth.fetch | no — external side effects and cost | not offered: make the remote call idempotent on the remote side |
A key is 8-128 characters of A-Z a-z 0-9 _ . : - (crypto.randomUUID() works), scoped to the app and the user, and lives 8 days; a key an offline replay claimed lives 30 days, because a queue waits as long as it must (an update awaiting approval, an expired session). Only a success is remembered: a call that failed did not happen, and a retry with the same key runs it. A key reused for different arguments rejects with IDEMPOTENCY_MISMATCH instead of returning another operation's result. VizumError.retryable says when repeating the same call can help (lost connection, timeout, rate limit); repeat a write that is not idempotent only with its key.
One key across the online call and its declared action (5.109, feature idempotency-across-paths). An app that sends something online with orm.callOn(model, ids, method, args, {idempotencyKey}) and, when the answer is lost, queues the same thing as the declared action whose target is that method (offline.run(action, {ids, ...params}, {idempotencyKey}), or actions.run) sends ONE operation: the second path answers the stored result in its own shape ({ok, result} for an action, the bare result for a call) and runs nothing. They match on the model, the method, the records and the arguments bound to their names by the method's own signature ([payload] and {payload} are the same call of def m(self, payload=None)); a method that takes *args or **kwargs, or another app's method, never matches across paths. Before answering, the second path runs its own checks except the one that needs doing it again: the grant (call:), the action's approval and groups, the online path's consent (the «App action» answer, unless it is a declared read call or a method of the app's own model), the grant's domain, and READ on the records — a user who lost WRITE on a record still gets the answer of what they already did; one who lost READ gets the refusal. The same key with other records or arguments is IDEMPOTENCY_MISMATCH.
const key = crypto.randomUUID(); // one key per business operation
async function save() {
try { return await Vizum.orm.create("x_vzm_order", vals, { idempotencyKey: key }); }
catch (e) { if (e.retryable) { return save(); } throw e; } // safe: same key
}
Permissions
Every grant a manifest can hold, from the same registry the installer's risk tier is checked against (tools/store_risk_tier_matches_contract_test.py). Privileged grants make the app wait for an administrator at install, and the store for a human reviewer; an update that ADDS one is re-approved. *Derived* grants are not written by the author: the validator builds them from another manifest section.
| Permission | Shape | Privileged | What it grants |
|---|---|---|---|
data | "user" | "global" | when global | Where Vizum.data / collections live: per user, or shared by every user of the app. |
schema | {models, create_models} | yes | Legacy native schema declaration. |
logic | "declarative" | "script" | when script | Server-side code: native.script hooks, jobs that run scripts, V.* bridge. |
orm | [{model, ops, fields?, write_fields?, domain?, chatter_only?, group_ops?, read_calls?}] | yes | Odoo models the app reads and writes, per model: operations, field ceilings, a domain. A create also has to satisfy the domain. group_ops: [{groups, ops}] (5.78) adds ops for the members of those groups only (@name of the app's own native.groups, <app-key>/@name of an app in requires, or a real xmlid; never an administrative group); an op sits in ops or in group_ops, not both, and min_platform is 5.78 or later. Closed models: vizum.* takes only an allowlist (the store's own vizum.licence* / vizum.store.* belong to com.vizumapps.hq); ir.actions.*, base.automation, ir.server.object.lines, ir.default and ir.filters are read-only; ir.cron takes scheduling writes only. The models whose rows are code are scoped to the app: ir.ui.view keys and ir.actions.report names must start with vzm_<the app id as a slug>. (every character of the id outside a-z0-9 becomes _: com.thelounge.lounge is vzm_com_thelounge_lounge.) and the platform records each one the app creates as the app's own, so only those can be changed; their QWeb expressions and static t-call targets are vetted on every write; an inherit_id must be the app's own view or an Odoo report template; a report accepts a short field list (no attachment, no groups); mail.template writes touch only the app's own templates with vetted expressions, a model the app can read, and no attachments, reports or layout. None of them is created, edited or linked through another record's x2many. Only call:get_combined_arch (view), call:send_mail (template) and call:report_action (report) run on them. ir.model.data, website.page and website.controller.page are read-only. A plain <style> (no url(), @import or other fetching construct) is allowed only in the app's own QWeb report templates. A copy of an Odoo report keeps that report's own expressions only when they are byte-identical to ones in the standard view or report the app read through the bridge in the last hour (call:get_combined_arch, or read of an arch field): read the standard document, then write the copy; a copy that redefines, with t-set or t-foreach/t-as, a name such an expression uses is refused. read_calls: [method] (5.105, native models x_vzm_* only) names the call: methods of the grant that only read: the platform runs the app-defined ones read-only (READ_CALL_WROTE when one writes) and the desktop does not ask the user about them; a new name is a widening the administrator approves |
ui | [op] | no | Host UI operations beyond the always-allowed confirm/openHtml/openDocument/exitFullscreen (fullscreen, 5.85, needs its entry). |
events | {emit: [name], listen: [name]} | no | Browser-local app-to-app events (see Event contracts). |
net | [host | *.wildcard | $setting:key | "*"] | yes | Hosts the server-side proxy may reach (SSRF-pinned). "*" (5.74) is any PUBLIC website and is its own approval line ("send requests to, and download from, any public website"; data the app can read may be included): GET/HEAD only, ports 80/443, a short header allowlist, public addresses only whatever net_allow_private says, never a $secret, a default daily cap, and the manifest needs min_platform 5.74. A $setting:key host is set only by an administrator typing it in the host settings dialog (settings.open): its manifest default is shown as a placeholder, never prefilled or stored, and the dialog warns that the app can send credentials to it. The app's own settings.set cannot change it, and api_settings / api_settings_admin_set are not valid orm call: targets. Approving a permissions.net that newly names $setting:key clears any value stored under that key while it was an ordinary setting, so the administrator must enter it. |
apps | [app id] | no | Apps this app may open with an intent. |
reports | [report name | "<prefix>.*" | "*"] | when * | Odoo reports the app may render. |
media | ["camera" | "microphone"] | no | Host-mediated camera / microphone (the browser still prompts the user). The camera also covers Vizum.ar: recognising barcodes and QR codes and drawing information over them; the video stays on the device. |
membership | boolean | no | native.membership: the app's declared method is told which users gained or lost the app's own groups (SDK 5.127). Grants nothing by itself: the subscription lives in native, which is reviewed. |
devices | ["print" | "scan" | "location" | "serial" | "bluetooth"] | yes | Host-mediated devices. |
secrets | [name] | no | Vault secret NAMES the app may ask for (values never reach the iframe; configure is admin-only). |
oauth | [provider] | yes | OAuth providers; tokens stay server-side. |
oauth_shared | boolean | no | Shared (company) OAuth connections usable by every user and by jobs. Needs oauth. |
oauth_paths | {provider: [path prefix]} | no | Narrows each provider to the API paths the app calls. Needs oauth. |
ai | boolean | [model id] | yes | The managed / BYOK AI gateway (metered). |
ai_image | boolean | no | Image generation, metered per image. Needs ai. |
ai_admin | boolean | yes | Instance-mutating AI store ops (BYOK key, billing, instance settings, RAG migration). Needs ai. |
store_account | boolean | yes | Links/unlinks the instance-wide Vizum store account. |
vector | "user" | "global" | when global | The server-side vector store, per user or shared. |
rag | boolean | yes | Server-side RAG indexing jobs (Knowledge). |
assistant | boolean | yes | Runs the AI Assistant agent as the real user. |
ocr | boolean | yes | Managed OCR (metered). |
audio | boolean | yes | Managed transcription (metered). |
editable | boolean | yes | AI live-document control between editor apps and the assistant. |
audit | boolean | yes | The platform's old→new change audit on the app's declared models. |
cutover | boolean | yes | Licence cutover (adopt / release), admin-only. |
sessions | [op] | yes | Native session revoke / login-alert policy, admin-only. |
db_cross_read | boolean | yes | Read other apps' shared Vizum.db rows the user can already see. |
files_public | boolean | yes | Mint public https links to the app's own files. |
instances_api | boolean | yes | Vizum Instances pass (store-signed identity for an external service). |
team | boolean | yes | Vizum.team: managers create and edit the users who play the app's roles. |
team_apps | [app id] | yes | Same-vendor apps a team role may be let into. Needs team. |
team_adopt | "dedicated" | yes | Take over the dedicated accounts that already play the app's roles. Needs team. |
adopt | [model] | yes | Copy the rows of a replaced module's models into the app's own (native.adopt). |
private_calls | ["<model>._<method>"] | no | Private Odoo methods the app's SERVER code calls. Needs logic. Only reachable from server scripts, which need logic: "script" (privileged). |
params | [ir.config_parameter key] | no | System parameters the app's SERVER code reads and writes. Needs logic. Only reachable from server scripts, which need logic: "script" (privileged). |
peer_data | ["<app id>:<key prefix>"] | no | Another app's GLOBAL data rows, read-only, from SERVER code. Needs logic. Only reachable from server scripts, which need logic: "script" (privileged). |
actions | [name] | yes | The approved action catalogue. *Derived from actions.* |
tables | [name] | yes | The app's Vizum.db tables. *Derived from tables.* |
workflows | [name] | yes | The app's workflows. *Derived from workflows.* |
webhooks | [name] | no | The app's inbound webhooks (they need data: "global", which is privileged). *Derived from webhooks.* |
native_bindings | [name] | yes | Action-menu bindings on native models. *Derived from native.bindings.* |
routes | [descriptor] | yes | Portal / internal / public pages. *Derived from native.routes.* |
public_routes | [descriptor] | yes | The public (anonymous) pages among them. *Derived from native.routes.* |
public_route_menus | [descriptor] | no | Website menu entries of public pages. *Derived from native.routes.* |
methods | ["<model>.<method>"] | yes | Native methods and overrides the app declares. *Derived from native.methods.* |
public_methods | ["<model>.<method>"] | no | Methods opened to every signed-in user. *Derived from native.methods.* |
callable_methods | ["<model>.<method>><app id>"] | no | Private methods the app opens to named apps (callable_by). Widening re-approves. *Derived from native.methods[].callable_by.* |
native_acl | sha256 | no | Fingerprint of the native ACLs / rules / groups: any change is re-approved. *Derived from native.* |
device_routes | [route name] | yes | The app's auth "device" routes: paired screens and kiosks reach these, with no user session. *Derived from native.routes.* |
device_broker | boolean | yes | Vizum.deviceBroker: claim and release Vizum devices on this instance's behalf (direct ESP32 sensors). Administrators only; the app must be installed with install_scope admin and declare min_platform 5.129. |
file_limits | {max_mb, count, total_mb} | yes | Raise Vizum.files above 64 MB a file / 50 files / 128 MB (ceilings 200 MB, 5000 files, 16 GB). A file over 32 MB goes through Vizum.files.putBlob (in parts of 32 MB). max_mb above 200 (up to 512, from 5.71) is its own administrator approval (permissions.large_files, derived) and needs an explicit storage cap set for the app. |
offline | {datasets: [{name, model, fields, domain?, order?, limit?}], writes?: [{model, ops, fields, expect?}], fallback?: boolean, max_mb?: number} | yes | The offline data layer (5.80): up to 12 named datasets (an orm grant that reads the model; fields inside its read ceiling; domain placeholders {uid}, {company_ids}, {today}, {today-N}, {today+N}; limit at most 20000; names ^[a-z][a-z0-9_]{1,40}$), the creates and writes that may be queued offline (ops inside the grant's ops, fields inside its write ceiling, expect names the computed fields the server compares on replay), fallback (Vizum.orm reads answer from the local copy when offline) and max_mb (at most 100). Part of the approved snapshot: any edit asks for approval again. min_platform is 5.80 or later. *Derived from offline.* |
large_files | {max_mb, total_mb} | yes | Derived from permissions.file_limits.max_mb above 200: the approval card reads "files up to X MB, Y GB per user". Without that approval the effective limit stays 200 MB. *Derived from permissions.file_limits.* |
feeds | boolean | yes | Vizum.feeds: administrators publish datasets of the app's ORM grant to an external BI client with a key of their own (needs permissions.orm). |
server_metrics | boolean | yes | Vizum.server: read the server's own metrics (CPU, memory, workers, disk I/O, storage, HTTP, database) over time. Administrators only; the app must be installed with install_scope admin and declare min_platform 5.96. |
link_routes | [route name] | yes | The app's auth "link" routes: anyone holding a guest link the app issued reaches these, with no account. *Derived from native.routes.* |
schedule | boolean | no | Vizum.schedule: timed reminders stored on the server that fire as the user's notifications of this app (a Vizum Desktop tab open rings, also with the app's window closed; the browser closed rings nothing). Only the current user's own, 50 per app and user. Not privileged: it reaches no data of anyone else. The app must declare min_platform 5.128. |
providers | ["hvac_cloud"] | yes | V.net.provider (server-side scripts only): the cloud providers the app may sign in to through the platform's adapter. Only "hvac_cloud" (an HVAC cloud provider) today. The sign-in, the session and the signed protocol stay on the server; the script never holds a key. Each HTTP request counts as one network call. From the capability of feature net-provider-supplied-hosts the platform supplies the provider's own hosts (an older platform needs them in permissions.net). No install floor: an older platform ignores the key, so detect the adapter with has_attr(V.net, "provider") . |
geocode | boolean | yes | Vizum.map.geocode / V.map.geocode: the app's address queries are sent, by the platform server, to the geocoding provider the administrator configured (rate-limited per app, cached, audited with the query as sent). Privileged: the queries leave the instance. The app must declare min_platform 5.143. |
sensors | ["motion"] | no | Device sensors relayed by the host: "motion" = tilt and movement while the app is open. Readings are coarsely quantised and stay on this device. Risk tier: the same as media camera (not privileged); an update that adds it still asks again. |
Host features
Vizum.capabilities().features lists what the host the app runs in serves. Detect a feature before relying on it; not every host serves every feature.
| Feature | What | Since | Desktop | Odoo action | Solo | ||
|---|---|---|---|---|---|---|---|
3d-kit | vizum-3d-kit runtime | 4.2 | ✓ | ✓ | ✓ | ||
actions | Vizum.actions | — | ✓ | ✓ | ✓ | ||
activity | Vizum.activity | — | ✓ | ✓ | ✓ | ||
ai | Vizum.ai | — | ✓ | ✓ | ✓ | ||
ai-tools | manifest ai_tools + Vizum.ai.tools | 5.3 | ✓ | ✓ | ✓ | ||
ai-tool-contracts | manifest ai_tools risk / side_effects / confirm / idempotent: validated at install, told to the assistant, and update/destructive tools confirmed with the user by every host before the app receives them | 5.67 | ✓ | ✓ | ✓ | ||
app-budgets | per-app budgets set by an administrator: AI tokens over 30 days (hard, reserved), Vizum.net.fetch calls per day, Vizum.files storage across users; past one a call rejects QUOTA_EXCEEDED | 5.67 | ✓ | ✓ | ✓ | ||
audio | Vizum.audio | 5.5 | ✓ | ✓ | ✓ | ||
audit | Vizum.audit | — | ✓ | ✓ | ✓ | ||
backend | Vizum.backend | — | ✓ | ✓ | ✓ | ||
badge | ui.setBadge | — | ✓ | ✓ | ✓ | ||
callOn | orm.callOn | — | ✓ | ✓ | ✓ | ||
charts | vizum-charts runtime | 4.2 | ✓ | ✓ | ✓ | ||
collections | Vizum.data.collection (named JSON collections with rev) | — | ✓ | ✓ | ✓ | ||
composition | requires with version ranges and contracts, optional dependencies, Vizum.apps.status(), uninstall protection for required apps | 5.67 | ✓ | ✓ | ✓ | ||
confirm | ui.confirm | — | ✓ | ✓ | ✓ | ||
contract | Vizum.platform / Vizum.sdk / Vizum.features() / Vizum.supports() | 5.67 | ✓ | ✓ | ✓ | ||
db | Vizum.db | — | ✓ | ✓ | ✓ | ||
devices | Vizum.devices | — | ✓ | ✓ | ✓ | ||
docs | Vizum.docs raw-document store | 5.2 | ✓ | ✓ | ✓ | ||
editable | Vizum.editable (AI live-document control) | 5.19 | ✓ | — | — | ||
errors | Vizum.errors | — | ✓ | ✓ | ✓ | ||
event-contracts | manifest event_contracts (versioned payload schemas) enforced on emit, meta {producer, version} to listeners, Vizum.events.catalog() | 5.67 | ✓ | — | — | ||
file-integrity | Vizum.files: sha256 recorded and returned, put({sha256}) refuses damaged bytes, the content must match the name's type, publicUrl links expire (expires_in, default one day) | 5.67 | ✓ | ✓ | ✓ | ||
files | Vizum.files (app-scoped binary storage) | — | ✓ | ✓ | ✓ | ||
format | Vizum.format helpers | — | ✓ | ✓ | ✓ | ||
geocode | Vizum.map.geocode(query, {limit, lang, country}) and V.map.geocode(query, limit, lang, country): the PLATFORM server resolves an address with the provider the administrator chose in Settings › Vizum › Geocoding (none by default: GEOCODE_NOT_CONFIGURED; a Nominatim base URL; or a key + URL template, the key kept encrypted). Never a browser network call. Per-app rate limit (60 a minute, 2,000 a day by default), a cache shared by the instance, every answer audited with the query as sent (vizum.geocode.audit). Query 2-200 characters; at most 10 results {lat, lon, display_name, category, type, importance}. Needs permissions.geocode and min_platform 5.143 | 5.143 | ✓ | ✓ | ✓ | ||
health | Vizum.apps.health(): healthy/degraded/failing from approval state, dependencies, runtime errors and failed tasks; administrators get api_health and api_update_preflight | 5.67 | ✓ | ✓ | ✓ | ||
idempotency | an idempotency key on orm.create, actions.run, backend.enqueue and offline.run (opts.idempotencyKey, or the SDK makes one): a retry or an offline replay runs once — IDEMPOTENCY_IN_PROGRESS / IDEMPOTENCY_MISMATCH | 5.67 | ✓ | ✓ | ✓ | ||
i18n | i18n/<lang>.json + Vizum._t | — | ✓ | ✓ | ✓ | ||
import | Vizum.import | — | ✓ | ✓ | ✓ | ||
instances | Vizum.instances.pass | 5.38 | ✓ | — | — | ||
intents | launch intents (Vizum.intent, apps.open payloads) | — | ✓ | ✓ | ✓ | ||
jobs | Vizum.jobs | — | ✓ | ✓ | ✓ | ||
license | Vizum.license | — | ✓ | ✓ | ✓ | ||
lifecycle | onVisibility/onPause/onResume/onClose | 5.7 | ✓ | ✓ | ✓ | ||
mail | Vizum.mail | — | ✓ | ✓ | ✓ | ||
media | host-mediated camera/microphone | — | ✓ | ✓ | ✓ | ||
meta | Vizum.meta | — | ✓ | ✓ | ✓ | ||
net | Vizum.net.fetch (server proxy) | — | ✓ | ✓ | ✓ | ||
net-any | permissions.net "*": any public website, download-only (privileged) | 5.74 | ✓ | ✓ | ✓ | ||
net-image | Vizum.net.fetchImage / V.net.fetch_image: one image, redirects re-checked, byte cap, PNG/JPEG/GIF/WEBP by magic number | 5.74 | ✓ | ✓ | ✓ | ||
net-provider | V.net.provider('hvac_cloud', op, args): server-side sign-in to an HVAC cloud provider, session kept in the vault (server-side scripts only; no host advertises it) | 5.138 | — | — | — | ||
net-provider-thermostat-json | V.net.provider('hvac_cloud', 'thermostat_query' | 'thermostat_control', args): a cloud thermostat's status read + setpoint control, transcoded through a second provider session and delivered by the provider's existing device channel (server-side scripts only; no host advertises it) | 5.145 | — | — | — | |
net-provider-thermostat-json-sensors | thermostat_query ALSO returns the live sensors (current_F, humidity), a fault flag, and sensors_at (the epoch of the last successful reading, for an 'as of hh:mm' label) of the cloud thermostat, read over the provider's existing device channel; the fields gate on this capability so a vApp tells 'the platform does not know the field' (absent) from 'the sensor read failed' (present, null). current_F/humidity carry the last known value only while recent; fault is always a fresh read or null. (server-side scripts only; no host advertises it) | 5.147 | — | — | — | ||
net-provider-supplied-hosts | a declared provider brings its own pinned hosts: the platform reaches the provider's fixed host list itself and never reads the app's permissions.net for it, so the app lists no provider host; an app that still lists them keeps working, and declaring a provider grants no V.net.fetch to those hosts. An app that omits the hosts sets min_platform to this capability (5.148) (server-side scripts only; no host advertises it) | 5.148 | — | — | — | ||
net-provider-thermostat-json-offline | a thermostat op whose cloud reply says the unit is offline or did not answer resolves to the UNIT_OFFLINE result (a normal state, logged at INFO) instead of CLOUD_ERROR; every other cloud error keeps CLOUD_ERROR. A vApp can treat UNIT_OFFLINE as 'not reachable now' rather than a cloud fault. Below this capability the result stays CLOUD_ERROR. (server-side scripts only; no host advertises it) | 5.150 | — | — | — | ||
script-env-flush-all | env.flush_all() in a script (native.methods, native.script hooks): writes out what the call's own transaction already changed, as in a module, so SQL that follows sees it. It takes no argument, returns None, reads no data back and opens no model, field or user. A vApp whose code calls it sets min_platform 5.152: before it the call raises AttributeError (server-side scripts only; no host advertises it) | 5.152 | — | — | — | ||
notifications | Vizum.notifications | — | ✓ | ✓ | ✓ | ||
oauth | Vizum.oauth | — | ✓ | ✓ | ✓ | ||
oauth-related-meta | One signed Facebook consent can materialise Facebook Page and linked Instagram Business connections. Facebook and Instagram must use the same Meta app ID (refused otherwise); the consent requests the union of both providers' scopes (comma or space separated); a consent that connects no Instagram account is reported as a failed connection naming Instagram (the callback page, and connect() rejects), and the Facebook connections are kept. | 5.119 | ✓ | ✓ | ✓ | ||
oauth-configure-confirmed | oauth.configure changes the provider configuration that every app and every connected account of the database share, so the desktop host shows an administrator what the change does (from the server's preview: client ID, whether the secret is set or replaced, scopes added and removed, endpoints moved, connected accounts, other apps using the provider; never the secret) and stores it only once confirmed, and only the change that was shown (CONFLICT if the configuration changed meanwhile). Cancelled: USER_CANCELLED; nothing to change: resolves unchanged without asking. Whoever confirms, the server refuses a new client_id or a removed scope while connected accounts hold tokens, and any move of a stored custom provider's endpoints (VALIDATION_FAILED); a new client secret is allowed and keeps every connection. The dialog keeps the step-up dialog's rules: one host dialog at a time and only over the app's active window of a visible, unlocked desktop (else RATE_LIMITED / PERMISSION_DENIED, no dialog), full screen ends before it renders, the tab's per-app limit applies (10 s after a cancel, 6 per 10 minutes: RATE_LIMITED with details.retryAfter), and a lock, minimise or close of the window closes it (USER_CANCELLED). Each change and refusal is audited (oauth:configure) without the secret. The bridge op no longer writes on its own, and Solo and native hosts reject configure with UNSUPPORTED_CALL. | 5.156 | ✓ | — | — | ||
ocr | Vizum.ocr | 5.4 | ✓ | ✓ | ✓ | ||
offline | Vizum.offline outbox | — | ✓ | ✓ | ✓ | ||
openList | ui.openList | — | ✓ | ✓ | ✓ | ||
openRecord | ui.openRecord | — | ✓ | ✓ | ✓ | ||
palette | ui.addPaletteCommand | — | ✓ | ✓ | ✓ | ||
panels | window.openPanel | — | ✓ | ✓ | ✓ | ||
permissions | Vizum.permissions | — | ✓ | ✓ | ✓ | ||
pgvector | Mode-3 pgvector setup (vector.pgv*) | 5.2 | ✓ | ✓ | ✓ | ||
read_group | orm.readGroup | — | ✓ | ✓ | ✓ | ||
realtime | Vizum.realtime (live receive needs the desktop bus) | — | ✓ | — | — | ||
reports | Vizum.reports | — | ✓ | ✓ | ✓ | ||
roles | Vizum.roles | — | ✓ | ✓ | ✓ | ||
runtime | self-hosted no-build runtime (/vizum/runtime/*) | 4.2 | ✓ | ✓ | ✓ | ||
schedule | Vizum.schedule — set({id, at | repeat + tz, title, body, payload}), clear(id), list(), missed() and ack(ids): a reminder kept on the server per app and user, fired every minute by a cron as a notification of the app (inbox row, bus ping, click action {app, payload}). repeat is an RRULE subset (FREQ=DAILY | WEEKLY, BYDAY, BYHOUR, BYMINUTE) or {days, time}, with an IANA tz: the next fire is computed in UTC from the zone, DST-correct. missed() returns the occurrences of the last 24 h the app has not acknowledged (at most 20). 50 schedules per app and user, payload 4 KB, 120 set calls an hour. Delivery: a Vizum Desktop tab open; the browser fully closed rings nothing. Needs permissions.schedule and min_platform 5.128 | 5.128 | ✓ | ✓ | — |
scope | Vizum.scope() | 4.1 | ✓ | ✓ | ✓ | ||
secrets | Vizum.secrets | — | ✓ | ✓ | ✓ | ||
session | Vizum.session (version-safe identity, company, currency) | — | ✓ | ✓ | ✓ | ||
settings | Vizum.settings | — | ✓ | ✓ | ✓ | ||
task-retry | manifest actions[].retry {max, backoff_seconds} and timeout_minutes: a backend task retries a retryable failure with doubling backoff, and has a deadline (progress() answers cancel_requested; the reaper fails a runaway) | 5.67 | ✓ | ✓ | ✓ | ||
telemetry | Vizum.telemetry | — | ✓ | ✓ | ✓ | ||
uikit | vizum-ui.js kit | — | ✓ | ✓ | ✓ | ||
user | Vizum.user (version-safe identity/groups) | 4.1 | ✓ | ✓ | ✓ | ||
vector | Vizum.vector | — | ✓ | ✓ | ✓ | ||
version | Vizum.session.odoo_version | 4.1 | ✓ | ✓ | ✓ | ||
watch | orm.watch / orm.live / Vizum.refresh (live re-read) | — | ✓ | — | ✓ | ||
webhook-deliveries | inbound webhook ledger: delivery-id dedupe, timestamp window, statuses, ack/fail, dead letter and retry | 5.67 | ✓ | ✓ | ✓ | ||
webhooks | Vizum.webhooks | — | ✓ | ✓ | ✓ | ||
workflow | Vizum.workflow | — | ✓ | ✓ | ✓ | ||
update-code-approval | an update that changes server code returns the app to approval; api_update_preflight names permission and code changes item by item | 5.69 | ✓ | ✓ | ✓ | ||
task-status | Vizum.backend task rows carry status: retrying / expired / dead_letter | 5.69 | ✓ | ✓ | ✓ | ||
contract-versions | provides name@x.y.z and requires {contract, version} | 5.69 | ✓ | ✓ | ✓ | ||
caller-app | caller_app() in a method called through a call: grant: the calling app's key, verified by the platform | 5.69 | ✓ | ✓ | ✓ | ||
device-routes | Vizum.device — a paired-device page | 5.70 | — | — | — | ||
paired-devices | Vizum.pairedDevices — pair, list and revoke an app's devices | 5.70 | ✓ | ✓ | ✓ | ||
access-bridge-field-visibility | Access Manager 5c — a field a restriction profile hides (hide) or makes unreadable (restrict_read) is hidden from every vApp the member runs: left out of reads, refused in domains, orders and groupings; Vizum.meta.fieldAccess(model) says which (sdk-next/docs/server-side.md) | 5.130 | ✓ | ✓ | ✓ | ||
access-field-hide | Access Manager 5d — the field mode hide (vizum.access.field) is offered: a field a restriction profile hides is hidden on the member's screens, in exports, in every vApp (5c) and on the platform's own read paths. native.methods and the script library raise an AccessError naming the field when a method reads it by attribute (read / search_read leave it out; a domain, dotted path, groupby or aggregate on it is refused; the real user decides, also inside the app's sudo()); the AI Assistant leaves it out of what it reads and describes and refuses a search or grouping on it; a dashboard shared by its owner hides a field hidden from the owner or the viewer; the knowledge index never indexes a field any active profile hides or makes unreadable on that model and reindexes when that set changes. vzm check warns METHOD_READS_FOREIGN_FIELD where a method reads a field of an Odoo model by attribute. Odoo's own RPC is unchanged: hide stays a screen rule there. | 5.133 | ✓ | ✓ | ✓ | ||
device-broker | Vizum.deviceBroker / V.device_broker — claim, rearm, unlink, list, rename and link direct ESP32 devices at the Vizum store's broker on this instance's behalf (sdk-next/docs/device-broker.md) | 5.129 | ✓ | ✓ | — | ||
device-enrol | A device route's enrol: {hook}: POST /vizum/device/<slug>/_enrol turns the one-time token the broker gave a device into a paired-device token; an app with auth "device" json routes and no device page serves them (sdk-next/docs/device-routes.md) | 5.129 | ✓ | ✓ | — | ||
large-files | Vizum.files above 200 MB: chunked putBlob, streamed getBlob/getRange, permissions.file_limits.max_mb up to 512 | 5.71 | ✓ | ✓ | — | ||
upload-resume | A chunked putBlob survives a dropped connection: same token, the part sent again with backoff, status then next_part, an explicit abort when it gives up | 5.72 | ✓ | ✓ | — | ||
native-i18n | Native strings from the catalogue: the same i18n/<lang>.json also translates what the app puts in Odoo's backend — field labels and help, selection options, model labels, groups, menus, window/server actions, view arch terms and the translatable values of native.records. A key is the English text or an optional "@native:<ref>" context key; written at install, update, approval and language install, from the stored en_US; an administrator's own translation is kept. Keys do nothing on an older platform, so no min_platform is needed | 5.84 | ✓ | ✓ | ✓ | ||
native-access | native.access: one of the app's own groups on an Odoo model the app does not own, what a module's ir.model.access row on a shared model does. [{model, group, ops, reason?}]: group "@name" (native.groups) only, ops a non-empty subset of read/write/create/unlink, within the app's plain permissions.orm grant on that model (no fields, write_fields, domain or chatter_only) and each narrowed by a native.rules entry of that same group (domain [] for every record), a reason required for write/create/unlink (null counts as absent), one entry per (model, group), never a model of native_access_policy. Up to Odoo 19 an ir.model.access row per entry, narrowed by those rules; on Odoo 20 ir.access permissions that carry their domains, never one without: the same rows on every line, for a member of other groups too. A new, wider or edited grant makes the update need approval, and api_update_preflight lists the grants (permissions.detail.native_access). Needs min_platform 5.101 | 5.101 | ✓ | ✓ | ✓ | ||
native-view-page | A native form page of the app's own: a form entry of native.views may carry page {name, label, fields, after_page?, groups?}, and the listed native.fields of that model go on the app's own tab of the form's notebook (after the page named by after_page when the form has it, else last; restricted to an app group with groups "@name"). The label is plain text and a msgid of the app's catalogue. One page per form; a field is on the page or placed, not both. A form without a notebook gets the fields after the last field of its main group. Uninstall drops the page with the app's view; an update rebuilds it (one view, one page). Needs min_platform 5.91: the install gate and vzm check both refuse a page below it. The page is shown in the Odoo web client's form; a vApp page on any host only sees the fields | 5.91 | ✓ | ✓ | ✓ | ||
native-view-strict | native.views report and strict mode: api_install and api_update_preflight return native_views_skipped (the placements that were skipped: model, view, kind, field, anchor, position, reason, strict) next to every key they always had; a native.views entry with "strict": true refuses the install or update, before anything is written, when one of its placements (or its page) would be skipped, naming model / view type / field / anchor, and a failed strict update leaves the previous version installed. A page on a form with no notebook is not a skip. Needs min_platform 5.93 for strict: true | 5.93 | ✓ | ✓ | ✓ | ||
native-view-pages | Several native form pages of one app: a native.views form entry may carry pages [{name, label, fields, after_page?, groups?}, ...] (at most 4 pages, 30 fields each, 120 in all; page stays valid as a one-item alias). All the pages go in the same inherited view, in order; after_page may name an earlier page of the same app; a page restricted to a missing group is skipped alone (never shown unrestricted); a form with no notebook gets every page's fields after the main group's last field, in page order. native_views_skipped and strict report and refuse per page. Needs min_platform 5.94 | 5.94 | ✓ | ✓ | ✓ | ||
server-i18n | Server-side translations: the bundle's i18n/<lang>.json also answers tr() in the app's model methods (language from context.lang, es_419 -> es -> msgid) | 5.73 | ✓ | ✓ | ✓ | ||
script-sha256 | V.sha256(data, encoding=None) in script hooks, jobs and native methods: the SHA-256 hex of a str (UTF-8), bytes or base64 str; at most 32 MiB a call and 256 MiB / 2000 calls a run. An integrity digest, not a signature | 5.75 | ✓ | ✓ | ✓ | ||
script-tr | V.tr(source, *args, lang=None, **kwargs) in script hooks (trigger guards included), jobs and native methods: the method-side tr() on the app's own i18n catalogue, in the language of the call (a hook: the user whose write fired it; a job: its job user) or of an installed lang=; a missing or malformed translation gives the source | 5.77 | ✓ | ✓ | ✓ | ||
orm-group-ops | group_ops on a permissions.orm grant: [{groups: [refs], ops: [...]}] gives extra ops (create, write, unlink, call:<method>) only to the calling user who is in at least one of the groups, so a UI app can leave ops read-only for everyone and give the owner app's administrators the writes. Solo mints its access group from ops only, never from group_ops | 5.78 | ✓ | ✓ | ✓ | ||
orm-create-fields | permissions.orm create_fields: the field ceiling of a create (and of a copy's defaults), apart from write_fields. | 5.121 | ✓ | ✓ | ✓ | ||
orm-empty-ops | A permissions.orm grant may have empty plain ops when group_ops carries every op, so a model closed to all but one group needs no placeholder op (Solo mints a native read ACL from any plain call:). | 5.121 | ✓ | ✓ | ✓ | ||
orm-strict-paths | permissions.orm strict_paths: every field reference (domain paths, read_group groupby and aggregates, order, having, name_search) must name fields the grant lists on each model of the path; only id is exempt. Hops are scoped by the comodel grant domain (rewritten to any); a groupby/order crossing a model whose grant has a domain is refused; name_search needs the model rec_name and its display dependencies granted; a many2one is returned as [id, false] unless the comodel name is granted and the record satisfies the comodel grant domain. | 5.121 | ✓ | ✓ | ✓ | ||
offline-data | permissions.offline {datasets, writes, fallback, max_mb}: named, approved slices of the user's own Odoo data kept on the device and read with Vizum.offline.query / readGroup with no network, and the creates and writes that may be queued meanwhile. Sync runs as the user on the server (grant, field ceiling and record rules apply); a queued write carries the values the user saw and a differing server value answers CONFLICT; a replay for another user answers OFFLINE_USER_MISMATCH. The rows are removed on logout or a change of user, the queue is not. Hosts: the desktop, the Odoo client action host and Solo, each on the one per-user layer its shell owns. | 5.80 | ✓ | ✓ | ✓ | ||
offline-deleted | A queued offline write or action on a record the server deleted meanwhile is listed by Vizum.offline.conflicts() with reason "deleted" (and canRecreate), not as a generic rejection: the server answers CONFLICT with details.deleted, the device drops the row it held, and the user discards the change or recreates the record (resolve keep_mine makes one create from what the user saw, offered when the app may create the model offline; keep_server and discard leave it deleted). | 5.86 | ✓ | ✓ | ✓ | ||
offline-versioned | Deterministic offline versioning (SDK 5.95). Every queued create and write is stamped with the app version and the field types it was made under; the replay checks it against the server as it is NOW. An op still valid applies; one naming a removed model, a removed or renamed field, a field whose type changed, a removed action, or a write the app's offline writes, grant, field ceiling or the user's rights no longer allow is refused per op with OFFLINE_STALE and a typed details.reason, and Vizum.offline.conflicts() lists it with that reason, the versions on both sides and the values the user entered (resolve accepts discard; keep the values for export). A dataset whose definition changed (fields and their types, domain, order, limit, grant) is dropped and refetched. When the installed version differs from the one the page runs, the app receives the event offline:app_updated {running, installed} and the host offers a reload. | 5.95 | ✓ | ✓ | ✓ | ||
step-up | Vizum.identity.confirm({action, reason, meaning}) in a host dialog that dims the whole desktop: the user types the account password THERE (never in the app), the host checks it on a dedicated route and hands the app a single-use token (120 s, bound to the user, app, action and session). Server code spends it with V.identity.consume(token, action), which returns a receipt (id, user, action, meaning, reason, confirmed_at). Every attempt is a row in a ledger only the platform writes. Not on Solo (UNSUPPORTED_CALL) or on a device page (PERMISSION_DENIED). | 5.79 | ✓ | ✓ | — | ||
trigger-su | The trigger payload's su (rec["su"] in a script hook): True when the write that fired the trigger ran in a superuser environment (env.su: a vApp method's sudo(), platform-internal code, uid 1), False for the user's own write. sudo() keeps the uid, so uid could not tell them apart. Computed by the platform from the write's own records, never from the app. A hook that reads it sets min_platform 5.81 | 5.81 | ✓ | ✓ | ✓ | ||
script-service-user | V.is_service_user() in script hooks, jobs and native methods: True when the code runs as the app's own service user (the passwordless job user the platform minted for it; jobs, triggers and device routes run as it), False for any human, an administrator included. Read-only and computed by the platform from the user the code runs as. An app that names it sets min_platform 5.81 | 5.81 | ✓ | ✓ | ✓ | ||
company-unique | A unique of a company_scoped native model may list x_company_id, the synthesized company column (a prefix unique per company, one settings row per company); nulls_not_distinct works with it as for any column. A unique that names it sets min_platform 5.81 | 5.81 | ✓ | ✓ | ✓ | ||
jobs-user-data | A job may run with permissions.data "user" (before 5.81 jobs required "global"). The platform writes no shared tick for it, and the job's own V.data is the private scope of the user it runs as (the app's service user): it cannot read a human user's keys. An app with jobs and data "user" sets min_platform 5.81 | 5.81 | ✓ | ✓ | ✓ | ||
device-embed-control | Vizum.device.embed handles control the player the page framed: setVolume(0-100), mute(), unmute(), play(), pause(). The page posts the YouTube IFrame API / Vimeo player.js command to that iframe only, to the provider's exact origin, for players this page created. Device pages only. An app that uses them sets min_platform 5.82 | 5.82 | — | — | — | ||
device-store | Vizum.device.store get/set/delete/all: small per-device settings (the frame has no localStorage), kept server side for the paired device and reachable only with that device's token. At most 64 keys of at most 4 KB of JSON each; dropped when the device is revoked. An app that uses it sets min_platform 5.82 | 5.82 | — | — | — | ||
vector-scope | Vizum.vector.sharedNamespaces() lists the namespaces (name, count, model, dim) a user-scoped app owns in the shared (global) scope; Vizum.vector.moveScope(ns, {from, to, user_id?, doc_id?, batch?}) moves a namespace's rows between the shared scope and a user's scope without re-embedding (the owner of the stored rows changes: nothing is embedded, nothing is metered). Administrator or the app's service user only; user to global is administrator only; batched ({moved, remaining, conflicts, done}), re-runnable, never overwrites a row the destination holds. V.vector.shared_namespaces() reads the list from server code. An app that calls V.vector.shared_namespaces sets min_platform 5.83 | 5.83 | ✓ | ✓ | ✓ | ||
ui-fullscreen | Vizum.ui.fullscreen() takes the whole screen for the app: the host paints the app's window over the viewport with the desktop chrome (dock, Odoo navbar, titlebar) hidden - always, with no user gesture - and also asks the browser for real fullscreen (browser in the result; best effort, because the click that led there happened inside the app's iframe and only some browsers carry that activation to the host). Vizum.ui.exitFullscreen() hands it back (always allowed, ends only the app's own full screen); a host button, Escape and the browser's own exit also end it, announced to Vizum.ui.onFullscreenChange(cb). The window's geometry is never touched. fullscreen is declared in permissions.ui; an app that calls it sets min_platform 5.85. Desktop and native hosts; Solo and device pages answer {active:false, supported:false} (device pages keep Vizum.device.fullscreen) | 5.85 | ✓ | ✓ | — | ||
device-receipt | Vizum.device.printReceipt(doc, opts) on a paired-device page whose route declares device.print: the app sends a structured document (lines with align/bold/size, QR, barcode, divider, feed, cut - never bytes), the host page builds the ESC/POS stream with the platform's own encoder and writes it to a printer a person paired at the device (WebUSB or Web Serial, Chromium). At most 200 lines, 120 characters a line, a 300-byte QR, 32 KB, 12 receipts a minute a device. Rejects PRINTER_NOT_PAIRED (fall back to print()) or PRINTER_UNAVAILABLE. An app that uses it sets min_platform 5.87 | 5.87 | — | — | — | ||
device-receipt-image | An image line in a Vizum.device.printReceipt document (a logo): {image: "data:image/png;base64,...", align, width, dither, threshold}. The source is a PNG or JPEG data: URL (at most 96 KB, 4 million pixels; there is no remote URL, the page fetches nothing), decoded on the device page, scaled to the paired printer's dot width (58 mm = 384, 80 mm = 576), reduced to 1 bit (Floyd-Steinberg dither, or a plain threshold) and sent as ESC/POS GS v 0 raster. At most 2 images and 800 dots (about 100 mm) tall each; more is VALIDATION_FAILED, never a hang. ESC/POS only: the platform has no CPCL output. An app that uses it sets min_platform 5.88 | 5.88 | — | — | — | ||
device-receipt-test | Vizum.device.printTest(): prints the platform's fixed test receipt (logo, printer name, paper width, time, a ruler as wide as the paper) on the paired printer, so an app can offer "Test printer" without composing a document. The same device.print grant, rate limit and errors as printReceipt. An app that uses it sets min_platform 5.88 | 5.88 | — | — | — | ||
device-print | Vizum.device.print(): the supported window.print() of a device page (the same device.print grant and rate limit, then the app prints its own view; Chrome started with --kiosk-printing prints with no dialog). An app that uses it sets min_platform 5.87 | 5.87 | — | — | — | ||
data-feed | Vizum.feeds — an administrator publishes a dataset (one model, declared columns, a domain) to an external BI client such as Power BI's refresh: GET /vizum/feed/<app>/<feed> with a per-feed key (HTTP Basic password or Bearer), keyset pages sized to the instance, a time budget, at most feed_concurrency pages at once (429 + Retry-After for the rest) | 5.89 | ✓ | ✓ | ✓ | ||
resource-governance | This platform bounds what one call may materialise: a bridge read or read_group over the instance's value ceiling (1,000,000; 500,000 on odoo.sh and small instances; an administrator's override) is refused as QUOTA_EXCEEDED with the page that fits, and data feeds are paged, time-budgeted and admitted one slot at a time | 5.89 | ✓ | ✓ | ✓ | ||
bulk-jobs | Vizum.bulk — heavy work as a queued, chunked background job on the cron worker, never on an HTTP worker: export(model, {domain, fields, format, name}) writes the rows to one of the app's files (CSV or JSON lines), rebuild(model, {domain, fields, hook, params}) hands them a chunk at a time to the app's own server hook; status/list/cancel. One job at a time per instance, first come first served; at most 3 waiting per app and 10 per instance (RATE_LIMITED beyond). Over-budget events toast the launching user and schedule one activity for the administrators (e-mail only on their opt-in) | 5.90 | ✓ | ✓ | ✓ | ||
search-limit-error | A search or search_read that asks for more than 5,000 records is refused with QUOTA_EXCEEDED (details {kind: "limit_too_large", asked, page: 5000}) instead of being cut to 5,000 rows without a word — for an app whose manifest declares min_platform 5.92 or later. An app that declares less keeps the old cut. Read more in pages: limit 5,000 with offset, an order that ends on id. The same refusal answers call:name_search with a limit above 5,000 and, on every host, a search_read served from the device's offline copy (api_offline config search_limit_error). read and read_group are bounded by the value ceiling instead (resource-governance: QUOTA_EXCEEDED without details.kind) | 5.92 | ✓ | ✓ | ✓ | ||
server-metrics | Vizum.server — an administrator's app reads the server's metrics over time: info() (hosting, scope, workers configuration, retention, which metrics this hosting exposes) and metrics({from, to, step}) (CPU, memory, workers, Odoo's disk I/O, storage, HTTP rate/latency/errors/concurrency and database series; null where a metric is unavailable). Container-scoped by default, no URLs, users or IPs, at most 1,500 points a call. Needs permissions.server_metrics, install_scope admin and min_platform 5.96 | 5.96 | ✓ | ✓ | — | ||
policy-reconcile | Policy reconciliation of installed apps: when the platform's install validator, permission classification, ORM clamp or package trust policy changes (and once a day), every installed app is judged again by the same code; a finding is reported, asks an administrator to approve again (the app keeps running), holds only the unsafe view/route/hook, or (trust) disables. Vizum.apps.health() carries a policy check for it. Nothing to declare | 5.97 | ✓ | ✓ | ✓ | ||
typed-action-result | A call: that opens a wizard is told so: for an app whose manifest declares min_platform 5.98 or later, a method that returns an Odoo action dict answers {type: "action", action_type, name, res_model, res_id, view_mode, target, context?} (plain scalars; domain and views are never copied, context only as lang and tz), a top-level recordset answers {type: "records", model, count, ids} (ids and an action's res_id only for records the app may read under its own grant), and a result that is not plain data answers ACTION_RESULT_UNSERIALIZABLE instead of a silent true. An app that declares less keeps true and the plain ids list | 5.98 | ✓ | ✓ | ✓ | ||
odoo-introspect | Odoo introspection for the Vizum IDE apps (the Vapp Creator, the Module Converter): vizum.app.api_odoo_introspect(op, models, parts, opts) through Vizum.orm.call, an administrator only. op "signature" (registry + installed modules hash, edition, whether Enterprise is licensed), "index" (every model), "models" (1-50 models: fields as the user sees them, methods with signature, decorators, the modules that define them and their origin ce/ee/custom/third_party, the bridge's verdict on each, and the model ACL) and "source" (1-20 methods of one model, Enterprise marked, credential-looking literals redacted). Code only: never a record, a default value, an ir.default or a configuration parameter; vizum.* models by name and access only. Reserved to com.vizumapps.vappcreator and com.vizumapps.moduleconverter; call:api_update_preflight with it | 5.99 | ✓ | ✓ | — | ||
preview-sourcemap | A preview build's source map: vizum.app.api_build(files, manifest, name, deps, install, changelog, typecheck, preview_sourcemap) with install false and preview_sourcemap true hands back preview_sourcemap (the v3 map of the inlined entry script, build-node paths and the token scrubbed) and preview_sourcemap_script (which inline module script it covers). Never with install true, never inside the package: the .vapp is byte-identical to a build without it. An older platform takes no 8th argument | 5.99 | ✓ | ✓ | — | ||
view-inspect | View inspection for the Vizum IDE apps (the Vapp Creator, the Module Converter): vizum.app.api_view_inspect(source, views, opts) through Vizum.orm.call, an administrator only. source {key} (an installed app) or {zip_b64} (a package not installed yet); for each native view the package touches (at most 10 per call): the combined arch without and with the app (outline or full), each spec's change, where each field lands (placed / skipped / invalid / pending), the other inheriting views and their conflicts, the primary sibling views recombined, and VIEW_* findings with the manifest path. Simulated with the installer's own code and Odoo's own view combination: nothing is written, no record row, ir.default or configuration parameter comes back. api_update_preflight also answers views_broken (a native.ui view that would abort the install). Reserved to com.vizumapps.vappcreator and com.vizumapps.moduleconverter | 5.100 | ✓ | ✓ | — | ||
ui-open-view | Vizum.ui.openView({model, view_type, view_xmlid?, res_id?, label?}) opens a real Odoo view: form (res_id for one record), list, kanban, calendar, pivot, graph or activity; view_xmlid picks one primary, active view of that model and type whose groups the user has (else NOT_FOUND); without it Odoo's default view of the type. Declared in permissions.ui ("openView"); a bad spec answers VALIDATION_FAILED. Odoo's access rules govern the data; the host never reads a record. Desktop and native hosts; Solo opens form and list in its overlays and refuses the other types (UNSUPPORTED_CALL), so it does not announce the feature | 5.100 | ✓ | ✓ | — | ||
actions-typed-result | A declared action is told what its method returned: for an app whose manifest declares min_platform 5.102 or later, Vizum.actions.run (and a backend task running the same action) answers {ok: true, result} with result in the shapes of a call: since 5.98: an Odoo action dict (the wizard a button opens, such as stock_sms's confirm.stock.sms or the form stock.picking.action_return opens) is {type: "action", action_type, name, res_model, res_id, view_mode, target, context?} (plain scalars; domain and views are never copied, context only as lang and tz), a top-level recordset is {type: "records", model, count, ids} (ids and an action's res_id only for records the app may read under its own grant), dates and decimals are normalised as in a call:, and a result that is not plain data answers ACTION_RESULT_UNSERIALIZABLE (the run is rolled back and audited as failed) instead of a silent true. A method that returns nothing still answers true. An app that declares less keeps the old answer: the value when it is plain JSON, else true | 5.102 | ✓ | ✓ | ✓ | ||
advisory-lock | advisory_lock(key, timeout_ms=None) in native methods and their library: a module's cr.execute("SELECT pg_advisory_xact_lock(...)") as one app's own transaction-scoped PostgreSQL lock on a str key (at most 256 characters), in the two-int form (-app id, first 4 bytes of sha256(key)) so it never meets another app's, the platform's or Odoo's locks; the wait is bounded (default 5000 ms, 0 = do not wait, at most 30000) and a timeout raises LockTimeout (LOCK_TIMEOUT) with the transaction still usable; the call also touches one row per (app, key) so that under Odoo's REPEATABLE READ a waiter whose snapshot predates the previous holder's commit fails to serialize and Odoo replays the request on a fresh snapshot. Released with the transaction, never with the connection | 5.103 | ✓ | ✓ | ✓ | ||
link-routes | Vizum.link — a guest-link page (native.routes auth "link"): info() (the share, the same shape as V.share), call(route, body), fileUrl(route, id), download(route, id), upload(route, file, {params, onProgress}), verify(code), close(). Server: V.links (issue, reissue, revoke, extend, close, status, list, ledger, issue_code) and V.link / V.share in a link route's hook. An app that uses them sets min_platform 5.104 | 5.104 | — | — | — | ||
read-calls | permissions.orm[].read_calls names the call: methods of a grant that only read, each a call:<name> of that grant (ops or group_ops), on a vApp native model (x_vzm_*) only, min_platform 5.105. Only a method an app defined on that model (native.methods, not an Odoo method it inherits nor an override) is run as a read call: the platform runs those in its sandbox. The platform runs a declared read call in a read-only window of the request's transaction (a SAVEPOINT with SET LOCAL transaction_read_only, the PostgreSQL switch Odoo 18+'s read-only cursor uses, the same on Odoo 17 to 20), with exactly the user, grant, record rules and sandbox it had before: PostgreSQL refuses every INSERT, UPDATE, DELETE, nextval() and SELECT FOR UPDATE, the ORM is flushed inside the window, and a post-commit callback, a write the method caught itself (inside a sandbox savepoint too), a file written to the filestore or an e-mail handed to SMTP (refused before they happen) or a bridge that acts outside the transaction (V.net, V.oauth, V.ai, V.vector, V.webhooks, V.identity, V.commit) counts as a write. A write is refused with READ_CALL_WROTE, rolled back and audited as platform:read_call_wrote; the transaction is read-write again afterwards. The desktop host does not show the App action dialog for a declared read call (api_list read_calls: declared, approved and sandboxed names only). A name an update adds is a widening: the update waits for the administrator; dropping names needs no approval (the dialog comes back) | 5.105 | ✓ | ✓ | ✓ | ||
restriction-profiles | Restriction profiles: vizum.access.profile (who is restricted: users, and the members of groups, minus administrators and the superuser) and vizum.access.restriction (what they may not do: read / edit / create / delete the records of a model, except those matching a domain, only for the records of some companies). The platform owns one group per profile and compiles each restriction into a global ir.rule (up to Odoo 19) or a group-less ir.access restriction (Odoo 20): AND-ed, so it only ever narrows, and it holds over call_kw, search_read, web_save, export_data and import, not only in the web client. An app administers them with an orm grant on the two models (admin-only ACL) and min_platform 5.106; the platform refuses a deny-listed model, vizum.*, ir.* (except ir.filters: create / write / unlink, never read, so a member keeps loading the shared favourites), res.users, res.groups and res.company, a profile that targets an administrator or the person making the change, and a domain that is not a plain list of conditions. The members of a group that implies a target group are restricted too. A restriction never applies to base.group_system or the superuser; membership is re-synced in the same transaction whenever a user's groups change and a failed sync keeps the previous members; uninstalling the app removes its profiles, rules, accesses and groups | 5.106 | ✓ | ✓ | — | ||
native-field-audience | A column an app adds to an Odoo model (native.fields) and every node it injects into an Odoo view (native.views placements and pages, the inserted nodes of an inherited native.ui view) are for the app's audience: its native.groups, the people and groups an administrator granted the app (Desktop «Specific people» or restricted groups, Solo, native menu) and the settings administrators. Odoo enforces it on the field itself (lists, forms, search filters, fields_get, read, search, export) and the injected nodes carry the same groups, so a filter or a page naming the column disappears with it. Opt-outs: a field with "visibility": "all" is every user's, as before 5.107 (its placements too); explicit groups keep exactly those (VZ-SEC-23); an inherited native.ui view with "visibility": "all" keeps its inserted nodes for everyone; an app that replaces an Odoo module (native.replaces) defaults to "all", as the module did, and opts a field in with "visibility": "app". A user outside the audience may still duplicate the record (the column is copied for them). Changing who may use the app re-tags the views and rebuilds the registry; the upgrade to 5.107 applies it to the apps already installed. "visibility": "app" needs min_platform 5.107; "all" is ignored by older platforms, which show the column to everyone anyway | 5.107 | ✓ | ✓ | ✓ | ||
restriction-operations | Blocked operations of a restriction profile: vizum.access.operation rows block the member's DIRECT Duplicate, Import, Export, posting a message or a log note, scheduling an activity, a button method, a report, a server action, an action and a view type, and vizum.access.profile.no_debug turns the developer mode off for the members (?debug=1, assets and tests included). The platform wraps only the declared (model, method) pairs once the registry is ready; members are refused with AccessError over call_kw, call_button, JSON-2, /jsonrpc, XML-RPC and the web routes, and what Odoo does by itself because of an allowed action (a credit note's copy, a backorder, an activity a confirmation schedules) keeps working. Administrators, the superuser and sudo() are never judged, and everyone else pays nothing. An app administers them with an orm grant on vizum.access.operation (admin-only ACL) and min_platform 5.108 | 5.108 | ✓ | ✓ | — | ||
idempotency-across-paths | One idempotency key across an online orm.call / callOn and the declared action whose target is the same method (offline.run or actions.run). With min_platform 5.109 a keyed call: runs at most once per key; when the same key reaches the other path with the same method, records and arguments (bound to their names by the method's signature), the server answers with the stored result in that path's own shape instead of running it again — after that path's grant, approval, consent, domain and READ checks. Same key with other arguments: IDEMPOTENCY_MISMATCH. A method whose arguments cannot be bound to names never matches across paths. | 5.109 | ✓ | ✓ | ✓ | ||
offline-replay-facts | V.offline_replay() in native methods, script hooks, jobs and routes: None for a live call; for a call the platform accepted as an offline replay of this user's queued op (the queued label from the client, the user match and the offline declaration checked by the server), {queued, received_at, first_seen_at, attempts, queued_version}, the times on the SERVER's clock (first_seen_at: the first request that carried the op's idempotency key, failed attempts included; kept 30 days). It does NOT prove the device was offline or when anything was captured: a client can label any call as a queued replay. A client-sent value never reaches it. min_platform 5.110 | 5.110 | ✓ | ✓ | ✓ | ||
access-node-catalogue | The native view-node catalogue of the Access Manager: vizum.app.api_access_view_nodes(model, view_types) through Vizum.orm.call, an administrator only. For one Odoo model it answers, per primary view of each type (form, list, kanban, search, calendar, pivot, graph, activity), the buttons (object or action, in the header or a smart button, and whether a blocked operation can refuse it), the notebook pages, the search filters and group-bys, the chatter (a chatter tag on Odoo 18-20, div.oe_chatter on 17) and the fields of the combined arch, computed as an administrator before any per-user processing; and, for every field of the model, its facts (type, stored, required, read-only, tracked, relation, computed, related, app column) with the validator's verdict per field restriction mode (access_field_policy) and every node of any active primary view (and of other models' inline sub-views) that names the field: in a modifier, a search domain, a group-by, a view attribute or a kanban expression (dependents: from 5.126 only what the view hook cannot rewrite for a member who cannot read the field; what it rewrites is in neutralized). The call's work is bounded and whatever a cap cuts is counted in truncated (budget_exceeded when the bound was reached). Every answered call is audited; a refused one is counted once per user, reason and hour. Metadata only: no record, no arch text, no domain and no group xmlid (a has_groups flag stands for them). Reserved to the Vizum-signed Access Manager (com.vizumapps.hide_menu), call:api_access_view_nodes | 5.111 | ✓ | ✓ | — | ||
access-field-restrictions | Native field restrictions of the Access Manager: vizum.access.field rows make an Odoo field read-only, required or not exportable for the members of a restriction profile (administrators and the superuser are never restricted). Judged on the member's OWN request, never on Odoo's consequences: a read-only state is still written when the member confirms the order. Covers create, write, web_save, import through load, a binary attachment with res_field, the website form, the chatter post and message edit, the composer, and a vApp bridge call made for the member; the app's own job, trigger or webhook is not judged. Not exportable also filters /web/export/get_fields. Administrators write the rows with the permissions.orm grant on vizum.access.field, naming the model and the field by NAME (model_id + field_name; the server resolves and validates it); the validator is the contract's access_field_policy. A read-only active also refuses the member's direct archive buttons; a default_<field> in the context of a create is the member's value. | 5.112 | ✓ | ✓ | — | ||
access-field-no-read | The Access Manager's field mode no_read (restrict_read on a vizum.access.field row): an Odoo field the members of a restriction profile cannot read AT ALL, enforced by Odoo's own field groups. The profile's group is appended, negated, to the groups the field already has, so read, search, order, group, export, write, fields_get and the views refuse or omit it for a member, a vApp's bridge read of it by a member is refused (an AccessError, never silently stripped), and the member's create cannot set it. Administrators and the superuser are never restricted; removing the row, the profile or the app gives the field its original groups back at once, in every worker. Because the refusal is Odoo's, every Odoo feature that reads the field AS the member fails with it: the server therefore refuses the fields invoicing and reports read (access_field_policy.no_read_flow_fields, with the evidence per field), a field tracked on Odoo 20 (its changes are written into the message body), a field another part of a screen names in a condition (it would break the view), and an app's own x_vzm_* column (the app's audience decides who sees it). Hide such a field on screen instead. | 5.113 | ✓ | ✓ | — | ||
access-catalogue-requires-pair | The node catalogue (vizum.app.api_access_view_nodes) offers restrict_read for a field that goes together with others. A field whose value Odoo copies into another field (product.template.standard_price is computed from product.product.standard_price, image_512 from image_1920, res.partner.email_normalized from email) is judged by the server together with its copy or source in the SAME profile: restricted alone it either shows the value through the other field or hides nothing. The catalogue has no profile, so each field of model_fields answers requires_pair, a list of {model, field, via} (via: source = it holds the value this field copies, copy = it keeps a copy of this field, group = it belongs to the same closure two hops away; a sibling-table rule adds sibling; a tolerant reader treats an UNKNOWN via as restrict together, never as a reason to skip the member), the closure of copies and sources by the validator's own rule, and ok_with_pair_modes / offered_with_pair_modes: the modes accepted ONLY together with requires_pair (restrict_read), which are then absent from refused and from ok_modes. Create the field's row and the rows of requires_pair in ONE create([...]) of vizum.access.field, or switch them on, off or remove them in one call: a row with one side only is refused. A pair with a member that is refused for another reason (a record rule, a screen that names it, a field tracked on Odoo 20, the deny lists) is not offered: restrict_read stays in refused with that member's code, a reason that names it and blocked_by {model, field, code}. TEMPORARY: on Odoo 20 the four res.partner fields one compute produces (contact_address, address, contact_address_inline, address_inline) are all refused with no_read_sibling_tracked while one of them is tracked, the message naming the group; the sibling-table work replaces it with the real rule and removes the code. A pair that reaches a field refused for another reason (res.partner.display_name, the rec_name: field_denied) is refused with that code and blocked_by. | 5.114 | ✓ | ✓ | — | ||
access-valuation-seams | Stock valuation keeps working for a member who may not read the cost. A member of a profile that made product.product.standard_price (or a field the valuation reads: the product's value_svl, avg_cost or total_value, a valuation layer's or a stock move's value, remaining_value, unit_cost) unreadable with restrict_read used to break Odoo's own stock flows, because the valuation reads the cost AS THE MEMBER: validating a delivery or marking an MO as done (Odoo 18, 19), validating a receipt of a standard-cost product or with no price on the line (18), validating a landed cost (19, 20), marking a standard-cost MO as done (20). Now, for THAT member only, the valuation computation runs as the superuser (sudo(): the uid stays, so the user of the request is still known): the value is computed and written and never returned to the member, who still cannot read the cost or the fields that hold it. The landed-cost line no longer fills its price from the cost: for the member it is 0 and the member types it (Odoo 17 showed the member the cost there; 18 to 20 refused the member). A user who is not a member, the administrator, a cron and any call already running as the superuser are unchanged. The platform does not depend on stock_account, mrp_account or stock_landed_costs: each method is wrapped when its model and method exist. Every seam escalates only for records the user of the request may read (record rules, so his companies); the landed-line onchange only on virtual lines; the leaf that public computes also reach (the move's value_justification, get_valuation_lines) only inside a seam or a landed cost's compute/validate, never when RPC-reachable code calls it directly. Private methods are run as the superuser, and so is product_price_update_before_done, a public method, for records the member may read. After each seam the restricted fields are dropped from the transaction cache, so a later attribute read by the member in the same request is judged again (Odoo 17 and 18 judge only on a cache miss). Limits: the fields the flows write the cost into (the valuation layers of Odoo 17 and 18, the value of the moves of 19 and 20) are not part of the cost pair; on Odoo 17 and 18 only stock managers read the layers, on Odoo 19 and 20 any stock, MRP, purchase or billing user reads the value of the moves (cost-family work), until a row restricts them; on Odoo 17 and 18 a landed cost needs all of the layer's fields readable by the member. No new field, mode or error code: an app that drives these flows as a restricted member sees them succeed where they raised an AccessError. | 5.115 | ✓ | ✓ | — | ||
access-valuation-seams-guarded | The stock valuation seams hold their limits for a member who may not read the cost. After a seam elevates, the restricted cost fields of the products it touched are dropped from the transaction cache, so the member's later read of standard_price in the same request is judged by his own check (Odoo 17 and 18 served it from the cache). A seam never elevates on a product the member may not read: the landed-cost line onchange of a virtual line naming such a product is Odoo's own call, and compute_landed_cost / button_validate of a landed cost with such a product are refused with an AccessError before anything is computed or written. | 5.117 | ✓ | ✓ | — | ||
team-multi-role | Vizum.team with several roles per member and an audit trail. team.save({roles: [...]}) gives the FULL set of declared roles (the legacy role: "x" is roles: ["x"], replace semantics); whoami() and members() keep role (the primary role, the same rule as before) and add roles (every held role, in manifest order); members({has_role}) matches any held role. Nobody changes their own roles. A role declared with grant: "admin" is given or taken by a settings administrator only, and an account holding one is managed by a settings administrator only; for an account holding any group outside the app a manager changes only its roles (its password, login, email, active flag and invitation are a settings administrator's). Every change writes one platform:team.* row in the app's audit trail (who, the target, the roles before and after, never a password); a refused change is recorded too. team.history() reads them, managers only; in Vizum.audit.search a member who is not a manager sees only the team rows about themselves. | 5.116 | ✓ | ✓ | ✓ | ||
native-groups-24 | native.groups holds up to 24 groups (8 before), and a group may name the heading it sits under in Settings > Users with privilege: "<label>". On Odoo 19 a heading (res.groups.privilege) is ONE selection on the user form, so a user holds one group of it: each label becomes a heading of its own, named "<app name>: <label>" and shared by the groups that name it; groups without privilege keep the app's heading. Odoo 17 and 18 have no privilege and show groups that are not a chain as checkboxes, so there the key changes nothing. Two groups of one app with the same label are refused. A manifest with more than 8 groups or a privilege sets min_platform to this capability. | 5.118 | ✓ | ✓ | — | ||
access-cost-family | The cost of a product is ONE group to restrict together. restrict_read of product.product.standard_price (or product.template.standard_price) leaves the cost readable through the fields that hold it or subtract it from a price: sale.order.line purchase_price, margin, margin_percent (price - margin = cost), the product_margin figures normal_cost, purchase_gap, expected_margin, expected_margin_rate, the valuation value_svl, avg_cost, total_value (cost x quantity), sale.report margin and the point-of-sale pos.order.line total_cost, margin, margin_percent, pos.order margin, margin_percent and report.pos.order margin, on Odoo 19 and 20 stock.move value, remaining_value, value_manual, and on Odoo 20 project.project estimated_cost, estimated_cost_ratio and, with the enterprise sale_account_accountant, sale.order.line unit_cost, total_cost. The validator refuses restrict_read of any member while another member stays readable in the same profile (no_read_cost_family, listing the missing ones), and api_access_view_nodes lists the others in requires_pair with via cost_family, so the Access Manager offers the whole group in ONE create([...]). A member is found by presence in the registry (a module that is not installed adds nothing), never by version. Only the fields that hold, subtract or multiply standard_price are members; editable seeds (event ticket price, purchase.order.line price_unit, account.analytic.line amount) are not; the valuation layers of Odoo 17 and 18 and the landed-cost adjustment lines are members, because a profile can contain a stock manager. On Odoo 19 and 20 with purchase installed a view context names standard_price, so the group is refused (no_read_dependents) until the screens neutralize it; on Odoo 20 product.product.standard_price is tracked (no_read_tracked). A point of sale whose price lists calculate from the cost is refused to a restricted cashier (the front end would price a paid order wrongly), with a plain sentence for the cashier, the names in the platform log, and warnings on the standard_price rows of the catalogue. The point of sale otherwise keeps working for a restricted cashier: on Odoo 17 the session loads without the cost and the order line stores its cost without the cashier being able to read it. The manufacturing overview report returns a restricted member its data with every cost amount at 0 on Odoo 17 and 18 (on 19 and 20 it refuses the member until the screens neutralize the reference), and only for a member whose cost read is actually refused. A landed cost the member computes or validates runs as the member, not as the superuser: the read of the cost group is the only thing lifted, for that call and never for the code of a vApp, so Odoo's own access rights and record rules apply to everything else. The lift is read-only, and Odoo modules an administrator installed run inside it, like the sudo() any installed module makes. A tolerant reader treats an UNKNOWN via as restrict together. | 5.120 | ✓ | ✓ | — | ||
access-cost-family-apps | An app's column that is computed from the cost is hidden WITH the cost family, never refused and never left readable. A column an app adds to an Odoo model (x_vzm_*: a native field with a formula or a related, a field of an app's own model, and the alias an app with native.aliases gives its column, which has no prefix) whose depends reach a member of the cost family, directly or through a related or computed field in between (an Odoo field or another app's column), shows the cost like the sale line's margin: the Price List Manager's x_vzm_pricelists_margin is (list_price - standard_price) / list_price. Until 5.119 the validator refused restrict_read of the cost while such a column existed (no_read_computed) and refused restrict_read of the column (app_column), so a customer with the app could not hide the cost. Now the platform finds those columns in the registry, only the ones an app owns and never a native Odoo field (a native field that depends on the cost stays in the explicit table of the family), and gives each the same negation as the members, for every profile that restricts a member it derives from: the member's read, search, group, order and export of the column are refused as for the cost, fields_get and the views leave it out, the bridge answers an AccessError as for any other not-readable field, and the app's other columns, its rows and its calls keep working. The admin never restricts the column: it is not in requires_pair (an old reader would ask for a column the platform refuses to restrict by hand), the validator no longer counts it as a copy of the cost, the catalogue row of every member of the family lists the columns in also_hidden ({model, field, string, app_key, app_name}, so the admin sees what else will be hidden and which app owns it), and the column's own row says follows_cost_family. What counts as derived is what the compute READS as well as what it declares: the compiler adds the names a formula reads to its depends, and an install made before that is judged by the body it runs, so a formula that reads standard_price with depends [] is still joined; a compute by an app's method reads fields nobody can list, so whatever depends it declares it is joined on a model that carries a family field or reaches one within two relation hops (the catalogue lists it with reason "method compute near cost"); a method compute that reaches the cost through 3 or more relations and declares no such depends is not auto-joined (a residual: the publisher declares its depends and the store review checklist flags it). An install made before the compiler added the reads is repaired by the module upgrade, which gives the formula columns their depends and recomputes the stored values. Ownership comes from the installer's ledger (the name x_vzm_* is only the fallback for a column the ledger does not name), and each also_hidden entry says how (ownership: ledger, own_model, alias or name). The stored value is still computed for everyone: the recompute runs as the superuser, so a restricted member's write that triggers it (a purchase receipt that averages the cost, a sale line, a price change) is not refused. On Odoo 19 and 20 the group is still refused for the screens that name standard_price (no_read_dependents) until they neutralize it; this feature does not change that. | 5.122 | ✓ | ✓ | — | ||
native-view-target | native.views[].target: which form a native.views form entry extends. "default" (or no key) is Odoo's default view of the model (the primary view of lowest priority), as before; "main" is the form the model's main window action opens (the first act_window a menu opens, else the first one) when it is a primary form of the model: res.users then lands on Settings > Users (base.view_users_form), not on the new-user dialog (base.view_users_simple_form). When no main form resolves, the entry is skipped as no_base_view (refused when strict). The preview's native_views_warnings and the inspector's VIEW_NOT_MAIN_FORM are not raised for an entry with target "main". "main" is for a form entry and needs min_platform 5.123 | 5.123 | ✓ | ✓ | ✓ | ||
trigger-fields | A write trigger watches the fields it names: native.script.hooks[].trigger.fields ["state", ...] becomes the trigger fields of the app's base.automation, and Odoo runs the hook only for a write that CHANGES the value of at least one of them, instead of for every write of the model (Odoo writes a stock move 13 to 21 times per operation). Without fields every write runs the hook, as before. Only with on write or create_or_write: a create still runs a create_or_write hook, and a create or unlink trigger refuses fields. A field the model does not have refuses the install; an update applies the new list to the existing trigger. A manifest that declares fields sets min_platform to this capability. | 5.124 | ✓ | ✓ | — | ||
rule-domain-group | A record rule compares a user's groups with one of the app's OWN native groups: the leaf ["<path>.$groups", "in" | "not in", {"$group": "@name"}], where <path> ends on a user (res.users) and $groups is its LAST hop. It reads «that user holds this role of the app»: a Representative reads only the cases of a technician who still holds the Distributor role. $groups compiles to the user's effective groups on each line (all_group_ids on Odoo 19 and 20, groups_id on 17 and 18, where Odoo writes every implied group into the user's own rows), so a group that implies the role counts. {"$group": "@name"} resolves only to a group the app declares in native.groups, by its platform xmlid, each time Odoo computes the rule; an xmlid, an id, a list, another operator or the token anywhere else is refused at validation. Who holds the group is read in SQL at every search, so a role taken away (Vizum.team.save, a res.users write, Settings > Groups) is seen by the next search in every worker, without a restart. A group that no longer exists makes the WHOLE rule match nothing, also under not in. A manifest that uses the tokens sets min_platform to this capability. | 5.125 | ✓ | ✓ | — | |
access-view-nodes | Native view nodes and on-screen attributes of the Access Manager (SCREEN ONLY): vizum.access.node rows hide a button, a notebook page, a search filter or group-by or the chatter, switch off Create / Edit / Delete / Duplicate / Import / Export of a view, or give one field read-only, required, invisible (true or a condition), options (no_create, no_create_edit, no_open, no_quick_create) and a cosmetic domain, for the members of a restriction profile; the read-only and required modes of vizum.access.field rows are shown on screen too, and Create / Edit / Delete are hidden where a model restriction refuses the operation for the member with no domain and no company scope. It is applied per user after Odoo's view cache, so one user's screen never reaches another's; administrators and the superuser are never touched. It changes only what the web client is told: the server still answers, and the enforcement stays with the operation guard (blocked buttons, copy, import, export, chatter posts, reports, actions, view types), the field guard (read-only, required, not exportable) and field-level no_read. Nodes are named by what the node catalogue lists (button:<method or action xmlid>, page:<name>, filter:<name>, groupby:<name>, chatter, root, field:<name>); a node a module's uninstall removes leaves the row inert. The validator is the contract's access_node_policy. The field mode hide (hidden on screens, in exports, in vApps and on the platform's own read paths) is offered from 5.133 (feature access-field-hide). NEUTRALIZATION: the same hook rewrites the member's own copy of a screen so that no expression names a field the member cannot read (restrict_read): a context name becomes False (default_price: standard_price arrives empty), a modifier that names it takes a fixed value (invisible and column_invisible 1, readonly 1, required 0, a decoration False), a domain leaf that names it (as the field, through a dotted path, or as an operand) becomes a constant leaf that selects no more than the leaf did, and the helper field node Odoo adds for the expression is removed. restrict_read is therefore no longer refused as no_read_dependents for those attributes (the product's cost with purchase and stock installed: it is offered on Odoo 17, 18 and 19; Odoo 20 still refuses it because the cost is tracked there, E1). What the hook does not rewrite still refuses and is listed in dependents: options, a group-by in a context (context.group_by), a view attribute that is a field name (default_order, default_group_by, date_start, color...), a kanban expression over record.X, a calendar filter's or a list groupby's name, attrs and states. The catalogue lists what it rewrites in neutralized. A user the profile does not reach gets Odoo's arch byte for byte. WITH THE COST HIDDEN, ON ODOO 19 AND 20: a member who may not read the cost creates, duplicates and varies a product (stock_account's create reads the cost to log a price change: it runs under the read-only cost lift of the valuation seams, which covers the cost group and never a write of the cost, and is off around the code of a vApp) and opens the manufacturing overview, which answers with every cost amount at 0. | 5.126 | ✓ | ✓ | — | ||
native-membership | native.membership: [{groups: ["@name"], method: "<own model>.<name>"}] with permissions.membership: true. The platform's own res.users and res.groups overrides (create, write, unlink; group commands, in_group_/sel_groups_ fields, users / user_ids, implied_ids, archive and unarchive, Vizum.team) compare the EFFECTIVE members (active users holding the group directly or through implied groups) of every subscribed group with the start of the transaction, record a mark of what may have changed in the writing transaction; after the commit a processor holding the membership lock, in READ COMMITTED, diffs the members now against a ledger of what the app was last told (so every event is between two committed states; the losses a writing transaction saw in its own view are reported too, so a loss followed by a re-grant elsewhere is delivered lost then gained) and writes one event per subscription and transaction (a gain and a loss in one transaction cancel); a write that reaches no subscribed group reads no members; a subscription is its method, not its position; the event is delivered to the method, as the app's job identity, in queue order: {event_id, kind: "delta", groups: {"@name": {gained, lost}}, reason, actor_uid, attempt, queued_at}; strictly in order per entry (a failed event holds the later ones); the handler reads the current committed state. Only the app's own groups: an xmlid or another app's group is refused. Each "@group>method" pair is part of the approved permissions (membership_subs): an update that adds one waits for the administrator, dropping one is free. Deleting a subscribed group reports its members lost (reason group_deleted). A failed delivery never blocks the write: it stays pending, is retried with backoff with the same event_id, writes the audit refusal platform:membership.refused, and the settings administrators are notified after 3 attempts. A reconcile ({kind: "reconcile", members: {"@name": [uid]}}) is sent at install, at update and every hour. V.is_member("@name", uid=None) answers membership from the current transaction. vizum.app._membership_flush() is the test helper: a commit's outbox and delivery, synchronously. A manifest with native.membership or server code naming V.is_member sets min_platform to this capability. | 5.127 | ✓ | ✓ | ✓ | ||
native-membership-sync | native.membership[].sync: "narrow" (needs min_platform 5.153): the users who LOST the entry's groups are delivered to its handler inside the writing transaction of the res.users / res.groups write or unlink (payload kind "narrow", sync true, groups{ref:{gained:[],lost}}, members), as the app's job identity, in a savepoint under a 5 s budget, so the app's audience and the role change commit or roll back together. A handler that raises or times out aborts the write with an error naming the app. Archiving or deleting a user never aborts: the platform records the person in vizum.app.membership.revoked, audits and notifies, and refuses them at every bridge entry and in the job runner for that app until a later delivery succeeds (rows never expire). The handler may not reach V.net, V.ai, V.commit or send mail while it runs. A revocation clears when a delivery tells the app the person is lost or gained, when the app no longer subscribes, or by an administrator (api_clear_membership_revocation). A settings administrator can switch it off per app (api_set_membership_sync_off, a forbidden call target for a vApp; reason required, audited, persistent banner). Widening stays asynchronous and handlers must be idempotent: the same loss may arrive again. | 5.153 | ✓ | ✓ | ✓ | ||
job-user-admin | An administrator views and sets the identity an app's server side runs as: vizum.app api_get_app_job_user(key, search) and api_set_app_job_user(key, choice) (a user id, "service" or "default"), administrator only, and Settings > App Permissions > Job user. Refused with the reason: the superuser, archived and portal users, another app's service user, a user outside the app's company, an administrator when no model of the app is admin-only, a user lacking what the app declares it needs (named, never granted). An app that declares a service user (native.service_user or a server-only writer) runs as it and as nobody else, also against a direct write of job_user_id. Audited as platform:job_user. A vApp cannot call them: both are forbidden call targets | 5.131 | ✓ | ✓ | — | ||
trigger-svc | The trigger payload's svc (rec["svc"] in a script hook): True when env.uid of the write is the app's own service user, False for anyone else, an administrator set as job user included. Read it as rec.get("svc") unless the app declares min_platform 5.130: before it the key is missing | 5.131 | ✓ | ✓ | — | ||
native-adopt-120 | native.adopt takes up to 120 entries, the same cap as native.models (it was 80 although 5.132 raised the models to 120). More than 80 adopt entries sets min_platform 5.137; more than 120 is refused at install ("native.adopt is a list of 1-120 entries") and by vzm check | 5.137 | ✓ | ✓ | — | ||
native-methods-1500 | An app may declare up to 1500 native.methods (1000 before). More than 1000 sets min_platform 5.137; more than 1500 is refused at install ("native.methods is a list of 1-1500 entries") and by vzm check. The total code size limit (2 MB) is unchanged | 5.137 | ✓ | ✓ | — | ||
native-models-120 | An app may create up to 120 own models in native.models (80 from 5.63, 50 before). More than 80 sets min_platform 5.132; more than 120 is refused at install ("native.models must be a list of at most 120 entries") and by vzm check | 5.132 | ✓ | ✓ | — | ||
orm-expect | A guarded call: orm.call / orm.callOn with expect: {field: value} (V.orm.call(..., expect=...) in a script). In one transaction the platform reads the records AS THE USER, locks them (SELECT ... FOR UPDATE, ids sorted so two guarded calls cannot deadlock, waiting at most 5 s), re-reads the expected fields as the user and runs the method only if every record holds every expected value; otherwise nothing runs, for any id, and the call rejects STATE_CHANGED (details {model, records: [{id, current}]}: the current value of the expected fields only, null for a record that is missing or the user cannot read, the same answer for both) or, when the locks were held for 5 s, BUSY (retryable). Up to 100 ids (required) and 10 stored fields of type selection, char, text, integer, float, monetary, boolean, date, datetime or many2one (by id), inside the grant's fields and not hidden from the user. Same grant, ACLs, allowlist and write ceiling as a plain call. It does not make the method idempotent, a method that commits ends the locks, and rows of other models are not locked. min_platform 5.133 | 5.135 | ✓ | ✓ | ✓ | ||
safe-platform-uninstall | Uninstalling the window manager or the platform with vApps installed keeps the instance working and the vApps' data. The uninstall hook raises a per-registry flag so the platform's overrides step aside while Odoo removes the module, then detaches every app: automations and server actions that call the platform are deactivated, method computes become plain columns, platform groups named in inherited views and access rows are re-homed to administrators (a record rule bound only to them is deactivated instead), native navigation is removed, and a signed detach ledger (app keys, versions, manifests, packages) is written to attachments the module does not own. The Apps screen's Uninstall opens a wizard when vApps exist: uninstall them first (their data is deleted, the box must be ticked), or detach and keep the data. Installing the platform again re-adopts the ledger's apps as needs_approval, through the same trust path as their original install (a bundled app from the platform's own bundle, any other from its stored package, re-verified; a signed app is installed again from the Store); triggers stay off until an administrator approves. A vApp cannot call any of it | 5.136 | ✓ | ✓ | — | ||
native-method-callable-by | One app's non-public native method is callable through a call: grant by another app only if (1) both apps are verified as published by the SAME publisher key (a signature whose bytes still hold, never an id prefix or the manifest's own claim; unsigned, sideloaded and unverified packages have none), (2) its owner lists the caller in native.methods[].callable_by (1-16 distinct app ids, not on a public method or an override; surfaced as permissions.callable_methods), or (3) the method is public: true. Otherwise AccessError naming both apps, at every call (bridge, orm.callOn, V.orm.call, actions, a record method in a script) and at install, update and approval when the owner is installed. Odoo's own methods and the app's own are unaffected. A vApp that declares callable_by sets min_platform 5.139; vzm check says so and warns (FOREIGN_APP_METHOD) on a call: grant on another app's model | 5.139 | ✓ | ✓ | — | ||
access-profile-window | Access Manager 9a — a restriction profile (vizum.access.profile) may carry starts_at / expires_at (UTC; a missing bound is open). Outside its window the profile has no members, so every restriction it holds (model and record rules, field modes, blocked operations, screen nodes, the fields hidden from apps) lifts at once and comes back together. A platform job re-syncs each profile whose window opened or closed every minute; writing a window takes effect at once; each boundary is logged (window_opened / window_closed). A window that ends while the job is down keeps the restriction (fail closed); one that starts late is reported by Vizum.apps.health() (check access_windows) when the job is more than 2 minutes behind. | 5.140 | ✓ | ✓ | ✓ | ||
access-readonly-user | Access Manager 9b — a restriction profile (vizum.access.profile) may set readonly_user: its members read what Odoo lets them read and write only their own preferences, Discuss in their own channels and «mark done» on their own activities; any other write is refused at the dispatched method and by Odoo's access check, and a vApp the member runs reads Odoo records and writes none. PROVISIONAL number, fixed at merge. | 5.141 | ✓ | ✓ | ✓ | ||
access-profile-ui-policy | Access Manager 9c — a restriction profile (vizum.access.profile) may set ten ui_* switches of the backend UI (search panel, custom filter, custom group by, saving a favourite, chatter send / log / activity, Insert in spreadsheet, Add a property, Archive / Unarchive). session_info carries the member's switches as vzm_access_ui and the web client hides those entries. Screen only: the server guard that pairs with each, where one exists, is named in Access Manager. PROVISIONAL number, fixed at merge. | 5.142 | ✓ | ✓ | ✓ | ||
ui-chatter-per-version | native.ui writes the form chatter in the markup of the running Odoo. Odoo 17 compiles only <div class="oe_chatter"> and Odoo 18, 19 and 20 only <chatter/>; a view written for either form is stored in the one this Odoo knows (the div becomes <chatter/> on 18+; <chatter/> becomes the div with the followers, activities and messages fields the view's model has on 17), with the chatter options and an inherited view's xpath carried across. A vApp with a chatter needs no min_odoo. No manifest key changes; a vApp cannot call any of it | 5.144 | ✓ | ✓ | — | ||
ui-chatter-per-version-runtime | A view a vApp creates or writes at runtime through the ir.ui.view bridge also gets the running Odoo's form chatter markup (<div class="oe_chatter"> on 17, <chatter/> on 18+), as native.ui does at install since 5.144. A write naming no model or type takes them from the stored view or its parent; an xpath that cannot be converted is refused as the bridge's normal error (AccessError); one write to several views needing different markup is refused. No manifest key changes | 5.146 | ✓ | ✓ | — | ||
action-consent | An administrator's approval of an app version covers the create / write / call: / mail.log / activity / named actions it declares, per company (vizum.app.action.consent): the desktop host stops asking users for them and flags the call; the server re-checks it (CONSENT_STALE) and audits it (consent:action, per app, user, action and hour). Never covered: unlink, mail.post, the fixed list of methods (post, send, validate, confirm, pay ...), actions marked "sensitive" (manifest key, min_platform at this level; SENSITIVE_REQUIRED for an app built for it), uploads and offline writes: for those the user's own 30-day memory works as before. A company can keep asking for everything (res.company.vizum_ask_app_actions). An update with the same server code and grants carries the consent; any other drops it. Read-only members are refused before it. | 5.149 | ✓ | — | — | ||
ar | Vizum AR - Vizum.ar.start({formats, hint, torch, positions, rate, unmatched}), overlay({value: card | null}, {replace}), stop() and status(), and the events ar:codes, ar:tap and ar:state (Vizum.ar.on). The host opens the rear camera on its real origin, reads barcodes and QR codes (the browser's BarcodeDetector; a vendored zxing-wasm reader only where the administrator switched it on, off by default) and draws, over each confirmed code, the plain-text card the app described. The video never reaches the app: it receives code values, and the view never writes to Odoo. One view per window, shared with Vizum.vision (busy). Needs permissions.media ["camera"] and min_platform 5.151; Vizum.supports("ar") is true only where the host has a camera API, the grant is approved and a reader exists. Served by the desktop, the native menu host and the Solo shell (the /vizum/pwa browser PWA and the Solo app run the same host); the reader is the browser's BarcodeDetector, or the bundled reader where the administrator switched it on. Since 5.155 start({layer:"app"}) puts the camera BEHIND the app (the iframe goes above the host's video and becomes transparent) and the app receives the ar:frame event: numbers only (corners in video and CSS pixels, a pinhole projection and a field of view that is never null), at up to 30 Hz with a window of 3 unacknowledged frames; the host chrome (strip, Close, border) stays above the app and losing it stops the camera. capabilities().ar.layer and layer_reason say whether a window can do it | 5.151 | ✓ | ✓ | ✓ | |
ar-layer | Vizum.ar.start({layer:"app", positions, rate<=30}): the camera behind the app, ar:frame events (numbers only, no pixel ever reaches the app), ar.setFov({h}), Vizum.ar.on("frame"); overlay() answers layer_app in this mode. Needs media camera | 5.155 | ✓ | ✓ | ✓ | ||
motion | Vizum.motion.start / stop / status and the events motion:orientation, motion:motion, motion:state: the host relays the device's tilt and movement (quantised to 1 degree, 1 degree/s and 0.1 m/s^2; 10-60 Hz, 20 at most and by default without an AR layer, 30 by default and 60 at most while it is on; stops when the window is hidden or not the top one; acknowledged per reading). The iframe gets no sensor API. iOS asks the person through a host button. Needs permissions.sensors ["motion"] and min_platform 5.155; capabilities().motion = {usable, granted, supported, needs_gesture, reason} | 5.155 | ✓ | ✓ | ✓ |
API reference
since is set where a capability introduced the member; a member without one predates the capability notes and is available on every supported platform. Permission is what the host gate and the server enforce for the bridge namespace behind the member.
Top level
Vizum._t, Vizum.app (since 5.76), Vizum.capabilities, Vizum.connect, Vizum.env, Vizum.features (since 5.67), Vizum.intent, Vizum.isMobile, Vizum.model, Vizum.onClose (since 5.7), Vizum.onPause (since 5.7), Vizum.onResume (since 5.7), Vizum.onVisibility (since 5.7), Vizum.platform (since 5.67), Vizum.query, Vizum.route, Vizum.scope, Vizum.sdk (since 5.67), Vizum.session, Vizum.supports (since 5.67), Vizum.theme
Signatures (from vizum-sdk.d.ts):
_t(key: string, ...params: any[]): string— i18n/<lang>.json lookup in the iframe,%sfilled in order.readonly app: { readonly key: string | null }— SDK 5.76 — this app's own key, sent by the host in the handshake:keyis set onceconnect()resolves (null before, and on a host older than 5.76).capabilities(): Vizum.Capabilitiesconnect(): Promise<{ caps: Vizum.Caps; theme: any; session: Vizum.Session | null; intent: any; mobile: boolean }>env: { mobile: boolean }— SDK v3 runtime environment.features(): string[]— SDK 5.67 — the feature ids this host serves (a copy ofcapabilities().features).readonly intent: anyreadonly isMobile: boolean— True on a phone-sized desktop (mirrors env.mobile).model<T = Record<string, any>>(name: string): { readonly name: string; search(domain?: Vizum.Domain, opts?: object): Promise<number[]>; searchRead(domain?: Vizum.Domain, fields?: (keyof T & string)[], opts?: object): Promise<Array<Partial<T> & { id: number }>…— A per-model accessor — the no-build equivalent of @vizum/vapp-types' model<T>().onClose(cb: (event: Vizum.CloseEvent) => void): Vizum.Subscription— One-shot dispose signal posted just before teardown.onPause(cb: (event: Vizum.VisibilityEvent) => void): Vizum.Subscription— Sugar over onVisibility, fired only on the hidden edge (visible === false).onResume(cb: (event: Vizum.VisibilityEvent) => void): Vizum.Subscription— Sugar over onVisibility, fired only on the visible edge (visible === true).onVisibility(cb: (event: Vizum.VisibilityEvent) => void): Vizum.Subscription— SDK v5.7 lifecycle events (opt-in, advisory; no manifest grant).readonly platform: Vizum.PlatformInfo— SDK 5.67 — the platform, its versions and its host (sdk-next/docs/platform-contract.md).query(model: string): Vizum.Query— SDK v4 fluent query builder (also Vizum.orm.query).readonly route: Vizum.Route | null— SDK 5.53 — the declared route (native.routes) this app was opened on, from a route page (/app/<slug>/<route>[/<sub>] or its /my/<slug>/… alias); null anywhere else.scope(): Promise<Vizum.Scope>— SDK 4.1 — what storage scope this app runs in.readonly sdk: Vizum.SdkInfo— SDK 5.67 — the runtime SDK's own version and api version.readonly session: Vizum.Session | nullsupports(name: string): boolean— SDK 5.67 — true when this platform AND this host servename: a feature id ("realtime") or an SDK member ("files.publicUrl","phone").readonly theme: { accent: string; dark: boolean }
actions
stability stable · permission: permissions.actions (non-empty)
Members: Vizum.actions.list, Vizum.actions.run
Bridge ops: list, run
list(): Promise<Array<{ name: string; label: string; model: string; groups: string[] }>>run(name: string, params: { ids: number[]; [k: string]: any }, opts?: Vizum.Idempotent): Promise<{ ok: boolean; result: Vizum.ActionRunResult }>— SDK 5.102 (supports("actions-typed-result"), appmin_platform5.102 or later):resulttakes the shapes of acall:(5.98) — a {@link Vizum.OrmActionResult} when the method opened a wizard or a form (stock_sms's c…
activity
stability stable · permission: permissions.orm (or own native models)
Members: Vizum.activity.done, Vizum.activity.list, Vizum.activity.schedule
Bridge ops: done, list, schedule
done(model: string, id: number, activityId: number, feedback?: string): Promise<boolean>list(model: string, id: number): Promise<Array<{ id: number; summary: string; note: string; date_deadline: string | null; user: [number, string]; type: string; overdue: boolean }>>schedule(model: string, id: number, opts: { summary?: string; note?: string; date_deadline?: string; user_id?: number; type?: string }): Promise<{ id: number }>
ai
stability stable · permission: permissions.ai; storeAccount also store_account; billing/byok/instance settings also ai_admin; image also ai_image
Members: Vizum.ai.account, Vizum.ai.appPermissions, Vizum.ai.billing, Vizum.ai.byokFallback, Vizum.ai.byokSet, Vizum.ai.byokStatus, Vizum.ai.chat, Vizum.ai.complete, Vizum.ai.embed, Vizum.ai.getInstanceSettings, Vizum.ai.image (since 5.25), Vizum.ai.managedModels, Vizum.ai.models, Vizum.ai.ragMigrate, Vizum.ai.rerank, Vizum.ai.setAllowedModels, Vizum.ai.setDefaultModel, Vizum.ai.setInstanceSettings, Vizum.ai.storeAccount, Vizum.ai.tools, Vizum.ai.usageByApp, Vizum.ai.usageByUser, Vizum.ai.usageHasRefs
Bridge ops: account, appPermissions, billing, byokFallback, byokSet, byokStatus, chat, complete, embed, getInstanceSettings, image, managedModels, models, ragMigrate, rerank, setAllowedModels, setDefaultModel, setInstanceSettings, storeAccount, usageByApp, usageByUser, usageHasRefs
account(): Promise<{ mode: string; account_type: string; balance: number; currency: string; trial: { active: boolean; remaining: number; expiry: string | null }; subscription_state: string; subscription_cancel_at?: string | null; data_purge_at?: string | null…— SDK v5.8 admin surface — base.group_system only; others get AccessError.appPermissions(payload: { keys: string[] }): Promise<{ rows: Array<{ key: string; name: string; data: string; logic: string | null; ai: boolean; ai_image: boolean; vector: string | null; orm: Array<{ model: string; ops: string[]; write_fields: string[] | null…— Admin only.billing(payload: { op: "topup" | "subscribe" | "portal" | "plans"; amount?: number; plan_id?: number }): Promise<{ url?: string; id?: string; plans?: any[] }>— SDK v5.8 admin surface — create or fetch a Stripe Checkout / portal / plans session.byokFallback(payload: { value: boolean }): Promise<{ ok: boolean }>byokSet(payload: { provider: string; key: string }): Promise<{ ok: boolean }>byokStatus(): Promise<{ providers: Array<{ provider: string; has_key: boolean }>; ai_byok_fallback: boolean }>— SDK v5.15 admin — read whether this instance BYOKs (bring your own key) to the Vizum store, per provider (booleans only — the key is never returned).chat(payload: { messages?: AiMessage[]; prompt?: string; system?: string; model?: string; max_tokens?: number; temperature?: number; tools?: any[]; /** Extended reasoning budget. Today only the anthropic branch * ('_apply_effort') applies it: raises 'max_toke…complete(prompt: string, opts?: { model?: string; system?: string; max_tokens?: number; temperature?: number }): Promise<string>embed(input: string | string[], opts?: { model?: string }): Promise<{ vectors: number[][]; model: string }>getInstanceSettings(): Promise<VizumAiInstanceSettings>— SDK v5.14 admin surface — instance-wide AI parameters.image(payload: { prompt: string; n?: number; size?: string } ): Promise<{ images: string[]; model: string | null; mime: string }>— SDK 5.25 — image GENERATION.managedModels(): Promise<{ models: Array<{ id: string; provider: string; model: string; label: string; enabled: boolean }>; all_enabled: boolean; default_model_id: string | null; mode: string; editable: boolean }>— SDK v5.9 admin surface — managed-MODEL selection: the models the owner offers this instance + which are enabled.models(): Promise<Array<{ id: string; label: string; provider: string }>>ragMigrate(opts?: { dryRun?: boolean }): Promise<{ migrated: number; skipped: Record<string, number>; namespaces: string[] }>— SDK v5.15 admin — local → managed RAG migration.rerank(query: string, documents: string[], opts?: { topN?: number; model?: string } ): Promise<{ results: Array<{ index: number; score: number }>; model: string | null }>— SDK v5 listwise reranker (provider-agnostic — runs on the chat model, no Cohere/Jina key).setAllowedModels(payload: { model_ids: string[] }): Promise<{ models: Array<{ id: string; provider: string; model: string; label: string; enabled: boolean }>; all_enabled: boolean; default_model_id: string | null; mode: string; editable: boolean }>— Admin: enable a subset of the owner's offered models (clamped to the owner ceiling).setDefaultModel(payload: { model_id: string | number }): Promise<{ models: Array<{ id: string; provider: string; model: string; label: string; enabled: boolean }>; all_enabled: boolean; default_model_id: string | null; mode: string; editable: boolean }>— SDK v5.13 admin — the default CHAT model for no-model managed calls (e.g.setInstanceSettings(payload: Partial<Pick<VizumAiInstanceSettings, "assistantMaxTokens" | "assistantTimeBudget" | "generalMaxTokens">>): Promise<VizumAiInstanceSettings>— SDK v5.14 — persist any subset of the instance AI parameters (clamped server-side) and return the fresh snapshot.storeAccount(payload: { op: "status" } | { op: "logout" } | { op: "login"; email: string; password: string } | { op: "signup"; name: string; email: string; password: string } ): Promise<{ linked: boolean; email?: string; name?: string; can_buy?: boolean; offl…— SDK v5.22 — link / unlink / inspect the Vizum store account from inside the app.tools: { handle(name: string, handler: (values: Record<string, any>) => any): void; }— SDK v5.3 — AI TOOLS: let the AI Assistant DRIVE your app.usageByApp(payload?: { days?: number }): Promise<{ rows: Array<{ app_id: number | null; key: string | null; name: string; calls: number; tokens_in: number; tokens_out: number; charge: number; /** Prompt caching, aggregated: 'cache_hit_ratio' = cache_read / to…—refsis the app's ownVizum.ai.chat({..., ref})breakdown — empty when the app never tags a call.usageByUser(): Promise<{ rows: Array<{ user_id: number | null; name: string; login: string | null; spend: number; calls: number; tool_calls: number; by_kind: Array<{ kind: string; charge: number; calls: number }> }> }>— SDK v5.8 admin surface — per-user spend + call rollup.usageHasRefs(payload?: { days?: number }): Promise<{ available: boolean }>— Admin: cheap yes/no — does any app tag its spend withref(no 90-day fold)?
apps
stability stable · permission: none (open targets checked against permissions.apps)
Members: Vizum.apps.find, Vizum.apps.health (since 5.67), Vizum.apps.installed, Vizum.apps.open, Vizum.apps.provides, Vizum.apps.requires, Vizum.apps.status (since 5.67)
Bridge ops: find, health, installed, open, provides, requires, status
find(contract: string): Promise<Array<{ key: string; name: string; version: string; ready: boolean; provides: string[] }>>health(): Promise<{ status: "healthy" | "degraded" | "failing"; lifecycle: "running" | "blocked" | "pending_approval" | "disabled" | "withdrawn"; /** 5.97 — 'policy': the platform's rules tightened after this version was installed */ checks: Array<{ name: "st…— 5.67 — how this app is doing: approval state, dependencies, its own runtime errors and failed background tasks over the last 24 h.installed(): Promise<Array<{ key: string; name: string; version: string; ready: boolean; provides: string[] }>>open(key: string, payload?: any): Promise<boolean>provides(): Promise<string[]>requires(): Promise<Array<Vizum.DependencyRow & { key: string; installed: boolean; ready: boolean }>>status(): Promise<{ state: "ok" | "degraded" | "blocked"; dependencies: Vizum.DependencyRow[] }>— 5.67 — whether everything this app requires is ready.
ar
stability stable · since 5.151 · hosts: desktop, native, solo · permission: permissions.media ['camera']
Members: Vizum.ar.on, Vizum.ar.overlay, Vizum.ar.setFov (since 5.155), Vizum.ar.start, Vizum.ar.status, Vizum.ar.stop
Bridge ops: overlay, setFov, start, status, stop
on(name: "state", cb: (e: { state: "started" | "stopped" | "denied" | "unsupported" | "error"; engine?: string; reason?: string }) => void): Vizum.Subscriptionoverlay(cards: Record<string, Vizum.ArCard | null>, options?: { replace?: boolean }): Promise<{ ok: true; count: number; refused?: number; invalid?: number } | { ok: false; reason: "not_running" }>— Set the cards:{ [codeValue]: Card | null }(null removes).setFov(opts: { h: number }): Promise<{ ok: true; h: number } | { ok: false; reason: "invalid" }>— SDK 5.155: your own estimate of the HORIZONTAL field of view in degrees (20..140), kept for the window's session; the nextframeevents carry it withfov.source: "estimated".start(opts?: Vizum.ArStartOptions): Promise<{ ok: true; engine: "native" | "wasm" } | { ok: false; reason: "denied" | "unsupported" | "busy" | "stopped"; detail?: string }>— Open the view.status(): Promise<{ running: boolean; engine?: "native" | "wasm"; visible: string[]; layer?: "app"; dropped?: number }>stop(): Promise<{ ok: true }>— Close the view and release the camera.
assistant
stability stable · permission: permissions.assistant
Members: Vizum.assistant.cancel, Vizum.assistant.confirm, Vizum.assistant.contexts, Vizum.assistant.delete, Vizum.assistant.deleteProject, Vizum.assistant.history, Vizum.assistant.kbInfo, Vizum.assistant.kbSearch (since 5.52), Vizum.assistant.models, Vizum.assistant.newSession, Vizum.assistant.projects, Vizum.assistant.rate, Vizum.assistant.reject, Vizum.assistant.rename, Vizum.assistant.saveProject, Vizum.assistant.send, Vizum.assistant.sessions, Vizum.assistant.setContext, Vizum.assistant.setEffort, Vizum.assistant.setModel, Vizum.assistant.setSessionProject, Vizum.assistant.sourceDoc, Vizum.assistant.thumb, Vizum.assistant.upload
Bridge ops: cancel, confirm, contexts, delete, deleteProject, history, kbInfo, kbSearch, models, newSession, projects, rate, reject, rename, saveProject, send, sessions, setContext, setEffort, setModel, setSessionProject, sourceDoc, thumb, upload
cancel(sessionId: number): Promise<{ ok: boolean }>— Ask the in-flight turn on this session to abort at its next step.confirm(sessionId: number, actionId: number): Promise<Record<string, any>>contexts(): Promise<{ available: Array<{ id: number; name: string; industry?: string; section?: string; summary?: string }>; current: number; current_name?: string; locals: Array<{ name: string }> }>— The user's available pre-contextualizations (personas) + their current pick + the applicable LOCAL contexts.delete(sessionId: number): Promise<{ ok: boolean }>deleteProject(id: number): Promise<{ ok: boolean }>history(sessionId: number): Promise<Array<Record<string, any>>>kbInfo(): Promise<{ docs: number; namespaces: string[]; names: string[]; tags: string[]; categories: string[] }>— SDK 5.19 — a CHEAP snapshot of the user's Knowledge Base (no LLM): how many documents are actually indexed, the namespaces, and the tag/category vocab.kbSearch(query: string, opts?: { catalogs?: string[]; topK?: number }): Promise<{ ok: boolean; catalogs?: string[]; note?: string; error?: { code: string; message?: string }; passages?: Array<{ source: string; text: string; score: number; kind?: string; categ…— SDK 5.52 — passages from the Knowledge (RAG) catalogs THIS user can read, with their sources: the Assistant's own retrieval (hybrid search, rerank), run as the calling user.models(): Promise<{ configured: boolean; models: Array<{ id: string; provider: string; model: string; label: string; vision: boolean }>; chosen: string | false }>newSession(title?: string, projectId?: number): Promise<{ id: number }>projects(): Promise<Array<{ id: number; name: string; persona_id: number; persona_name: string; instructions: string; session_count: number }>>— Per-user chat folders, each with an attached persona.rate(sessionId: number, messageId: number | string, rating: "up" | "down"): Promise<{ ok: boolean; message_id?: number; rating?: "up" | "down" }>— SDK 5.19 (active learning, still evolving) — thumbs up/down on ONE assistant message (its id comes from history()/send()'s message_id — a numeric Odoo id, a numeric string also works, the server coerces with int()).reject(sessionId: number, actionId: number): Promise<Record<string, any>>rename(sessionId: number, title: string): Promise<{ ok: boolean }>saveProject(vals: { id?: number; name: string; persona_id?: number; instructions?: string; sequence?: number; active?: boolean }): Promise<Record<string, any>>— Create/update a project.send(sessionId: number, text: string, opts?: { /** Multimodal input (SDK v5.11) — attachments from upload(). */ attachments?: Array<{ id: string; route?: string }>; /** SDK v5.19 — the editor document the user is viewing (live-edit awareness). */ active_doc?:…sessions(): Promise<Array<Record<string, any>>>setContext(id?: number): Promise<{ ok: boolean; context_id: number }>— Pick one of YOUR allowed global contexts (or 0/null to clear).setEffort(effort?: "auto" | AiEffort): Promise<{ ok: boolean; effort: string }>— Pick your reasoning EFFORT (extended thinking for Claude & thinking-capable models).setModel(model?: string): Promise<{ ok: boolean; model: string }>— Pick the model for YOUR chats (one of your allowed models; ""/null = automatic).setSessionProject(sessionId: number, projectId?: number): Promise<{ ok: boolean; project_id: number | false }>— Move a chat into a project (0/null = out of any project).sourceDoc(namespace: string, docId: string): Promise<{ ok: boolean; doc?: { doc_id: string; name: string; content_type: string; size: number; data: string } }>— SDK v5.2 — fetch a cited Knowledge SOURCE document for in-chat preview.thumb(attachmentId: string): Promise<{ ok: boolean; mime?: string; name?: string; data?: string | null }>— A small image preview for an uploaded attachment (history re-render).upload(sessionId: number, data: string, name?: string, mime?: string): Promise<{ attachment_id: string; mime: string; name: string; size: number; session_id: number }>— SDK v5.11 — store an uploaded file (base64) as a session-scoped attachment; keeps bytes OUT of the chat text.
audio
stability stable · since 5.5 · permission: recordClip/recordStop/micLevel: permissions.media ['microphone']; transcribe/status: permissions.audio
Members: Vizum.audio.micLevel, Vizum.audio.recordClip (since 5.10), Vizum.audio.recordStop, Vizum.audio.status, Vizum.audio.transcribe, Vizum.audio.transcribeBatch
Bridge ops: micLevel, recordClip, recordStop, status, transcribe
micLevel(): Promise<{ level: number; ready: boolean }>— Instantaneous RMS mic level (0..~1) — for a live meter / dead-mic detection.recordClip(ms: number | { ms?: number }): Promise<{ b64: string; mime: string; size: number }>— Record ~msof audio from the mic in-browser -> a clip.recordStop(): Promise<{ ok: boolean }>— Release the microphone acquired by recordClip().status(): Promise<{ mode: "local" | "managed"; provider: string; available: boolean; credits_per_min?: number | null }>— Which engine is live + a cost hint.transcribe(input: File | Blob | string | { data: string; content_type?: string } | { namespace: string; doc_id: string }, opts?: { mode?: "local" | "managed"; language?: string; translate?: boolean; model?: string; onProgress?: (p: { stage?: string; duration?…— input: a File | Blob | base64 string | {data,content_type} | {namespace,doc_id}.transcribeBatch(inputs: Array<File | Blob | string>, opts?: { mode?: "local" | "managed"; language?: string; translate?: boolean; model?: string; onProgress?: (p: { item: number; total: number }) => void } ): Promise<{ results: Array<Vizum.AudioResult | { err…— Batch transcribe; per-item progress via opts.onProgress.
audit
stability stable · permission: none (app-scoped); activity: permissions.audit server-side
Members: Vizum.audit.activity, Vizum.audit.log, Vizum.audit.search
Bridge ops: activity, log, search
activity(query?: { model?: string; res_id?: number; action?: "create" | "write" | "unlink"; user_id?: number; source?: "vapp" | "native"; date_from?: string; date_to?: string; limit?: number; offset?: number }): Promise<Array<{ id: number; ts: string; user: […— SDK v5: the old->new CHANGE trail the platform captures for the app's declared native.audit models.log(action: string, payload?: Record<string, any>): Promise<{ id: number; ts: string }>search(query?: { action?: string; user_id?: number; ok?: boolean; date_from?: string; date_to?: string; limit?: number; offset?: number }): Promise<Array<{ id: number; ts: string; user: [number, string]; action: string; payload: Record<string, any>; ok: boole…
backend
stability stable · permission: permissions.actions (non-empty)
Members: Vizum.backend.approve, Vizum.backend.cancel, Vizum.backend.enqueue, Vizum.backend.list, Vizum.backend.progress, Vizum.backend.status
Bridge ops: approve, cancel, enqueue, get, list, progress
approve(id: number | string): Promise<any>cancel(id: number | string): Promise<any>enqueue(action: string, params?: Record<string, any>, opts?: Vizum.Idempotent): Promise<{ id: number | string; [k: string]: any }>— SDK 5.67idempotencyKey: one task per key, however often it is retried.list(q?: Record<string, any>): Promise<Vizum.BackendTask[]>progress(id: number | string, vals?: { done?: number; total?: number; cursor?: any; /** Report a percentage directly when 'total' isn't known/tracked this call — * used ONLY when 'total' is absent; 'done'/'total' take priority when both are * given. */ progre…— A RUNNING action reports {done,total,cursor?} between batches for a live progress bar + resumable cursor, and gets back {cancel_requested} so it can stop cleanly on a cooperative cancel().status(id: number | string): Promise<Vizum.BackendTask>
bulk
stability stable · since 5.90 · hosts: desktop, native, solo · permission: permissions.orm (search + read on the model, as the calling user); rebuild also permissions.logic "script"
Members: Vizum.bulk.cancel (since 5.90), Vizum.bulk.export (since 5.90), Vizum.bulk.list (since 5.90), Vizum.bulk.rebuild (since 5.90), Vizum.bulk.status (since 5.90)
Bridge ops: cancel, export, list, rebuild, status
cancel(id: number): Promise<Vizum.BulkJob>— A queued job is cancelled at once; a running one stops at its next chunk.export(model: string, spec: { fields: string[]; domain?: unknown[]; format?: "csv" | "jsonl"; name?: string }): Promise<Vizum.BulkJob>list(): Promise<Vizum.BulkJob[]>rebuild(model: string, spec: { fields: string[]; domain?: unknown[]; hook: string; params?: Record<string, unknown> }): Promise<Vizum.BulkJob>— Needs permissions.logic "script":hookreceives {rows, chunk, last, job: {id, params}}.status(id: number): Promise<Vizum.BulkJob>
cutover
stability stable · permission: permissions.cutover (+ administrator)
Members: Vizum.cutover.adopt, Vizum.cutover.release
Bridge ops: adopt, release
adopt(p?: { app_key?: string; source_db?: string; version?: string; [k: string]: any }): Promise<any>release(p?: { app_key?: string; source_db?: string; version?: string; [k: string]: any }): Promise<any>
data
stability stable · permission: none (scope from permissions.data)
Members: Vizum.data.collection, Vizum.data.del, Vizum.data.get, Vizum.data.list, Vizum.data.set, Vizum.data.subscribe
Bridge ops: collection, del, get, list, set, subscribe
collection<T = any>(name: string): Vizum.Collection<T>del(k: string): Promise<boolean>get(k: string): Promise<any>list(): Promise<string[]>set(k: string, v: any): Promise<boolean>subscribe(k: string, cb: () => void): Promise<boolean>
db
stability stable · permission: permissions.tables; meta_tables/meta_fields/query_app: permissions.db_cross_read; owner_query: row ACL
Members: Vizum.db.count, Vizum.db.delete, Vizum.db.get, Vizum.db.insert, Vizum.db.insertMany, Vizum.db.insert_many, Vizum.db.meta, Vizum.db.ownerQuery, Vizum.db.queryApp, Vizum.db.search, Vizum.db.share, Vizum.db.update
Bridge ops: count, delete, get, insert, insert_many, meta_fields, meta_tables, owner_query, query_app, search, share, update
count(table: string, domain?: any[][]): Promise<number>delete(table: string, id: string): Promise<boolean>get(table: string, id: string): Promise<Record<string, any> | null>insert(table: string, values: Record<string, any>): Promise<Record<string, any>>insertMany(table: string, rows: Array<Record<string, any>>): Promise<Array<Record<string, any>>>— Bulk insert in one round-trip; returns the created rows (with ids).insert_many(table: string, rows: Array<Record<string, any>>): Promise<Array<Record<string, any>>>meta: { tables(): Promise<Array<Record<string, any>>>; fields(appKey: string, table: string): Promise<Array<Record<string, any>>>; }— Cross-app Vizum.db read (Phase 4) — PRIVILEGED, READ-ONLY, ACL-bounded.ownerQuery(table: string, rowId: string, widgetId: string): Promise<any[]>— Run-as-owner render of a SHARED row's owner-persisted widget query.queryApp(appKey: string, table: string, spec?: Record<string, any>): Promise<any>search(table: string, domain?: any[][], opts?: { order?: string; limit?: number; offset?: number; scope?: "mine" | "shared" }): Promise<Array<Record<string, any>>>share(table: string, id: string, opts: { users?: Array<number | "job_user">; visibility?: "private" | "shared" | "public" }): Promise<any>— 5.41:usersmay include "job_user" — the identity this app's jobs run as, resolved server-side (never the superuser, an archived or a portal user).update(table: string, id: string, values: Record<string, any>, opts?: { expect?: Record<string, any> }): Promise<Record<string, any> & { _updated?: boolean }>— 5.39:opts.expect= compare-and-set — written only if the row still holds those values (row lock); the result then carries_updated: true | false.
dev
stability stable · client-side only (no bridge op)
Members: Vizum.dev.logs
logs(q?: Record<string, any>): Promise<any[]>
device
stability stable · hosts: device · permission: an auth "device" page (native.routes); print, printReceipt and printTest also need device.print: true on that page's route
Members: Vizum.device.blobUrl (since 5.70), Vizum.device.cache (since 5.70), Vizum.device.call (since 5.70), Vizum.device.embed (since 5.70), Vizum.device.embeds (since 5.70), Vizum.device.fileUrls (since 5.70), Vizum.device.fullscreen (since 5.70), Vizum.device.info (since 5.70), Vizum.device.onReload (since 5.70), Vizum.device.print (since 5.87), Vizum.device.printReceipt (since 5.87), Vizum.device.printTest (since 5.88), Vizum.device.store (since 5.82)
Bridge ops: blobUrl, cache, call, embed, embedMute, embedPause, embedPlay, embedRemove, embedSetVolume, embedUnmute, embedUpdate, embeds, fileUrls, fullscreen, info, onReload, print, printReceipt, printTest, storeAll, storeDelete, storeGet, storeSet
blobUrl(url: string): Promise<string | null>— An object URL forurl, served from the offline cache when the network is gone.cache(urls: string[]): Promise<{ cached: number; bytes: number }>— Pre-fetch URLs into the offline cache (LRU, the page's device.cache_mb).call(route: string, payload?: Record<string, any>): Promise<any>— Run the app's device json routeroute(its hook sees V.device).embed(opts: Vizum.DeviceEmbedOptions): Promise<Vizum.DeviceEmbedHandle>— A YouTube / Vimeo player laid over this app by the platform page (a player nested in the sandboxed app frame does not play).embeds(): Promise<number>— Remove every player this app opened; resolves with how many.fileUrls(route: string, ids: number[]): Promise<Record<string, string>>— Signed URLs (24 h) for records of the device file routeroute, keyed by id.fullscreen(): Promise<boolean>— Ask the platform page to go fullscreen.info(): Promise<Vizum.DeviceInfo>— This device: id, name, the record it is linked to, the page it was paired on.onReload(cb: () => void): () => void— The administrator pressed "Reload screen".print(): Promise<true>— SDK 5.87 (Vizum.supports("device-print"), routedevice.print: true) — the supportedwindow.print()of a device page: same grant and rate limit, then this frame prints its own view (style it with@media print).printReceipt(doc: Vizum.ReceiptLine[] | { lines: Vizum.ReceiptLine[] }, opts?: { cut?: boolean; feed?: number; columns?: number }): Promise<true>— SDK 5.87 (Vizum.supports("device-receipt"), routedevice.print: true, min_platform 5.87) — print a receipt on the ESC/POS printer a person paired at this screen.printTest(): Promise<true>— SDK 5.88 (Vizum.supports("device-receipt-test"), routedevice.print: true, min_platform 5.88) — print the platform's fixed test receipt (logo, printer name, paper width, time, a ruler as wide as the paper) on the pa…store: Vizum.DeviceStore— SDK 5.82 — per-device key/value storage (the app frame has no localStorage).
deviceBroker
stability stable · since 5.129 · hosts: desktop, native · permission: permissions.device_broker (a privileged boolean, install_scope admin, min_platform 5.129). Administrators only; each mutating call is confirmed on the host
Members: Vizum.deviceBroker.claim (since 5.129), Vizum.deviceBroker.link (since 5.129), Vizum.deviceBroker.list (since 5.129), Vizum.deviceBroker.rearm (since 5.129), Vizum.deviceBroker.rename (since 5.129), Vizum.deviceBroker.unlink (since 5.129)
Bridge ops: claim, link, list, rearm, rename, unlink
claim(o: { device_id: string; code: string; name?: string; link?: number | null }): Promise<Vizum.BrokerDevice>link(id: number, recordId: number | null): Promise<Vizum.BrokerDevice>list(): Promise<Vizum.BrokerDevice[]>rearm(id: number): Promise<Vizum.BrokerDevice>— A lost response: a new enrolment code; the previous one dies.rename(id: number, name: string): Promise<Vizum.BrokerDevice>unlink(id: number): Promise<Vizum.BrokerDevice>— The device's token is cut first, then the store is told (queued if it is unreachable).
devices
stability stable · permission: permissions.devices: print | scan | location
Members: Vizum.devices.bluetoothSupported (since 5.134), Vizum.devices.location (since 5.24), Vizum.devices.onScan, Vizum.devices.print, Vizum.devices.printReceipt, Vizum.devices.readTag, Vizum.devices.scan, Vizum.devices.serialSupported
Bridge ops: location, onScan, print, printReceipt, readTag, scan
bluetoothSupported(): boolean— SDK 5.134 — whether Web Bluetooth (the standard navigator.bluetooth) works in this app's frame: the host delegated it (declare permissions.devices ["bluetooth"] and min_platform 5.134) AND the browser has it.location(opts?: { timeout?: number; maximumAge?: number; highAccuracy?: boolean }): Promise<{ lat: number; lng: number; accuracy: number; ts: number }>— SDK 5.24 (grant devices:["location"]) — one-shot GPS fix, HOST-mediated (geolocation is auto-denied in the opaque-origin iframe).onScan(cb: (scan: { type: "nfc" | "rfid"; value: string }) => void): Promise<boolean>— NFC taps + RFID/barcode reader scans captured by the desktop (grant devices:["scan"]).print(title: string, lines: string[]): Promise<boolean>— Host-rendered text printing (scriptless).printReceipt(lines: Array<string | { text: string; align?: "left" | "center" | "right"; bold?: boolean; size?: "normal" | "double"; underline?: boolean }>, opts?: { cut?: boolean; [k: string]: any }): Promise<boolean>— Send an ESC/POS receipt to the user's default paired printer (grant devices:["print"]).readTag(opts?: { timeout?: number }): Promise<{ ok: boolean; type?: "nfc" | "rfid"; value?: string; cancelled?: boolean; degraded?: boolean }>— One-shot ACTIVE NFC/RFID read — "read a tag now", the active twin of onScan (grant devices:["scan"], no new token).scan(opts?: Record<string, any>): Promise<{ ok: boolean; text?: string; format?: string; cancelled?: boolean }>— One-shot barcode/QR scan on the native host — the phone camera, or the hardware laser on a rugged PDA (grant devices:["scan"], same grant as onScan/readTag).serialSupported(): boolean— Whether navigator.serial is available (declare permissions.devices ["serial"]).
docs
stability stable · client-side only (no bridge op)
Members: Vizum.docs.del, Vizum.docs.get, Vizum.docs.list, Vizum.docs.put
del(namespace: string, sel: { doc_id?: string } | { all: true }): Promise<{ deleted: number }>— Delete by id or all in the namespace.get(namespace: string, docId: string): Promise<{ doc_id: string; name: string; content_type: string; size: number; data: string } | null>— Fetch one document (with its base64data), or null.list(namespace?: string): Promise<Array<{ doc_id: string; name: string; namespace: string; content_type: string; size: number; at: string }>>— List stored documents (metadata only — no bytes).put(namespace: string, doc: { doc_id: string; name?: string; content_type?: string; data: string }): Promise<{ doc_id: string; size: number }>— Store/replace a source document.
domain
stability stable · client-side only (no bridge op)
Members: Vizum.domain.compile, Vizum.domain.mount, Vizum.domain.open, Vizum.domain.parse, Vizum.domain.summarize
compile(ast: any): Promise<Vizum.Domain>— Pure helper: AST -> Odoo domain array.mount(elOrId: HTMLElement | string, opts: DomainBuilderOpts): Promise<DomainController>— Mount an inline builder into a container (element or element id).open(opts: DomainBuilderOpts): Promise<Vizum.Domain | null>— Open a modal builder; resolves to a domain array, or null if cancelled.parse(domain: Vizum.Domain | string): Promise<any>— Pure helper: Odoo domain (array or string) -> editable AST.summarize(domain: Vizum.Domain | string, opts?: { model?: string; fields?: DomainField[] }): Promise<string>— Human-readable one-liner for a domain (filter chips / previews).
editable
stability stable · since 5.19 · permission: permissions.editable
Members: Vizum.editable.active, Vizum.editable.apply, Vizum.editable.clear, Vizum.editable.onApply, Vizum.editable.publish
Bridge ops: active, apply, clear, publish
active(): Promise<{ app_key: string; doc_id: string; title?: string; snapshot: string; tools: Array<{ op: string; args?: any; desc?: string }> } | null>apply(appKey: string, docId: string, ops: any[]): Promise<{ ok: boolean }>clear(): Promise<{ ok: boolean }>onApply(cb: (ops: any[], docId: string) => void): () => voidpublish(reg: { doc_id: string; title?: string; snapshot: string; tools?: Array<{ op: string; args?: any; desc?: string }> }): Promise<{ ok: boolean }>
errors
stability stable · client-side only (no bridge op)
Members: Vizum.errors.capture, Vizum.errors.catalog (since 5.67), Vizum.errors.is (since 5.67), Vizum.errors.normalize (since 5.67), Vizum.errors.retry (since 5.67)
readonly _count: number— Internal rate-limit counter (capped at 20 per session) — read-only, not something a vApp should set.capture(err: any, ctx?: Record<string, any>): Promise<boolean>catalog(): Record<Vizum.ErrorCode, { category: Vizum.ErrorCategory; retryable: boolean; legacy: string[]; server: string[] }>— SDK 5.67 — the error catalogue this SDK was built with.is(err: unknown, codeOrCategory: Vizum.ErrorCode | Vizum.ErrorCategory): boolean— SDK 5.67 —is(err, "CONFLICT")oris(err, "network"): a code or a category.normalize(err: unknown): Vizum.VizumError— SDK 5.67 — any failure (a rejected call, an Error, a string, a resolved{ok: false, error}value) as a VizumError.retry<T>(fn: (attempt: number) => Promise<T>, opts?: { retries?: number; baseMs?: number; maxMs?: number }): Promise<T>— SDK 5.67 — retryfnwhile it fails with a retryable error, with exponential backoff (defaults: retries 3, baseMs 250, maxMs 4000); any other error rejects at once.
events
stability stable · permission: permissions.events.emit / .listen (built-in host events need none)
Members: Vizum.events.catalog (since 5.67), Vizum.events.emit, Vizum.events.on
Bridge ops: catalog, emit:<name>, listen:<name>
catalog(): Promise<Array<{ name: string; version: number; description: string; schema: Record<string, unknown>; producer: string }>>— 5.67 — the event contracts installed apps declare.emit(name: string, payload?: any): Promise<boolean>— Rejects with VALIDATION_FAILED (5.67) when the payload does not match the event's contract in this app's manifestevent_contracts.on(name: string, cb: (payload: any, meta: Vizum.EventMeta | null) => void): Promise<boolean>—meta(5.67): who emitted the event and the contract version it follows; null for host events and events without a contract's producer.
feeds
stability stable · since 5.89 · hosts: desktop, native, solo · permission: permissions.feeds (privileged; base.group_system for every method)
Members: Vizum.feeds.define (since 5.89), Vizum.feeds.list (since 5.89), Vizum.feeds.remove (since 5.89), Vizum.feeds.revoke (since 5.89), Vizum.feeds.rotateKey (since 5.89)
Bridge ops: define, list, remove, revoke, rotateKey
define(name: string, spec: { model: string; fields: string[]; domain?: unknown[]; label?: string }): Promise<Vizum.DataFeed & { cost: Vizum.DataFeedCost }>— Create or update a feed: one model, scalar and many2one columns, a domain.list(): Promise<Vizum.DataFeed[]>remove(name: string): Promise<true>— Delete the feed and its key.revoke(name: string): Promise<Vizum.DataFeed>— Kill the feed's key (the definition stays).rotateKey(name: string): Promise<Vizum.DataFeed & { secret: string }>— Issue a NEW key, returned ONCE assecret; the previous key dies at once.
files
stability stable · permission: none (app-scoped); publicUrl/revokePublic: permissions.files_public
Members: Vizum.files.del, Vizum.files.download, Vizum.files.get, Vizum.files.getBlob (since 5.71), Vizum.files.getRange (since 5.71), Vizum.files.list, Vizum.files.publicUrl (since 5.27), Vizum.files.put, Vizum.files.putBlob (since 5.70), Vizum.files.revokePublic (since 5.27)
Bridge ops: del, download, get, getBlob, getRange, list, publicUrl, put, putBlob, revokePublic
del(name: string): Promise<boolean>download(filename: string, data: Blob | ArrayBuffer | Uint8Array | string, contentType?: string): Promise<boolean>— Save a file to the user's disk.get(name: string): Promise<{ name: string; b64: string; mimetype: string; size: number; sha256?: string }>getBlob(name: string): Promise<Blob>— SDK 5.71 — the stored file as a Blob, streamed (no base64).getRange(name: string, offset: number, length: number): Promise<Uint8Array>— SDK 5.71 —lengthbytes (at most 32 MB) fromoffset.list(): Promise<Array<{ name: string; size: number; mimetype: string; sha256?: string | null }>>— sha256 is null for a file stored before 5.67 until it is next read.publicUrl(name: string, opts?: { expires_in?: number }): Promise<{ name: string; url: string; size: number; mimetype: string; expires_at?: string }>— 5.67: the link expires —opts.expires_inseconds, 60 ..put(name: string, b64: string, opts?: { sha256?: string }): Promise<{ name: string; size: number; mimetype?: string; sha256?: string }>— 5.67: the bytes must be what the name says (a .png that is not an image is refused, VALIDATION_FAILED);opts.sha256— the checksum the app computed — refuses a file damaged on the way.putBlob(name: string, blob: Blob, opts?: { sha256?: string; onProgress?: (p: { phase: "hash" | "upload"; sent: number; total: number }) => void }): Promise<{ name: string; size: number; mimetype: string; sha256: string }>— SDK 5.70 — a Blob or File streamed by the host (no base64): past 16 MB, up to permissions.file_limits.max_mb (ceiling 200).revokePublic(name: string): Promise<boolean>— Invalidate every public link previously minted for this file.
format
stability stable · client-side only (no bridge op)
Members: Vizum.format.date, Vizum.format.datetime, Vizum.format.money, Vizum.format.number
date(d: Date | string): stringdatetime(d: Date | string): stringmoney(n: number | string): stringnumber(n: number | string, decimals?: number): string
help
stability stable · client-side only (no bridge op)
Members: Vizum.help.button, Vizum.help.show
button(title: string, sections: Array<{ h?: string; p?: string; rows?: [string, string][] }>): HTMLButtonElementshow(title: string, sections: Array<{ h?: string; p?: string; rows?: [string, string][] }>): void
identity
stability stable · since 5.79 · hosts: desktop, native · permission: none (like ui.confirm): the host asks the user for the account password in its own dialog; the password never reaches the app. A vApp that names Vizum.identity sets min_platform 5.79
Members: Vizum.identity.confirm (since 5.79)
Bridge ops: confirm
confirm(opts: Vizum.ConfirmRequest): Promise<Vizum.ConfirmResult>— Ask the HOST to re-confirm the user.
import
stability stable · client-side only (no bridge op)
Members: Vizum.import.csv, Vizum.import.open, Vizum.import.pick, Vizum.import.xlsx
csv(text: string): { columns: string[]; rows: Array<Record<string, any>> }— Parse CSV text (auto-detects , ; tab; full quote handling).open(opts?: { accept?: string; requiredColumns?: string[] }): Promise<{ columns: string[]; rows: Array<Record<string, any>>; filename: string }>— Picker + parse in one call; validates opts.requiredColumns.pick(accept?: string): Promise<File>— Native file picker; resolves with the chosen File (rejects Error("UserDenied")/Error("NoFile")).xlsx(arrayBuffer: ArrayBuffer): Promise<{ columns: string[]; rows: Array<Record<string, any>> }>— Parse an .xlsx ArrayBuffer (native ZIP + inflate + sharedStrings reader).
instances
stability stable · since 5.38 · permission: permissions.instances_api
Members: Vizum.instances.pass (since 5.38)
Bridge ops: pass
pass(): Promise<{ token: string; expiresAt: number }>
jobs
stability stable · permission: none (the app's own manifest jobs)
Members: Vizum.jobs.list, Vizum.jobs.logs, Vizum.jobs.runNow, Vizum.jobs.status
Bridge ops: list, logs, run_now, status
list(): Promise<any[]>logs(name: string): Promise<any[]>runNow(name: string): Promise<any>status(name: string): Promise<any>
license
stability stable · permission: none
Members: Vizum.license.current, Vizum.license.openUpgrade, Vizum.license.require
Bridge ops: current, openUpgrade
current(): Promise<{ licensed: boolean; [k: string]: any }>openUpgrade(): Promise<boolean>require(): Promise<{ licensed: boolean; [k: string]: any }>— Throws Error("LicenseRequired") AND opens the store when unlicensed.
link
stability stable · since 5.104 · hosts: link · permission: an auth "link" page (native.routes, permissions.link_routes); the routes it calls are this app's auth "link" routes
Members: Vizum.link.call (since 5.104), Vizum.link.close (since 5.104), Vizum.link.download (since 5.104), Vizum.link.fileUrl (since 5.104), Vizum.link.info (since 5.104), Vizum.link.upload (since 5.104), Vizum.link.verify (since 5.104)
Bridge ops: call, close, download, fileUrl, info, upload, verify
call<T = any>(route: string, body?: { [k: string]: any }): Promise<T>— Run the app's link json routeroute(its hook sees V.link and V.share).close(): Promise<true>— End this visit (the guest session); the link stays valid.download(route: string, id: number, opts?: { field?: string }): Promise<true>— The page downloads the record's file (this frame cannot).fileUrl(route: string, id: number, opts?: { field?: string }): Promise<string>— A 10-minute URL of a link file route's record (for an img or a video).info(): Promise<Vizum.ShareInfo>— The share this page serves ({@link Vizum.ShareInfo},kind: "link").upload<T = any>(route: string, file: Blob, opts?: Vizum.LinkUploadOptions): Promise<T>— Send one file to a link upload route; resolves the hook'sresult.verify(code: string): Promise<true>— The 6-digit code the app sent (V.links.issue_code in a hook).
mail
stability stable · permission: permissions.orm (or own native models)
Members: Vizum.mail.log, Vizum.mail.post
Bridge ops: log, post
log(model: string, id: number, body: string): Promise<{ id: number }>post(model: string, id: number, body: string, opts?: { subject?: string }): Promise<{ id: number }>
map
stability stable · permission: permissions.geocode (a privileged boolean; min_platform 5.143). The platform server resolves the query with the provider the administrator chose
Members: Vizum.map.geocode (since 5.143), Vizum.map.tileUrl (since 5.48)
Bridge ops: geocode
geocode(query: string, opts?: Vizum.GeocodeOptions): Promise<Vizum.GeocodeResult[]>— SDK 5.143 — resolve an address on the PLATFORM server (never a browser network call), with the geocoding provider the administrator chose in Settings › Vizum › Geocoding.tileUrl(): string
media
stability stable · permission: permissions.rag or permissions.audio
Members: Vizum.media.cancel, Vizum.media.ingest, Vizum.media.jobs, Vizum.media.result, Vizum.media.runNow, Vizum.media.status, Vizum.media.upload
Bridge ops: cancel, ingest, list, result, run_now, status, upload_chunk, upload_finish, upload_init
cancel(id: number): Promise<{ ok: boolean }>ingest(cfg: { source_type: string; source_ref: string; web_url?: string; name?: string; namespace: string; language?: string; max_minutes?: number; scope_kind?: "user" | "group" | "global"; [k: string]: any }): Promise<{ id: number; state: string }>jobs(): Promise<Array<Record<string, any>>>result(id: number): Promise<{ transcript?: { text: string; segments: any[] }; [k: string]: any }>— -> the transcript once state === 'done'.runNow(id: number): Promise<Record<string, any>>status(id: number): Promise<{ state: string; progress?: number; duration_s?: number; chunks_indexed?: number; [k: string]: any }>upload(file: File | Blob, cfg?: { name?: string; language?: string; translate?: boolean; max_minutes?: number; doc_id?: string; index_rag?: boolean; namespace?: string; scope_kind?: "user" | "group" | "global"; window_seconds?: number; onProgress?: (p: { phas…— SDK v5.21 — chunked upload of a LOCAL GB file → async server transcription.
meta
stability stable · permission: permissions.orm (or own native models)
Members: Vizum.meta.fieldAccess (since 5.130), Vizum.meta.fields, Vizum.meta.models, Vizum.meta.selection
Bridge ops: fields, models, selection, fieldAccess
fieldAccess(model: string): Promise<{ model: string; hidden: string[]; no_read: string[]; readonly: string[]; required: string[]; not_exportable: string[]; rev: string }>— 5c — what this user's access restrictions (Access Manager profiles, the manifest's fieldgroups) do tomodel's fields.fields(model: string): Promise<Array<{ name: string; label: string; type: string; required: boolean; readonly: boolean; selection: Array<[string, string]> | null; relation: string | null }>>models(): Promise<Array<{ model: string; label: string; ops: string[]; fields: string[] | null }>>selection(modelDotField: string): Promise<Array<[string, string]>>
motion
stability stable · since 5.155 · hosts: desktop, native, solo · permission: permissions.sensors ['motion']
Members: Vizum.motion.on, Vizum.motion.start, Vizum.motion.status, Vizum.motion.stop
Bridge ops: start, status, stop
on(name: "state", cb: (e: { state: "stopped" | "no_events"; reason?: string }) => void): Vizum.Subscriptionstart(opts?: { rate?: number }): Promise<{ ok: true; rate: number } | { ok: false; reason: "unsupported" | "insecure_context" | "denied" | "busy" | "stopped" | "hidden" | "background" }>—rate10..60 Hz.status(): Promise<{ running: boolean; rate: number | null } & Vizum.MotionAvailability>stop(): Promise<{ ok: true }>
net
stability stable · permission: permissions.net (host allowlist)
Members: Vizum.net.fetch, Vizum.net.fetchImage (since 5.74)
Bridge ops: fetch, fetchImage
fetch(url: string, opts?: { method?: string; headers?: Record<string, string>; body?: any; timeout?: number }): Promise<{ status: number; ok: boolean; headers: Record<string, string>; binary: boolean; body: string; json?: any }>— Headers / URL / string bodies may carry$secret:nameplaceholders — substituted server-side, the value never reaches the browser.fetchImage(url: string, opts?: { max_bytes?: number; timeout?: number }): Promise<{ content_type: "image/png" | "image/jpeg" | "image/gif" | "image/webp"; size: number; sha256: string; base64: string }>— SDK 5.74 — download ONE image server-side.
notifications
stability stable · permission: none (app + user scoped)
Members: Vizum.notifications.clear, Vizum.notifications.count, Vizum.notifications.create, Vizum.notifications.list, Vizum.notifications.markRead
Bridge ops: clear, count, create, list, mark_read
clear(): Promise<boolean>count(unread?: boolean): Promise<number>create(n: { title: string; body?: string; priority?: "low" | "normal" | "high"; user_id?: number; action?: Record<string, any> }): Promise<AppNotification>list(query?: { unread?: boolean; limit?: number; offset?: number }): Promise<AppNotification[]>markRead(ids: number[], value?: boolean): Promise<boolean>
oauth
stability stable · permission: permissions.oauth
Members: Vizum.oauth.accounts, Vizum.oauth.configure, Vizum.oauth.configured, Vizum.oauth.connect, Vizum.oauth.connected, Vizum.oauth.disconnect, Vizum.oauth.fetch, Vizum.oauth.status
Bridge ops: configure, connect, disconnect, fetch, status
accounts(provider: string): Promise<Array<{ account_ref: string; name: string; shared: boolean; expired: boolean }>>— SDK 5.26 — the accounts of one provider (see status().accounts).configure(provider: string, conf: { client_id: string; client_secret: string; scopes: string; auth_url?: string; token_url?: string; api_base?: string; }): Promise<{ ok: boolean; configured: boolean; unchanged?: boolean }>— Admin-only: store the provider's client_id/secret/scopes in the DB registry.configured(provider: string): Promise<boolean>— True once an admin has configured the provider's client_id/secret/scopes.connect(provider: string, opts?: { shared?: boolean; related?: string[] } ): Promise<{ provider: string; connected: boolean }>— Host-opened consent popup; resolves when the provider round-trip lands.connected(provider: string): Promise<boolean>disconnect(provider: string, account?: string | null): Promise<boolean>—account= "" for the caller's own connection, or a page/business id.fetch(provider: string, path: string, opts?: { method?: string; headers?: Record<string, string>; body?: any }): Promise<{ status: number; ok: boolean; headers: Record<string, string>; binary: boolean; body: string; json?: any }>— Proxied call to the provider's api_base with the user's token injected.status(): Promise<Array<{ provider: string; configured: boolean; connected: boolean; /** SDK 5.26 — the accounts this caller can act as. A brand with five Facebook * pages is FIVE entries, not one boolean. 'expired' = the token lapsed and * cannot be refreshe…
ocr
stability stable · since 5.4 · permission: permissions.ocr
Members: Vizum.ocr.recognize, Vizum.ocr.recognizeBatch, Vizum.ocr.scan, Vizum.ocr.status
Bridge ops: recognize, status
recognize(input: File | Blob | string | { data: string; content_type?: string } | { namespace: string; doc_id: string }, opts?: { want?: "text" | "blocks" | "tables"; languages?: string[]; mode?: "local" | "managed"; pages?: string; dpi?: number; preprocess?:…— input: a File | Blob | base64 string | {data,content_type} | {namespace,doc_id}.recognizeBatch(inputs: Array<File | Blob | string>, opts?: { want?: "text" | "blocks" | "tables"; languages?: string[]; onProgress?: (p: { item: number; total: number }) => void } ): Promise<{ results: Array<Vizum.OcrResult | { error: string }>; pages_total: …— Batch recognise; per-item progress via opts.onProgress.scan(opts?: { want?: "text" | "blocks" | "tables"; languages?: string[]; camera?: boolean; mode?: "local" | "managed"; pages?: string; dpi?: number; preprocess?: boolean; model?: string; onProgress?: (p: { page: number; total: number }) => void } ): Promise<V…— Quick capture UI (file, or the device camera withcamera: true) → an OcrResult, or null if cancelled.status(): Promise<{ mode: "local" | "managed"; provider: string; languages?: string[]; available: boolean; credits_per_page?: number | null }>— Which engine is live + a cost hint.
offline
stability stable · permission: status/pending: none; run/flush: permissions.actions; datasets/sync/query/readGroup/conflicts/resolve: permissions.offline.datasets; create/write: permissions.offline.writes (5.80)
Members: Vizum.offline.conflicts (since 5.80), Vizum.offline.create (since 5.80), Vizum.offline.datasets (since 5.80), Vizum.offline.flush, Vizum.offline.on, Vizum.offline.onAppUpdated (since 5.95), Vizum.offline.onConflict (since 5.80), Vizum.offline.onFlushed, Vizum.offline.onSync (since 5.80), Vizum.offline.pending, Vizum.offline.query (since 5.80), Vizum.offline.readGroup (since 5.80), Vizum.offline.resolve (since 5.80), Vizum.offline.run, Vizum.offline.status, Vizum.offline.sync (since 5.80), Vizum.offline.write (since 5.80)
Bridge ops: conflicts, create, datasets, flush, pending, query, readGroup, resolve, run, status, sync, write
conflicts(): Promise<Vizum.OfflineConflict[]>— SDK 5.80 — the changes the server refused as a conflict and that wait for a decision.create(model: string, vals: Record<string, any>, opts?: Vizum.Idempotent & { expect?: Record<string, any> }): Promise<{ id: string; queued: true; opId: string }>— SDK 5.80 — queue a create (a model inoffline.writeswith opcreate).datasets(): Promise<Vizum.OfflineDataset[]>— SDK 5.80 — the datasets the manifest'soffline.datasetsdeclares, with what the device holds of each.flush(): Promise<{ sent: number; failed: Array<{ id: string; error: string }>; remaining: number }>on(cb: (s: { online: boolean }) => void): voidonAppUpdated(cb: (u: { running: string | null; installed: string }) => void): { stop(): void }— SDK 5.95 — the version installed on the server is not the one this page runs (learned when the device reconnects and syncs).onConflict(cb: (c: Vizum.OfflineConflict) => void): { stop(): void }— SDK 5.80 — a queued change came back as a conflict.onFlushed(cb: (r: { sent: number; remaining: number }) => void): voidonSync(cb: (r: Vizum.OfflineSyncResult) => void): { stop(): void }— SDK 5.80 — a dataset finished syncing.pending(): Promise<Array<{ id: string; action: string; params: object; ts: number }>>query(name: string, opts?: Vizum.OfflineQuery): Promise<Vizum.OfflineQueryResult>— SDK 5.80 — read a dataset from the device:domain(= != < <= > >= in not in like ilike =like =ilike, & | !),order,limit,offset,fields.readGroup(name: string, opts: Vizum.OfflineReadGroup): Promise<Array<Record<string, any>>>— SDK 5.80 — a read_group over a dataset, on the device: one or twogroupbyfields (date fields with:day|week|month|quarter|year), aggregatesfield:count|sum|avg|min|max.resolve(id: number, choice: "keep_mine" | "keep_server" | "discard"): Promise<{ resolved: boolean; choice: string }>— SDK 5.80 — settle one:keep_minesends it again over the server's values,keep_serverdrops it and shows the server's values,discarddrops it.run(action: string, params?: { ids?: number[]; [k: string]: any }, opts?: Vizum.Idempotent): Promise<{ ok: boolean; result: any } | { id: string; queued: true }>status(): Promise<{ online: boolean }>sync(name?: string): Promise<{ datasets: Vizum.OfflineSyncResult[] }>— SDK 5.80 — bring one dataset (or all) up to date now.write(model: string, ids: Array<number | string>, vals: Record<string, any>, opts?: Vizum.Idempotent): Promise<{ queued: true; opId: string; opIds: string[] }>— SDK 5.80 — queue a write (a model inoffline.writeswith opwrite).
orm
stability stable · permission: permissions.orm (or own native models); per-model ops, fields, domain
Members: Vizum.orm.call, Vizum.orm.callOn, Vizum.orm.create, Vizum.orm.live, Vizum.orm.query, Vizum.orm.read, Vizum.orm.readGroup, Vizum.orm.search, Vizum.orm.searchCount, Vizum.orm.searchRead, Vizum.orm.unlink, Vizum.orm.watch, Vizum.orm.write, Vizum.orm.writeBlob (since 5.70)
call(model: string, method: string, args?: any[], opts?: { context?: Vizum.OrmContext; ids?: number[] } & Vizum.Idempotent & Vizum.Guarded): Promise<any>—opts.context: see {@link Vizum.OrmContext}.callOn(model: string, ids: number[], method: string, args?: any[], opts?: { context?: Vizum.OrmContext } & Vizum.Idempotent & Vizum.Guarded): Promise<any>—opts.context: see {@link Vizum.OrmContext}.create(model: string, vals: object, opts?: { context?: Vizum.OrmContext } & Vizum.Idempotent): Promise<number[]>— SDK 5.67idempotencyKey: a retry with the same key never creates a second record.live(renderFn: () => any, opts?: { interval?: number; debounce?: number; models?: string[] }): { refresh(): void; stop(): void }— SDK v5.4 — LIVE DATA the easy way.query(model: string): Vizum.Queryread(model: string, ids: number[], fields?: string[]): Promise<any[]>readGroup(model: string, domain: Vizum.Domain, groupby: string[], aggregates: string[], opts?: { limit?: number; offset?: number; order?: string }): Promise<any[]>search(model: string, domain?: Vizum.Domain, opts?: object): Promise<number[]>searchCount(model: string, domain?: Vizum.Domain): Promise<number>searchRead(model: string, domain?: Vizum.Domain, fields?: string[], opts?: object): Promise<any[]>unlink(model: string, ids: number[]): Promise<boolean>watch(model: string, domain: Vizum.Domain, cb: (h: { count: number; max_write: string | null }) => void, opts?: { interval?: number }): Vizum.Watcherwrite(model: string, ids: number | number[], vals: object, opts?: { expect?: Record<string, unknown>, context?: Vizum.OrmContext }): Promise<boolean | { updated: boolean; current: Record<string, unknown> }>— Withopts.expect({field: value}, SDK 5.43) this is a COMPARE-AND-SET on a NATIVE model: the rows are locked, re-read, and written only if every expected field still holds its value — and it resolves to `{updated, cur…writeBlob(model: string, id: number, field: string, blob: Blob): Promise<{ id: number; field: string; size: number; sha256: string }>— SDK 5.70 — stream a file into a binary field of one record (what a devicefileroute plays); checked likewrite(model, [id], {[field]: base64}).
pairedDevices
stability stable · hosts: desktop, native, solo · permission: native.routes with auth "device" (permissions.device_routes); changes need base.group_system
Members: Vizum.pairedDevices.claim (since 5.70), Vizum.pairedDevices.link (since 5.70), Vizum.pairedDevices.list (since 5.70), Vizum.pairedDevices.reload (since 5.70), Vizum.pairedDevices.rename (since 5.70), Vizum.pairedDevices.revoke (since 5.70)
Bridge ops: claim, link, list, reload, rename, revoke
claim(pin: string, opts: { name: string; link?: number | null }): Promise<Vizum.PairedDevice>— Claim the PIN a device page shows; the device receives its token on its next poll.link(id: number, link: number | null): Promise<Vizum.PairedDevice>list(): Promise<Vizum.PairedDevice[]>reload(id: number): Promise<Vizum.PairedDevice>— The screen reloads on its next heartbeat.rename(id: number, name: string): Promise<Vizum.PairedDevice>revoke(id: number): Promise<{ ok: true }>— Unpair: the device's token dies now and the page returns to the PIN screen.
perf
stability stable · permission: none (sessions: administrator)
Members: Vizum.perf.desktop, Vizum.perf.ping, Vizum.perf.sessions
Bridge ops: desktop, ping, sessions
desktop(): Promise<{ windows: { total: number; open: number; minimized: number; vapps: number; actions: number; byKind: Record<string, number>; activeId: number | null }; memory: { heapMB: number; totalMB: number; limitMB: number; pct: number } | null; fps: n…— This browser tab's live runtime: windows open, JS heap, sampled FPS, desktop state.ping(): Promise<{ latencyMs: number; server: { ts: string; uid: number } }>— Round-trip latency (ms) of a cheap server call — a proxy for how busy the Odoo server is (server CPU/RAM are not exposed to apps).sessions(): Promise<{ supported: boolean; online_users: number | null; online_mine: number; sessions_total: number | null; sessions_mine: number | null; sessions_note?: string }>— ADMIN only: active-session counts across the instance.
permissions
stability stable · permission: none
Members: Vizum.permissions.explain, Vizum.permissions.raw
Bridge ops: explain, raw
explain(): Promise<Array<{ capability: string; detail: string; risk: "low" | "medium" | "high" }>>raw(): Promise<Record<string, any>>
phone
stability stable · since 5.31 · hosts: desktop · permission: permissions.media ['microphone']
Members: Vizum.phone.answer, Vizum.phone.dial, Vizum.phone.dtmf, Vizum.phone.hangup, Vizum.phone.mute, Vizum.phone.onState, Vizum.phone.register, Vizum.phone.state, Vizum.phone.unregister
Bridge ops: answer, dial, dtmf, hangup, mute, register, state, unregister
answer(): Promise<any>— Answer what is ringing.dial(opts?: Record<string, any>): Promise<any>dtmf(digits: string): Promise<any>hangup(): Promise<any>— Ends a live call, and REJECTS one that never got answered — a BYE on an unestablished session is ignored and the caller keeps ringing.mute(on?: boolean): Promise<any>onState(cb: (state: string) => void): () => void— "registered" | "registration" | "ringing" | "dialing" | "answered" | "ended" | "mute" | "audio_blocked" | "transport_down"register(ticket?: Record<string, any>): Promise<any>state(): Promise<any>unregister(): Promise<any>
rag
stability stable · permission: permissions.rag
Members: Vizum.rag.create, Vizum.rag.estimate, Vizum.rag.feedback, Vizum.rag.fields, Vizum.rag.groups, Vizum.rag.indexDoc, Vizum.rag.jobs, Vizum.rag.listChunks, Vizum.rag.models, Vizum.rag.remove, Vizum.rag.runNow, Vizum.rag.update
Bridge ops: create, delete, estimate, feedback, fields, groups, index_doc, list, list_chunks, models, run_now, update
create(cfg: { name: string; model_name: string; domain?: Vizum.Domain; namespace: string; field_names: string[]; scope_kind?: "user" | "group" | "global"; interval_number?: number; interval_type?: "minutes" | "hours" | "days"; max_per_run?: number; [k: string…estimate(cfgOrId: number | Record<string, any>): Promise<{ ok: boolean; pending: number; this_run: number; est_chunks: number; per_record: number; note?: string }>— Estimate a SAVED job (pass its id) or an ad-hoc config object.feedback(cfg: { ns?: string; namespace?: string; docIds?: string | string[]; doc_ids?: string | string[]; rating: "good" | "ok" | "bad"; q?: string; question?: string; a?: string; answer?: string }): Promise<{ ok: boolean }>— SDK 5.19 (active learning, still evolving) — rate an answer/its sources good | ok | bad, tagged with the doc_id(s) it was grounded in.fields(model: string): Promise<Array<Record<string, any>>>— A model's text+binary fields.groups(): Promise<Array<Record<string, any>>>— Admin — groups eligible for shared-scope jobs.indexDoc(cfg: { namespace: string; doc_id: string; name?: string; content_type?: string; scope?: string }): Promise<{ ok: boolean; doc_id: string; chunks: number; kinds: { text: number; table: number; image: number } }>— SDK v5.19 — MULTIMODAL index of an already-uploaded document (its bytes stored via Vizum.docs).jobs(): Promise<Array<Record<string, any>>>listChunks(cfg: Record<string, any>): Promise<Array<Record<string, any>>>models(): Promise<Array<Record<string, any>>>— Indexable models.remove(id: number): Promise<{ deleted: boolean }>runNow(id: number): Promise<Record<string, any>>update(cfg: { id: number; [k: string]: any }): Promise<Record<string, any>>
realtime
stability stable · permission: none (app-scoped)
Members: Vizum.realtime.channel
Bridge ops: publish, subscribe, unsubscribe
channel(name: string): RealtimeChannel
refresh
stability stable · client-side only (no bridge op)
Members: Vizum.refresh.on
on(cb: () => void, opts?: { interval?: number; debounce?: number; models?: string[] }): { refresh(): void; stop(): void }
reports
stability stable · permission: permissions.reports (per report name)
Members: Vizum.reports.data, Vizum.reports.download, Vizum.reports.html
Bridge ops: data, download, html
data(reportName: string, ids: number[]): Promise<{ b64: string; size: number }>— SDK v3: the PDF as bytes for re-upload (e.g.download(reportName: string, ids: number[], filename?: string): Promise<boolean>html(reportName: string, ids: number[]): Promise<{ html: string }>— Report Builder 1.0: a self-contained HTML string (same-origin CSS already inlined, <script> stripped) — for a live preview inside the app; the app iframe cannot fetch /report/html/...
roles
stability stable · permission: none
Members: Vizum.roles.has, Vizum.roles.list, Vizum.roles.mine
Bridge ops: has, list, mine
has(name: string): Promise<boolean>list(): Promise<Array<{ name: string; label: string; active: boolean }>>mine(): Promise<string[]>
schedule
stability stable · since 5.128 · hosts: desktop, native · permission: permissions.schedule (a boolean, not privileged; min_platform 5.128). Always the current user's, this app's
Members: Vizum.schedule.ack (since 5.128), Vizum.schedule.clear (since 5.128), Vizum.schedule.list (since 5.128), Vizum.schedule.missed (since 5.128), Vizum.schedule.set (since 5.128)
Bridge ops: ack, clear, list, missed, set
ack(ids: number[]): Promise<number>— Acknowledge missed occurrences byfire_id; resolves how many were marked.clear(id: string): Promise<boolean>— Remove the scheduleid; resolvesfalsewhen there was none.list(): Promise<Vizum.Schedule[]>— This app's schedules for the current user (at most 50).missed(): Promise<Vizum.ScheduleMissed[]>— Occurrences fired in the last 24 hours that the app has not acknowledged (at most 20, oldest first).set(s: Vizum.ScheduleSpec): Promise<Vizum.Schedule>— Create or replace the scheduleid.
scope
stability stable · permission: none
Bridge ops: get
script
stability stable · permission: permissions.logic = 'script'
Members: Vizum.script.run
Bridge ops: run
run(name: string, payload?: Record<string, any>): Promise<any>
secrets
stability stable · permission: permissions.secrets
Members: Vizum.secrets.configure, Vizum.secrets.exists, Vizum.secrets.names
Bridge ops: configure, names
configure(name: string): Promise<{ name: string; configured: boolean }>— Host-rendered dialog where an ADMIN types the value (rejects Error("UserDenied") on cancel).exists(name: string): Promise<boolean>names(): Promise<Array<{ name: string; configured: boolean }>>— Every manifest-declared secret + whether an admin configured it.
server
stability stable · since 5.96 · hosts: desktop, native · permission: permissions.server_metrics (privileged; install_scope admin; base.group_system for every method)
Members: Vizum.server.info (since 5.96), Vizum.server.metrics (since 5.96)
Bridge ops: info, metrics
info(): Promise<Vizum.ServerInfo>— Hosting, scope, worker configuration, retention and which metrics this hosting can read.metrics(query: Vizum.ServerMetricsQuery): Promise<Vizum.ServerMetrics>— The series betweenfromandto, one point perstepseconds (at most 1,500 points; a longer range is refused with VALIDATION_FAILED naming the smallest step that fits).
sessions
stability stable · permission: permissions.sessions (+ administrator)
Members: Vizum.sessions.getPolicy, Vizum.sessions.revoke, Vizum.sessions.revokeUser, Vizum.sessions.setActivityAudit, Vizum.sessions.setActivityAuditGlobal, Vizum.sessions.setLoginAlerts, Vizum.sessions.setSessionTimeout
Bridge ops: getPolicy, revoke, revokeUser, setActivityAudit, setActivityAuditGlobal, setLoginAlerts, setSessionTimeout
getPolicy(): Promise<{ login_alerts: boolean; session_timeout: { enabled: boolean; hours: number; min_hours: number }; activity_audit: { enabled: boolean; global: boolean }; supported: boolean }>— Read the session security policy.revoke(deviceId: number): Promise<{ revoked: number[] }>— Revoke one session by its res.device id (signs that session out).revokeUser(userId: number): Promise<{ revoked: number[] }>— Revoke ALL of a user's other sessions by res.users id.setActivityAudit(on: boolean): Promise<{ login_alerts: boolean; session_timeout: { enabled: boolean; hours: number; min_hours: number }; activity_audit: { enabled: boolean; global: boolean }; supported: boolean }>— Master switch for the activity audit (admin).setActivityAuditGlobal(on: boolean): Promise<{ login_alerts: boolean; session_timeout: { enabled: boolean; hours: number; min_hours: number }; activity_audit: { enabled: boolean; global: boolean }; supported: boolean }>— ADVANCED master switch: also audit native-UI / RPC writes (a global per-write hook on the whole DB).setLoginAlerts(on: boolean): Promise<{ login_alerts: boolean; session_timeout: { enabled: boolean; hours: number; min_hours: number }; activity_audit: { enabled: boolean; global: boolean }; supported: boolean }>— Enable/disable in-app "new sign-in" alerts (admin; needs Odoo 18+).setSessionTimeout(on: boolean, hours?: number): Promise<{ login_alerts: boolean; session_timeout: { enabled: boolean; hours: number; min_hours: number }; activity_audit: { enabled: boolean; global: boolean }; supported: boolean }>— Enable/disable idle-session auto-revoke afterhours(admin; needs Odoo 18+).
settings
stability stable · permission: none (set: administrator)
Members: Vizum.settings.get, Vizum.settings.open, Vizum.settings.set (since 5.35)
Bridge ops: get, open, set
get(): Promise<Record<string, any>>— Stored values with manifest defaults merged in.open(): Promise<Record<string, any>>— Opens the host settings dialog (admin saves; rejects Error("UserDenied") on cancel).set(values: Record<string, any>): Promise<Record<string, any>>— SDK 5.35 — write the app's own settings from the app's own form, for choices whose options only exist at runtime (models this gateway offers, knowledge bases that exist).
team
stability stable · since 5.60 · permission: permissions.team
Members: Vizum.team.history (since 5.116), Vizum.team.invite, Vizum.team.members, Vizum.team.remove, Vizum.team.roles, Vizum.team.save, Vizum.team.setActive, Vizum.team.setPassword, Vizum.team.whoami (since 5.65)
Bridge ops: history, invite, members, remove, roles, save, set_active, set_password, whoami
history(query?: { user_id?: number; limit?: number; offset?: number }): Promise<Vizum.TeamHistoryRow[]>— SDK 5.116 — the team's audit trail, newest first, managers only.invite(id: number): Promise<boolean>— Send the sign-up / reset-password email (needs auth_signup).members(query?: { role?: string; has_role?: string }): Promise<Vizum.TeamMember[]>— 5.116:rolekeeps the members whose PRIMARY role it is;has_rolethe members who play it at all.remove(id: number): Promise<boolean>— Take away every role of this app (the user's other groups stay).roles(): Promise<Array<{ name: string; label: string; help: string; grant?: "manager" | "admin" }>>— 5.116:grant— "admin" when only a settings administrator gives or takes the role.save(member: { id?: number; name?: string; login?: string; email?: string; phone?: string; role?: string; roles?: string[]; password?: string }): Promise<Vizum.TeamMember>— Create (noid) or update a member;roleis a role name from the manifest.setActive(id: number, active: boolean): Promise<Vizum.TeamMember>setPassword(id: number, password: string): Promise<boolean>whoami(): Promise<{ uid: number; manager: boolean; role: string | false; roles?: string[]; settings_admin?: boolean }>— SDK 5.65 — the caller: is a manager of the team, and the role they play.
telemetry
stability stable · permission: none
Members: Vizum.telemetry.summary, Vizum.telemetry.track
Bridge ops: summary, track
summary(query?: { event?: string; date_from?: string; date_to?: string }): Promise<{ totals: Record<string, number>; total: number }>track(event: string, n?: number): Promise<boolean>
ui
stability stable · permission: permissions.ui lists the op (confirm/openHtml/openDocument/exitFullscreen always allowed)
Members: Vizum.ui.addPaletteCommand, Vizum.ui.confirm, Vizum.ui.editDomain, Vizum.ui.evalDomain, Vizum.ui.exitFullscreen (since 5.85), Vizum.ui.fullscreen (since 5.85), Vizum.ui.notify, Vizum.ui.onFullscreenChange (since 5.85), Vizum.ui.openApp, Vizum.ui.openDocument, Vizum.ui.openHtml, Vizum.ui.openList, Vizum.ui.openRecord, Vizum.ui.openUrl (since 5.21), Vizum.ui.openView (since 5.100), Vizum.ui.pinWidget, Vizum.ui.setBadge, Vizum.ui.sound
Bridge ops: addPaletteCommand, confirm, editDomain, evalDomain, exitFullscreen, fullscreen, notify, openApp, openDocument, openHtml, openList, openRecord, openUrl, openView, pinWidget, setBadge, sound
addPaletteCommand(label: string): Promise<boolean>confirm(message: string, title?: string): Promise<boolean>— The host's own confirmation dialog (Odoo's, neverwindow.confirm): true on Confirm, false on Cancel, Escape or close.editDomain(model: string, domain?: string, title?: string): Promise<string | null>— Host-rendered REAL Odoo domain builder (DomainSelectorDialog).evalDomain(domainStr: string): Promise<Vizum.Domain>— Evaluate an Odoo domain STRING into a concrete domain ARRAY in the host (py.js), resolving relative-date virtual operators.exitFullscreen(): Promise<{ active: boolean; browser: boolean; supported?: false }>— SDK 5.85: hand the screen back.fullscreen(on?: boolean): Promise<{ active: boolean; browser: boolean; supported?: false }>— SDK 5.85 (grant "fullscreen"): take the whole screen for this app.notify(msg: string, type?: "info" | "success" | "warning" | "danger"): Promise<boolean>onFullscreenChange(cb: (e: { active: boolean }) => void): () => void— SDK 5.85:cb({ active: false })when the full screen ends for a reason the app did not ask for (the host's Exit button, Escape, the browser leaving fullscreen).openApp(key: string, payload?: any): Promise<boolean>— Open another INSTALLED vapp by key (grant "openApp").openDocument(doc: { data: string; contentType: string; name?: string }): Promise<{ opened: boolean; mode: "view" | "download"; popupBlocked?: boolean }>— Open the app's OWN stored document (a KB source PDF, an image, a text file).openHtml(html: string, title?: string): Promise<boolean>— Open a self-contained HTML document (e.g.openList(model: string, domain?: Vizum.Domain, label?: string): Promise<boolean>openRecord(model: string, id: number, label?: string): Promise<boolean>openUrl(url: string): Promise<boolean>— Open an EXTERNAL http(s) URL in a new tab.openView(spec: Vizum.UiOpenViewSpec): Promise<boolean>— C-IDE-2 — open ONE real Odoo view (grant "openView"): a kanban, calendar, pivot, graph, activity, list or form view, or a specific primary view by xmlid.pinWidget(spec: { label: string; model: string; measure?: string; domain?: Vizum.Domain; format?: string; icon?: string; [k: string]: any }): Promise<boolean>— SDK 4.1 (grant "pinWidget"): pin a LIVE KPI to the desktop.setBadge(n: number | string): Promise<boolean>sound(name?: "message" | "notification" | "share" | "snap" | "trash" | "error"): Promise<boolean>— SDK 5.30 — a short synthesized chime for an event the user should HEAR (an arriving chat message above all).
user
stability stable · permission: none
Members: Vizum.user.groups, Vizum.user.id, Vizum.user.inGroup, Vizum.user.info, Vizum.user.isAdmin, Vizum.user.isSystem, Vizum.user.login, Vizum.user.name
Bridge ops: groups, in_group, info
groups(): Promise<string[]>id: numberinGroup(xmlid: string): Promise<boolean>info(): Promise<{ id: number; name: string; login: string; isAdmin: boolean; isSystem: boolean; lang: string; tz: string; company: { id: number; name: string } }>isAdmin: booleanisSystem: booleanlogin: stringname: string
vector
stability stable · permission: permissions.vector
Members: Vizum.vector.del, Vizum.vector.dump, Vizum.vector.getDocAccess, Vizum.vector.global, Vizum.vector.graph, Vizum.vector.graphClear, Vizum.vector.graphDocs, Vizum.vector.graphRecompute, Vizum.vector.indexedDocs, Vizum.vector.moveScope (since 5.83), Vizum.vector.namespaces, Vizum.vector.pgvActivate, Vizum.vector.pgvBackfill, Vizum.vector.pgvIndex, Vizum.vector.pgvProvision, Vizum.vector.pgvStatus, Vizum.vector.principals, Vizum.vector.search, Vizum.vector.setDocAccess, Vizum.vector.sharedNamespaces (since 5.83), Vizum.vector.stats, Vizum.vector.tags, Vizum.vector.unpack, Vizum.vector.upsert
Bridge ops: delete, docdelete, docget, doclist, docput, dump, get_doc_access, global, graph, graph_clear, graph_docs, graph_recompute, indexed_docs, move_scope, namespaces, pgvactivate, pgvbackfill, pgvindex, pgvprovision, pgvstatus, principals, search, set_doc_access, shared_namespaces, stats, tags, upsert
del(namespace: string, sel: { id?: number | number[] } | { doc_id?: string | string[] } | { doc_id: string | string[]; gen: string | number } | { doc_id: string | string[]; keep_gen: string | number } | { all: true }): Promise<{ deleted: number }>—{doc_id, keep_gen}(platform 5.44) retires every chunk of the doc whosemeta.genis notkeep_gen(commit a rewrite);{doc_id, gen}retires only that generation (roll a failed rewrite back).dump(namespace: string, opts?: { offset?: number; limit?: number }): Promise<{ namespace: string; dim: number; model: string | null; total: number; offset: number; limit: number; items: Array<{ id: number; doc_id: string; seq: number; text: string; meta: Reco…— Small-corpus offline fallback (packed base64 float32; refused above a few thousand vectors).getDocAccess(namespace: string, docId: string): Promise<{ is_public: boolean; users: Array<{ id: number; name: string }>; groups: Array<{ id: number; name: string }>; can_manage: boolean }>— Per-document ACCESS grants (users / groups / public=internal).global(namespace: string, query: string, opts?: Record<string, any>): Promise<Array<{ cid: string; title: string; summary: string; size: number; rank: number; member_names: string[] }>>— GraphRAG G2 global/map-reduce — the community summaries most relevant toquery(broad/thematic "what are the main themes" questions).graph(namespace: string): Promise<{ entities: Array<{ name: string; type: string; rank: number; mentions: number; community: number }>; edges: Array<{ src: string; dst: string; relation: string; weight: number }> }>— GraphRAG G1 — the knowledge graph for a namespace: entities (with PageRankrank+community) + relation edges (scoped like the chunks).graphClear(namespace: string): Promise<{ ok: boolean; cleared: number }>— WIPE the namespace's knowledge graph for the caller's writable scope(s) so it can be rebuilt clean.graphDocs(namespace: string): Promise<{ doc_ids: string[]; counts: Record<string, number> }>— Which documents in a namespace are IN the knowledge graph (the per-doc "in the graph" indicator's authoritative data source) + how many entities mention each.graphRecompute(namespace: string): Promise<{ communities: number; summarized: number; entities_described: number; entities: number; edges: number } | { busy: true }>— GraphRAG on-demand recompute: rebuild the namespace's graph NOW (PageRank + community detection/summaries + entity descriptions) for the CALLER's writable scope, so a "Temas" panel fills right after ingest instead of wa…indexedDocs(namespace: string): Promise<{ doc_ids: string[]; counts: Record<string, number>; degraded?: boolean; degraded_reason?: string }>— Which documents are ACTUALLY indexed on the server fornamespace, in the caller's readable scope (global ∪ own ∪ my-groups + per-doc grants), with per-doc chunk counts — server truth for a Library, not a local registr…moveScope(namespace: string, opts: { from: "global" | "user"; to: "user" | "global"; user_id?: number; doc_id?: string | string[]; batch?: number }): Promise<{ moved: number; remaining: number; conflicts: number; done: boolean; from: string; to: string; user_…— SDK 5.83 — move one namespace's rows between the shared scope and a user's scope WITHOUT re-embedding: the owner of the stored rows changes (embedding, text, metadata and chunk order stay as stored; nothing is embedded …namespaces(): Promise<Array<{ name: string; count: number; model: string | null; dim: number }>>pgvActivate(opts: { active: boolean; force?: boolean }): Promise<{ available: boolean; installed: boolean; column_ready: boolean; active: boolean; mode: string; pending_backfill: number; error?: string; [k: string]: unknown }>— Switch RAG storage to Mode 3 or back to Local (admin, platform 5.45).pgvBackfill(): Promise<{ backfilled: number; skipped?: number; remaining: number; error?: string }>— Migrate existing base64 vectors into pgvector (admin, batched).pgvIndex(opts?: { blocking?: boolean }): Promise<{ extversion: string | null; dims: number[]; present: number[]; built: number[]; invalid: number[]; unindexable?: number[]; max_ann_dim?: number; sql: string[]; blocking: boolean; error?: string; failed?: { dim…— Per-dimension hnsw index state (admin, platform 5.45).pgvProvision(): Promise<{ available: boolean; installed: boolean; column_ready: boolean; active: boolean; mode: string; pending_backfill: number }>— Install the pgvector extension + column in this database (admin).pgvStatus(): Promise<{ available: boolean; installed: boolean; column_ready: boolean; active: boolean; mode: string; pending_backfill: number }>— Mode-3 (local pgvector) setup, for the "RAG pgvector Setup" guide.principals(query: string): Promise<{ users: Array<{ id: number; name: string }>; groups: Array<{ id: number; name: string }> }>— For the share picker — people/groups matchingquery.search(namespace: string, query: string | number[], opts?: { topK?: number; minScore?: number; mode?: "vector" | "keyword" | "hybrid"; filter?: { doc_id?: string | string[] } } ): Promise<{ matches: Array<{ id: number; score: number; text: string; doc_id: str…— Top-k retrieval.setDocAccess(namespace: string, docId: string, cfg: { is_public?: boolean; user_ids?: number[]; group_ids?: number[] } ): Promise<{ ok: boolean }>sharedNamespaces(): Promise<Array<{ name: string; count: number; model: string | null; dim: number }>>— SDK 5.83 (Vizum.supports("vector-scope")) — the namespaces this user-scoped app owns in the SHARED (global) scope, with row counts: what an app that narrowed from vector "global" to "user" must migrate.stats(namespace: string): Promise<{ namespace: string; count: number; dim: number; model: string | null; bytes: number; doc_count: number }>tags(namespace: string): Promise<{ tags: string[]; categories: string[] }>— The distinct tag vocabulary + auto-classified categories in a namespace.unpack(b64: string): Float32Array— Decode one packed base64 float32 blob to a Float32Array (offline path).upsert(namespace: string, items: Array<{ id?: number; text?: string; embedding?: number[]; doc_id?: string; seq?: number; meta?: Record<string, any> }>, opts?: { embed_model?: string }): Promise<{ upserted: number; dim: number; model: string }>— Upsert chunks.
vision
stability stable · since 5.11 · permission: permissions.media ['camera']
Members: Vizum.vision.onResults, Vizum.vision.start, Vizum.vision.stop
Bridge ops: start, stop
onResults(cb: (r: { hands: Array<{ landmarks: Array<{ x: number; y: number; z?: number }>; handedness: any }>; preview?: string; }) => void): Vizum.Subscriptionstart(opts?: { numHands?: number; preview?: boolean }): Promise<{ ok: boolean; already?: boolean }>stop(): Promise<{ ok: boolean }>
webhooks
stability stable · permission: permissions.webhooks
Members: Vizum.webhooks.ack (since 5.67), Vizum.webhooks.deliveries (since 5.67), Vizum.webhooks.fail (since 5.67), Vizum.webhooks.list, Vizum.webhooks.retry (since 5.67), Vizum.webhooks.url
Bridge ops: ack, deliveries, fail, list, retry, url
ack(deliveryId: number): Promise<Vizum.WebhookDelivery>— 5.67 — mark a queued delivery (its row'sv.delivery) processed.deliveries(hook?: string | null, opts?: { status?: Vizum.WebhookDeliveryStatus; limit?: number }): Promise<Vizum.WebhookDelivery[]>— 5.67 — the delivery ledger (metadata only, newest first, at most 200).fail(deliveryId: number, error?: string): Promise<Vizum.WebhookDelivery>— 5.67 — could not process it: re-queued, or dead-lettered at the hook's max_attempts.list(): Promise<Array<{ name: string; url: string; signed: boolean }>>retry(deliveryId: number): Promise<Vizum.WebhookDelivery>— 5.67 — installer or administrator: send a failed / dead-lettered delivery back.url(name: string): Promise<{ url: string }>
window
stability stable · permission: none
Members: Vizum.window.close, Vizum.window.openPanel, Vizum.window.resize, Vizum.window.setTitle
Bridge ops: close, openPanel, resize, setTitle
close(): Promise<boolean>openPanel(path: string, opts?: { title?: string; w?: number; h?: number; payload?: any }): Promise<boolean>resize(w: number, h: number): Promise<boolean>setTitle(t: string): Promise<boolean>
workflow
stability stable · permission: permissions.workflows
Members: Vizum.workflow.approve, Vizum.workflow.cancel, Vizum.workflow.get, Vizum.workflow.list, Vizum.workflow.reject, Vizum.workflow.start
Bridge ops: approve, cancel, get, list, reject, start
approve(id: number, opts?: { note?: string }): Promise<WorkflowInstance>cancel(id: number): Promise<WorkflowInstance>get(id: number): Promise<WorkflowInstance>list(query?: { workflow?: string; state?: string; mine?: boolean; limit?: number }): Promise<WorkflowInstance[]>reject(id: number, opts?: { note?: string }): Promise<WorkflowInstance>start(name: string, payload?: Record<string, any>): Promise<WorkflowInstance>
Compatibility matrix
Generated from the since values above: the oldest capability that serves each member introduced by a capability note. Members without a since are served everywhere.
| Member | Since | At 5.156 |
|---|---|---|
Vizum.ocr.* | 5.4 | ✓ |
Vizum.audio.* | 5.5 | ✓ |
Vizum.onClose | 5.7 | ✓ |
Vizum.onPause | 5.7 | ✓ |
Vizum.onResume | 5.7 | ✓ |
Vizum.onVisibility | 5.7 | ✓ |
Vizum.audio.recordClip | 5.10 | ✓ |
Vizum.vision.* | 5.11 | ✓ |
Vizum.editable.* | 5.19 | ✓ |
Vizum.ui.openUrl | 5.21 | ✓ |
Vizum.devices.location | 5.24 | ✓ |
Vizum.ai.image | 5.25 | ✓ |
Vizum.files.publicUrl | 5.27 | ✓ |
Vizum.files.revokePublic | 5.27 | ✓ |
Vizum.phone.* | 5.31 | ✓ |
Vizum.settings.set | 5.35 | ✓ |
Vizum.instances.* | 5.38 | ✓ |
Vizum.instances.pass | 5.38 | ✓ |
Vizum.map.tileUrl | 5.48 | ✓ |
Vizum.assistant.kbSearch | 5.52 | ✓ |
Vizum.team.* | 5.60 | ✓ |
Vizum.team.whoami | 5.65 | ✓ |
Vizum.apps.health | 5.67 | ✓ |
Vizum.apps.status | 5.67 | ✓ |
Vizum.errors.catalog | 5.67 | ✓ |
Vizum.errors.is | 5.67 | ✓ |
Vizum.errors.normalize | 5.67 | ✓ |
Vizum.errors.retry | 5.67 | ✓ |
Vizum.events.catalog | 5.67 | ✓ |
Vizum.features | 5.67 | ✓ |
Vizum.platform | 5.67 | ✓ |
Vizum.sdk | 5.67 | ✓ |
Vizum.supports | 5.67 | ✓ |
Vizum.webhooks.ack | 5.67 | ✓ |
Vizum.webhooks.deliveries | 5.67 | ✓ |
Vizum.webhooks.fail | 5.67 | ✓ |
Vizum.webhooks.retry | 5.67 | ✓ |
Vizum.device.blobUrl | 5.70 | ✓ |
Vizum.device.cache | 5.70 | ✓ |
Vizum.device.call | 5.70 | ✓ |
Vizum.device.embed | 5.70 | ✓ |
Vizum.device.embeds | 5.70 | ✓ |
Vizum.device.fileUrls | 5.70 | ✓ |
Vizum.device.fullscreen | 5.70 | ✓ |
Vizum.device.info | 5.70 | ✓ |
Vizum.device.onReload | 5.70 | ✓ |
Vizum.files.putBlob | 5.70 | ✓ |
Vizum.orm.writeBlob | 5.70 | ✓ |
Vizum.pairedDevices.claim | 5.70 | ✓ |
Vizum.pairedDevices.link | 5.70 | ✓ |
Vizum.pairedDevices.list | 5.70 | ✓ |
Vizum.pairedDevices.reload | 5.70 | ✓ |
Vizum.pairedDevices.rename | 5.70 | ✓ |
Vizum.pairedDevices.revoke | 5.70 | ✓ |
Vizum.files.getBlob | 5.71 | ✓ |
Vizum.files.getRange | 5.71 | ✓ |
Vizum.net.fetchImage | 5.74 | ✓ |
Vizum.app | 5.76 | ✓ |
Vizum.identity.* | 5.79 | ✓ |
Vizum.identity.confirm | 5.79 | ✓ |
Vizum.offline.conflicts | 5.80 | ✓ |
Vizum.offline.create | 5.80 | ✓ |
Vizum.offline.datasets | 5.80 | ✓ |
Vizum.offline.onConflict | 5.80 | ✓ |
Vizum.offline.onSync | 5.80 | ✓ |
Vizum.offline.query | 5.80 | ✓ |
Vizum.offline.readGroup | 5.80 | ✓ |
Vizum.offline.resolve | 5.80 | ✓ |
Vizum.offline.sync | 5.80 | ✓ |
Vizum.offline.write | 5.80 | ✓ |
Vizum.device.store | 5.82 | ✓ |
Vizum.vector.moveScope | 5.83 | ✓ |
Vizum.vector.sharedNamespaces | 5.83 | ✓ |
Vizum.ui.exitFullscreen | 5.85 | ✓ |
Vizum.ui.fullscreen | 5.85 | ✓ |
Vizum.ui.onFullscreenChange | 5.85 | ✓ |
Vizum.device.print | 5.87 | ✓ |
Vizum.device.printReceipt | 5.87 | ✓ |
Vizum.device.printTest | 5.88 | ✓ |
Vizum.feeds.* | 5.89 | ✓ |
Vizum.feeds.define | 5.89 | ✓ |
Vizum.feeds.list | 5.89 | ✓ |
Vizum.feeds.remove | 5.89 | ✓ |
Vizum.feeds.revoke | 5.89 | ✓ |
Vizum.feeds.rotateKey | 5.89 | ✓ |
Vizum.bulk.* | 5.90 | ✓ |
Vizum.bulk.cancel | 5.90 | ✓ |
Vizum.bulk.export | 5.90 | ✓ |
Vizum.bulk.list | 5.90 | ✓ |
Vizum.bulk.rebuild | 5.90 | ✓ |
Vizum.bulk.status | 5.90 | ✓ |
Vizum.offline.onAppUpdated | 5.95 | ✓ |
Vizum.server.* | 5.96 | ✓ |
Vizum.server.info | 5.96 | ✓ |
Vizum.server.metrics | 5.96 | ✓ |
Vizum.ui.openView | 5.100 | ✓ |
Vizum.link.* | 5.104 | ✓ |
Vizum.link.call | 5.104 | ✓ |
Vizum.link.close | 5.104 | ✓ |
Vizum.link.download | 5.104 | ✓ |
Vizum.link.fileUrl | 5.104 | ✓ |
Vizum.link.info | 5.104 | ✓ |
Vizum.link.upload | 5.104 | ✓ |
Vizum.link.verify | 5.104 | ✓ |
Vizum.team.history | 5.116 | ✓ |
Vizum.schedule.* | 5.128 | ✓ |
Vizum.schedule.ack | 5.128 | ✓ |
Vizum.schedule.clear | 5.128 | ✓ |
Vizum.schedule.list | 5.128 | ✓ |
Vizum.schedule.missed | 5.128 | ✓ |
Vizum.schedule.set | 5.128 | ✓ |
Vizum.deviceBroker.* | 5.129 | ✓ |
Vizum.deviceBroker.claim | 5.129 | ✓ |
Vizum.deviceBroker.link | 5.129 | ✓ |
Vizum.deviceBroker.list | 5.129 | ✓ |
Vizum.deviceBroker.rearm | 5.129 | ✓ |
Vizum.deviceBroker.rename | 5.129 | ✓ |
Vizum.deviceBroker.unlink | 5.129 | ✓ |
Vizum.meta.fieldAccess | 5.130 | ✓ |
Vizum.devices.bluetoothSupported | 5.134 | ✓ |
Vizum.map.geocode | 5.143 | ✓ |
Vizum.ar.* | 5.151 | ✓ |
Vizum.ar.setFov | 5.155 | ✓ |
Vizum.motion.* | 5.155 | ✓ |
Concepts
Composition — apps that need other apps
*Since capability 5.67* (the plain app-id form below works since SDK v4).
An app declares what it needs in requires and what it offers in provides. The platform resolves every dependency against what this database has installed, reports it to the app and to administrators, and stops an administrator from removing an app other apps cannot run without — by accident.
{
"min_platform": "5.67",
"provides": ["crm:leads:v1"],
"requires": [
"com.acme.contacts",
{ "app": "com.acme.inventory", "version": "^1.4" },
{ "app": "com.acme.labels", "version": ">=2.1.0 <3.0.0", "optional": true },
{ "contract": "payments:v1", "optional": true }
]
}
| Entry | Means |
|---|---|
"com.acme.contacts" | that app, any version, required (SDK v4 — every platform accepts it) |
{ "app": …, "version": "^1.4", "optional": false } | that app, in a version range |
{ "contract": "payments:v1" } | any ready app whose manifest provides that contract name, exactly |
{ "contract": "payments", "version": "^1.2" } | any ready app that provides payments in a version inside the range (5.69) |
The object forms need min_platform 5.67: an older platform refuses the manifest at install, and vzm check says so.
Versioned contracts (5.69)
Before 5.69 a contract carried its version in its name (payments:v1, payments:v2), so a provider could only say "I am v1". Since 5.69 a provides entry may name the version of the contract the app implements, and a requirement may take a range:
{
"min_platform": "5.69",
"provides": ["payments@1.3.0"],
"requires": [{ "contract": "ledger", "version": "^2.1" }]
}
- A versioned requirement is met only by a provider that names a version inside the range; a provider in another version reports the dependency incompatible (the row's
provided_versionssays which version each provider names). A provider that names no version cannot meet a range. - An unversioned requirement (
{ "contract": "payments" }) accepts a provider in any version. - The 5.67 names keep their meaning:
payments:v1is a name, matched exactly, by every platform. Vizum.apps.find(name)takes the contract name (payments, orpayments:v1).- Removal protection counts only providers in the dependent's range: uninstalling the last provider of
^1.2is refused even while another app providespayments@3.0.0. vzm diffreads a provider's minor bump (@1.3.0→@1.4.0) as compatible, and a new major, or a provider that stops naming its version (@1.3.0→ no version), as breaking. A dependent that pins an exact version (1.3.0) instead of a range opts out of minor bumps: semver ranges (^1.3) are what a provider's compatible release promises.
Both forms need min_platform 5.69: an older platform refuses them at install (its provides grammar has no @, and a contract requirement there takes no version), and both the install gate and vzm check (MIN_PLATFORM_TOO_LOW) refuse a manifest that uses them with a lower min_platform. A versioned contract may appear once in provides.
Version ranges
Space-separated comparators that must all hold:
| Range | Accepts |
|---|---|
^1.4 | >=1.4.0 <2.0.0 (for 0.x: the same minor — ^0.2.1 is <0.3.0) |
~1.2.3 | >=1.2.3 <1.3.0 |
1.4.x, 1.x, 1 | that prefix |
1.2.3 / =1.2.3 | exactly that version |
>=1.2.0 <2.0.0 | both |
Status
Each dependency is ready (installed, approved, in range), missing, disabled (installed but waiting for approval or disabled) or incompatible (installed, outside the range). The app as a whole is ok (every required dependency ready), degraded (an optional one is not) or blocked (a required one is not).
const { state, dependencies } = await Vizum.apps.status();
if (state === "blocked") {
const what = dependencies.filter((d) => !d.optional && d.status !== "ready");
showSetupScreen(what); // tell the user what to install or update
}
Installing and updating
A first install never fails for an unmet requirement: the order apps arrive in is not the installer's to choose, so the app installs and reports blocked until what it needs arrives.
Approving a first install whose non-optional requirement is missing or out of range does not make it ready: the app stays in needs_approval with the reason waiting for <app> <range> (Apps Manager health, api_installed_detail().waiting_for). Approving several apps at once still approves the others. This holds for a zero-risk first install too (nothing to approve, a plain Install click): it does not land ready and wait to be refused at open, it waits in needs_approval with the same reason. Once the required app is installed, a normal approval puts it in ready. An app that is already ready while a non-optional requirement is missing (installed before this rule) is refused when opened: "<App> needs <X>, which is not installed". Optional requirements and installed-but-disabled dependencies never hold an app back.
The name of a requirement is not shown to people who cannot open the app. When an app is restricted (Specific people) and a user outside the list opens it, the platform checks the access first and answers the ordinary access error; the "needs X, which is not installed" message, which names the app's requirement, is only for users who may open the app (administrators included).
An update never replaces a working app with a version that cannot run. When the new version requires an app (or contract) that is missing or incompatible, the install is refused with an error naming it, before anything is written: the installed version stays as it was and keeps working. The same refusal covers the store's automatic update, which keeps the app on its current version. An app that is installed and in range but disabled (or waiting for approval) does not refuse the update — an administrator brings it back and nothing is lost — and an optional requirement never does. The store's automatic update waits (no failed attempt counted, the version is not blacklisted) and installs once the dependency arrives. Restoring the rollback snapshot of a failing update is not stopped by this gate. A required app that is ready but still waiting for the Odoo module it replaces counts as ready, so its dependents can be installed in the middle of a module-to-vApp cutover. Approving an update installed before this gate also refuses a missing requirement.
Vizum.apps.requires() returns the same rows with the SDK v4 fields (key, installed, ready) still on them.
A first install never fails because a dependency is missing — apps arrive in whatever order they arrive (an update does, see above). api_install returns the report (dependencies) for a first install or a version change (re-installing the same version answers only {id, key, state}), and the installed-app detail shows it next to required_by.
Removal protection
Uninstalling an app that another ready installed app requires (not optionally) — by id, or as the last ready provider of a contract it requires — is refused, naming the apps that need it. A disabled or unapproved dependent does not hold it in place. An administrator can force it: the desktop's uninstall dialog names the apps that depend on it, and confirming there forces the uninstall (api_uninstall(key, force=true) over RPC); the dependents then report blocked instead of failing without a word. Whether you may uninstall is checked first, so a user who may not learns nothing about the dependents.
Checked before you ship
vzm check reports INVALID_REQUIRES for a range that does not parse, an app requiring itself or a contract it provides (in any version), a duplicate entry or a versioned contract provided twice, and MIN_PLATFORM_TOO_LOW for the object form below 5.67 and for versioned contracts below 5.69.
Group-scoped ops on the models of an app you require (5.78)
A UI app that sits on an owner app (an Engine) often needs the owner's administrators to write where its technicians only read. An orm grant says so with group_ops:
{"model": "x_vzm_service_brand",
"ops": ["search", "read"],
"group_ops": [{"groups": ["com.example.service/@admin"],
"ops": ["create", "write", "unlink", "call:rebuild_index"]}]}
opsis what everyone who runs the app gets. Eachgroup_opsentry adds itsopsonly for the calling user (never the installer) who is in at least one of itsgroups.fields,write_fields,domainandchatter_onlystay the grant's own and bound both lists.- A group is
"@name"(one of this app'snative.groups),"<app-key>/@name"(a group of an app inrequires, resolved the way that app's own rules resolve it) or a real xmlid such assales_team.group_sale_manager.base.group_system,base.group_erp_managerand any group that implies them are refused. A group that does not exist on the instance grants nothing. - The platform computes it in one place, so
Vizum.orm,call:, actions andread_groupall honour it. In Solo mode the access group every Solo user holds is minted fromopsonly, never fromgroup_ops: a technician who is not in the owner's admin group keeps the owner's own read-only ACL. The group named ingroup_opshas to carry the Odoo ACL for the model itself (the owner'snative.models[].access, or a module). - Platform 19.0.10.150.197 (VZ-SEC-34, no capability change): on a vApp model that declares
native.models[].access(yours or a required app's), the Solo access group keeps read and only the writes every Solo audience group is declared to have, even when they sit in plainops. Each role writes through its own declared ACL rows. Declare inaccessexactly what each role writes: a write the UI does for a role thataccessdoes not give it is refused by the server. Native Odoo models carry no declared role access, soopson them is still what every Solo user does; split them per role withgroup_ops. - An op is in
opsor ingroup_ops, not both. For the approval: moving an op fromopsintogroup_opsnarrows the grant (the update applies with no new approval and replaces the approved snapshot); adding agroup_opsop nobody had, or naming one more group for it, widens it.Vizum.app.info-style introspection (api_metamodels) lists the ops the current user holds. min_platform5.78 or later. An older platform ignores the key, so the app would lose the group's ops silently; the validator, the Store gate andvzm check(MIN_PLATFORM_TOO_LOW) refuse the manifest below it.Vizum.supports("orm-group-ops")tells a screen whether the host serves it.
Example: one app sending through another, whatsapp.send:v1 (5.69)
A provider exposes a public native method and names it in provides; the caller holds a call: grant on the provider's model and, in the method, the provider learns which app called with caller_app() (verified by the platform; a direct RPC call gives None). The WhatsApp vApp does this so Vizum Forms can send a text or a PDF about a submitted form. Read this example for the whole shape:
// provider (WhatsApp 5.2.0): manifest.json
{ "min_platform": "5.69", "provides": ["whatsapp.send:v1"],
"native": { "methods": [{ "model": "message", "name": "send_message", "api": "model", "code": "def send_message(self, to, text=None, attachment_id=None, options=None): ..." }] } }
// caller (Forms): manifest.json
{ "requires": [{ "contract": "whatsapp.send:v1" }],
"permissions": { "orm": [{ "model": "x_vzm_whatsapp_message", "ops": ["call:send_message"] }] } }
const r = await Vizum.orm.call("x_vzm_whatsapp_message", "send_message", [to, text, attachmentId, { key }]);
if (!r.ok) handle(r.error_code); // a refusal is data: APPS_DISABLED, CALLER_NOT_ALLOWED, RATE_LIMITED...
What the provider should do, and WhatsApp does: keep an administrator switch that is off after install and update (no field default, no seeded row); answer refusals as a typed {ok, error_code} instead of raising; check the caller against an allow-list (empty = com.vizumapps.* only, otherwise the admin's ids); rate-limit per caller_app(); record caller_app() on the row it creates; run as the user so the provider's record rules apply. The full contract (parameters, result, every error_code) is in demo_apps/whatsapp/HISTORY.md under v5.2.0. A second example with the same shape: Cloud Print's print.job:v1 (demo_apps/cloudprint), which predates caller_app() and takes the caller as a label.
A worked contract: forms.fill@1.0.0 (Vizum Forms)
Vizum Forms (com.vizumapps.forms, 1.0.0) is the first app to publish a versioned contract other apps use on purpose. It declares "provides": ["forms.fill@1.0.0"] and min_platform 5.98. A consumer asks for it with {"contract": "forms.fill", "version": "^1.0"} in requires (optional, if the consumer works without checklists) and finds the provider with Vizum.apps.find("forms.fill").
What the contract promises. "Open Forms on template X, linked to record Y, and tell me when it is sent." Two ways in, one event out.
- From the browser (Dispatch, Assets, a portal page). Open the app with a typed payload:
await Vizum.ui.openApp("com.vizumapps.forms", {
fill: {
template: "VEH-PRE", // the template CODE (stable across versions), required
record: { model: "fleet.vehicle", id: 5 }, // the Odoo record to link; optional unless the template requires it
return: "com.acme.dispatch" // optional: the app key Forms brings back after the form is sent
}
});
Forms reads it as Vizum.intent (or the intent event when it is already open), looks the template up by code among the published templates the user can see, and opens the first screen of the wizard. A request it cannot read (no template, a record that is not {model, id} with a positive integer, a return that is not an app key) opens nothing and tells the user so. A code that is not published, or a record of a kind the template does not accept, says so and stays on the inbox. The payload is data: Forms never evaluates it.
Which records. A template lists the models it may be linked to; a template with no list accepts res.partner, fleet.vehicle, stock.picking, project.task, repair.order, maintenance.equipment, sale.order, product.product, helpdesk.ticket and, since Forms 1.0.3, the asset of Vizum Assets (x_vzm_assetreg_asset). A model whose app or Odoo module is not installed is refused like any record that does not exist, and Forms' screens offer the asset only while Assets is installed.
- Through the ORM bridge (Dispatch's own code, a work-order screen). Call Forms' native method with the consumer's own
call:startgrant onx_vzm_forms_template:
const started = await Vizum.orm.call("x_vzm_forms_template", "start", [null, { res_model: "fleet.vehicle", res_id: 5 }, "VEH-PRE"]);
// -> { id, name, version_id, state: "draft", template_id, caller_app: "com.acme.dispatch" }
The method is start(template_id=None, link=None, template_code=None); pass the code to name the template. Forms checks the same rules as for a person (published, link kind, link required) and records caller_app() (5.69: the platform names the app whose call: grant made the call; a direct RPC call, a job or a person leaves it empty) on the submission as x_caller_app, so a manager can tell which app asked. The draft belongs to the calling user, who fills it in Forms from their inbox.
The event. When a form is sent Forms emits forms.submitted (contract version 1, 5.67). Events are browser-local, so only apps open on the same desktop hear it (Vizum.supports("event-contracts") is false in Odoo's menus and in Solo, and Forms then emits nothing):
{ "id": 31, "state": "submitted", "template_code": "VEH-PRE",
"res_model": "fleet.vehicle", "res_id": 5, "result": "pass", "score_pct": 100 }
| Field | Type | Notes |
|---|---|---|
state | "submitted" or "queued" | queued: sent from a device that is offline; the server confirms later |
template_code | string, at most 64 | the code the consumer asked for |
result | "pass", "fail" or "none" | none when the template does not score |
id | integer | the submission; absent while queued |
res_model, res_id | string, integer | only when the form is linked |
score_pct | number 0 to 100 | only when the template scores |
A consumer listens with permissions.events.listen: ["forms.submitted"] and reads meta.producer === "com.vizumapps.forms" and meta.version to handle a later version. The payload schema is closed (additionalProperties: false), so a consumer can rely on the table above.
Compatibility. forms.fill@1.x adds optional fields only. A new required field in fill, a renamed event field or a changed type is forms.fill@2.0.0 and forms.submitted version 2.
A form another app asks for: forms.request@1.0.0 (Vizum Forms)
forms.fill@1 opens Forms on a form now, for the person at the screen. Since Forms 1.1.0 (min_platform 5.105) a Vizum app can also ask for a form to be filled by someone, linked to a record of its own, and read the result later from its own code: Dispatch asks the technician of a job for the vehicle check, Proof of Delivery for a temperature log at a stop.
"requires": [{ "contract": "forms.request", "version": "^1.0", "optional": true }],
"permissions": { "orm": [{ "model": "x_vzm_forms_submission",
"ops": ["call:forms_templates", "call:forms_request", "call:forms_status", "call:forms_result", "call:forms_cancel"] }] }
const key = `dispatch-job-${job.id}-VEH-PRE`;
const r = await Vizum.orm.call("x_vzm_forms_submission", "forms_request", [{
template: "VEH-PRE", res_model: "x_vzm_dispatch_job", res_id: job.id, assignee_uid: tech.uid, due_at: "2026-10-20 15:00:00" }, key]);
// later: { requests: [{ id, state: "pending" | "draft" | "overdue" | "submitted" | "cancelled", result, assignee, ... }] }
const s = await Vizum.orm.call("x_vzm_forms_submission", "forms_status", [{ keys: [key] }]);
const res = await Vizum.orm.call("x_vzm_forms_submission", "forms_result", [s.requests[0].id]); // answers, no pictures
forms_request(spec, key)is idempotent on (calling app, key);forms_status(ids | {keys} | {res_model, res_id}),forms_result(id),forms_cancel(id)andforms_templates({link_model?}). The three that only read are read calls (5.105).- Only Vizum's own apps as the platform verified them (
caller_app_signed(), never the key's prefix); a third-party app usesforms.fill@1. Every method runs as the calling person (a person who is not a Forms user is refused); the record is read as that person and as the assignee, so a form never names a record its filler cannot open. A template with no list of linkable models also takes the calling app's own record (x_vzm_dispatch_job,x_vzm_pod_stop). - An app reads and withdraws only its own requests: an id or a key of another app's request answers
NOT_FOUND. Requests are rate-limited (120 a minute per app). Refusals are data:NOT_FROM_APP,CALLER_NOT_ALLOWED,FORBIDDEN,RATE_LIMITED,BAD_KEY,BAD_SPEC,UNKNOWN_TEMPLATE,BAD_LINK,NOT_FOUND,BAD_ASSIGNEE,NOT_SENT,ALREADY_CLOSED. - Nothing is sent: the form lands in the assignee's Forms inbox (offline on the phone too), marked with the app that asked; e-mail and WhatsApp stay under Forms' own switches, off on install and on update.
Every parameter, answer and code: demo_apps/forms/docs/CONTRACT-forms.request.md.
A register other apps share: the assets.* contracts (Vizum Assets)
Vizum Assets (com.vizumapps.assetreg, 1.0.0, min_platform 5.98) publishes four versioned contracts: assets.asset@1.0.0 (find assets by label, code, serial or linked record; list a customer's equipment for the Portal app), assets.meter@1.0.0 (record a reading, read meters and what preventive work is due), assets.custody@1.0.0 (check out, check in, who holds an asset) and assets.timeline@1.0.0 (write on an asset's timeline, read the outbox of events with a cursor, report work done). Same shape as whatsapp.send:v1: a call: grant per method, caller_app() checked against com.vizumapps.* plus the administrator's allow-list, an idempotency key scoped to the calling app, a per-app rate limit, and refusals as data. Because events are browser-local, api_events_since is a durable outbox a server-side consumer reads with its own cursor. The full reference, every input, result and error_code: contracts/assets.md.
A worked contract: portal.share@1.0.0 (Vizum Portal)
Vizum Portal (com.vizumapps.portal, 1.0.0) gives a business's customer a secure page about one job, order or request. It provides three contracts — portal.share@1.0.0, portal.panel@1.0.0, portal.feed@1.0.0 — so another app (Dispatch, Forms, a field-service app) can open a customer page for its own work and follow what the customer does on it. A consumer asks for it with {"contract": "portal.share", "version": "^1", "optional": true} and holds its own call: grant on x_vzm_portal_share:
{ "model": "x_vzm_portal_share",
"ops": ["call:portal_share_create", "call:portal_share_publish", "call:portal_share_revoke",
"call:portal_share_status", "call:portal_panel", "call:portal_feed"] }
Create a page, once. options.key is required: a retry with the same key answers the page that exists.
const r = await Vizum.orm.call("x_vzm_portal_share", "portal_share_create", [
{ app: "com.acme.dispatch", ref: "JOB-2026-0142", title: "Air-conditioning service" }, // or { model, res_id }
{ partner_id: 7 }, // or { name, email, mobile, lang }
{ key: "job-142", expires_in_days: 14, notify: false },
]);
// -> { ok: true, share_id: 31, public_id: "9f7c…", url: "https://…/app/portal/share#vzl_…", expires_at, links: true }
urlis the customer's link, and this answer is the only place it ever appears (the platform keeps only its SHA-256). Hand it to the person, show a QR code, never store it. A replay of the same key answersurl: null.links: false(andurl: null) on a platform without guest links (before 5.104), or when the instance refused the link (its guest-link policy; the reason is inlink_error): the page exists, the customer sees it when signed in to their Odoo portal account, and staff can issue the link later.caller_app()decides: Vizum's own apps — verified by the platform (caller_app_signed(): delivered by the Vizum Store and signed by Vizum, never a key prefix) — may create pages by default; any other app only when Portal's administrator lists it in Settings. A direct RPC call has no caller and is refused (NOT_FROM_APP).
Put something in front of the customer.
await Vizum.orm.call("x_vzm_portal_share", "portal_share_publish", [31, { kind: "eta", title: "Visit scheduled",
body: "Tuesday 09:00–11:00" }]);
await Vizum.orm.call("x_vzm_portal_share", "portal_share_publish", [31, { kind: "document", attachment_id: 812 }]);
await Vizum.orm.call("x_vzm_portal_share", "portal_share_publish", [31, { kind: "approval",
title: "Replace the fan motor", amount: 185, currency: "USD" }]);
await Vizum.orm.call("x_vzm_portal_share", "portal_share_publish", [31, { kind: "upload_request",
title: "Photo of the unit's label", accept: "photo", max_files: 1 }]);
A document is copied (Portal keeps its own bytes; the customer never gets a link to your attachment), an approval is frozen as a snapshot with its SHA-256, and every item is addressed by a random public id. The calling person must be able to read the attachment and, for an {model, res_id} subject, the record.
Follow what happens. portal_feed(cursor, limit) is the durable way: poll it from your own job and keep the answer's cursor.
{ "ok": true, "cursor": 118, "events": [
{ "seq": 117, "type": "approval.decided", "share_id": 31, "subject": { "app": "com.acme.dispatch", "ref": "JOB-2026-0142" },
"at": "2026-10-12 18:42:10", "data": { "approval": 9, "decision": "approve" } } ] }
Types: share.created, share.opened, share.closed, share.revoked, approval.decided, upload.received, message.received. On the desktop Portal also emits portal.share.created, portal.share.opened, portal.approval.decided, portal.upload.received and portal.message.received (version 1, closed schemas {share_id, at, res_model?, res_id?, app?}, decision on approvals) — browser-local, so only apps open on the same desktop hear them.
A panel on your record. portal_panel(model, res_id) answers {contract: "portal.panel@1", shares: [...]}, the pages about that record the calling person may read (state, expiry, opens, what waits, the last event). Draw it yourself; there is no cross-app iframe.
Errors are data: NOT_FROM_APP, CALLER_NOT_ALLOWED, BAD_SUBJECT, SUBJECT_NOT_READABLE, BAD_CONTACT, BAD_ITEM, BAD_REQUEST, SHARE_NOT_ACTIVE, NOT_FOUND, RATE_LIMITED (30 creates a minute per app), LINKS_UNAVAILABLE. Notifications to the customer stay Portal's: they are off until Portal's manager turns them on.
Compatibility. portal.share@1.x, portal.panel@1.x and portal.feed@1.x add optional fields and new item kinds or feed types only; a consumer ignores what it does not know. A renamed field, a new required argument or a changed type is version 2. The full reference is demo_apps/portal/README.md.
A scheduling layer: the dispatch.* contracts (Vizum Dispatch)
Vizum Dispatch (com.vizumapps.dispatch, 1.0.0, min_platform 5.98) owns *when and who* for every other app: a work order, a ticket, a preventive visit on an asset becomes a job on a person, a crew, a vehicle or a room, checked by the same conflict engine the dispatcher's board uses. It provides four versioned contracts. Each method is a public native method that checks caller_app(), runs as the calling user (Dispatch's groups and record rules decide) and answers {ok: false, error_code, error} instead of raising.
| Contract | Methods | What for |
|---|---|---|
dispatch.job@1.0.0 | dsp_job_upsert(spec, key), dsp_job_assign, dsp_job_status, dsp_job_cancel, dsp_jobs_for_record (model x_vzm_dispatch_job) | schedule the work of a record, idempotent on (calling app, key); hard conflicts refuse, warnings need ack_reason |
dispatch.availability@1.0.0 | dsp_free_slots(type_code, from, to, opts), dsp_resources(opts) | offer a free time |
dispatch.booking@1.0.0 | dsp_booking_request(type_code, start, contact, key), dsp_booking_cancel, dsp_bookings_for_partner (model x_vzm_dispatch_booking) | book for a customer; off until a manager turns booking on |
dispatch.feed@1.0.0 | dsp_changes(since_seq, opts) (model x_vzm_dispatch_change) | a durable, paged change stream for apps that are not open on the same desktop |
// a work-order app that schedules through Dispatch when it is installed, and keeps its own planner when it is not
"requires": [{ "contract": "dispatch.job", "version": "^1.0", "optional": true }],
"permissions": { "orm": [{ "model": "x_vzm_dispatch_job", "ops": ["call:dsp_job_upsert", "call:dsp_job_status"] }] }
if ((await Vizum.apps.find("dispatch.job")).some((p) => p.ready)) {
const r = await Vizum.orm.call("x_vzm_dispatch_job", "dsp_job_upsert",
[{ type_code: "HVAC-VISIT", title: order.name, link: { res_model: "repair.order", res_id: order.id, res_name: order.name } },
`repair:${order.id}`]);
if (r.ok) await Vizum.ui.openApp("com.vizumapps.dispatch", { job: r.job.id });
}
The calling app is allowed when it is first-party (com.vizumapps.*) or listed by an administrator in Dispatch's settings; writes are rate-limited per calling app. Browser events (dispatch.job.scheduled, .status_changed, .completed, dispatch.booking.confirmed, dispatch.conflict.raised, version 1, closed schemas with ids only) tell an app open on the same desktop. Another app opens Dispatch on a record's jobs with Vizum.ui.openApp("com.vizumapps.dispatch", {intent: "record", res_model, res_id}).
Dispatch itself consumes these contracts, each optional: forms.fill@1 (a job type lists Forms template codes; the technician fills them from My Day and "Finish" waits for them), whatsapp.send:v1 (confirmations, reminders and "on the way", off by default), assets.asset@1 / assets.timeline@1 (the equipment of a job; a finished job is logged on the asset) and, since Dispatch 1.1.0, portal.share@1 (a dispatcher's "Create the customer link" on a job: one Portal page per job, key dispatch-job-<id>, with the visit window and, when asked, a visit confirmation and a photo request; the link is shown once and Dispatch never stores it; Portal e-mails it only with Dispatch's master switch and "Portal e-mails the customer link" on, both off, and Portal's own switches) and, since Dispatch 1.2.0, pod.capture@1 / pod.feed@1 (a job type marked "Needs a proof of delivery", off by default, gets one Proof of Delivery stop when the job is placed, key dispatch-job-<id>; the job sheet reads pod_status({res_model, res_id}) and opens POD with its intents; My Day's "Finish" waits for the proof online and finishes with "proof waiting to sync" offline, settled from pod_events_since; cancelling a job calls pod_cancel). Since Dispatch 1.3.0 a moved, reassigned or readdressed job moves its planned stop along with pod_update (pod.capture@1.1, Proof of Delivery 1.2.0), and repeating jobs and online bookings use the contract's unattended path on behalf of the dispatcher whose standing consent is on the job type; the required forms are asked of the job's lead person with forms.request@1 (Forms 1.1.0: forms_request, forms_status, forms_cancel), with forms.fill@1 kept for an older Forms. Every parameter, answer and code: demo_apps/dispatch/docs/CONTRACT-dispatch.*.md.
A cost layer: the costing.* contracts (Vizum Costing)
Vizum Costing (com.vizumapps.costing, 1.0.0, min_platform 5.105) costs recipes, jobs and trips with one engine and says whether each meets its target. Since 1.1.0 it also reads Vizum Dispatch's route days as trips (through read grants on Dispatch's job and resource models, as the person, since dispatch.* publishes no per-day route contract). Other apps read a sheet's numbers, quote a trip, open a sheet for one of their records and post what something really cost. Every method is a native method that checks caller_app() first, runs as the calling user (Costing's groups and record rules decide what that person sees) and answers {ok: false, error_code, error} instead of raising. The reads are declared read_calls (5.105): they run read-only and raise no «App action» dialog.
| Contract | Methods (model) | What for |
|---|---|---|
costing.estimate@1.0.0 | cst_estimate_get(spec), cst_estimate_quote(spec) (read calls), cst_estimate_upsert(spec, key) (x_vzm_costing_sheet), cst_actual_add(spec, key) (x_vzm_costing_actual) | the cost, price, margin and verdict of a sheet named by {sheet_id}, {code}, {product_id}, {res_model, res_id} or the caller's {key}; a trip quote (cost and price at the target margin) from a template trip or from lines; a trip or job sheet opened for a record, idempotent on (calling app, key); an actual cost posted on a sheet, idempotent on (calling app, sheet, key) |
costing.alerts@1.0.0 | cst_alerts(since_id, opts) (x_vzm_costing_alert, read call) | a paged feed of margin breaches, cost changes over the threshold, budget overruns and trips under their margin; since Costing 1.1.0 each message is in the calling user's language and carries its currency (the alert's company's) |
// a dispatch or TMS app that prices a job's trip through Costing when it is installed
"requires": [{ "contract": "costing.estimate", "version": "^1.0", "optional": true }],
"permissions": { "orm": [
{ "model": "x_vzm_costing_sheet", "ops": ["call:cst_estimate_quote", "call:cst_estimate_upsert"],
"read_calls": ["cst_estimate_quote"] },
{ "model": "x_vzm_costing_actual", "ops": ["call:cst_actual_add"] } ] }
if ((await Vizum.apps.find("costing.estimate")).some((p) => p.ready)) {
const q = await Vizum.orm.call("x_vzm_costing_sheet", "cst_estimate_quote",
[{ template: "TRUCK-LONG", distance_km: 415, hours: 9.5, days: 1, km_per_l: 3.1, target_pct: 18 }]);
if (q.ok) showQuote(q.quote, q.cost, q.breakeven_per_km);
await Vizum.orm.call("x_vzm_costing_sheet", "cst_estimate_upsert",
[{ kind: "trip", name: job.number, template: "TRUCK-LONG", distance_km: 415, revenue: 1180,
link: { res_model: "x_vzm_dispatch_job", res_id: job.id } }, `dispatch-job-${job.id}`]);
}
The calling app is allowed when it is first-party (com.vizumapps.*) or listed by a Costing manager in its settings; writes are rate-limited per calling app (30 a minute by default). A summary never carries a supplier, a person or a line: ids, numbers and the verdict. Browser events (event_contracts, version 1, closed schemas, ids and numbers only): costing.margin.breach (a sheet crossed its target), costing.cost.changed (an item's cost moved, with how many sheets it pushed over target), costing.budget.overrun (a job's cost code spent ahead of its progress). Vizum Dashboards builds widgets on the stored results (x_cost_pct, x_margin_pct, x_status, x_cpi, x_cost_per_km...) of x_vzm_costing_sheet. Costing itself consumes whatsapp.send:v1 (the alert digest, off by default) and, when installed, reads Margin Guard's company floor. Every parameter, answer and code: demo_apps/costing/docs/CONTRACT-costing.*.md.
Lots that will not sell in time: expiry.radar@1.0.0 (Vizum Expiry Radar)
Vizum Expiry Radar (com.vizumapps.expiry, 1.1.0, min_platform 5.105) knows, for every dated lot on hand, how much will not sell before its date (a FEFO sell-through simulation per product and warehouse over the last weeks' sales), what that costs, and which action it suggests (transfer, markdown, use inside, return to vendor, donate, scrap, quarantine). It provides one versioned contract on x_vzm_expiry_risk; three of its four methods are read calls (5.105), so a consumer's person is not asked the «App action» question for them.
| Method | What for |
|---|---|
api_lots_at_risk(query) | lots at risk with filters (within_days, bands, product, location, warehouse, category, vendor, suggested kind) — a POS, an ordering app, a dashboard |
api_lot_status(ref) | one lot ({lot_id} or {lot_name, product_id?}): dates, quantity by location, band, suggestion |
api_events_since(cursor, limit) | a durable outbox read with a cursor: lot.at_risk, lot.expired, action.done, lot.recalled |
api_log_waste(payload, options) | another app (a waste tracker, a POS) records stock it wrote off or marked down; idempotent on options.key |
"requires": [{ "contract": "expiry.radar", "version": "^1", "optional": true }],
"permissions": { "orm": [{ "model": "x_vzm_expiry_risk", "ops": ["call:api_lots_at_risk", "call:api_log_waste"] }] }
const r = await Vizum.orm.call("x_vzm_expiry_risk", "api_lots_at_risk", [{ within_days: 7, bands: ["critical", "high"] }]);
if (r.ok) for (const l of r.lots) console.log(l.product.name, l.days_left, l.units_at_risk, l.suggestion.kind);
Each lot of api_lots_at_risk and api_lot_status carries short_on_arrival (Expiry Radar 1.1.0, optional, still expiry.radar@1.0.0): true when the lot arrived with less shelf life than its vendor promises on delivery (Expiry Radar's Settings › Vendor terms), false when no promise is set or the receipt is unknown. An Expiry Radar 1.0.x answers without it: read it as optional (l.short_on_arrival === true), never as a required field.
Every method runs as the calling person (record rules and companies decide), checks the calling app — Vizum-signed (caller_app_signed()) or listed by an Expiry Radar manager — and answers refusals as data (NOT_ALLOWED, NOT_FROM_APP, CALLER_NOT_ALLOWED, RATE_LIMITED, BAD_INPUT, NOT_FOUND). A person without an Expiry Radar role (a portal user included) is refused; the event feed is a manager's or an allowed app's. Browser events expiry.lot.at_risk, expiry.action.done and expiry.lot.recalled (version 1, closed schemas, ids and numbers only) tell apps open on the same desktop. Expiry Radar itself uses whatsapp.send:v1 for its morning message (off until a manager turns it and a channel on), creates one private board in Vizum Dashboards on request, and opens Vizum Product Label Builder on a marked-down product. Every parameter and answer: demo_apps/expiry/docs/CONTRACT-expiry.radar.md.
Credit before a sale on credit: credit.check@1.1.0 (Vizum Credit Control)
Vizum Credit Control (com.vizumapps.credit, 1.1.0, min_platform 5.110) is the one place the credit rule lives: Odoo's own limit, the exposure term by term (open receivables, confirmed orders not invoiced, Point of Sale sales on account, the document in hand), at the day's rate, and an overdue rule. Its guard runs when a sale order is confirmed or a customer invoice is posted, in three modes (warn, the default; hold until a manager releases; block). An app that sells on credit asks first instead of keeping a second rule:
"requires": [{ "contract": "credit.check", "version": "^1", "optional": true }],
"permissions": { "orm": [{ "model": "x_vzm_credit_profile", "ops": ["call:api_check", "call:api_request_release"] }] }
const r = await Vizum.orm.call("x_vzm_credit_profile", "api_check", [partnerId, orderTotal, {}]);
if (r.ok && !r.allow) showHold(r.sentence, r.release_required);
// r.release_required: ask a manager, with a reason and a time limit (the person who asks never decides)
await Vizum.orm.call("x_vzm_credit_profile", "api_request_release",
[partnerId, { model: "sale.order", id: orderId }, orderTotal, reason, { key: `slm-${orderId}`, hours: 24 }]);
| Method | What for |
|---|---|
api_check(partner_id, amount, context) | read call: {allow, code, state, sentence, available, release_required, mode} before selling amount more on credit; collectors and managers also get disputed and promise_due (1.1.0, optional keys) |
api_request_release(partner_id, ref, amount, reason, options) | a manager releases one document (ref), or the customer until a date (options.kind: "until_date"); idempotent on options.key |
api_releases_pending(limit) | read call: the releases waiting for this manager's decision (Vizum Approvals' inbox) |
api_events_since(cursor, limit) | read call: customer.on_hold, customer.released, promise.broken, payment.recorded (1.1.0 adds application.decided, dispute.opened, dispute.resolved) |
Every method runs as the calling person, with Credit Control's ownership rules: a salesperson reaches the customers they sell to and reads the state and the available amount only, a collector the customers assigned to them, a manager the company's; any other customer answers NOT_FOUND, like one that does not exist. The calling app must be Vizum-signed (caller_app_signed()) or listed by a Credit Control manager; refusals are data (NOT_ALLOWED, NOT_FROM_APP, CALLER_NOT_ALLOWED, RATE_LIMITED, BAD_INPUT, BUSY, NOT_FOUND). Browser events credit.customer.on_hold and credit.release.decided (version 1, ids only). Credit Control's guard uses triggers, not method overrides, so it never competes with an app that overrides sale.order.action_confirm (the platform refuses a second owner of one method at install): with PSI DSD installed both checks run on a route sale and the stricter decides. Every parameter, answer and code: demo_apps/credit/docs/CONTRACT-credit.check.md.
A product search other apps ask: finder.search@1.0.0 (Vizum Finder)
Vizum Finder (com.vizumapps.finder, 1.0.0, min_platform 5.116) is the counter's search: words, codes, barcodes, synonyms, measures and typos, the stock here, in a sister branch and on the way, the customer's own price, the substitutes of a product and what fits a vehicle or a machine. An app that needs a product search asks Finder instead of building a second one:
"requires": [{ "contract": "finder.search", "version": "^1", "optional": true }],
"permissions": { "orm": [{ "model": "x_vzm_finder_settings",
"ops": ["call:api_search", "call:api_substitutes", "call:api_fits"],
"read_calls": ["api_search", "api_substitutes", "api_fits"] }] }
const r = await Vizum.orm.call("x_vzm_finder_settings", "api_search", ["tubo 1/2", { partner_id: 7, limit: 10 }]);
if (r.ok) for (const it of r.items) console.log(it.name, it.price, it.stock_here, it.chips);
| Method | What for |
|---|---|
api_search(query, options) | read call: {items, total, fuzzy, facets}; options {partner_id, fit, categ_id, brand, attrs, in_stock, limit, offset} |
api_substitutes(product_id, context) | read call: what can replace a product, with the reason; a controlled product answers CONTROLLED |
api_fits(target, options) | read call: the products that fit a vehicle, a machine or a key |
Every method runs as the calling person: a product they cannot read or sell never appears, nor does one of another company, and no answer carries a cost, a margin or a vendor price. The calling app must be Vizum-signed (caller_app_signed()) or listed by a Finder manager; refusals are data (NOT_ALLOWED, CALLER_NOT_ALLOWED, BAD_INPUT, NOT_FOUND, CONTROLLED). Browser events finder.quote.saved and finder.substitution.approved (version 1). Every parameter, answer and code: demo_apps/finder/docs/CONTRACT-finder.search.md.
Evidence other apps ask for: pod.capture@1.1.0 (Vizum Proof of Delivery)
Vizum Proof of Delivery (com.vizumapps.pod, 1.2.0, min_platform 5.110) is the evidence piece of a delivery, a pickup or a return: lines with a scan, partial quantities and refusals with a reason, photos, the recipient, a signature, cash collected and a GPS fix checked against the address, captured on the phone also offline, sealed by the server and written back to the Odoo transfer when a manager allows it. Another app asks for a proof instead of building its own:
"requires": [{ "contract": "pod.capture", "version": "^1.0", "optional": true }],
"permissions": { "orm": [
{ "model": "x_vzm_pod_stop", "ops": ["call:pod_request", "call:pod_status", "call:pod_cancel", "call:pod_for_record",
"call:pod_update", "call:pod_consent_grant", "call:pod_consent_revoke"] },
{ "model": "x_vzm_pod_event", "ops": ["call:pod_events_since"] } ] }
const r = await Vizum.orm.call("x_vzm_pod_stop", "pod_request", [{
kind: "delivery", res_model: "x_vzm_dispatch_job", res_id: job.id, res_name: job.number, partner_id: job.partnerId,
driver_uid: me, lines: [{ name: "Filter", qty: 2 }] }, `dispatch-job-${job.id}`]);
if (r.ok) await Vizum.ui.openApp("com.vizumapps.pod", { capture: { stop: r.stop.id } });
// later: pod_status([r.stop.id]) -> { stops: [{ state: "delivered" | "partial" | "failed", signed, photos, seal, ... }] }
pod_request(spec, key)is idempotent on (calling app, key);pod_status(ids | {res_model, res_id}),pod_cancel(stop_id, reason)andpod_for_record(res_model, res_id)answer facts only: never a picture, a signature, a phone or the recipient's name.caller_app()decides: Vizum's own apps as the platform verified them (caller_app_signed(), never the key's prefix) while the administrator's list is empty, only the listed apps otherwise; writes are rate-limited per app; the method runs as the calling person (a driver may ask only for a stop of their own). Refusals are data:NOT_FROM_APP,CALLER_NOT_ALLOWED,RATE_LIMITED,BAD_KEY,BAD_SPEC,UNKNOWN_RULE,FORBIDDEN,NOT_FOUND,ALREADY_CLOSED, and since 1.1.0STOP_SEALED,NOT_UNATTENDED,CONSENT_INVALID.- 1.1.0 (Proof of Delivery 1.2.0), additive: every 1.0 call answers as before.
pod_update(stop_id, spec)makes a planned stop follow its record when it moves or changes hands:date,window_from/window_to,driver_uid(0 takes the stop from its driver),street/city/lat/lng,res_name,instructions; any other key isBAD_SPEC. Only the calling app's own stop, only a dispatcher or manager, and a stop with a sealed proof is never changed (STOP_SEALED). Answers{ok, changed, stop}and writes the feed eventupdated. A consumer that needs it asks for^1.1. - The unattended path (1.1.0). A Vizum app's scheduled work runs as its own service account, which is nobody in Proof of Delivery. A dispatcher of Proof of Delivery first gives a standing consent, as themself, through the calling app:
pod_consent_grant(scope)(the app's opaque scope, Dispatch: one per job type), held by Proof of Delivery and taken back withpod_consent_revoke(id). Thenpod_request,pod_updateandpod_cancel(third argument{consent}) acceptconsent: <id>only when the platform verified the caller as Vizum's (caller_app_signed()), the call runs as that app's own service account (the login the platform mints for it, 5.81 — never another app's, never Proof of Delivery's own), the consent is that app's, active, of this company, and its person is still a dispatcher there; a stop asked for under a consent is changed or cancelled unattended only under a consent of the same scope. The stop records the person. Dispatch 1.3.0 gives the consent on a job type ("Give my consent") for its repeating jobs and online bookings. - Browser events
pod.deliveredv1 andpod.failedv1 (closed schemas, ids and codes,queued: truewhen captured offline); the durable path ispod.feed@1.0.0,pod_events_since(cursor, limit), polled from the consumer's own job. - The consumer pattern for a job that needs a proof (Dispatch, Rental Pro): ask for the stop when the job is scheduled, open the capture from the field screen, and let "Finish" wait for
pod_statusonline or the feed offline. Vizum Dispatch 1.2.0 is the reference consumer:demo_apps/dispatch/hooks/lib/pod.pyand its README section "The proof of delivery".
A sealed proof (1.1.0) also has a verification page for its recipient: a dispatcher, or the stop's driver at the door, makes a guest link (auth: "link", 5.104) whose QR code or address opens the proof without an account; the server recomputes the seal from what it stores, every picture hashed again, and compares it with the seal the link was issued for. The page reaches only the stop its link names, is rate-limited by the platform, and lets the recipient rate the delivery once. It is not part of the contract: a consumer reads seal from pod_status.
Proof of Delivery itself consumes portal.share@1 (the proof on a customer page; the subject is its own object {app: "com.vizumapps.pod", ref} and the PDF goes as bytes), whatsapp.send:v1 and forms.fill@1, each optional and off until a manager turns it on. Every parameter, answer and code: demo_apps/pod/docs/CONTRACT-pod.*.md.
Who may come in, and why not: memberships.access@1.0.0 (Vizum Memberships)
Vizum Memberships (com.vizumapps.memberships, 1.0.0, min_platform 5.116) holds plans, members and families, bills them on Community (one invoice per family, drafts a manager posts) and gives ONE access verdict to its kiosk, its reception screen and any other app. It provides two versioned contracts on x_vzm_memberships_settings; the read methods are read calls (5.105).
| Contract | Method | What for |
|---|---|---|
memberships.access@1.0.0 | api_verdict(member_ref, options) (read) | the verdict the kiosk gets: {allowed, code, reason, first_name, plan, state, credits} — Booking before a reservation, a door app, a POS |
api_use_credit(member_ref, payload, options) | one class credit (Booking), idempotent on options.key; refused as a verdict (allowed: false, code) when the member may not come | |
api_return_credit(member_ref, payload, options) | gives back the credit a use with the same key took (a cancellation inside the caller's policy), once | |
memberships.status@1.0.0 | api_member_status(partner_id) (read) | a contact's memberships: plan, state, start, end, next bill, credits |
api_events_since(cursor, limit) (read) | member.joined, member.overdue, member.frozen, member.cancelled, member.renewal, member.freeze_ending, checkin |
member_ref is a membership id, {membership_id} or {partner_id} (that contact's newest live membership). Verdict codes: OK, NOT_STARTED, CANCELLED, EXPIRED, FROZEN, OVERDUE (unpaid past the grace days), OUTSIDE_HOURS, NO_CREDITS, UNKNOWN; reason is the same plain sentence the kiosk shows, in the person's language.
"requires": [{ "contract": "memberships.access", "version": "^1", "optional": true }],
"permissions": { "orm": [{ "model": "x_vzm_memberships_settings",
"ops": ["call:api_verdict", "call:api_use_credit", "call:api_return_credit"], "read_calls": ["api_verdict"] }] }
const v = await Vizum.orm.call("x_vzm_memberships_settings", "api_verdict", [{ partner_id: 42 }, {}]);
if (!v.allowed) showRefusal(v.reason); // "Payment overdue", "No classes left", ...
const u = await Vizum.orm.call("x_vzm_memberships_settings", "api_use_credit", [{ partner_id: 42 }, { note: "Yoga 18:00" }, { key: `booking-${bookingId}` }]);
Every method runs as the calling person (their companies and their contact rules decide what exists) and the person must hold a Memberships role: an app acts for the person, it does not lend its own rights (an unattended service account is refused in 1.0). The calling app must be Vizum-signed (caller_app_signed()) or listed by a Memberships manager; a direct RPC call may read, never write. Refusals are data: NOT_ALLOWED, NOT_FROM_APP, CALLER_NOT_ALLOWED, RATE_LIMITED (120 writes a minute per app), BAD_INPUT, NOT_FOUND. Nothing personal beyond the first name leaves the verdict: no surname, birthdate or amount. Browser events memberships.checkin and memberships.status.changed (version 1, ids and codes only). Reference: demo_apps/memberships/docs/CONTRACT-memberships.md.
A front desk on top of the scheduling layer: the booking.* contracts (Vizum Booking)
Vizum Booking (com.vizumapps.booking, 1.0.0, min_platform 5.105) is the customer side and the front desk of a clinic, a salon, a gym, a restaurant or a garage: bookings with seats, classes, a waitlist that offers a freed seat, customer links (5.104) to confirm, change or cancel, portal booking, recalls and no-show strikes. It consumes Vizum Dispatch where it is installed (dispatch.availability@1 for the free times, dispatch.booking@1 to place each booking as a job, dispatch.feed@1 for Dispatch's own cancellations), as the person at the screen, and works on its own hours and resources without it. It provides three contracts on x_vzm_booking_booking; the reads are read calls (5.105):
| Contract | Method | What for |
|---|---|---|
booking.slots@1.0.0 | bk_services() | the bookable services, their channels and seats |
bk_free(service, from, to, opts) | free times between two UTC timestamps (at most 31 days), seats left, as a customer sees them | |
booking.manage@1.0.0 | bk_book(service, start, contact, options) | book (or hold, options.hold) for a contact; idempotent on (calling app, options.key) |
bk_status(id) / bk_cancel(id, reason) | one booking the calling person may open | |
booking.feed@1.0.0 | bk_events_since(cursor, limit) | booking.confirmed, booking.cancelled, booking.late_cancel, booking.no_show, booking.arrived, booking.rescheduled, booking.done |
"requires": [{ "contract": "booking.manage", "version": "^1", "optional": true }],
"permissions": { "orm": [{ "model": "x_vzm_booking_booking", "ops": ["call:bk_free", "call:bk_book"],
"read_calls": ["bk_free"] }] }
const free = await Vizum.orm.call("x_vzm_booking_booking", "bk_free", ["consult", "2026-10-20 13:00:00", "2026-10-21 13:00:00", { party: 1 }]);
const r = await Vizum.orm.call("x_vzm_booking_booking", "bk_book",
["consult", free.slots[0].start, { partner_id: 42 }, { key: `wa-${conversationId}` }]);
Every method checks the calling app first — Vizum-signed (caller_app_signed()) or listed by a Booking manager — and runs as the calling person, who needs a Booking front-desk role for anything but bk_services; the seat is taken under the same lock and check as a booking made at the desk, and a customer at the strikes limit is refused STAFF_ONLY. Refusals are data: NOT_FROM_APP, CALLER_NOT_ALLOWED, RATE_LIMITED, FORBIDDEN, BAD_SPEC, UNKNOWN_SERVICE, NOT_BOOKABLE, SLOT_TAKEN, STAFF_ONLY, NOT_FOUND, NOT_OPEN. Browser events booking.confirmed, booking.cancelled and booking.arrived (version 1, ids and a UTC time). Booking sends nothing by itself unless a manager turns the messages on. What Booking would need from Dispatch to drop its own seat ledger (hold, confirm, reschedule, state, seats left, an unattended path for a signed app's service account): demo_apps/booking/docs/DESIGN.md. Every parameter, answer and code: demo_apps/booking/docs/CONTRACT-booking.md.
What to buy, refill and request: replenish.plan@1.0.0 and replenish.request@1.0.0 (Vizum Replenish)
Vizum Replenish (com.vizumapps.replenish, 1.0.0, min_platform 5.110) plans what each location needs: a daily forecast that leaves out the days a product was out of stock, safety stock from a service level (by ABC class when ABC-XYZ is installed), the vendor's lead time, minimum order quantity and case size, and one sentence that explains each number. It turns the plan into drafts only (RFQs per vendor, created by a person; van refills, internal transfers drafted at night) and takes material requests from vans and job sites with four-eyes approval. Odoo's stock.warehouse.orderpoint stays the rule of record: Replenish writes min/max only after a preview and only on rules a manager handed to it. Another app asks it instead of keeping a second stock rule:
"requires": [{ "contract": "replenish.plan", "version": "^1", "optional": true },
{ "contract": "replenish.request", "version": "^1", "optional": true }],
"permissions": { "orm": [{ "model": "x_vzm_replenish_request", "ops": ["call:api_suggest", "call:api_request"] }] }
const r = await Vizum.orm.call("x_vzm_replenish_request", "api_suggest", [[productId], vanLocationId]);
if (r.ok) for (const i of r.items) showRefill(i.product_id, i.qty, i.cover_days);
// a technician needs a part on a job site: a material request a manager approves (never the requester)
await Vizum.orm.call("x_vzm_replenish_request", "api_request",
[siteId, [{ product_id: partId, qty: 2 }], "2026-10-20", { key: `wo-${workOrderId}-${partId}` }]);
| Method | What for |
|---|---|
api_suggest(product_ids, location_id) | read call: the suggested quantity, days of cover and the explanation per product (≤ 200 products) |
api_request(site_id, lines, needed_by, options) | another app files a material request for a site the calling person belongs to; it waits for a manager's approval; idempotent on options.key |
api_requests_pending(limit) | read call: the requests waiting for this manager's decision, never their own (Vizum Approvals' inbox) |
api_events_since(cursor, limit) | read call: request.approved, request.ordered, stockout.risk |
Every method runs as the calling person, with Replenish's ownership rules: a requester reaches the sites where they are responsible or a member and never sees a price, a buyer the vendors assigned to them, a warehouse person the sites their warehouse refills, a manager the company's; any other id answers NOT_FOUND, like one that does not exist. The calling app must be Vizum-signed (caller_app_signed()) or listed by a Replenish manager; refusals are data (NOT_ALLOWED, NOT_FROM_APP, CALLER_NOT_ALLOWED, RATE_LIMITED, BAD_INPUT, NOT_FOUND). Browser events replenish.request.decided and replenish.drafts.created (version 1, ids only). Replenish reads ABC-XYZ's classes (abcxyz:classes:v1) and Expiry Radar's presence (shelf-life cap) when they are installed, and never confirms a purchase or a transfer. Every parameter, answer and code: demo_apps/replenish/docs/CONTRACT-replenish.md.
What a person earned, for other apps: commissions.earnings@1.0.0 (Vizum Commissions)
Vizum Commissions (com.vizumapps.commissions, 1.0.0, min_platform 5.116) computes variable pay from Odoo's own documents (posted invoices and credit notes, POS orders, piece-work tallies, carrier statements), explains every line in one sentence and runs it through statements with disputes, four-eyes approval and a draft payout (payslip input, vendor bill or CSV). It provides one versioned contract on x_vzm_commissions_settings; every method is a read call (5.105).
| Method | What for |
|---|---|
api_my_earnings(period_kind) (read) | the CALLER's own {total, earned_today, tier, currency, as_of, period} — a staff day sheet in Booking, Salesman, Punch Clock |
api_statement_status(earner_id, period) (read) | {exists, state, statement_id, total, approved_at, paid_at} for the earner themselves or a manager or payroll person of the earner's company |
api_events_since(cursor, limit) (read) | statement.approved, statement.paid, dispute.opened the caller may see, at most 200 |
"requires": [{ "contract": "commissions.earnings", "version": "^1", "optional": true }],
"permissions": { "orm": [{ "model": "x_vzm_commissions_settings",
"ops": ["call:api_my_earnings", "call:api_statement_status", "call:api_events_since"],
"read_calls": ["api_my_earnings", "api_statement_status", "api_events_since"] }] }
const e = await Vizum.orm.call("x_vzm_commissions_settings", "api_my_earnings", ["month"]);
if (e.ok) showMeter(e.earned_today, e.total, e.tier); // the caller's own numbers, never anybody else's
Every method runs as the calling person, who must hold a Commissions role; pay is private, so another earner's id (or another company's) answers NOT_FOUND, and no answer carries a cost or a margin. The calling app must be Vizum-signed (caller_app_signed()) or listed by a Settings administrator; a direct RPC call answers NOT_FROM_APP, an unlisted app CALLER_NOT_ALLOWED. Browser events commissions.statement.approved and commissions.dispute.opened (version 1, ids only). Commissions sends nothing by itself unless a manager turns the statement e-mail on, and never writes to a carrier. Every parameter, answer and code: demo_apps/commissions/docs/CONTRACT-commissions.md.
Where a sale order stands in its billing schedule: billing.schedule@1.0.0 (Vizum Progress Billing)
Vizum Progress Billing (com.vizumapps.progressbilling, 1.0.0, min_platform 5.116) bills by progress on a native sale.order: a schedule of values, pay applications in the AIA G702/G703 shape (work, stored materials, retention that reduces or stops, change orders), approval by a second person, and a draft account.move created through the ORM as the person who presses the button (Odoo computes taxes and the fiscal position; the app never posts). It also builds the monthly owner statement of a property from the analytic lines of its accounts. It provides one versioned contract on x_vzm_progressbilling_settings; the method is a read call (5.105).
| Method | What for |
|---|---|
api_billing_status(order_id) (read) | {contract_sum, billed, pct, retention_held, last_app: {number, state, period_to} | null} for the order's contract, so Salesman, Credit Control or a customer portal can show "62 % billed, 4 250 held" |
"requires": [{ "contract": "billing.schedule", "version": "^1", "optional": true }],
"permissions": { "orm": [{ "model": "x_vzm_progressbilling_settings",
"ops": ["call:api_billing_status"], "read_calls": ["api_billing_status"] }] }
const s = await Vizum.orm.call("x_vzm_progressbilling_settings", "api_billing_status", [orderId]);
if (s.ok) showBar(s.pct, s.retention_held); // NOT_FOUND when the order has no contract or the caller may not see it
Every method runs as the calling person, who must hold a Progress Billing role; a project manager reaches only the contracts they manage, and another company's contract answers NOT_FOUND. Progress Billing sends nothing by itself unless an administrator turns the e-mail switches on (OFF on install and after every update). Full method list: demo_apps/progressbilling/docs/API.md.
A scrap that waits for a second person: shopfloor.scrap@1.0.0 (Vizum Shop Floor)
Vizum Shop Floor (com.vizumapps.shopfloor, 1.0.0, min_platform 5.116) is the tablet terminal, the production board, the OEE monitor and the kitchen prep list on Odoo's own manufacturing records, for Community and Enterprise 17 to 20. A scrap over the value or share limits of its Settings waits for a second person. The app provides one read call on x_vzm_shopfloor_settings:
| Method | What for |
|---|---|
api_scrap_pending(limit) | read call: the scrap requests waiting for a Shop Floor manager's decision, never one the caller filed or changed (an inbox such as Vizum Approvals') |
"permissions": { "orm": [{ "model": "x_vzm_shopfloor_settings", "ops": ["call:api_scrap_pending"], "read_calls": ["api_scrap_pending"] }] }
The answer carries ids, names and numbers only ({ok, requests: [{id, name, production, workcenter, product, qty, value, requester, filed_at}]}). The decision is made in Shop Floor: four-eyes (the requester and every editor are refused), the figures are approved as filed (a changed order answers CONFLICT and nothing is scrapped), and the scrap is made with the approving manager's own Odoo rights. Shop Floor itself uses Odoo's methods (button_start, button_pending, button_finish, button_block, unblock, scrap) as the logged-in person, and, when installed, Vizum Costing (costing.estimate@1: cst_estimate_get, cst_estimate_upsert, cst_actual_add) and Vizum Expiry Radar (expiry.radar@1: api_lots_at_risk, api_log_waste). Every parameter, answer and code: demo_apps/shopfloor/docs/CONTRACT-shopfloor.scrap.md.
Selling a fraction of a roll, bar or sack: cutfraction.quote@1.0.0 and cutfraction.cut@1.0.0 (Vizum Cut & Fraction)
Vizum Cut & Fraction (com.vizumapps.cutfraction, 1.0.0, min_platform 5.139) keeps the pieces of a roll, a bar, a sheet or a sack that Odoo holds as one quantity: it ranks which piece a cut comes from, books the offcut as a remnant and writes the waste off as the person. It provides two contracts, both on x_vzm_cutfraction_settings. Every method runs as the calling person, who must hold a Cut & Fraction role (clerk or above); an outsider, a portal user and the service account answer NOT_ALLOWED, and an id of another company answers NOT_FOUND. The caller rule is callable_by: each api_ method names Counter Quote (com.vizumapps.counterquote), and any other app of the same verified publisher reaches them too; no other app may.
| Method | What for |
|---|---|
api_cf_quote(spec) | read call: {product_id, qty, uom_id?}; the price of the fraction (list price of the product x quantity + the cut fee), the piece it would come from, the waste and the remnant after, and the shortage. Never writes |
api_cf_reserve(spec) | {product_id, qty, uom_id?, quote_ref, ttl_min?, client_uuid}; holds the length on a piece until it expires (30 minutes by default); the same client_uuid answers the same reservation (repeat) |
api_cf_release(spec) | {reservation_ids} or {quote_ref}; lets the reservations go, answers {released} |
api_cf_confirm(spec) | {reservation_ids, doc?: {kind, id}}; the physical cut: the piece goes down, the offcut becomes a remnant, the document is linked |
api_cf_status(spec) | read call: {reservation_ids} or {quote_ref}; state, expiry, quantity and piece of each |
api_cut_options(product_id, {company_id}) | read call (cutfraction.cut@1.0.0): the units a product may be sold by; never writes |
"permissions": { "orm": [{ "model": "x_vzm_cutfraction_settings",
"ops": ["call:api_cut_options", "call:api_cf_quote"], "read_calls": ["api_cut_options", "api_cf_quote"] }] }
api_cut_options answers {ok: true, options: [{uom_id, name, step, min, note}]}. options is [] when the product has no active Cut & Fraction item (it is not cut by this app); a company_id that is not the caller's company, or a product the caller cannot see, answers NOT_FOUND. The options are the product's own unit first, then every active unit of the same category that is not bigger than it, biggest first. uom_id is an Odoo unit, so the sale line is created with it and Odoo's own conversion prices and moves it. name is translated (Cut by the m), note is The rest is kept as a remnant. step is a display hint: 0.5 for the product's unit and for any unit of at least half a reference metre or kilo, else 1. min is the item's effective scrap threshold (its own, else its family's) converted into that unit, rounded up to a whole step and never below one step. Odoo's rounding still decides the quantity. The caller never creates a remnant or a lot: the sale order is Odoo's, the remnant is Cut & Fraction's when the delivery is done. Every parameter, answer and code: demo_apps/cutfraction/docs/DESIGN.md.
Sensors on Digital Signage: iot.signage@1.0.0, metering.signage@1.0.0, signage.board@1.0.0
Three contracts, no new platform capability (native methods, caller_app(), call: grants, provides / requires, read_calls 5.105). Design and the options weighed: Signage 1.6.0 HISTORY.
iot.signage@1.0.0(Vizum IoT 2.5.0, methods onx_vzm_iot_sensor;publicsince 2.12.1: since platform 5.66 a non-public method refuses a caller who is not a user of IoT, and the platform fix for #4387 (pending) will refuse every cross-app call to one;publiclets any signed-in user reach it, socaller_app()is the first line of each method:iot_signage_sensorsandiot_signage_snapshotanswer only Digital Signage; a direct call or any other app getscaller_refused):iot_signage_sensors()lists the live sensors by box with whether each is allowed on screens;iot_signage_snapshot(ids, hours)answers one sensor's value, unit, ages, state, zone and a short series, and ONLY for sensors a Vizum IoT technician switched on (x_signage, off by default, written by a technician only; per idok,not_sharedorgone);iot_signage_allow(ids, allow)is the technician's switch (the group is checked inside after the caller check: only Signage, Remote Metering or IoT's own window may call it, any other caller or a direct call getscaller_refused, a person who is not a technician getstechnician_only) and is written to IoT's event log. Nothing about the address, serial, firmware, battery or radio leaves. Signage holds no read grant on any IoT model: a screen can never read more than the answer.metering.signage@1.0.0(Remote Metering 1.1.0,rm_signage_states(ids)on the alarm model; only Signage): per allowed sensor the worst open alarm (unacknowledged before acknowledged, then severity) as state, severity, the limit as text and minutes open, plus the limits of the enabled value rules as bands for a gauge. A sensor not allowed on screens answersnot_sharedand nothing else. No person, note, rule name or e-mail setting.signage.board@1.0.0(Digital Signage 1.6.0, methods onx_vzm_signage_canvas; only IoT and Remote Metering):sg_boards()lists the boards the CALLING person may edit with their free places;sg_add_sensor_widgets(canvas_id, items)adds onesensorwidget per sensor to the board's DRAFT on free cells of a 440 x 300 grid, all or nothing. Run as the calling person (Signage's groups and rules decide); never publishes. Refusals are data:CALLER_NOT_ALLOWED,NOT_EDITOR,BOARD_NOT_FOUND,BOARD_FULL,TOO_MANY_WIDGETS,BAD_ITEMS.- Scoping per board: a screen may ask only for a widget (name and settings) on the PUBLISHED copy of a canvas it plays; publishing re-checks every sensor as the publisher; revoking a sensor in IoT makes the next refresh say so on the wall.
Apps that feed IoT: iot.ingest@1.0.0 (Vizum IoT)
Vizum IoT 2.12.0 lets another app publish its own equipment and sensors into IoT and append readings to them. The readings pass through the pipeline a Raspberry Pi's use, so zones, history, hour and day rollups, events and Remote Metering alarms work on them with no change. Full reference: contracts/iot-ingest.md. Home Control is the first consumer. No new platform capability (native methods, caller_app() 5.69, caller_app_signed() 5.104, call: grants).
- Four public methods on
x_vzm_iot_sensor:iot_ingest_upsert(spec),iot_ingest_append(readings, options),iot_ingest_list(options),iot_ingest_retire(spec). They are public so the contract works when the calling user is not a user of IoT; the caller gate and the row scoping keep them safe. Refusals are data (NOT_FROM_APP,CALLER_NOT_ALLOWED,BAD_INPUT,LIMIT_REACHED,RATE_LIMITED,KEY_REUSED). - Who may call: Vizum-signed apps, or an app a technician listed in IoT Settings; the deny list in the same screen overrides both. Ownership: through the ingest methods an app sees and changes only its own rows (
x_source_app, written by the server). Readers ofiot.sensors@1.1.0see published sensors like any other: that is the purpose. - Idempotent:
options.keyonappend; a reading is also unique per (sensor, instant). - Retire is not uninstall: uninstalling keeps the data; a technician archives or deletes it in IoT Settings.
Alarms for other apps: metering.alarms@1.0.0 (Remote Metering)
Remote Metering 1.2.0 lets another app read the alarms of the sensors and equipment the calling person can see. Full reference: contracts/metering-alarms.md. Home Control is the first consumer. No new platform capability (native method, caller_app() 5.69, caller_app_signed() 5.104, read calls 5.105); min_platform stays 5.116.
- One public, read-only method on
x_vzm_remotemetering_alarm:metering_alarms_list(query); the consumer holdscall:metering_alarms_listand lists it inread_calls. Refusals are data (NOT_FROM_APP,CALLER_NOT_ALLOWED,FORBIDDEN,BAD_INPUT). - Who may call: Vizum-signed apps, or an app a manager listed in Remote Metering Settings; the deny list in the same screen overrides both. The person needs a Remote Metering role.
- Scoping: both reads run as the person, so Remote Metering's record rules and every
ir.ruleon Vizum IoT's sensors and equipment apply. A hidden, deleted or unknown sensor is simply absent (rows,totalandcounts). Paged, newest first; no people, notes or e-mail data.zone_id/zone_nameare the alarm's own record of the zone (Vizum IoT zones carry no record rules). Ids above 2147483647 areBAD_INPUT; unknown query keys are ignored, so a typo such assensor_idgives an unfiltered read, still scoped to the person. - Lists per company: the allow and deny lists belong to the company being read (
query.company_id, default the person's current company); a manager sets them in each company.
Alarm rules from other apps: metering.rules@1.0.0 (Remote Metering)
Remote Metering 1.3.0 lets another app propose an alarm rule. The proposal is a draft that a Remote Metering manager accepts or rejects in Remote Metering's own Rules screen; it is never a silent rule. Full reference: contracts/metering-rules.md. Home Control is the first consumer. No new platform capability (native methods, caller_app() 5.69, caller_app_signed() 5.104, advisory lock 5.103, read calls 5.105); min_platform stays 5.116.
- Two public methods on
x_vzm_remotemetering_rule:metering_rule_propose(draft)(a write) andmetering_rule_status(query)(a read call); the consumer holdscall:on both and lists only the status inread_calls. Refusals are data (NOT_FROM_APP,CALLER_NOT_ALLOWED,FORBIDDEN,BAD_INPUT,LIMIT_REACHED,BUSY). - Who may call: Vizum-signed apps, or an app a manager listed under "Apps that may propose rules"; "Apps that may not propose rules" overrides both. The person needs a Remote Metering role. Remote Metering's own screen methods refuse every other app and every call with no app (a consumer's server code included), so the contract is the only way.
- Strict drafts: Remote Metering's own rule validation decides, and every value must come back unchanged (a value the engine would clamp is
BAD_INPUT). Sensors are checked as the person; a hidden, deleted or unknown sensor gives one answer. Idempotent per company, app and key; 20 pending per app; a draft expires in 30 days; a rejected key stays rejected until housekeeping deletes it 180 days after the decision; an accepted draft is kept for good. The manager's decision names the version of the draft it saw, and a draft the app changed meanwhile answersCHANGEDin Remote Metering's screen. A NUL character in a text isBAD_INPUT. - Lists per company: the allow and deny lists belong to the company (
draft.company_id, default the person's current company); a manager sets them in each company. A job user needs a Remote Metering role.
Contracts with covered items, an SLA and a visit schedule: Service Contracts (com.vizumapps.servicecontracts)
Vizum Service Contracts 1.0.0 (min_platform 5.116) manages maintenance contracts and insurance policies: covered items, SLA by priority inside coverage hours, a preventive-maintenance schedule that generates visits, included hours and visits, a billing schedule that creates draft account.move invoices as the caller, a renewal pipeline with price uplift, and profitability per contract. Contract approval and every price uplift are four-eyes: the creator, every editor and the submitter are refused, and the figures are approved as built (a hash taken at submission; a change answers CONFLICT FIGURES_CHANGED). It provides servicecontracts.coverage@1.0.0 (api_coverage on x_vzm_servicecontracts_settings, read call): for a customer or a linked asset, the active contracts, their SLA and the hours and visits left; it names no price. It consumes dispatch.job@1.0 (optional, off in Settings until a manager turns it on): a visit sent to Dispatch calls dsp_job_upsert with the key svc:<visit id>, a corrective request with svcr:<request id>, so sending twice updates and never duplicates; dsp_job_status brings a finished job back. It reads assets.asset@1.0 (api_lookup, optional). Commissions needs no call: the draft invoices carry invoice_user_id = the contract's account manager, the field Commissions reads. Details: demo_apps/servicecontracts/docs/DESIGN.md.
Plan the visits, then send them to Dispatch: Route Planner (com.vizumapps.routeplanner)
Vizum Route Planner 1.0.0 (min_platform 5.110) plans recurring sales, delivery and service routes and measures compliance, coverage and profitability. It provides no contract. It consumes dispatch.job@1.0 (optional, off in Settings until a manager turns it on): "Send to Dispatch" calls dsp_job_upsert once per stop of a published plan with the key rp:<line id>, so sending twice updates the jobs and never duplicates them. It reads Salesman visits and POD stops (by grant, optional) as evidence of a visit. Details: demo_apps/routeplanner/docs/DESIGN.md.
Concepts
Vizum Assets contracts — assets.asset, assets.meter, assets.custody, assets.timeline
Vizum Assets (com.vizumapps.assetreg, 1.0.0, min_platform 5.98) is the equipment and asset register: assets with QR labels, meters and their readings, documents that expire, custody (check-out and check-in), preventive plans by date or by usage, and a timeline per asset. It publishes four versioned contracts so other apps use the register instead of keeping a second one: Dispatch finds the asset of a job, Forms records the meter answered in a checklist, Work Orders and Rental Pro lend and take back equipment, the Portal app shows a customer their equipment.
"provides": ["assets.asset@1.0.0", "assets.meter@1.0.0", "assets.custody@1.0.0", "assets.timeline@1.0.0"]
A consumer declares what it uses, optional when it works without Assets, and holds a call: grant per method:
// consumer manifest.json
{ "min_platform": "5.98",
"requires": [{ "contract": "assets.asset", "version": "^1", "optional": true },
{ "contract": "assets.meter", "version": "^1", "optional": true }],
"permissions": { "orm": [
{ "model": "x_vzm_assetreg_asset", "ops": ["call:api_lookup", "call:api_due"] },
{ "model": "x_vzm_assetreg_reading", "ops": ["call:api_record_reading"] } ] } }
// browser
if ((await Vizum.apps.find("assets.asset")).some((a) => a.ready)) {
const r = await Vizum.orm.call("x_vzm_assetreg_asset", "api_lookup", [{ code: "AST-00042" }]);
if (r.ok) show(r.asset); else explain(r.error_code);
}
# server: a native method or a job of the consumer
res = V.orm.call("x_vzm_assetreg_reading", [], "api_record_reading",
[{"asset": {"serial": "SN-1"}, "meter": "hours", "value": 1250}, {"key": "wo-12-close"}])
The TypeScript types of every input and result are in demo_apps/assetreg/src/contract/types.ts; copy them.
Rules every method follows
- Results are plain JSON (5.98 typed results), never records. Dates are
"YYYY-MM-DD", timestamps"YYYY-MM-DD HH:MM:SS"in UTC. - A refusal is data, never an exception:
{ok: false, error_code, error}.erroris a sentence in the person's language;error_codeis stable:NOT_FOUND,AMBIGUOUS,BAD_INPUT,FORBIDDEN,CALLER_NOT_ALLOWED,RATE_LIMITED,READING_BACKWARDS,ALREADY_CHECKED_OUT,NOT_CHECKED_OUT,NOT_AVAILABLE,CONFLICT, and since 1.1.0 (additive: a new refusal for a case that was an error before)KEY_REUSED,SIGNATURE_REQUIRED. - It runs as the person who uses the calling app: their record rules and their companies decide what exists. An asset of another company, or one they cannot read, answers
NOT_FOUND, the same as one that does not exist. - Who may call. Assets reads
caller_app()(verified by the platform, 5.69). Apps of Vizum (com.vizumapps.*) may call; any other app only when an administrator lists it in Assets › Settings › Contract callers (elseCALLER_NOT_ALLOWED). A write needs a verified calling app: call through your owncall:grant (Vizum.orm.callorV.orm.call), not by calling the method on a record from your native code, which reaches Assets without the platform's caller marker and is refused.api_list_for_partneris the Portal app's only (com.vizumapps.portal) or a listed app's. - Writes are idempotent with
options.key(8 to 128 ofA-Z a-z 0-9 _ . : -), scoped to the calling app: the same key from the same app answers the first result withduplicate: trueand changes nothing. A bad key isBAD_INPUT. - Writes are rate-limited per calling app: 120 a minute (
RATE_LIMITED). - Every write names the caller on the asset's timeline (
source_app), so a manager sees which app did what.
How an asset is named (AssetRef)
One key is enough: {id}, {token} (the label token), {url} (the URL a label's QR encodes, https://<instance>/app/assetreg/qr/<token>), {code} (AST-00042), {serial}, {lot_name} (a stock.lot), or {link: {model, id}} for the asset linked to a fleet.vehicle, maintenance.equipment, stock.lot, project.project or account.asset. Two assets with the same serial answer AMBIGUOUS.
assets.asset@1 (model x_vzm_assetreg_asset)
| Method | Arguments | Result |
|---|---|---|
api_lookup | ref: AssetRef | {ok, asset: AssetSummary} |
api_search | {text?, category?, state?, health?, partner_id?, location_id?, limit (1 to 200, default 50), offset} | {ok, total, assets: AssetSummary[]}; limit over 200 is BAD_INPUT, never cut silently |
api_for_record | model, id: a res.partner (owner or customer site, or custodian), one of the linked models above, or a record of another app tied with api_link | {ok, assets: AssetSummary[]} |
api_link | {asset_id, model, id, name?}, options | {ok, event_id}: ties a record of the calling app (a job, an order, a submission) to the asset |
api_list_for_partner | partner_id (its commercial entity and contacts) | {ok, assets: PublicAssetSummary[]}: no custodian, no cost, serial masked (******3456), documents without files |
AssetSummary: {id, code, name, kind, state, health, category: {id, name} | null, location, custodian: {kind, name}, checked_out, due_back, serial, make, model, partner, next_due_at, attention, company_id, url}. kind: equipment, vehicle, tool, it, furniture, site, other. state: draft, available, in_use, checked_out, maintenance, out_of_service, lost, retired. health: ok, due_soon, overdue, expired_doc, out_of_service.
assets.meter@1
| Method | Model | Arguments | Result |
|---|---|---|---|
api_record_reading | x_vzm_assetreg_reading | {asset: AssetRef, meter: hours | km | mi | cycles | custom (default hours), value, read_at?, unit? (custom), rollover?, source_ref?}, options | {ok, reading_id, delta, flagged, flag_reason: "jump" | "over_24h" | null, work_created: id[]}; READING_BACKWARDS, BAD_INPUT (not a number, below zero or above 10^12), KEY_REUSED (the options.key was used for another asset's reading) |
api_meters | x_vzm_assetreg_asset | ref: AssetRef | {ok, asset_id, meters: [{type, unit, label, value, total, at, avg_daily, primary}]} |
api_due | x_vzm_assetreg_asset | {asset?: AssetRef, within_days?, within_value?} | {ok, due: [{plan: {id, name}, asset: {id, code, name}, next_due_at, next_due_value, projected_due_at, status, trigger, overdue}]} |
read_at is when the reading was taken (a device that was offline sends its own time; never in the future). A reading dated before the newest one must fit between its neighbours in time, is judged against the one before it (flagged, never refused, when odd) and never moves the meter (late: true). A manager settles a flagged reading in Assets (reading_review, not a contract method): the reading is kept as it was and the decision sits beside it; a dismissed reading stops counting for the meter's current value and the average. A reading lower than the last one is READING_BACKWARDS unless rollover: true (a replaced meter: a new segment starts, total keeps the lifetime value plans count on). A jump above three times the asset's average daily use (Settings) or more hours than the days had is recorded and flagged for a manager. A reading inside a usage plan's lead creates its work item (work_created), once.
assets.custody@1
| Method | Model | Arguments | Result |
|---|---|---|---|
api_check_out | x_vzm_assetreg_movement | {asset, to_kind: user | partner | employee, to_id, due_back?, condition?: good | worn | damaged, meter?, notes?, to_location_id?, signature? (PNG base64, 150 KB at most), photos?}, options | {ok, movement_id, state: "open"}; ALREADY_CHECKED_OUT (also when another device lent it a moment ago), NOT_AVAILABLE, SIGNATURE_REQUIRED (only when the company turned "Require a signature" on in Settings; off by default) |
api_check_in | x_vzm_assetreg_movement | {asset, condition?, meter?, notes?, location_id?, signature?, photos?, report? (open corrective work when damaged), movement_id? (the loan it closes), at?}, options | {ok, movement_id, state: "closed", work_id}; NOT_CHECKED_OUT, CONFLICT (lent again since), FORBIDDEN (only who lent it, who holds it or an Assets manager), SIGNATURE_REQUIRED (as above) |
api_custody | x_vzm_assetreg_asset | ref | {ok, asset_id, custodian: {kind, name}, location, checked_out, out_at, due_back} |
assets.timeline@1
| Method | Model | Arguments | Result |
|---|---|---|---|
api_log_event | x_vzm_assetreg_event | {asset, kind?: external | form_submitted | work_done, summary (1 to 280), ref_model?, ref_id?, url? (https or a path)}, options | {ok, event_id} |
api_events_since | x_vzm_assetreg_asset | cursor (an event id, 0 first), kinds?: EventKind[], limit (1 to 500) | {ok, cursor, events: [{id, at, kind, asset_id, code, summary, data, source_app, ref_model, ref_id}]} oldest first |
api_work_done | x_vzm_assetreg_work | {work_id | ext_model + ext_id, done_at?, done_value?, note?}, options | {ok, work_id, next_due}: closes the work and re-bases its plan on what was done. Allowed to the app that holds the work's twin (ext_app), its assignee or an Assets manager (FORBIDDEN otherwise: a work item sent to an app is not closed by another app); a future done_at or a done_value above the meter is BAD_INPUT |
api_events_since is the outbox: events are browser-local (events.md), so a server-side consumer (a job of Dispatch, a Portal page) reads what happened with a cursor it keeps. The cursor only moves forward. Kinds include pm_due, work_created, work_done, checked_out, checked_in, reading, reading_flagged, doc_expiring, doc_expired, scanned, linked, external.
Browser events (event_contracts, version 1)
assets.asset.scanned {asset_id, code, via?} · assets.asset.updated {asset_id, code, state?} · assets.asset.meter_recorded {asset_id, code, meter, value, flagged?, queued?} · assets.asset.checked_out {asset_id, code, custodian_kind?, due_back?, queued?} · assets.asset.checked_in {asset_id, code, condition?, queued?} · assets.asset.due {asset_id, code, plan_id, trigger: date | usage, overdue?, next_due_at?}. Closed schemas; ids, codes and kinds only, never a person's name. queued: true when a device that is offline recorded it.
What Assets uses from other apps
- Forms (
forms.fill@1.0.0): "Fill a form" on an asset opens Forms on the category's template, linked to the asset's vehicle or equipment (or the asset). A submission made on an asset joins its timeline (read by Assets'work_syncjob), and a work item waiting for that template is completed by it. - Dispatch (
dispatch.job@1, optional): with Settings › "Send work to Dispatch" on, preventive and corrective work is a Dispatch job (dsp_job_upsert, keyed on the work row) and Dispatch's completion closes it. - WhatsApp (
whatsapp.send:v1): the optional morning digest, off until the master switch and the channel are on. - Label Builder (
labels.print@1, a later version of Label Builder): custom layouts and ZPL printing.
Compatibility
@1.x adds optional fields only. A removed or renamed field, a changed type or a new required argument is @2.0.0, and the events change version the same way.
Concepts
Events — between open apps, with a contract
Vizum.events lets the apps open on a desktop talk to each other: one emits, the others listen. Both sides need the grant (permissions.events.emit / .listen). Events are browser-local and immediate — nothing is stored or delivered to someone who is not looking. For data other users or a later session must see, use Vizum.realtime (server fan-out to the users of an app), Vizum.db or webhooks.
await Vizum.events.emit("order.created", { id: 42, total: 99.5 });
Vizum.events.on("order.created", (payload, meta) => {
// meta (5.67): { producer: "com.acme.orders", version: 2 }
});
Event contracts (capability 5.67)
The app that emits an event can declare its shape, once, in its manifest:
{
"min_platform": "5.67",
"permissions": { "events": { "emit": ["order.created"] } },
"event_contracts": {
"order.created": {
"version": 2,
"description": "A sales order was confirmed",
"schema": {
"type": "object",
"required": ["id", "total"],
"additionalProperties": false,
"properties": {
"id": { "type": "integer", "minimum": 1 },
"total": { "type": "number", "minimum": 0 },
"status": { "enum": ["draft", "confirmed"] }
}
}
}
}
}
What the platform does with it:
- At install it checks the contract: only events the app may emit, an integer
version1–999, and a schema written with the supported keywords only —type,enum,const,properties,required,additionalProperties,items,minItems,maxItems,minLength,maxLength,pattern,minimum,maximum,description. Any other keyword is refused instead of being silently ignored. Apatternthat repeats a group made only of repeated parts —(a+)+,(\w+\s?)+— is refused too: it can take exponential time on the desktop of whoever emits. - A contract reserves the event's name. Once a ready app declares a contract for an event, no other app may emit it: the desktop rejects the emit with
VALIDATION_FAILEDnaming the producer. Adding an event topermissions.events.emitin an update is a permission widening, like adding one tolisten— an administrator approves it. - On every emit the desktop host checks the payload against the contract. A payload that does not match is not delivered to anyone, and
emitrejects with aVizumErrorVALIDATION_FAILEDnaming each problem (payload.total must be >= 0). - Every listener receives
meta = { producer, version }as the second argument — the app that emitted it and the contract version, so a listener can handle two versions while the producer migrates.versionisnullwhen the producer declares no contract for that event;metaisnullfor the host's own events. Vizum.events.catalog()lists every contract the ready (approved, enabled) installed apps you can see declare:[{ name, version, description, schema, producer }]— what you can listen for, and in which shape.
Versioning. Raise version whenever a listener could break: a removed or renamed field, a changed type, a new required field. Adding an optional field to an open object is not a new version.
vzm check reports INVALID_EVENT_CONTRACT (an event the app does not emit, an unsupported keyword), MIN_PLATFORM_TOO_LOW below 5.67, and EVENT_UNDOCUMENTED (info) for each event the app emits without a contract.
Events run on the desktop. In Odoo's own menus and in Vizum Solo an app is alone in its page, so emit, on and catalog degrade there (Vizum.supports("event-contracts") is false).
Concepts
Lifecycle, health and update preflight
*Since capability 5.67.*
Where an app is
| Lifecycle | Means |
|---|---|
running | approved, and every required dependency is ready |
blocked | approved, but a required dependency is missing, disabled or the wrong version (composition) |
pending_approval | installed; an administrator has not approved it yet (a first install of a privileged app, an update that widened its permissions, or — SDK 5.69 — an update that changed its server code) |
disabled | an administrator switched it off |
withdrawn | the Vizum Store revoked this version (package signing) |
Which companies see it
An app installed from a zip (a developer install) is available to the company the installer was working in. Another company's user who tries to install the same key gets "already installed for the company X ...". A Settings administrator changes that in Apps Manager ("Available to": all companies, or one of the administrator's own companies); the call is vizum.app.api_set_app_company(key, company_id) (false = every company) and each change is audited as platform:company_scope. Apps from the Vizum Store and the first-party defaults stay available to every company: their licence belongs to the whole database, so restricting one is refused and a Store update clears the restriction. This is visibility only; the rows of a company_scoped model stay isolated by company either way. No SDK call and no capability.
How it is doing — Vizum.apps.health()
const h = await Vizum.apps.health();
// { status: "healthy" | "degraded" | "failing", lifecycle: "running", since: "…",
// checks: [{ name: "runtime_errors", status: "degraded", detail: "1 error(s) …" }, …] }
Five checks, from facts the platform already records — nothing to configure:
| Check | healthy | degraded | failing |
|---|---|---|---|
state | approved and running | — | waiting for approval, disabled, or withdrawn |
dependencies | all ready | an optional one is not | a required one is not |
runtime_errors | none | 1–2 | 3 or more |
background_tasks | none failed | 1–2 failed | 3 or more failed |
schema | the database matches the manifest | a declared field's type or comodel in the database differs from the manifest | — |
A sixth check appears only when it has something to say: jobs_time_limit (degraded) names each job whose last run in the window went past the platform's 300 second limit for method jobs (see reliability); a healthy app reports nothing.
So does policy (SDK 5.97, feature policy-reconcile): it appears when the platform's own rules changed after your app was installed and your installed version no longer fits them (see *When the platform's rules tighten*, below).
Errors and tasks count over the last 24 hours. A runtime error counts only when the app's own code threw it, and not a benign notice — the same evidence the automatic update uses to roll a bad release back. The app's status is its worst check.
The schema check names each drifted field — job.hours: database char, manifest datetime — left by an update that changed a field's type before the platform refused it (see below). It never blocks an update; the cure is a migration in the app.
The desktop's Apps Manager shows this instead of the bare state: one call, api_apps_status(), when it opens, gives every visible app its lifecycle, whether a pending_approval is an *update* waiting (not a first install), the missing apps of a blocked one and — for the installer or an administrator — its health and the checks that are not healthy. The card reads *Ready*, *Needs attention*, *Broken*, *Needs approval*, *Update awaits approval*, *Missing dependency*, *Disabled* or *Withdrawn*, with the reason in its tooltip, and the filter chips count those same words.
An app reads only its own health. An administrator or the app's installer reads any app's with api_health(key), and the installed-app detail includes it.
What an update will do — api_update_preflight
In the Store window, the Update button shows this report before it installs anything. It opens a review sheet that downloads the new version, runs the preflight (nothing is installed or written) and lists data loss, irreversible changes, server code, permissions and migrations. A version with data loss or an irreversible change needs the administrator to tick a box; a version with a blocking finding cannot be confirmed; if the preflight itself fails the sheet blocks and offers *Retry*. The preview download is not counted as a download of the app in the store's statistics.
Before installing a package over an installed version, ask what it would change. Nothing is installed or written.
env["vizum.app"].api_update_preflight(vapp_base64)
{
"ok": true, "blocking": [],
"version": {"from": "1.4.2", "to": "2.0.0", "change": "major"},
"trust": {"state": "store_verified", "publisher": "acme", "publisher_key": "ed25519:…",
"publisher_signed_key": "ed25519:…"},
"permissions": {"added": ["ai"], "removed": [], "changed": ["net", "orm"], "tier": "privileged",
"needs_approval": true,
"detail": {"ai": {"from": null, "to": true},
"net": {"added": ["api.stripe.com"]},
"orm": {"widened": [{"model": "res.partner", "ops": ["write"],
"fields": ["email"]}]}}},
"code": {"hooks": {"changed": ["sync_orders"], "added": ["refund"]},
"routes": {"changed": ["portal_status"]}},
"schema": {"create": 3, "remove": ["model:x_vzm_acme_old"],
"data_loss": ["model:x_vzm_acme_old"], "refused": []},
"migrations": {"db": [{"version": "2.0.0", "table": "orders"}], "native": []},
"dependencies": {"state": "ok", "dependencies": []},
"reversible": {"reversible": false, "why": ["removes native schema that can hold data"]},
"approval": {"needed": true, "reasons": ["server code changed", "permissions widened"]}
}
blocking— every install gate that would refuse the package:min_platform, Odoo version and modules, routes, games, licence binding, a revoked version, a signature that does not hold, this instance'spackage_trustpolicy, an admin-only install, a package asking for the first-partyvizum.appcalls (call:api_install,call:api_admin_app_list, …) that is not verified by the Vizum Store and signed by a Vizum publisher key — the same message the install gives —, and a field whose type or comodel changes (schema.refused, below).trust.publisher_signed_key— the publisher key whose own signature covers these exact bytes (nullwhen no publisher signature does), as the install records it.permissions.needs_approval— the update widens what the app may do, so the app goes back topending_approvaluntil an administrator approves it.grants_widenedandteam_widenedsay which: the permissions, the team's roles (native.team), or both.permissions.detail— the change, item by item, against what this database APPROVED: hosts, secrets, devices and every other list grant asadded/removed; scalars asfrom/to; nested grants (OAuth providers and scopes) one level down;ormper model — new models, and for existing ones the operations, fields andwrite_fieldsgained (dropping a field list reads asfields: ["*"], every readable field) and a changeddomain.native_teamlists what changes in the roles the app's managers hand out (native.team): rolesadded(with their groups) andremoved, the groups and apps each kept role gains (widened) or loses (narrowed),managersgroups added or removed,adopt_existing, andapp_scoped: truewhen the team's roles list apps. While the app already waits for approval, the team is compared with the last APPROVED version.code— server code and entry points that change: script hooks (code or trigger), the script library, model methods, routes, jobs, webhooks, fields with server behaviour (compute,default,formula,aggregate,related,depends), constraints, automations, native migrations, manifestactions, and what the server evaluates innative.ui— view expressions (QWebt-*,domain,context,attrs/invisible/readonly/required,groups,decoration-*), action domains and context, server-action code, report expressions — asadded/changed/removed. Layout is not code: reordering, relabelling or a plain menu does not count. They run with the grants the app already holds, so since SDK 5.69 a change here sends the app back topending_approvallike a wider grant (below).approval— whether installing the package leaves the app waiting for an administrator, and why:first install,server code changed,permissions widened,team roles widened(a new role, a group a role gains, a role let into a new app,adopt_existingturned on, any change ofmanagers, and on anapp_scopedteam also a role, group or app removed; otherwise removing or relabelling does not ask). A zero-tier app (no privileged permission) never waits.schema.data_loss— native schema the new version stops declaring that can hold data — every app-owned model and every stored field, whether or not it has rows today (an app-owned model is dropped; a field on a shared model is archived, not dropped).reversible— whether the update can be undone. The automatic pre-update snapshot restores the app's data (KV and collections,Vizum.dbrows, its files) — not its code; the previous code comes back by reinstalling the earlier package (automatic for an auto-update, which keeps it). Neither brings back native-model data an update drops or a native migration rewrites, and a downgrade may not read newer data.
A server-code change is approved like a wider grant (SDK 5.69)
An update — a new version, the automatic update, or a same-version repackage — whose code is not empty puts the app in pending_approval. Until an administrator approves it, none of its server code runs: hooks, methods, routes, webhooks and jobs run only for an approved app. An app an administrator disabled stays disabled.
- The reference is the installed manifest, compared item by item (the full SHA-256 of each hook's code and trigger, each library part and method, each route, job and webhook, each field's server behaviour, each constraint, automation and migration step). A migration that newly runs an approved hook, or a compute that wires an approved method to a new field, counts as a change: the code that runs changed even though no body did. No fingerprint is stored, so the first update after an instance moves to 5.69 re-asks only if the code really changed.
- An update with the same code (new UI, new assets, a new name) and an app with no server code keep their state. An asset-only refresh of the same version changes nothing.
- The installed-app detail carries
code_change— the names that changed since the last approval, never the code — and a second update while the app waits adds to that list instead of replacing it. Approving clears it.
In the desktop, the detail of an app whose update waits for approval starts with a card This update asks for — every line of the permission cards the new version produces that the approved one did not, in the same words — and, when its code changed, a card Server code changed: "Script hooks changed: sync_orders", "Routes added: portal_status"…
Only the app's installer or an administrator can plan an update of an installed app; any internal user can plan a first install.
What an update may not change — a field's type or comodel
An update that changes the type of a field that already exists, or the comodel of a relational one, is refused on every instance before anything is written, with the list of fields; api_update_preflight reports it in blocking and schema.refused; the Store refuses to publish it; vzm diff and vzm schema diff exit with SCHEMA_CHANGE_REFUSED. This holds on the app's own models and on the columns it adds to shared ones, whatever the version number.
Why: the platform reconciles the schema by adding and removing fields, never by changing one in place. Measured on Odoo 19 before the refusal existed — a changed type was not applied (the column kept the old type, silently, and the app read and wrote the wrong one), and a changed comodel was rewritten under the stored ids, which then pointed at another model's records.
The way to do it, in one update:
- declare a new field with the new type or comodel;
- fill it from the old one —
{"op": "copy", "model": "job", "from": "hours", "to": "duration"}in this version'snative.migrations(a plain column copy), or a migration hook for a conversion a copy cannot do (a char to a datetime, a partner id to a user id); - stop declaring the old field. It is dropped (or archived, on a shared model) in the same update, after the copy has run.
A rename_field keeps the field's type and comodel, so a rename alone passes; a rename that also changes the type is refused like any other type change. A one2many may change its comodel (it stores no ids; the platform re-points it), never its type. A verified rollback — the automatic one, reinstalling a snapshot whose bytes still match the digest recorded when it was taken — is exempt: it returns the app to a manifest it already ran. The Store compares an upload with every lower version still installable (passed or pending, not revoked), because an instance may jump to it from any of them.
When the platform's rules tighten
*SDK 5.97, feature policy-reconcile.* The install validator, the permission classification, the ORM clamp and the package trust policy judge every installed app again whenever a platform upgrade changes one of them, and once a day. Your app is judged by the same code that judged its package at install — nothing new to declare, nothing to call. What can happen to an installed version, from the mildest:
| What changed | Policy id | What happens to the installed app |
|---|---|---|
| A rule of the install validator your package no longer passes | validator.package | report: a policy finding (degraded); the app keeps running |
| An approved ORM grant the platform now refuses at call time | orm.platform_clamp | report: the call was already refused; now the finding names it |
| A permission you hold became privileged | perm.classification | reapprove: the app keeps running, the check is *failing* (red) until an administrator approves it again or disables it |
| A view, route or script hook the validator now refuses as unsafe | validator.ui, validator.routes, validator.script | hold that part only: that one view is deactivated, that route answers 404, that hook raises; the rest of the app runs. When the unsafe part cannot be separated, the app is reported, and goes back to *needs approval* only when an administrator re-checks the apps |
| The package no longer meets the instance's package trust policy (its files changed after they were verified) | trust.package | disable, as the package trust policy always did |
A store revocation still disables the version at once. A pass that would hold, re-approve or disable more than 5 apps, or more than 20 % of the running apps, changes nothing: it tells the administrators and waits for them (Apps Manager › Re-check apps › Apply to these apps).
Every decision leaves a row in the app's audit trail under the reserved prefix policy: (policy:report, policy:reapprove, policy:hold_part, policy:needs_approval, policy:disable, policy:mass_guard, policy:resolved, policy:released, policy:approved, …): the policy id and version, the finding, the part. Vizum.audit.log refuses an action that starts with policy:.
What to do as a developer: publish a version that passes. Installing it re-judges the app at once: what the old version had held is released and its findings resolve. An administrator's approval also accepts the current findings of that version knowingly (the approval row lists them), so they are not acted on again.
Grandfathering is not re-judged: a capability gate (min_platform) or another install gate never runs again on an installed app.
What a finding says: cause and category
Every finding carries its cause — {"type", "key"}, what made it fail — and its category, next to the free text. They are in the app's stored findings, in api_apps_status (policy), in api_policy_preview, in the audit row of each policy:* decision, and api_policy_status counts the open findings by category (by_category) and, per cause (by_cause, keyed type:key), gives the number of apps (count) and the first 50 of them (apps, with truncated when there are more).
| Policy id | Cause type (key) | Category |
|---|---|---|
perm.classification | permission (the permission) | reapproval — a permission the app holds became privileged |
orm.platform_clamp | orm_grant (the model) | privilege_forbidden — the platform now refuses an approved grant |
validator.ui / validator.routes / validator.script | view / route / hook (the part, or native.ui / native.routes / native.script for the whole section) | unsafe_to_run |
trust.package | trust (package_trust) | trust — the package no longer meets the trust policy |
validator.package | capability (min_platform) when the rule is a capability gate, else package | incompatibility |
Where every kind of outcome is handled
The hardening program names eight outcomes. Five are decided by this pass; three act elsewhere, and nothing in the pass duplicates them:
| Outcome | Handled by |
|---|---|
| informational | this pass defines the category (report), but no policy emits it today: every finding is one of the categories below |
| incompatibility | this pass: validator.package, report |
| re-approval needed | this pass: perm.classification, reapprove |
| privilege now forbidden | this pass: orm.platform_clamp names it; the runtime clamp already refuses the call, for an app installed before the rule too |
| unsafe to run | this pass: validator.*, hold_part or needs_approval; trust.package, disable |
| package revoked | the store's revocation list (R3), applied at once and hourly on every instance — see package-signing.md, *Revocation* |
| publisher compromise | not built in the pass: it needs publisher identity beyond the pinned key (hardening batch B3); today a store administrator revokes the affected versions, which is the row above |
| critical vulnerability | the store re-gate (vizum_store_publisher/models/store_regate.py, G5): it re-runs the gates on every served version, records the finding and tells the publisher and the store administrators; only an administrator revokes, and only for a HIGH exploitable finding |
Concepts
Uninstalling the platform — what happens to your app
*Since capability 5.136.*
An administrator can uninstall the Vizum window manager or the Vizum apps platform from Odoo's Apps screen. Before 5.136 that left the database half removed: the platform's columns were dropped with their data, 106 models and 55 tables stayed behind, and the automations of every installed vApp still called a model that no longer existed, so creating or writing a contact, a product, a stock move or a sale failed with KeyError: 'vizum.app'. From 5.136 the uninstall is safe, and your data stays.
What your app can rely on
- Your data is kept. Your
x_vzm_*tables, the rows in them and the columns your app added to Odoo's models (res.partner.x_vzm_<slug>_*, ...) are not touched. A column that was computed by one of yournative.methodsbecomes a plain stored column that keeps its value. - Your app stops calling the platform. Automations and server actions that call
env['vizum.app'](every script hook trigger) are deactivated, and the menus the app created are archived. Odoo keeps working: the native screens behave as if the hooks were not there. - Nothing becomes public. A view node, field, access row or record rule that was restricted to a group the platform created is re-homed to Odoo administrators (
base.group_system). A record rule that calledvzm_group(...)becomes deny-all until the app is back. Both are listed in the ledger. - A column pointing at a platform model (a many2one to
vizum.*) becomes a plain integer: the id stays, the foreign key goes.
The detach ledger
Before anything changes, the uninstall writes a ledger: the key, version, state, manifest and settings of every installed app, the list of artifacts it created, and the stored package as a zip. It lives in ir.attachment rows with no owner (vizum-detach-ledger-*.json, vizum-detach-pkg-*.vapp), which the module's own uninstall does not delete. The ledger is signed (HMAC-SHA256 of its exact bytes, keyed from database.secret); the parameter vizum_apps_platform.detach_ledger_id is the only way to find it, and the packages carry their SHA-256. Only administrators can read these attachments.
Installing the platform again
post_init_hook queues a one-shot cron (Vizum: re-adopt the vApps detached by an earlier uninstall). For every app in the ledger it:
- deletes what the detach left dormant and the install is about to create again (views, automations, server actions, access rows);
- installs the app again by the path it was installed by the first time: an app that came from the platform's own bundle from that bundle; any other from its stored package, with no shortcut, so a signature, digest, publisher pin and the
package_trustpolicy decide exactly as for an upload (an app that was unsigned stays unsigned, and an instance that is not registered refuses it as before); - puts it in
needs_approval, with no approved permissions. Its triggers stay off until an administrator approves it. Thex_vzm_*tables and columns that survived are adopted, not created again.
The run is idempotent and never raises; a failure is written on the ledger entry and an administrator can retry it with the server action Restore detached vApps.
Known limits
- A record rule that a CUSTOMER made and bound only to a group the platform created is deactivated by the detach and stays inactive and group-less after a reinstall: only the apps' own rules are rebuilt. Reactivating it by hand would turn it into a global rule; bind it to a group first.
- A signed app is asked for again from the Store. The dormant automations and rules it left behind, the rewritten views and the re-homed access rows of that app are not cleaned when the Store installs it, so an inherited view can appear twice. Nothing is lost; archive the duplicate.
- A many2one the detach turned into a plain integer stays an integer after a re-adoption until the app is updated.
- Re-adoption deletes the leftovers the ledger recorded (views, automations, server actions, access rows) even if an administrator edited them while the platform was away; keep such a change in your own view or automation.
- If an administrator uninstalls an app on purpose while a long re-adoption run holds the ledger lock for more than 5 seconds, the app's entry is not removed from the ledger and a later «Restore detached vApps» installs it again; uninstall it once more.
- The wizard's «uninstall all apps first» shows a transient database error (a serialization failure) as «Stopped» and does not retry it by itself; nothing is lost and a second click continues.
- «Restore detached vApps» never runs over an app that is installed: it is marked done and left as it is.
The Apps screen
With vApps installed, Uninstall on either module opens a wizard (an interactive call only; a script, the CLI or a cron proceeds, and the hook detaches). It lists the apps, their tables with row counts and the columns they added, and offers: uninstall all vApps first (their data is deleted; the box must be ticked; one failure stops it before the module), keep the data and detach the apps, or cancel.
Two things to do as a developer
- Do not rely on a platform class being present in a column default, a
relatedpath or a viewgroupsattribute of an Odoo model: after a detach those are rewritten as above. - Keep
native.methodscomputes idempotent: a re-adopted app recomputes them on its first approval.
Concepts
Where your data lives — JSON vs native Odoo tables
Vizum gives you three places to keep data, from a zero-ceremony JSON key–value bag up to real Odoo tables that the rest of Odoo — reports, pivots, other modules, Studio — can see. Choose by one question: does anything outside your app need to read this?
The three storage tiers
| Mechanism | What it is | Best for | Visible to native Odoo? (reports / pivots / other modules) |
|---|---|---|---|
Vizum.data |
Per-user or global JSON key→value blobs. No schema. | UI state, preferences, tiny app-private scratch data. | NO — opaque JSON |
Vizum.db |
App-private JSON rows in an internal relational store (SDK v4). Equality indexes, ~1M rows/table. | Structured data only your app needs to query. | NO — app-only JSON |
native.* + Vizum.orm |
Real Odoo tables & columns declared in your manifest (or provided by a companion addon). | Anything accountants, dashboards, automations or sibling apps must read, aggregate or report on. | YES — first-class Odoo |
Vizum.data for tiny UI state, Vizum.db
for app-private structured data nobody else needs, and go native the moment anything else
must see it — Odoo's report engine, a pivot/graph view, an automation, a sibling app, or a real
many2one into res.partner / sale.order.
JSON rows can never be pivoted, filtered or joined by Odoo; native rows always can.
native.* rows cross the ORM boundary into Odoo's reporting and views; JSON storage stays inside your app.Declaring fields on an existing model
Add columns to a shared Odoo model — e.g. surface an app-owned attribute on
res.partner. The platform creates a real manual field named
x_vzm_<app>_<name> and archives it (never drops it)
on uninstall, so data survives.
"permissions": {
"schema": { "models": ["res.partner"], "create_models": false }
},
"native": {
"fields": [
{ "model": "res.partner", "name": "health", "type": "integer", "label": "Health Score" },
{ "model": "res.partner", "name": "tier", "type": "selection", "label": "Tier",
"selection": [["bronze","Bronze"], ["silver","Silver"], ["gold","Gold"]] }
]
}
Who sees those fields
Since 5.107 a column you add to an Odoo model, and everything you put on Odoo's views,
is for the app's people: the groups you declare in native.groups, the
people and groups an administrator granted the app (Desktop, Solo, native menu) and the settings
administrators. Everyone else does not see it in lists, forms, filters, RPC or export. A column
every Odoo user needs says "visibility": "all" (no
min_platform needed: an older platform ignores it); explicit groups keep
exactly those. An app that replaces an Odoo module (native.replaces)
keeps the module's behaviour: its columns stay everyone's unless a field says
"visibility": "app". A column with no visibility
and no groups raises no min_platform need, but below
5.107 vzm check warns FIELD_AUDIENCE_FLOOR: an older
platform would show it to every user. Set min_platform 5.107 if you rely on the audience,
or "visibility": "all" if the column is meant for everyone. Details: Server-side code → who sees your columns.
"fields": [
{ "model": "res.partner", "name": "health", "type": "integer", "label": "Health Score" },
{ "model": "res.partner", "name": "tier", "type": "char", "label": "Tier", "visibility": "all" }
]
Showing those fields on Odoo's form
native.views puts them on the model's views. place
slots a field next to one of the form's own fields; since 5.91 a form entry can also
carry a page: your fields on a tab of their own in the form's notebook,
so Odoo's layout stays clean. The label is plain text and a msgid of your
i18n/<lang>.json; groups limits the tab to one of
your native.groups. Rules and the fail-soft cases:
Server-side code → native.views.
"min_platform": "5.91",
"native": {
"views": [
{ "model": "res.partner", "type": "form",
"page": { "name": "health", "label": "Health", "fields": ["health", "tier"],
"after_page": "sales_purchases" } }
]
}
A placement the form cannot take (the anchor is not a field of the form, or only sits inside
a one2many's inline list) is skipped, and since 5.93 you are told:
api_install and api_update_preflight return a
native_views_skipped list (model, view, field, anchor, reason). To make a
view all-or-nothing, set "strict": true on its entry: the install or update
is then refused before anything is written, naming the field and the anchor, and a failed strict
update leaves the previous version installed. Needs min_platform 5.93.
A form entry goes on the model's default form (its primary form of lowest priority),
which is not always the one people open: on res.users it is the
new-user dialog (base.view_users_simple_form), not Settings › Users.
api_update_preflight warns about it in
native_views_warnings (both views named), and the view inspector as
VIEW_NOT_MAIN_FORM. Since 5.123 a form entry can choose:
"target": "main" extends the form the model's main menu opens
(Settings › Users for res.users); "default",
or no key, keeps the default form. When no main form resolves, the entry is skipped
(no_base_view; refused with "strict": true).
"main" needs min_platform 5.123.
"min_platform": "5.93",
"native": {
"views": [
{ "model": "res.partner", "type": "form", "strict": true,
"place": [ { "field": "tier", "after": "function" } ] }
]
}
One tab holds 30 fields. When an app adds more to a model (36 on hr.employee),
since 5.94 a form entry may carry pages, a list of up to 4 pages
(30 fields each, 120 in all). They are shown in order, after_page may name an
earlier page of the list, and they all live in the same view, so an update that renames or reorders them
leaves one view. Needs min_platform 5.94.
"min_platform": "5.94",
"native": {
"views": [
{ "model": "hr.employee", "type": "form",
"pages": [
{ "name": "payroll", "label": "Payroll", "fields": ["salary", "bank"] },
{ "name": "balances", "label": "Payroll: balances", "fields": ["vacation"],
"after_page": "payroll" }
] }
]
}
Declaring a brand-new model
Own a whole table. The platform creates a real model
x_vzm_<app>_<model> with x_-prefixed
columns. Owned models are hard-dropped on uninstall (their rows go with them), so use
them for app-owned data, not shared business records.
"permissions": {
"schema": { "models": [], "create_models": true }
},
"native": {
"models": [
{ "name": "vehicle", "label": "Vehicle",
"access": [{ "group": "base.group_user", "ops": ["read","write","create","unlink"] }],
"fields": [
{ "name": "make", "type": "char", "label": "Make" },
{ "name": "year", "type": "integer", "label": "Year" },
{ "name": "owner", "type": "many2one", "relation": "res.partner", "label": "Owner" },
{ "name": "status", "type": "selection", "label": "Status",
"selection": [["active","Active"], ["sold","Sold"]] }
]
}
]
}
Field types: char, text,
integer, float, monetary, boolean,
date, datetime, selection,
many2one/many2many/one2many (with
relation; a one2many also names its
inverse, from SDK 5.57). From 5.57 a many2one
takes ondelete (set null, cascade,
restrict) and a field takes required — a
document and its lines. From 5.58 your own models also take
default, sequence,
unique, chatter/tracking
and computed fields (formula, related,
aggregate) — see the server-side guide.
Reading & writing native data from app.js
app.js
await Vizum.connect();
const MODEL = "x_vzm_vehicles_vehicle"; // x_vzm_<app>_<model>; fields are x_-prefixed
const rows = await Vizum.orm.searchRead(MODEL, [["x_status","=","active"]],
["x_make","x_year","x_owner"], { order: "id desc", limit: 50 });
const id = await Vizum.orm.create(MODEL,
{ x_make: "Ford", x_year: 2024, x_owner: partnerId }); // bare int id for a many2one
await Vizum.orm.write(MODEL, [id], { x_status: "sold" });
const byYear = await Vizum.orm.readGroup(MODEL, [], ["__count"], ["x_year"]); // real SQL aggregation
Vizum.data blobs and Vizum.db rows
cannot be pivoted, grouped or joined by Odoo's report engine, list/pivot/graph views, or other
modules — only native.* tables can. Migrating JSON → native later means a one-time
backfill; choosing native up front avoids it.
native block raises the app to the
PRIVILEGED tier — an administrator approves it at install — and a
brand-new native model needs the Odoo server restarted once before its rows are queryable
(a registry hot-reload gap; see Troubleshooting). Adding fields
to an existing model needs no restart.
Already using a companion addon?
If your app calls Vizum.orm against models defined by a packaged
Odoo addon you ship alongside (the way the PSI field-service apps use dsd.*),
you are already fully native — those are real tables the rest of Odoo sees, no
native.* needed. The manifest native.*
mechanism is the alternative for apps that want to declare their schema without shipping
a separate module. Don't re-declare an addon's models as native.models
— that creates empty parallel tables and orphans the real data.
Concepts
Theming — look native for free
The host pushes the desktop theme into your app, live. The SDK keeps two things in sync:
| Hook | What it does |
|---|---|
--vizum-accent | CSS variable on :root — the user's accent color. |
.vizum-dark | Class toggled on <html> when the desktop is in dark mode. |
With vizum-glass.css you also get --vz-bg · --vz-ink ·
--vz-card · --vz-muted · --vz-border plus ready-made
.vz-card · .vz-btn · .vz-input classes:
.my-panel { background: var(--vz-card); color: var(--vz-ink); }
.my-cta { background: var(--vizum-accent); }
/* nothing else to do — light/dark/accent switches reach you instantly */
SDK & server
The browser SDK — window.Vizum
Load two files, call connect(), and everything below is yours
(within your approved permissions).
Vizum.* and VUI.* calls in your browser
against a built-in mock — no Odoo, no install, nothing leaves your machine. Edit any of the
examples (KV storage, ORM reads, an internal table, a form, a chart, dialogs) and hit Run.
<link rel="stylesheet" href="/vizum_apps_platform/static/sdk/vizum-glass.css">
<script src="/vizum_apps_platform/static/sdk/vizum-sdk.js"></script>
Vizum.connect()
const { caps, theme } = await Vizum.connect();
// caps = the permissions the admin actually approved
// theme = { accent: "#6c5bd8", dark: false } — kept live afterwards
Vizum.data — persistence
await Vizum.data.set("settings", { sound: true, limit: 20 }); // any JSON
const s = await Vizum.data.get("settings"); // null if absent
const keys = await Vizum.data.list();
await Vizum.data.del("settings");
// global-scope apps can react to writes made by OTHER users:
Vizum.data.subscribe("counter", () => refresh());
Vizum.orm — Odoo data
const rows = await Vizum.orm.searchRead(
"sale.order", [["state", "=", "sale"]],
["name", "amount_total"], { limit: 10, order: "amount_total desc" });
const ids = await Vizum.orm.search("sale.order", [["state", "=", "sale"]]);
const recs = await Vizum.orm.read("sale.order", ids, ["name", "state"]);
const count = await Vizum.orm.searchCount("sale.order", []);
const created = await Vizum.orm.create("crm.lead", { name: "From my vapp" });
const newId = created[0]; // ⚠ create returns number[] (an array of ids) — take [0] for a single record
await Vizum.orm.write("crm.lead", newId, { priority: "2" }); // passing the array as the id raises "unhashable type: list"
await Vizum.orm.call("res.currency", "name_search", ["USD"]); // needs "call:name_search"
// server-side aggregation (needs the "read_group" op) — dashboards should
// NEVER pull thousands of rows to add them up in JS:
const byMonth = await Vizum.orm.readGroup(
"sale.order", [["state", "=", "sale"]],
["date_order:month"], // groupby (granularity optional)
["amount_total:sum", "__count"], // aggregates
{ limit: 12, order: "date_order:month" });
// → [{ "date_order:month": "2026-05-01", "amount_total:sum": 80214.5, "__count": 31 }, …]
// many2one groupby values arrive as [id, display_name]
Denied calls reject with Error("PermissionDenied") — catch them
and degrade gracefully.
Vizum.query — fluent queries (SDK v4)
const hot = await Vizum.query("crm.lead")
.where("stage_id.name", "=", "Proposition")
.where("expected_revenue", ">", 10000)
.orWhere("priority", "=", "3") // (revenue>10k OR priority=3)
.order("expected_revenue desc")
.fields("name", "expected_revenue", "partner_id")
.limit(20)
.all();
const total = await Vizum.query("sale.order").where("state", "=", "sale").count();
const lead = await Vizum.query("crm.lead").where("id", "=", id).first(); // row | null
const pageOf = await Vizum.query("account.move")
.whereIn("move_type", ["out_invoice", "out_refund"])
.group([["state", "=", "posted"], ["state", "=", "draft"]]) // (posted OR draft)
.order("invoice_date desc")
.page(0, 25); // {rows, total, page, pages}
A chainable builder that compiles to a real Odoo domain (inspect it with
.toDomain()) and runs through the same
orm proxy — so the manifest allowlist, your declared
fields and the user's ACLs apply exactly as with
searchRead. where chains
with AND; orWhere ORs with the previous term;
group([...]) adds a parenthesised OR-block.
Terminals: all · first · count · ids · page ·
groupBy. Reads only — use Vizum.orm.create/write
to change data. Available as Vizum.query() or
Vizum.orm.query().
Vizum.net — external APIs (allowlisted)
// manifest: "permissions": { "net": ["api.frankfurter.app"] }
const res = await Vizum.net.fetch("https://api.frankfurter.app/latest?to=EUR");
if (res.ok) {
console.log(res.json.rates.EUR); // .json is pre-parsed for JSON responses
}
// POST with headers — Cookie/Host are stripped, your API keys pass through:
await Vizum.net.fetch("https://api.example.com/items", {
method: "POST",
headers: { "X-Api-Key": "…" },
body: { name: "from my vapp" }, // objects are sent as JSON
});
The request happens on the Odoo server, never in the browser: no CORS, no leaked
session. Hosts outside permissions.net reject with
PermissionDenied; binary bodies arrive base64-encoded with
binary: true.
Reach and images (5.74). A host you name is reachable on ports 80 and 443; for another
port write "host:port". "net": ["*"]
is any public website (download-only, never a private or metadata address, privileged,
needs min_platform 5.74).
Vizum.net.fetchImage(url, {max_bytes}) downloads one image
(PNG, JPEG, GIF or WEBP, checked by its bytes) and re-checks every redirect.
Details: Network & images.
Cloud providers in server code (SDK 5.138)
# native method (Python). Manifest: "permissions": {
# "providers": ["hvac_cloud"],
# "secrets": ["hc_acct_1_user", "hc_acct_1_pass"] }
if has_attr(V.net, "provider"):
res = V.net.provider("hvac_cloud", "list_appliances", {"account": "1"})
if not res["ok"]:
V.log(res["code"]) # BAD_CREDENTIALS, NETWORK, CLOUD_ERROR ... (a dict, never raised)
V.net.provider(provider, op, args) signs in to a cloud on the
server and speaks its signed protocol, so the script never holds a key, a password hash or a
session. The only provider is hvac_cloud (an HVAC cloud provider); the
operations are login, list_appliances,
send_frame (appliance_id,
frame_hex, at most 512 bytes) and logout.
It exists in server code only: there is no browser call.
- Declare
permissions.providers: ["hvac_cloud"](privileged; the install card reads “Sign in to an HVAC cloud provider with the account you configure”). The provider brings its own pinned hosts (5.148, featurenet-provider-supplied-hosts): list nothing inpermissions.netfor it; declaring it grants noV.net.fetchto those hosts. Below 5.148 a platform does not supply them (vzm check:PROVIDER_HOSTS_FLOOR). - Credentials are read by name:
hc_acct_<account>_userand_pass, or the secrets you pass asuser_secret/pass_secret; all declared inpermissions.secrets. - The session stays on the server, encrypted. It never reaches the script, the iframe, the
secrets listing, an export or a log. Logout, uninstalling the app and changing a
credential drop it. On
SESSION_EXPIREDthe platform re-logs in once and retries once. - A run that rolls back loses a session it just obtained (the next call signs in again). A
request Odoo replays after a serialization failure (two workers signing in at once) can send
a frame again: send idempotent commands. A sign-in that waits more than 20 s for
another worker’s raises
LockTimeout. - A refused password pauses automatic sign-in for 10 minutes
(
list_appliancesandsend_frameanswerBAD_CREDENTIALSwithout calling the cloud), unlessloginis called or the secret is changed. - Any unexpected failure inside the adapter answers
{ok: False, code: "CLOUD_ERROR", cloud_code: "INTERNAL"}with a generic message; logout takes the sign-in lock (it may raiseLockTimeout). - A cloud error is a dict
{ok: False, code, message}; running out of the run’s 50 network calls or the daily cap raises, asV.net.fetchdoes. Each HTTP request is one network call (a login is two). - No install floor: a platform older than 5.138 ignores the key (one from 5.138 that predates
the id
hvac_cloudrefuses the install). Feature detection works in native-method code only: probe withhas_attr(V.net, "provider")(the builtinhasattris undefined in the sandbox);vzm checkthen reportsPROVIDER_FEATURE_DETECTED(a warning) instead ofMIN_PLATFORM_TOO_LOW. A plain hook or job that callsV.net.provideritself needsmin_platform5.138.
All shapes and error codes: sdk-next/docs/net.md.
Cloud thermostat, phone-free (SDK 5.145; sensors 5.147)
# native method (Python). Manifest adds the transcode session's two secrets:
# "providers": ["hvac_cloud"],
# "secrets": [..., "hvac_cloud_app_id", "hvac_cloud_secret"]
res = V.net.provider("hvac_cloud", "thermostat_query",
{"appliance_id": "100000000000001", "account": "home"})
# { ok, appliance_id, power "on"|"off", mode "auto"|"cool"|"heat"|"emergency_heat"|"fan",
# setpoint_F, current_F } — current_F is null until the sensor capability (5.147) is assigned
res = V.net.provider("hvac_cloud", "thermostat_control",
{"appliance_id": "100000000000001", "account": "home",
"payload": {"power": "on", "mode": "cool", "temperature": 72}})
# sets power/mode/setpoint, reads back and CONFIRMS before it answers
Two higher-level ops read and set a cloud thermostat without a phone and without the
app building a device frame (the send_frame floor). The adapter
transcodes the intent through a second, ephemeral provider session and delivers it over the
same device channel, so the script only sees a safe dict.
- Both ops gate on the feature
net-provider-thermostat-json(SDK 5.145); below that they fail closed with{ok: False, code: "UNSUPPORTED_OP"}and make no cloud call. They go with the provider idhvac_cloud(vzm check:PROVIDER_ID_MIXED). - The transcode host is the provider's own too (supplied by the platform). Declare the
second session's secrets
hvac_cloud_app_idandhvac_cloud_secret(elseNOT_CONFIGURED). thermostat_queryreturnspower,mode,setpoint_Fandcurrent_F. Under platform capability 5.147 (featurenet-provider-thermostat-json-sensors) it also populatescurrent_Fand addshumidity,faultandsensors_at(epoch of the last reading, for an “as of hh:mm” label).- Absent vs. null. A sensor field is absent (no key) when the platform is below
min_platform5.147 — it does not know the field; it is present withnullwhen the read failed or is stale.faultis a fresh read or null (never carried);current_F/humiditycarry the last known value only while recent, andsensors_atgives its age. thermostat_controlsetspower,modeand/ortemperature(whole °F; the device rounds; a Celsius-looking value isBAD_INPUT, never clamped), reads the state back and confirms it (a mismatch fails closed withcloud_codePOWER_UNCONFIRMED/MODE_UNCONFIRMED/SETPOINT_UNCONFIRMED). Control is rate-limited per thermostat (RATE_LIMITED), so send idempotent commands.
All shapes, fields and error codes: sdk-next/docs/net.md.
Vizum.ui — talk to the desktop
Vizum.ui.notify("Saved!", "success"); // info | success | warning | danger
Vizum.ui.openRecord("res.partner", 42, "ACME"); // opens a REAL Odoo form window
// open a real Odoo LIST window, filtered (grant: "openList"):
Vizum.ui.openList("sale.order", [["partner_id", "=", 42]], "ACME's orders");
// open ONE real Odoo view (grant: "openView"; feature "ui-open-view"):
Vizum.ui.openView({ model: "crm.lead", view_type: "kanban", label: "Pipeline" });
Vizum.ui.openView({ model: "res.partner", view_type: "form", res_id: 42,
view_xmlid: "base.view_partner_simple_form" }); // a primary view only
// host-rendered confirmation dialog (always available) -> boolean:
const yes = await Vizum.ui.confirm("Post 3 invoices now?", "Approval Center");
if (!yes) { return; }
confirm is Odoo's own dialog on the desktop and in native windows, never the
browser's window.confirm (which a sandboxed frame cannot show). It resolves
true on Confirm and false on Cancel, Escape or close.
Vizum.window — your own window
Vizum.window.setTitle("Pomodoro — 12:25 left");
Vizum.window.resize(480, 600);
Vizum.window.close();
Full screen — Vizum.ui.fullscreen (SDK 5.85)
A kiosk, a signage console or a counter screen wants every pixel. Declare
"ui": ["fullscreen"] and call it from your own button:
if (Vizum.supports("ui-fullscreen")) {
const r = await Vizum.ui.fullscreen(); // -> { active: true, browser: true | false }
Vizum.ui.onFullscreenChange((e) => { if (!e.active) showEnterButton(); });
}
await Vizum.ui.exitFullscreen(); // always allowed, ends only YOUR full screen
Two layers, because of the gesture. The click happens inside your sandboxed iframe, and the
browser's Fullscreen API asks for a fresh user gesture in the page that calls it — the host.
Chrome and Firefox pass the activation up to the host for a few seconds; Safari does not. So the host
always paints your window over the whole viewport with the dock, the Odoo navbar and the
titlebar hidden (a kiosk: no gesture needed, and the window's saved size and position are never
touched), and also asks the browser for real fullscreen. browser
tells you which one you got; a refusal is normal, not an error, and the kiosk still stands.
Leaving. The host always shows a small Exit full screen button, and Escape (or the
browser's own exit) gives the screen back, because a sandboxed app cannot be trusted to. You hear it in
onFullscreenChange({active:false}). Closing, minimising or focusing another
window ends it too. A step-up confirmation (identity.confirm) always leaves full screen first and refuses it while its dialog is open. Desktop and Odoo-menu hosts only; Solo and paired-device pages answer
{active:false, supported:false} (a device page already has
Vizum.device.fullscreen). Set min_platform 5.85;
vzm check flags a missing "fullscreen" entry in
permissions.ui.
Vizum.help — ship documentation with your app
// One call gives you a floating ? button + a styled help overlay. Free.
Vizum.help.button("My App — guide", [
{ h: "Getting started", p: "One-paragraph overview of what the app does." },
{ h: "Shortcuts", rows: [["Ctrl+B", "bold"], ["⟳", "refresh the data"]] },
]);
// or open it yourself from any button:
Vizum.help.show("My App — guide", sections);
Every serious app ships in-app help — the Vapp Creator template already includes a
wired-up Vizum.help.button() for you to edit.
Vizum.session & Vizum.format — who is using you (SDK v2)
const { session } = await Vizum.connect();
// { uid, name, login, lang, tz, company: {id, name},
// currency: { name, symbol, position, decimal_places } }
Vizum.format.money(1234.5); // "$ 1,234.50" — REAL Odoo currency & position
Vizum.format.number(0.1234, 2); // locale-aware
Vizum.format.date("2026-06-11");
Vizum.format.datetime("2026-06-11 14:00:00"); // Odoo naive-UTC → user tz
A safe subset only: no tokens, no groups. Stop hardcoding "$" — every serious app
formats through Vizum.format.
Vizum.app.key — your own key (SDK 5.76)
await Vizum.connect();
const key = Vizum.app.key; // "com.acme.crm"
The host sends the key in the connect handshake on every host (desktop, native, Solo,
device). Never parse it out of the URL: on a multi-database host the run path is
/vizum/apps/run/@<db>/<key>/… and a regex over it reads
@<db> as your key. vzm check and the
Store upload gate warn on code that does. Choosing a shell before the first paint? Await
Vizum.connect() first; the key is there when it resolves.
Collections — multi-user data done right (SDK v2)
const tasks = Vizum.data.collection("tasks");
const { id, rev } = await tasks.insert({ title: "Ship v2", done: false });
await tasks.update(id, rev, { title: "Ship v2", done: true }); // rev 1 → 2
// stale writes REJECT instead of clobbering a teammate:
try { await tasks.update(id, 1, { done: false }); }
catch (e) { /* e.message === "rev_conflict" → reload the item */ }
const rows = await tasks.list(); // [{id, rev, value}, …] (≤500)
await tasks.remove(id);
tasks.subscribe(() => refresh()); // live, when data:"global"
Each item is its own record with an optimistic revision — the cure for the
classic “two users editing one big KV document” clobber. Scope follows
permissions.data (user/global) exactly like the KV.
Vizum.files — binary storage (SDK v2)
await Vizum.files.put("export.png", base64Png); // small files; 64 MB/file by default
const f = await Vizum.files.get("export.png"); // {b64, mimetype, size}
const all = await Vizum.files.list(); // 50 files, 128 MB by default
await Vizum.files.del("export.png");
// Big files (5.70+): streamed, no base64 in the browser
await Vizum.files.putBlob("tour.mp4", file, { onProgress: p => bar(p.sent / p.total) });
const part = await Vizum.files.getRange("tour.mp4", 0, 1_000_000); // Range read
Backed by real attachments, scoped like your KV, never reachable through the public
run URL. Stop smuggling base64 blobs through data.set.
Raise the limits with permissions.file_limits (up to 200 MB a file
since 5.70, 512 MB since 5.71, the part above 200 MB behind its own administrator
approval). Above 32 MB the host uploads in parts, re-checks the quota at commit, and since
5.72 resumes after a dropped connection: it asks the server which part is next, sends
only what is missing and, if it gives up, aborts so nothing stays reserved (nothing changes in
your code; Vizum.supports("upload-resume") tells you the host does it).
Details: Files.
Vizum.apps — intents (SDK v2)
// manifest: "permissions": { "apps": ["com.vizumapps.kds"] }
await Vizum.apps.open("com.vizumapps.kds", { table: "T5" });
// the TARGET app receives the payload:
const { intent } = await Vizum.connect(); // when opened by an intent
Vizum.events.on("intent", (p) => { … }); // when already running
Declared targets only, and only if the user has the target installed —
Error("AppNotInstalled") otherwise.
Dock badge & command palette (SDK v2)
Vizum.ui.setBadge(7); // red counter on your dock tile
Vizum.ui.setBadge(""); // clear it
Vizum.ui.addPaletteCommand("POS: new sale"); // shows in the desktop palette
Vizum.events.on("palette", (label) => { … }); // fired when the user runs it
Pin a live KPI to the desktop (SDK 4.1)
Pin any aggregate as a desktop widget that re-queries Odoo on its own timer —
it keeps updating even after your app is closed. Declare
"ui": ["pinWidget"]; the model
must be one your app is granted to read, and the widget runs as the user.
await Vizum.ui.pinWidget({
label: "Open deals",
model: "crm.lead",
measure: "expected_revenue:sum", // "field:sum|avg|min|max"; omit ⇒ record count
domain: [["probability", "<", 100]],
format: "cur", // "cur" | "int" | "dec" | "pct" | null
icon: "🎯",
});
Clicking the widget opens the matching Odoo list. Sheets Lite ships this:
select a =ODOO(…) cell and hit 📌 Pin KPI.
Vizum UI Kit — components for free (SDK v2)
<script src="/vizum_apps_platform/static/sdk/vizum-ui.js"></script>
document.body.appendChild(VUI.table({
columns: [
{ key: "name", label: "Product" },
{ key: "price", label: "Price", align: "right", format: (v) => VUI.money(v) },
],
rows: products,
onRow: (r) => Vizum.ui.openRecord("product.template", r.id, r.name),
}));
document.body.appendChild(VUI.chart.bar(series)); // series = [{label, value}]
// SDK v4.20 — the full chart family, one call each:
VUI.chart.line(series); VUI.chart.area(series); // trend, filled trend
VUI.chart.donut(series); VUI.chart.pie(series); // share-of-total + legend
VUI.chart.hbar(series); // horizontal ranking
VUI.chart.multibar(groups, seriesList, { mode: "stacked" }); // or "grouped"
VUI.chart.multiline(groups, seriesList); // several trends, one plot
// multi-series shape: groups = [{label}], seriesList = [{name, values: [...]}]
const ok = await VUI.confirm("Delete this board?");
VUI.searchPicker({ model: "res.partner", fields: ["name", "email"],
onPick: (r) => setCustomer(r) });
Zero dependencies, XSS-safe by construction (everything renders through
textContent), themed by the same tokens as
vizum-glass.css. Components:
el · dialog · confirm · prompt · table ·
chart.bar/line/area/donut/pie/hbar/multibar/multiline ·
searchPicker · tabs · empty · form · wizard · pipeline · signature · pdf · dataGrid · xlsx.
VUI.dataGrid — the enterprise grid (SDK v3)
const grid = VUI.dataGrid({
model: "account.move",
columns: [
{ field: "name", label: "Number" },
{ field: "partner_id", label: "Customer" }, // many2one renders its name
{ field: "invoice_date", label: "Date", type: "date" },
{ field: "amount_total", label: "Total", type: "money", total: true, editable: true },
],
domain: [["move_type", "=", "out_invoice"]],
search: ["name", "partner_id"], // top search box
groupBy: "partner_id", // grouped headers w/ counts + Σ, click to drill in
orderBy: "invoice_date desc", pageSize: 25, id: "invoices",
actions: [{ label: "✓ Post", onClick: (ids) => postAll(ids) }],
onOpen: (r) => Vizum.ui.openRecord("account.move", r.id, r.name),
});
container.appendChild(grid);
grid.reload(); // call after external changes
Server-side everything: paging (searchRead +
searchCount), column sort, per-column filters and
grouping (readGroup) all build real Odoo domains —
so the manifest allowlist, your declared fields and the user's ACLs apply to
every byte. Inline editing (double-click) goes through
orm.write and therefore through the host's
confirmation gate. Bulk actions receive the selected ids. The ⚙ menu hides
columns per user (persisted). ⤓ XLSX exports every row matching the
current filters (up to 10k) as a real .xlsx built
in-browser with zero dependencies — also available standalone as
VUI.xlsx("file.xlsx", [{name, rows}]) for any
tabular data (strings, numbers, full unicode).
VUI.wizard — multi-step flows (SDK v4)
const wiz = VUI.wizard({
steps: [
{ title: "Customer", fields: [ // a step is a VUI.form spec…
{ name: "name", label: "Company", required: true, width: "full" },
{ name: "email", label: "Email", type: "email" } ] },
{ title: "Items", render: (box, ctx) => { // …or draw anything
box.appendChild(buildLineEditor(ctx.values)); } },
{ title: "Confirm", render: (box, ctx) => {
box.appendChild(VUI.el("p", { text: "Create order for " + ctx.values.name + "?" })); } },
],
onCancel: () => win.close(),
onFinish: async (v) => { await Vizum.orm.create("sale.order", toOrder(v)); },
});
container.appendChild(wiz.el);
A guided multi-step flow with a numbered step rail (done / current / pending),
Back / Next / Finish, and accumulated state. A step with
fields builds a VUI.form
and won't advance until it validates; a step with
render(box, ctx) draws anything and reads prior
answers from ctx.values. An optional
validate(values) per step returns an error string
to block. onFinish(allValues) runs busy-stated;
completed steps are clickable to go back. Returns
{el, values(), goTo, next, back}.
VUI.signature + VUI.pdf — capture & document (SDK v4)
const sig = VUI.signature({ width: 420, height: 170, label: "Customer signature" });
container.appendChild(sig.el);
// later — build a proof-of-delivery PDF and stash it on the record:
if (!sig.isEmpty()) {
const doc = VUI.pdf({ size: "A4", margin: 50 });
doc.text("Proof of Delivery", { size: 20, bold: true });
doc.text("Order " + order.name, { y: 120, size: 12, color: [0.3, 0.3, 0.3] });
doc.line(50, 140, 545, 140, { width: 0.8 });
doc.text("Received by:", { y: 160 });
doc.image(sig.toDataURL("image/jpeg"), { x: 50, y: 180, w: 240 }); // JPEG only
doc.save("delivery_" + order.name + ".pdf"); // or doc.toBase64()
await Vizum.files.put("pod_" + order.id + ".pdf", doc.toBase64()); // keep it
}
VUI.signature is a HiDPI-correct canvas pad with smooth strokes, a
placeholder hint and a clear button; isEmpty(),
clear() and
toDataURL()/toBlob() (JPEG-on-white by default).
VUI.pdf is a zero-dependency PDF builder — text (Helvetica /
Helvetica-Bold, color, left/center/right), line,
rect, multi-page
(addPage) and JPEG images (DCTDecode), with
top-left point coordinates. Output as
save(filename) · toBase64() · toBlob() · dataUrl() —
hand the base64 to Vizum.files.put or a net upload.
Images are JPEG-only so embedding stays robust (no PNG predictors) — and
signature.toDataURL() already returns JPEG, so the
two compose directly. For pixel-perfect branded documents use server-side
Vizum.reports (QWeb); reach for
VUI.pdf when an app needs to generate one on the
spot with no template.
VUI.pipeline — a drag-and-drop kanban board (SDK v4)
const board = VUI.pipeline({
model: "crm.lead",
stageField: "stage_id",
stageModel: "crm.stage", // shows empty stages too (else derived from data)
domain: [["type", "=", "opportunity"]],
fields: ["name", "expected_revenue", "partner_id"],
sumField: "expected_revenue", // per-column Σ in the header
search: ["name", "partner_id"],
onOpen: (r) => Vizum.ui.openRecord("crm.lead", r.id, r.name),
// onMove defaults to orm.write({stage_id: newStage}); override for side-effects
});
container.appendChild(board);
board.reload();
One column per stage, cards drag between them, and the move persists through
orm.write — so the manifest allowlist, the host
confirmation gate and the user's ACLs apply to every stage change. Stages come
from stageModel (so empty columns still render),
from an explicit stages list, or are derived from the
data with read_group. Each column shows a live count
and an optional sumField total; supply a
card(rec) renderer for a custom card or let the kit
draw name + amount. Override onMove(id, stageId, rec)
to run a real Odoo method (e.g. callOn to mark won)
instead of a plain write.
A call: result is plain JSON data:
dates and datetimes as ISO strings in UTC ("2026-10-03",
"2026-10-03 12:30:05"), decimals as numbers, UTF-8 bytes
as text, sets and tuples as arrays, a top-level recordset as its ids. The whole result comes back as
true (and the platform logs a warning) when it holds a recordset nested in a
dict or list, bytes that are not UTF-8, NaN or Infinity, any other object, more than 32 levels,
10 000 entries or 4 MiB.
Since 5.98 (Vizum.supports("typed-action-result"), for an app that
declares min_platform 5.98) that true is gone.
A method that returns an action (the wizard a button opens) answers
{type: "action", action_type, name, res_model, res_id, view_mode, target}
(plain values; no domain, no views, context only lang, tz), a top-level recordset answers
{type: "records", model, count, ids} (ids only for models the app may read), and anything else that is not data is rejected
with ACTION_RESULT_UNSERIALIZABLE (the call is rolled back).
VUI.form — declarative forms with validation (SDK v4)
const f = VUI.form({
columns: 2,
fields: [
{ name: "name", label: "Name", required: true, width: "full" },
{ name: "email", label: "Email", type: "email" },
{ name: "amount", label: "Budget", type: "money", min: 0 },
{ name: "stage", label: "Stage", type: "select",
options: [["new", "New"], ["won", "Won"], ["lost", "Lost"]] },
{ name: "partner_id", label: "Customer", type: "many2one",
model: "res.partner", fields: ["name", "email"] },
{ name: "due", label: "Due", type: "date" },
{ name: "vip", label: "VIP account", type: "checkbox" },
],
values: { stage: "new" },
onSubmit: async (v) => { // busy spinner until it resolves
await Vizum.orm.create("crm.lead", v);
Vizum.ui.notify("Saved", "success");
},
});
container.appendChild(f.el);
// programmatic: f.values(), f.setValues({…}), f.validate(), f.reset()
One object describes the whole form; the kit renders inputs, labels, help text
and inline validation, and wires a busy-stated submit button. Field
type: text · textarea · number ·
integer · money · email · tel · url · password · select · checkbox · date ·
datetime · many2one. many2one fields open a
searchPicker over your granted model.
required · min · max and a custom
validate(value, all) run before
onSubmit; throw {field, message}
from onSubmit to flag a server-side error on a specific
field. Same theme tokens, same XSS-safe rendering as the rest of the kit.
Vizum.orm.watch — live data without hammering (SDK v2.1)
const watcher = Vizum.orm.watch("pos.order",
[["state", "in", ["draft", "paid"]]],
(h) => refresh(), // called ONLY when something changed
{ interval: 6 }); // seconds (4–60, default 8)
watcher.stop();
The desktop polls a cheap server fingerprint (record count + max
write_date, one aggregated query) and wakes your app only on a
change. Needs the search grant on the model; your manifest
domain is injected as always. (The poll pauses while the tab is hidden.)
Vizum.orm.live / Vizum.refresh — auto live data (SDK v5.4)
Don't list the models by hand. Vizum.orm.live(render) runs your
render, notes every model it reads, watches them, and re-runs it whenever any
change — created/updated/deleted by you OR another user — plus when the tab regains
focus. One line replaces a reload timer.
// vanilla: a single idempotent render (clear-then-build) stays live by itself
const live = Vizum.orm.live(async () => {
const rows = await Vizum.orm.searchRead("sale.order", dom, fields);
render(rows); // auto re-runs on any sale.order change / tab focus
});
// live.refresh() → force a re-run now; live.stop() → tear down
// no single render fn (load-once, or a React store)? point refresh at your reload:
Vizum.refresh.on(() => reload()); // fires on any model you've READ changing + on focus
Both build on watch (same cheap fingerprint, same
search grant) — no extra permission. opts:
{ interval?:4–60s, debounce?:ms, models?:[…] }.
Vizum.window.openPanel — multi-window apps (SDK v2.1)
// a SECOND window of your app, showing another file of your package:
Vizum.window.openPanel("inspector.html", { title: "Inspector", w: 380, h: 520,
payload: { recordId: 42 } });
// inspector.html reads the payload via Vizum.intent and talks to the main
// window with Vizum.events (same app = same event bus)
Panels close automatically with their parent window.
Vizum.reports — real QWeb PDFs (SDK v2.1)
// manifest: "permissions": { "reports": ["account.report_invoice"] }
await Vizum.reports.download("account.report_invoice", [invoiceId], "invoice.pdf");
Rendered by Odoo's own report engine as the user — record rules apply. Per-report allowlist in the manifest.
i18n — ship translations in the package (SDK v2.1)
// package: i18n/es.json → {"New order": "Nueva orden", "Hello %s": "Hola %s"}
Vizum._t("New order"); // "Nueva orden" when session.lang = es_*
Vizum._t("Hello %s", name);
The SDK loads i18n/<lang>.json (then the short code) at
connect; missing keys fall back to the key itself.
The same files translate the server (5.73). The package's
i18n/<lang>.json also answers tr()
in your model methods: tr("%s items", n) reads the call's
context.lang, tries es_419 then
es, then your source text. Use Odoo's language codes for the files and
the manifest's i18n list (es_419, not
es-419); each translation keeps the placeholders of its source text;
a translations-only update needs no new approval. The Desktop, native-window and Solo hosts all send the user's language
(Vizum.supports("server-i18n")). A language the instance has not installed no
longer breaks the call. Set min_platform 5.73. See
Server-side code.
Native strings from the same files (SDK 5.84)
The package's i18n/<lang>.json also translates what your app puts in
Odoo's backend: field labels and help, selection options, model labels, groups, menus, actions and
view terms. Nothing to declare and no min_platform: a key is the English
text of the manifest ("Customer": "Cliente") or, to remove an ambiguity,
"@native:audit.state=done": "Hecho"
(@native:<model>, .<field>,
#help, =<value>,
@native:@<group>,
@native:<record id>.<field>,
@native:app). The strings are written at install, update, approval and
whenever a language is installed; the English source is never touched, a missing string stays in
English and a translation an administrator typed by hand is kept.
vzm check warns about untranslated native strings
(--strict-i18n makes it an error). See
Server-side code.
Translated messages in hooks and jobs (SDK 5.77)
Trigger hooks (guards included), jobs and methods have
V.tr(source, *args, lang=None, **kwargs), the same lookup as
tr() on your i18n/<lang>.json files.
A guard's V.fail(V.tr("Phone cannot change")) reaches the user in the
language of the write that fired it; a job speaks its job user's language, and
lang=partner.lang translates one mail per recipient (an unknown code is
ignored). The text is looked up first and interpolated after; a missing or malformed translation
gives your source text and never raises. Set min_platform 5.77; the install
gate and vzm check refuse V.tr below it. See
Server-side code.
Who wrote, and who is running — rec["su"] and V.is_service_user() (SDK 5.81)
A trigger hook's payload has rec["su"]: True when the
write that fired it ran in a superuser environment (a method of your app that wrote with
sudo(), platform code, the superuser), False when it
was the user's own write. sudo() keeps the user's id, so
rec["uid"] alone cannot tell the two apart; with su a
guard can say "a ticket is created only through wl_issue". The platform sets it
from the write itself, and your app cannot. V.is_service_user(), in methods, hooks and
jobs, is True when the code runs as your app's own service user (what its jobs,
triggers and device routes run as) and False for any person, an administrator
included: a method can tell that your paired screen, not a person at the counter, called it. Set
min_platform 5.81; the install gate and vzm check refuse
both below it. See Server-side code.
A trigger that watches fields — trigger.fields (SDK 5.124)
Odoo writes a record many times in one operation (a stock move 13 to 21 times in a Validate), and a write
trigger runs its hook on every one of them, once per record. Name the fields the hook decides on,
"trigger": {"model": "stock.move", "on": "write", "fields": ["state"]}, and the
hook runs only for a write that changes the value of one of them. Without
fields every write runs it, as before. Only with on
"write" or "create_or_write" (a create still runs a
create_or_write hook); a field the model does not have refuses the install, and an
update applies the new list. A guard names every field it decides on: it does not see a write that changes
only another one. Set min_platform 5.124; vzm check refuses
fields below it. See Server-side code.
React to role changes — native.membership and V.is_member() (SDK 5.127)
A vApp may not override res.users or res.groups, so the
platform does it once for every app and tells yours which users gained or lost
its own groups. Declare "native": {"membership": [{"groups":
["@role_distributor"], "method": "link._on_membership"}]} with
"permissions": {"membership": true}. The method (a native.methods
entry with "api": "model" on one of your own models) receives one dict:
{event_id, kind, groups: {"@role_distributor": {gained, lost}}, members, reason, attempt,
queued_at}. Membership is effective (active users, directly or through implied groups; archiving
and deleting a user are losses), counted once per transaction against its start, so a gain and a loss in
one transaction make no call. The call comes after the commit, as your app's job user, in order; a
failure never blocks the administrator's change and is retried with the same
event_id, audited and shown to the administrators. A
kind: "reconcile" call with the current members comes at
install, update and every hour: use it to fix whatever drifted. Only your own "@name"
groups are accepted. For a reach that matters, check membership when you read as well:
V.is_member("@role_distributor", uid) answers from the current transaction, and a
$group rule leaf does it declaratively. In an Odoo test,
env["vizum.app"]._membership_flush() does what a commit does. Set
min_platform 5.127. See Server-side code.
Closing the gap: "sync": "narrow" (SDK 5.153). After a commit the
event can arrive minutes late, and until then a record rule that tests your own audience still lets the person
in. Add "sync": "narrow" to an entry and the users who lost its groups reach
your method inside the transaction of the write, as your job user, before the write returns
(kind: "narrow", sync: true, members
with the current list). Your writes commit or roll back with the role change. If the method raises or takes more
than 5 seconds, the administrator's change is refused with an error naming your app. Archiving or deleting a user
never fails: if your method cannot be told, the platform records the person and refuses them in your app (every
bridge call and every job) until a later delivery succeeds. Gains stay asynchronous, and your method must be
idempotent: the same loss can arrive again. A settings administrator can switch the synchronous path off for one
app, with a reason, and a banner then says role removals are late. Set min_platform
5.153.
Sensors that enrol themselves — Vizum.deviceBroker (SDK 5.129)
A sensor with no screen cannot show a PIN. Its owner scans the QR on its label; your app claims it at the Vizum
store's broker with Vizum.deviceBroker.claim({device_id, code, name}) (also
rearm, unlink, list,
rename, link; the same in server code as
V.device_broker). The device is then told where your instance is and receives a one-time
token, and posts it to /vizum/device/<slug>/_enrol. The platform burns the token, mints
the device token exactly as PIN pairing does, and runs your enrol hook, which answers the record the device's data
lands in. Declare "permissions": {"device_broker": true} (privileged, install_scope
admin, min_platform 5.129) and "enrol": {"hook": "..."} on one
auth: "device" json route. A device route needs no page: a headless app serves its json routes.
What stays with the platform. Only an administrator calls the broker, and the host asks first; the
instance's identity and the enrolment token never reach your app, and the url handed to the device is the instance's own
public https address. Every enrolment refusal is the same 401, a token works once, wrong tokens are charged to the source,
and unlink cuts the credential before it tells the store. A device the store revokes is cut
within the hour. See Direct devices.
Addresses on a map without sending them from the browser — Vizum.map.geocode (SDK 5.143)
Declare "permissions": {"geocode": true} and min_platform 5.143,
then await Vizum.map.geocode("Calle 93 #11-27, Bogotá", {limit: 3, country: "co"}) in the
window, or V.map.geocode(query, limit=1) in a hook, job or method. The platform server sends
the query to the provider the administrator chose in Settings › Vizum › Geocoding (a Nominatim server or a commercial
service with its key kept encrypted) and answers at most 10 results of
{lat, lon, display_name, category, type, importance}; the browser calls nobody. Per-app
limits (60 a minute and 2,000 a day by default), a cache shared by the instance, and an audit of every call with the
query as sent. With no provider configured (the default) the call fails with
GEOCODE_NOT_CONFIGURED. vzm check warns when
permissions.net or the code names a geocoding service. See Geocoding.
Reminders that fire with the window closed — Vizum.schedule (SDK 5.128)
Timers in an iframe die with the window. Vizum.schedule keeps the reminder on the server, per
app and user, and a cron turns each due one into a notification of your app. Declare
"permissions": {"schedule": true} and min_platform 5.128, then
Vizum.schedule.set({id: "wake", repeat: "FREQ=WEEKLY;BYDAY=MO,TU,WE,TH,FR;BYHOUR=7;BYMINUTE=30",
tz: "America/New_York", title: "Wake up", payload: {...}}) (or at: "2026-10-18T14:00:00Z"
for one time), clear(id), list(), and
missed() / ack(ids) for what fired in the last 24 hours
(at most 20) while nobody was there. repeat accepts only FREQ=DAILY|WEEKLY, BYDAY, BYHOUR
and BYMINUTE, with an IANA zone: 07:30 stays 07:30 across a DST change. Limits: 50 per app and user, payload 4 KB,
set 120 an hour.
Where it rings. A Vizum Desktop tab open in the browser shows a toast, plays a sound and, if the user granted it, a browser notification, also with your app's window closed or minimised. With the whole browser closed nothing rings: this is not a guaranteed alarm. Browsers can block sound that no click started, so a background tab may stay silent while the toast and the inbox row still appear. Web Push and the Android app are separate, later work. See Schedules & alarms.
Was this a queued replay? — V.offline_replay() (SDK 5.110)
In methods, hooks, jobs and routes, V.offline_replay() is None
for a live call. For a call the platform accepted as an offline replay of this user's queued op it returns
{queued, received_at, first_seen_at, attempts, queued_version}: when the server
received this request and the first time it saw the op's key, both on the server's clock.
It does not prove the device was offline, nor when anything was captured: "queued" is a
label the client sends, and anyone with the user's session can send it. Judge anything that must be on
time with first_seen_at, never with a time the device sent, and show a device time
outside the window the server can vouch for as unverified. Set min_platform 5.110.
See Server-side code.
More than 5,000 records — pages (SDK 5.92)
One search returns at most 5,000 records. An app that declares min_platform 5.92
gets QUOTA_EXCEEDED (details.page = 5000) for a bigger
limit instead of the old silent cut; read more with limit
5000 and offset, ordered by a key that ends on id.
vzm check reports LIMIT_ABOVE_PAGE.
Big exports and rebuilds — Vizum.bulk (SDK 5.90)
One bridge call returns at most the instance's value ceiling (1,000,000, or 500,000 on odoo.sh and small
instances); above it the call is refused with QUOTA_EXCEEDED and the page size
that fits. For more, queue a background job: Vizum.bulk.export(model, {fields, domain,
format: "csv"}) writes one of your app's files, Vizum.bulk.rebuild(model, {fields,
hook}) hands the rows to your server hook a chunk at a time. Jobs run on the cron worker, as the user,
one at a time per instance; poll Vizum.bulk.status(id). Set
min_platform 5.90; check Vizum.supports("bulk-jobs").
See Background jobs.
Data feeds for Power BI — Vizum.feeds (SDK 5.89)
Power BI refreshes outside your app, calling Odoo directly. Give it a governed door instead of
Odoo's native API: with permissions.feeds (privileged, next to an ORM grant), an
administrator calls Vizum.feeds.define("order-lines", {model, fields, domain}) and
Vizum.feeds.rotateKey("order-lines"), which returns the key once. Power BI then pages
GET /vizum/feed/<app>/order-lines with the key as a Basic password: only the
declared columns, read as that administrator through your grant (record rules apply), keyset pages sized to the
instance, a time budget, and one page at a time on small instances (429 with
Retry-After, which Power Query honours). The host confirms
define and rotateKey. Set
min_platform 5.89; check Vizum.supports("data-feed").
See Data feeds.
Server metrics — Vizum.server (SDK 5.96)
An administrator's app can show how the Odoo server is doing. With
permissions.server_metrics (privileged, install_scope: "admin",
min_platform 5.96), Vizum.server.info() answers the hosting,
the scope the numbers describe (the container by default, never another tenant's load), the worker configuration
and which metrics this hosting can read, and
Vizum.server.metrics({from, to, step}) answers CPU, memory, workers, Odoo's disk I/O,
HTTP rate, latency, errors and concurrency, and database series: one value per
step (60 s to 1 day), at most 1,500 points, one-minute data for 48 hours and hourly data
for 30 days. A value that is not available is null, never 0. Nothing that identifies a
URL, user or IP address is recorded. Check Vizum.supports("server-metrics").
See Server metrics.
Signing an action — Vizum.identity.confirm (SDK 5.79)
For an approval, a release or a period close, ask the host to re-confirm the user:
const {token} = await Vizum.identity.confirm({action: "qms.doc:42:approve",
meaning: "approval", reason: "Approve SOP-017"}). The host draws its own dialog over a backdrop
that dims the whole desktop and the user types the account password there; your app never
sees it. Spend the single-use token (120 seconds, bound to the user, your app, that exact
action and the session) in server code with
V.identity.consume(token, action): it returns a receipt
(id, name, login, meaning, reason, time) to store on your signature
record, or raises one fixed error for every failure. Put the record in action.
Rejects USER_CANCELLED, RATE_LIMITED
(details.retryAfter) or PERMISSION_DENIED; Vizum Solo
answers UNSUPPORTED_CALL (check Vizum.supports("step-up")).
Every attempt is a row in a ledger only the platform writes. Never draw a password box in your own
window: vzm check and the Store warn
(PASSWORD_INPUT_IN_BUNDLE). Set min_platform 5.79; the
install gate refuses V.identity below it. See
Server-side code.
Paired screens — kiosks, signage, time clocks (SDK 5.70)
A route with auth: "device" serves a page to a screen that has no
Odoo login. The screen shows an 8-digit PIN, an administrator claims it on the desktop, and it
then holds a revocable token. The device's code runs only as the app's own service user
(native.service_user: true), the page can embed YouTube or Vimeo and
keeps an offline cache, and a screen that has not reached the server for 7 days must be paired
again. Vizum.pairedDevices lists, claims (by PIN), renames, links, reloads and revokes them
from the desktop. Everything, with the limits: Device routes.
Screen volume and settings — player control and Vizum.device.store (SDK 5.82)
The handle Vizum.device.embed returns can now steer its player:
setVolume(0–100), mute(),
unmute(), play() and
pause(). The page sends the player's own command to the player it created,
addressed to its exact origin, never a wildcard; a handle the page did not issue is
NOT_FOUND. The app frame has no localStorage, so
Vizum.device.store (get, set,
delete, all) keeps a screen's own settings on the
platform: JSON values of at most 4096 bytes, at most 64 keys per paired device, readable only by that
device of that app, dropped when it is revoked. Set min_platform 5.82;
vzm check refuses Vizum.device.store below it. See
Device routes.
Printing at a kiosk — Vizum.device.printReceipt, printTest and print() (SDK 5.87, 5.88)
A route with "device": {"print": true} (approved by an administrator, shown on the
approval card) lets its device page print without a dialog. Vizum.device.printReceipt(doc, opts)
takes a structured document (lines with align, bold and size, a QR, a barcode, a divider, a feed, a cut): the
page builds the ESC/POS bytes itself and writes them to a printer a person paired at the screen, once, from the
page's own setup panel, which an administrator opens for ten minutes from Paired devices (WebUSB or Web Serial, Chromium browsers). The app never sends bytes. At most 200 lines,
120 characters a line, a 300-byte QR and 12 receipts a minute. An unpaired printer rejects
PRINTER_NOT_PAIRED, and the app falls back to Vizum.device.print(),
the supported window.print() of a device page: start Chrome with
--kiosk-printing and no dialog appears. Since 5.88 a document can carry a logo,
{image: "data:image/png;base64,…", align, width} (a PNG or JPEG data: URL, never a
remote URL; the page scales it to the printer's paper width, 58 or 80 mm, reduces it to one bit and prints it as
raster; at most 2 images, 800 dots tall each; ESC/POS only), and Vizum.device.printTest()
prints the platform's own test receipt so the app can offer a "Test printer" button
(Vizum.supports("device-receipt-image"), "device-receipt-test";
set min_platform 5.88 for them). Set min_platform 5.87;
vzm check and the install gate enforce it. See Device routes.
Hashing on the server (SDK 5.75)
Hooks, jobs and native methods have V.sha256(data), a metered SHA-256
hex digest (a string, bytes or base64). It is a fingerprint, for detecting change or
deduplicating — a hash is not a signature; it never proves who made the data. Set
min_platform 5.75. See Server-side code.
One request at a time on a key (SDK 5.103)
Native methods have advisory_lock(key, timeout_ms=None), what a module does with
cr.execute("SELECT pg_advisory_xact_lock(...)"): your app's own lock on a string key
(at most 256 characters) until the transaction ends. A sale the phone sends twice takes
advisory_lock("sale:%s:%s" % (self.env.uid, ref)) before it looks for the order, and
is created once. The wait is bounded (5000 ms by default, 0 to 30000); after it the call raises
LockTimeout (LOCK_TIMEOUT, retryable) and your transaction is still usable.
When the previous holder committed after your request began, the call makes Odoo replay the request on a fresh
snapshot, so the replay sees the first sale — let that error go. Locks are per app: another app's key never
waits on yours. Set min_platform 5.103; vzm check and the
install gate enforce it. See Server-side code.
Background jobs (SDK v2.1)
// manifest (requires data:"global"; max 5 jobs):
"jobs": [{ "name": "daily_digest", "every_hours": 24,
"webhook": "https://api.example.com/hook" }] // host must be in net
// in the app — react live or on next open:
Vizum.data.subscribe("job:daily_digest", () => buildDigest());
const last = await Vizum.data.get("job:daily_digest"); // {ts, job}
The platform cron writes a tick into your global store on schedule (and optionally POSTs your webhook). Your JS never runs server-side — the tick is the contract.
Camera & microphone (SDK v2.2)
// manifest: "permissions": { "media": ["camera"] } // or "microphone"
// then use the STANDARD web APIs — no Vizum wrapper needed:
const stream = await navigator.mediaDevices.getUserMedia({ video: true });
const codes = await new BarcodeDetector().detect(videoEl); // barcode scanning
Without the manifest entry the sandboxed iframe gets no device access at
all (Permissions-Policy). With it, the host delegates the capability — and the
browser still shows its own camera/mic prompt to the user on first use, so
access is always double-gated. Feature-detect with
Vizum.capabilities().features.includes("media").
Adding media in an update re-triggers approval
(permission widening). See Vizum Barcode for a worked camera scanner.
Vizum.audit — a business diary you can show an auditor (SDK v3)
await Vizum.audit.log("approved_purchase", { purchase_id: 55, amount: 1200000 });
const logs = await Vizum.audit.search({ action: "approved_purchase",
date_from: "2026-06-01" });
// → [{ts, user: [id, name], action, payload, ok, duration_ms, app_version}]
App-scoped: every user of the app reads its trail (an approver wants to see everyone's approvals), never another app's. Payloads cap at 4 KB, entries prune after 180 days, and action runs land here automatically — including failures, written in an isolated transaction so a rollback can't erase the evidence.
The platform also writes its own lines to the trail. A line that starts with
identity. or platform: (a step-up
confirmation, an offline replay, a resource event) is about one user: a member of the app
reads only their own. trust: and policy:
lines are about the installation. The app's managers (a Settings administrator, or a manager
of the app's approved team) read every line; a member tracing their own call by
request_id finds all of its lines.
Vizum.actions — auditable business operations (SDK v3)
// manifest — the admin approves a CATALOGUE, not loose method calls:
"actions": [
{ "name": "invoice.post", "model": "account.move", "method": "action_post",
"label": "Post invoices", "groups": ["account.group_account_invoice"] }
]
// in the app:
await Vizum.actions.run("invoice.post", { ids: [12, 13, 14] });
Each run: host confirmation dialog → required Odoo groups checked on
top of normal ACLs → record-bound method executes as the user → automatic
audit entry with who/ids/params/result/duration. Declaring actions makes the
app privileged-tier, and adding one in an update requires re-approval. This is
what turns “the app calls action_post” into
“finance approved posting invoices from this app, and every run is on file”.
The run answers {ok: true, result}. Since 5.102
(Vizum.supports("actions-typed-result"), for an app that declares
min_platform 5.102) result is typed like a
call: result: a method that opens a wizard or a form answers
{type: "action", action_type, name, res_model, res_id, view_mode, target},
a recordset answers {type: "records", model, count, ids}, and a result that is
not data is rejected with ACTION_RESULT_UNSERIALIZABLE (the run is rolled back).
Older apps keep the previous answer: the value when it is plain JSON, otherwise true.
Vizum.webhooks — receive events, stop polling (SDK v3)
// manifest (requires data:"global"):
"webhooks": [{ "name": "whatsapp_inbound", "secret_name": "wa_app_secret" }]
// in the app — give the URL to the provider, react live:
const { url } = await Vizum.webhooks.url("whatsapp_inbound");
Vizum.data.subscribe("c:hook_whatsapp_inbound", refreshChat);
const deliveries = await Vizum.data.collection("hook_whatsapp_inbound").list();
The URL is a capability (an HMAC token bound to app+hook — unguessable,
no session needed). Hooks with a secret_name also
verify Meta-style X-Hub-Signature-256 against the
vaulted secret. GET handles the Meta/WhatsApp subscribe handshake (use the
URL's last segment as the verify token). Deliveries are 64 KB max, kept
FIFO 500 per hook in the app's global collection, with a bus ping so open
desktops update instantly. Shopify orders, Stripe payments, WhatsApp
messages — no polling.
Heads-up — public URL. The webhook URL is built from your Odoo's
web.base.url. If that is an internal address (a LAN
IP like https://192.168.x.x:8090, localhost, a
docker host), external providers like Meta/WhatsApp, Shopify or Stripe can't
reach it. Set the system parameter
vizum_apps_platform.public_base_url (Settings ▸
Technical ▸ System Parameters) to your public HTTPS origin and the
webhook URL uses that instead — without moving
web.base.url, which also drives email/report links.
The server must still be reachable from the internet (reverse proxy / tunnel)
with a valid TLS certificate.
Vizum.import + VUI.importMapper — Excel in, Odoo records out (SDK v3)
// one call: file picker -> parse -> map columns -> batch create
const res = await VUI.importMapper({ model: "res.partner" });
// res = {created: [ids…], errors: [{row, error}], total}
// or the pieces, when you want your own flow:
const { columns, rows } = await Vizum.import.open({ requiredColumns: ["name"] });
const fields = await Vizum.meta.fields("res.partner"); // labels, types, required…
CSV parsing auto-detects , ; tab and handles quoted
delimiters/newlines; .xlsx is read natively (ZIP central directory +
DecompressionStream + sharedStrings — zero
libraries). The mapper auto-suggests by column/label similarity, casts types,
imports in chunks of 50 and falls back to row-by-row on a failed chunk so one
bad line never sinks fifty good ones — every failure reported with its row
number. Vizum.meta.fields() returns metadata only,
and only for models the manifest already grants.
Vizum.jobs — a scheduler you can operate (SDK v3)
// manifest — cron expressions and retries (or keep every_hours):
"jobs": [{ "name": "sync_orders", "cron": "*/15 * * * *",
"webhook": "https://api.acme.com/hook",
"retry": { "count": 3, "backoff": "exponential" } }]
// in the app:
await Vizum.jobs.runNow("sync_orders"); // manual trigger
const st = await Vizum.jobs.status("sync_orders"); // {last_run, fails, retry_at…}
const history = await Vizum.jobs.logs("sync_orders"); // audit-backed run log
Full 5-field cron (*/n, ranges, lists, dow 0-7) at
10-minute platform resolution. Failed webhook deliveries retry with
exponential or linear backoff up to count times —
state is visible in status(), and every run
(scheduled, manual or retry) lands in the audit trail with its outcome.
A job that calls a native method has a 300 s limit. A method cannot be
interrupted from outside, so an overrun is measured, not cut off: it is recorded on the job,
in the audit trail and in the app's health as jobs_time_limit.
Keep a batch under the limit. (A script job has its own 120 s budget, which does stop it.)
Vizum.sessions — see & revoke who is logged in (SDK 5)
// manifest: "permissions": { "sessions": ["revoke", "revokeUser"] } — admin-gated.
// Read the device list through Vizum.orm on res.device (Odoo 18+):
const devices = await Vizum.orm.searchRead("res.device", [],
["user_id", "browser", "platform", "ip_address", "last_activity", "is_current"]);
await Vizum.sessions.revoke(deviceId); // kill ONE session (a res.device id)
await Vizum.sessions.revokeUser(userId); // kill ALL of a user's OTHER sessions
A sandboxed app cannot end an Odoo session itself — the real kill
(res.device._revoke()) is private and identity-checked.
The platform performs it under a base.group_system gate plus a host
confirmation, so you can build a "my devices" panel, an IT force-logout, or a
kill-switch for a lost laptop without shipping a native module. Needs Odoo 18+
for res.device.
Session security policy — alerts, idle timeout, audit (SDK 5)
// manifest: "permissions": { "sessions": ["getPolicy", "setLoginAlerts",
// "setSessionTimeout", "setActivityAudit", "setActivityAuditGlobal"] }
const p = await Vizum.sessions.getPolicy();
// → { login_alerts, session_timeout:{enabled, hours, min_hours},
// activity_audit:{enabled, global}, supported }
await Vizum.sessions.setLoginAlerts(true); // in-app notify on every NEW sign-in
await Vizum.sessions.setSessionTimeout(true, 24); // auto-revoke sessions idle > 24h (min 2h)
await Vizum.sessions.setActivityAudit(true); // turn ON the change-log (see Vizum.audit.activity)
await Vizum.sessions.setActivityAuditGlobal(true); // ADVANCED: also log native-UI / RPC writes
One platform capability replaces a whole "advanced session management" add-on —
no native install. getPolicy() reads on every Odoo
version; the setters are admin-only. Login alerts poll new
res.device sign-ins on a 2-minute cron and notify the
user in-app. Session timeout auto-revokes idle sessions on a cron — with a
2-hour floor that protects active users, because Odoo only refreshes
last_activity about once an hour. Both degrade
gracefully where res.device is absent (Odoo 17);
supported in the policy tells you which side you are on.
Vizum.audit.activity — old→new field history (SDK 5)
// manifest — declare WHICH models/fields to change-log, and opt in:
"permissions": { "audit": true },
"native": { "audit": { "models": [
{ "name": "res.users", "fields": ["login", "active", "name"] },
{ "name": "x_vzm_myapp_contract", "fields": ["x_state", "x_amount"] }
] } }
// in the app — read the trail (needs the admin's master switch ON):
const rows = await Vizum.audit.activity({ model: "res.users", limit: 50 });
// → [{ ts, user:[id,name], model, res_id, record_name, action, source,
// fields_changed:["login"], changes:{ login:{old, new} }, summary }]
A field-level "who changed what, from what, to what" diary. Where
Vizum.audit logs business operations you record
explicitly, this captures real old→new diffs on the models you declare in
native.audit. Two layers: writes through your app
are captured at the ORM choke point; flip
setActivityAuditGlobal(true) and a global
base write/create/unlink hook also captures native-UI and
external-RPC writes to the same models. Off by default, entries prune after 180 days,
and the query filters by model, res_id, user_id, action, source,
date_from, date_to, limit.
Vizum.db — internal relational tables (SDK v4)
// manifest — typed tables with indexes, internal m2o and Odoo refs:
"tables": {
"commission_run": { "fields": { "period": "string", "state": "selection",
"total": "float" }, "indexes": ["period"] },
"commission_line": { "fields": { "run_id": "many2one:commission_run",
"invoice_id": "odoo:account.move",
"amount": "float" } }
}
// in the app — no 500-row cap, domain search, typed coercion:
const run = await Vizum.db.insert("commission_run", { period: "2026-06", total: 18.5e6 });
const lines = await Vizum.db.search("commission_line",
[["run_id", "=", run.id], ["amount", ">", 0]], { order: "amount desc", limit: 100 });
await Vizum.db.update("commission_run", run.id, { state: "posted" });
A lightweight relational store per app for the data Collections can't
hold well: commission engines, bank reconcilers, import staging, approval
boards. Field types: string · text · integer · float ·
monetary · boolean · date · datetime · selection · many2one:<table> ·
odoo:<model>. Searches take a domain (= != <
<= > >= in like ilike) applied server-side; values are type-coerced
and odoo: references are existence-checked with the
user's ACLs. Rows are scoped user/global like the KV store. Declaring tables
makes the app privileged-tier; adding a table in an update re-triggers
approval.
// manifest "migrations" — applied to stored rows when the version climbs:
"version": "1.2.0",
"migrations": [
{ "version": "1.1.0", "table": "commission_line", "ops": [
{ "rename": ["amt", "amount"] }, // amt → amount on every row
{ "default": ["approved", false] } ] }, // fill missing field
{ "version": "1.2.0", "table": "commission_line", "ops": [
{ "drop": "legacy_note" } ] }
]
Because rows are schema-less JSON, adding a field needs no migration —
new rows carry it, old rows read the default. For renames, drops, copies and
back-fills, declare a migrations entry per
version: when an updated package installs, the platform runs every migration
whose version falls in
(installed, new], in order, transforming the app's
existing rows. Ops: {rename:[a,b]} · {drop:f} ·
{default:[f,v]} · {copy:[a,b]}. They run server-side at install time —
there is no runtime API and nothing for the app to call.
// a table can declare validation rules — enforced on every insert/update:
"commission_line": {
"fields": { "email": "string", "amount": "float", "status": "string", "period": "string" },
"rules": [
{ "field": "amount", "required": true, "min": 0 },
{ "field": "email", "format": "email" },
{ "field": "status", "choices": ["draft", "posted"] },
{ "unique": ["period"] }
]
}
Beyond field types, a table's rules are enforced by
the server on every Vizum.db.insert/update —
required, numeric
min/max,
format (email /
url), choices (an
allowed set) and composite unique (no two of the
app's rows share that field combination). A violation rejects the write with a
clear error, so the invariant holds no matter which client wrote it — the same
guarantee an Odoo model constraint gives, declared in the manifest.
Vizum.workflow — a process engine (SDK v4)
// manifest — ordered steps, group assignees, per-step SLA:
"workflows": [{
"name": "purchase_approval",
"steps": [
{ "name": "manager_review", "assignee": "group:purchase.group_purchase_manager", "sla_hours": 24 },
{ "name": "finance_review", "assignee": "group:account.group_account_manager" }
]
}]
// in the app — start, then assignees approve/reject down the chain:
const wf = await Vizum.workflow.start("purchase_approval", { purchase_id: 55 });
// running | approved | rejected | cancelled — wf.step is the live stage
await Vizum.workflow.approve(wf.id, { note: "ok by manager" });
const mine = await Vizum.workflow.list({ state: "running" });
Where Vizum.actions run one approved
operation, a workflow manages a whole process: ordered steps, each with
a group (group:<xmlid>) or the starter
(user:starter) as assignee, optional SLA deadlines,
and approve/reject with notes. Only an assignee of the current step can
act; advancing notifies the next step's assignees via
Vizum.notifications, and a breached SLA escalates
automatically (a 15-minute cron). Every transition lands in the instance history
and the audit trail. Declaring workflows makes the app
privileged-tier.
Vizum.notifications — a persistent per-user inbox (SDK v4)
await Vizum.notifications.create({
user_id: 12, title: "PO00045 pending", body: "Needs your approval",
priority: "high", action: { payload: { purchase_id: 45 } } });
const unread = await Vizum.notifications.count(); // badge it
const rows = await Vizum.notifications.list({ unread: true });
await Vizum.notifications.markRead(rows.map((r) => r.id));
An inbox owned by your app and scoped per user on the server — it survives
reloads, carries read/unread state, a low·normal·high
priority and a click action, and pings the bus so open
desktops update live. No permission needed: the server scopes every row by
app + user. The inbox is FIFO-bounded and auto-pruned after 90 days. This is the
channel workflows, jobs and webhooks use to reach a human.
Vizum.realtime — ephemeral multi-user pub/sub (SDK v4)
// every open copy of THIS app shares a named room — nothing is stored:
const ch = Vizum.realtime.channel("kds");
ch.on("ticket", (t, { from }) => renderTicket(t));
ch.on("bump", (id) => removeTicket(id));
// publishing reaches every other screen instantly (via the Odoo bus):
await ch.publish("ticket", { id: 7, table: 12, items: [...] });
// later: ch.close();
A live room for screens that must agree right now: kitchen displays,
picking stations, live approval queues, presence cursors. A channel is just a
name every open copy of the same app shares; publish
fans a small JSON event (≤16 KB) out over the Odoo bus to every other
instance, stamped with the sender's user id. Nothing is persisted — when a
screen reloads it has no history, so pair realtime with
Vizum.db or Collections for the durable copy.
Channels are app-scoped by construction (the host only forwards your own app's
messages) and need no permission.
Vizum.offline — act now, sync later (SDK v4)
// run an approved action — or queue it if there's no connection:
const r = await Vizum.offline.run("delivery.confirm", { ids: [orderId] });
if (r.queued) { VUI.toast && VUI.toast("Saved — will sync when back online"); }
Vizum.offline.on((s) => { offlineBanner.hidden = s.online; }); // connectivity flip
Vizum.offline.onFlushed((f) => refresh()); // outbox drained
const queued = await Vizum.offline.pending(); // [{id, action, params, ts}]
const { online } = await Vizum.offline.status();
A vapp runs in an opaque-origin iframe and cannot persist anything
locally — so the host (a normal origin) owns the outbox. While the
browser is offline, offline.run(action, params)
queues the intent in localStorage and returns
{queued: true}; the moment connectivity returns the
host replays each queued action through the same
api_action path, so it is re-validated
server-side (allowlist, groups, ACLs, audit) exactly as if run live. A
permanent rejection (bad action / no access) is dropped and reported in
flush()'s failed list;
a transport error keeps the item queued. The unit of deferral is an
approved action, never arbitrary code — so offline never weakens the
security model. Confirmation is asked once, when the user acts; the automatic
replay does not re-prompt. Needs permissions.actions.
Offline data — read and write with no coverage (SDK 5.80)
// manifest: "offline": {datasets: [...], writes: [...]}, "min_platform": "5.80"
await Vizum.offline.sync(); // bring the device copy up to date
const { records, as_of } = await Vizum.offline.query("stops", { domain: [["x_state", "=", "todo"]], order: "x_seq" });
const made = await Vizum.offline.create("sale.order", { partner_id: 7 }, { expect: { amount_total: 120 } });
await Vizum.offline.write("x_vzm_dsd_visit", [4, made.id], { x_note: "gate code" }); // made.id is a temp id
const issues = await Vizum.offline.conflicts(); // what the server refused
await Vizum.offline.resolve(issues[0].id, "keep_mine"); // or "keep_server", "discard"
Vizum.offline.onAppUpdated(({ running, installed }) => saveDraft()); // 5.95: the server moved to another version
The host keeps the datasets your manifest declares on the device and answers
query and readGroup from them with no
network. create and write are queued, applied to the
local rows at once and replayed in order, exactly once, as the user, when the network is back; a queued
write carries the values the device saw and the server answers CONFLICT if
someone changed them; a change sent again after its answer was lost (a reload mid-sync, or the Android
background worker and the app sending the same queue) answers what it answered the first time and
never conflicts with work already done. From 5.109, the same idempotencyKey on an online
orm.callOn and on the queued declared action that runs the same method makes them one
operation: whichever arrives second answers the stored result and runs nothing. A refused change is listed for the user to keep, replace or discard. If the app
was updated meanwhile (5.95), each queued change is checked against the new version: what is still valid
applies, and what is not (a removed or retyped field, a narrowed permission) is listed with its reason for
the user to export or discard, never applied with its old meaning. The rows
are removed on logout; the queue stays, and signing out with changes or sync issues still pending
warns first and never discards them. Everything is in Offline data;
vzm new offline-field-app scaffolds a working app with tests.
App composition — requires & provides (SDK v4)
// manifest — declare what you need and what you offer:
"requires": ["com.acme.crm_core"],
"provides": ["contract:invoice_printer", "contract:lead_scorer"]
// at runtime — check your deps, discover peers, route to a contract:
const deps = await Vizum.apps.requires(); // [{key, installed, ready}]
if (deps.some((d) => !d.ready)) { showSetupBanner(deps); }
const printers = await Vizum.apps.find("contract:invoice_printer");
if (printers[0]?.ready) { await Vizum.apps.open(printers[0].key, { invoice_id: id }); }
const all = await Vizum.apps.installed(); // [{key, name, version, ready, provides}]
Apps stop being islands. requires names peer vapps
this one needs — Vizum.apps.requires() reports
whether each is installed and ready so you can prompt the user to add a missing
one (advisory, not a hard install block, since apps can arrive in any order).
provides publishes capability contracts
(free-form strings) that any app can discover with
Vizum.apps.find(contract) and then reach through the
existing intent (Vizum.apps.open, gated by
permissions.apps). The catalogue read
(installed()) is low-risk — the user already sees
every app in the launcher — so discovery needs no permission; only
opening a peer does.
Vizum.telemetry — product analytics, privately (SDK v4)
Vizum.telemetry.track("export_clicked");
Vizum.telemetry.track("rows_imported", batch.length);
const s = await Vizum.telemetry.summary({ date_from: "2026-06-01" });
// { totals: { export_clicked: 42, rows_imported: 1880 }, total: 1922 }
Where Vizum.audit records business operations
(who posted which invoice), Vizum.telemetry is the
other axis — product analytics: which features get used, how often.
To stay bounded and privacy-safe it stores only daily aggregated counters
per (app, event, day) — never per-user rows, never
free-form payloads. track(event, n?) bumps today's
counter; summary({event?, date_from?, date_to?})
returns the app's own roll-up. Admins see it under Vizum Apps Admin →
Telemetry. App-scoped and zero-risk.
Vizum.permissions + Vizum.meta — self-aware apps (SDK v4)
// show the user exactly what this app can do (risk-tagged):
const caps = await Vizum.permissions.explain();
// [{capability: "Read & change Contacts", detail: "ops: read, write", risk: "high"}, …]
// build dynamic UIs from the granted models' metadata:
const models = await Vizum.meta.models(); // [{model, label, ops, fields}]
const cols = await Vizum.meta.fields("crm.lead"); // [{name, label, type, …}]
const stages = await Vizum.meta.selection("crm.lead.priority"); // [["0","Low"], …]
const access = await Vizum.meta.fieldAccess("product.product"); // {hidden: ["standard_price"], no_read, readonly, …, rev}
An administrator can hide fields from some users with Access Manager. The platform then hides them from
your app too: a hidden field you name in a read is left out of the result with no error, a domain, order
or grouping on it is refused, and Vizum.meta.fields() does not list it.
Vizum.meta.fieldAccess(model) says which fields are restricted for the current
user; its rev changes when the answer does. Design your screens to work without
any field the user may not see.
Vizum.permissions.explain() turns the app's
approved grants into a human-readable, risk-tagged list — drop it into
a "what can this app do?" panel so users (and you) can audit a vapp from the
inside; raw() returns the snapshot itself.
Vizum.meta exposes metadata for the models the
manifest grants — fields(model),
models() (every granted model + label + ops) and
selection("model.field") — so generic tools (import
mappers, report builders, dynamic forms) can adapt to the schema. Metadata
only: reading actual data still goes through the
orm proxy with every gate intact. Both are
read-only and zero-risk.
Vizum.mail + Vizum.activity — live in Odoo (SDK v4)
// post to the record's chatter (the model needs a "write" orm grant):
await Vizum.mail.post("crm.lead", id, "Called the customer — very interested");
await Vizum.mail.log("crm.lead", id, "Internal note: budget confirmed");
// schedule / list / complete real Odoo activities on the record:
await Vizum.activity.schedule("crm.lead", id,
{ summary: "Send proposal", date_deadline: "2026-06-20" });
const todos = await Vizum.activity.list("crm.lead", id); // [{id, summary, overdue, …}]
await Vizum.activity.done("crm.lead", id, todos[0].id, "Sent");
The point of a Vizum app is to live inside Odoo, not beside it.
Vizum.mail posts to a record's chatter
(message_post) — a real message or an internal note;
Vizum.activity schedules, lists and completes the
same activities the rest of Odoo shows in the systray and on the form.
Both ride the model's existing orm grant — a
mutation needs the write op, listing needs
read — and run as the user, so Odoo's ACLs
and record rules decide. No new permission: if your app can already write the
record, it can talk about it.
Vizum.roles — declarative app roles (SDK v4)
// manifest — name your roles and map each to Odoo groups:
"roles": [
{ "name": "manager", "label": "Approver", "groups": ["base.group_system"] },
{ "name": "agent", "groups": ["base.group_user"] }
]
// in the app — gate your OWN UI (real authority stays in Odoo ACLs):
if (await Vizum.roles.has("manager")) { showApproveButton(); }
const mine = await Vizum.roles.mine(); // ["agent", …]
const all = await Vizum.roles.list(); // [{name, label, active}]
A clean way to express "who can do what" inside your app without scattering
group xmlids through the UI. Each role maps to one or more Odoo groups;
has()/mine() are
evaluated server-side as the real user (has_group),
so the answer can't be spoofed from the iframe — but it's for
presentation: the server still enforces real ACLs on every
orm/action call. A role
with no groups matches everyone. Read-only and zero-risk, so declaring roles
never changes the install tier.
Vizum.ai — a managed LLM gateway (SDK v4)
// manifest — declare access (true = any gateway model, or an allowlist):
"permissions": { "ai": true }
// in the app — the platform admin owns the provider key:
const out = await Vizum.ai.chat({
system: "You are a terse sales assistant.",
messages: [{ role: "user", content: "Summarise lead #" + id + ": " + notes }],
max_tokens: 400,
});
console.log(out.content, out.usage); // {input, output, cache_read, cache_write} tokens
const text = await Vizum.ai.complete("Write a follow-up email subject line.");
const models = await Vizum.ai.models(); // [{id, label, provider}]
const { vectors } = await Vizum.ai.embed(["alpha", "beta"]); // OpenAI gateway
A platform-managed bridge to an LLM: the administrator configures one
gateway once (provider + key + the models to expose, under
Vizum Apps Admin → AI), and any app that declares
permissions.ai can call it. The provider key is
encrypted at rest and never crosses the iframe boundary — exactly like
Vizum.secrets, but owned by the platform. Five
providers ship — anthropic ·
openai · gemini ·
ollama (Cloud) · grok —
all normalised to one shape — {content, tool_calls, usage,
model, finish_reason} — so swapping providers doesn't touch app code.
Every call is metered (token usage per app/user) and written to the audit
trail, the output-token ceiling and an optional monthly budget are enforced
server-side, and permissions.ai can pin the app to
a specific model allowlist. Declaring it makes the app privileged-tier (it can
incur provider cost).
Prompt caching. On Anthropic models the platform marks the stable start of
the request (the tool list, the system text and the
conversation tail) as cacheable, so a repeated prefix is read at 0.1× the
input price (0.05× on Opus 5.5 and Sonnet 5.5). Nothing to
declare. Keep system and tools
identical between calls and put anything that changes (a timestamp, the user's name,
fetched records) in the last user message. usage adds
cache_read and cache_write;
input is only the uncached part, so the total input is
input + cache_read + cache_write. Budgets and monthly caps count
the real cost (writes 1.25×, reads 0.1×). Details:
sdk-next/docs/prompt-caching.md.
Vizum.assistant — an AI bridge to Odoo & your apps (SDK 5)
// manifest: "permissions": { "assistant": true } — admin-approved, high-risk.
// A server-side agent runs a tool-loop as the REAL user (no sudo): it reads any
// model the user can read and PROPOSES writes you confirm. Per-user history.
const turn = await Vizum.assistant.send(0, "how many open opportunities do I have?");
// → { session_id, text, tool_steps:[…], pending_actions:[…] }
// the model proposed a write? it never auto-runs — confirm (or reject) it:
const t2 = await Vizum.assistant.send(turn.session_id, "create a lead 'Acme' for partner 14");
if (t2.pending_actions.length)
await Vizum.assistant.confirm(t2.session_id, t2.pending_actions[0].id);
// per-user chat history (each user sees ONLY their own):
const chats = await Vizum.assistant.sessions();
const msgs = await Vizum.assistant.history(chats[0].id);
await Vizum.assistant.newSession("Q3 review");
await Vizum.assistant.rename(id, "Renamed"); await Vizum.assistant["delete"](id);
The agent runs server-side as the logged-in user, so Odoo's ACLs and
record rules are the real limit — it only sees and changes what that user can.
Reads execute immediately; create/update PROPOSE a pending action
the user must confirm() (a hallucination can't
silently write). Model tiers: technical/infra models
(ir.*, res.users,
mail.mail, payment internals) are blocked;
accounting/stock (account.move,
stock.*) are read-only; everything else is
read + confirm-write. The admin configures the provider once (Anthropic /
OpenAI / Gemini / Ollama Cloud / Grok) — keys never leave the server.
Ships as the AI Assistant app (chat UI + an "Ask AI" desktop entry).
Vizum.perf — desktop health metrics (SDK 5)
// this browser tab's live runtime — windows, JS heap, sampled FPS, desktop state
const d = await Vizum.perf.desktop();
// d.windows {total, open, minimized, vapps, actions, byKind, activeId}
// d.memory {heapMB, totalMB, limitMB, pct} | null d.fps d.desktop {active, mobile, effects, space…}
// server responsiveness — round-trip latency of a cheap call (server CPU/RAM aren't exposed)
const { latencyMs } = await Vizum.perf.ping();
// ADMIN only — active-session counts across the instance (sessions_* need Odoo 18+; null on 17)
const s = await Vizum.perf.sessions(); // {supported, online_users, online_mine, sessions_total, sessions_mine}
Read-only health metrics for monitoring the Vizum desktop's impact on
speed, memory and the server. The sandboxed iframe can't read the parent
tab's performance APIs, so desktop() is computed by
the host. Ships as the admin-only Vizum Monitor app. Degrades gracefully
on Odoo 17 (no res.device session counts).
Vizum.vector & Vizum.docs — Knowledge / RAG (SDK 5–5.2)
// manifest: "permissions": { "vector": "user", "ai": true }
// Vizum.vector is the durable, server-side embedding store (an opaque-origin vapp
// iframe cannot persist anything). Rows are scoped user/global like Vizum.db; embedding
// rides permissions.ai so the provider key never reaches the iframe.
await Vizum.vector.upsert("kb", [
{ text: "chunk one", doc_id: "f1", seq: 0, meta: { src: "policy.pdf" } },
{ text: "chunk two", doc_id: "f1", seq: 1 }]); // host embeds the text
const { matches } = await Vizum.vector.search("kb", "how do I request leave?",
{ topK: 5, mode: "hybrid" }); // vector | keyword | hybrid (RRF) → [{id,score,text,doc_id,seq,meta}]
// Vizum.docs — keep the ORIGINAL document bytes so the index can be RE-BUILT later
// (new embedding model, a storage-mode switch, recovering lost vectors). Stored in the
// FILESTORE (disk), never DB rows, so the database never bloats. Rides permissions.vector.
await Vizum.docs.put("kb", { doc_id: "f1", name: "policy.pdf",
content_type: "application/pdf", data: base64Bytes });
const doc = await Vizum.docs.get("kb", "f1"); // {…, data: base64} → re-chunk & re-embed
Three storage modes, chosen by the instance admin (Vizum Apps Admin → AI → Managed Mode → RAG vector storage) — your code is identical in all three; only where the vectors live changes:
| Mode | Where vectors + documents live | Cost / setup |
|---|---|---|
| Local default | this database (embeddings in-row, documents in the filestore), searched in memory | none — zero setup |
| Managed | vizumapps.com pgvector + filestore, per instance | metered $/1,000 vectors·month (billed like managed AI); plug-and-play |
| Local pgvector | your Postgres via the pgvector extension — searched in-database (<=> cosine) |
high scale, on-prem, no recurring fee; needs the extension |
// Mode-3 (local pgvector) setup — what the "RAG pgvector Setup" app automates.
// pgvStatus is read-only; pgvProvision / pgvBackfill require an admin user.
const s = await Vizum.vector.pgvStatus(); // {available, installed, column_ready, active, mode, pending_backfill}
if (s.available && !s.column_ready) await Vizum.vector.pgvProvision(); // CREATE EXTENSION + column
if (s.pending_backfill) await Vizum.vector.pgvBackfill(); // migrate existing vectors (no re-embed)
Vector residency is the client's choice because a knowledge base can be sensitive.
stats().backend reports where a namespace currently lives
(local / pgvector / managed).
Switching mode does not migrate existing vectors — re-index from
Vizum.docs, or use Backfill for the local→pgvector
move. This powers the Knowledge (RAG) flagship app.
The shared vector scope — sharedNamespaces and moveScope (SDK 5.83)
An app whose approved vector grant is "user" reads and
writes its own rows. An app that narrowed from "global" to
"user" (an update that asks the administrator again) leaves its old rows in the
shared scope, where namespaces() does not list them. Two calls let the
administrator, or the app's own service user in a migration job, find them and move them without paying
for the embeddings again:
// -> [{ name, count, model, dim }]: the namespaces THIS app owns in the shared scope
const shared = await Vizum.vector.sharedNamespaces();
for (const ns of shared) {
let r;
do { // each call moves one batch of whole documents; loop until done
r = await Vizum.vector.moveScope(ns.name, { from: "global", to: "user", batch: 2000 });
} while (!r.done); // r = { moved, remaining, conflicts, done, from, to, user_id }
}
What a move does. The owner of the stored rows changes; the embedding, text, metadata, chunk order and
doc_id stay exactly as stored, no embedding is computed and nothing is metered.
A call is one transaction, bounded by batch (default 2000, at most 10000; whole
documents move together), and re-running it after done moves nothing. A row whose
(doc_id, chunk_seq) the destination already holds is never overwritten or
duplicated: it stays in the source and is counted in conflicts. A document that has
fully left the shared scope loses its knowledge graph there and its per-document access grants, so nothing
public keeps naming it; the graph of the destination is rebuilt by re-indexing or
graphRecompute().
Who may call. An administrator, or the app's service user (V.is_service_user());
never a plain user. The store does not record who wrote a shared row, so "the shared rows that belong to this
user" cannot be told; the caller names the target (user_id; an administrator may omit
it for their own scope, the service user must name it). { from: "user", to: "global" }
publishes private knowledge to every user of the app and is administrator-only. A "global"
app has no user scope to move into and is refused; managed RAG storage lists but cannot move. Server code reads
the list with V.vector.shared_namespaces(). Set min_platform
5.83 (the install gate refuses V.vector.shared_namespaces below it, and
vzm check the browser members); Vizum.supports("vector-scope")
tells a screen whether the host serves it.
AI tools — let the Assistant use your app (SDK 5.3)
// manifest.json — tools the AI Assistant can DISCOVER + invoke. With a `model` the
// Assistant CREATEs a record (confirmed); WITHOUT a model your app builds the content.
"ai_tools": [
{ "name": "create_document",
"description": "Build a document. title + content_md (markdown).",
"input": ["title", "content_md"] }
]
// CLIENT-HANDLED — the Assistant opens your app with the request; you build it:
Vizum.ai.tools.handle("create_document", async ({ title, content_md }) => {
const id = await Vizum.orm.create(MODEL, { x_name: title, x_data: mdToHtml(content_md) });
Vizum.ui.notify("Created by AI", "success");
});
The Assistant calls use_vapp_tool(app_key, tool, values) — so a
user can say "draft a 5-slide deck about Q3" and the right app builds it. Vizum Doc,
Vizum Point, Sheets Lite, Dashboard Builder and Report Builder ship example tools.
Vizum.backend — async business operations (SDK v3)
// the unit of execution is an APPROVED manifest action — never code:
"actions": [{ "name": "invoice.post", "model": "account.move",
"method": "action_post", "approval": true }]
const t = await Vizum.backend.enqueue("invoice.post", { ids: bigList });
// queued | waiting_approval | running | done | failed | cancelled
const st = await Vizum.backend.status(t.id); // + per-task log lines
await Vizum.backend.approve(t.id); // admins release gated tasks
Tasks run on the server (2-minute queue tick) as the user who enqueued
them — their ACLs and record rules decide, and a failed action rolls back
its own writes only (savepoint) while the task records the failure honestly.
Actions with "approval": true wait for an
administrator. The window stays free; runs land in the audit trail like any
action. The app states an intent — the bridge validates — the queue
executes: the same contract heavier external workers will honour.
The queue is bounded: at most 500 pending (waiting_approval
+ queued) tasks per app and 100 per user, and
enqueue() beyond that rejects with
QUOTA_EXCEEDED. A task nobody approves within 7 days is
cancelled (last_error: "approval expired"), never deleted.
Vizum.license + Vizum.errors + Vizum.devices (SDK v3)
// licensing-aware features (paid store apps):
const lic = await Vizum.license.current(); // {pricing, licensed, status, expires}
await Vizum.license.require(); // throws + opens the store when unlicensed
// observability — uncaught errors are captured automatically into the audit
// trail (rate-limited); add your own context:
Vizum.errors.capture(err, { screen: "approval_board" });
const errors = await Vizum.dev.logs();
// devices — manifest: "permissions": { "devices": ["print", "serial"] }
await Vizum.devices.print("Picking 00042", ["PICK 00042", "2 x Desk", "1 x Lamp"]);
const port = await navigator.serial.requestPort(); // scales, scanners, printers
print renders your text into a scriptless
host-side frame and opens the system print dialog — labels and tickets without
a single privileged byte. serial delegates the
standard Web Serial API to your sandboxed iframe (Chromium; the browser shows
its own port chooser). Both are manifest grants and count as permission
widening on update.
Web Bluetooth (SDK 5.134)
// manifest: "min_platform": "5.134", "permissions": { "devices": ["bluetooth"] }
// then use the STANDARD Web Bluetooth API - no Vizum wrapper:
if (Vizum.supports("devices.bluetooth")) {
const dev = await navigator.bluetooth.requestDevice({ filters: [{ services: ["0000ffe0-0000-1000-8000-00805f9b34fb"] }] });
const gatt = await dev.gatt.connect();
}
bluetooth delegates navigator.bluetooth
to your sandboxed iframe. The browser shows its own device chooser and needs a click on
every requestDevice(); the app never sees a device the person
did not choose. The approval card reads “Connect to Bluetooth devices you choose”,
and adding it in an update asks for approval again. Without the manifest entry (or while it is
not approved) the host does not delegate it and requestDevice()
rejects with a SecurityError. Vizum.supports("devices.bluetooth")
(also Vizum.devices.bluetoothSupported()) is true only when the
host delegated it and the browser has Web Bluetooth: Chrome and Edge on desktop and
Android; not Firefox or Safari, and not the Android WebView host of the Vizum app (a separate
decision). The app talks to the device directly; nothing goes through Odoo.
Vizum AR (SDK 5.151; also on Solo, the phone PWA and native-menu apps since 5.154)
// manifest: "min_platform": "5.151", "permissions": { "media": ["camera"] }
if (Vizum.supports("ar")) {
Vizum.ar.on("codes", async ({ visible }) => {
const cards = {};
for (const c of visible) {
cards[c.value] = { title: "Take 4 x COFFEE", badge: "x4", tone: "focus", tap: true };
}
await Vizum.ar.overlay(cards, { replace: true });
});
Vizum.ar.on("tap", ({ value }) => confirmPick(value));
const r = await Vizum.ar.start({ hint: "Point at the shelf" });
if (!r.ok) { /* r.reason: "denied" | "unsupported" (r.detail) | "busy" */ }
}
Vizum AR draws information from Odoo on top of what the camera sees. The host opens the rear
camera, reads the barcodes and QR codes in view and paints, over each one, the plain-text card
your app described. The video never reaches your app: you receive the
values of the codes (ar:codes, only when the set of visible
codes changes) and answer with a value→card map (overlay).
The view never writes to Odoo; whatever your app does with a code, it does with
the ORM, as the user. Cards are text only (title 40, subtitle 60, badge 12 characters, a
tone and an arrow from fixed lists);
at most 200 are kept and 30 drawn, and overlay allows 20 calls a
second.
It uses the same media: ["camera"] grant as
Vizum.vision; the approval card now says the camera also recognises
codes. vzm check reports AR_NEEDS_CAMERA
and AR_MIN_PLATFORM. The reader is the browser’s
BarcodeDetector (Chrome and Edge on Android, ChromeOS and macOS). In a
browser without one, start() answers
{ ok: false, reason: "unsupported", detail: "engine_disabled" }
unless the administrator switched on the bundled WebAssembly reader in Settings; that switch is
off by default. One view per window, shared with vision
(busy); it stops when the window closes, is minimised or the tab is
hidden. Use labels of at least 4 cm for 1–3 m. Since 5.154 the same calls run
in the /vizum/pwa/ phone PWA, in the Solo Android app (it asks for the camera
permission once; the Back button closes the view) and in native-menu apps; the bundled reader is kept
for offline starts after one online use. Full reference:
sdk-next/docs/ar.md; develop without a camera with
VizumMock.ar.
Vizum AR app layer and Vizum Motion (SDK 5.155)
// manifest: "min_platform": "5.155",
// "permissions": { "media": ["camera"], "sensors": ["motion"] }
await Vizum.ar.start({ layer: "app", rate: 30 }); // the camera goes BEHIND your page
Vizum.ar.on("frame", (f) => {
for (const d of f.detections) { place(d.value, d.css); } // four [x, y] corners in your page’s pixels
});
await Vizum.motion.start({ rate: 30 });
Vizum.motion.on("orientation", ({ alpha, beta, gamma }) => tilt(beta, gamma));
With layer: "app" the host keeps the video in its own
<video> under your transparent page and sends
frame events: numbers and strings only (at most 30 a second, 4 KB, 30
detections), with the video size, your page’s box, a field of view that is never null
(source is default or
estimated after Vizum.ar.setFov({ h })) and a
pinhole proj for 3D. No pixel reaches your app. The host
draws the “using the camera” strip, the Close button and the border above your
iframe, so the app cannot cover them; if they are hidden the camera stops
(ar:state stopped, reason
chrome, covered or
fullscreen). Vizum.capabilities().ar.layer says
whether the window can show a layer.
Vizum.motion is relayed by the host: the iframe gets no sensor API. It needs
permissions.sensors: ["motion"] (the approval card reads “Read this
device’s tilt and movement while the app is open”; an update that adds it asks again). Angles
are quantised to 1° (rotation rate 1°/s) and acceleration to 0.1 m/s², 10–60 Hz (20 Hz at most without an AR layer); the stream stops when the window is hidden or not the top one (an app behind another one, or under a dialog, a record sheet or the Solo drawer), and start answers hidden or background there without listening. On iOS the host shows
its own “Allow motion” button. vzm check reports
MOTION_NEEDS_SENSORS. Full references:
sdk-next/docs/ar.md and sdk-next/docs/motion.md;
develop without hardware with VizumMock.ar.emitFrame and
VizumMock.motion.
Receipt printing & scan capture (SDK 4.1)
// ESC/POS receipt to the user's default paired printer (USB / Bluetooth-BLE /
// Serial — paired once in Settings → Devices). grant: "devices": ["print"]
await Vizum.devices.printReceipt([
{ text: "ACME Foods", align: "center", bold: true, size: "double" },
{ text: "Order S00042", align: "center" },
"--------------------------------",
"2 x Desk $ 90.00",
{ text: "TOTAL $120.00", bold: true },
], { cut: true }); // each line = a string OR {text, align, bold, size, underline}
// NFC taps + RFID/barcode reader scans the desktop captures. grant: "devices": ["scan"]
Vizum.devices.onScan(({ type, value }) => { // type: "nfc" | "rfid"
lookupByCode(value);
});
printReceipt sends a real ESC/POS byte stream to the
printer the user paired in Settings → Devices — no port chooser
per print. onScan surfaces hardware the desktop owns
(an NFC reader via Web NFC, or a keyboard-wedge RFID/barcode scanner), so several
apps can share one device. Both live under the
"devices" grant and count as permission widening on update.
Vizum.settings — admin-ready configuration for free (SDK v3)
// manifest — a typed schema, no UI code needed:
"settings": [
{ "key": "default_journal_id", "type": "many2one", "model": "account.journal",
"label": "Journal" },
{ "key": "alert_days", "type": "integer", "default": 7, "label": "Alert after (days)" },
{ "key": "mode", "type": "selection", "options": ["fast", "thorough"], "default": "fast" },
{ "key": "notify_managers", "type": "boolean", "default": true }
]
// in the app:
const cfg = await Vizum.settings.get(); // {alert_days: 7, …} with defaults merged
await Vizum.settings.open(); // HOST renders the whole form
Vizum.events… // the "settings" event fires with the new values after save
Types: string, integer,
float, boolean,
selection and many2one
(type-ahead picker that searches as the admin — Odoo ACLs apply). Values are
stored per database, validated/coerced server-side, only administrators can
save, and clearing a field falls back to the declared default. This is what
makes apps installable by non-technical admins.
Network host settings. A setting that permissions.net
names with $setting:<key> is a network host the app may
call and send $secret: credentials to, so it is the administrator's
decision, never the app's. Only the host dialog (Vizum.settings.open())
can write it, and only what the administrator typed: the manifest default
of such a key shows as a placeholder and is not stored until the admin enters it, and the dialog
adds a fixed warning beside the field. Vizum.settings.set refuses the
key with an AccessError. When an update newly names a
setting as a network host, the value stored under that key before is cleared on approval:
the administrator enters it in the dialog.
Vizum.secrets — keys that never live in your zip (SDK v3)
// manifest: "permissions": { "secrets": ["openai_api_key"], "net": ["api.openai.com"] }
if (!await Vizum.secrets.exists("openai_api_key")) {
await Vizum.secrets.configure("openai_api_key"); // HOST dialog — admin types it
}
// use it WITHOUT ever seeing it: the server substitutes $secret:… after
// the allowlist checks, so the value never reaches the browser at all:
const r = await Vizum.net.fetch("https://api.openai.com/v1/chat/completions", {
method: "POST",
headers: { Authorization: "Bearer $secret:openai_api_key" },
body: { model: "gpt-4o-mini", messages: [...] },
});
The vault is write-only from the app's side: names()
/exists() only reveal what is configured, and
configure() opens a host-rendered password input the
iframe can neither draw nor read (cancelling rejects with
Error("UserDenied")). Values are encrypted at rest
(Fernet keyed off database.secret) and adding a
secret in an update is permission widening — re-approval required.
This is what makes serious integrations shippable: HubSpot sync, WhatsApp
connectors, AI assistants — with zero tokens inside the .vapp package.
Vizum.oauth — Google/HubSpot/anything OAuth2, per user (SDK v3)
// manifest: "permissions": { "oauth": ["google"] }
// 1) once per database, an admin registers the provider (System Parameters →
// vizum_apps_platform.oauth_providers): auth_url, token_url, api_base,
// client_id, client_secret, scopes.
if (!await Vizum.oauth.connected("google")) {
await Vizum.oauth.connect("google"); // host popup → provider consent → done
}
const r = await Vizum.oauth.fetch("google", "/calendar/v3/users/me/calendarList");
console.log(r.json.items);
Connections are per user (your calendar, not your colleague's) and the
whole token lifecycle lives server-side: the code exchange uses the client
secret inside Odoo, tokens are stored encrypted, refreshed automatically a
minute before expiry, and fetch() joins your path
to the provider's registered api_base — there is no
way to point it anywhere else. Nor can oauth.configure
move a built-in provider (Gmail, Outlook, OneDrive, Google Drive, the social
networks) off its own hosts: an endpoint may change its path — a Microsoft
tenant, an API version — but never its host, and a provider of your own keeps
the endpoints it was stored with. The callback round-trip is HMAC-signed and
expires in 10 minutes. Combined with Vizum.secrets
this unlocks real connectors: Google Calendar ↔ Odoo scheduler, HubSpot sync,
WhatsApp dashboards — with zero credentials in the package.
Changing a provider (5.156). The registry holds one entry per provider for the
whole database, shared by every app and every connected account, so
oauth.configure never changes it on an app's say-so: the
desktop shows an administrator what the change does — client ID, secret set or replaced,
scopes added and removed, endpoints moved, connected accounts, other apps — and stores it
only once confirmed. Cancelled, it rejects with USER_CANCELLED;
whoever confirms, the server refuses a new client ID or a removed scope while accounts hold
tokens. Rotating the client secret is allowed and keeps every connection. Like
identity.confirm's, the dialog opens only over the app's
active window, one at a time, after leaving full screen; asking again right after a cancel
rejects with RATE_LIMITED. Solo and the
native host reject configure. See
OAuth provider configuration.
The callback address. The provider console must allow
<web.base.url>/vizum/oauth/callback, and
Vizum.oauth.status() reports the exact value as
redirect_uri on each provider row — show that one instead of
building it. Google refuses private names such as .local: an
administrator can list other addresses Odoo is reached at in the system parameter
vizum_apps_platform.oauth_redirect_origins (for example
https://localhost:8444). A connect started from a listed
address uses that address for its callback, signed into the round-trip; any other host
is ignored, and web.base.url never moves.
Meta (SDK 5.119): an administrator can configure Facebook and Instagram with
the same META_APP_ID and
META_APP_SECRET, then call
Vizum.oauth.connect("facebook", {shared:true,
related:["instagram"]}). One signed consent creates the Facebook Page
connections and the linked Instagram Business connections. Existing
oauth.fetch("facebook", ...) and
oauth.fetch("instagram", ...) calls remain unchanged.
The pair is one Meta app: Facebook and Instagram must use the same Meta app ID
(client_id), and a related consent across two Meta apps is
refused at connect. The consent requests the union of both providers' configured
scopes, each once; scopes may be separated by commas or spaces. When Facebook connects
but no Instagram Business account does (none is linked to the Pages, or the consent
did not grant the Instagram permissions), the callback page reports the failure
(Meta authorized Facebook, but returned no linked Instagram Business account)
instead of Connected, and connect() rejects with an
error naming Instagram; the Facebook connections are kept.
Tooling — typings, mock, schema (SDK v2.1)
| File or call | What it gives you |
|---|---|
/vizum_apps_platform/static/sdk/vizum-sdk.d.ts |
TypeScript definitions for the whole SDK surface. |
…/sdk/vizum-sdk-mock.js |
Drop-in mock for unit tests (node/jsdom) — override any method, seed
VizumMock.kv, capture VizumMock.log. |
…/sdk/manifest.schema.json |
JSON Schema for CI validation of your manifest. |
Vizum.capabilities() |
{platform, features[]} — feature-detect instead of version-sniffing. |
Vizum.events — app ↔ app
// manifest: "events": { "emit": ["pomodoro.done"], "listen": ["pomodoro.done"] }
Vizum.events.emit("pomodoro.done", { task: "Write docs" });
Vizum.events.on("pomodoro.done", (p) => Vizum.ui.notify("Done: " + p.task));
SDK & server
Server-side code — hooks, triggers, guards and the platform's contracts PRIVILEGED
Related copies. Odoo copies a field's groups into every related field during setup, before the platform adds the negation. The platform therefore appends the negation to every related field whose chain ends at a restricted field, however long the chain (res.company.website, account.move.line.ref, mail.followers.email), and restores them the same way. The consequence is the rule of this mode: a report that prints a related copy as the member (the company's website in a report header) fails for the member too. A computed field has no groups to follow, so the validator refuses the field while any computed field that is not related depends on it, directly or through other computed or related fields (secret, then a non-stored middle field, then a stored copy), stored or not: a stored or compute_sudo field holds the value, and a non-stored one returns it on Odoo 17 and 18 once a sudo() read has filled the cache. A name search of ANY model (_rec_names_search, dotted entries such as partner_id.name resolved) that matches on the field refuses it too.
A vApp runs in a sandboxed iframe, and anything enforced only there can be bypassed by opening the same record in Odoo's own form. A rule that must hold, such as "only the owner may share this" or "an approved request cannot change", has to live on the server. This page covers the server side of a vApp: where its code runs, as whom, what it receives, and the platform contracts it can rely on.
For the capability each feature needs, see Platform capabilities (generated from the platform's own notes). The TypeScript side is in @vizum/vapp-types (vizum.d.ts).
1. Where server code lives
Server code is Python, declared in the manifest under native.script.hooks:
"permissions": { "logic": "script" },
"native": {
"script": {
"hooks": [
{ "name": "approve", "code": "..." },
{ "name": "guard_share", "code": "...",
"trigger": { "model": "x_vzm_sheets_doc", "on": "write", "enforce": true } }
]
}
}
permissions.logic: "script"is privileged: an administrator approves it at install time, and the code is AST-vetted at install and again on every run. The code is statements and, from 5.62, top-level functions (see "Functions, a shared library, and the time budget"). There are no imports,lambda,class,withormatch, and no underscore attributes (V._envis unreachable). Loops are allowed, which is why every run has a call budget and, from 5.62, a time budget.- The only rich global is
V:V.orm,V.db(5.33),V.data,V.files(5.27),V.net,V.oauth(5.26),V.ai,V.vector(5.40, read-only; 5.83:shared_namespaces()),V.webhooks(5.67:ack,fail,deliveries— settle what a job drained from itshook_*queue, reliability.md),V.json,V.sha256(5.75),V.tr(5.77),V.identity(5.79:consume(token, action)spends a step-up confirmation, identity-step-up.md),V.log,V.fail,V.uid(),V.is_service_user()(5.81),V.offline_replay()(5.110), the time helpers (V.now_odoo,V.add_minutes,V.minutes_between,V.clock), andV.commit()in jobs only (5.28). Each run has a call budget: 50net, 10aiand 20vectorcalls. Since 5.67 a script'sV.netandV.files.putalso count against the per-app budgets an administrator sets (QUOTA_EXCEEDED, reliability.md), and a script'sV.files.putcannot passsha256.V.files.public_url(name, expires_in=None)takes the browser'sexpires_in(seconds, 60 to 2,592,000, default one day; the sameVALIDATION_FAILEDoutside that range); a platform from before VZ-SEC-40 has the one-argument form only and raisesTypeErrorfor the second, never a link of the wrong length. AV.files.putover an existing name ends that file's public link, whatever the bytes: mint a new one after the overwrite (files.md). Since 5.74V.net.fetch_image(url, {max_bytes})downloads one image (redirects re-checked, byte cap, PNG/JPEG/GIF/WEBP only; net.md) and counts as onenetcall. Since 5.138V.net.provider(provider, op, args)signs in to a cloud server-side (hvac_cloudtoday; native methods, hooks and jobs only;permissions.providers; net.md); detect it in native-method code withhas_attr(V.net, "provider"), since the builtinhasattris undefined in the sandbox). Each HTTP request it makes counts as onenetcall. V.orm.readandV.orm.search_readreturn a binary or image field (image_1920,datas) as a base64str, orFalsewhen it is empty, on every Odoo line — the same valueVizum.orm.readgives the iframe. Odoo itself reads it asbyteson 17/18/19 and as{content, size}on 20; the platform normalises it, so a script never branches on the line. Abin_sizeread keeps Odoo's size label. Every value is loaded in full, so a script that only needs to know whether a file exists, or how big it is, reads withcontext={"bin_size": True}instead of pulling the bytes.- A hook ends by setting
result, which is whatVizum.script.runresolves to.
A hook runs in one of three ways:
| How it runs | Declared by | Runs as |
|---|---|---|
App-invoked: Vizum.script.run(name, payload) from the iframe | the hook alone | the calling user, floored by that user's record rules |
| Trigger: on a create, write or (5.56) delete of a model | trigger: {model, on, enforce?, fields?} | the app's job user |
| Job: on a schedule | jobs[]: {name, every_hours | cron, script: "<hook>"} (max 5 jobs) | the app's job user |
The job user is job_user_id, or the installer by default. The platform refuses to run a trigger or a job as the superuser, an archived user or a portal user. It logs the refusal and records it in the app's own audit trail. A job is not an ir.cron of its own: one platform cron dispatches every app's jobs, so finding no cron named after your app is expected.
Viewing and changing the job user (5.130)
An administrator sees and sets the job user in Settings › App Permissions › Job user of the app, or by calling vizum.app api_get_app_job_user(key, search) and api_set_app_job_user(key, choice) (both administrator-only, checked on the server). A vApp cannot call them: both are in the bridge's forbidden call: targets, so a manifest that asks for call:api_set_app_job_user or call:api_get_app_job_user is refused at install, and a grant that predates the rule is refused at run time, even in an administrator's session. Who an app's server side runs as is the administrator's decision, made in Settings.
api_get_app_job_user returns the job user (id, name, login, kind: service, installer, service_of_another_app or other), the installer, the effective user and its source, a health (status ok, warning or error, with the reason and the lists of errors and warnings), what the app needs (the groups, and admin_required), whether a service user is available and the candidates, each with eligible and, when it is not, the reason.
api_set_app_job_user takes choice: a user id, "service" (the app's own service user, made now when the manifest asks for one) or "default" (the platform's own choice again: the service user when the manifest declares one, otherwise the installer). It refuses, naming the reason:
- the superuser, an archived user, a portal or public user;
- another app's service user;
- a user outside the company the app is restricted to;
- a user who holds an administrator group while the app declares no model that needs one;
- a user who lacks a group the app declares it needs;
- a user who cannot do what a job or trigger hook is proven to use on Odoo's own models (see «A job identity can never break silently» below);
- any user but the service user, for an app that declares one (see below).
A job identity can never break silently (platform fix, 2026-10-22)
Who installed an app is not who runs it. Four rules keep an app's server side from failing, or running as somebody nobody chose, without anyone being told.
- A shell install never makes an administrator the identity. When the superuser installs (
odoo shell, a script),installer_idis a person recorded for the audit trail (base.user_adminwhen it is active, internal and still an administrator and, for a company-restricted app, works in that company; otherwise the first such member ofbase.group_system) andinstaller_autois set. The runners (job_user_id, else the installer) never fall back to an installer that is only recorded. An app that declaresnative.service_userkeeps its service user; any other has no job identity until an administrator picks one, health is anerror(«needs a job identity») and the administrators of the app's company are notified at install with the users the setter would accept. A database with no administrator records no installer and logs it: nothing is blocked. An upgrade does the same for rows already installed that way (installer uid 1 or empty, no job user): a person for the audit trail (platform:job_user, modeinstaller,runs_as: false), never an identity. - Health judges the native access a job proves it uses.
permissions.ormis one allowlist with two users: the app's frontend calls it as the *viewing* user andV.ormin a hook calls it as the *job user*, so a declaration alone does not say who exercises it. The platform treats a(model, op)as the job user's only when a hook that a job runs (jobs[].script) or a trigger fires, or the script library such a hook can call, contains a literalV.orm.<op>('<model>', ...). For those pairs it asks Odoo's model access (has_access; groups and implied groups; record rules are not evaluated) as the candidate user: a miss is anerrornaming the model, the op and the Odoo group that grants it, andapi_set_app_job_userrefuses it. A declared pair not proven that way, acall:<method>op and a model this database does not have are listed aswarningsand never refuse the setter. A model your hook uses without a literal inV.orm(a native method, a computed name) is not seen: write the literal or grant the access.needs.nativelists the declared pairs withserver_side. - Announcements, once per finding. After every install, update and approval, and daily, each ready app's identity is judged. An
errorsends a high-priority Desktop notification, and an administrator identity the app does not declare it needs a separate low-priority one, to the administrators of the app's company. A notice is sent again only when the finding changes (read or not) or clears and returns. The app shows a red Job user needs attention badge (amber for a warning) in Settings › App Permissions (job_identity_statusinapi_admin_app_list). - At run time. An
AccessErrorof a job or trigger hook is logged with the job user and the declared operations it lacks; the end user who fired a trigger sees only the operations and where an administrator fixes it (Settings › App Permissions › Job user), never the identity's login.
An app with a service user runs as it and as nobody else. When the manifest declares native.service_user: true (or a field only the app's server side writes), the job user is the app's service user. The setter refuses every other user, and so does a direct write({"job_user_id": ...}) on the app's record (a ValidationError); the platform's own assignment of the service user passes. Before 5.130 an administrator could put a person, an administrator included, in that field, and every guard that decided "the writer is the user the hook runs as" then trusted that person's writes. A row that still names a person from before is shown in Settings as degraded (health.status error, with the reason) and one click on «Use the app's service user» (api_set_app_job_user(key, "service")) heals it; an update of the app heals it too.
What the app declares it needs is read from native.models[].access: base.group_user and every non-administrator group a model names (the groups the service user receives), and an administrator when some model of the app grants access to administrator groups only (nobody else can write it, so the job that writes it must run as one). For a person, the setter never grants a group: it reports the missing ones, and the administrator fixes the user in Settings › Users.
The service user's groups on Odoo's own models (platform fix, 2026-10-23). The service user also receives the fewest Odoo groups that cover every native operation the app declares in permissions.orm and a job or trigger hook proves it uses (the same server_side pairs health checks). For each such (model, op) the platform reads which groups grant it in this Odoo version's own access records (ir.model.access, or ir.access from Odoo 20) and:
- never chooses an administrator group, a group that implies one, or a portal/public group;
- drops a group whose name or xmlid says «administrator»/«manager» when a «user» group grants the same op;
- picks the smallest set that covers every pair (one group that covers several models wins: *PoS User* covers reads of
pos.configandpos.order); ties go to the fewest implied groups, then to the lowest group id, so the answer is the same every time.
If an op is granted only to administrator groups, «Use the app's service user» is refused, naming the model and the op, and no user is made: the platform never makes an administrator of an app. Ask for less in permissions.orm, or keep that work in the frontend, where it runs as the viewing user.
Consent. Choosing the service user agrees to the service user, not to groups, so the groups are shown before saving: api_get_app_job_user returns them as service.grants (with service.refusals and service.missing), the panel says «The service user will be given: …», and Save sends their ids back as the third argument of api_set_app_job_user(key, "service", grant_groups). The platform computes the set again and refuses when it differs from what was shown, or when groups are due and none were confirmed. Every grant is audited as platform:service_user_groups (the groups, the service user, the administrator who confirmed).
The extra rights stay unusable. A group carries more than the op that made the platform choose it (*PoS User* may also write orders). The app's server code reaches Odoo only through V.orm, which runs only the ops the manifest declares, so a write the app did not declare is refused by the bridge even though Odoo's access control would allow it.
Existing service users. A service user made before this fix lacks those groups. Health reports it as an error («the service user needs: …») and the administrators are told, with the path *Settings → App permissions → <app> → Job user → Use the app's service user*. Neither the daily check nor an update grants them on its own: a group is a grant of rights, so an administrator confirms the exact set in the panel.
Service users are archived (platform fix, 2026-10-23).
The platform creates an app's service user as archived (active = False), internal (not portal), with no password, and with the login vzm-service.<app>@apps.vizum.invalid. When another app's service user already holds that short login (two keys that end alike), the second app gets a login written from its whole key instead; each app's user and the platform's mark on it are its own, and one app never runs as another app's service user. An archived user does not count as an Odoo user licence: Odoo's licence report (publisher_warranty) counts active users only. It also cannot sign in.
The runners (jobs, triggers including enforcing ones, device and guest-link routes, schedules, queued tasks) accept an archived user only when it is the platform's own service user of that same app: its service login, the platform's mark on it (an xmlid of the platform module), no password, internal. Any other archived user stays refused, including another app's service user.
Odoo works with an archived acting user: chatter posts keep the service user as author, activities can be scheduled, ir.sequence and record rules evaluate normally. An archived user is not auto-subscribed as a follower of what it creates.
An update never re-activates it; the platform refuses to unarchive one from any screen or API; health warns when one is active ("counts as an Odoo user licence") and saving "Use the app's service user" archives it.
Existing service users are archived by the upgrade, one audit row platform:service_user_archived per app.
Settings > App Permissions > Service users lists them (app, groups, archived or not, last job run) with "Re-apply least-privileged groups" and "Open in Odoo".
Every change is written to the app's audit trail as platform:job_user (the user before and after, the mode and who changed it), and the platform refreshes what it caches about the job identity at once, so triggers, jobs and device routes use the new user from the next run without a restart. "@server" (a field only the app's server side writes) means the current job user.
Jobs and permissions.data (5.81)
Before 5.81 a job required permissions.data: "global", because each tick the platform wrote (job:<name>) lives in the app's shared store. Since 5.81 an app with jobs may declare "data": "user". The platform then writes no tick and sends no ping for its jobs (the schedule, Vizum.jobs and the audit trail do not need one), and the job's own V.data is the private scope of the user the job runs as, the app's service user. A job cannot read a human user's private keys; the keys it writes are its own, and no user reads them back through Vizum.data (they are the service user's, not theirs). To hand data to people a job must write it to a model or to a global store. A job of an app without a service user runs as the installer, and its V.data is then that installer's scope. An app that declares jobs with data: "user" must declare min_platform 5.81; the install gate and vzm check enforce it (an older platform refuses the combination), and Vizum.supports("jobs-user-data") tells a screen whether the host serves it.
2. Triggers: the payload
A trigger hook receives rec once per written (or, with on: "unlink", deleted) record. The TypeScript mirror is Vizum.TriggerRec.
| Key | Since | Content |
|---|---|---|
id | — | the record id |
event | 5.32 | "create", "write" or (5.56) "unlink" |
changed | 5.32 | sorted names of the written fields; [] on a create or a delete |
old | 5.32 | pre-image of the written fields on a write; {} on a create; on a delete (5.56), every stored content field as the row is before the deletion |
new | 5.54 | post-image: the written fields on a write, every stored content field on a create; {} on a delete |
uid | 5.54 | env.uid of the write. It is 1 when server code writes as the superuser (a cron, a migration): a guard that decides by uid must handle 1 on purpose |
owner | 5.54 | the row's create_uid, or False |
installing | 5.56 | True while the app's own install or update runs (its migrations). The guard is not skipped then: it is told, and decides |
su | 5.81 | True when the write that fired the trigger ran in a superuser environment (env.su), False when it ran in the user's own environment. See "Who wrote: uid and su" below |
The platform reads old and new with its own rights and applies the same ceilings to both:
- Only fields the app's read grant on that model covers are included.
- Fields the app hides from the hook identity (VZ-SEC-23, see §4) are never included.
- A relational value is ids: an id (or
False) for many2one, a list for x2many. - A date or datetime is an ISO string.
- A binary is
{"__binary__": size}. - Text over 64 KB is
{"__text__": length}.
Decide from the payload, do not re-read the row. The hook runs as the job user, whose record rules may hide the very row it guards (a private document is visible only to its owner). This is why 5.54 added uid, owner and new. A hook that reads them sets min_platform: "5.54".
# guard_share — only the owner may change who a document is shared with
if "x_shared_with" in rec["changed"] and rec["uid"] not in (rec["owner"], 1):
V.fail("Only the owner can change who this document is shared with.")
Who wrote: uid and su (5.81)
sudo() keeps the caller's uid, so rec["uid"] is the same for a person's own write and for a write that one of your methods made with sudo() on that person's behalf. rec["su"] tells them apart:
| The write that fired the trigger | rec["uid"] | rec["su"] |
|---|---|---|
a user's own write (the form, V.orm, an RPC) | that user | False |
a method of your app that wrote with self.sudo() / .sudo().create(...) | the user who called the method | True |
platform or Odoo code that wrote with sudo() | the user in that environment | True |
the superuser (a cron, a migration, uid 1) | 1 | True |
Exactly: su is env.su of the records that were written, read by the platform when the write fires the trigger. It is not "the user is an administrator" and not "the app's service user wrote it" (use V.is_service_user() for who the *code* runs as; the service user's own write has su False unless it also called sudo()). The app cannot set it: the platform assigns it on every trigger run, after any context the caller built.
# guard_issue — a ticket is created only by wl_issue, which writes with sudo()
if rec["event"] == "create" and not rec["su"]:
V.fail(V.tr("Tickets are issued from the counter or a screen."))
A hook that reads rec["su"] must declare min_platform 5.81 (the install gate and vzm check refuse it below: on an older platform the key is missing and the guard raises KeyError). Vizum.supports("trigger-su") tells a screen whether the host serves it.
Was the writer the app's service user: svc (5.130)
rec["svc"] is True when env.uid of the write is this app's own service user (the passwordless account the platform made for it; a look-alike login or another app's service user does not count) and False for anyone else, an administrator set as job user included. A guard that lets only the app's server side write a record reads it directly:
# history is written by the app's server side only
if not rec.get("svc") and not rec["su"]:
V.fail(V.tr("History is written by the system."))
It replaces V.uid() compared with rec["uid"] plus V.is_service_user(). Read it as rec.get("svc"), never rec["svc"], unless the app declares min_platform 5.130: on an older platform the key is missing, rec["svc"] raises KeyError, and rec.get("svc") is None (falsy), so the guard fails closed. The install gate does not check this for you.
3. A trigger hook that raises aborts the write
A trigger runs inside the transaction of the write that fired it. Any exception it raises, including V.fail(msg), propagates out of Odoo's automation and rolls the triggering write back. The user sees msg as the error. This makes a trigger a guard, and it has three consequences:
- Nothing the hook writes on the failing path survives. An audit row, a counter or a notification written before
V.failis rolled back with the write. To record a refusal, do it from somewhere that does not fail, such as the iframe after it catches the error, or a job. - An ordinary hook that crashes also blocks the write. A
KeyErrorin a numbering hook stops every save of that model. Write non-guard hooks defensively. enforce: truemakes the guard fail closed. Without it, a hook that *cannot run* (the app is not ready, or its job user is missing, archived, portal or the superuser) is skipped, and the write proceeds unguarded. With it, the write is refused instead. Declare it on every hook that exists to refuse writes, and never on one that grants, indexes or numbers.enforceneeds 5.32.
Watching fields (5.124)
Odoo writes a record many times in one operation: a stock move is written 13 to 21 times by a Validate, and a write trigger runs its hook on every one of them, once per record. A hook that only cares about some fields names them:
{ "name": "move_guard", "code": "...",
"trigger": { "model": "stock.move", "on": "write", "fields": ["state"], "enforce": true } }
The hook then runs only for a write that changes the value of at least one of the fields (they become the trigger fields of the app's base.automation). A write that leaves all of them as they were, or writes only other fields, does not run it. For Location Restrictions this took a Validate from 28 hook runs per move to 2.
- Without
fields, every write runs the hook, as before. - Only with
on: "write"or"create_or_write". A create still runs acreate_or_writehook. Acreateorunlinktrigger refusesfields: there is nothing to watch. - 1 to 64 distinct field names of the model (on your own models,
x_<name>). A field the model does not have refuses the install, because a guard that watches a field that does not exist would never run.vzm checkchecks your own models' fields; the install checks the rest. - An update applies the new list to the existing trigger.
rec["changed"]still lists every field the write named. - A guard watches every field it decides on: the hook does not see a write that changes only a field it did not name.
fieldsneedsmin_platform5.124.Vizum.supports("trigger-fields")tells a screen whether the host serves it.
Guarding a deletion (5.56)
on: "unlink" runs the hook before the record is deleted, for every ORM deletion: the Odoo backend (an administrator included), sudo(), crons, odoo shell, and the app's own Vizum.orm.unlink. A hook that raises aborts the deletion, and enforce: true fails closed exactly as for a write. One hook has one event, so a model guarded on write and on delete declares two hooks.
{ "name": "pay_group_delete_guard", "code": "…",
"trigger": { "model": "x_vzm_contador365_pay_group", "on": "unlink", "enforce": true } }
# pay_group_delete_guard — a group with members is not deleted
if V.orm.count("x_vzm_contador365_employee_link", [["x_pay_group", "=", rec["id"]]]):
V.fail("Move this group's employees to another group first.")
What a delete guard never sees. Deletions that PostgreSQL performs itself do not go through the ORM, so no trigger runs for them:
- the link of a many2one whose target is deleted. Every vApp many2one is
set nullat the database, so the field is simply cleared; - rows removed by an Odoo
ondelete="cascade"field; - the link rows of a many2many.
Guard the record that is actually deleted (the parent) and decide there from what still refers to it. A guard on the child does not run when the parent goes.
Uninstalling the app is never blocked by its own guard. The platform removes the app's automations before its models, and a model's table is dropped, not unlinked row by row.
A vApp that declares on: "unlink" sets min_platform: "5.56"; an older platform refuses the manifest at install.
4. Security contracts the platform enforces
These rules hold whatever the vApp does, so do not build around them.
- Portal users have no bridge (VZ-SEC-21). A portal (share) session cannot call the vApp bridge: no
orm,data,db,run_hookor install. The single exception is a declared route (5.53, below). A route page receives a run-token minted for that app, route and user, and reaches only a minimal session,ormwithin the app's grant (bounded by the visitor's own record rules), the route's own json hooks (with its rate limit, size cap and audit), watch hashes, domain evaluation and token refresh. - Hidden fields stay hidden along every path (VZ-SEC-23). A field the manifest hides with
groups(on anative.models[].fields[]entry) is protected only by the bridge, because Odoo does not applygroupsto a manualx_field. The bridge resolves every hop of a dotted path (x_rel.x_secret) in domains,order,fields,groupby, aggregates,any/not anysub-domains andwatchhashes, and refuses the first hidden field on any model along the path. Do not expect a dotted path to reach a field the direct name cannot. x_company_idis the platform's (5.51). On acompany_scopednative model the platform synthesizesx_company_id; never declare acompany_id.- A create that names none is stamped with the user's current company.
- An explicit value on create, write, a nested line update or a copy default must be one of the user's active companies (for a hook, the job user's companies). Anything else raises a ValidationError.
- Writing
False(making the row global) is refused. - Before 5.51 an explicit company on create was replaced silently, and a write was not checked at all.
- An app's own models need no orm grant (5.49). The server always granted an app its own native models, and the fields it added to shared models, with no
permissions.orm. From 5.49 the browser agrees:connect()returnscaps.orm_native: true. The platform computes it; declaring it in the manifest does nothing.
5. Naming: what your fields are called in Odoo
| You declare | Odoo name |
|---|---|
A model in native.models, "name": "doc" | x_vzm_<app>_doc |
A field of your own model, "name": "status" | x_status, not x_vzm_<app>_status |
A field added to a shared model in native.fields, {"model": "res.partner", "name": "tier"} | x_vzm_<app>_tier |
company_scoped: true on your model | x_company_id (synthesized) |
The x_<name> rule for your own model's fields is the one people get wrong. A domain, a fields list or a V.orm call that uses x_vzm_<app>_status on your own model finds nothing. The platform also mirrors your model's rec_name into x_name. rec_name must name a declared char or text field of the model: a selection, a number or a relation is refused at install, and vzm check reports it first.
Showing your fields on Odoo's forms: native.views (place, a page of your own in 5.91, strict in 5.93, several pages in 5.94)
A field you add to a shared model in native.fields appears on that model's views only where native.views puts it. One entry per model and view type (form, list/tree, search, kanban); the platform compiles each into ONE inherited view, vzm_<app>_<model>_<type>.
"views": [
{"model": "res.partner", "type": "form",
"place": [{"field": "tier", "after": "function"}],
"page": {"name": "salesman", "label": "Salesman", "fields": ["klass", "visits"],
"after_page": "sales_purchases", "groups": "@manager"}}
]
placeputs a field next to an anchor of the form (after/beforean Odoo field name). An anchor the form does not have is skipped with a warning and the install goes on. Only the form's own fields count: a field that exists only inside a one2many's inline list or form (onsale.order:product_id,price_unit,tax_ids, insideorder_line) is not an anchor of the form (before 5.91 such an anchor aborted the whole install). From 5.93 every skipped placement is reported (below) and can be made an error (strict).page(5.91, form only) gives your fields their own tab of the form's notebook, so they do not crowd Odoo's layout. It becomes<page name="vzm_<app>_<name>" string="<label>">with one group holdingfields:name: snake_case, 2–32 characters.label: 1–64 characters of plain text (no{},<>, control characters or line breaks). It is a msgid of youri18n/<lang>.json:"Salesman": "Vendedor"translates the tab.- A lowercase
t-at the start of a word is refused in a label, sot-shirtsorSizes t-xis rejected:t-is the prefix of QWeb directives (t-esc,t-out,t-att-…), and a label must never read as one wherever it is shown or translated. Inside a word it is fine (Short-term), and so is a capitalT-(T-shirts), since QWeb directives are lowercase.vzm checkand the installer apply the same rule. fields: 1–30 names you declared innative.fieldsfor that model. A field is on the page or inplace, never both. One page per form.after_page(optional): thenameof the form page to follow (sales_purchases,internal_notes…). When the form has no such page, yours is the last one.groups(optional):"@name"of a group innative.groups; only its members see the tab.- A form without a notebook (
res.partner.category) gets the fields after the last field of its main group, without a tab; with no group at all they are skipped with a warning. The install never fails over a page. - Uninstalling the app removes the page with its view. An update rebuilds the view from the new manifest: renaming the page, removing it or moving fields between
placeandpageleaves one view and one page. - Needs
min_platform5.91 (vzm checkand the installer say so). An older platform ignorespage, and those fields are then on no form. - Where it shows: in the Odoo web client's form of that model, on every host that opens it (an app opened from an Odoo menu, or Odoo itself). A vApp page on Desktop, Solo/PWA or Android reads and writes the same fields through the ORM; it shows no Odoo form, so the page is not part of it.
Several pages on one form: pages (5.94)
One tab of 30 fields is not enough for a model like hr.employee with 36 of your fields. Use pages (a list) in place of page:
"views": [
{"model": "hr.employee", "type": "form", "strict": true,
"pages": [
{"name": "payroll", "label": "Payroll", "fields": ["salary", "bank"], "groups": "@payroll"},
{"name": "balances", "label": "Payroll: balances", "fields": ["vacation", "loans"],
"after_page": "payroll"}
]}
]
- Each page takes the rules of
pageabove. Limits: at most 4 pages per app per form, 30 fields a page, 120 fields in all. Page names are unique in the entry, and a field is on exactly one page (or inplace, never both). - The pages are shown in list order.
after_pagemay name an Odoo page (sales_purchases) or an earlier page of the same list ("after_page": "payroll"); a page that follows itself or a later one is refused. pagestays valid as a one-itempages(it keeps itsmin_platform5.91). Use one or the other in an entry, never both.- All the pages go in the same inherited view, so uninstalling drops them all, and an update that renames, removes, adds or reorders pages rebuilds one view: no orphan view, no duplicate page.
- A page restricted to a group that does not exist on the database is skipped alone (never shown unrestricted); the others stay.
native_views_skippedandstrictjudge each page: one entry per skipped page,kind: "page",field= the page'sname. - A form without a notebook gets every page's fields after the last field of its main group, in page order, without tabs.
- Needs
min_platform5.94 (vzm checkand the installer say so);supports("native-view-pages")is true from 5.94.
Knowing what was skipped, and refusing to skip (5.93)
By default a placement that cannot be made is skipped (you do not lose the install over a cosmetic anchor). From 5.93 you are told, and you can say "not for this view".
- The report.
api_installreturns, andapi_update_preflightpreviews, a NEW keynative_views_skippednext to the keys they always had (an old Desktop or Creator ignores it). Each entry:
{"model": "res.partner", "view": "form", "kind": "place", "field": "visits",
"anchor": "mobile", "position": "after", "reason": "anchor_missing", "strict": false}
reason is anchor_missing (the form has no such field), anchor_in_subview (only inside an x2many's inline list/form), no_base_view (the model has no base view of that type), page_group_missing, page_form_unreadable or page_no_place (a page that has no notebook and no main group). kind is place, or page (then field is the page's name). An empty list means everything was placed. The platform keeps the list on the installed app (api_installed_detail returns it as native_views_skipped to administrators only; anyone else gets []), so a screen can show it later. api_update_preflight lists every skip, strict ones too (strict: true, which also refuse the install through blocking); if the plan itself cannot be made it holds one {"kind": "error", "reason": "plan_unavailable", "message"} entry instead of staying silent.
- The form it lands on. A
native.viewsform entry goes on the model's DEFAULT form (default_view: its primary form of lowest priority), which is not always the form people open. Onres.usersthat isbase.view_users_simple_form, the new-user dialog, not Settings › Users (base.view_users_form).api_update_preflightlists each such entry innative_views_warnings(administrators only, likenative_views_skipped):{"model", "view": "form", "reason": "not_main_form", "resolved", "main"}, the two views' xmlids; the view inspector reports it asVIEW_NOT_MAIN_FORM. It is a warning: the install places the fields as before. - Choosing the form (SDK 5.123):
"target". A form entry may say"target": "main": it then extends the form the model's main window action opens (the firstact_windowof the model a menu opens, else its first one), when that is a primary form of the model. Onres.usersyour fields land on Settings › Users (base.view_users_form)."target": "default", or no key, is the default form, as before. When no main form resolves (the model has no window action, or its action names no form) the entry is skipped asno_base_view, and a"strict": trueentry refuses the install or update. An entry with"target": "main"never raisesnative_views_warnings/VIEW_NOT_MAIN_FORM."main"is for a form entry and needsmin_platform5.123 (an older platform ignores the key and uses the default form);vzm checksays so. Featurenative-view-target.
"min_platform": "5.123",
"native": {"views": [{"model": "res.users", "type": "form", "target": "main",
"place": [{"field": "badge", "after": "login"}]}]}
It names only what your manifest asked for and whether the form has that anchor: nothing a user of the app's own install API could not already see.
strict.{"model": "res.partner", "type": "form", "strict": true, "place": […]}makes that entry all-or-nothing: if any of its placements (or itspage) would be skipped, the install or update is refused before anything is written, with an error naming the model, the view type, the field, the anchor and the reason. A strict update that fails leaves the previous version installed and untouched, andapi_update_preflightlists the problem inblocking(ok: false).strictis per view entry, not per app, so a critical form can be strict while cosmetic ones stay soft. Apageon a form with no notebook is not a skip (the fields go after the main group, as in 5.91), so strict accepts it. Absent orfalse= skip as before. An own model has no base view until the app creates one: a strict view on an own model is refused.- One entry per
(model, type). Twonative.viewsentries for the same model and type are refused (vzm checkand the installer): one inherited view is built per pair, so the second would be ignored. Put every placement and the page in one entry. - A strict refusal depends on this database's views, not on the package: the store's automatic update waits (no attempt counted, no blacklist), and approving several apps at once approves the others and leaves this one waiting.
- Needs
min_platform5.93 forstrict: true(vzm checkand the installer say so): an older platform ignores the key and would skip silently, which is whatstrictforbids.
Who sees your columns on Odoo's models and views (5.107)
A column you add to an Odoo model, and everything you inject into an Odoo view, is for the app's audience:
- the groups you declare in
native.groups; - the people and groups an administrator granted the app: the Desktop «Specific people» list or the groups of a restricted app, the Solo audience and the native-menu audience;
- the settings administrators (
base.group_system).
Everyone else does not see the column in Odoo's lists and forms, in the search panel (fields, filters and group-bys that name it), through fields_get, read, search_read, a search on it or an export; the page, filter or button you inject disappears with it. Before 5.107 every user saw them (ABC-XYZ's ABC/XYZ classes in the product list, measured on Odoo 19). Odoo enforces it on the field itself (its groups) and the injected nodes carry the same groups.
| You declare | Who sees it |
|---|---|
native.fields entry, nothing else | the app's audience (the default) |
"groups": ["@manager"] | exactly those groups and the settings administrators (VZ-SEC-23) |
"visibility": "all" | every user, as before 5.107; its native.views placements too |
a native.ui inherited view with "visibility": "all" | the nodes it inserts stay everyone's; the columns inside still follow their own rule |
an app that replaces an Odoo module (native.replaces) | every user, as the module's columns and views were (RULE V); a field opts in with "visibility": "app" |
"fields": [
{"model": "product.template", "name": "abc", "type": "selection", "selection": [["A", "A"]]},
{"model": "product.template", "name": "barcode2", "type": "char", "visibility": "all"}
]
- Mark
"visibility": "all"on a column every user of Odoo needs (a reference other modules or reports read, a value your UI reads for users the app is not granted to). Without it, a user outside the audience who opens a screen of your app that reads the column getsACCESS_DENIED. visibilityis"app"(the default) or"all";"all"withgroupsis refused, and so isvisibilityon anything but an inherited view innative.ui."all"needs nomin_platform: an older platform ignores the key and shows the column to everyone, so an app can mark its columns before its instances upgrade."app"needsmin_platform5.107 (an error below it). A column with novisibilityand nogroups(the default is the audience) raises no need, but withmin_platformbelow 5.107vzm checkwarnsFIELD_AUDIENCE_FLOOR: an older platform ignores the audience and shows the column to every user. Setmin_platform5.107 if the app relies on the column being visible only to its groups, or"visibility": "all"to say it is meant for everyone. The warning does not block a publish. An app that replaces an Odoo module (native.replaces) defaults to"all"and gets no warning.- Duplicating a record stays possible for a user outside the audience: the column is copied for them. A create by such a user cannot set the column (Odoo 17/18 allowed it; the platform refuses it on every line).
tracking: trueon a restricted column: Odoo 17–19 keep the change in the chatter for the audience only. Odoo 20 writes tracked changes into the message text, which every reader of the record sees, so there a restricted column is not tracked.- When an administrator changes who may use the app, the views are re-tagged and the registry is rebuilt; the upgrade that brings 5.107 does it for the apps already installed.
Vizum.supports("native-field-audience").- A read with no
fieldsand a hidden column. When a field is hidden from the caller (groups, 5.59/5.107),Vizum.ormsearch_read / read with nofieldsreturns every field of the model the caller may read, minus the hidden ones, exactly as Odoo's own implicit read does. It no longer fails with anAccessErroron an unrelated Odoo field that carriesgroups. A field you name is still judged by name: a hidden one is refused, one Odoo refuses the user is Odoo'sAccessError. - A hidden column is hidden under every name it has. With
native.aliases: truethe non-x_alias of a hidden column, and a field that isrelatedto one (anative.fieldscopy on another model, whatever its name), are hidden too: refused infields, in a domain, in an order and in aread_group, and left out of a whole read.
A document with lines (5.57)
From 5.57 your own models can hold a document and its lines the way an Odoo module does:
"models": [
{ "name": "order", "fields": [
{ "name": "name", "type": "char", "required": true },
{ "name": "line_ids", "type": "one2many", "relation": "x_vzm_<app>_line", "inverse": "order" } ] },
{ "name": "line", "fields": [
{ "name": "order", "type": "many2one", "relation": "x_vzm_<app>_order",
"required": true, "ondelete": "cascade" },
{ "name": "qty", "type": "float" } ] }
]
one2manyneedsinverse: the manifest name (nox_) of a many2one declared on the model inrelation, which must be one of your own models, pointing back at this one.ondeleteon a many2one:set null(the default, as before 5.57),cascade(the line goes with its order) orrestrict(a master still referenced cannot be deleted). PostgreSQL enforces it, so it holds for the backend,sudoand crons too. A cascade is one of the deletions no trigger sees (§3): guard the order, not its lines.required: the column is NOT NULL. Not for an x2many or a boolean. A required many2one needscascadeorrestrict.- On a shared model a
one2manymay list your rows ({"model": "res.partner", "name": "orders", "type": "one2many", "relation": "x_vzm_<app>_order", "inverse": "partner"}), butrequiredandcascadeare refused there: they would act on records other modules own. - Write lines through the document with Odoo's commands —
[0, 0, {…}]creates,[1, id, {…}]updates,[2, id, 0]deletes — fromVizum.ormorV.orm. A version may add, change or drop any of this; droppingondeletereturns the field toset null. - A vApp that uses
inverse,ondeleteorrequiredsetsmin_platform: "5.57".
Defaults, numbering, unique keys, chatter and computed fields (5.58)
What a module writes in Python, your own models declare. The platform applies it in the ORM, so it holds for your vApp, the Odoo backend and RPC alike:
{ "name": "order", "chatter": true, "unique": [["code"]], "fields": [
{ "name": "number", "type": "char", "sequence": { "prefix": "OT-", "padding": 5 } },
{ "name": "code", "type": "char" },
{ "name": "state", "type": "selection", "default": "draft", "tracking": true,
"selection": [["draft", "Draft"], ["done", "Done"]] },
{ "name": "date", "type": "date", "default": { "dynamic": "today" } },
{ "name": "owner", "type": "many2one", "relation": "res.users", "default": { "dynamic": "user" } },
{ "name": "partner", "type": "many2one", "relation": "res.partner" },
{ "name": "partner_email", "type": "char", "related": "partner.email" },
{ "name": "line_ids", "type": "one2many", "relation": "x_vzm_<app>_line", "inverse": "order" },
{ "name": "subtotal", "type": "float", "aggregate": { "of": "line_ids", "op": "sum", "field": "amount" } },
{ "name": "discount", "type": "float" },
{ "name": "total", "type": "float", "formula": "round(subtotal * (1 - discount / 100), 2)" } ] }
default: a literal of the field's type, or{"dynamic": "today" | "now" | "user" | "company"}. An explicit value always wins.sequence: the field is numbered on create when it is left empty, from anir.sequencethe app owns.unique: real PostgreSQL unique indexes. An install or update refuses while existing rows already collide, and names the value. Empty values never collide. Since 5.68 an entry can also be an object:{"fields": [...], "nulls_not_distinct": true}makes empty values collide too (PostgreSQL 15+), and"name": "code_uniq"names the index<table>_code_uniq, as Odoo names a module's constraint — code that recognises its own duplicate byexc.diag.constraint_namefinds it. Since 5.81 auniqueof acompany_scopedmodel may listx_company_id, the synthesized company column, beside declared fields:{"company_scoped": true, "unique": [["x_company_id", "prefix"]]}is a prefix unique per company (two companies reuse one, one company cannot), and[["x_company_id"]]is one settings row per company. It is the real column, sonulls_not_distinctworks as for any other. Auniquethat names it on a model that is notcompany_scopedis refused (there is no such column), and so is a declared field calledx_company_idnamed in the same list (it would be ambiguous). An app that uses it must declaremin_platform5.81; the install gate andvzm checkenforce it, andVizum.supports("company-unique")tells a screen whether the host serves it.check(5.68, on a model): real PostgreSQL CHECK constraints, each{"name": "quantity_positive", "field": "quantity", "op": ">=", "value": 1}— a declared integer, float or monetary field compared (>,>=,<,<=,!=) with a number. The constraint is<table>_<name>, as Odoo names a module'sCHECK (quantity >= 1), and a write that breaks it fails in the database (anIntegrityError), whoever writes. An empty value passes, as in SQL. An install or update refuses while rows already break it, and says how many; removing the entry or uninstalling drops the constraint. A condition over two fields stays aconstraintsmethod.order(5.68, on a model): its default order, as a module's_order—"order": "code_id, sequence, id", in your field names, each optionallyasc/descandnulls first/nulls last; besides your declared fields it may nameid,create_date,write_date,create_uidandwrite_uid.pair(5.68, many2many between two of your own models):"pair": "manual.model_ids"onmodel.manual_ids, and"pair": "model.manual_ids"back onmanual.model_ids, make the two fields ONE relation seen from each side — linking a manual on the model shows the model on the manual, as two module fields sharing onerelationtable. Each must name the other and point at the other's model. It is set when the fields are created; an update cannot pair two existing fields (their rows live in different tables).chattergives the model messages, followers and activities.trackingposts a field's changes there.formulareads the record's own fields by their manifest names; its dependencies are worked out for you.relatedreads through one of the model's many2one, and can go on from there:order.customer.emailon a line. Steps through your own models use their manifest names; from the first Odoo model on (res.partner, …) use Odoo's names. A related field may itself read through another related field. Add"store": trueto store it.aggregatetotals a one2many (sum,count,min,max,avg). All three compile into compute code the platform writes; your manifest supplies names and operators only.- A vApp that uses any of these keys sets
min_platform: "5.58".
Fields only some writers may set (5.59)
A field of your own model can be reserved, the way a native module keeps a verdict or an approval out of its users' hands:
"fields": [
{ "name": "verdict", "type": "char", "server_only": true },
{ "name": "approved", "type": "boolean", "writers": ["@manager"] },
{ "name": "score", "type": "integer", "computed_by": "score_hook" }
]
writerslists who may set the field on create or write:"@server","@<a group you declare>"or a group xmlid.server_only: trueis["@server"].computed_bynames a trigger hook on this model that computes the field, and is also["@server"].- The ORM refuses everyone else, on every path: your iframe, your invoked hooks (
Vizum.script.runruns as the user), the Odoo backend and RPC. An administrator is a writer only when listed. "@server"is your app's service user. The platform creates it when you declare a server writer, or"native": {"service_user": true}. It is an internal user with no password, so it cannot sign in. It belongs tobase.group_userand the groups your models grant access to. It becomes the app's job user, so your triggers and jobs run as it and may write these fields. Python running as the superuser (migrations,odoo shell) also counts as the server. The service user is archived when the app is uninstalled. It always reaches its own app, even an app restricted to *Specific people* and even when it is not in that list: the app's hooks, guards, jobs and device routes would otherwise be locked out of the app they belong to. The exception is exactly that one user for exactly that app; it opens no other restricted app. It works in the companies of the user who installed the app and mirrors them at all times: a company the installer gains is added to the service user, one it loses is removed, the moment the installer's companies change (not on the next update). A company created later reaches the service user only when the installer has it: a company an administrator creates is NOT one the service user works in until that administrator (or anyone) adds the *installer* to it, from the user form or the company's users; the service user follows that change whoever makes it. When the installer is not a regular active internal user (OdooBot, an archived account), the service user works in the main company only; it never widens. Give the installer the companies first. A hook or job working for another company passesx_company_idexplicitly. The service user is one identity, so a create that names no company is stamped with its *main* company, whatever company the triggering record belongs to. (A later improvement may default a trigger hook to the company of the triggering record; today it does not.)- These keys belong to the app's own models. On a column you add to a shared model (
native.fields, e.g.res.partner)writers,server_onlyandcomputed_byare refused at install (and byvzm check): the runtime enforces them only on your own models, so accepting them would leave the field open to everyone. - The pattern for a protected computed value: the user writes the inputs, a trigger hook (
on: "create_or_write") computes the result withV.orm.write, and the result field sayscomputed_bythat hook. - A vApp that uses any of these keys sets
min_platform: "5.59".
Your app's own groups: native.groups (24 and privilege in 5.118)
native.groups creates real Odoo groups for your app; access, rules, views and team roles name one as "@name". Each entry is {name, label, implies?, adopt?, privilege?}.
- Up to 24 groups (8 before 5.118). A manifest with more than 8 sets
min_platform: "5.118": an older platform refuses it. The labels of one app's groups are unique (Odoo 17/18 keep a group's name unique in its category, and every group of the app sits in one). privilege: the heading a group sits under in Settings › Users. On Odoo 19 a heading (res.groups.privilege) is ONE selection on the user form: an administrator picks one group of it. With every group under the app's single heading, a user could hold one of your roles. Give independent roles headings of their own and keep a chain under one:
"groups": [
{ "name": "technician", "label": "Technician", "privilege": "Access level" },
{ "name": "approver", "label": "Approver", "privilege": "Access level", "implies": ["@technician"] },
{ "name": "admin", "label": "Administrator", "privilege": "Access level", "implies": ["@approver"] },
{ "name": "role_sales", "label": "Sales", "privilege": "Sales role" },
{ "name": "role_support", "label": "Support", "privilege": "Support role" }
]
Each label becomes one heading named <app name>: <label>, shared by the groups that name it; groups without privilege keep the heading named after the app. A heading no group names any more is removed on the update, and every heading goes with the app. The heading's name is not translated (the group labels are). privilege is 1-64 characters and needs min_platform: "5.118".
- Odoo 17 and 18 have no privilege: every group of the app stays under its category, which already shows groups that are not a chain as checkboxes. The key changes nothing there.
- Feature
supports("native-groups-24").
A rule on a user's groups: $groups and {"$group": "@name"} (5.125)
A record rule can ask whether a user still holds one of YOUR app's roles. A Representative reads only the cases of a technician assigned to him who still holds the Distributor role:
"rules": [{
"groups": ["@role_representative"],
"perms": ["read"],
"domain": [
["x_representative_id", "=", "uid"],
["x_technician_id.$groups", "in", {"$group": "@role_distributor"}]
]
}]
<path>.$groupsis the user's groups.$groupsis the LAST hop of a path that ends on a user (res.users):x_technician_id.$groups, oruser_id.$groupsin anative.rulesentry on an Odoo model. A path that does not end on a user is refused at install.{"$group": "@name"}is one of the groups your app declares innative.groups. An xmlid (base.group_user), an id, a list or a group of another app is refused at validation: a rule compares with your own roles only.inornot in, nothing else, and only in a leaf of the rule's own domain (never inside ananyvalue).- Implied groups count. A user who holds a group that implies
@role_distributorholds it. The platform compiles$groupsper Odoo line:all_group_idson 19 and 20,groups_idon 17 and 18, where Odoo writes every implied group into the user's own groups. - A role taken away is taken away at once. Who holds the group is read in the database at every search, so the next search excludes the rows: after
Vizum.team.save, a change on the user form, or removing the user in Settings › Groups, in every worker and without a restart. - A group that no longer exists matches nothing. The rule resolves the group each time Odoo computes it; if it was deleted, the WHOLE rule matches no row (also with
not in), never every row. Odoo recomputes a rule in every worker from that worker's next request (its cache invalidation signal); anot inrule in a request already running when the group is deleted keeps its answer until that request ends.innever matches a deleted group. - A manifest that uses it sets
min_platform: "5.125";vzm checksays so. Featuresupports("rule-domain-group").
When a user gains or loses one of your groups: native.membership (5.127)
native.membership: [{groups: ["@role"], method: "<own model>.<name>"}] with permissions.membership: true: the platform's own res.users / res.groups overrides call your method, once per transaction and after the commit, with the users who gained or lost each listed group (effective membership), and with a reconcile of the current members at install, update and every hour. Only your own groups. Check membership at read time too: V.is_member("@role", uid). Full contract, payload and test helper: native-membership.md.
Your app's team: users and roles (5.60)
An app whose managers add their own agents, drivers or technicians used to need an Odoo module for it. From 5.60 the manifest declares the roles and the platform does the rest:
{ "permissions": { "team": true },
"native": {
"groups": [{ "name": "manager" }, { "name": "agent" }],
"team": {
"managers": ["@manager"],
"roles": [
{ "name": "agent", "label": "Agent", "groups": ["base.group_user", "@agent"] },
{ "name": "manager", "label": "Manager", "groups": ["base.group_user", "@manager"] }
] } } }
Vizum.team.roles(),members(),save({id?, name, login, email, role, password?}),setActive(id, active),remove(id),setPassword(id, password)andinvite(id).- Only a member of a
managersgroup, or a settings administrator, may call them. The app sends a role name, never groups: the server gives exactly that role's groups, as an administrator approved them withpermissions.team. - Each role names exactly one of
base.group_user/base.group_portaland at least one of your own groups. A group that is, or implies, an administrative group is refused, in the manifest and again at run time. - Administrative accounts are never touched. Someone your team did not create (an existing employee) can be given or relieved of your roles. Their password and active flag are not yours to change.
removetakes away your app's groups only. The person keeps whatever else they do in the company.- An update that widens the team waits for an administrator, like a wider grant: a new role, a group added to a role, a role let into a new app (or onto the phone launcher),
adopt_existingturned on, or any change ofmanagers(removing a manager group also lifts the protection of the accounts that hold it). Removing a role or a group, or changing alabelorhelp, does not — except on a team whose roles listapps: there the catalogue also keeps the other roles' holders out of those apps, so removing a role, or a group or app of one, waits too. While the update waits, the team keeps running on the roles that were approved: the pending ones are not handed out. - A vApp that calls
Vizum.teamsetsmin_platform: "5.60".
Everything a user-provisioning module did (5.65)
5.65 covers the rest of what a module like united_insurance_team did:
{ "permissions": { "team": true, "team_apps": ["com.acme.field"], "team_adopt": "dedicated" },
"native": { "team": {
"managers": ["@manager"],
"adopt_existing": "dedicated",
"roles": [
{ "name": "agent", "label": "Agent", "groups": ["base.group_user", "@agent"],
"apps": [{ "key": "com.acme.field", "solo": true }] },
{ "name": "manager", "label": "Manager", "groups": ["base.group_user", "@manager"] }
] } } }
Vizum.team.whoami()returns{uid, manager, role}, so the app knows whether to show the team screen.roleis the member's HIGHEST role, the same on Odoo 17, 18, 19 and 20: membership counts the groups a user holds including implied ones, and role A outranks role B when A's own groups imply B's own groups (a manager group that implies the user group makes the manager outrank the user). Roles that do not imply each other tie, and the role declared later innative.team.roleswins. Member rows reportrolethe same way.- Member rows also carry:
phone;last_login;self(the caller);role_label;admin: an administrative account that holds one of your roles is listed, but every write still refuses it.
savechanges for an existing member:- it takes a
phone; - it reactivates a member your app manages;
- it refuses to change the caller's own role.
- it takes a
- A role can let its members into another app of the same vendor (the first two segments of the id), so an agent can open the field app, and with
solo: trueit also appears on the phone launcher. Each app must be listed inpermissions.team_apps, which an administrator approves. Nothing is ever taken away. adopt_existing: "dedicated"(approved aspermissions.team_adopt) runs on install and on every update. It takes over the accounts that already play your roles and hold nothing else: onlybase.group_user, your role and manager groups, and what those imply. These are typically the agents the module you replace created. Your managers can then reset their password or deactivate them. Anyone with another group (an accountant who is also an agent) still only gains or loses your role.- A vApp that uses any of these sets
min_platform: "5.65".
Vizum.team: several roles per member, admin-only roles and the trail (5.116)
5.116 is the provisional number of this capability; it is fixed when it merges.
{ "permissions": { "team": true },
"native": { "team": {
"managers": ["@admin"],
"roles": [
{ "name": "tech", "label": "Technician", "groups": ["base.group_user", "@tech"] },
{ "name": "sales", "label": "Sales", "groups": ["base.group_user", "@sales"] },
{ "name": "admin", "label": "Management", "groups": ["base.group_user", "@admin"],
"grant": "admin" },
{ "name": "approver", "label": "Approver", "groups": ["base.group_user", "@approver"],
"grant": "admin" }
] } } }
- Several roles.
save({id?, …, roles: ["tech", "sales"]})gives the member exactly that set: every listed role is given, and your other roles are taken away.rolesholds 1 to 16 distinct names, each declared innative.team.roles. Sendrolesor the oldrole, never both;role: "tech"still meansroles: ["tech"](replace). - Detection by the role's own group. A member plays a role when they hold its own (
"@name") groups, counting implied ones. Give each role one marker group of its own and let the markers imply the capability groups the roles share: holding one marker is holding that role only. rolesandrolein the answers.whoami()returns{uid, manager, role, roles, settings_admin}and member rows addrolesandrole_labels.roleslists every role the member plays, in manifest order.roleis unchanged: the primary role, chosen as before. Drop every held role another held role outranks (A outranks B when B's own groups are in the closure of A's own groups and A has a group B's closure lacks); among those left, the one declared last innative.team.roleswins. Soroles[0]is not necessarilyrole.members({role})still filters on the primary role;members({has_role})keeps everyone who plays the role at all.- Nobody changes their own roles, a manager or a settings administrator included.
grant: "admin"(default"manager"): only a settings administrator gives or takes the role, and an account that holds it (password, active flag, invitation, roles, removal) is managed by a settings administrator only. Use it for an Approver and for any role that makes its holder a manager of the team:vzm checkwarns about a role that reaches amanagersgroup without it, because any manager could then create peer managers.- Accounts outside your app. A manager gives and takes your roles on any internal user, including an employee who also works in Inventory or Sales. For an account that holds any group outside your app, a manager's
savewrites only your role groups: its password, login, email, name, phone and active flag, andinvite,setPasswordandsetActive, are a settings administrator's, even when your team created the account. Your app's groups are what your roles and managers name,base.group_user/base.group_portal, and what those imply. Administrative accounts and__system__are never touched. - The trail. Every change writes one row to your app's audit trail with the action
platform:team.create,.update,.roles,.activate,.deactivate,.password,.inviteor.remove: who did it, the target, the roles before and after, the fields changed and whether a password changed (never the password). A refused change writesplatform:team.refusedon its own transaction, so the refusal survives the rollback (at most one per user, app and op per minute). Your app cannot write these actions itself. - Who reads it.
Vizum.team.history({user_id?, limit?, offset?})(from a method:team.history(user_id=…)) returns[{id, ts, by: [id, name], action, target: [id, login], ok, changes}], newest first, to the team's managers only. InVizum.audit.searcha member who is not a manager sees only the team rows about themselves (as the target or the actor); your app's own rows stay visible as before. The rows follow the usual 180-day retention. - A vApp that sends
roles, declaresgrantor callshistorysetsmin_platform: "5.116".
Translations, JSON and lines in another app's model (5.63)
"translate": trueon a char, text or html field keeps one value per language, astranslate=Truedoes in a module."type": "json"stores a JSON value (a diagnosis' raw readings, a rule's parameters).- A one2many may list rows of a shared model: declare in
native.fieldsa many2one that the app adds to that model, pointing at your model, and name it as theinverse. For example,res.partner.visit→x_vzm_<app>_visit, listed by the visit'spartner_ids. - An app may have up to 80 own models (5.63), or up to 120 with
min_platform: "5.132". More than 120 is refused at install and byvzm check. - A vApp that uses any of this sets
min_platform: "5.63".
Server-side translations: one catalogue for the screen and the server (5.73)
The i18n/<lang>.json files the browser SDK already reads (Vizum._t) now answer tr() in your model methods. There is one catalogue, no new manifest key and nothing to declare beyond what you already do:
manifest.json "i18n": ["es", "es_419", "fr"] (the locales you ship)
i18n/es.json {"Close": "Cerrar", "%s items": "%s artículos", "%(n)d of %(t)d": "%(n)d de %(t)d"}
i18n/es_419.json {"Close": "Cerrar (LatAm)"}
- Format: a flat JSON object
{"msgid": "msgstr"}. The msgid is your source text (English); the msgstr is the translation. An empty msgstr counts as untranslated. - Lookup:
tr("%s items", n)in a method reads the language the call asked for: thecontext.langthe bridge received (Vizum.orm.*(..., {context: {lang: "es_419"}})), else the language of the call itself. It tries the exact code, then its base language (es_419thenes), then your msgid. The%formatting is applied after. A missing key, or a translation whose format fails, gives your msgid formatted —tr()never raises because of a catalogue. - Any language code works: the ORM uses
langonly when that language is installed on the instance (otherwise it keeps its own language, and the call works);tr()uses the requested code, installed or not. Awith_context(lang=X)inside your method changes the languagetr()answers in. - Locale names: the files, the manifest's
i18nlist andcontext.langall use Odoo's own language codes:es,es_419,ar_001,pt_BR,sr@latin,sr@Cyrl.es-419,ESor a filei18n/es-419.jsonis refused. - Install-time rules (the instance, the Store's upload and
vzm checkapply the same ones): every value is a string; each msgstr has the same placeholders as its msgid (same count of%s/%d/%(name)s, any order); no executable markup in a value (<script>,on*=handlers,javascript:); no width or precision above 999 or*in a placeholder (%1000d,%*d; a literal50% doneis text, not a placeholder); at most 512 KB per catalogue, 2 MB together, 8192 characters per string. - Updating only the translations asks no new approval and is written to the app's audit trail (
i18n:update, with the changed locales). - Native strings (the labels of native fields, selection values, menus, views) are translated from the same files since 5.84: see "Native strings from the catalogue" below.
- A vApp that uses it sets
min_platform: "5.73"andVizum.supports("server-i18n")tells the screen whether the host serves it (the Desktop, native and Solo hosts all do: the language of the call iscontext.lang, sent on the sameorm.*bridge call in each). The module translator turns a module'si18n/*.pointo these files (fuzzy andmsgctxtentries are skipped).
Native strings from the catalogue (5.84)
The same i18n/<lang>.json also translates what the app puts in Odoo's own backend: field labels and help, selection options, model labels, groups, the app's top menu, menus, window and server actions, view and report terms, a binding's label, the translatable values of native.records (a mail template's subject, a product's name) and a public page's website-menu entry. Nothing to declare: an app that ships a catalogue gets it. No new manifest key and no min_platform (the keys do nothing on a platform before 5.84); Vizum.supports("native-i18n") tells a screen the host serves it.
A key is the English text the manifest declares, so a catalogue that already says "Customer": "Cliente" starts translating the backend with no change, or a context key @native:<ref> that names the string and removes any ambiguity (it wins over the English text):
| Key | What it translates |
|---|---|
@native:<model> | a model's label (audit, the manifest's short name) |
@native:<model>.<field> | a field's label (<model> is the short name of the app's own model, or the real name of a shared one: res.partner.vat_extra) |
@native:<model>.<field>#help | its help text |
@native:<model>.<field>=<value> | one option of a selection |
@native:@<group> | a group's name |
@native:<record id>.<field> | a translatable value of a native.ui / native.records record (menu_audits.name) |
@native:binding:<name> | a binding's label (the app name is put in front, as in English) |
@native:app | the app's name in the top menu and its client action |
@native:route:<name> | the entry of a public page in a website's menu |
A view's, a report's and a portal card's arch is translated term by term: the key is the exact text node or translatable attribute (string, help, placeholder, title, confirm, sum, avg, alt, aria-label, add-label), inline markup included ("Review <b>all</b> items"). A value used as a view term must be a well-formed XML fragment; a bad one is skipped (that term stays in English) and vzm check warns about it.
- When: at install, at every update and approval, when a language is installed or reactivated later (the "Add a language" wizard, "Update terms",
-u vizum_apps_platform), and on demand: select the app's rows in Settings > Vizum > Native Schema by App and use Action > "Reload backend translations" (administrators;vizum.app.action_native_i18n_reload). - Languages: only the installed languages the catalogue covers (
es_419thenes).en_USis the manifest's text and is never written; a string with no msgstr stays in English. - Updates replace: the value is computed from the stored English every time, so a changed msgstr replaces the old one and a removed msgid, or a renamed English source, goes back to English (nothing stale). A translations-only update asks no approval, writes no schema and is audited (
i18n:update,i18n:nativewith the counts per language). While an update waits for approval the strings already live are translated at once; new artefacts are, once approved. - An administrator's own translation is kept: the platform replaces a language value only when it is missing, equals the English, or is exactly what it wrote last time (a hash on
vizum.app.native_i18n_state); anything else is the administrator's and stays. - Not a permission: the keys live in
i18n/*.json, not in the manifest, and the write is made by the platform from the vetted catalogue. An app cannot callupdate_field_translationson a metadata model. vzm checkwarns, per declared language, about native strings with no translation (NATIVE_I18N_UNTRANSLATED), an@native:key that names nothing (NATIVE_I18N_DANGLING) and a view term that is not XML (NATIVE_I18N_BAD_XML);--strict-i18nmakes them errors. Neither the instance nor the Store refuses a package for them. An@native:value carries no%placeholder (that one is an error everywhere).- The browser caches menus and views for the session: reload the page after a language change.
V.tr in hooks and jobs (5.77)
A trigger hook (a guard included), a job and a method can call V.tr(source, *args, lang=None, **kwargs): the same catalogue and the same rules as tr() above, so a guard's message reaches the user in their language instead of a raw code.
# trigger hook (enforce): the message the writer sees
if "phone" in rec["changed"]:
V.fail(V.tr("Phone cannot change"))
# job: one mail per recipient, in the recipient's language
subject = V.tr("%(n)s open nonconformities", n=n, lang=partner["lang"])
- Language:
lang=wins when it is an installed Odoo language code (partner.lang); an unknown, malformed or uninstalled code is ignored without an error and the next rule applies. Otherwise the language of the call: in a hook that is the language of the user whose write fired it (the hook itself runs as the job user); with no language in the call, the language of the user the code runs as. A job (script hook or method) runs in its job user's language, not the scheduler's. - Order of work: look the text up first, then interpolate
*args(%s) or**kwargs(%(name)s). A missing translation, or one whose placeholders do not fit the arguments, gives the source text, never an exception: a guard cannot be broken by a catalogue.sourceis positional-only, soV.tr("%(source)s", source=x)works. - A plain
tr()stays method-side only.V.tralso works inside a method, where it equalstr(pluslang=). - A vApp that names
V.tr(or a name bound toV,W = V; W.tr(...)) in a method, a hook ornative.script.libmust declaremin_platform5.77 or later; the install gate andvzm check(MIN_PLATFORM_TOO_LOW) enforce it.Vizum.supports("script-tr")tells a screen whether the host serves it.
V.is_service_user() in methods, hooks and jobs (5.81)
True when the code runs as the app's own service user: the passwordless, platform-created job user (native.service_user: true, or the identity "@server" writers imply) that the app's jobs, triggers and device routes run as. False for every human (an administrator included), for a job user an administrator picked by hand, and for the superuser. The platform reads it from the user the code runs as, so a script cannot change it, and sudo() inside a method does not hide it (it keeps the uid).
# wl_issue — a kiosk must name its screen; a person at the counter need not
if V.is_service_user() and not screen_id:
raise UserError(tr("Choose the screen this ticket is issued from."))
It replaces the technical-group workaround (a @device group the service user joins): no extra group shows in Odoo's user form. A vApp that names V.is_service_user (or a name bound to V) must declare min_platform 5.81; the install gate and vzm check (MIN_PLATFORM_TOO_LOW) enforce it. Vizum.supports("script-service-user") tells a screen whether the host serves it.
V.offline_replay(): what the server knows about a queued op (5.110)
None for a live call. When the platform accepted the call as an offline replay of this user's queued op (Vizum.offline.run, offline.create / offline.write, from the web outbox or the Android background worker), a dict:
facts = V.offline_replay()
# {"queued": True, "received_at": "2026-10-14 09:00:05", "first_seen_at": "2026-10-14 08:59:41",
# "attempts": 2, "queued_version": "1.0.1"}
received_at: when the server received THIS request, on the server's clock (UTC).first_seen_at: the first time the server received a request carrying this op's idempotency key, failed attempts included (kept 30 days, the offline lifetime). A retry keeps the first one.attempts: how many requests carried the key so far.queued_version: the app version the op says it was queued under.
What it does not prove. It does not prove that the device was offline, that the op waited on the phone, or when anything was captured. "Queued" is a label the client sends: anyone holding the user's session can send any call labelled as a queued replay, at any moment, with any time in its payload. What the server checks is only that the label belongs to this user (the queued uid) and to an op the app declares offline. The two times are the only facts on the server's clock.
How to use it — trust the server's times, treat the device's as a claim:
facts = V.offline_replay()
captured = payload.get("captured_at") # the device's claim
deadline_met = (facts["first_seen_at"] if facts else V.now_odoo()) <= window_end
if facts and captured and earliest_known <= captured <= facts["first_seen_at"]:
label = "captured offline" # plausible: before the server first saw it
else:
label = "device time not verified"
Judge anything that must be on time with first_seen_at (or the live call's own time), never with a time the device sent. Bound a device time below by a server-recorded event it depends on (the stop was assigned, the vehicle departed) and above by first_seen_at; outside that window, show it as unverified. A value a client puts under the platform's context key never reaches V.offline_replay(). Naming V.offline_replay needs min_platform 5.110 (vzm check: MIN_PLATFORM_TOO_LOW).
V.vector.shared_namespaces() in jobs and migration hooks (5.83)
Lists the namespaces the app owns in the shared (global) vector scope, [{name, count, model, dim}]. An app whose approved vector grant is "user" cannot see them through namespaces(), and they are what a migration from vector: "global" to "user" has to move. It answers only for an administrator or the app's own service user (V.is_service_user(): a job, trigger or migration hook runs as it); a plain user gets an AccessError, because the store does not record who wrote a shared row. The move itself is Vizum.vector.moveScope (rows change owner, nothing is re-embedded or metered). An app that names V.vector.shared_namespaces (or a name bound to V) must declare min_platform 5.83; the install gate and vzm check (MIN_PLATFORM_TOO_LOW) enforce it. Vizum.supports("vector-scope") tells a screen whether the host serves it.
V.identity.consume in methods and hooks (5.79)
Vizum.identity.confirm (identity-step-up.md) collects a re-confirmation of the user in a host dialog and gives the page a single-use token. Spend it in server code:
receipt = V.identity.consume(token, "qms.doc:%d:approve" % self.id)
self.write({"x_signed_by": receipt["user_name"], "x_signature_id": receipt["id"]})
It returns the receipt (id, user_id, user_name, login, action, meaning, reason, confirmed_at) or raises a UserError with one fixed text for every failure. Only a call that comes from the user's own browser session can spend a token: a job, a cron, a webhook and a device route cannot. A token is bound to this app, this user, this session and this exact action. If your method fails after consume, the transaction rolls back and the token is unspent again. A vApp that names V.identity (or a name bound to V) must declare min_platform 5.79; the install gate, the Store upload gate and vzm check (MIN_PLATFORM_TOO_LOW) enforce it.
Taking over the data of the module you replace (5.64)
When a customer already runs the Odoo module your vApp replaces, its records live in that module's tables. native.adopt brings them over:
{ "permissions": { "adopt": ["dsd.route", "dsd.visit"] },
"native": { "adopt": [
{ "model": "route", "from": "dsd.route", "fields": { "name": "name", "driver": "driver_id" } },
{ "model": "visit", "from": "dsd.visit", "fields": { "name": "name", "route": "route_id",
"partner": "partner_id" } } ] } }
- On every install and update, while
fromexists, each of its rows is copied once into your model.vizum.app.adopt.maprecords the copy, so a row is never adopted twice and rows the module creates later are picked up on the next update. - Links between adopted models are translated (a visit's
routepoints at the adopted route). Links to shared models such asres.partnerkeep their ids. - Every
frommust be listed inpermissions.adopt; the administrator approves it. Platform, identity and security models, and secret fields, are refused. - Uninstalling the module afterwards is the customer's step. Your vApp then owns the data.
tools/module2vapp.py <module> --manifest out.jsonwrites theadoptmap for you.- A vApp that adopts sets
min_platform: "5.64". native.adopttakes up to 80 entries, or up to 120 withmin_platform: "5.137"(the same cap asnative.models). More than 120 is refused at install ("native.adopt is a list of 1-120 entries") and byvzm check.
Adopting a real database (5.68). The copy is data, not business events:
- Your own
create/writeoverrides and yourconstraintsstay out of the copy: no audit trail, no "cannot publish yet" guard over a link the second pass has not filled. Once every link is in place the constraints run once over the adopted rows; a row that breaks one stops the adoption with the constraint's own message. Seedrecordsand normal writes keep running your overrides. - The mark is per app: only the adopting app's overrides, constraints and trigger hooks stand aside. Another app's guard on the same Odoo model (
res.partner) still runs while the columns are copied, and the adopting app's own constraints over its columns on Odoo's models run once at the end, like the ones over its own models. - A binary stored as an attachment is shared by reference: the adopted row gets its own
ir.attachmenton the same stored file, and no byte is read, so hundreds of megabytes of manuals cost no memory. A binary that cannot be shared is read one row at a time and created in batches of a few MB. refstranslates a (model name, id) pair kept in plain columns: the module's model becomes your technical model and the id the adopted row's. A pair naming a model you did not adopt is left as it is.
{ "model": "audit_event", "from": "my.audit.event",
"fields": { "res_model": "res_model", "res_id": "res_id" },
"refs": [ { "model_field": "res_model", "id_field": "res_id" } ] }
When the model follows from another field (an annotation anchored by its kind), name it instead: { "id_field": "res_id", "model_by": { "field": "kind", "models": { "model": "my.model" } } }. model_field is an adopted char field (a selection lists the module's names, not yours), field any adopted field, id_field an adopted integer. A model_by pair whose record was deleted loses its link (the id becomes 0) rather than land on a stranger's row; the platform logs how many pairs it left as they were and how many it unlinked.
- A compute method that assigns several fields (
imp.count_a = ...; imp.count_b = ...) no longer turns the assignment of a sibling into a write: the fields it computes are protected while it runs, as Odoo does for fields that share one compute. A write override or anappliedguard on the model is not reached by a read. - A link to
ir.modelorir.model.fieldswhose target is a model (or a field) you adopted becomes a link to your own model (fieldx_<name>); the module's rows go with its uninstall.
A whole Odoo module as a vApp (5.66)
5.66 runs a module's own Python as your vApp's code. PSI DSD Engine is the module psi_dsd ported this way, and the module's 818 tests pass against it.
{ "permissions": { "logic": "script", "methods": ["route.close_all", "res.partner.create"],
"orm": [{ "model": "res.partner", "ops": ["read", "search", "write", "create"] }] },
"native": {
"aliases": true,
"methods": [
{ "model": "route", "name": "close_all", "api": "model",
"code": "def close_all(self):\n self.p_assert_manager()\n return len(self.sudo().search([('state', '=', 'open')]))\n" },
{ "model": "res.partner", "name": "create", "override": true,
"code": "def create(self, vals_list):\n recs = super_(vals_list)\n return recs\n" } ] } }
native.methodsare installed as real methods of the Odoo class, so a button, a cron, another app'scall:or plain Odoo code calls them like any method. Inside one,selfandenv[...]are *governed* records: yourormgrants, Odoo's access rights and record rules.sudo()lifts the access rights and rules, never your grants. An app may declare up to 1000 methods, or up to 1500 withmin_platform: "5.137"; more is refused at install and byvzm check.- An override of an Odoo method (
create,write,action_confirm…) calls the original withsuper_(...). Overrides are listed inpermissions.methods, which the administrator approves. - One app owns each method of an Odoo model. Two apps cannot both put the same
model.name(an override or a new method) on one database: the install, an update and the approval refuse the second app, with a message that names both apps and the method. The first app counts as soon as it is installed, ready or still waiting for approval, and a disabled app that is approved again is refused when another app took its method in the meantime. The rule is per database: two apps that overridesale.order.createfor two different customers never meet. Your own models' methods are namespaced and never collide. To guard an Odoo method another app may already override, use an enforcing trigger hook (§3): every app's hooks run. - A private method
_assert_manageris called asself.p_assert_manager(): names that start with_stay forbidden in the sandbox. - Assigning a field is
rec.field = valueorrec.field += value, one field per statement: the platform turns each into a governed write. It refuses, at install, in the Store and invzm check: a chained assignment whose target is an attribute (a = rec.x = v), an unpacking into a tuple or list that holds an attribute or a starred name (a, rec.x = ...,first, *rest = items), anddelor an annotated assignment that names an attribute. An attribute inside a subscript target is a read and is fine:rows[m.id, t.id] = acc = {}. - Computes, defaults (
{"method": name}), constraints and jobs can name a method. permissions.private_callslists the private Odoo methods your code calls (sale.order._create_invoices) — never the platform's ownvizum.*models, which the validator, the store and the runtime all refuse (vizum.app._store_checkoutwould buy with the saved card).permissions.paramslists their.config_parameterkeys it reads and writes.permissions.peer_datalists"<app id>:<key prefix>"entries: another app's global data rows, read-only.peer_data(app_id, prefix)returns them.- What a
call:returns (orm.call/orm.callOn/V.orm.call): plain JSON data, with this contract (it is the platform's own, not a copy of Odoo's JSON-RPC): dates and datetimes become ISO strings in UTC ("2026-10-03","2026-10-03 12:30:05"; a timezone-aware datetime is converted to UTC first, a naive one is taken as UTC);Decimalbecomes a number (a float);bytesbecome UTF-8 text when decodable; sets and tuples become arrays; a top-level recordset becomes its list of ids. The whole result falls back totrue, and the platform logs a WARNING naming the app, model and method (never the data), when it holds: a recordset nested inside a dict or list (an app never receives ids it did not ask for through a gated read),bytesthat are not UTF-8 text (the platform does not guess base64), NaN or Infinity, a lazy value or any other object, more than 32 levels of nesting, more than 10 000 list or dict entries, or more than 4 MiB serialized. From 5.98 (supports("typed-action-result"), an app that declaresmin_platform5.98 or later) the collapse totrueis gone: a method that returns an Odoo action dict (typestartingir.actions., e.g. the wizard a button opens) answers{type: "action", action_type, name, res_model, res_id, view_mode, target, context?}(plain scalars;domain,viewsandhelpare never copied andcontextonly as lang and tz, so an app can tell a wizard opened and which record it is on); a top-level recordset answers{type: "records", model, count, ids}(not the bare ids list;idsholds only the records the app may read under its ownreadgrant on that model, the same goes for an action'sres_id;contextcarries onlylangandtz); anything else that is not plain data (a recordset nested in a dict, a lazy value, an object, a result over the caps) raisesACTION_RESULT_UNSERIALIZABLE(categoryunsupported) and the call's changes are rolled back like any failed call.true,nulland plain JSON results are unchanged. Apps below 5.98 keep everything described above. Types:Vizum.OrmActionResult,Vizum.OrmRecordsResult. - A guarded call (
V.orm.call(model, ids, method, args, expect={"state": "to approve"}), SDK 5.135, featureorm-expect): the platform locks the records, re-reads the expected fields as the user and runs the method only if each record still holds every expected value. Otherwise nothing runs, for any id, and the call raisesStateChanged(str(e)ends with the current values of the expected fields as JSON:{"model": ..., "records": [{"id": 7, "current": {"state": "purchase"}}]}; a record that is missing or the user cannot read answersnull), orBusywhen the row locks were still held after 5 s (retryable). Both areUserErrors:except Exception as e:catches them, uncaught they abort the hook and roll its writes back.idsis required; up to 100 ids and 10 stored fields. Use it wherever a button method accepts any state (button_cancel) and the record may have moved on since the script read it. Full rules: guarded-call.md. - What a declared action returns (
Vizum.actions.run, and a backend task that runs the action):{ok: true, result}. Up to 5.101resultis the method's value whenjson.dumpsaccepts it andtrueotherwise — a wizard (stock_sms'sconfirm.stock.smsafter a validate), the formstock.picking.action_returnopens on Odoo 20, a recordset or a date all came back astrue. From 5.102 (supports("actions-typed-result"), an app that declaresmin_platform5.102 or later)resulttakes thecall:shapes above:{type: "action", ...}for an action dict,{type: "records", model, count, ids}for a recordset (ids andres_idonly inside the app's own read grant, so a wizard model the app cannot read answersres_id: null), dates and decimals normalised, and what is not plain data rejects withACTION_RESULT_UNSERIALIZABLE: the run is rolled back and the audit trail records it as failed. A method that returns nothing still answerstrue. An app below 5.102 keeps the old answer exactly, whatever the platform. Type:Vizum.ActionRunResult. V.sha256(data, encoding=None)(SDK 5.75) — the SHA-256 ofdataas 64 lowercase hex characters, in script hooks, jobs and native methods alike.datais astr(hashed as UTF-8),bytes, or, withencoding="base64", a standard base64strof the bytes (whatV.orm.readandV.files.gethand back for a binary field). It refuses with aUserErroranything over 32 MiB, invalid base64, any other type or encoding, and a run that hashes over 256 MiB in all or makes over 2000 calls. The size is read from the length and charged to the run before any decode, so a refused input still uses budget and a spent budget fails the call without touching the data. It is the metered digest: a plain hook or job has nohashlib, md5/sha1 or streaming object, and there is no HMAC (a key would have to come from a secret, and scripts never hold one; since 5.138 the provider adapterV.net.provideris how a signed cloud protocol is spoken without the script holding a key: the adapter reads the secrets, signs, and keeps the session on the server). The one exception is the code of an app with native methods (its methods, and its library in a hook or job run): a ported module keepshashlib.sha256,hashlib.sha1andhashlib.md5(constructor,update,digest,hexdigest,copy) so it runs as the module did. Thathashlibis metered on the same run budget asV.sha256: each constructor and eachupdateis a call, its bytes are charged before they are hashed, one call takes at most 32 MiB, and it fails with the same budget error. AstrraisesTypeErroras in Python. These are integrity digests (md5/sha1 are built withusedforsecurity=False): never a password hash or a signature. New code usesV.sha256.V.sha256or a name bound toV(W = V) that reads.sha256must declaremin_platform5.75 or later;hashlib.sha256and any other base do not count. The install gate andvzm check(MIN_PLATFORM_TOO_LOW) enforce it. A hash is not a signature.V.sha256is an integrity digest: use it as a fingerprint, to detect a change or to deduplicate. Anyone can compute it from the same bytes, so it proves nothing about who made the data and never authenticates a publisher. Who made a package, and whether the store or an instance trusts that publisher, is the job of Ed25519 package signing, publisher keys andstore_verified(seepackage-signing.md).caller_app()(SDK 5.69) — in a method another app invoked through itscall:grant from the bridge, the key of THAT app, verified by the platform (a marker JSON-RPC cannot forge);Nonefor any other entry (a direct RPC call, Odoo, a job, a trigger, the app's own code). The name is a 5.69 feature: a method ornative.script.libpart that namescaller_appmust declaremin_platform5.69 or later, and both the install gate andvzm check(MIN_PLATFORM_TOO_LOW) enforce it. Inside one run the answer is the innermost entry's verified caller: if another app's code re-enters your app through its owncall:grant, your method sees that app, never the outer caller.native.aliases: truemakes each declared field name a real field of the ORM. Your code, the other apps, views, reports and tests keep sayingdriver_id, notx_driver_id.native.records,native.rules,native.ui(views, actions, menus, QWeb reports with vetted expressions, thevzm_geo_mapwidget) andgroups[].impliescarry the rest of a module: its data, its record rules, its backend and its group tree. A seeded record may say"match": ["code"](5.68): if its xmlid is gone (an importer created the row, an uninstall left it), the one row with those values is anchored again instead of being created twice — never a row another seeded record already holds. Writet-out, nevert-escort-raw. Odoo 20 droppedt-esc: its QWeb ignores the directive and the element renders empty. On Odoo 20 the platform rewrites everyt-escin a view arch or mail template body tot-outat install and update (an xpath's@t-escand an inherited view's<attribute name="t-esc">too, as Odoo renamed its own reports), andt-outescapes exactly ast-escdid; 17–19 keept-escas written, because Odoo's own views there still carry it.vzm checkwarns about each one (QWEB_T_ESC).t-rawprints unescaped HTML and has no safe equivalent: the install refuses it, in a view, a patch or a mail template (QWEB_T_RAW). Everyt-*directive but a name (t-set,t-as…) is vetted as an expression —t-att,t-lang,t-cache,t-valuefandt-logincluded — and so is the value an inherited view sets with<attribute name="t-…">. On a directive, an<attribute>may notremove, and mayaddonly to at-if/t-elifjoined with" and "/" or "(QWEB_PATCH_REFUSED).t-set/t-astake a plain name: any other value (t-set="{…}"updates the values) is vetted as an expression. A mail template innative.recordsmeets the same gate as one written through the bridge: its body is read as Odoo reads it (HTML: any case, unquoted values), andattachment_ids,report_template_idsandemail_layout_xmlidare refused. The form chatter follows the Odoo too (5.144). Odoo 17 compiles only<div class="oe_chatter">and Odoo 18, 19 and 20 only<chatter/>; the other form shows as raw field lists. Write either in anative.uiview and it is stored as the running Odoo compiles it, as<list>/<tree>is: the div becomes<chatter/>on 18+,<chatter/>becomes the div on 17 with only the fields the view's model has (message_follower_idsandmessage_idsfor a mail.thread,activity_idsfor mail.activity.mixin; set"chatter": trueon the model). Theopen_attachments/reload_on_post/reload_on_follower/reload_on_attachmentattributes and the fields'post_refresh/open_attachmentsoptions are carried across, and an inherited view's//chatteror//div[hasclass('oe_chatter')]xpath is renamed. The other attributes of the element (position,invisible,groups,id) stay on it, and only a form view (or a view extending one) is converted: a QWeb template'sdiv.oe_chatteris left alone. The options are an approximation: Odoo 17'spost_refreshhasalways,recipientsandnever,<chatter>has only the booleansreload_on_post/reload_on_follower/reload_on_attachment, sorecipientsbecomesTrueand returns asTrue. An xpath may name the chatter withhasclass('oe_chatter')or@class='oe_chatter', alone or as oneandcondition (//div[@class='oe_chatter' and @id='a']is//chatter[@id='a']on 18+); any other way (contains(@class, …), anor,*[…]) is refused when the app installs, and so is content put INSIDE the chatter (position="inside"ondiv.oe_chatter:<chatter>takes no children;before/afterare fine). A view whoseinherit_idchain loops or is longer than 10 views without reaching a model and a type is refused as well. The conversion at install needsmin_platform5.144 (featureui-chatter-per-version); nomin_odoois needed for the chatter. From 5.146 (featureui-chatter-per-version-runtime) the same conversion applies to a view the vApp creates or writes at runtime through their.ui.viewbridge (a write that names no model or type takes them from the stored view or its parent), and a refusal there is the bridge's normal error (AccessError); one write to several views that need different markup is refused. A vApp that writes views with a chatter at runtime declaresmin_platform5.146. A view that extends an Odoo view names it as"inherit_id": {"$xmlid": "<module>.<name>"}(or{"$ref": "<an earlier ui id>"}). A malformed xmlid is refused by the validator, and an xmlid that names no view on the database refuses the install: the view is never created standalone.native.access(SDK 5.101) is a module'sir.model.accessrow on a model it does not own: one of your groups gets an operation on an Odoo model (psi_dsd lets its drivers readaccount.move; its record rule then keeps them to their own customers' invoices).
"native": { "access": [
{ "model": "account.move", "group": "@driver", "ops": ["read"],
"reason": "Drivers see the invoices of the customers on their route" } ] }
modelis an Odoo model your app does not own. Your own models keepnative.models[].access; naming one here is refused with that pointer.groupis one of your own groups,"@name"fromnative.groups. Never another module's xmlid: the grant reaches only the people an administrator put in your group.opsis a non-empty subset ofread,write,create,unlink, and each op must be covered by yourpermissions.ormgrant on that model (readby any read op,group_opsof that group included): an app gives its users no more than it holds itself. That grant must be plain: one withfields,write_fields,create_fields,domainorchatter_onlyis refused, because an access row gives the group every field and every row of the model.- Every op needs a
native.rulesentry on that model for the same group with that perm. Its domain is the rows the group gets;"domain": []says "every record", and the administrator sees it at approval. A rule of another group (even one that implies yours) does not count. This is what makes the rows the same on every line: Odoo ≤ 19 ORs only the record rules of the groups a user is in, Odoo 20 ORs the permissions themselves, so a grant with no rule of its own group would show a user who is also, say, a salesman every record on 20 and only theirs on 19. reasonis required forwrite,createandunlink(at most 500 characters). The administrator reads it on the approval screen.- One entry per
(model, group). Never a model of the contract'snative_access_policy: the identity and security models (res.users,res.groups,res.company,ir.*,ir.model.access,ir.rule…), the platform's own (vizum.*), credentials,base.*,bus.*,auth_*,mail.alias*and the website pages. - Your
native.ruleson the same model narrow the grant exactly as the module's record rules did. Up to Odoo 19 an entry is anir.model.accessrow and the rules areir.rulerows. On Odoo 20 the entry becomesir.accesspermissions that carry the domains of your rules for that group (never a permission without a domain), and a rule of a group above it (a manager that implies the driver) becomes that group's permission, so a member reads the same rows on every line. On Odoo 20 the group implications are read when the app is applied: an administrator who later edits what your groups imply sees it reflected at the next install, update or approval. - The model must exist on the database: a grant on a model whose module is not installed refuses the whole install (it never installs half the access).
- Porting a module (Vapp Creator, Module Converter): emit the module's record rule for the group on that model, or a rule with
"domain": []when the module had none. The second is a visible widening the reviewer sees in the manifest and at approval. - An administrator who archives one of the rows keeps it archived: an update rewrites the row but never its active flag. An entry dropped from the manifest, and the uninstall, remove the rows whatever that flag says.
- A new entry, or a new op on one, makes the update wait for an administrator (
native_acl), andapi_update_preflightlists each grant inpermissions.detail.native_access. An entry dropped from the manifest is removed by the update; uninstalling the app removes every one. - Needs
min_platform5.101: an older platform would install the app without the access.vzm checkreportsNATIVE_ACCESS_INVALIDandMIN_PLATFORM_TOO_LOW.
Replacing a module that is installed. Declare it with "replaces": "<module>".
- While the module is installed, your app *waits*. It creates its models and columns and adopts the data, but adds nothing to Odoo's models: no alias, override, constraint, view, menu or job there. Those names and that behaviour are still the module's.
native.adoptalso copies the module's columns on Odoo's models into yours:{"model": "res.partner", "from": "res.partner", "fields": {"dsd_driver_id": "dsd_driver_id"}}. A link to a model you adopted is translated.groups[].adopt: "<module>.<group xmlid>"puts the people in the module's group into yours. The administrator approves each group by its xmlid inpermissions.adopt.- When the customer uninstalls the module, the chatter and attachments of every adopted record move to its copy before Odoo deletes them. On the next start your app goes live by itself: aliases, overrides and backend.
tools/module2vapp_port.pytranslates a module into these keys, andtools/module2vapp_port_tests.pyports its tests so you can prove nothing changed.- A vApp that uses any of these sets
min_platform: "5.66".
Restriction profiles (5.106)
An administration app (Hide Menu 1.3.0 is the first) can take away what Odoo already allows, for the people an administrator names. It cannot give anything: a restriction only narrows.
Two platform models, admin-only, written through a normal permissions.orm grant (read + write, which includes create and unlink) like vizum.menu.hide:
| model | what it says |
|---|---|
vizum.access.profile | who is restricted: user_ids, and every member of group_ids. active, note, app_key (read-only: the platform stamps your app id, the uninstall removes by it). starts_at / expires_at (9a: the window, UTC; outside it the profile restricts nobody). Read-only: member_ids (restricted now), group_id (the platform-owned group), sync_pending, in_window (9a). |
vizum.access.restriction | profile_id, model_id (an ir.model), restrict_read / restrict_write / restrict_create / restrict_unlink, an optional domain, optional company_ids, active. One row per model per profile. |
# a hook of an app that holds the grants, run as an administrator
p = V.orm.create("vizum.access.profile", {"name": "Warehouse clerks", "app_key": "com.acme.admin",
"group_ids": [[6, 0, [clerks_group_id]]]})
V.orm.create("vizum.access.restriction", {"profile_id": p[0], "model_id": sale_order_model_id,
"restrict_write": True, "restrict_unlink": True})
- How it is enforced. The platform keeps one
res.groupsper profile (xmlidvizum_apps_platform.vzm_rp_<id>) whose members are the profile's users plus the members of its groups, minus administrators and the superuser. Each restriction becomes Odoo's own record-level security: up to Odoo 19 a globalir.rule(AND-ed with every other rule) per profile and model, on Odoo 20 a group-lessir.accessrow (a restriction, AND-ed). Because it is Odoo's rule, it holds overcall_kw,search_read,web_save,export_data, the import and every action, not only in the web client. A restricted read hides records (a search returns fewer rows; reading a hidden id raisesAccessError); a restricted write, create or unlink raisesAccessError. - A domain narrows the members instead of blocking them. Without a
domain, the members get none of the restricted operations. Withdomain(a Python list of conditions), they keep the operation on matching records only. Only a plain list of(field, operator, value)leaves and&|!is accepted; the value may be a literal,company_ids,company_idoruser.id/user.company_id.id(a short chain of the attributes in the contract'saccess_restriction_policy); the operators includeany/not any. Strings are plain quoted text (no prefix, triple quote or backslash escape) and numbers plain digits without leading zeros, no comments, brackets at most 20 deep. A literal[]is refused: leave the domain empty to block every record. It is read as a syntax tree and never evaluated at write time;vzm checkreads it the same way (the same cases run on both sides). - Companies. With
company_ids, the restriction applies to the records of those companies (company_id in ids), whatever companies the member has active in the switcher: toggling the active companies changes nothing. Records of other companies stay free; the domain, if any, still decides what the member keeps inside the scoped companies. The model must have acompany_idfield, or the row is refused. Withoutcompany_ids, every record. - Membership follows its targets. Members of a target group include the members of every group that implies it (Odoo 19+
all_group_ids, earlier lines already hold the implied groups), and administrators are excluded in the same implied sense. The group is re-synced in the same transaction whenever a user is created, a user's groups oractivechange, a group's members or implied groups change, so a new member of a target group is restricted from the first request and a person taken out is released at once. A sync that fails keeps the previous members (it never empties the group, which would lift restrictions) and setssync_pending; the next sync or registry load retries. Archived users stay members. An archived profile restricts nobody. - The way back.
base.group_systemand the superuser are never members and the rule also testsbase.group_systemitself. A profile cannot target an administrator, a group that is or impliesbase.group_system, or the person making the change. An administrator can always open, edit and delete a profile.group_id,sync_pending,in_windowandapp_keybelong to the platform (app_keyis stamped from the app the ORM bridge says made the row): a caller that writes them getsAccessError. The platform trusts a profile's group only when nothing else claims it (its one xmlid is the profile's, no other profile points at or targets it, no group implies it, not an administrator group), and a profile cannot target the group of another profile. Compiled rule andir.accessrows are the platform's only when they carry its xmlid (vzm_rr_<profile>_<model>); a rule that merely has the same name is never touched. - What cannot be restricted (one validator,
access_restriction_policyin the contract; the row is refused with aValidationErrorthe app shows verbatim): the models ofnative_access_policy(credentials, the platform's ownvizum.*,bus.*,base.*,auth_*,mail.alias*), everyir.*exceptir.filters,res.users,res.groups,res.company. Favourites are the one hole inir.*:ir.filtersmay be restricted for create, write and unlink, never read (the shared favourites keep loading for a member), and a domain may name onlyuser_id/user_ids/is_default/model_id/action_id, without|or!, and never so that a member could delete the shared favourites while their own are blocked. A member then opens an action with shared favourites fine, and cannot save or delete one. - Audit and uninstall. Every change writes a
vizum.app.auditrow for the app named inapp_key(a profile made through the ORM by an administrator, with none, is recorded under Hide Menu, when it is ready and an administrator approved it an orm grant on these models). Whatever else is recorded, every change also writes a row in the platform's ownvizum.access.log(administrators read it; no app is granted it; the profile id is an integer, so the row outlives the profile), so a change never goes unrecorded when no app can hold the audit (platform:access_profile_created,…_updated,…_deleted,…_restriction_*,…_members_synced,…_sync_failed). Uninstalling the app removes its profiles, restrictions, the rules or access rows and the groups. - Named residual: a restricted record's NAME can appear on a record the member may read. Odoo renders a many2one value with
sudo()(odoo/orm/fields_relational.py,Many2one.convert_to_read: the visibility of a many2one's id and name follows the record that holds it, not the record it points at). So a visible sale order, purchase order or transfer whosepartner_idis a hidden partner shows that partner's name, throughread,web_readand a vApp bridge or jobread. The same holds for a composeddisplay_name: a visible child contact is shown as "Hidden parent, Child". The contents, the fields and the actions of the hidden record stay denied: reading it by id, searching, grouping, exporting, importing, printing, writing, deleting and its chatter are all refused. It is native Odoo behaviour, like thesudo()residual of third-party code, and the platform does not mask it. The access matrix pins it (vizum_apps_platform/tests/access_matrix/expected/<line>.json,KNOWN_LEAK(m2o-name)andKNOWN_LEAK(composed-display-name)). - Not covered here: field-level and screen-level restrictions (hiding a field, a button or a tab). Blocking the member's own Duplicate, Import, Export, buttons, reports and view types is the next section.
- Needs
min_platform5.106 (supports("restriction-profiles"));vzm checkreportsMIN_PLATFORM_TOO_LOWfor an orm grant onvizum.access.*below it, andaccessRestrictionErrorinlib/native.mjsanswers a row exactly as the platform does (the same cases run on both sides) andvzm checkapplies it to thevizum.access.restrictionrows an app ships innative.records(ACCESS_RESTRICTION_INVALID). A row names its model only by themodel_idxmlid, which does not say which model it is (model_auth_totp_deviceisauth_totp.device), so the model is looked up on the Odoo atlas, never rebuilt from the name; where the atlas is absent or does not know it, the row is judged on operations and domain and reported asACCESS_RESTRICTION_UNVERIFIED(the platform refuses it at install).
Time windows (9a)
A profile may carry starts_at and/or expires_at (UTC; a missing bound is open). Outside its window the profile has no members, so every restriction it holds (model and record rules, field modes, blocked operations, screen nodes, the fields hidden from apps) lifts at once and comes back together. A platform job runs every minute and re-syncs each profile whose window opened or closed; changing a window takes effect at once. A window that ends while the job is down keeps the restriction until the job runs (fail closed); one that starts while it is down starts late, and the app's health (Vizum.apps.health(), check access_windows) and Access Manager say so when the job is more than 2 minutes behind. Each boundary is logged (window_opened / window_closed in vizum.access.log). On Odoo 17 the job is created with numbercall -1. An app that writes a window sets min_platform 5.140 (feature access-profile-window): an older platform has no such fields.
Read-only everywhere (9b)
min_platform 5.141 (PROVISIONAL, fixed at merge; feature access-readonly-user): an older platform has no such field.
A profile with readonly_user makes its members read-only on every model: they read what Odoo already lets them read, and write only their own preferences (on their own user, only the fields Odoo lets every user write on themselves: never groups, login, active or companies) and res.users.settings, Discuss (chats, groups and the channels they are in: posting, uploading there), and «mark done» on their own activities (decision D3, measured on Odoo 17-20). Administrators, the superuser, OdooBot, the apps' job users and the platform's service users are never members, and Odoo's own code under sudo() is never judged. Two layers: what the request dispatches (call_kw, buttons, JSON-2, server actions, XML-RPC execute_kw) must be a read method or a measured D3 entry point, which closes a button that writes under sudo(); and Odoo's own access check per line refuses any other write, create or delete. A vApp the member runs reads Odoo records and writes none: the bridge lets in api_orm (read operations, and a call: only to a read method) and the entry points that only read; every other bridge entry point, the app's own storage included (Vizum.data, Vizum.db writes, Vizum.files), is refused for the member.
Screen switches (9c)
min_platform 5.142 (PROVISIONAL, fixed at merge; feature access-profile-ui-policy): an older platform has no such fields.
Ten switches on a profile hide entries of the member's web client: the side search panel (ui_no_search_panel), «Add Custom Filter» (ui_no_custom_filter), the custom «Group By» (ui_no_custom_groupby), «Save current search» (ui_no_favourite_save), the chatter's «Send message», «Log note» and «Activities» (ui_no_chatter_send, _log, _activity), the Enterprise «Spreadsheet» cog item (ui_no_spreadsheet), «Add a property» (ui_no_add_property) and the Archive / Unarchive actions (ui_no_archive_menu). Screen only: they hide an entry and refuse nothing. For a server-side refusal, pair a switch with its guard: favourites with the ir.filters restriction, the chatter with blocking the message, note and activity operations on the profile, Archive with a read-only active field or 9b read-only. The member's switches reach the web client in session_info.vzm_access_ui; an administrator never gets them. An open tab keeps the switches it loaded until it is reloaded; the server sends the current ones on the next load. Five of them (search panel, custom filter, custom group by, spreadsheet, add a property) are a convenience, not access control: they have no server guard to pair with.
Blocked operations and the developer mode (5.108)
A restriction profile also blocks what a member does, not only which records they reach. A vizum.access.operation row (one per blocked operation, written through an orm grant like the other two models, min_platform 5.108, supports("restriction-operations")) says «the members of profile_id may not do this».
| field | what it says |
|---|---|
profile_id | the profile whose members are refused |
kind | what is blocked (table below) |
scope | model (default) or all; all is allowed for copy, import and export only and names no model |
model_id | an ir.model; required for copy, import, export, message, note, activity, button, view_type unless scope is all |
method | button only: a public method name (^[a-z][a-z0-9_]{0,79}$) |
report_id, server_action_id, action_id | the record a report, server_action or action row names |
view_type | view_type only: form, list (tree is stored as list), kanban, pivot, graph, calendar, activity, map, gantt, cohort, hierarchy; never search |
active, note | switch the row off without deleting it; why it exists |
vizum.access.profile gains no_debug (see «Developer mode off») and operation_ids.
kind | what a member is refused | paths covered |
|---|---|---|
copy | the Duplicate they dispatch (copy, and copy_multi on Odoo 17), also on the explicit children whose Duplicate duplicates the parent's record (today product.product for product.template, copy_multi included); a model that merely _inherits the parent, like account.payment through account.move, is not named by the row and is not judged | call_kw, call_button, JSON-2 (19+), /jsonrpc, XML-RPC |
import | load on the model, the dry run included | every non-sudo call |
export | export_data on the model, /web/export/csv, /web/export/xlsx, the export field list and the /json route (18+) | every non-sudo call |
message | posting a comment on a mail.thread model: the chatter route /mail/message/post (and the portal chatter /mail/chatter_post on Odoo 17), message_post, message_post_with_source, a direct create of a mail.message on the model, message_ids commands in write / web_save / create, the full composer in comment mode and, for invoices, the «Send & Print» wizard (account.move.send on 17, account.move.send.wizard on 18+) when email is among the chosen methods (checkbox_send_mail on 17, sending_methods on 18+): printing or downloading only is not a message | the chatter route and the member's direct call |
note | the same for a log note | as message |
activity | scheduling an activity on the model: the schedule wizard, the activity form, a direct create / name_create / copy / write on mail.activity (judged on the res_model the row really got, whatever the context default was), activity_schedule, activity_ids commands in write / web_save / create | the member's direct call |
button | one public method of the model (call_button or any RPC of it) | the member's direct call |
report | the member's request for the document: any HTTP route that is not an RPC (/report/..., a point-of-sale or custom print route, an invoice download), a direct RPC of the renderers, report_action called directly, and the entry in the Print menu | HTTP routes and the member's direct call |
server_action | the member's direct run of that server action (an RPC of run on its id, /web/action/run, a path action of load_breadcrumbs), loading its definition, and its entry in the Action menu | the member's direct run |
action | /web/action/load of an action (the definition is not served) | the web route |
view_type | get_views / get_view of that type, and the type in the action's views and view_mode; blocking form blocks opening any record of the model | every non-sudo call |
- The platform blocks the member's direct action, never Odoo's consequences. A
copy,button,activityormessagerow refuses the call the member's request dispatched: this method on this model. The same method reached from another allowed flow passes: the copy inside a credit note, a backorder or a return, an activity a confirmation schedules, a notification a workflow posts, a blocked button called by an allowed one. The platform reads the dispatched call from what Odoo itself dispatches: thecall_kw/call_buttonJSON body, the matched JSON-2 path (a body that names another model or method is refused), and, for/jsonrpcand XML-RPC, the arguments of the object service'sexecute/execute_kw(Odoo unbinds the HTTP request there, so the platform records them at the service entry point); it never inspects Python frames. A request on one of those paths whose method cannot be read is refused for the member, that request only, and logged.import,exportandview_typehave no caller other than the member's own request, so they judge every non-sudo call. Aserver_actionrow is not one of them: it judges only the direct run. A report is the member's own request on an HTTP route that is not an RPC and in a direct RPC: a render nested in an RPC of another method, or with no request at all (cron, shell), is a consequence and passes: a flow's render (a template's attachment, «Send & Print», a print on validate) is a consequence and passes. - A vApp's bridge call made for the member is the member's direct action (G-5). A vApp reaches the server as
vizum.app.api_orm,api_action,api_mail,api_activityor the assistant's writes, so the request dispatched the bridge, not the method. The bridge reports the INNER (model, method) to the guard for exactly that call:call:copy, acall:of a blocked button,api_mailpost / log,api_activityschedule, anapi_ormwrite/createthat carriesmessage_ids/activity_idscommands, an action whosemethodis a blocked one, and the assistant's create / update are refused with the same error and the same log as a direct call. Blocks apply to direct client calls; an app's own server logic, including jobs and triggers, is the app's flow. The pair is reported ONLY when the request itself came through a client entry (api_orm,api_action,api_mail,api_activity,api_assistantor the assistant'sassist_write), as the server's own recorder saw it: nothing the client sends decides it. A job, a trigger or a webhook that runs as a restricted member and callsV.orm(acall:copy, amessage_post, a write withmessage_ids/activity_idscommands) is therefore not refused: the request is not a client entry, so no pair is reported. Anative.methodsmethod reached by a client bridge call that copies, posts or schedules by itself passes too: the marked pair is the method the bridge reached, so what it does inside is a consequence, like Odoo's own flows. Only the outermost bridge pair counts, and it is set for that call alone (it never reaches the next request or job). - Who is judged. Only members of the profile. Administrators (
base.group_system), the superuser and any call undersudo()are never judged. An administrator is never a member, so the rows cannot block the administration of the rows. - Cost. The platform wraps only the declared (model, method) pairs, once the registry is ready. A model nobody blocks carries no wrapper, and a non-member pays one cache lookup on a wrapped method. Adding a pair makes the other workers reload their registry once; deactivating or deleting a row, editing the targets of a wrapped pair and every membership change only clear a cache.
- Methods a button cannot name. The CRUD that restrictions govern and the methods the other kinds own (
write,create,unlink,read,search_read,web_save,copy,load,export_data,message_post,get_views,run,action_send_mail, ...; the list is the contract'saccess_operation_policy.deny_methods) and any name with a leading underscore. The models are the ones restrictions refuse, without their.filtersexception. - «Send message» also blocks «Send by email». The send buttons of quotations and orders open the full composer in comment mode, which is the
messageoperation; for invoices the «Send & Print» wizard is covered as a directmessageentry. Mass mail (composition_mode == 'mass_mail') is a separate operation that no kind covers. - What stays readable. An
exportblock stops the export file; the member still reads the same records withsearch_read. To stop reading use a read restriction. Anactionblock stops the action from loading; the data and the views stay governed by the restrictions and theview_typerows. A button hidden by a view change stays callable over RPC. - Direct aliases. The web client reaches some operations through another public method of the same model:
web_saveandweb_save_multicreate or write,copy_multicopies. One table (DIRECT_ALIASES) makes a call dispatched as the alias the same direct operation for every wrapped pair, on the lines that have the method. - Server actions. A
server_actionrow refuses the member's DIRECT run of that action (an RPC ofrunon its id,/web/action/run, a path action ofload_breadcrumbs); the same action run by an allowed button or by another action is a consequence and passes. A member who runs a server action DIRECTLY (/web/action/runor an RPC ofrun) and whose type is declarative and does exactly a blocked operation is refused as the direct operation: Duplicate Record (Odoo 19+;copy), Send message / Log note (mail_post;message/note), Schedule Activity (next_activity;activity), Create Record of amail.messageormail.activity, Update Record ofmessage_ids/activity_ids, and an Execute Existing Actions that holds one of them; only the action whose id the request named counts, so acodeaction that calls a declarative one is not refused.mail_postby «email» (17-19) is a message too: the template goes out throughmail.mailand shows in the record's chatter as anemail_outgoingmessage (measured on 17, 18, 19 and 20). An action of typecodekeeps passing, because what it does is the administrator's decision; so does the same action run by something else (an automation rule, a button). Odoo 17 and 18 have no Duplicate Record type. /jsonand exports. The experimental/jsonroute (18+) is refused to a member of ANY profile with anexportrow, for every model, not only the restricted ones: v1 closes it whole. The residual is the one theexportrow already has: the same records stay readable withsearch_read; use a read restriction to stop reading.- Portal prints. Odoo renders
/my/orders/<id>?report_type=pdf,/my/invoices/...and the like insudo(); the platform overridesCustomerPortal._show_reportand judges the report for the real user before the render, so an internal member with the report blocked is refused there. A portal customer or a non-member is not affected. - What it does not block. Sudo paths other than that one: a server action an automation rule runs, a button method Odoo calls in sudo (an online payment confirming an order).
- Deleted records. A row that carries a report, a server action, an action or a model is deleted through the ORM when that record is deleted (a module's uninstall included): audited, caches cleared, the other workers told. A row may carry only the record of its own kind (
report_idon areportrow, and so on); the validator refuses a stray one. - Audit. Every create, write and delete of an operation row writes
platform:access_operation_*tovizum.access.logand to the app'svizum.app.audit. Every refusal counts invizum.access.log: one row per user, operation and hour (platform:access_refused,pair,bucket,count), written in its own cursor so it survives the rollback of the refused request. The server log follows the counter: ONE WARNING per user, operation and hour, for the refusal whose upsert created the row (count1; the database decides, so every worker agrees); the repeats of that hour log at DEBUG, and thecountcolumn holds how many there were. A refusal that could not be counted stays a WARNING. A request whose RPC cannot be read logs its own warning, once per request. - One validator.
access_operation_errorinvapp_validation.pyjudges every row. It is mirrored by the contract'saccess_operation_policyandaccessOperationErrorinlib/native.mjs; the same cases (tools/access_operation_cases.json) run on both sides.vzm checkapplies it to thevizum.access.operationrows an app ships innative.records(ACCESS_OPERATION_INVALID;ACCESS_OPERATION_UNVERIFIEDwhen the model of an xmlid cannot be resolved without the Odoo atlas) and reportsMIN_PLATFORM_TOO_LOWfor a grant below 5.108.
Hidden versus blocked.
| you want | hide it (view change) | block it (operation row) |
|---|---|---|
| a menu, tab, filter, field or chatter button disappears | yes | not needed |
| a button's method refuses the member over RPC | no: call_button checks no view groups, so a hidden button stays callable | button row |
| Duplicate, Import, Export refuse | the menu entry disappears; the RPC stays callable | copy, import, export rows |
| a report or server action refuses | the toolbar entry disappears | report, server_action rows |
| a view type cannot be opened | no | view_type row |
Developer mode off. no_debug on a profile clears the developer mode for its members. The only writer of the session flag is ir.http._handle_debug; right after it the platform sets request.session.debug to an empty string when the session's user is a member, for ?debug=1, ?debug=assets and ?debug=tests alike, and answers a member's GET of /web or /odoo that carries debug with a redirect to the same address without it. A session that had debug on before the user joined the profile is cleaned at its next request, session_info carries no debug bundle parameter, and the technical menus and the base.group_no_one nodes the user's groups show in debug disappear. The flag grants no access right: it is a UI gate held on the server, so it does not stop an RPC that reads a field Odoo allows the user to read in any mode. Measured on Odoo 17, 18, 19 and 20 with a field declared groups="base.group_no_one", in one session per user: with the developer mode cleared a member who holds base.group_no_one reads nothing of it over RPC (read, search_read, web_read, export_data, fields_get), while a non-member with debug on reads it, so the gate also closes these fields. Administrators are never members, so their developer mode is never cleared.
Access Manager catalogue (5.111)
The Access Manager (com.vizumapps.hide_menu) restricts what members see and do, but a vApp cannot read ir.ui.view or ir.model.fields (the ORM policy denies ir.*). So the platform answers for it: vizum.app.api_access_view_nodes(model, view_types), called with Vizum.orm.call on vizum.app (permissions.orm call:api_access_view_nodes, min_platform 5.111, supports("access-node-catalogue")). It is read-only and metadata only: no record, no arch text, no domain and no group xmlid ever comes back (a has_groups flag stands for them).
Who may call it. All of these, in this order, or AccessError: an internal user; an administrator (base.group_system); the call arrives through the bridge from the app the platform reserves it to (com.vizumapps.hide_menu; a direct call_kw is refused); and that app's installed package is verified as published by Vizum (signed by the Vizum Store and carrying the pinned Vizum publisher's own signature, bytes intact). A package that only declares the Access Manager's key, a sideloaded one, or one signed by another key, is refused at install and at run time. The com.vizumapps.* prefix proves nothing.
await Vizum.orm.call("vizum.app", "api_access_view_nodes", ["sale.order", ["form", "list"]])
// -> { schema: 1, json: "<the answer as ONE JSON text>" } (JSON.parse(json): VizumAccessCatalogue)
view_types is null (every type) or a list of form, list (tree is accepted), kanban, search, calendar, pivot, graph, activity. The answer is one text under 3 MB; what a large model could not fit is counted in truncated. A model that can never be restricted (identity, security, credentials, the platform's own data: access_field_policy.deny_models and deny_prefixes) answers denied and no nodes.
Per primary view of each type (the model's first primary view of a type is its default; at most 8 per type), read from the combined arch computed as an administrator, never from a stored arch and before any per-user processing:
| list | what each entry carries |
|---|---|
buttons | <button> and <a> with type="object" or type="action": key (button:<method> for an object button, button:<action xmlid> for an action button), name, type (object / action), tag (button / a), string, model (the comodel for a button inside a sub-view), method, action_xmlid (an action button holds the action's database id once its view is loaded; the catalogue turns it back into the xmlid), in_header, smart (oe_stat_button), has_groups, blockable (a vizum.access.operation row of kind button / action can refuse it: for an object button the method is one the line lets an RPC call, checked by the same function the operation row's own validation uses, and not owned by another switch; for an action button the target is an ir.actions.* record) |
pages | key (page:<name>), name, string, fields inside it, has_groups; a page without a name has key: null and is listed but not offered |
filters, groupbys | key (filter:<name>, groupby:<name>), name, string, has_groups, and for a group-by the field. A search <filter> whose context has group_by is a group-by |
chatter | {present, kind}: chatter on Odoo 18-20, div.oe_chatter on 17 |
fields | the fields the view shows (name, has_groups), the fields of relational sub-views excluded |
A view Odoo cannot combine is listed with error: "arch_unreadable" and the others still answer.
model_fields: every field of the model with string, type, store, required, readonly, tracking, groups (a flag), relation, computed, related, inherited, rec_name, in_rule_domain (a record rule's domain names it), app_column (an x_vzm_* column an app added), has_default (the code declares one; ir.default rows are not read), dependents and the verdict per field restriction mode: ok_modes, refused ({mode: {code, reason}}) and offered_modes (the accepted modes this platform already ships; available_modes at the top level lists them).
- The modes are
restrict_write(read-only),restrict_empty(required),restrict_export(not exportable),restrict_read(no_read, not readable at all) andhide(hidden on screens and in exports). The verdict is the contract'saccess_field_policy, judged by one validator (access_field_errorhere,accessFieldErrorinvzm check, the same cases intools/access_field_cases.json). A mode withmin_platform: nullin the policy is judged but not offered yet. - What is refused, and the
code: a model or field on the deny lists (model_denied,field_denied); incompatible modes; an app's ownx_vzm_*column forrestrict_readandhide(app_column: the app's audience decides who sees it);restrict_write/restrict_emptyon a field that is not stored;restrict_emptyon a boolean, on a computed read-only field, or withrestrict_writeon a field without a default; and forrestrict_read: a required field without a default, Odoo'sno_read_deny_fields(active,company_id,state, the record name…), a field a record rule names, the fields Odoo's invoicing and reports read (no_read_flow: the sale order's customer reference, validity date, salesperson and payment terms, and the invoice's note, salesperson and payment terms, each with its measuredevidence), a field other screens name (no_read_dependents) and a field tracked on Odoo 20 (no_read_tracked: the change is written into the message body, readable by whoever sees the record). - A field that goes together with others (SDK 5.114, feature
access-catalogue-requires-pair). The catalogue has no profile, so a field whose value Odoo copies into another field (no_read_copy,no_read_computed) is not refused any more when the pair rule is its ONLY obstacle:restrict_readleavesrefusedandok_modesand moves took_with_pair_modes(offered_with_pair_modesfor the ones this platform ships), and the field carriesrequires_pair,[{model, field, via}]:
"standard_price": {
"ok_modes": ["restrict_write", "restrict_export"],
"refused": {"restrict_empty": {"code": "required_computed", "reason": "…"}},
"ok_with_pair_modes": ["restrict_read"],
"offered_with_pair_modes": ["restrict_read"],
"requires_pair": [{"model": "product.product", "field": "standard_price", "via": "source"}]
}
requires_pair is the closure of the field's copies and sources by the validator's own rule (the members' copies and sources too, nearest first), and every member names the others from its own side (product.product.standard_price lists product.template.standard_price, via: "copy"). via says what the LISTED field is to this one: source (it holds the value this field copies), copy (it keeps a copy of this field), group (the same closure, two hops away). Other values can follow (sibling: the sibling tables of Odoo 20, contact_address and contact_address_inline, will feed the same list). The rule for a tolerant reader: an unknown via means «restrict together»: create the row of that member with the others, never skip it and never refuse the field because of a value you do not know. The list is empty, and ok_with_pair_modes too, for every other field. How to create a pair: one create([...]) of vizum.access.field with the row of the field and one row per entry of requires_pair, all restrict_read: true, in the same profile (through Vizum.orm.call("vizum.access.field", "create", [[row, ...]])). The same applies to switching the rows on, off or removing them: in one call. A row with one side only is refused («restrict together: A + B»). Each profile is judged on its own: restrict the pair in every profile that needs it. A pair with a member that is refused for another reason (a record rule, a screen that names it, the deny lists, a field tracked on Odoo 20, a flow field) is not offered: restrict_read stays in refused with that member's code (the cost on Odoo 20 reads no_read_tracked), a reason that names the member and blocked_by: {model, field, code}; requires_pair is empty. The cost family (SDK 5.120, feature access-cost-family). via: "cost_family" marks a member of the ONE group that carries a product's cost. restrict_read of product.product.standard_price alone leaves the cost readable through the fields that hold it or subtract it from a price, and no depends links most of them to it, so the platform lists them itself (models/access_cost_family.py, each entry with the Odoo source line that proves it): product.product / product.template standard_price; sale.order.line purchase_price, margin, margin_percent (price - margin = cost); product_margin's normal_cost, purchase_gap, expected_margin, expected_margin_rate; the valuation's value_svl (17, 18), avg_cost, total_value (cost x quantity, as indirect members: no dependency edge, the link is data written at creation on 17 and 18); stock.lot standard_price (Odoo 18 to 20: the lot's cost is seeded from the product cost and follows it for a standard or average product; total_value, avg_cost come from it), sale.report margin and the point-of-sale pos.order.line total_cost, margin, margin_percent, pos.order margin, margin_percent, report.pos.order margin (measured readable by a salesman); on Odoo 19 and 20 stock.move value, remaining_value, value_manual (quantity x cost, written when the move is done); on Odoo 20 project.project estimated_cost, estimated_cost_ratio and, with the enterprise sale_account_accountant, sale.order.line unit_cost, total_cost. A member is in the list when its field is in the registry (an uninstalled module adds nothing; no version number decides). Every member lists every other (requires_pair), and the members' own copies follow (sale.order.margin, stock.move.standard_price: via: "group"). The validator refuses restrict_read of one member while another stays readable in the same profile: no_read_cost_family («restrict together: A + B»); create the rows in ONE create([...]) as for any pair. Members that only a stock manager reads natively: the landed-cost adjustment lines (former_cost, final_cost, every line) and the valuation layers of Odoo 17 and 18 (value, unit_cost, remaining_value) are members, because a profile can contain a stock manager; compute_landed_cost and button_validate of a landed cost run as the member with the cost lift on (see the valuation seams). Not members: editable seeds (an event ticket's or booth's price, purchase.order.line.price_unit, product.supplierinfo.price (a purchase price, not the cost), account.analytic.line.amount), the invoice-based figures of product_margin (total_cost, total_margin, total_margin_rate: sums of vendor-bill lines, not standard_price), and stock.move.price_unit (a transaction price, measured); the table's EXCLUDED says why for each. On Odoo 20 product.product.standard_price is tracked, so the group stays refused there with no_read_tracked and blocked_by until that is decided. A group with more members than your install has simply is smaller; a member refused for another reason (a screen that names margin in a condition, for example) blocks the group and blocked_by names it. As everywhere in requires_pair, a tolerant reader treats an unknown via as restrict together. The manufacturing overview report (report.mrp.report_mo_overview) builds its data from the costs on Odoo 17 and 18 whether or not the member may read them; for a restricted member every amount under a cost key (mo_cost, real_cost, bom_cost, the unit and total costs, their decorators) comes back as 0, the keys and everything else unchanged and nothing elevated. On Odoo 19 and 20 the report reads the cost of the stock move as the member and used to refuse him (5.120); from 5.126 it runs under the cost lift (the read of the cost group and nothing else, off around a vApp's code) and answers him with the same zeros. The point of sale is a flow the restriction must not break. Odoo 17 loads standard_price as the cashier when it opens the session, so a cashier who is a member was refused the session; for that member the cost fields he may not read leave the loaded field list (no cost is carried, nothing is elevated). Odoo 18, 19 and 20 read the same fields as the cashier in another place, and Odoo refuses the WHOLE read when one name is unreadable, so the member's point of sale opened with no products at all (measured: 18 answers data: [] with the AccessError text under error; 19 and 20 answer an empty list and only log the error): the session reads the list of product.product._load_pos_data_fields (18, 19, 20) and of product.template._load_pos_data_fields (19, 20). For the member exactly the names he is refused leave those lists (the same lists feed the barcode search, the variant read and the receipt), and the conversion to the currency of the point of sale skips the refused price (19 and 20: _convert_pos_data_currency; 18: _process_pos_ui_product_product hands Odoo's own code a stand-in zero that is taken out again). Measured on 18, 19 and 20, Community and Enterprise, with the point of sale's currency equal to the company's and different: the session loads the products with no standard_price key or value anywhere in the payload, an order is validated (paid) and a cashier who is not a member gets the payload Odoo builds. The order line stores qty x cost when the order is created (pos.order.line._compute_total_cost, every line): for the member it runs as the superuser and the stored cost stays unreadable. Measured on 17: a restricted cashier opens the session, the payload has no standard_price, and an order is validated (paid). The front end reads standard_price only in the product-information popup and for a pricelist rule based on the cost, so those show no cost for the member (a cost-based pricelist rule cannot be priced there). A point of sale that prices from the cost is refused to a restricted cashier. The front end applies a price-list rule based on the cost in the browser (rule.base === "standard_price": models.js:338 on 17, product_product.js:175 on 18, product_template_accounting.js:107 on 19, :133 on 20), from the standard_price it loaded; a cashier who may not read the cost gets none and the rule computes a wrong price on a paid order. So pos.session.load_pos_data (17) / load_data (18 to 20) raises a UserError for a restricted cashier when a price list of that point of sale holds an active rule based on the cost: its default price list, every price list in available_pricelist_ids, and the price lists a rule based on a price list points to, however deep; a point of sale that does not use price lists is not affected. The cashier reads a plain sentence (translated); the administrator gets the point of sale, the price list, the rule, the profile and the two ways out (remove the cost-based rule from this point of sale's price lists, or lift the cost restriction for the user) in the platform log (pos_cost_pricelist_refused) and, before anyone restricts the cost, as warnings on the standard_price rows of the node catalogue ([{code: "pos_cost_pricelist", pos, pricelist, rule, rule_id, via, message}]; the key is on every row, empty when there is nothing to say). Any other cashier, or a point of sale without such a rule, loads as before. Set min_platform 5.120. An app's column derived from the cost (SDK 5.122, feature access-cost-family-apps). A column an app adds to an Odoo model (x_vzm_*: a native.fields entry with a formula or a related, a field of the app's own model, and the alias an app with native.aliases gives its column, which has no prefix) whose depends reach a member of the family, directly or through a related or computed field in between (an Odoo field, or another app's column), shows the cost the way the sale line's margin does: the Price List Manager's x_vzm_pricelists_margin is (list_price - standard_price) / list_price. Until 5.119 that column made the cost impossible to hide: the validator refused restrict_read of the cost (no_read_computed, naming the column) and refused restrict_read of the column (app_column). Now the column joins the family and is hidden with it. The platform finds it in the registry, only the columns an app owns (a manual x_vzm_* field) and never a native Odoo field: a native field that depends on the cost stays in the explicit table, with the Odoo source line that proves it. For every profile that restricts a member the column derives from, the column gets the same negation as the member, so Odoo refuses the member on read, search, group, order and export, fields_get and the views leave it out, and the bridge answers an AccessError exactly as for any other not-readable field (the app's other columns, its rows and its calls keep working; an app that reads the column for such a member must expect the refusal, as it must for the cost). The admin never restricts the column and is never asked to: it is not in requires_pair (a reader that treats an unknown via as "restrict together" would ask for a column the platform refuses to restrict by hand), the validator no longer counts it as a copy of the cost, and the catalogue row of every member lists the columns in also_hidden ([{model, field, string, app_key, app_name}], the owning app's name included, so the admin sees what else will be hidden); the column's own row says follows_cost_family: true. What counts as derived is what the compute reads as well as what it declares: the compiler adds the names a formula reads to its depends (a formula is a plain expression, so the list is complete), an install made before that is judged by the body it runs, so a formula that reads standard_price with depends: [] is still joined, and a compute by an app's method, which reads fields nobody can list, is joined whatever depends it declares on a model that carries a family field or reaches one within two relation hops (the also_hidden entry says reason: "method compute near cost"; over-hiding is the accepted price). Residual: a method compute that reaches the cost through 3 or more relations and declares no such depends is not auto-joined; the publisher must declare its depends, and the store review checklist flags such computes. The module upgrade repairs the formula columns of an app installed before the compiler added the reads: it gives them their depends and recomputes the stored values, so they follow the cost. Ownership comes from the installer's ledger; the name x_vzm_* is only the fallback for a column the ledger does not name (a field made by hand is still hidden), and each also_hidden entry carries ownership (ledger, own_model, alias, name). The stored value is still computed for everyone: the recompute runs as the superuser (a stored computed field is computed with superuser rights), so a restricted member's write that triggers it (a purchase receipt that averages the cost, a sale line, a price change) is not refused, and the value is the one a non-member's write gives (measured on Odoo 17 to 20). On Odoo 19 and 20 the group is still refused for the screens that name standard_price (no_read_dependents), as before; this feature does not change that. Set min_platform 5.122. Temporary: on Odoo 20 one compute (res.partner._compute_address) produces four fields together, contact_address, address, contact_address_inline and address_inline, and contact_address_inline is tracked. restrict_read on any of them is refused with no_read_sibling_tracked («<field> is computed together with contact_address, address, contact_address_inline, address_inline and contact_address_inline is tracked, so the value would stay readable through it»), by the evidence that a member of the group exists and is tracked (and only where a tracked field leaks, Odoo 20). The sibling-table PR deletes that code; do not build on it. The rec_name: res.partner.display_name is on the deny list of every line, so a pair whose closure reaches it (email and vat do) is refused with field_denied and blocked_by: {model: "res.partner", field: "display_name"}; measured on stock Odoo 17-20, every such field is refused earlier for another reason (a screen names it, a name search), so no field shows blocked_by display_name there.
dependents(M9) lists, as<view type>#<view id>/<node key>:<attribute>(<model>:<type>#<id>/…for a view of another model), every node that NAMES the field and that the view hook cannot rewrite for a member who cannot read it (from 5.126:options,context.group_by, a view attribute that is a field name, a kanban expression, thenameof a calendar filter or a list groupby; a context, a modifier, adecoration-*, a domain and afilter_domainare inneutralized, withneutralized_total, and refuse nothing: «Screens of a profile's members»), in any active primary view of any type, listed or not (the Access Manager lists eight views per type at most, but every one is scanned). A field is named by: a modifier (invisible,column_invisible,readonly,required,attrs,states,decoration-*); a search<filter domain>, a search<field filter_domain>and a filter'sdate(they name THIS model's fields, unlike a relational field'sdomain, whose leaves name the comodel and of which only the operands count); acontextoperand orgroup_by;options; a view attribute that points at a field (default_order,default_group_by,fold_field,progress,parent_fieldand the calendar'sdate_start,date_stop,date_delay,color,all_day,create_name_field,recurrence_update,aggregate; the list is checked against each Odoo line's own RNG schemas, arch validators and calendar parser by a test), thenameof a calendar<filter>and of a list<groupby>; a kanban QWeb expression overrecord.X; andparent.Xin a sub-view, which counts for the PARENT model. A dotted domain leaf (order_id.user_id) counts for each hop (sale.order.order_idand theuser_idof its comodel), an inherited field also for the_inheritsparent's field, and the inline sub-views of other models that hold an x2many to this one are scanned too (a modifier onres.partnerinsideres.partner.category's form is a dependent of the partner's field). A field's own modifiers do not count.dependentsis capped (12 per field) anddependents_totalsays how many there were.- Nothing is cut silently.
truncatedcounts what a cap removed:views(not listed),nodes(per-kind cap),view_fields,dependents,model_fields,views_unscanned, andarch_unreadable(a view Odoo could not combine). The work of one call is bounded: views are read from the database in batches and only while the budget lasts, the rest is a counter, a view whose own arch is over 1.5 million characters is not combined, and a combined arch with more XML elements than the budget has left is counted with a bounded walk and dropped. When any bound is reachedbudget_exceededistrue, even if it was the last view.budgetsays which one:{exceeded, bound, count, limit}withboundone ofviews,nodes,time,holder_models,arch_chars(the first reached), and the platform logs one WARNING per scan naming the model, the bound and the db. A view that could not be combined, or a bound reached, makes the scan incomplete: every field then carriesdependents_complete: falseto the validator andrestrict_readis refused withno_read_dependents_unknown(hiding on screen does not need the list). The scan is cached per registry and per state of the views (a change counter that moves on every create, write and unlink of a view, so two edits in one transaction are both seen), so the 3a/3b validator can ask once per row. in_rule_domainis true when a record rule (ir.rule.domain_force,ir.access.domainon Odoo 20) reads the field on its own model, through a related model (order_id.user_idreadssale.order.user_id) or through an_inheritschild (a rule onres.usersthat namescommentreadsres.partner.comment).has_defaultsays the code declares a default without running one that could touch the database: a constant is judged by its value (lambda: Falseis no default), any other code is assumed to answer;ir.defaultrows are not read.- Every view has
view.has_groups(the primary view itself is restricted to groups; a flag). - For 3a.
restrict_writeon a required field with no default refuses the value the member sends but the member must still send one to create the record; the validator pairs it withrestrict_emptyonly when a default exists (required_no_default). - Audit. Every answered call writes
platform:access_catalogueinvizum.app.audit(model, bytes, ms,budget_exceeded, thetruncatedkeys; the time is also the row'sduration_ms). A call that is refused or has bad arguments is counted, not logged row by row: ONEvizum.access.logrowplatform:access_catalogue_refusedper user, reason (not_internal,not_admin,not_bridge,not_verified,arguments) and hour, with acount, written in its own transaction so the refusal's rollback does not take it; the first refusal of the hour logs a WARNING and the repeats log at DEBUG, so a user who repeats a refused call cannot grow the table. - Hiding a node on screen is UI only. The catalogue says what can be hidden; the server-side enforcement of a button's method, the chatter's posts, export, import and Duplicate is the blocked operations above, and of a field's value the field restrictions. A hidden node is still reachable over RPC unless one of those blocks it.
Native field restrictions: read-only, required, not exportable (5.112)
A restriction profile also restricts single fields of Odoo's own models. A vizum.access.field row (one per profile and field, written through an orm grant like the other models of the profile, min_platform 5.112, supports("access-field-restrictions")) says «the members of profile_id may not change / must fill in / may not export this field».
| field | what it says |
|---|---|
profile_id | the profile whose members are restricted |
model_id, field_name | the ir.model and the technical NAME of the field. An app cannot read ir.model.fields: it sends the name the node catalogue listed, and the server resolves it (ir.model.fields._get) against this registry; an unknown name is refused with «is not a field of <model>: choose one the catalogue lists» |
restrict_write | Read-only: the members cannot set the field; reading it works |
restrict_empty | Required: a record the members create holds a value, and a write of theirs cannot empty it |
restrict_export | Not exportable: no export path of theirs reaches the field, at any level |
restrict_read | Not readable at all (5.113, supports("access-field-no-read")): Odoo itself refuses the field to them, server-side; see «Not readable at all» below |
active, note | switch the row off without deleting it; why it exists |
vizum.access.profile gains field_ids. The modes combine where the validator allows (read-only plus required needs a default; see below). restrict_read («not readable at all») is described at the end of this section (5.113); hide (hidden on screens) arrives with a later phase and the validator refuses it today.
The principle: block the member's direct action, never Odoo's consequences. A read-only field refuses the value the *member's own request* sets. Odoo's own writes to that field while it carries out an action the member may do pass: with sale.order.state read-only, the member still confirms the order, and action_confirm writes state; a stored computed field follows the field the member renames. Exception, by design: a read-only active also refuses the member's direct action_archive, action_unarchive and toggle_active, because those methods ARE the member writing active (a job or an Odoo flow that calls them is not judged). The platform wraps only the pairs a row needs: create / write / load of the restricted model, the same two on every model that delegates to it (_inherits: res.users for a res.partner field, product.product for a product.template field), the model of the lines of a restricted one2many, ir.attachment for a binary field, the archive methods for active, and the export_data of every model. A model nobody restricts carries no wrapper, and a non-member pays one cached lookup. Administrators, the superuser and sudo() are never judged.
What «the member's own request» is. The arguments of the method the request dispatched, walked for create, write, update (public RPC on Odoo 17 and 18), web_save, web_save_multi, load, name_create, copy (and its default), message_post (its arguments name the mail.message fields it sets), and message_new / message_update. The x2many commands (0, _, vals) and (1, id, vals) are followed into the comodel, and the default_<field> keys of the context are read. Setting a field also sets what it stands for: the same field on a delegation parent (_inherits), the target of a writable related field, and the one2many a many2one is the inverse of.
message_new and message_update are what the mail gateway calls (fetchmail, as the superuser or the alias owner). They are judged ONLY when they arrive through a client entry (call_kw, the bridge): an incoming email still creates and updates records normally.
An echoed default is not a choice (create only). The web client sends back the defaults it was given. On a create, a value equal to the default the field would have anyway — computed in the member's own user and company, with the default_* keys of the context stripped, ignoring any default the member saved for themselves, and compared as the field keeps it in the cache (dates, command lists, empty html) — is not the member's choice and passes. A write equal to the default still counts. A default_<field> key in the context of a create IS the member's value: an action that pre-fills a read-only field is refused for the members (fail-closed; the app's UI must not offer that field in such an action).
Routes:
| route | how it is enforced |
|---|---|
call_kw, call_button, /jsonrpc, XML-RPC, JSON-2 (19+) | the payload of the dispatched call |
web_save, web_save_multi (19+) | the same: their vals are the member's |
import (load, and the import wizard of base_import) | the column paths; a refused row is not imported |
| binary / attachment into a field | a direct ir.attachment create or write whose MERGED res_model + res_field target (new values, then default_res_* of the context, then the current values) is the field; an upload that only attaches a file has none and passes |
website form (/website/form/...) | extract_data for a logged-in member: a read-only field of the model, or a required one that is missing and has no default, is a form error; public visitors are never members |
chatter post /mail/message/post, the portal chatter of Odoo 17, message edit /mail/message/update_content | the controllers judge the mail.message fields of the values (Odoo posts as sudo()); message_post by call_kw is judged on the same fields; _message_update_content is private, Odoo refuses it |
| discuss / mail composer | mail.compose.message and mail.message creates are plain RPC; what the composer then writes on the target record is Odoo's own send logic and passes |
| any other HTTP route of the platform's reach | read as unreadable: it refuses the restricted fields in its values (the error goes in the strict direction) |
Through a vApp. A call a vApp's client makes for the member (api_orm, api_action, api_mail, api_activity, api_assistant, assist_write) is the member's direct action, exactly as for blocked operations (G-5): a member's write of a read-only field through any installed vApp is refused, and api_mail names the message fields it posts. The app's own scheduled job, trigger or webhook is the app's flow and passes, whoever is the job user. A vApp cannot export through the bridge at all (export_data is a forbidden call: target). api_orm admits only context.lang, so a bridge call cannot send a default_<field>.
Required. After a create the member's request asked for, every record it made holds a value (0 is a value), else ValidationError. This is a budget, not «refuse if missing»: the request says how many records of the model it asks for (the rows of a create, the (0, _, vals) commands, the rows of an import, the copies), and ONLY that many are judged, the first ones; a record Odoo adds beyond that count, or creates INSIDE the member's create of the same model, is Odoo's own and passes. Without the budget a read-only or required field would refuse Odoo's own helper records (a child contact, a template made for a variant) that the member never sent. A write that empties the field is refused: a scalar before it runs, an x2many ((5,), (6, 0, []), (3, id)) on what is left when it is done.
Not exportable. export_data refuses a path that reaches the field at any hop (partner_id/ref on sale.order; ref on res.users and a template field on product.product, which take them from a delegation parent; a stored read-only related field such as x.secret = partner_id.ref exports its target, so it is refused and not offered either), and /web/export/get_fields leaves the field out of every level it lists. search_read, read and the pivot's xlsx export still return the value.
The validator. One validator (access_field_error, the contract's access_field_policy, the vzm check mirror) judges every row. It refuses the deny-listed models and id and the magic fields; restrict_write and restrict_empty on a field that is not stored; restrict_empty on a boolean, on a computed read-only field, and together with restrict_write on a field without a default. On the app's own x_vzm_* columns read-only, required and not exportable are allowed. vzm check judges the vizum.access.field rows an app ships in native.records (model_id by xmlid, field_name by name; the atlas gives the type and whether the field is stored, the rest is the platform's to judge at install, so the check always says «not fully verified»), and each mode needs min_platform at its own floor (5.112; restrict_read 5.113).
Audit and uninstall. Every create, write and delete of a row writes platform:access_field_* to vizum.access.log and to the app's vizum.app.audit; every refusal counts in vizum.access.log like a blocked operation (field_write:<model>.<field>, field_empty:..., field_export:...). Deleting the profile, the model or the field, or uninstalling the app, deletes the rows through the ORM: no wrapper keeps judging.
Dynamic fields. A field Odoo computes on screen as the member works (price_unit, date_order) can be made read-only here; the value the web client sends back for it is the one Odoo computed, which this guard cannot tell from a typed one when it differs from the field's static default. Such a field becomes usable only once the screens phase makes it read-only on screen too (the client then does not send it).
Residuals (named, not silent).
- A controller that writes a record as
sudo()or the superuser with values taken from the member's form, other than the website form, cannot be judged: the platform cannot see the member's payload behind a sudo write. On stock Odoo that is the portal account form (the route is/my/accounton Odoo 17 and 18 and/my/address/submiton 19 and 20; it writes the member's own partner as sudo); a third-party controller of the same kind is the same. Restricting those fields for portal members is not enforced there. - A non-RPC route of another module that writes as the member is judged on the fields in its values, which may refuse more than the member asked.
- A method the platform does not parse (a custom wizard
apply(vals),res.partner.update_address) is judged on thecreateandwriteit makes only when the member's request names the field; a value passed in a custom method's own arguments is not walked. - A vApp's own native method that writes the member's arguments through
V.ormis the app's flow. - A nested import path (
order_line/price_unit) is refused when the child model is restricted, but the import wizard reports it as a row message instead of anAccessError;name_createof the wizard is counted as an import row. - Required on the chatter's
mail.messageis enforced formessage_postbycall_kwand for the bridge; the chatter controller route (Odoo posts assudo()on 18 and later) is judged for read-only fields only. - A pivot's
export_xlsxwrites aggregated values of a field the member may not export. - The budget counts records in order: an Odoo-made record of the same model that finishes BEFORE the member's own, in a request that is not nested in it, takes its place.
- Until the screens phase ships, the field stays editable on screen and the save is refused with a message that names the field.
Not readable at all: restrict_read (5.113)
restrict_read (no_read, min_platform 5.113, supports("access-field-no-read")) is the one mode that IS field groups. The platform appends the profile's group, negated (!vizum_apps_platform.vzm_rp_<profile id>), to the groups Odoo gave the field, so Odoo itself refuses the member: read, search (domain and order), search_read, web_read, web_search_read, read_group and formatted_read_group, export_data and write raise AccessError; the field is missing from fields_get and from the member's form, list and search views; the member's create cannot set it (not even through default_<field> in the context, which Odoo 17 and 18 would otherwise accept); duplicating a record still works and copies the value (the copy is made by the platform, as the superuser). An implicit read, one that names no field, simply leaves it out. Odoo's own groups stay and the negation is AND-ed to them, so the field is only ever narrower than it was. Administrators, the superuser and sudo() are never restricted; the membership sync keeps an administrator out of the profile group, and a hand-made membership does not restrict one.
- The refusal is an
AccessErroron every line. Odoo 18, 19 and 20 crash with aValueErrorwhen they build the error text for a member who also holds the developer-mode group, because the builder resolves every group withenv.refand a negated group is not a record. The platform overrides the builder (18check_field_access_rights, 19_check_field_access, 20check_field_access) and raises Odoo's own text without the group list; Odoo 17 handles a negation itself. - A vApp's bridge call made for the member is refused, never stripped silently. The bridge reads as the member: a read that names the field (
read,search_readwithfields, a domain or an order on it) raisesAccessError; a read that names no field, or only other fields, works and the field is absent. A grantedfieldslist that includes the field makes a read with no request refuse, because that list is explicit. An administrator or a user outside the profile reads it as before. A field that must be invisible in an app without refusing the whole call ishide(a later phase). - Tracking. Odoo 17, 18 and 19 filter a tracking value by the reader's rights on its field: the member's chatter shows no change of the field and an administrator's does (measured on 17 through
message_format, on 18 and 19 throughmail.tracking.value._filter_has_field_access). Odoo 20 writes the change into the message body, readable by anyone who sees the record, so a field tracked on 20 is refused (E1); a field that becomes tracked after its row (another module) is not tracked, for any user, while it is not readable. - A trail of the change, on Odoo 20 only, without the value. Because Odoo 20 does not track such a field (see above), the platform records THAT it changed, never WHAT: one
vizum.access.logrow per record and write (action=platform:tracked_field_changed; payload = model, record id and field names; the real user,sudo()included). Nothing about the old or the new value is stored. The log is readable bybase.group_systemonly, so a restricted member cannot read their own events. It is not full auditing. It covers writes made through the ORM (write). It does not log: acreate; a stored computed field that is recomputed; a change made with raw SQL or by anything that bypasses the ORM; an x2many field changed from the other side (the child record's own write) is not logged against the parent, and a command on the field itself is logged only if the set of linked records differs after the write. A binary is never read to find out whether it changed: one stored as an attachment is compared by the attachment's checksum; one stored in the table (attachment=False) is logged whenever a write names it, so that case can over-report (an event for a write that left the bytes as they were).tracking_disableandmail_notrackdo not silence it. On 17, 18 and 19 nothing is logged, because Odoo's own tracking record is there (hidden from the member).
Because the refusal is Odoo's, every Odoo feature that reads the field AS the member fails with it. That is why restrict_read is explicit, and why the validator (access_field_error, the contract's access_field_policy, the vzm check mirror) refuses it where Odoo's own flows would break or leak:
| refused | why | policy |
|---|---|---|
| the fields Odoo's invoicing and reports read (E2) | creating the invoice, posting it or printing the quote or the invoice fails for the member: sale.order.client_order_ref, validity_date, user_id, payment_term_id; account.move.narration, invoice_user_id, invoice_payment_term_id | no_read_flow_fields, with the measurement that justifies each (the flow suite of tests/test_access_fields_no_read.py forces one field at a time to the negation on Odoo 17-20 and pins which steps fail, including Send & Print); a new entry needs its measurement in the same change. They can still be hidden on screen. |
_rec_name, display_name, active, state, company_id, company_ids, currency_id, the magic fields, id | record rules, name display and workflows read them | no_read_deny_fields |
| a field a record rule's domain names | the rule is evaluated as the member | catalogue in_rule_domain |
| a field another part of a screen names where the view hook cannot rewrite it (M9, from 5.126) | options, a group-by in a context, a view attribute that is a field name (default_order, date_start, color…), a kanban expression, the name of a calendar filter or a list groupby, attrs, states, in any primary view of the model or of a model that holds an x2many to it: the view would not load. A context, a modifier, a decoration-* and a domain that name it are rewritten for the member (5.126, «A screen that never names what the member cannot read») and refuse nothing. The refusal lists the nodes. A scan that could not be completed (a view that could not be read, the work budget) refuses too | catalogue dependents, dependents_complete |
| a field tracked on Odoo 20 (E1) | the change is written into the message body | no_read_tracked_refused_from_line |
the four fields one compute produces on res.partner (Odoo 20: contact_address, address, contact_address_inline, address_inline) while one is tracked — temporary, 5.114 | the address stays readable through the tracked sibling; the sibling-table work replaces this refusal with the real rule (the group joins requires_pair) and removes the code | no_read_sibling_tracked (evidence: a member exists and is tracked) |
a member of the cost family while another member stays readable (no_read_cost_family), 5.120 | the cost is also shown by the sale line's purchase_price, margin, margin_percent, the product_margin figures, the valuation and, on Odoo 20, the project's estimated cost: restrict them together in one request | models/access_cost_family.py (COST_FAMILY, each entry with its Odoo source line) |
a copy of a field that stays readable, or a field whose copy stays readable (no_read_copy, no_read_computed) | a copy computed as the member from another field (product.template.standard_price from product.product.standard_price, image_512 from image_1920) shows the value while its source is readable, and the source shows it through its copy | the two go together in one request (create([...]), or both rows switched on, off or removed in one call): «restrict together: A + B». One side cannot leave alone. Each profile is judged on its own. A row saved with one side only is kept, logged and written to vizum.access.log (platform:access_no_read_unsound) at registry load. The catalogue offers the pair (requires_pair, 5.114). |
an app's own x_vzm_* column (Q6) | the app's audience (5.107) decides who sees it; a column computed from the cost is not restricted by hand either: it follows the cost family (follows_cost_family, also_hidden, 5.122) | app_column_refused_modes |
a field inherited through _inherits (D1) | the row would do nothing: restrict the parent's field (product.template.list_price, not product.product.list_price); the delegated copies follow the parent | inherited_from |
| a field with a stored or superuser-computed copy (D2) | res.partner.email feeds email_normalized, stored in its own column: Odoo would still show the copy. The refusal lists the fields | computed_copies |
a field a name search reads (_rec_names_search) | the search matches on it as the member | rec_name |
| a required field with no default | the member could not save a record | validator |
restrict_read together with restrict_empty | contradictory | incompatible_pairs |
No sudo() is added to make a flow pass: the choice is between the field and the flow. The node catalogue (api_access_view_nodes) gives the verdict and the reason per field before a row is written.
Lifecycle. A restriction is added at once and removed only after the commit. A row created or switched on (or its profile) changes the fields' groups in place, in this process, in one pass that applies the negations; a row deleted or switched off, a profile deactivated or deleted and the uninstall of the app that made it stay in force until the transaction commits, and then the groups are read again from the committed rows. So a savepoint that rolls a removal back leaves the field closed, never open. A rollback of the whole transaction reads them again too (Odoo up to 19; Odoo 20 sets the registry up again). The other workers are told after the commit and rebuild their registry at their next request, reading the rows with SQL as at every setup. While the code runs ahead of the database (before -u) the map is empty.
Related copies. Odoo copies a field's groups into every related field during setup, before the platform adds the negation. The platform therefore appends the negation to every related field whose chain ends at a restricted field (res.company.website, account.move.line.ref, mail.followers.email) and restores them the same way. The consequence is the rule of this mode: a report that prints a related copy as the member (the company's website in a report header) fails for the member too. Computed copies cannot be guarded that way, so the validator refuses the field while a stored or compute_sudo field depends on it.
Residuals (named, not silent).
- The validator judges a row when it is written. A view, a record rule, a tracking declaration or a computed field that arrives later (a module installed after the row) can conflict with it; nothing rechecks at registry rebuild. Re-save the row after installing modules, or hide the field instead.
- Odoo 17 and 18 skip the check on a value that is already in the cache. A
sudo()read fills the cache, and a later attribute read by the member in the same request (a report, a template or a computed field rendered as the member after asudo()step) returns the value without the groups check; a non-storedrelatedcopy read as an attribute is not checked on 17 and 18 either. RPC reads start from the database and are checked. Odoo 19 and 20 check on every attribute read. - A restriction added and committed reaches the other workers when they see the registry signal: a few seconds in other workers, like any Odoo permission change. In this worker it is in force at once.
- A pending removal is invisible to the OTHER workers until the commit signals them, as for any registry change. A savepoint rolled back after an ADDITION leaves the field closed in this worker until the commit or rollback of the transaction reads the database again (fail-closed).
- The platform's own sudo paths (offline sync, feeds,
native.methods, the assistant) read as the superuser or with their own field filters; they belong to the bridge-visibility work (5c/5d) and coverrestrict_read, not onlyhide. - A third-party report, wizard or
computethat reads the field as the member fails with anAccessErrorand is not in the validator's list; the validator only knows what the measurements and the platform's own screens show. The value stays stored in clear in the database and is readable by any code that runs assudo(); a custom wizard or controller that reads it withsudo()returns it to the member.
Screens of a profile's members: nodes, attributes, and a screen that never names what a member cannot read (5.126)
vizum.access.node rows (Access Manager phase 5b) change what the members' web client is told, not what the server accepts. A row is one (profile, model, view type, node key); the key is semantic and is what the node catalogue lists (button:<method or action xmlid>, page:<name>, filter:<name>, groupby:<name>, chatter, root, field:<name>), so an arch that moves a node does not stale the row and a node a module's uninstall removes leaves the row inert (resolved reads false; nobody's screen breaks).
| row | what the member's screen gets |
|---|---|
button, page (hide) | invisible="1" on a form or list, the node removed from a kanban or an activity view (a QWeb template does not read the modifier) |
filter, groupby (hide) | removed from the search view, so a search_default_<name> of an action finds nothing to activate |
chatter (hide) | <chatter/> (Odoo 18-20) or div.oe_chatter (17) removed |
root (root_flags) | create, edit, delete, duplicate, import or export_xlsx set to False on the view root; Create / Edit / Delete also go where a profile restriction refuses the operation on the whole model (no domain, no company scope) |
field_attr (attrs) | readonly, required, invisible (true, or a vetted condition over the model's own fields: no call, attribute or subscript), options (no_create, no_create_edit, no_open, no_quick_create), a cosmetic domain; the condition joins Odoo's own with or, it never replaces it |
ONE mechanism. An override of ir.ui.view._postprocess_access_rights, which Odoo runs per user after its view cache, on a fresh parse of the cached arch. There are no platform views and no xpaths (an inherited view cannot carry groups, and an xpath into another module's node breaks every user the day that module goes), and one user's screen can never reach another's. Administrators, the superuser and a user no profile reaches return at once; the policy of a member is one ormcache entry keyed on (uid, group set), dropped by the same clear_caches signal as the registry. A user the profile does not reach gets Odoo's arch byte for byte.
Screen only. Hiding a button does not block the method (phase 7's button operation does), hiding a tab does not hide its fields (restrict_read does), a hidden field is readable over RPC unless it is also not readable, and a read-only or required mode shows on screen but is enforced by the field guard (3a). The field mode hide is offered from 5.133: it also hides the field in every vApp (5c) and on the platform's own read paths (5d), both below.
A screen that never names what the member cannot read
A field restrict_read takes away is gone from fields_get, from every read and from the form. A view can still name it in an expression the client evaluates: purchase puts context="{'default_price': standard_price}" on the vendor lines of the product forms (purchase/views/product_views.xml, the seller_ids and variant_seller_ids of the template and the variant forms), sale_margin makes a column readonly by margin_percent. On Odoo 19 and 20 the server answers such a view with a hidden helper <field name="X" data-used-by="…"/> so the client can evaluate the expression, and reading it as the member raised an AccessError: the product form did not open. restrict_read of the cost was therefore refused as no_read_dependents wherever purchase is installed (M9).
From 5.126 the hook rewrites the member's own copy of the arch, for the fields their profile makes unreadable and for nobody else, so that no expression names such a field, and removes the helper node (and any <field> node of an unreadable field). The value used, attribute by attribute:
| attribute that names the field | value used | why | |
|---|---|---|---|
context | the name is replaced by False, the other keys stay ({'default_price': False, 'k': 1}); parent.X likewise | the flow keeps working with the value empty: a vendor line is created with an empty price, never with the cost | |
invisible, column_invisible | 1 (hidden), the whole attribute | fail closed: it shows nothing it cannot judge. The whole attribute is replaced, never partly evaluated: a partial evaluation could leak the value through the SHAPE of its result (a side channel) | |
readonly | 1 (not editable), the whole attribute | the member cannot change what depends on a value they cannot see | |
required | 0 (not required), the whole attribute | the view-level required is UX only; the model's own required and constraints still apply server-side (approved by evolution), and demanding a value for a condition the member cannot evaluate could leave a record they can never save | |
decoration-* | False (no highlight) | cosmetic: nothing is revealed by it | |
domain of a relational field, filter_domain, a search <filter domain> | the leaf that names the field, as the field, through a dotted path (product_id.standard_price) or as an operand (('price', '>', standard_price)), becomes the constant leaf (0, '=', 1); the other leaves stay | the neutralized domain never selects more than the original: a leaf under an odd number of ! becomes (1, '=', 1) so the negation stays false; a fragment whose polarity cannot be told (`[' | '] + [...], an expression that decides between two domains) turns the whole attribute into [(0, '=', 1)]` |
A domain is placed leaf by leaf only when it is a list, a + of lists or a conditional over lists whose terms are constant operators or literal (field, op, value) tuples; anything else that names the field (a slice, a call, ['!' if x else '&', …], a starred term) turns the whole attribute into [(0, '=', 1)]. The subdomain of an any / not any leaf is read against the comodel and the WHOLE leaf takes the constant of its polarity. parent.parent.X (only one enclosing record is tracked) and X.attr are rewritten whole (False, never False .attr).
Where an attribute's text is not Python the client could read either, the attribute takes the same constant ({} for a context, the empty domain for a domain): it is never taken for innocent.
At runtime the hook also fails closed on what it cannot rewrite (a module installed after the row): the options entries that name the field are dropped, a group-by filter or a date filter on it is removed, a view attribute that IS the field (default_order=…) is deleted, a calendar filter or list groupby named by it is removed; the screen opens and the audit reports the row. A kanban t-* expression is not touched yet (follow-up).
What still refuses at validation (no_read_dependents, listed in the catalogue's dependents): options ({'currency_field': X}, {'color_field': X}), a group-by that names the field in a context (context.group_by), a view attribute that IS a field name (default_order, default_group_by, fold_field, progress, parent_field, the calendar's date_start, date_stop, date_delay, color, all_day, create_name_field, recurrence_update, aggregate), a kanban expression over record.X (qweb), the name of a calendar <filter> or a list <groupby>, a filter's date, and the legacy attrs and states. The list is UNHANDLED_ATTRS of vizum_apps_platform/models/access_neutralize.py; a test fails when the catalogue labels an attribute that is in neither list. What the hook rewrites is in neutralized (with neutralized_total) so an administrator can see which screens will change for the members.
Residuals (named, not silent).
- The rewrite is the member's screen. A third-party
ir.ui.viewthe web client reads withoutget_view(a website page, a QWeb report) is not an arch with expressions and is unaffected. - A required or read-only attribute chosen for the unknown condition can differ from what the member would have got had they been allowed to read the field: that is the cost of not revealing it (
invisiblecan hide a button the cost decided to show). - A view that arrives after the row (a module installed later) and names the field in an attribute that is not rewritten breaks that screen for the member: the validator judges a row when it is written. After every registry setup (
-u,-i) the activeno_readrows are checked against the views again, and each field a screen now names where the hook cannot rewrite it is REPORTED in the log and in the platform's trail (platform:access_no_read_screen, once per row and node list). The row is never deleted or disabled. - Purchase residuals, measured per line (tests
test_c6–test_c8): (1)purchase.order.line.price_unitis a stored compute that runs as superuser and, with no vendor price, takesstandard_price: a restricted member who creates a purchase line reads the cost there (17, 18, 19, 20); evolution is deciding whether it joins the cost family. (2) The purchase product catalog (/product/catalog/order_lines_info) reads the cost: on 18 and 19 as the member, so the «Catalog» button fails with anAccessError; on 17 and 20 it RETURNS the cost (on 17 an attribute read of the company-dependent cost is not checked at all; on 20 the route reaches it by a path that is not). (3) The first screen after a profile change can be stale because of the browser'sget_viewsdisk cache: a usability issue, not a confidentiality one. - The cosmetic
domainof a field row is advice to the client, not a restriction: the server accepts any value the field accepts.
##### Stock valuation for a member who may not read the cost (SDK 5.115, feature access-valuation-seams)
Odoo's stock valuation reads product.standard_price as the user of the request. For a member of a profile that made the cost unreadable (the pair restrict_read of product.product.standard_price and product.template.standard_price) that is an AccessError in the middle of an ordinary stock flow. Measured on Odoo 17, 18, 19 and 20 (a member who may run the flows, the cost restricted, one request per step):
| flow of the member | Odoo 17 | Odoo 18 | Odoo 19 | Odoo 20 |
|---|---|---|---|---|
| validate a receipt (price on the line) | works | works | works | works |
| validate a receipt, or finish an MO, of an average or FIFO product that already has stock | works | refused (product_price_update_before_done) | works | works |
| validate a receipt with no price on the line | works | refused (_get_price_unit) | works | works |
| validate a receipt of a standard-cost product | works | refused (_get_in_svl_vals) | works | works |
| validate a delivery | works | refused (_prepare_out_svl_vals) | refused (_set_value; standard and average cost, not FIFO) | works |
| confirm an MO and mark it as done | works | refused (_prepare_out_svl_vals) | refused (_set_value; standard and average cost, not FIFO) | works, but refused for a standard-cost product (_cal_price) |
| create, compute and validate a landed cost | works | works | refused (_get_value_from_std_price) | refused (_get_value_from_std_price) |
| pick the product of a landed-cost line | shows the cost | refused | refused | refused |
(Odoo 17 checks field groups when a field is *read*, not when server code reads it as an attribute, so nothing refuses there and the landed-cost line leaks; the wrappers are installed on 17 as well so that the flows stay whole the day the attribute read is judged there too.)
The rule. For that member, and only for the methods above, the platform runs the valuation computation as the superuser (sudo(): the uid stays, so the user of the request is still known). The value is computed and written (the valuation layer on 17 and 18, the value of the move on 19 and 20, the unit price of the move); it is never returned to the member: those methods return plain numbers and dictionaries that the stock code writes with sudo() already, and a recordset that comes back is bound to the member's environment again. The member still cannot read standard_price, on either field of the pair, by any path (read, web_read, search_read, export). A user who is not a member, the administrator, a cron, and a call that already runs as the superuser get Odoo's own code path, unchanged: the flows write the same values for a member and for a non-member (compared in the tests, for the standard, average and FIFO cost methods, with manual and with automated valuation).
The landed-cost line (stock.landed.cost.lines.onchange_product_id) shows the cost of the service product as its price. For the member the price is 0 and the member types the price, as on a purchase line (5.117): the rest of the onchange (the name, the split method, the account) is Odoo's. Everyone else keeps the cost. Only a virtual line (the web client's onchange) is handled; the method is public, so a call on a saved line is never run as the superuser.
The point of sale (SDK 5.120, the cost family). pos.order.line._compute_total_cost stores qty x cost when an order is created (every line of Odoo); for a restricted cashier it is wrapped like the methods above (as_superuser, record access checked first) and the stored cost stays unreadable for him. On Odoo 17 pos.session._loader_params_product_product lists the fields the session reads as the cashier: for a member the cost fields he may not read leave the list (access checked first, nothing elevated, no value carried). On Odoo 18, 19 and 20 the same is done on product.product._load_pos_data_fields and, on 19 and 20, product.template._load_pos_data_fields (the lists the session, the barcode search and the variant read use), with _convert_pos_data_currency (19, 20) and _process_pos_ui_product_product (18) following it.
Creating, duplicating and varying a product (SDK 5.126, Odoo 19 and 20). stock_account's product.product.create ends with products._change_standard_price({product: 0 for product in products if product.standard_price}) (19: stock_account/models/product.py:283, 20: :194), a read of the cost as the user of the request. For a member whose profile made the cost unreadable it raised an AccessError, so he could not create a product, duplicate one or add a variant (a variant is a product.product create too), although none of them touches the cost. The create now runs with the read-only cost lift of access_cost_lift (the same one the landed-cost flows use, 5.120): the read of the cost group is accessible inside the call and nothing else is. Odoo's access rights and rules on the product, its template and the attribute lines stay the member's own, a cost he sends in the values is still refused (the lift never covers a write of the cost), the lift is off around the code of a vApp, and afterwards the cost group is dropped from the cache. Odoo 17 and 18 have no such create and need nothing. The manufacturing overview (report.mrp.report_mo_overview) runs under the same lift on every line and answers a restricted member with every cost amount at 0, where Odoo 19 and 20 used to refuse him.
What the platform does not depend on. stock_account, mrp_account and stock_landed_costs are optional: each method is wrapped when its model and its method exist in the registry (a line of Odoo that does not have the method adds nothing). Every seam escalates only for records the user of the request may read (access rights and record rules, so the companies he works in): several wrapped methods are public (product_price_update_before_done, compute_landed_cost, button_validate) or reachable from public ones with any ids. The landed-line onchange is run as the superuser only when every id is a virtual record (a string id sent through the RPC is not one). The leaf _get_value_from_std_price is also reached by public computes that return what it builds (the move's value_justification names the exact standard price; get_valuation_lines returns former_cost), so it is run as the superuser only inside a seam, never when RPC-reachable code calls it directly: a direct read(['value_justification']) or get_valuation_lines by the member stays Odoo's own call. _get_price_unit is wrapped only where there are valuation layers (17, 18): on 19 and 20 no flow needs it, and its public callers (the MO overview report, margin) would show the cost. A second receipt of an average or FIFO product that already has stock, and a second MO of the same product, call product_price_update_before_done, which reads the cost: wrapped for the same reason.
Which profile engages it. The member of a profile that makes any field the valuation reads unreadable: standard_price (either model), the product's value_svl, quantity_svl, avg_cost, total_value, a lot's standard_price, avg_cost, total_value, a valuation layer's unit_cost, value, remaining_value, a stock move's value, remaining_value, price_unit. A profile that restricts none of them does not engage it.
Where the cost is written, and who can read it (SDK 5.120: these fields are part of the cost family). The fields a flow writes the cost into are members of the cost family, because a profile can contain a user who reads them natively (a stock manager). Measured with native groups (a record per group combination, read as that user):
- Odoo 17 and 18, the valuation layers (
value,unit_cost,remaining_value): readable by the stock manager group only, and the landed-cost lines (former_cost,final_cost, every line) likewise. A profile may contain a stock manager, so they are members: restricted with the rest of the family the member is refused them. - Odoo 19 and 20, the value of the stock move (
stock.move.value,remaining_value,value_manual): readable by any stock, MRP, purchase, billing or accounting user. Members of the family.
The flows that write them keep working. On 17 and 18 a landed cost creates layers and adds to value and remaining_value; on every line compute_landed_cost creates the lines with former_cost. compute_landed_cost and button_validate of a landed cost are not elevated: they run AS THE MEMBER with a cost lift on, so the access rights and record rules of the landed cost, its lines, the journal entry and the valuation are Odoo's own and the member needs the rights a user who is not a member needs. The lift is a module-private ContextVar that only the seam sets and resets (in a finally; it is not a context key, so a caller cannot send it) and it makes the cost group READABLE, and nothing else: the field-group check of the cost and of the fields the pair rule restricts with it, minus what the family excludes (Odoo 17 and 18 check_field_access_rights, Odoo 19 and 20 _has_field_access). It is read-only; the only writes it lets through are the four fields the landed-cost flow itself writes as the member (the adjustment line's former_cost and, on 17 and 18, the layers' unit_cost, value and remaining_value). Odoo modules an administrator installed (overrides, automations, computes, templates) run INSIDE the lift, in the same trust class as the sudo() residual of any installed module. It is switched off again around the code of a vApp (a hook, a trigger, a job, a route, a native method): nothing a vApp runs inside the call reads the cost. The two methods return Odoo's True, no cost. Afterwards the restricted fields (the products', the lines', the layers', the moves' value, remaining_value, value_manual) are dropped from the cache, an exception included, so the member is still refused the cost.
Limits (named, not silent).
- Until 5.126 Odoo 19 and 20 refused the pair for the validator (a form names
standard_pricein theseller_idscontext). The screen neutralization of 5.126 handles that node, so 19 takes real rows; only Odoo 20 still refuses it, because the cost is tracked there (E1), and the rule is exercised on 20 with the row forced into the registry's map, which is what an accepted row would do. It engages the moment such a row exists. - The cache is closed. Odoo 17 and 18 judge a field's groups only when the value is not in the transaction cache, and the superuser's reads fill that cache. After every seam the restricted fields are dropped from the cache (pending writes are flushed first), so a later attribute read of the cost by the member in the same request goes back through his check (refused on 18, 19, 20; Odoo 17 judges no attribute read at all, but nothing is served from the cache).
- A dropship receipt (
_get_dropshipped_svl_vals) is wrapped like the receipt; it needs the dropship route, which the tests do not install, so only the mechanism tests prove that the wrapper is installed. - The invoice and accounting flows (anglo-saxon price differences,
_stock_account_get_anglo_saxon_price_unit) read the cost for the invoicing user; they belong to the invoicing flows of the cost family, not to the stock flows here. - The MO overview report, the margin of a delivered sale line and
compute_landed_cost's adjustment lines (former_cost,final_cost) show costs or values derived from it to a member on Odoo 19 and 20 (measured: the report returns the unit and total costs); the valuation seams add none of them, and they belong to the cost family. - A third-party module that reads the cost in a method this list does not name still raises for the member, as before.
Set min_platform 5.115 if your app depends on a restricted member completing these flows (supports("access-valuation-seams")).
What the seams never leave behind (SDK 5.117, feature access-valuation-seams-guarded). After a seam elevates (the landed-cost leaf, compute_landed_cost and button_validate, the landed-cost line onchange) the restricted cost fields of the products it touched are dropped from the transaction cache: the member's own read of standard_price later in the same request is judged by his own check again (Odoo 17 and 18 judge a field's groups only on a cache miss, so the superuser's read would have served it). A seam does not elevate on a product the member may not read (a record rule, a company he does not work in): the landed-cost line onchange of a virtual line naming such a product is Odoo's own call, the leaf is not elevated for a move of such a product, and compute_landed_cost / button_validate of a landed cost with such a product are refused with an AccessError before anything is computed or written (on Odoo 17 and 18 Odoo's own call would have valued the cost with its internal sudo(); on 19 and 20 Odoo refuses by itself). Set min_platform 5.117 to rely on it.
In vApps and the platform's other read paths (5c)
A field a profile hides (hide) or makes unreadable (restrict_read) is hidden from every vApp the member runs, whatever the app's grant says. Odoo's own RPC is unchanged: hide stays a screen rule there.
- A hidden field named in a read is left out of the result with no error, exactly as it is absent from an «all fields» read (
read,search_read, a hook script'sV.orm, a bulk export, an offline dataset). A read whose list is left empty returnsidonly. Arestrict_readfield keeps Odoo's refusal (AccessError). - Everything that would let an app infer the value is refused: a domain leaf on it (direct, dotted, inside
any/not any, or a name search that reaches it), anorderterm, agroupby, an aggregate, ahaving, aread_progress_baron it, and anapi_watch_hashdomain. - The read-like
call:targets leave it out:fields_get,default_get,copy_dataand thevalueofonchange. Vizum.meta.fieldsomits it,Vizum.meta.selectionrefuses it, andVizum.meta.fieldAccess(model)says which fields are restricted:{model, hidden, no_read, readonly, required, not_exportable, rev}.no_readis the part ofhiddenrefused even when named in a read.revchanges whenever the answer does; poll it to notice a policy change (there is no event). An administrator gets empty lists. An app that calls it setsmin_platform5.130.- Its stored values are kept out too: the
ir.attachmentrows that hold a hidden binary field's bytes, and themail.tracking.valuerows of a hidden field (their old and new values), are not returned to the member. - A feed serves a hidden column as null; a
restrict_readcolumn stays refused when the feed is defined. - Never hidden from apps (
access_field_policy.bridge_exempt_fields): the model's_rec_name,display_name,id,company_id,currency_idandactive. Hiding them would break every name search, many2one display and company or currency scoping; they stay hideable on screens.stateis not exempt. - A search never matches on a hidden field it does not name. A text value on a many2one, on
display_nameor inchild_of/parent_ofmakes Odoo name-search the target, which matches every field of its_rec_names_search(res.partner: complete_name, email, ref, vat, company_registry). When one of them is hidden, the leaf is rewritten, never refused: it becomes(path, 'in', ids), the ids found as the real user by searching only the target's visible name fields and its_rec_name. A search by a customer's name still finds; a search by the hidden value finds nothing, with no error. That holds on the bridge, innative.methods(also undersudo()) and for the AI Assistant'sname_search. A domain that names a hidden field is still refused.child_of/parent_ofare refused when the target's_parent_nameorparent_pathis hidden. The rewrite applies whenever the target hides any field, and it searches the model's own name-search fields too (product.productdefault_code and barcode,account.accountcode);call:name_searchanswers the same way, its rows never in the order of a hidden field. The check follows the whole name-search closure, at any depth: a relational name field (account.move.lineproduct_id) andproduct.template's variants are name-searched too, so a field hidden there rewrites the leaf; the rewrite itself follows three hops and matches nothing past them. Known limits: achild_ofwith a list of names, a very broad text (a large id list), and undersudo()the ids follow the real user's record rules. The sub-domain of anany/not anyis walked like the domain itself. Known limit: hiding a many2one does not hide the one2many that is its inverse; hide both. - The order of the rows never follows a hidden field. An
orderterm on one is refused, and so is a many2one term whose comodel's_ordersorts by one: Odoo sortsparent_idbyres.partner._order. With noorder, a model whose_ordersorts by a hidden field (res.partnersorts bycomplete_name) runs with those terms dropped,idwhen none is left: on the bridge, innative.methods, for the AI Assistant and for a shared dashboard. Without that, the order of the ids a search returns tells the hidden values apart. - Residuals. A computed copy of a hidden field is a separate field (
sale.order.line.purchase_price): hide each one; a related copy is hidden with its source.product.template's own copies of a variant field (barcode, default_code, standard_price: computed from the variants and searched on them) are hidden with the variant's field, for a domain, a read, an order and a group. Rows already synced to an offline device stay there until its next sync, which drops the column. The result of an Odoo method called throughcall:other than the ones above is that method's own. On Odoo 20 the chatter body shows tracked changes. The groups of aread_groupby a many2one, and the results of aname_searchwith an empty text, come back in Odoo's order for that comodel.
The platform's own read paths (5d)
native.methodsand the script library. Reading a hidden field by attribute (rec.standard_price,recs.mapped("standard_price")) raises anAccessErrorthat names the field: a silentFalsewould feed a zero into a computation the method may write.read()andsearch_read()leave it out, as the bridge does; a domain, a dotted path, agroupbyor an aggregate on it is refused. The real user decides, also inside the app's ownsudo():sudo()lifts Odoo's rights, never a profile. Odoo's own code called throughsuper_is not judged.vzm checkwarnsMETHOD_READS_FOREIGN_FIELDwhere a method reads a field of an Odoo model by attribute: read it withread()or handle the error.- The AI Assistant leaves the field out of what it reads and describes, refuses a search or grouping on it, does not show a hidden image field, and refuses a name search whose text is matched on a hidden field (a partner's name search also matches its reference and email: hiding either refuses the member's name searches).
- A dashboard shared by its owner runs as the owner for the viewer: a field hidden from either of them is hidden (domain, grouping and aggregates refused, column left out).
- The knowledge index never indexes a field that any active profile hides or makes unreadable on that model, or a related copy of one (an index is read by many people, and a chunk cannot be filtered per reader). When that set changes, the next run of the job indexes every record again.
- A display name that shows a hidden value (a rate named by its hidden line size, a contact named «Company, Person» when the complete name is hidden) is shown as
#<id>by the AI Assistant and in a shared dashboard, and is not indexed. The name's dependencies are followed through relations (a package named by its type's hidden length). A text value counts when it appears in the name, so a name that only *can* include the field (a partner's email, shown in some contexts) is left alone while it does not; any other value (a number, a date) counts whenever it is set, because the name may show it in another form. A related copy of a related copy is hidden too. Residual: the vApp bridge keepsdisplay_name(5c,bridge_exempt_fields), and a many2one value shows the related record's display name, so such a name still reaches a vApp. Do not hide a field a model's name is built from when that matters. - What is hidden is the value, not the field's name.
Vizum.meta.fieldAccesstells an app which fields are hidden, and a method that reads one by attribute is refused with an error that names it (a silentFalsewould feed a zero into what it computes): listing a model's fields (fields_of,has_field) shows it. - The bridge-exempt fields stay exempt here too: the model's
_rec_name,display_name,id,company_id,currency_idandactiveare hidden on screens and in exports only, on every platform path as in vApps (Access Manager marks them «screens and exports only»): hiding them would break every name search and many2one display.
One at a time on a key: advisory_lock (5.103)
A module serialises two requests that act on the same business key with self.env.cr.execute("SELECT pg_advisory_xact_lock(hashtext(%s))", (key,)) — psi_dsd's idempotent route sale takes one per (user, client reference), so a sale the phone sends twice is created once. A vApp method has no cursor; it calls:
def create_sale(self, client_ref, vals):
advisory_lock("sale:%s:%s" % (self.env.uid, client_ref))
found = self.sudo().search([("client_ref", "=", client_ref),
("create_uid", "=", self.env.uid)], limit=1)
if found:
return {"order_id": found.id, "existing": True}
return {"order_id": self.create(dict(vals, client_ref=client_ref)).id, "existing": False}
advisory_lock(key, timeout_ms=None)takes your app's own lock onkey(a non-emptystrof at most 256 characters) until the transaction ends — commit or rollback, never later: there is no unlock, and a request that fails cannot leave one behind. A second call on the same key in the same transaction returns at once. It returnsTrue.- The wait is bounded.
timeout_msdefaults to 5000 and may be 0 (do not wait) to 30000. When another transaction still holds the key, it raisesLockTimeout(error codeLOCK_TIMEOUT, retryable). Nothing was locked and your transaction is still usable, so you may catch it:except LockTimeout: return {"busy": True}. Uncaught, the call fails and its changes roll back. - Why the call can make Odoo replay the request. Odoo runs every request in REPEATABLE READ: its snapshot is taken at the request's first statement, before any wait. A lock that only waited would wake the second request on a snapshot that cannot see the first one's sale, and it would create the sale again. So when the previous holder of the key committed after your transaction began,
advisory_lockraises PostgreSQL's serialization failure, and Odoo replays the whole request on a fresh snapshot (an HTTP request up to 5 times; a cron on its next run). The replay finds the first sale. Do not catch that error: let it go. The same holds on every route that runs your hook for someone who is not signed in to Odoo: guest link routes (auth: "link"), portal json routes, paired-device routes and inbound webhooks. Those routes answer any other failure with a generic refusal, but they let a lost race through to Odoo, so the guest who lost gets your own answer (NOT_OPEN,SLOT_TAKEN) from the replay, never a500. If the race is still lost after the replays (a burst of callers on one key), the caller gets503 {"ok": false, "error": "busy"}withRetry-After: 1: retry the same call. - Rule: take the lock BEFORE any effect outside the database. A replay runs your code again from its first line, and the database changes of the lost attempt are rolled back, but a
V.net.fetchPOST, a sent mail or any call to another system is not. Whatever your method did beforeadvisory_lockis repeated. So calladvisory_lockfirst, then check, then act; never fetch, send or notify above it. (The platform charges each rate limit once per HTTP request, however many times Odoo replays it.) - Per app. Two apps never share a lock, even with the same key string, and an app's locks never meet the platform's or Odoo's own. Equal keys always serialise. Two different keys of your app share a lock with a probability of 2^-32 per pair; they then wait for each other when they need not, and never run together when they must not.
- Lock what you check. The lock serialises the code that takes it, nothing else: every path of your app that reads and then writes the same thing must take the same key. To make Odoo's own writers of a row wait, lock the row itself with
lock_read(record, field). Writing a record only to lock it (partner.write({})) locks every sale of that customer and rewriteswrite_date; use a key that names what you protect ("credit:%s" % partner.id). - In
native.methodsand theirnative.script.lib; not in hooks or jobs. Setmin_platform: "5.103"— the install gate andvzm checkrefuse a lower one (MIN_PLATFORM_TOO_LOW).Vizum.supports("advisory-lock")from the app's page.
Writing out pending changes: self.env.flush_all() (5.152)
A module calls self.env.flush_all() to write out what the transaction already changed (stored computed fields, deferred writes) before it reads the same rows another way. A vApp method and a script hook call the same thing:
def recount(self):
self.write({"x_km": 99})
self.env.flush_all() # the stored computes of the write are now in the database
- It writes only changes this call's transaction already made. It reads nothing back, returns
None, takes no argument, and opens no model, field or user that the app's grants do not already open: there is still no cursor (self.env.cr). - A record kept from a call that is over cannot flush; it raises the same
AccessErroras any other use of it. - Set
min_platform: "5.152"; below it the call raisesAttributeError.
Shipping data, and changing it across versions (5.61)
{ "native": {
"seed": [
{ "model": "brand", "key": "code", "rows": [{ "code": "dk", "name": "Daikin" }] },
{ "model": "item", "key": "name", "update": false, "file": "seed/items.json" }
],
"migrations": [
{ "to": "2.0.0", "ops": [
{ "op": "rename_field", "model": "item", "from": "qty", "to": "quantity" },
{ "op": "copy", "model": "item", "from": "name", "to": "title" },
{ "op": "hook", "name": "migrate_notes" } ] }
] } }
seedupserts rows by a naturalkeyon every install and update, in list order. A value{"$ref": "<model>:<key>"}points at a row seeded earlier."update": falseonly creates what is missing, so a user's later edits survive your updates. Rows go inline, or in a.jsonfile inside your bundle.migrationsrun on the update that crossesto. A first install migrates nothing. The platform remembers the version it last reconciled, even when an update waited for approval.rename_fieldkeeps the column and its data under the new name.copyfills a new, empty column from an old one.hookruns one of your script hooks as the app's job user, withrec = {from, to, installing: true}.- Order: renames first, then the new schema, then copies and hooks while old and new columns both exist, and only then are undeclared columns dropped.
- Uninstalling deletes your models and their data, as uninstalling an Odoo module does.
- A vApp that declares either key sets
min_platform: "5.61".
Functions, a shared library, and the time budget (5.62)
A hook can hold real logic:
- Top-level
defandwhileare allowed. A function may not be nested in another (so there are no closures) and may not carry decorators. Recursion works. native.script.libis{name: code}, code that every hook of the app runs first. Use it for the functions and constants your hooks share: a rules evaluator, the limits of a diagnosis.- Every run has a time budget: 30 seconds, or 120 for a scheduled job. The platform injects a check into every loop and function body. A run that uses its budget stops with «Script … stopped: it used its N-second time budget» and its transaction rolls back.
- Still refused:
import,lambda,class,with,match,try/except*, underscore names and attributes,format, and code nested more than 100 levels deep (the deepest real script measured is 27) — each refused at install with the reason, never a server error. - A vApp that uses
def,whileorlibsetsmin_platform: "5.62".
Tooling for your own models
vzm check manifest.jsonreports, field by field, what the platform will refuse innative(a one2many without its inverse, a default of the wrong type,trackingwithoutchatter…) and themin_platformyour declarations need.vzm packruns it first.vzm introspect --manifest manifest.json --out src/own-models.tswrites typed interfaces of your own models with the names the platform creates (x_vzm_<app>_<model>,x_<field>), computed fields marked readonly.@vizum/vapp-typesexportscmd(cmd.create(vals),cmd.update(id, vals),cmd.delete(id),cmd.link(id),cmd.unlink(id),cmd.set(ids),cmd.clear()) for writing lines through a one2many, andownModel(appId, name)/ownField(name)for the technical names.
6. Routes (5.53) and what is pending (5.55)
native.routes declares pages and json endpoints the platform serves for the app. Its type is NativeRoute in @vizum/vapp-types.
- Paths:
GET /app/<slug>/<route>[/<sub>], and/my/<slug>/…for a portal page. The page embeds the app with the route as its intent, available asVizum.route. - Json endpoints:
POST /app/<slug>/<route>. It is CSRF-checked, the body is at most 64 KB, calls are rate-limited per user, app and route and audited, and the declared hook runs as the visitor. authis"portal"(signed-in users ofgroups) or"user"(internal users only).- Approval: declaring or editing a route needs approval, and an app with routes needs
install_scope: "admin"andmin_platform: "5.53".
Pending, 5.55: auth: "public" and website_menu are in progress. Until they merge, the validator refuses both.
6-bis. permissions.orm on vizum.app: an allowlist of call: methods
call:<method> on the platform's own model vizum.app runs as whoever opens the app, often an administrator, and most of its methods act on other apps (action_approve, api_secret, action_disable, api_uninstall). Only these are callable, and the platform, vzm check and the store refuse any other at install, at upload and at run time:
api_list, andapi_vapp_sourcefor the app's own key.- First-party only (they act on other apps):
api_installandapi_build(com.vizumapps.vappcreator);api_admin_app_list,api_set_app_access,api_set_app_native_menu,api_set_app_soloandapi_set_app_users(com.vizumapps.hide_menu);api_odoo_introspectandapi_update_preflight(com.vizumapps.vappcreator), and the sameapi_odoo_introspectandapi_update_preflight(com.vizumapps.moduleconverter): the Module Converter's key, reserved before it ships. Both are read-only (featureodoo-introspect, see Odoo introspection);api_view_inspect(com.vizumapps.vappcreator) and the sameapi_view_inspect(com.vizumapps.moduleconverter): what a package does to Odoo's views, simulated with the installer's code and nothing written (featureview-inspect, see View inspection);api_access_view_nodes(com.vizumapps.hide_menu): the Access Manager's catalogue of a model's screen nodes and field verdicts, read-only (featureaccess-node-catalogue, see «Access Manager catalogue» below). The view lookup behindVizum.ui.openViewis host-only: no app can call it. The key is only a string a package can carry, so the instance also demands that the installed package is verified as published by Vizum (signed by the Vizum Store,trust_statestore_verified): a sideloaded or locally built package with that id is refused at install and its grant is dropped at run time. - An
actions[]entry never targetsvizum.app(an action passes the caller's own keyword arguments to the method).
6-ter. read_calls: the call: methods that only read (5.105)
A method another app (or your own UI) reaches through a call: grant asks the desktop user App action the first time, like a write. List the ones that only read in the grant's read_calls and the dialog goes; the platform then runs them in a read-only transaction and refuses one that writes with READ_CALL_WROTE (audited as platform:read_call_wrote). The method keeps its user, grant and sandbox; V.net, V.oauth, V.ai, V.vector, V.webhooks, V.identity and V.commit refuse inside it, as do the filestore and SMTP. Only methods you define on your native models (x_vzm_*) qualify, and a name an update adds waits for the administrator. Details: Read calls.
7. Before you publish
- Set
min_platformto the highest capability you use (Platform capabilities). - A guard is proven only when it refuses. Write the refusing write in a test, see it fail, then check the hook catches it through Odoo's own form, not just the iframe.
SDK & server
Routes — portal, internal and public pages (native.routes) PRIVILEGED
A vApp can ask the platform to serve it at a URL of its own, outside the desktop: /app/<slug>/<route>, where <slug> is the last segment of the app id (com.acme.site_pages → site_pages; an app id takes lowercase letters, digits and _, never -). The page is a platform page that embeds the app's sandboxed iframe; the app contributes no server markup. Types: NativeRoute in @vizum/vapp-types. Who can open what, and with which capabilities: docs/vapp-routes-auth-matrix.md in the platform repository (this page is also published as a section of the developer guide, where a relative link would not resolve).
auth | since | kinds | who opens it | caps on the page |
|---|---|---|---|---|
public | 5.55 | page | anyone on the internet, no sign-in | none |
portal | 5.53 | page, json | signed-in users of the route's groups | the app's data grant, bounded by the visitor's rules |
user | 5.53 | page, json | internal users (of groups, if given) | the same |
public | 5.68 | file | whoever holds a link the platform signed for one user | none: a file, read as that user |
Every app with routes needs "install_scope": "admin", and an administrator approves the routes: they are privileged. Public pages are approved on their own ("Public web pages", with each full URL).
A minimal public page with a website menu entry
manifest.json:
{
"schema": 1,
"id": "com.acme.site_pages",
"name": "Site Pages",
"version": "1.0.0",
"min_platform": "5.55",
"entry": "index.html",
"icon": "icon.png",
"install_scope": "admin",
"permissions": { "data": "user" },
"native": {
"routes": [
{ "name": "pricing", "kind": "page", "auth": "public", "title": "Pricing",
"website_menu": { "text": "Pricing", "sequence": 50 },
"sitemap": true }
]
}
}
index.html:
<!doctype html>
<html lang="en"><head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Pricing</title>
<script src="/vizum_apps_platform/static/sdk/vizum-sdk.js"></script>
</head>
<body>
<main id="app"></main>
<script>
Vizum.connect().then(() => {
const r = Vizum.route; // {name, params, sub, path} or null
const plan = r && r.params.plan; // /app/site_pages/pricing?plan=pro
document.getElementById("app").textContent =
plan ? "Plan " + plan : "Our plans";
});
</script>
</body></html>
What happens once an administrator approves it:
https://<your site>/app/site_pages/pricingopens for anyone, signed in or not. The page sub-path and query string reach the app asVizum.route.sub/Vizum.route.params.- With the
websitemodule installed, "Pricing" appears in the top menu of every website of the app's company (every website when the app has no company). Withoutwebsite, no menu is created and the page still serves (Odoo's plain frontend layout). Installingwebsiteafter the app was approved adds the menu entry only at the app's next reconcile (approving it again, or an update). sitemap: truelists the page in/sitemap.xml(default: not listed). Switching it on later, or adding or rewording the menu entry, is a change the administrator approves again (thesequenceis not).- Disabling the app, an update waiting for approval, or uninstalling it removes the menu entry and answers the site's ordinary 404 at once.
What a public page cannot do
A public page has no capabilities: Vizum.connect() resolves with session: null and empty caps, and every SDK call — Vizum.orm, Vizum.script.run, Vizum.data, Vizum.files, Vizum.ai, Vizum.net, Vizum.vector, Vizum.db, and the host UI calls too — answers PermissionDenied. Map tiles through the platform proxy are not available either. The bundle is served, but never manifest.json or a *.py file. Declaring permissions.orm does not change that: the grant applies to the desktop and to portal/user routes, never to the public page. Build it from what ships in the bundle.
The validator also refuses, by name:
- a public
jsonroute ("public json routes do not exist") — server data for anonymous callers goes through a guest link instead (auth: "link", 5.104, link-routes.md): the app issues a revocable, expiring link per customer and itsjson,fileanduploadroutes run as its service user withV.link; groupson a public page;website_menuorsitemapon aportaloruserroute;website_menu.parent(entries go in the top menu), unknown keys, atextover 40 characters, asequenceoutside 0–10000;- a hand-written
permissions.public_routesis replaced by the list the validator computes.
Caching
A visitor with no session cookie and no utm_* parameter or cookie gets Cache-Control: public, max-age=300 with Vary: Cookie, Accept-Language, and no cookie is set, so a shared cache can keep it. Everyone else (signed in, an existing session, a campaign link) gets private, max-age=60 with Vary: Cookie. Anything that must change within five minutes for anonymous visitors does not belong in a public page's server-rendered part — and a public page has none beyond the platform frame.
A file behind a signed link (kind: "file", 5.68)
A module that serves a binary to a viewer without a session — a PDF in the app's iframe (an opaque origin carries no cookie), a video that seeks by Range — does it with a signed, dated link made for one user. As a vApp the platform signs and checks that link: the app only asks for it.
{"name": "document", "kind": "file", "auth": "public",
"token": {"max_age": 300},
"files": {"manual": {"model": "manual", "field": "file", "filename": "file_name"}}}
In the app's methods:
url = base_url() + route_file_url("document", "manual", record.id) # for the user asking
token = route_file_token("document", "manual", record.id) # the token alone
route_file_verify("document", "manual", record.id, token) # {"uid", "expiry"}
/app/<slug>/document/manual/<id>/<token> then serves the field as the user the link was made for:
- the platform checks the signature (a key per app: another app's link is worthless here), the expiry, the route, the kind and the record; a link never lives longer than
max_age(the platform's ceiling is 7 days); - the user must be active and internal — a portal user only when the route says
"allow_portal": true— never the superuser; a browser with a session of ANOTHER user is refused (a link pasted in a chat does not open from a colleague's account); - that user needs read access to the record and the field;
- a refusal is a
403that says "expired" or "not valid", never which part was wrong; - an authorized answer carries
Access-Control-Allow-Origin: null(the app's iframe has an opaque origin; never*, never the request's origin echoed back) withAccess-Control-Allow-Credentials: trueandVary: Origin: the viewer sends credentials, and the session they carry can only REFUSE a link (another user's session), never grant one — without a valid link it is a 403 whatever the session. A refusal carries no CORS at all, so a sandboxed iframe of another site does not even read the error; - answers honour
Range(Accept-Ranges: bytes), carryX-Content-Type-Options: nosniffand no cookie; a type the browser runs (HTML, SVG, scripts, XML) is a download withContent-Security-Policy: sandbox; PDF, text, JSON, raster images, audio and video are shown inline.
What the route serves (its kinds, models and fields), how long a link lives and whether portal users get links are part of what the administrator approves: changing them asks again.
Run URLs name their database, and are always anonymous
A vApp's own files (index.html, app-*.js, style-*.css, chunks of a multi-file build) are served at /vizum/apps/run/<key>/<token>/<exp>/<entry>. On an instance with several databases behind a dbfilter, the host puts the database in the path, /vizum/apps/run/@<db>/<key>/… (and /vizum/tiles/@<db>/… for map tiles), so every relative URL your bundle requests keeps it. Write relative URLs (./app.js); never build an absolute /vizum/apps/run/... path yourself.
Both URL kinds are capability URLs: a URL that names a database (@<db>) is always served on an anonymous session that is never saved, even when the named database is the one the browser is signed in to, and even when the name is hidden, not loaded or does not exist (the answer is then 404 and no cookie is set or rotated). The page authenticates by its run token, verified inside that database; a token minted for another database opens nothing (403). Do not expect the signed-in user's cookie in a request your iframe makes to these paths: there is none, whatever it sends.
SDK & server
Paired devices — screens and kiosks with no login (auth "device") PRIVILEGED
A screen on a wall, a kitchen display, a kiosk or a time clock is not a person: it has no Odoo login, it must keep running unattended, and it must be revocable from the desktop in one click. SDK 5.70 gives a vApp a paired device identity for that.
- The device opens the app's device page. The platform shows an 8-digit PIN.
- An administrator claims the PIN from the desktop (a platform dialog, or the app through
Vizum.pairedDevices.claim). The device receives a long random device token — once — and keeps it in its own storage. - From then on the device calls the app's
jsonandfileroutes with that token. Each call runs the route's hook withV.deviceset; never a user session, never a cookie. - Unpairing (revoke) kills the token at once: the next call answers 401 and the page goes back to the PIN screen.
The token is stored hashed on the instance; nobody can read it back. The platform owns every security decision: PIN expiry and attempts, the token check, revocation, rate limits, the declared model and fields of a file route, the response headers. The hook only decides *what* this device may see.
Manifest
{
"min_platform": "5.70",
"install_scope": "admin",
"permissions": {
"logic": "script",
"orm": [ { "model": "x_vzm_signage_media", "ops": ["read", "search"], "fields": ["x_file"] } ]
},
"native": {
"script": { "hooks": [
{ "name": "device_snapshot", "code": "…" },
{ "name": "device_media", "code": "…" }
] },
"routes": [
{ "name": "player", "kind": "page", "auth": "device", "title": "Player",
"device": { "embeds": ["youtube", "vimeo"], "cache_mb": 2048 } },
{ "name": "snapshot", "kind": "json", "auth": "device", "hook": "device_snapshot" },
{ "name": "media", "kind": "file", "auth": "device", "hook": "device_media",
"model": "x_vzm_signage_media", "fields": ["x_file"] }
]
}
}
| key | on | meaning |
|---|---|---|
auth: "device" | page, json, file | a paired device of THIS app opens it; nobody else |
device.embeds | device page | third-party players the page may frame for the app (Vizum.device.embed): youtube (served from www.youtube-nocookie.com), vimeo (player.vimeo.com). Nothing else |
device.cache_mb | device page | offline media cache ceiling, 64–4096, default 1024 |
device.print | device page | SDK 5.87: true lets the page print receipts on a printer paired at the device (Vizum.device.printReceipt) and print its own view (Vizum.device.print). A widening: approved with the route, shown on the approval card ("Device pages may print receipts…"). Needs min_platform 5.87 (vzm check and the install gate enforce it) |
model, fields | file | the ONE model and the binary fields the route may stream |
hook | json, file | the native.script.hooks entry that answers it |
kind: "file"exists only withauth: "device"in 5.70.- A device
fileroute reads as the job user, so it needs what an orm grant needs: the manifest must carrypermissions.ormwith an explicit rule on thatmodel(the"*"read wildcard does not count: it is scoped to the viewing user, and this route runs as the job user) whoseopsincludereadand whosefields(empty means all) cover the route'sfields, and the platform's orm policy must letreadthrough on the model. Platform models (vizum.*), code-bearing models and the like stay closed — the route does not reopen them. The rule is resolved the way the runtime resolves it: the first rule on the model decides, so an explicit rule withoutreadrefuses even beside a"*"rule. The rule'sdomainis a ceiling on ids as well: a record outside it answers 403 before the hook runs, whatever the hook would say. The platform checks all this at validation, at the store gate and on every file request against the approved permissions: remove or narrow the grant and the route answers 403 at once. The model is the real model name (an app's own model isx_vzm_…, granted like any other). Native fields the app owns on a SHARED model are readable throughV.ormwithout a grant, but a device file route stays fail-closed: it still demands the explicit grant. - Declare
native.service_user: true(5.59) in any app with device routes. The platform then creates the app's own service uservzm-service.<slug>at install and makes it the job user the device hooks run as. Without a job user every device call is a 503 (never the installer's rights); the job user always reaches its own app, even one restricted to *Specific people* (server-side);vzm checkwarns (DEVICE_NEEDS_SERVICE_USER) and the Apps Manager flags the app:"native": { "service_user": true, "script": { ... }, "routes": [ ... ] }. - A device route never takes
groups,path,website_menu,sitemaporportal_entry. - The validator synthesizes
permissions.device_routes(the names) — a privileged grant shown on its own line of the approval card ("Paired devices: player, snapshot, media"). Adding a device route, or changingembeds,modelorfields, is a widening the administrator approves again.
Devices with no screen: headless routes and token enrolment (SDK 5.129)
A sensor has no page to show a PIN on. Since 5.129 an app with auth: "device" json routes and no page serves those routes (_pair/*, the page session, its worker, manifest and icon stay page-only), and one route may carry "enrol": {"hook": "..."}: a device that holds a one-time token from the Vizum store's broker enrols at POST /vizum/device/<slug>/_enrol and receives the same device token PIN pairing hands out, with no person present. The app claims devices at the broker with Vizum.deviceBroker. The whole flow, its limits and its errors: device-broker.md.
Pairing (the platform does it, the app does not)
- The device opens
https://<instance>/app/<slug>/<page>(a device page). With no token in its storage, the platform page shows the app's name, an 8-digit PIN and its expiry (5 minutes), and polls. - An administrator claims it:
- from the desktop: the app's detail in the Apps Manager → Pair a device → PIN, a name, optionally the record the device is linked to (the PIN may be typed as the screen prints it,
1234 5678; spaces and hyphens are ignored). A device paired this way with no link is a working device that no record points at: an app that needs the link says "this screen is not linked" until the administrator (or the app,Vizum.pairedDevices.link) links it, and the same token sees the link on its next call; - or from the app (
Vizum.pairedDevices.claim(pin, {name, link})) — base.group_system only. The app'sclaim,linkandrevokefirst ask the person on the host: a dialog shows the PIN, the device name and the link (the same way an ORM write is confirmed), and nothing reaches the server if it is denied.linkmust be none (nullor"") or a non-negative integer record id: any other value (42.9,true,"12",-1) is refused before the dialog, so the value shown is the value stored.list,renameandreloaddo not ask.
- from the desktop: the app's detail in the Apps Manager → Pair a device → PIN, a name, optionally the record the device is linked to (the PIN may be typed as the screen prints it,
- The device's next poll receives
{token, device}exactly once; the PIN is consumed. A claim must be collected within 60 s of the claim and before the PIN's expiry; a late poll getsexpiredand no token, and the device starts again.
Limits (platform constants): a PIN lives 300 s and has 8 digits (10^8 PINs: with at most 1000 waiting, a typo hits another waiting PIN with probability 1e-5); at most 5 wrong claims per user per app per 10 minutes, then claims are refused for that window; at most 20 PIN requests per source and 100 per app per 10 minutes (an IPv6 source is counted by its /64 prefix) and 1000 waiting PINs per app — the slot is reserved under a per-app database lock, so parallel requests cannot exceed it. _pair/poll is throttled too, before any lookup: 60 per minute per pairing and 300 per minute per source (the page polls every 3 s); past either, 429 with Retry-After.
Behind a reverse proxy, run Odoo with proxy_mode = True. The per-source limit keys on the client address Odoo sees. Without proxy_mode every visitor shares the proxy's address: 20 requests in 10 minutes would then block pairing for the whole instance. With it Odoo reads X-Forwarded-For, so each source is counted on its own (make the proxy overwrite that header, never append to a client's).
A copied screen (clone detection)
A device's token lives in the page's storage, so copying that storage gives a second screen that works. The platform detects it (ADR-0005): the device page also holds a rolling nonce that the platform hands it on its heartbeat (about every 5 minutes) and that its next call acknowledges. Two holders of one copied credential cannot both stay current: the one that falls behind is reported. Nothing in an app changes: the nonce is between the page and the platform, Vizum.device.info() is the same four fields, and the app never sees it. By default the report is a flag (an audit row device:clone_suspected, a notice to the administrator who paired the screen, a warning on the device in the Apps Manager and a degraded device_clone health check) and the screen keeps working; an instance can set vizum_apps.device_clone_action = revoke to unpair the device at once. The administrator clears a flag with the check mark in the Apps Manager. A screen paired before this feature, or one whose cached page script is older, sends no nonce and is never flagged.
The device page (what the app's iframe gets)
The page is a platform shell with no cookies and no user session. Once paired, it loads the app's bundle in the usual sandboxed iframe with allow="fullscreen; autoplay; encrypted-media", and the iframe's SDK exposes:
await Vizum.connect();
Vizum.device.info(); // {id, name, link, page} — never the token
await Vizum.device.call("snapshot", {}); // the json route's hook result
await Vizum.device.fileUrls("media", [12, 13]); // {12: url, 13: url} — signed, 24 h
await Vizum.device.blobUrl(url); // an object URL served from the offline cache
await Vizum.device.cache([url, …]); // pre-fetch into the offline cache (LRU, cache_mb)
Vizum.device.onReload(() => location.reload()); // the admin pressed "Reload screen"
Vizum.device.fullscreen(); // request fullscreen on the platform page
Playing YouTube and Vimeo: Vizum.device.embed
A player (YouTube, Vimeo) nested in the app's frame does not play: the app runs in a sandboxed frame with an opaque origin, and the embedders refuse an ancestor chain that contains the origin null (YouTube answers error 153, Vimeo shows no video, whatever the referrer policy). So the device page — the platform's own page, on the instance's origin — frames the player and lays it over the app at the rectangle the app asks for:
const player = await Vizum.device.embed({
provider: "youtube", // "youtube" | "vimeo", and declared in this route's device.embeds
id: "dQw4w9WgXcQ", // YouTube: 11 chars [A-Za-z0-9_-]; Vimeo: 1-12 digits
rect: { x: 40, y: 60, w: 960, h: 540 }, // in the APP frame's coordinates
muted: true, autoplay: true, loop: false, start: 0, // all optional; defaults muted + autoplay
});
await player.update({ rect: { x: 0, y: 0, w: 1280, h: 720 } }); // move/resize, or change muted/loop/...
await player.remove();
await Vizum.device.embeds(); // remove every player this app opened; resolves with how many
- The platform builds the URL; the app never supplies one. Only providers the route's *approved*
device.embedslists are accepted (PERMISSION_DENIEDotherwise), ids are validated (VALIDATION_FAILED), and the player iswww.youtube-nocookie.com/embed/<id>with fixed safe parameters (playsinline,rel=0,enablejsapi=1withorigin=the page's own origin,autoplay,mute,loop,start) orplayer.vimeo.com/video/<id>?dnt=1…&api=1. The players' postMessage APIs answer only the framing page; no host or script source is added. - The player's iframe carries
referrerpolicy="strict-origin-when-cross-origin",allow="autoplay; encrypted-media; picture-in-picture; fullscreen"and asandboxthat keeps its own scripts and storage but allows no top-level navigation, popup or form. It takes no taps from the app. - The rectangle is clamped to the app frame's box: a player can never cover platform UI outside the frame, and one wholly outside is hidden. It follows window resizes. At most 4 players at once (
QUOTA_EXCEEDED). - Players are removed on unpair, on offline-lease expiry, on "Reload screen", on page close and when the app is mounted again; the SDK also calls
Vizum.device.embeds()onpagehide. - The device page's CSP gets
frame-src 'self'plus exactly the approved providers; the app bundle's CSP no longer names any third party (a frame there never played, and a hostile bundle gets none for free). - On the desktop editor, the native menu and Solo,
Vizum.device.embedrejects withPERMISSION_DENIEDand one clear message ("plays a video on a paired-device page only"): show your own thumbnail there. The emulator refuses it the same way.
Controlling the player: volume, mute, play, pause (SDK 5.82)
The app cannot reach a player (it lives in the page, not in the app's frame), so the handle embed() returned carries the controls:
const player = await Vizum.device.embed({ provider: "youtube", id: "dQw4w9WgXcQ", rect, muted: false });
await player.setVolume(35); // 0 to 100 (VALIDATION_FAILED outside it)
await player.mute(); await player.unmute();
await player.pause(); await player.play();
- The page posts the player's own postMessage command (YouTube IFrame API, Vimeo player.js) to the iframe it created, with
targetOriginthe provider's exact origin (https://www.youtube-nocookie.com,https://player.vimeo.com), never"*". The message is built by the page from a fixed table; the app supplies a handle and a number, nothing else. No new host, no CSP change. - Only a handle this page issued to this app is accepted; anything else (another id, a removed player,
__proto__) rejectsNOT_FOUNDand posts nothing. - A command sent before the player has loaded is kept, and applied when it is ready; what you asked (volume, muted, playing) is re-applied after
embed.updatereloads the player. - Bridge ops:
embedSetVolume,embedMute,embedUnmute,embedPlay,embedPause. Featuredevice-embed-control. Setmin_platform5.82, or detect it withVizum.supports("device-embed-control"). (vzm checkcannot see a method on a handle: it checksVizum.device.storeand the other named members only.)
Per-device storage: Vizum.device.store (SDK 5.82)
The app's frame has an opaque origin, so it has no localStorage. A screen's own settings (a volume, a layout) are kept by the platform instead, for this paired device of this app:
await Vizum.device.store.set("volume", 40); // any JSON value; resolves true
const volume = await Vizum.device.store.get("volume"); // the value, or null
await Vizum.device.store.delete("volume"); // whether the key existed
const everything = await Vizum.device.store.all(); // { key: value }
| Keys | 1 to 64 characters of a-z A-Z 0-9 _ . : - | |||
| Values | JSON, at most 4096 bytes of compact JSON (UTF-8 bytes, not characters) | |||
| Count | at most 64 keys per device (256 KB), QUOTA_EXCEEDED past a limit; overwriting a key or reusing a freed slot always works | |||
| Scope | the paired device, and so its app; another device, or another app's device with the same key, reads nothing | |||
| Reached by | POST /vizum/device/<slug>/_store with the device's own token: `{op: get | set | delete | all, key, value}`. A body cannot name another device |
| Lifetime | until the device is revoked (its keys go with it); an administrator can read the rows, nobody edits them by hand | |||
| Offline | a screen reloaded while offline reads the last values it saw (kept in the page, wiped on unpair and lease expiry); a write offline rejects NETWORK_ERROR |
Feature device-store; Vizum.device.store needs min_platform 5.82 (vzm check enforces it). It counts as one call of the device's 120 per minute. Do not keep secrets or personal data there: it is a settings store.
Vizum.device exists only on a device page; everywhere else every call answers PermissionDenied. A device page has no other capability: Vizum.orm, Vizum.data, Vizum.ai, … answer PermissionDenied (its server data comes from its own json routes).
The platform sends a heartbeat every 30 s (last_seen, the screen size and user agent); the answer carries reload when an administrator asked for one. A heartbeat with a body that is not a JSON object, is malformed or is over the 64 KB limit answers 400 / 413 like every other device route, and changes nothing: the screen's info is not updated and the pending reload is not consumed, so the next valid heartbeat still receives it.
Printing at the device (SDK 5.87, images and test print 5.88)
A kiosk that issues tickets must not show a print dialog. Two supported ways, both need "device": {"print": true} on the page's route and min_platform 5.87.
Receipts on a paired printer: Vizum.device.printReceipt(doc, opts)
try {
await Vizum.device.printReceipt([
{ text: "Waiting list", align: "center", bold: true, size: "double" },
{ divider: true },
"Ticket A-042",
{ qr: "https://example.com/t/A-042", size: 6, align: "center" },
{ barcode: "A042", type: "code128", hri: true, align: "center" },
{ feed: 2 }, { cut: true },
], { feed: 3 });
} catch (e) {
if (Vizum.errors.is(e, "PRINTER_NOT_PAIRED")) await Vizum.device.print(); // fall back
else throw e;
}
- The app sends data, never bytes. A document is an array of lines (or
{lines}): a string,{text, align, bold, underline, size: "normal|double|wide|tall"},{qr, size 1-16, ecc L|M|Q|H},{barcode, type code128|code39|ean13|ean8|upca|itf, height 30-200, hri},{image, ...}(5.88, below),{divider},{feed 0-10}and{cut}. The page builds the ESC/POS stream with the platform's own encoder (vizum_window_manager/static/src/js/core/escpos.js,buildDocument: the same encoder every host uses). Control bytes are stripped from text, a QR or barcode is a length-prefixed block, an unknown key (raw,bytes,hex…) is refused withVALIDATION_FAILED, so an app cannot send an arbitrary USB command. Limits: 200 lines, 120 characters a line, a 300-byte QR, 48 characters a barcode, 32 KB in all.opts:cut(default true),feed(0-10, default 3),columns(24-64, default 48, the width of a divider). - A logo: the
imageline (SDK 5.88,Vizum.supports("device-receipt-image")).
await Vizum.device.printReceipt([
{ image: LOGO_DATA_URL, align: "center", width: 200 }, // "data:image/png;base64,..."
{ text: "Waiting list", align: "center", bold: true },
"Ticket A-042",
]);
image is a data:image/png;base64,… or data:image/jpeg;base64,… URL, nothing else: there is no remote URL and the device page fetches nothing (a kiosk may be offline). Embed the logo in the app or read it from Vizum.files/your own route first. Options: align (left, center, right, like text), width (8-832 dots; default the image's own width; never more than the printer's width, so an app that does not know the paper just asks for a size), dither ("floyd-steinberg", the default, keeps photos and anti-aliased logos readable; "threshold" is a plain black/white cut, crisper for line art) and threshold (1-254, default 128: a pixel darker than it prints). The page decodes the picture with the browser, composites transparency over paper, scales it with an area average to the paired printer's dot width (the setup panel has a Paper width choice: 58 mm = 384 dots, 80 mm = 576 dots, the default; the same choice sets the default columns, 32 or 48), reduces it to 1 bit and sends ESC/POS GS v 0 raster in 128-row bands. Limits: the source is at most 96 KB and 4 million pixels, at most 2 images a receipt, each at most 800 dots tall (about 100 mm) after scaling, 64 KB of raster in all. An oversize, corrupt, wrongly typed or undecodable image rejects VALIDATION_FAILED (the decoder gives up after 8 s): never a hang. ESC/POS only: the platform has no CPCL encoder, so there is no CPCL graphics path and no CPCL-specific error. Prefer a PNG a few hundred pixels wide in black on white or transparent; a big colour photo prints as a dithered grey patch.
- One person pairs the printer, once, at the device, and only inside an administrator's setup window. A device page is an unattended screen, so the gear is not on the page until an administrator presses the printer button on the device in Paired devices (Apps Manager) on the desktop: that opens a 10-minute setup window (audited as
device:setup, administrators only, 0-30 minutes throughapi_paired_devices(key, "setup", {id, minutes}); the heartbeat tells the page how many seconds are left). Outside it the gear is hidden and every panel action is refused; if the window runs out while the panel is open, or the screen goes offline, it closes (fail closed). The window is enforced by the page: it keeps a passer-by out, not someone who controls the browser itself (developer tools), who could already print with the paired printer. Pair and Forget happen in the browser, so they are not reported to the server (the opening of the window is). A browser only opens USB or serial ports after a click. Inside the window the gear opens the platform's Printer setup panel: a name for the printer, Pair USB printer (WebUSB) and Pair serial printer (Web Serial, with the speed), Test print, Forget printer. This panel belongs to the page, not to the app iframe, whoseallowstill lists neitherusbnorserial. The browser keeps the grant; the page keeps the printer's vendor/product, serial number and name in the device's own browser storage, and it is dropped when the device is unpaired or re-paired. - Browsers. WebUSB and Web Serial exist in Chromium browsers only (Chrome, Edge, Opera, on Windows, macOS, Linux, ChromeOS; Chrome on Android has WebUSB, Web Serial needs a recent version). Firefox and Safari have neither: the call rejects
PRINTER_UNAVAILABLE. On Windows a USB printer that Windows' print driver owns cannot be claimed by WebUSB: pair it as a serial (COM) printer, or give it a WinUSB driver. A Bluetooth printer paired with the OS as a serial port works with the serial option. - Test print counts against the same limit (12 a minute) as receipts. It is the platform's fixed test receipt, which an app can print too (SDK 5.88):
await Vizum.device.printTest()prints the logo, the printer's name, its paper width, the time and a ruler as wide as the paper, so an app can offer a Test printer button with no document to compose. The samedevice.printgrant, limit and errors asprintReceipt;Vizum.supports("device-receipt-test");min_platform5.88. The setup panel's Test print button is this same call. - Errors.
PRINTER_NOT_PAIRED(nobody paired a printer, or the paired one is not connected: fall back toprint()),PRINTER_UNAVAILABLE(retryable: the printer did not take the data, or the browser has no WebUSB/Web Serial),VALIDATION_FAILED,RATE_LIMITED(at most 12 receipts a minute a device, jobs run one at a time in order),PERMISSION_DENIED(the route has nodevice.print, or the host is not a device page).Vizum.supports("device-receipt").
The page's own view: Vizum.device.print()
The supported form of window.print() from a device page. The page checks the same grant and rate limit, then the app frame prints its own view (style it with @media print). It resolves true once the print was handed to the browser; it does not know whether paper came out. Vizum.supports("device-print").
By default Chrome shows its print dialog. To print with no dialog, start the browser that runs the screen with --kiosk-printing, which sends the job to the system's default printer:
# Linux / ChromeOS-flex kiosk
chromium --kiosk --kiosk-printing https://<instance>/app/<slug>/<page>
# Windows shortcut target
"C:\Program Files\Google\Chrome\Application\chrome.exe" --kiosk --kiosk-printing https://<instance>/app/<slug>/<page>
Set the default printer in the operating system first, and test with the setup panel's Test print for a receipt printer or a print from the app for this one. The flag is a browser setting an administrator chooses; the platform cannot turn it on. An app that can be installed as a Chrome app window or PWA needs the same flag on the launcher.
Calling a device route directly (no SDK)
POST /vizum/device/<slug>/<route> Authorization: VizumDevice <token> json body ≤ 64 KB
POST /vizum/device/<slug>/_heartbeat Authorization: VizumDevice <token>
POST /vizum/device/<slug>/_sign Authorization: VizumDevice <token> {"route", "ids"}
POST /vizum/device/<slug>/_store Authorization: VizumDevice <token> {"op", "key", "value"} (5.82)
GET /vizum/device/<slug>/<route>/<id>?s=<signature> (a signed file URL)
POST /vizum/device/<slug>/_pair/start {"page", "info"} → {pairing, pin, poll, expires_in}
POST /vizum/device/<slug>/_pair/poll {"pairing", "poll"} → {state: waiting|paired|expired, token?, device?}
GET /vizum/device/<slug>/manifest.webmanifest?page=<page> (install the page full screen)
GET /vizum/device/<slug>/icon (its icon)
Answers: 200; 400 invalid request; 401 no/revoked/unknown token (the page returns to the PIN screen); 404 unknown app, an app with no approved device page, or an unknown route once the token is valid (the same 404 as any URL: a caller with no valid token or signed URL never learns which routes or apps exist); 413 body too large; 429 over the limit (120 calls per device per minute; Retry-After).
A signed file URL carries no token: s is an HMAC of (app, route, device, id, expiry), valid 24 h so an offline cache can keep it. It stops working the moment the device is revoked.
Hooks: V.device
A device route's hook runs as the app's job user (the app needs one: set it in the app's settings; without it the route answers 503, never the installer's rights, and the Apps Manager flags the app: "devices will not work until the service user is set"), inside a savepoint, with V.device:
# json route
d = V.device # {"id": 7, "name": "Lobby TV", "link": 42, "page": "player"}
screen = V.orm.read("x_vzm_signage_screen", [d["link"]], ["x_playlist_id"])
return {"version": 3, "slides": [...]}
# file route: return True to allow streaming `fields` of record `V.payload["id"]`
return V.orm.count("x_vzm_signage_media",
[("id", "=", V.payload["id"]), ("x_screen_ids", "in", [V.device["link"]])]) > 0
V.device is None in every other hook. Because the hook runs as the job user, scope every read by the device — the platform guarantees who the device is, not what it may see.
File responses: X-Content-Type-Options: nosniff, Range support, Cache-Control: private, max-age=86400 for images and documents and private, no-store for video/* and audio/*, no Set-Cookie, no CORS. Media is no-store on purpose: the browser's HTTP cache evicts the oldest entries first, and a streamed video (every Range answer is cached as its own entry) pushed the app frame's own files out of it, so an offline reload showed a blank screen. Offline media comes from the page's Cache Storage (the host downloads it itself), never from the HTTP cache — write your player to use the URL the host gives it, not to rely on the browser caching the file. Only an allowlist is served inline, the same as the 5.68 file routes: pdf, text/plain, json, video/*, audio/* and png/jpeg/gif/webp. Any other type is a download (Content-Disposition: attachment) with Content-Security-Policy: sandbox. The field must be one the job user can read (groups on a field apply). The device bundle responses (the credential is in the path) send Referrer-Policy: no-referrer.
The administrator's side (Vizum.pairedDevices, desktop)
await Vizum.pairedDevices.list(); // [{id, name, link, page, last_seen, online, active}]
await Vizum.pairedDevices.claim("482913", {name: "Lobby", link: 42});
await Vizum.pairedDevices.rename(7, "Lobby TV");
await Vizum.pairedDevices.link(7, 43);
await Vizum.pairedDevices.reload(7); // the screen reloads on its next heartbeat
await Vizum.pairedDevices.revoke(7); // unpair: the token dies now
Needs the app to declare at least one device route; claim, revoke, link and reload need base.group_system. The Apps Manager shows the same list and actions for every app with device routes.
Offline
Offline lease. A screen that is offline cannot receive the 401 of a revocation, so the page enforces a lease locally: a device with no successful authenticated call for 7 days (_session, heartbeat or a json route; platform constant DEVICE_OFFLINE_LEASE_S) tears the app frame down, deletes every device cache and its token, and shows the pairing screen again. The page checks it on load, every minute, and before it serves a cached answer or media file after a network failure. Every successful authenticated answer renews it, and a fresh pairing starts it together with the token. A missing or unreadable lease value counts as expired (fail closed): a screen that has a token but no valid lease is torn down at the next check, and it needs a successful authenticated call to serve cached data or media again.
A screen that is off for more than 7 days must be paired again. The 7 days are the platform constant DEVICE_OFFLINE_LEASE_S (one value for every app, not configurable per app). The server enforces the same lease: a device whose last authenticated call (or pairing) is older than that gets 401 on every route, its signed file URLs and its bundle credential stop working, and it must be paired again. Every authenticated call records last_seen, written at most once a minute per device.
The client lease is a display control. It limits what a left-behind screen keeps showing; it does not protect against someone with access to the browser profile or the clock: they can read Cache Storage with the developer tools, or set the clock back. Treat a stolen screen as compromised and revoke it (the next authenticated call gets the 401 and wipes everything).
A device keeps playing when the network drops: the platform page keeps the last good answer of every json route call and of every fileUrls (the signed URLs are valid 24 h, so an offline reload asks for the same URLs the media cache holds), serves media from its offline cache (Vizum.device.cache / blobUrl), and reconnects on its own. The caches belong to one device: their names carry the device id. When the device is unpaired (the 401 after a revoke) or paired again, the page deletes the data and media caches, the cache index and the saved session: a revoked or stolen screen keeps nothing. Limits: device.cache_mb, least-recently-used eviction, and only responses of THIS app's device routes are cached — never a desktop or session response.
A reload while offline (a power cut, the browser restarting) comes back to the last state:
- the platform page and its scripts come from a service worker scoped to that one device page (
/vizum/device/<slug>/sw.js?page=<page>, registered with scope/app/<slug>/<page>and sent with exactly thatService-Worker-Allowed). It answers only the navigation to that page's own path; the app's other pages (user and portal pages, which need the visitor's cookies) pass through untouched. The page asks it to keep its own URL the first time it opens, so even the first reload offline finds it; - the app is mounted with the last session the screen received. Its bundle credential lives 7 days (bound to the device; unpairing kills it on the next request that reaches the server), and the bundle files are served
Cache-Control: private, max-age=<until it expires>, immutable, so the browser's HTTP cache answers them without the network. (The app's frame is sandboxed with an opaque origin: a service worker cannot serve it.) - with no saved session, or one older than 7 days, the screen shows "Waiting for the network…" and retries. When the network returns, a screen mounted from the last session asks for a fresh one and starts its heartbeat.
Keeping the screen awake
The platform page takes a Screen Wake Lock once the app is mounted, and takes it again whenever the page becomes visible (a browser drops it when the tab is hidden). The app's frame is also allowed to (allow="screen-wake-lock *"; the page sends Permissions-Policy: screen-wake-lock=*), so navigator.wakeLock.request("screen") works inside the app. Both say * because the app's frame is sandboxed with an opaque origin, which neither self nor a bare feature name matches. Where the browser has no wake lock (some TV browsers, battery saver), set the device's own screen-off timeout to "never".
Full screen
A browser enters full screen only on a user's gesture. Three ways to get it on a screen:
- Install the device page as an app (recommended for Android, ChromeOS, Windows): open the device page in Chrome or Edge → menu → *Install app* / *Add to Home screen*. The page carries a web app manifest (
/vizum/device/<slug>/manifest.webmanifest) withdisplay: "fullscreen", so the installed app opens full screen, with no tap, every time. - Kiosk mode: start the browser with the device page in kiosk mode, e.g.
chrome --kiosk --autoplay-policy=no-user-gesture-required https://<instance>/app/<slug>/<page>(Windows/Linux), or the OS's kiosk / single-app mode (Android *App pinning*, ChromeOS kiosk apps). Also allows sound on autoplay. - One tap: otherwise the platform page shows a transparent layer with a small "Tap to show full screen" hint; the first tap anywhere turns full screen on and the layer goes away. Until that tap, the app plays normally in the window. In the app,
Vizum.device.fullscreen()called from a tap handler works too.
When the page already runs full screen (an installed app, kiosk mode, a browser in F11) the layer is never shown. The page sends Permissions-Policy: fullscreen=*, autoplay=*, encrypted-media=*, picture-in-picture=*, screen-wake-lock=* and the app's frame carries allow="fullscreen *; autoplay *; encrypted-media *; picture-in-picture *; screen-wake-lock *" — on device pages only, never on the desktop.
Large media files (permissions.file_limits, Vizum.files.putBlob)
Vizum.files keeps 64 MB a file, 50 files and 128 MB per user (per app with data: "global"). A signage, training or catalogue app asks for more, as its own approved grant:
"permissions": { "data": "global", "file_limits": { "max_mb": 100, "count": 2000, "total_mb": 8192 } }
| key | default | platform ceiling |
|---|---|---|
max_mb | 64 | 200 (512 from 5.71, see Large files) |
count | 50 | 5000 |
total_mb | 128 | 16384 |
- Privileged: the approval card shows the numbers; raising any of them later is approved again. The administrator's storage cap (Apps Manager → Usage & budgets) still applies on top, for all users together.
- Upload large files with
Vizum.files.putBlob(name, blob)(aBloborFile): the host sends the bytes to/vizum/apps/files/<key>/putwith the user's session — no base64 from the browser, no JSON. The server reads the body into memory (up tomax_mb, at most one streamed upload per user at a time: a second one answers 429 until the first finishes), so keepmax_mbas low as the app needs.Vizum.files.put(name, base64)keeps working for small files; above 16 MB useputBlob. Above 32 MB the host uploads in parts (5.71, files.md): each part is its own streamed request under the same rules — the upload slot, a bounded read,Content-Lengthchecked, the open upload checked before the body is read.
Media a device plays: Vizum.orm.writeBlob
A device's file route streams a binary field of a declared model (signed URL, Range supported, so a 100 MB video plays and seeks without being downloaded whole) — not Vizum.files. To put a large file where a device can play it, stream it into that field:
const rec = await Vizum.orm.create("x_vzm_signage_media", { x_name: file.name });
await Vizum.orm.writeBlob("x_vzm_signage_media", rec, "x_file", file); // {id, field, size, sha256}
- The host streams the bytes to
/vizum/apps/records/<key>/write_blob?model=…&res_id=…&field=…with the user's session (same CSRF proof asputBlob), raw — no base64 in the browser, no JSON-RPC body limit. - The write is
api_orm's, as the user: it passes exactly whatVizum.orm.write(model, [id], {field: base64})would — the app's orm grant (model,write,write_fields), record rules, the app's guards and triggers. On the desktop the user confirms it like any write. - Size: up to the app's approved
file_limits.max_mb(default 64 MB, at most 200 here: these routes read the body in one request; above 200 MB useVizum.files.putBlob, which uploads in parts). Both streamed routes (files/put,write_blob) accept bodies up to that 200 MB platform ceiling (Odoo's own 128 MiB default is raised for them). The host checks the file size against the app's approvedmax_mbbefore it uploads (an over-limit file is refused with "file too large" and nothing is sent), because Odoo answers a body *declared* above the 200 MB ceiling with its own plain HTML 413 before the route runs; the host also reads any non-JSON 413 as "file too large". A body above the app'smax_mbbut within the ceiling is the route's JSON 400.write_blobchecks the app's orm grant (model,write,write_fields) before it reads the body and takes the user's single upload slot first. The write itself still passesapi_ormwith base64, so while it runs a worker holds about 2.3 times the body (the raw bytes are dropped once encoded; Odoo decodes the base64 again when it stores the field): about 470 MB for a 200 MB file, for one upload per user. Odoo re-runs an endpoint after a database serialization failure; the platform keeps the (encoded) body on the request for that retry and refuses a body whose length differs fromContent-Length, so a retry never writes an empty field. - The field must be a
binaryorimagefield; the devicefileroute that plays it declares the same model and field ("model": "x_vzm_signage_media", "fields": ["x_file"]).
Residual risks
Known and accepted; read them before you rely on a limit.
- A distributed flood can delay new pairings. Each source address (an IPv4 address, or an IPv6 /64) may start a limited number of pairings, and an app holds at most 1000 waiting PINs. An attacker who controls many sources (about 100 IPv4 addresses or IPv6 /64 prefixes) can keep that pool full and delay new pairings. Pairing is started by an administrator and can be retried; 8-digit PINs keep the chance that a typo hits somebody else's PIN at about 1 in 100 000 (1000 PINs out of 10^8).
- Behind a proxy, set
proxy_mode. The per-address limits use the address Odoo sees. Withoutproxy_mode, every request comes from the proxy and shares one bucket. - Streamed uploads hold memory.
files/putandwrite_blobhold up to about 2.3 times the body (200 MB ceiling: about 470 MB) per concurrent user; one upload per user at a time, but many users uploading at once add up. Size the workers for it. - The offline lease is a display control (see Offline): browser-profile or clock access defeats it.
SDK & server
Guest links — customers with no account (auth "link") PRIVILEGED
A customer who follows a repair, approves a quote or sends a photo of a broken part has no Odoo account and should not need one. SDK 5.104 gives a vApp an account-less guest link:
- The app issues a link from its server code (
V.links.issue). The platform answers with a URL that carries a long random token — once. The instance keeps only the token's SHA-256; nobody can read it back. - The customer opens the URL. The token travels in the URL fragment (
https://<instance>/app/<slug>/<page>#vzl_…), so it reaches no access log, no proxy and noReferer. The platform page exchanges it once for a short guest session and removes it from the address bar. - The page runs the app's bundle. The app calls its own
json,fileanduploadroutes throughVizum.link; each call runs the route's hook as the app's service user withV.linkdescribing the share — never the token. - The link expires (30 days by default, 90 at most), can be revoked at once, and re-issuing it revokes the old one. Every opening, call, download, upload, code and refusal is a row of the platform's link ledger.
The platform owns every token decision: the token, the session, expiry, revocation, limits, the code step, the response headers, the ledger. The app decides what a link shows: its hooks read only what belongs to the share V.link names.
Manifest
{
"min_platform": "5.104",
"install_scope": "admin",
"permissions": {
"logic": "script",
"orm": [
{ "model": "x_vzm_portal_document", "ops": ["read", "search"], "fields": ["x_file"] },
{ "model": "x_vzm_portal_upload", "ops": ["read", "create", "write"], "fields": ["x_file", "x_filename", "x_request_id"] }
]
},
"native": {
"service_user": true,
"script": { "hooks": [ { "name": "guest_state", "code": "…" }, "…" ] },
"routes": [
{ "name": "share", "kind": "page", "auth": "link", "title": "Your service" },
{ "name": "state", "kind": "json", "auth": "link", "hook": "guest_state" },
{ "name": "decide", "kind": "json", "auth": "link", "hook": "guest_decide",
"link": { "verify": "code", "per_minute": 10 } },
{ "name": "doc", "kind": "file", "auth": "link", "hook": "guest_document",
"model": "x_vzm_portal_document", "fields": ["x_file"] },
{ "name": "up", "kind": "upload", "auth": "link", "hook": "guest_upload",
"model": "x_vzm_portal_upload", "fields": ["x_file"],
"link": { "max_mb": 10, "accept": ["image", "pdf"], "filename_field": "x_filename",
"review": "guest_upload_review", "match": { "field": "x_share_id", "scope": "share" } } }
]
}
}
| key | on | meaning |
|---|---|---|
auth: "link" | page, json, file, upload | a guest holding a link THIS app issued opens it; nobody else |
kind: "upload" | link routes only | one file the guest sends, written by the platform into the route's one declared binary field |
link.verify: "code" | json, file, upload | the guest must first type a one-time code the app sent (below) |
link.per_minute | json, upload | calls a minute per link on this route, 1–60 (the platform allows 60 a minute for all of a link's routes) |
link.max_mb | upload | the largest file, 1–25 MB (default 10) |
link.accept | upload | image (PNG, JPEG, GIF, WebP, HEIC, AVIF) and/or pdf — decided by the bytes, never the declared type; SVG and HTML are always refused |
link.filename_field | upload | a char or text field of the same record the platform fills with the file's name (any other type is refused at run time; the file itself only ever goes to a binary or image field) |
link.review | upload (required) | the hook that receives the bytes and accepts, replaces or rejects them — nothing is stored before (quarantine, below) |
link.strip_metadata | upload | default true: a photo's EXIF (with its GPS position), XMP, comments and text chunks are removed before any hook sees it (JPEG, PNG, WebP; the image itself is copied byte for byte). It fails closed: a file whose metadata cannot be removed — a malformed JPEG, or HEIC/AVIF, which the platform does not rewrite — is refused (415). Set false to accept HEIC/AVIF as they come |
link.match | file, upload | {"field", "scope"}: the platform checks that record[field] equals the link's scope[scope] (a many2one compares its id) before any hook runs — a weak hook cannot hand out another share's record |
model, fields | file, upload | the ONE model; the binary fields a file route may stream (up to 8), the ONE field an upload writes |
- A route name is 2–32 characters (
^[a-z][a-z0-9_]{1,31}$):share, nots. - A
fileroute reads, and anuploadroute writes, as the service user: the manifest needs an explicitpermissions.ormrule on that model (the"*"read wildcard never counts) whoseopscarryread(file) orwrite(upload) and whosefields(andwrite_fields, for an upload) cover the declared fields. Platform models (vizum.*) stay closed. The rule'sdomainis a ceiling on ids. Checked at validation, byvzm check, by the store and on every request against the approved snapshot. - A link route never takes
groups,path,portal_entry,website_menu,sitemap,device,token,filesorallow_portal. - The validator synthesizes
permissions.link_routes(the names): a privileged grant with its own approval card line, "Customer links: anyone holding a link this app issues can open … for up to 90 days". Adding a link route, or changing itslinkblock, model or fields, is a widening the administrator approves again. - Declare
native.service_user: true. Hooks run as the app's service user; without one every link call answers 503 (vzm checkwarnsLINK_NEEDS_SERVICE_USER; the Apps Manager's health check flags it).
Issuing links (server code: V.links)
# a staff method or hook: share a job with its customer
out = V.links.issue(ref=str(share_id), scope={"share": share_id},
contact_hint="María P. (…4521)", expires_in=14 * 86400, grace=7 * 86400)
# out = {"id": 41, "token": "vzl_…", "url": "https://acme.example/app/portal/share#vzl_…",
# "expires_at": "2026-10-26 09:12:03"}
V.orm.write("x_vzm_portal_share", [share_id], {"x_link_id": out["id"]})
result = {"url": out["url"]} # shown ONCE to the person who shares it
| call | does |
|---|---|
V.links.issue(ref=None, expires_in=None, grace=None, scope=None, contact_hint=None, page=None) | a new link: {id, token, url, expires_at}. expires_in seconds, 3600 – 7 776 000 (90 days), default 2 592 000 (30 days). grace 0 – 2 592 000 (30 days): how long the link stays open after close(). ref ≤ 128 characters, scope a JSON object ≤ 2 KB, contact_hint ≤ 64 characters — all three are the app's own and come back in V.link. page names the link page the URL opens (default: the first) |
V.links.reissue(id, expires_in=None) | revokes id and issues its replacement (same ref, scope, hint, page, grace) — the customer lost the link |
V.links.revoke(id, reason=None) | dead now: the token, every guest session, the signed file URLs |
V.links.extend(id, expires_in) | a new expiry from now (within the 90 days); ledgered |
V.links.close(id, grace=None) | the job is over: the link stays open for its grace (set at issue, or here), then expires; grace 0 ends it now |
V.links.status(id) / V.links.list(ref=None, live=True, limit=None) | {id, ref, scope, contact_hint, page, expires_at, state: live/expired/revoked, issued_on, closed_on, revoked_on, last_used_on, open_count} — never the token |
V.links.ledger(id, limit=None) | that link's ledger rows: {at, event, route, outcome, source, ua} — for the app's own access panel |
V.links.issue_code() | inside a link route's hook only: a fresh one-time code for THIS visitor (below) |
- An app reaches only links it issued: the key is the bridge's, never an argument.
- The token is shown once. Store
id, never the token. To give the customer the link again,reissue(the old one dies). Keeping a token readable on the server would make the database a copy of every customer's key. - First-party is verified, not named. An app counts as Vizum's when the platform verified at install that the Vizum Store delivered it, a pinned Vizum publisher key signed it and its bytes are intact — never because its key starts with
com.vizumapps.; a side-loaded package with such a key issues nothing under the default policy. A method that trusts first-party CALLERS more (e.g.portal.share@1) readscaller_app_signed(): the calling app's key under that same rule, elseNone(caller_app()stays the verified caller of any publisher). - Who may issue is the app's own decision (Portal: any of its users). Which apps may issue is the instance's: Vizum's own apps, verified as published and signed by Vizum, issue by default; any other app needs an administrator to set
vizum_apps.guest_links_policytoall, or to list its key invizum_apps.guest_links_apps(comma-separated).offstops every app from issuing. The setting governs issuing only: links already out keep working until they expire or are revoked (switch a route off to stop them at once, below). At most 120 issues a minute and 50 000 live links per app. - Inside a link route's hook a guest never mints, extends or re-points a link:
issue,reissue,extend,close,status,listandledgerraise there;revoke()(the guest's own link) andissue_code()work.
The link page and Vizum.link (browser)
The page is a platform shell with no Odoo session: the same bytes for every visitor, every link and every invented token. Once it has exchanged the fragment, it loads the app's bundle in the usual sandboxed iframe, and the iframe's SDK exposes:
await Vizum.connect();
const share = await Vizum.link.info(); // {kind: "link", link, ref, scope, contact_hint, expires_at, verified, route, user_id: null, partner_id: null}
const state = await Vizum.link.call("state", { since: cursor });
img.src = await Vizum.link.fileUrl("doc", docId); // a 10-minute signed URL
await Vizum.link.download("doc", docId); // the PAGE downloads it
const up = await Vizum.link.upload("up", file, { params: { request: reqId },
onProgress: ({ loaded, total }) => bar(loaded / total) });
await Vizum.link.verify("482913"); // the code the app sent
await Vizum.link.close(); // end this visit
Vizum.linkanswers only on a link page; everywhere elsePermissionDenied. A link page has no other capability:Vizum.orm,Vizum.data,Vizum.files,Vizum.ai… answerPermissionDenied(onlyVizum.ui.notifyworks). Its data comes from its own routes.- Errors:
LINK_INVALID(unknown, expired, revoked, closed — one code for all; the page then shows "This link is no longer valid. Ask the sender for a new link."),VERIFY_REQUIRED,RATE_LIMITED(retryable),QUOTA_EXCEEDED(a file overmax_mb),VALIDATION_FAILED(a file whose bytes are not an accepted type, a wrong code),NOT_FOUND(an unknown route). - The app's frame cannot download (it is sandboxed without
allow-downloads):Vizum.link.downloadmakes the platform page do it.
Hooks: V.link and V.share
A link route's hook runs as the app's service user (never the installer, never uid 1), inside a savepoint, with:
V.link # {"id": 41, "ref": "1207", "scope": {"share": 1207}, "contact_hint": "María P. (…4521)",
# "expires_at": "2026-10-26 09:12:03", "verified": False, "route": "state"}
share = V.orm.read("x_vzm_portal_share", [V.link["scope"]["share"]], ["x_title", "x_state"])
V.linkis what the platform verified, passed explicitly to the script runner — never taken from the request or the RPC context, never the token. It isNonein every other hook.- Scope every read by
V.link. The platform guarantees *which* share the guest holds, not what it may see: the service user's grant is the app's whole grant. A body value (rec) is input, never identity: readV.link["scope"], notrec["share_id"]. A guest holds no generic ORM — nothing from a link reaches the app's data except through your hooks. - A file route's hook gets
rec = {"id", "field"}and returnsTrueto let the platform stream that declared field of that record of the declared model; anything else is a 403. - An upload is held in quarantine until the app accepts it:
- the platform checks the size and the type (by the bytes) and strips the photo's metadata (
link.strip_metadata, default on); - the route's hook gets
rec = {"name", "mimetype", "size", "sha256", "params"}— the file's description, not its bytes — and returns the id of the record of the declared model the file is for (usually a row it just created), or{"id": …, "result": …}; - the review hook (
link.review) gets the same plus"id"and"data"(base64) and answersTrue/{"accept": True}(store them),{"data": base64}(store THESE bytes — a cleaned or converted file, checked again for size and type) or anything else, such as{"reject": "why"}(store nothing; delete your row if you want). A dict's"result"is what the page receives, and its"name"renames the file (say,"photo.jpg"after converting it to JPEG); - only then does the platform write the accepted bytes into the declared field as the service user, under the same grant and domain ceiling. The file's name (the review's
"name", or the one the page sent) goes intolink.filename_fieldmade safe — printable, no/or\, no leading dot, at most 200 characters — and with the extension of the type of the bytes actually stored: a review that hands back a JPEG forphoto.pngstoresphoto.jpg, andscanstored as a PNG isscan.png.
- the platform checks the size and the type (by the bytes) and strips the photo's metadata (
Steps 2 to 4 are one unit: if the platform refuses the file after a hook ran (a replacement over max_mb or of a refused type, a record outside the grant or link.match), everything the hooks did in that request (the row step 2 created, a counter the review raised) is rolled back, and the refusal is a ledger row; the page receives 413 or 415.
Until step 4 the bytes live only in that request: no grant, no other request and no record can read them, so a raw photo (its GPS included) is never readable before the review.
One hook for links and portal users: V.share
Some customers come back often and have an Odoo portal account (auth: "portal" routes, 5.53). One hook serves both: V.share describes the share in one shape, whichever way the customer came:
s = V.share
# a guest link: {"kind": "link", "link": 41, "ref": "1207", "scope": {...}, "contact_hint": "...",
# "expires_at": "...", "verified": False, "route": "state", "user_id": None, "partner_id": None}
# a portal user: {"kind": "portal", "link": None, "ref": None, "scope": None, "contact_hint": None,
# "expires_at": None, "verified": True, "route": "state", "user_id": 12, "partner_id": 7}
if s["kind"] == "link":
shares = [s["scope"]["share"]]
else:
shares = V.orm.search("x_vzm_portal_share", [("x_partner_id", "=", s["partner_id"])])
kind: "portal"is set for a hook that anauth: "portal"route admitted the visitor to (its json route, orVizum.script.runfrom its page):user_idis the visitor,partner_idtheir commercial partner. Such a hook runs as the visitor, as every portal hook does (route matrix), not as the service user.V.shareisNoneeverywhere else (the desktop, jobs, triggers,userroutes).Vizum.link.info()returns the same shape (kind: "link"); the types call itVizum.ShareInfo.
The one-time code (link.verify: "code")
For a sensitive action (approving a quote), a route can require a code the customer receives through a channel the app controls:
- The page calls a route, say
send_code, whose hook doescode = V.links.issue_code()and sends it with the app's own channel (e-mail throughmail.mail, WhatsApp through its contract). The platform never sends anything itself. - The customer types it:
Vizum.link.verify(code). - From then on, for this visit (the guest session, at most 8 hours), routes with
link.verify: "code"answer; before, they answerVERIFY_REQUIRED.
6 digits, valid 10 minutes, 5 attempts (then the code dies), at most 5 codes an hour per visit. Wrong codes also add up per link over its whole life, across visits, codes and sources: at 20 the link is locked — revoked with the reason locked: too many wrong codes (in V.links.status / list as revoke_reason, a lock row in the ledger) — and has to be re-issued. That bounds the guesses at any link to 20, a chance of 20 in a million. Every wrong code also counts against the visitor's source (30 failures in 10 minutes lock the source out). Kept only as an HMAC. An app whose channels are all off simply does not use verify — the step is per route, off unless declared.
Calling the routes directly (no SDK)
POST /vizum/link/<slug>/_open Authorization: VizumLink <token> {"page"} → session cookie + bundle credential
POST /vizum/link/<slug>/_session (cookie) {"page"} → bundle credential (a reload)
POST /vizum/link/<slug>/<route> (cookie) json body ≤ 64 KB → {ok, result}
POST /vizum/link/<slug>/<route> (cookie) the file's bytes, X-Vizum-Filename, X-Vizum-Params (an upload route)
POST /vizum/link/<slug>/_sign (cookie) {"route", "ids", "field"?} → signed file URLs
POST /vizum/link/<slug>/_verify (cookie) {"code"}
POST /vizum/link/<slug>/_close (cookie) → the cookie goes
GET /vizum/link/<slug>/<route>/<id>?g=…&e=…&f=…&s=…[&dl=1] (a signed file URL)
Every POST carries X-Vizum-Link: 1 (a header a cross-site form cannot send; these routes answer no CORS preflight) and, when the browser sends them, Sec-Fetch-Site: same-origin and an Origin of this site — on top of the cookie's SameSite=Strict.
Answers: 200; 400 invalid body or a wrong code; 401 for every link failure — unknown, malformed, expired, revoked, closed, another app's link, no or a dead session — with the same body, byte for byte; 403 VERIFY_REQUIRED or a CSRF refusal; 404 an app with no approved link page, or an unknown route once the session is valid (a caller without a live link never learns which routes exist); 411 an upload without Content-Length; 413 over the body cap or max_mb; 415 a file whose bytes are not accepted; 429 over a limit (Retry-After: 60); 503 the route is switched off, or the app has no service user, or ({"ok": false, "error": "busy"}, Retry-After: 1) the call lost a race on a key its hook locks with advisory_lock after Odoo replayed it several times: send it again. A single lost race is replayed by Odoo and the guest gets the hook's own answer.
Security, decision by decision
| Token | vzl_ + 256 random bits (base64url), shown once to the minting call, stored as SHA-256 only (a 256-bit secret needs no slow hash), compared with hmac.compare_digest. Bound to one link row of one app of one database; never re-pointed — re-issuing is a revocation |
| Transport | the URL fragment (never sent to a server, never in a log or a Referer), read and removed (history.replaceState) by the page before it loads anything else; sent once to _open in an Authorization header |
| Files (VZ-SEC-40) | a signed file URL lives 10 minutes and is bound to the guest session, the route, the record, the field and the field's current content (the attachment's id and checksum, or the SHA-256 of a column-stored value): once the file is rewritten the old URL answers 401 and never serves the new bytes; the page asks for a fresh URL, and the hook decides again. A URL is signed, and its content read for that binding, only for a record the guest may have by every check without side effects — the explicit read grant and its domain ceiling, the service user's rights and record rules, link.match; other ids get no URL and nothing of them is read (the hook runs once, at the fetch). No link endpoint mints a Vizum.files public token (the one VZ-SEC-40 rotates on every overwrite); an upload writes a record field, never a public file |
| Page | a link opens the page it was issued for (page at issue, default the first link page) and no other page of the app; the session it gives is bound to that page too |
| Session | vzs_ + 256 bits in a cookie vzm_link: HttpOnly, SameSite=Strict, Path=/vizum/link/<slug>/, Secure on https and whenever web.base.url is https (a TLS proxy without proxy_mode); stored as SHA-256; lives 8 hours at most and never past its link; at most 20 per link (the oldest go). Odoo's own session is never saved |
| Expiry | 1 hour – 90 days, default 30; grace after close() up to 30 days; extending is ledgered. A day after expiry the hash is wiped (nothing can match it again) and a ledger expire row says so |
| Revocation | instant: the hash and every session go; also on app uninstall (ledgered) and, while the app is disabled or awaiting approval, every link route refuses |
| Uniform refusal | every link failure answers 401 with the same bytes; the reason goes only to the ledger |
| Limits | per link 60 calls a minute and 10 uploads/code checks a minute; per route link.per_minute; per source (an IPv4 address, an IPv6 /64) 300 calls a minute and 30 failed resolutions per 10 minutes, after which the source is refused 429 even with a good link; per app 3 000 calls a minute; opens 30 per link per 10 minutes. Counted atomically on their own cursor (the device throttle) |
| Bodies | json 64 KB; uploads max_mb ≤ 25, Content-Length required and checked before reading, type by content (vizum_app_file.sniff, the files.md signature table) |
| Headers | link page: Cache-Control: no-store, X-Robots-Tag: noindex, nofollow, Referrer-Policy: no-referrer, X-Frame-Options: DENY, X-Content-Type-Options: nosniff, and a CSP stricter than a public page's: default-src 'none'; script-src 'self'; style-src 'self'; img-src 'self' data:; connect-src 'self'; frame-src 'self'; frame-ancestors 'none'; base-uri 'none'; form-action 'none'; object-src 'none' (no inline script: the page's config is a JSON data block). json answers: no-store, nosniff, no-referrer, no cookie but the session's. Files: the 5.68 inline allowlist, everything else a sandboxed download; no-referrer |
| Identity | hooks run as the service user, with V.link passed explicitly; a guest never mints a link |
| Ledger | vizum.app.link.ledger: issue, reissue, open, call, file, upload, code, verify, revoke, extend, close, expire, refused, kill/unkill — with the route, the outcome, the source as an HMAC of the /64 prefix under a daily key (rows of one day match, nothing more), the browser family, the user who issued or revoked. Closed to vApps; administrators read it under *Vizum Apps Admin › Guest links*. Kept 365 days (vizum_apps.guest_links_ledger_days, at least 30) |
| Kill switch | api_guest_links(key, "kill"/"unkill", {route}) (administrators): the route answers 503 at once, its page shows "Temporarily unavailable"; ledgered and audited |
The administrator's side
- *Vizum Apps Admin › Guest links*: the links (never a token), the ledger (filter by app, link, event, source; group by event), the routes switched off.
api_guest_links(key, op, args), base.group_system only:routes(the app's link routes and whether each is switched off),list,ledger({id?}),revoke({id, reason?}),kill/unkill({route, reason?}).
Emulator
bootEmulator({ manifest, host: "link", fixtures: { link: { ref, scope, contact_hint } } }) answers Vizum.link like a link page:
emulator.linkHook("state", (body, { link }) => ({ ref: link.ref }));
emulator.linkHook("send_code", (_body, ctx) => { ctx.issueCode(); return true; });
await Vizum.link.call("send_code");
await Vizum.link.verify(emulator.linkCode());
emulator.linkState("revoked"); // every call now rejects LINK_INVALID
It checks the routes against the manifest (kind, verify, max_mb, accept by the file's type) and refuses every other namespace on a link host. Not modelled: tokens, cookies, limits, the ledger, content sniffing of the bytes.
Residual risks
- A link is a bearer credential. Whoever holds it is the customer until it expires or is revoked. Keep lifetimes short, use the code step for decisions, revoke on request.
- Behind a proxy, set
proxy_mode. The per-source limits use the address Odoo sees. - Distributed guessing is hopeless against 256 bits, but a botnet can spend its sources' failure budgets; the per-app ceiling bounds the load.
SDK & server
Porting an Odoo module to a vApp — runs exactly like the module PRIVILEGED
A vApp can do everything an Odoo module does and run exactly as one: the same models, the same columns on Odoo's models, the same Python methods, record rules, groups, crons, data, views, menus and reports. The difference is how it arrives. A vApp comes from the Vizum Store, the administrator approves what it may touch, and nothing is copied into the customer's addons path.
The proof is PSI DSD Engine: the module psi_dsd (25 models, 385 methods) ported this way. The module's own 818 tests pass against the vApp, and they pass again on a database migrated from the module.
1. Translate the module
python3 tools/module2vapp_port.py path/to/my_module --key com.acme.mymodule --out port.json
# a module of yours whose import-time values the port needs:
python3 tools/module2vapp_port.py path/to/my_module --key com.acme.mymodule \
--evaluate inprocess --out port.json
The translator reads the module's Python and XML and writes the manifest's native section. By default it runs nothing of the module (--evaluate static): the module's code is read as text, so it is safe for a module you did not write. A name the module computes when it is imported (_DEPENDS = component_depends(...), a selection built from a table) stays unknown in that mode and becomes a TODO. For a module you own and trust, --evaluate inprocess runs each models file's top level, with Odoo stubbed, to learn those values; it needs the main thread, applies a time limit to each statement, and is no boundary against hostile code, so never use it on an upload. The paths the module's manifest names must stay inside the module, and a symlink, a FIFO or a device is never read.
| In the module | In the vApp |
|---|---|
class X(models.Model) with _name | native.models[] (x_vzm_<app>_<name>) |
fields added to an Odoo model (_inherit) | native.fields[], columns x_vzm_<app>_<field> |
| field names | kept: native.aliases: true makes them real (related) fields |
model methods, overrides with super() | native.methods[], overrides call super_() |
_private() helpers | p_private() (underscore names stay forbidden in the sandbox) |
@api.depends computes, defaults, @api.constrains, SQL CHECK | computes, defaults and constraints by method |
ir.model.access.csv, ir.rule, res.groups | model access, rules, native.groups (with implies) |
| XML data, sequences | native.records (each adopts the module's xmlid) |
ir.cron | jobs with "method": "<model>.<name>" |
| views, actions, menus, QWeb reports | native.ui |
depends: an Odoo module (sale, stock…) | odoo_requires — the install refuses where it is missing (base, web are always there) |
depends: a custom module already ported | requires {app, version} (5.67) — see below |
Translations
A module's i18n/es.po becomes the app's i18n/es.json (5.84: the same file also translates the backend). Take the entries of the module's .po and map them:
In es.po | In i18n/es.json |
|---|---|
model:ir.model.fields,field_description:<module>.field_<model>__<field> | the English label as the key, or @native:<model>.<field> |
model:ir.model.fields,help:... | @native:<model>.<field>#help |
model:ir.model.fields.selection,name:... | @native:<model>.<field>=<value> |
model:ir.ui.menu,name:<module>.menu_x, model:ir.actions.act_window,name:... | @native:<record id>.name (or the English text) |
model_terms:ir.ui.view,arch_db:<module>.view_x | one entry per term: the exact English text as the key |
code:addons/<module>/... | the English text as the key (a method's tr()) |
vzm i18n import-po does that mapping for you:
vzm i18n import-po my_module/i18n/es.po --manifest manifest.json # writes ./i18n/es.json
It reads the .po (multi-line strings, escapes, plural forms: msgstr[0] is taken) and merges it into the app's i18n/<lang>.json:
- Every entry becomes the plain English
msgid→msgstr: field labels and help, selection labels, model labels, menus, actions, groups, view terms andcode:strings (they servetr(),V.tr()andVizum._ttoo). - When the same English text has two different translations, and the manifest's
native.models/native.fieldscan name the contexts, each one gets its own key (@native:<model>.<field>,@native:<model>.<field>#help,@native:<model>.<field>=<value>,@native:<model>) and the Odoo model name is mapped to the app's short model name. What the manifest cannot name stays one plain msgid (the most frequent translation) and is listed as AMBIGUOUS in the report. - An existing catalogue entry is never overwritten unless
--overwrite. - Skipped and counted:
#, fuzzyentries, emptymsgstr, translations with executable markup (the same rule as the install gate), placeholders that differ from the msgid's, strings above 8192 characters, and translations equal to the source.
The module translator (tools/module2vapp_port.py) skips fuzzy and msgctxt entries too. vzm check lists the native strings of the manifest that the catalogue does not cover.
A module that depends on another custom module (one in the same addons directory) needs that module ported first. Tell the translator which app it became, and the port requires it:
python3 tools/module2vapp_port.py path/to/my_module --key com.acme.mymodule \
--ported acme_core=com.acme.core@^1.2 --out port.json
The platform then refuses to install com.acme.mymodule without com.acme.core 1.2 or a later 1.x, and refuses to uninstall com.acme.core while this app needs it (composition.md). A custom dependency you have not mapped is reported as a TODO.
What the translator cannot carry is reported as a TODO. It covers raw SQL, a row lock (.lock() exists for SELECT … FOR UPDATE) and another app's data. For each one, write a replacement method in an overrides folder: one file per method, with a comment saying why.
2. What the methods may touch
Inside a method, self and env[...] are *governed* records:
- They are limited by the app's
permissions.ormgrants, by Odoo's access rights and by record rules.sudo()lifts the access rights and rules, never the grants. - Grant
writeonly where the code writes. A write grant nobody exercises is attack surface. - Also listed, and approved by the administrator:
- overrides of Odoo methods, in
permissions.methods; - private Odoo methods the code calls, in
permissions.private_calls; ir.config_parameterkeys, inpermissions.params;- another app's data rows, read-only, in
permissions.peer_data.
- overrides of Odoo methods, in
- Another app calls a method only with its own
call:<method>grant.
3. Prove it behaves the same
python3 tools/module2vapp_port_tests.py path/to/my_module port.json OUT_ADDON_DIR
This copies the module's tests into a test-only addon. Only what names the module changes: model names, the module's xmlids, and imports of module-level helpers. Install the vApp in a test database and run that addon. "Works the same" means those tests pass.
4. Migrate a customer that runs the module
Declare "replaces": "my_module" and the adoption maps (the translator writes them):
- While the module is installed, the app waits. It creates its tables and columns and copies the data. Nothing of it touches Odoo's models yet: no alias, no override, no constraint, no view, no menu, no job. Those names and that behaviour are still the module's.
- What is copied:
- every row of the module's models;
- the module's columns on Odoo's models, with links translated to the adopted rows;
- the members of the module's groups (
groups[].adopt).
- When the customer uninstalls the module:
- the chatter and attachments of every adopted record move to its copy;
- on the next start the app goes live by itself: aliases, overrides, backend.
- Uninstalling the module drops its tables. Rehearse it on a copy of the customer's database first.
A vApp that uses any of this sets min_platform: "5.66" — "5.67" if it requires a ported app (the object form of requires). vzm check names the version each declaration needs.
SDK & server
The vzm CLI — check, doctor, context, sign, verify
vzm ships in @vizum/vapp-cli and needs nothing but Node 22.13 or later: no install step runs its checks. It carries the platform contract and the manifest schema it checks against — copies generated from the platform itself — so its answer is the platform's, not a second opinion.
npx vzm check # manifest + code against the platform contract
npx vzm check --gate publish # may it go to the Store? exit 0 ok · 1 blocked · 3 pending (compat-gate.md)
npx vzm grants # the permissions.orm the code needs vs the declared one (grants.md)
npx vzm doctor # is this project and this machine ready?
npx vzm context # the context a coding agent needs for THIS app (--write: AGENT_CONTEXT.md)
npx vzm context --feature orm --model sale.order # only the part of the app being worked on
npx vzm test # the app's tests on the platform emulator (emulator.md)
npx vzm dev # the app in a browser, on the platform emulator
npx vzm diff OLD.vapp manifest.json # what a new version changes; breaking without a major = exit 1
npx vzm schema diff OLD.vapp manifest.json # each data change: SAFE / WARNING / BREAKING / DESTRUCTIVE
npx vzm introspect --manifest manifest.json --out src/models.ts
npx vzm pack dist # zip the build into a .vapp (runs the native checks first)
npx vzm keygen --publisher acme && npx vzm pack dist --sign vzm-signing.key
npx vzm verify app.vapp # who vouches for a package — see package-signing.md
npx vzm new react-ts my-app
npx vzm i18n import-po my_module/i18n/es.po # a module's .po into i18n/es.json (5.84 native strings)
npx vzm --help # every command and its flags
vzm check
npx vzm check [manifest.json] [--src dir]… [--dist dir] [--json] [--strict-i18n] [--no-odoo] [--strict-odoo]
What it checks, and why each one matters:
| Check | Code | Level |
|---|---|---|
| The manifest against the platform's JSON schema (every key, every type) | SCHEMA | error |
The native section: relations, defaults, numbering, computed fields, migrations, team, adopt, a column/view/Action-menu binding on an Odoo model that permissions.schema.models does not list (the installer refuses it) — and the min_platform it needs | NATIVE | error |
A Vizum.* member the platform does not have (a typo, an invented API, a mock left in) | UNKNOWN_API | error (warning when the code detects it first and falls back) |
A named permissions.net host without a port in an app below min_platform 5.74: from 5.74 it is reachable on 80/443 only (see net) | NET_PORT_UNDECLARED | warning |
Code that parses the app key out of the run URL (a regex or split over /vizum/apps/run/, which on a multi-database host reads @<db>): use Vizum.app.key (see app-key) | APP_KEY_FROM_URL | warning |
A password input (type="password", type: "password") in the bundle: Vizum asks for the account password only in its own host dialog, Vizum.identity.confirm (see identity-step-up); never ask for it inside an app window | PASSWORD_INPUT_IN_BUNDLE | warning |
V.identity in server code with min_platform below 5.79 | MIN_PLATFORM_TOO_LOW | error |
An offline declaration the platform refuses: a dataset or write model without an orm grant that carries the op (the "*" grant does not count), fields outside the grant's ceiling, more than 12 datasets, limit over 20 000, max_mb over 100, an unknown placeholder in a domain, a duplicate name, permissions.offline written by hand | OFFLINE_INVALID | error |
offline with min_platform below 5.80 | MIN_PLATFORM_TOO_LOW | error |
An actions[].model that is neither an own native model, a model the app adds fields to, nor in permissions.orm (the platform refuses the action on every run) | ACTION_MODEL_NOT_GRANTED | error |
V.is_service_user in server code, or rec["su"] in a trigger hook, with min_platform below 5.81 | MIN_PLATFORM_TOO_LOW | error |
V.vector.shared_namespaces in server code with min_platform below 5.83 | MIN_PLATFORM_TOO_LOW | error |
A unique that names x_company_id (a company_scoped model), or jobs beside permissions.data: "user", with min_platform below 5.81 | NATIVE | error |
Vizum.device.store (or another member the contract dates 5.82) with min_platform below 5.82 | MIN_PLATFORM_TOO_LOW | error |
Vizum.ui.fullscreen with min_platform below 5.85 (detected through Vizum.supports("ui-fullscreen") or "ui.fullscreen", it is FEATURE_DETECTED) | MIN_PLATFORM_TOO_LOW | error |
Vizum.ui.fullscreen while permissions.ui does not list "fullscreen" (the bridge drops the call) | PERMISSION_MISSING | warning |
An i18n/<name>.json not named after an Odoo language code (es, es_419; not es-419) | I18N_LOCALE | error |
A route the platform would refuse: a file route that is neither public nor device, or a device file route with no explicit permissions.orm read rule | INVALID_ROUTE | error |
A member newer than min_platform | MIN_PLATFORM_TOO_LOW | error — or FEATURE_DETECTED (info) when the code detects it first: Vizum.supports(), typeof Vizum.x, "x" in Vizum or optional ?. access |
A manifest feature newer than min_platform: object-form requires, event_contracts, AI tool contracts (all 5.67) | MIN_PLATFORM_TOO_LOW | error |
The i18n/<lang>.json files next to the manifest, or in public/i18n/ for a Vite app (5.73): strings only, each msgstr's placeholders equal to its msgid's, no executable markup, no width or precision above 999 or * (I18N_WIDE), at most 512 KB each and 2 MB together — the same rule the instance and the Store apply | I18N_JSON / I18N_VALUE / I18N_PLACEHOLDERS / I18N_MARKUP / I18N_SIZE | error |
A language the manifest's i18n declares that has no i18n/<lang>.json (next to the manifest or in public/i18n/; with --dist <dir>, in the build output — the layout that ships). vzm pack refuses the same package even with --no-check, the instance's install and the Store's upload refuse it too: without the file the catalogue is a 404 and server tr() stays in English | I18N_CATALOGUE_MISSING | error |
Native strings the catalogue does not translate (5.84): per declared language, a field or model label, help, selection option, group, binding, menu or action name, or view term of the manifest with no msgstr (the English text or its @native: key); an @native: key that names nothing the manifest declares; a msgstr used as a view term that is not a well-formed XML fragment. --strict-i18n turns all three into errors. An @native: value with a % placeholder is I18N_PLACEHOLDERS (error) | NATIVE_I18N_UNTRANSLATED / NATIVE_I18N_DANGLING / NATIVE_I18N_BAD_XML | warning (error with --strict-i18n) |
min_platform above every platform the SDK knows | MIN_PLATFORM_UNKNOWN | error |
| A deprecated / removed / experimental member | DEPRECATED_API / REMOVED_API / EXPERIMENTAL_API | warning / error / warning |
A namespace only some hosts serve (phone is desktop-only) used without detection (Vizum.supports(), typeof Vizum.x, "x" in Vizum or optional ?. access, as for MIN_PLATFORM_TOO_LOW) | HOST_LIMITED | warning |
| A permission that does not exist | UNKNOWN_PERMISSION | error |
A grant that needs another (ai_image needs ai) | PERMISSION_REQUIRES | error |
A grant the platform builds itself (tables, actions…) written by hand | DERIVED_PERMISSION | warning |
| A namespace the code calls without the grant it needs | PERMISSION_MISSING | warning |
A bridge method the host's own gate refuses for this manifest — it answers PERMISSION_DENIED in the browser and the server never sees the request: Vizum.offline.create / .write without that model in offline.writes, Vizum.offline.sync without offline.datasets, Vizum.ui.notify without "notify" in permissions.ui, Vizum.ai.image without ai_image… Judged by the platform's gate itself (allowedCall of vapp_protocol.js) with the caps the platform derives from the manifest. Read through direct calls, SDK aliases and handles the code keeps (const off = Vizum.offline, (vz as unknown as {offline: Api}).offline, a helper that returns it). Vizum.supports() is no guard: it says the platform has the member, not that this app may call it | BRIDGE_GATE_REFUSED | error |
The same call in a file that reads its own approved grants first — the caps Vizum.connect() resolves with (caps.offline, caps?.ocr) or Vizum.permissions.raw() / .explain() —, as one bundle serving two manifests does (a Panel and its Field companion) | BRIDGE_GATE_GRANT_DETECTED | info |
A permissions.orm grant the platform refuses at install: a vizum.* platform model outside the allowlist (vizum.app is read + call: only), write/create/unlink/call: on ir.actions.* (except ir.actions.report), create/unlink or fields beyond active, nextcall, interval_number, interval_type, priority on ir.cron, anything but reading the identity models, any access to credential models, write/create/unlink on base.automation, ir.server.object.lines, ir.default, ir.filters, ir.model.data, website.page and website.controller.page (read-only), any call: on ir.ui.view, mail.template or ir.actions.report other than get_combined_arch, send_mail and report_action respectively, and any grant on vizum.licence* / vizum.store.* outside the store's own back-office app (com.vizumapps.hq) | ORM_GRANT_REFUSED | error |
Code on the native path (native.methods, a field whose compute names a method, native.constraints; hooks, jobs, triggers, native.script.lib and the declarations do not count) together with a create, write, unlink or copy grant (including group_ops) on mail.template, ir.ui.view or ir.actions.report, while min_platform is below the capability that makes the native runtime pass those writes through the platform's model guard (5.157). Interim rule: raise min_platform once that fix ships, or drop the grant. A short, dated allowlist of first-party apps the security sweep judged safe is exempt (pinned to a maximum version, with an expiry) | NATIVE_MODEL_WRITE_UNGUARDED | error |
| A privileged grant no scanned code uses | PERMISSION_UNUSED | info |
A name in server code (native.methods, native.script.lib, a hook) that no scope and no sandbox global defines: it installs, then raises NameError when the line runs | UNDEFINED_NAME | error (the Store refuses a new upload with the same finding) |
A requires entry the platform cannot read (bad range, unknown keys) | INVALID_REQUIRES | error |
An event_contracts entry outside the platform's rules (version 1–999, its schema keywords) | INVALID_EVENT_CONTRACT | error |
| An event the code emits that has no contract | EVENT_UNDOCUMENTED | info |
An AI tool contract outside the server's rules (risk, side_effects, confirm) | INVALID_AI_TOOL_CONTRACT | error |
An Odoo model, field or call: method the code names through the SDK that a line the manifest installs on does not have (sale.order.line.tax_id on 19/20 → tax_ids; name_get exists on 17 only), or that no line has; the atlas of 17–20 and what is never reported: odoo-schema. --no-odoo skips them | ODOO_MODEL_UNKNOWN / ODOO_MODEL_MISSING_ON_LINE / ODOO_FIELD_UNKNOWN / ODOO_FIELD_MISSING_ON_LINE / ODOO_METHOD_UNKNOWN / ODOO_METHOD_MISSING_ON_LINE | error that does not block (exit 0; --strict-odoo blocks). Warning inside a try, on a call chained to .catch, or, for *_MISSING_ON_LINE, inside a branch of a condition; a permissions.orm name no line has: warning. Behind a direct Odoo version guard (if (odooVersion() >= 20), an early return), only the lines the guard runs the call on count |
A method call the bridge never runs: a private _method, an @api.private ORM method (mapped…), a CRUD primitive as call:, a call the ORM policy closes | BRIDGE_CALL_REFUSED | error that does not block (--strict-odoo blocks) |
Code that does what permissions.orm does not grant: no entry for the model, an op not listed, a write outside the write ceiling; a read outside fields (dropped from the result) | GRANT_MISSING | error that does not block (--strict-odoo blocks) / warning |
| A file that names the SDK but is too deep, too curried or too much work to read: its Odoo names are not verified | ODOO_NOT_READ | warning |
A field placed after / before an anchor of a native form (native.views[].place, a native.ui form view) that is not in a <group> on a target line and edition: it shows with no label (Odoo 20's res.partner vat); data from the native-view atlas, odoo-schema. --no-odoo skips it | NATIVE_ANCHOR_UNLABELLED | warning |
A t-esc in a native.ui / native.records view arch or mail template body: Odoo 20 ignores it (the element renders empty). On Odoo 20 the platform rewrites it to t-out at install, which escapes the same way; 17–19 render it as written. Write t-out | QWEB_T_ESC | warning |
A t-raw there (an attribute, an inherited view's <attribute name="t-raw">, or a mail template body): it prints unescaped HTML, Odoo 20 no longer has it and the platform refuses it at install. Write t-out, with a Markup value where you mean HTML | QWEB_T_RAW | error |
An inherited view's <attribute> patch on a t-* directive with remove, or with add / separator anywhere but a t-if / t-elif joined with " and " / " or ": they splice raw text into the expression, and the platform refuses them at install. Set the whole value instead | QWEB_PATCH_REFUSED | error |
The scan reads src/, js/, hooks/, index.html and app.js next to the manifest (or the --src paths you pass; or, with --entry <file> (repeatable), only the files those entries reach through relative imports, plus hooks/ — for ONE bundle served under several manifests: vzm check manifest.phone.json --gate publish --entry src/phone/PhoneApp.tsx judges the phone app over the code it runs, not over the desktop screens it never imports. Name every root the variant renders, a lazily loaded screen included) and follows the SDK where it can see it: Vizum.x, the V export of @vizum/vapp-types, an alias such as export const sdk = V (at the top of a file it holds in every file; bound inside a block, such as a function's const t = window.Vizum, only in that block — so a minified build's other t.x are not read as the SDK), V. in server hooks, and a parameter typed as the SDK — @param {typeof Vizum} V or (V: typeof Vizum), the pattern that lets the same module run on the emulator. A bare parameter named V is not followed (it is often the app's own). A namespace member the bridge does not serve (Vizum.ai.tools.handle runs in the page) needs no grant. It ignores comments, string contents and test files. --json prints the findings for CI or an agent; the exit code is 1 when there is an error — except the Odoo schema errors (ODOO_*, BRIDGE_CALL_REFUSED, GRANT_MISSING), which fail when the code runs on a line or makes the call, never at install: they are printed as error (does not block; --strict-odoo makes it block) and the exit code stays 0 unless --strict-odoo. --json says blocking_errors and strict_odoo. The Vapp Creator treats them the same way (shown as errors, Package and Install not stopped); the publish gate, vzm check --gate publish, blocks on them (below). --strict-odoo stays and means "the Odoo errors block"; for publication, --gate publish supersedes it.
Detect a member newer than your min_platform like this, and check accepts it:
const can = (n) => typeof Vizum.supports === "function" && Vizum.supports(n);
if (can("files.publicUrl")) { url = await Vizum.files.publicUrl(path); }
The same checks without a filesystem — for a tool that holds the manifest and sources in memory, such as Vapp Creator in the browser — are in @vizum/vapp-cli/lib/check-core.mjs:
import { analyseInMemory } from "@vizum/vapp-cli/lib/check-core.mjs";
const { problems } = analyseInMemory(manifest, [{ file: "index.html", text }], { contract, schema });
vzm check --gate publish
npx vzm check [manifest.json] --gate publish [--acks vzm-compat.json] [--src dir]… [--json]
The publish gate: one verdict on whether the app may be submitted to the Store, from the same function the Vapp Creator's «Submit to Store» runs (lib/compat-gate.mjs). Every finding is blocking (a verified defect: the Odoo schema errors above, BRIDGE_CALL_REFUSED, GRANT_MISSING, and every error of plain vzm check), pending (what the checks cannot verify: an ORM call whose model the reader cannot name — ODOO_UNVERIFIED_CALL, listed at its place —, a file not read, a model without atlas data, an error inside a try or a condition) or info. Exit 0 ok, 1 blocked, 3 pending items not acknowledged. Each pending item prints a fingerprint; acknowledge it, with a note, in vzm-compat.json at the project root (--acks reads another file; vzm pack never packages it). An atlas false positive is waived where it is reported with // vzm-compat-ignore CODE: reason — never BRIDGE_CALL_REFUSED or GRANT_MISSING. --json prints the verdict; its paths are relative to the project root. Every code, the acknowledgement file and the waiver: compat-gate.
contract and schema are the two JSON files the package carries (lib/platform-contract.json, lib/manifest.schema.json). vzm check calls this function too, so the two cannot disagree. With odoo: loadOdooContext() (from lib/contract.mjs; in a browser: the expanded lib/odoo-atlas.json and an @lezer/javascript parser) it runs the Odoo schema checks as well.
vzm grants
npx vzm grants [manifest.json] [--src dir]… [--json] [--write] [--allow-widen]
The smallest permissions.orm the code needs: the ops of its calls, call:<method> for each method the bridge accepts, and a fields / write_fields ceiling when every read and write names its fields (never "*"). It prints the diff against the declared grant, model by model, and leaves the manifest untouched. Exit 0 the grant is the assembled one, 1 the code needs something the grant lacks, 3 only narrowing is on offer. Each entry is marked widening (an administrator approves the update again) or not, and each narrowing is proven or not: it is not proven while a call the reader cannot follow, server code or a mention of the model outside a followed call might still need it. What the platform refuses, a deny model for example, is listed as refused and never proposed. A call whose model or method the reader cannot name is listed as unverified, never guessed.
--write applies the proven narrowing and every change that does not widen, and touches only permissions.orm in the file. Widening needs --allow-widen, and a narrowing that is not proven is never written. Exit 1 when a widening was left out. Every rule: grants.
vzm i18n import-po
vzm i18n import-po <module>/i18n/<lang>.po [--manifest manifest.json] [--out dir] [--lang code] [--overwrite] [--dry-run] [--json]
Turns the .po of a native module being ported into the app's i18n/<lang>.json (the catalogue that 5.84 also uses for the backend: fields, selections, menus, actions, groups, views). The language comes from the file name (es.po → es), --lang overrides it; the catalogue is written next to the manifest (--out changes the directory) and keeps what it already holds. It is checked with the catalogue's own rules before anything is written.
In the .po | In the catalogue |
|---|---|
field label, help, selection, model, menu, action, group, view term, code: | the English msgid → msgstr |
the same msgid with two translations, contexts nameable from the manifest | @native:<model>.<field>, …#help, …=<value>, @native:<model> |
the same msgid with two translations, contexts not nameable | one plain msgid (the majority) and an AMBIGUOUS line in the report |
#, fuzzy, empty msgstr, executable markup, differing placeholders, equal to the source | skipped, counted |
Existing entries are never overwritten unless --overwrite. --dry-run prints the report only; --json prints it as JSON (added, kept, overwritten, ambiguous, skipped, plurals, kinds). Without a manifest.json no @native: key can be produced (the plain msgids are still imported). Exit code 1 means nothing was written.
vzm doctor
Node version, the CLI and the contract it carries (capability, SDK, api version), the manifest (check's verdict and the install tier: zero, or privileged with the grants an administrator must approve), the @vizum/* packages and whether they resolve outside the sdk-next workspace, node_modules and dist. --json for machines; exit 1 on a failure.
vzm context
Prints, for the app in the current directory, the minimum exact context a coding agent needs: the platform it targets, the rules it must follow, the grants the app holds and what they mean, every member it can call (with the capability each needs, flagged when newer than min_platform), what needs a grant it does not hold, the error catalogue, and vzm check's current findings. Without a manifest it prints the whole platform. --json gives the same as data. Point an agent at it instead of at the documentation:
npx vzm context > AGENT_CONTEXT.md
The whole context of a large app is long — Contador 365's is 14 KB, most of it the field lists of its orm grant. Focus it on the part being worked on:
| flag | effect |
|---|---|
--app <dir | manifest.json> | the app to describe (a directory means its manifest.json) |
--feature <namespace> (repeatable, or comma-separated) | only those Vizum.* namespaces in "can call" / "needs a grant"; an unknown name is refused with the list |
--model <model> (repeatable) | a section per model: how the app reaches it (its own model, an orm grant, or none — then what to add), the grant's ops, field ceiling and domain, the columns the app adds there, and its fields |
--fields <fields.json> | Odoo's fields for --model, offline — the Vizum.meta.fields / fields_get output keyed by model, the same file vzm introspect --from reads |
A model can be named by its manifest name (job) or its technical one (x_vzm_<slug>_job). The app's own models list their fields from the manifest, with the x_ names the platform creates. For an Odoo model without --fields the section says how to get them: field names drift between Odoo 17, 18, 19 and 20. Without a focus, the orm grant is summarised one model per line; its field lists and domains are in --model.
vzm keygen, vzm sign, vzm verify, vzm pack --sign
npx vzm keygen --publisher <name> [--out vzm-signing.key]
npx vzm pack <dist-dir> --sign vzm-signing.key
npx vzm sign <x.vapp> --key vzm-signing.key [--out signed.vapp]
npx vzm verify <x.vapp> [--store-key <base64>] [--json]
keygen writes an Ed25519 key (mode 600) and never overwrites one. pack --sign and sign add signature.json; pack never copies a stale signature.json from the build directory. verify prints the state an instance would record — store_verified needs --store-key — and exits 1 when the package is tampered: changed after it was signed, or a signature that is malformed, does not verify, or names another app, version or key. A package you upload to the store must be signed (the store refuses a new version without its developer's signature from the date Vizum sets; the refusal names these commands). Format, states and the store's rules: package-signing.md.
vzm diff
npx vzm diff <old manifest.json | old.vapp> [new manifest.json] [--json]
What a new version changes, offline — the same question api_update_preflight answers on an instance: the version jump, permissions added / removed / changed (and the privileged ones that make every administrator re-approve), native models and fields, Vizum.db tables and fields, provides, event contracts and requires. A breaking change — removed native or Vizum.db schema (data), a native field whose type or relation changed, a removed selection value, a removed provides, a removed event contract, an event schema changed without a new version — in a release that is not a new major exits 1 (BREAKING_WITHOUT_MAJOR; from 1.0.0 on — a 0.x release is exempt). So do an event schema changed without raising its contract version, even in a major (EVENT_SCHEMA_CHANGED), an unchanged version (VERSION_NOT_RAISED) and two different app ids (DIFFERENT_APP). A downgrade is a warning (DOWNGRADE); a changed min_platform is info (MIN_PLATFORM_CHANGED: instances below the new value cannot install the update); a new privileged grant is NEEDS_APPROVAL (info).
vzm schema diff
npx vzm schema diff <old manifest.json | old.vapp> [new manifest.json] [--json]
What a new version does to the app's data, change by change, each classified:
| level | means | examples |
|---|---|---|
SAFE | nothing stored changes meaning, nobody loses an ability | a model, field, table or column added; a selection value added; a label, help, default or index changed; a declared rename_field |
WARNING | data is kept, behaviour changes | a required field with no default (existing rows stay empty); a field becoming required; a column the app added to a shared model removed (it is archived, not dropped); a field removed after native.migrations copy; new unique indexes, constraints, Vizum.db rules; changed groups, writers, rules, access, translation, formulas |
BREAKING | the installed schema no longer matches, or the update fails | a field's type changed (the platform keeps the old type, silently); a many2one's relation changed (rewritten in place: stored ids then point at the other model's records); a selection value removed (the update refuses while a row holds it); a Vizum.db column's type changed |
DESTRUCTIVE | stored data is deleted on every instance | the app's own model or one of its fields removed (its column is dropped); a field no longer stored; a Vizum.db table or column removed |
The levels are what the platform does on update, measured on Odoo 19 (vizum_app_schema.reconcile), not a guess. native.migrations of the version jump are taken into account: a rename_field keeps the data (SAFE), a copy carries it into the new field before the old one goes (WARNING); a migration declared for another version does not protect this one.
A changed type or comodel of an existing field exits 1 whatever the version (SCHEMA_CHANGE_REFUSED): every instance refuses that update, and the Store refuses to publish it. The way to do it is a new field with the new type or comodel, filled in the same update's native.migrations ({"op": "copy", "model", "from", "to"}) or a migration hook, and the old field no longer declared. Any other BREAKING or DESTRUCTIVE change in a release that is not a new major exits 1 (SCHEMA_BREAKING_WITHOUT_MAJOR, from 1.0.0 on); a DESTRUCTIVE change always warns (SCHEMA_DESTRUCTIVE). vzm diff counts the BREAKING kinds above as breaking too, and no longer calls a declared rename a removal.
vzm new is agent-ready
Every scaffold gets, generated from the CLI's own contract: AGENTS.md (the commands and the platform's rules for a coding agent), CLAUDE.md (pointing to it), AGENT_CONTEXT.md (vzm context for the new app — refresh it with vzm context --write), a first emulator test in tests/app.test.mjs, and check / test / context scripts in package.json. A template without a build step (vanilla-multi) gets a package.json holding only @vizum/vapp-cli, so npm install && npx vzm test runs its test. Templates: react-ts, vanilla-ts, vanilla-multi, and offline-field-app (5.80: a route list and detail that read from the device, a note, a photo and a signature queued while offline, a Sync issues screen, and emulator tests of the offline paths; also a no-build template).
SDK & server
Odoo introspection, view inspection and preview source maps — for the IDE apps PRIVILEGED
Three platform features for the Vizum IDE apps: the Vapp Creator (com.vizumapps.vappcreator) and the Module Converter (com.vizumapps.moduleconverter). A third-party app cannot use them: the platform, vzm check and the store refuse the grants for any other key.
| Feature | Vizum.supports() |
|---|---|
| Live introspection of the instance's Odoo | odoo-introspect |
| What a package does to Odoo's views, simulated | view-inspect |
| A preview build's source map | preview-sourcemap |
The platform capability that introduced each is in Platform capabilities. Vizum.ui.openView, which opens a real Odoo view and is open to any app, is described with the view inspection below.
Live introspection
vizum.app.api_odoo_introspect(op, models, parts, opts) answers what this Odoo has: its models, their fields as the current user sees them, their methods and where each one is defined, the bridge's verdict on each method, and, on demand, a method's source. The Odoo schema checks (odoo-schema) use it as the live source for the instance's line.
Who may call it
"permissions": {"orm": [{"model": "vizum.app",
"ops": ["call:api_odoo_introspect", "call:api_update_preflight"]}]}
const r = await Vizum.orm.call("vizum.app", "api_odoo_introspect", ["signature"]);
const head = JSON.parse(r.json); // r = {schema: 1, op: "signature", json: "…"}
Every op answers {schema: 1, op, json}, its answer as one JSON text. A call: result is bounded to 10,000 JSON nodes by the bridge, and the description of one large model (res.partner with its fields and methods) has more; one string is one node, under the bridge's 4 MB byte cap.
The platform checks four things, in this order, and refuses with an AccessError when one fails:
- The user is an internal user.
- The user is an administrator (
base.group_system). - The call comes through the bridge from one of the two IDE apps. The platform sets a marker on the call that JSON-RPC cannot forge, so a direct
/web/dataset/call_kwis refused, even from an administrator's session. - The installed package of that app is verified as published by Vizum: the Vizum Store signed it, and so did a pinned Vizum publisher key. A sideloaded package with the same id holds nothing.
call:api_update_preflight (what installing a package would change, nothing is written) is granted with it, to the same two apps.
The arguments are positional: Vizum.orm.call passes no keywords.
Ops
op | models | parts / opts | Answer |
|---|---|---|---|
"signature" | — | — | {schema: 1, signature, registry_sequence, modules_hash, odoo, edition}. Cheap: call it when the IDE opens and re-read the rest when signature changes |
"index" | — | — | every model: {model, label, transient, abstract, modules}, plus signature, edition, odoo |
"models" | 1 to 50 names | parts: any of "model", "fields", "methods", "access" (default all four). opts.private: true adds _methods; opts.base: true adds the ORM's own methods | odoo-schema.json v1, below |
"source" | exactly 1 name | opts.methods: 1 to 20 method names | every definition of each method, below |
A wrong argument is a ValidationError that names the limit: an unknown op, models that is not a list or has more than 50 names, an unknown part or option, a name that is not a string.
op: "models"
{ schema: 1, source: "live", odoo: "19.0", edition: {name: "ee" | "ce", licensed: bool},
signature, generated, truncated: [model…], denied: {model: "broker_deny" | "unknown"},
models: { "<model>": {
label, transient, abstract, table, modules: [...], inherit: [...], inherits: {parent: field},
rec_name, order, company_check,
fields: { "<name>": {
type, string, help?, selection?, relation?, relation_field?, required, readonly, store, index,
translate, related?, compute?, inverse?, search?, depends: [...], groups: [xmlid…],
company_dependent, tracking, modules: [...], inherited, inherited_from?, has_default }},
methods: { "<name>": {
signature: "(vals_list)", decorators: ["api.model_create_multi", …],
defined_in: [{module, origin: "ce" | "ee" | "custom" | "third_party", license}],
doc: "<first docstring line>",
bridge: {callable, code, odoo_rpc_private} }},
truncated_methods?: true,
access: {read, write, create, unlink} }}}
- fields are the ones
fields_getgives the current user: a field the user's groups hide is not there.string,helpandselectionare what Odoo's UI shows this user (a dynamic selection is evaluated as the UI evaluates it). has_defaultsays only that the code declares a default. The value is never computed, andir.defaultis never read.- methods: the public methods of the model (and with
opts.privatethe_ones), without the methods that only the ORM itself (BaseModel) or extensions ofbasedefine, unlessopts.base. At most 1,500 per model (truncated_methods).decoratorsare read from the attributes Odoo's decorators set, on every line.defined_inlists the classes of the model that define the method, in MRO order (the override that runs first comes first). - origin of a module:
eewhen its licence isOEEL-1,cewhen it is one of Odoo's own addons,third_partyforOPL-1or the OCA, otherwisecustom. - access is the model ACL of the current user (never as superuser). Record rules depend on the records and are not summarised.
The bridge's verdict
bridge.code says whether an app can hold call:<method> on that model and have the bridge run it, and which rule refuses it. The rules are the bridge's own, in the order it applies them:
code | Meaning |
|---|---|
ok | Callable (callable: true) |
underscore | A name starting with _: a call: target is a public method name |
invalid_name | Any other name the validator refuses (^[a-z][a-z0-9_]{1,63}$) |
forbidden_target | A CRUD or raw-ORM primitive (write, search_read, web_save…) or a desktop-only call: use the op instead |
model_denied | A credential model the broker never reaches (ir.config_parameter, vizum.app.secret…) |
model_read_only | A model a vApp may only read: identity and security models, code-bearing rows (ir.default, base.automation…), ir.actions.*, a model that delegates to a guarded one |
guarded_model | A model that takes only listed calls (ir.ui.view: get_combined_arch; ir.cron: method_direct_trigger) |
platform_model | A platform model, or a vizum.app method outside its allowlist |
odoo_private | Odoo marks it @api.private (or it is a class method, or an unsafe attribute): Odoo refuses it over RPC, and so does the bridge (VZ-SEC-35) |
odoo_rpc_private is the @api.private fact itself, given for every method.
What is never returned
- a record, a field value, a default value, an
ir.defaultor a configuration parameter; - a model of
BROKER_DENY_MODELS: it answers indeniedas"broker_deny", by name only (the index shows its name only); - a platform model
vizum.*: its name andaccess, nothing else (no field, no method, no source); - a server path: source files are named relative to their addon (
sale/models/sale_order.py); - the platform's own code: a method the platform defines lists
{module, denied: "platform"}indefined_in, and one only the platform defines has no signature, decorators or doc.
op: "source"
{ schema: 1, model, truncated: bool,
methods: { "<name>": [ {module, origin, license, file, line, source, truncated?} … ] } }
One entry per definition, in MRO order. Enterprise code is included and marked (origin: "ee", license: "OEEL-1"); the IDE shows the badge. A definition from one of the platform's own addons (vizum_apps_platform, vizum_window_manager and the other Vizum addons) answers {module, denied: "platform"}, with no code. A source file outside its addon's directory (a symlink, a .. path) is named ‹outside module›, never by its server path. A method that has no source on disk (a .pyc-only install) answers source: null, reason: "unavailable"; a name that is not a method of the model answers {reason: "unknown"}. Any method name may be asked for, _ ones included (opts.private only changes the models listing).
A source call stops after the same time budget as models (a quarter of limit_time_real, at most 30 s): the methods not reached answer {reason: "time"} and truncated is true.
Limits: 16 KB per definition (past it that definition is cut and says truncated: true). 64 KB per method and 512 KB per call are counted BETWEEN definitions: the first definition that does not fit, and every one after it, comes with source: null, truncated: true, reason: "budget"; no definition is cut in the middle by them.
Redaction of credentials (best effort)
Every source, signature and docstring line passes through a redaction before it leaves the server. It replaces with ‹redacted›:
- the whole string literal (a run of strings joined by implicit concatenation or
+, triple-quoted strings and f-strings included) when any part of it looks like a credential, or when it is the value of a credential-named target: a name or key ending inpassword,passwd,pwd,pw,secret,token,key,apikey,bearerorcredentials(api_key = …,"token": …,password=…,('password', …)), or the value ofset_param(<name>, …); - token shapes whatever they are called:
sk-…,sk_live_…/sk_test_…,rk_live_…/rk_test_…, SendGridSG.….…, Slackxox[abposr]-…andhooks.slack.com/…,Bearer <token>, AWSAKIA…, GoogleAIza…, GitHubghp_…, JWTs, PEM private keys, auser:password@in a URL — in strings, comments and docstrings.
Unquoted name=value (# token=…) is redacted in comments, in the text of literals and in the docstring line. Names also match in camelCase (clientSecret) and as passphrase, auth, authorization; Basic <base64> is a token shape. The assignment shapes are name = …, self.name = …, name: T = …, (name := …), cfg["name"] = … and name = ("…" "…"). When a definition cannot be tokenized (a cut or broken one), the redaction fails closed: the content of every quoted literal and every comment is replaced.
It is best effort. It cannot know every secret, and it may redact a harmless literal whose name looks like one (group_key = "partner_id"). Code, SQL and numbers are otherwise left as they are. Known limits, not redacted: a secret encoded or computed in the code (bytes.fromhex(…), b64decode(…), \x escapes, "".join([…]), % formatting), tuple unpacking (user, pw = "u", "…"), a positional tuple (auth=("admin", "…")), a secret written as prose (# the password is …) and an opaque string with no shape and no credential name.
Signatures are taken from the source, not from the running objects: a default that is not None, a boolean, a number or a short string literal shows as … (api_key=os.environ.get("API_KEY") is never evaluated or shown).
Limits and caching
- A
modelsanswer is at most 2 MB; the model that would cross the line and the ones after it are named intruncated, and the IDE asks for them again. A call stops after a quarter oflimit_time_real(at most 30 s) the same way. - The code facts of a model (everything except
string,help,selectionandaccess, which depend on the user) are cached per registry: a module install or update changesregistry_sequence, and sosignature. signature= a hash of the registry sequence and of the installed modules and their versions: identical for every worker of the instance, new after any install, update or uninstall.
edition.licensed
true only on Enterprise (web_enterprise installed) with a subscription code, an expiration date that is absent or in the future, and an expiration reason that is not trial. Only the boolean leaves the server. The IDE decides with it whether Enterprise view archs may be sent to an AI model.
Audit
Every call leaves one vizum.app.audit row on the calling app: ide:introspect with {op, n_models, parts, bytes, ms, truncated}, or ide:source with {model, methods, bytes, ms}. Names, never content.
View inspection
vizum.app.api_view_inspect(source, views, opts) answers what a package does to the native views it touches: its native.views entries and the views its native.ui records inherit. Same callers and gate as api_odoo_introspect (an administrator, through the bridge, a store-verified Vapp Creator or Module Converter): grant call:api_view_inspect on vizum.app.
source:{"key": "<app key>"}for the app as installed here (modeinstalled), or{"zip_b64": "<package>"}for a package that is not installed yet, or a new version of one that is (modepreinstall). A package the platform's validator refuses answers oneVIEW_PLATFORM_INVALIDfinding and nothing else.views:nullfor every view the package touches (at most 10; the rest are named intruncated), or 1 to 10 of{"model": "res.partner", "type": "form"}(the model's default view of that type) or{"xmlid": "sale.view_order_form"}. More than 10 is refused.opts:{"arch": "outline" | "full", "siblings": true | false}.outline(default) keeps the tags and theirname,string,position,expr,t-nameandgroups;fullis the XML.
The answer is {schema: 1, json}; parse json (type ViewInspectV1 in @vizum/vapp-types):
{ schema: 1, mode, odoo, edition: {name, licensed}, signature, truncated: ["res.partner/kanban"],
findings: [...], # no base view: a missing parent view, a refused package
views: [{ model, type, base: {id, xmlid, module, priority, mode},
arch_without, arch_with, # outline or full; null above 400 KB
changes: [{path, op: insert|replace|attributes|move|remove, origin: "native.views[0]"}],
placements: [{field, origin, status: placed|skipped|invalid|pending, path?, reason?}],
inheriting: [{id, xmlid, module, priority, mode, touches: [path], conflict?}],
siblings: [{xmlid, mode: "primary", ok, error?}],
findings: [{code, severity, origin, manifest_path, message}] }] }
How it simulates, and why the answer equals an install
The app's specs are built by the installer's own functions: native.views through the same dry run the preflight and the strict install gate use (the placements an install would skip, with the same reason), native.ui records through the installer's value resolution (list/tree named as this Odoo names it, t-esc written t-out on Odoo 20, groups_id / group_ids). They enter Odoo's own view combination as in-memory view records, with the installer's priority (99 for native.views, the record's own for native.ui), after the existing views of the same priority. Nothing is created: no view, field or model. arch_without leaves out the views the app itself put in the database; arch_with is what the install produces, which the platform's tests compare with a real install on every Odoo line.
A native.ui record the installer reuses is simulated the way the installer treats it: its own xmlid when the app is installed, then match, then adopt. Without "update": true the reused view keeps its stored arch (nothing is applied twice); with it, the new values take the view's place among its siblings. A view that inherits another of the package's own views (inherit_id: {"$ref": …}) is simulated on that view's simulated copy. Each step is also checked with Odoo's own view validator (what _check_xml runs after it combines: unknown actions, private methods on buttons, names an expression uses that the view does not have, and whatever else this Odoo refuses); a step it refuses is reported as VIEW_ODOO_INVALID and left out. The app's own columns that do not exist yet are left out of that check.
Mode installed also runs Odoo's own validation of the app's real views inside a savepoint that is always rolled back. In mode preinstall the app's own columns do not exist yet: their placements are pending (with the path where they will land) and Odoo's validation is not run.
Findings
| Code | Severity | Meaning |
|---|---|---|
VIEW_ANCHOR_MISSING | warning (error when the entry is strict) | a native.views anchor is absent: the placement is skipped |
VIEW_ANCHOR_IN_SUBVIEW | warning (error when strict) | the anchor is only inside an x2many sub-view (another model's fields) |
VIEW_UI_ANCHOR_MISSING | error | a native.ui xpath, or the view it extends, cannot be found: the install aborts |
VIEW_ANCHOR_AMBIGUOUS | warning | more than one node matches; Odoo takes the first in document order |
VIEW_POSITION_INVALID | error | a position Odoo does not know, an attributes body that is not <attribute>, a bad xpath |
VIEW_FIELD_UNKNOWN | error | an inserted field is not a field of the model of its context on this Odoo |
VIEW_ODOO_INVALID | error | Odoo refuses the combined view (a sibling, an installed view's validation) |
VIEW_PLATFORM_INVALID | error | the platform's validator refuses the package or an arch |
VIEW_ATTRS_REMOVED | error | attrs= / states= (gone since Odoo 17) |
VIEW_LIST_NAME | info | the arch writes the list view <tree>/<list> the other way; the platform renames it |
VIEW_PRIORITY | warning | another inheriting view targets the same node after the app, addresses nodes by position after it, or replaces an anchor |
VIEW_KANBAN_TEMPLATE | warning | a field outside the kanban card template (17 kanban-box, 18+ card): loaded, not shown |
VIEW_BREAKS_NATIVE | warning | the app replaces, removes, moves or hides a native node |
VIEW_NOT_MAIN_FORM | warning | a native.views form entry lands on the model's default form (default_view: the primary form of lowest priority), which is not the form its main window action opens; the message names both (res.users: base.view_users_simple_form, the new-user dialog, instead of base.view_users_form) |
api_update_preflight answers the blocking half too, as views_broken: [{model, xmlid, origin, reason}] (administrators only, like native_views_skipped): every view of the package that Odoo would refuse at install (an xpath it cannot locate, a parent view that does not exist, what its view validator rejects). It works within the same time budget (what it could not reach is one entry with reason time_budget), and a plan it cannot make is one plan_unavailable entry, never an error: the IDE refuses to install a package that Odoo would refuse.
What is never returned, limits, audit
- No record row, no
ir.default, no configuration parameter, noarch_fsor file path: views are metadata. - At most 10 views per call, each arch at most 400 KB, the answer at most 2 MB, a quarter of
limit_time_real(at most 30 s); what does not fit is named intruncated. - One
vizum.app.auditrow per call:ide:view_inspectwith{mode, views, bytes, ms}(names only).
Vizum.ui.openView
Vizum.ui.openView({model, view_type, view_xmlid?, res_id?, label?}) opens a real Odoo view: a form (with res_id, one record), list, kanban, calendar, pivot, graph or activity view. Any app may use it: declare "openView" in permissions.ui, and feature-detect with Vizum.supports("ui-open-view") to keep a low min_platform.
await Vizum.ui.openView({ model: "crm.lead", view_type: "kanban", label: "Pipeline" });
await Vizum.ui.openView({ model: "sale.order", view_type: "form", res_id: 42 });
await Vizum.ui.openView({ model: "res.partner", view_type: "form", view_xmlid: "base.view_partner_simple_form", res_id: 7 });
- The host checks the spec first:
modelan Odoo model name,view_typeone of the types above (treeis read aslist),res_ida positive integer withformonly,view_xmlid<module>.<name>. Anything else rejects withVALIDATION_FAILED. view_xmlidopens that view only when it is a primary, active view ofmodelandview_typewhose groups the user has; otherwiseNOT_FOUND. The host resolves it through a host-only server method that no app can call. Withoutview_xmlid, Odoo's default view of the type opens.- Odoo's access rules govern the data the view shows; the host never reads a record.
- The lookup (
vizum.app.api_view_ref(model, view_type, xmlid)) is callable by any internal user over RPC, because the host calls it for the user: it answers a view id (view metadata) orfalse, and never for a view the user's groups do not allow. Apps cannot call it through the bridge. - Desktop and native hosts. Vizum Solo opens a form on one record and a list in its overlays (
view_xmlidis not honoured there) and refuses the other types withUNSUPPORTED_CALL; Solo does not announceui-open-view. - Adding
"openView"topermissions.uiin an update widens the app'suigrant: the update waits for an administrator's approval on every instance.
Preview source maps
vizum.app.api_build(files, manifest, name, deps, install, changelog, typecheck, preview_sourcemap) takes an 8th positional argument. With install false and preview_sourcemap exactly true, the build service builds with vite build --sourcemap hidden, takes the map of the inlined entry script out of dist/ before packing, and the answer carries:
preview_sourcemap: the Source Map v3 JSON text. Itsfile,sourceRoot,sourcesandsourcesContentare scrubbed like the build log (the build node's paths, the token);mappingsandnamesare left as they are.preview_sourcemap_script: which inline<script type="module">ofindex.htmlit covers (0).
The .vapp is byte-identical to the one a build without the flag produces: the map is never inside the package, and hidden writes no sourceMappingURL. With install true the platform never asks for a map and drops one the service sends. A map over 8 MB, or one that is not a v3 map, is dropped with a warning in warnings. Anything but a real true asks for nothing.
An older platform raises on an 8th argument: pass it only when Vizum.supports("preview-sourcemap") is true, and cache the preview build apart from the build you install or download.
SDK & server
The platform emulator — vzm test and vzm dev
*Since capability 5.67.* vizum-sdk-mock.js answered every call with success. The emulator answers the way the platform does, so a test that passes on it says something about the platform.
Mock (vizum-sdk-mock.js) | Emulator (@vizum/vapp-cli/emulator) | |
|---|---|---|
| SDK | a hand-written stand-in | the real vizum-sdk.js, on the real bridge protocol |
| Permissions | none | the platform's own bridge gate, with the caps the platform derives from your manifest |
| ORM | succeeds | applies the server's per-model, per-operation rule (permissions.orm, the "*" read grant, the app's own native models) |
| Errors | none | the platform's error results: VizumError with code, category, retryable, correlationId |
| Failures | none | inject any error code, go offline, add latency |
| Unknown call | succeeds | fails "not emulated" until your test stubs it |
What it emulates: data, db (the manifest's tables; each value converted to its column's type as the server does — "42" into an integer column is 42, "3.5" or "abc" is VALIDATION_FAILED, an unknown column too — Odoo domains, order, limit), orm (fixture records, Odoo domains), user, ui (confirm needs an answer you give it), events (enforcing your event_contracts), apps (composition with the platform's version-range grammar), permissions and, from 5.80, Vizum.offline (below).
vzm test
npx vzm test # every tests/**/*.test.mjs, with node's test runner
import test from "node:test";
import assert from "node:assert";
import { bootApp } from "@vizum/vapp-cli/emulator";
test("orders survive a dropped connection", async () => {
const { Vizum, emulator, close } = await bootApp({
fixtures: { orm: { "res.partner": [{ id: 7, name: "ACME", is_company: true }] } },
});
try {
emulator.fail("orm.exec", "NETWORK_ERROR", { times: 1 });
await assert.rejects(Vizum.orm.create("res.partner", { name: "x" }),
(e) => e.code === "NETWORK_ERROR" && e.retryable);
const id = await Vizum.orm.create("res.partner", { name: "x" }); // the retry works
assert.ok(id);
assert.strictEqual(emulator.callsTo("orm").length, 2);
} finally { close(); }
});
bootApp() reads the manifest vzm test points at; bootEmulator({ manifest }) takes one directly. The controls:
| Call | Does |
|---|---|
emulator.fail(pattern, code, { times, message }) | calls matching "orm.exec", "db.*" or "*" fail with that code |
emulator.offline(true) | every server call fails OFFLINE until offline(false); Vizum.offline.* keeps answering from the device store, and going back online replays the queue (await emulator.settled()) |
emulator.serverChange(model, id, vals) | somebody else changed a record on the "server" (5.80): a queued write to it comes back as a conflict |
emulator.serverDelete(model, id) | somebody deleted a record on the "server" (5.86): a queued write to it comes back as a deleted issue (canRecreate), and resolve(id, "keep_mine") recreates it |
emulator.offlineState() | the device's queue, issues and rows (5.80) |
emulator.latency(ms) | every call waits |
emulator.appState("disabled" | "needs_approval" | "ready") | the app's state on the instance: out of ready, every call that reaches the server fails ACCESS_DENIED («App … is not available», what _get_ready answers) — an administrator disabled it, or an update sent it back to approval while its window was open; host-only calls (ui, devices, vision, reports, editable) keep answering |
emulator.health({ errors, tasks }) | the runtime errors and failed tasks Vizum.apps.health() reports (dependencies come from the apps fixture) |
emulator.answerConfirm(true, false…) | the answers to the next Vizum.ui.confirm() calls |
emulator.method(model, name, fn) | the body of a server method the app calls with Vizum.orm.call / callOn (5.105): fn({args, ids, orm, user}), checked against the grant's call: ops; one the grant lists in read_calls runs read-only — a create, write or unlink rejects with READ_CALL_WROTE and is rolled back (read calls) |
emulator.readCalls() | {model: [method]}: the declared read calls the desktop runs without the App action dialog (5.105) |
emulator.stub(ns, method, fn) | supply a namespace the emulator does not model |
emulator.calls, emulator.callsTo(ns, method) | every call, with ok and code |
emulator.data, .tables, .records, .notifications | the emulated state, to assert on |
Offline data (5.80) is emulated. An in-memory device store sits next to the in-memory records: datasets, sync, query, readGroup, create (temp id), write (with the base the device saw), conflicts, resolve, and the offline:sync / offline:conflict events behave as the host's engine does (see offline-data.md). fixtures.computed[model] = (vals) => ({field: value}) plays the server's computed fields for a create's expect. Not modelled: the 7 day limit, the max_mb quota, storage failures, the scheduler (call sync() yourself), record rules.
Guest links (5.104) are emulated on a link host: bootEmulator({ manifest, host: "link", fixtures: { link: {ref, scope, contact_hint} } }), emulator.linkHook(route, fn), emulator.linkState("revoked"), emulator.linkCode() — see link-routes.md.
SDK 5.70 is not emulated. Paired-device routes (auth: "device", Vizum.device, Vizum.pairedDevices) and the streamed upload (Vizum.files.putBlob, Vizum.orm.writeBlob) need a real instance — PIN pairing, the hashed device token and the byte store live on the platform. The emulator refuses them with UNSUPPORTED_CALL and says why; give a call a value with emulator.stub("files", "putBlob", fn) to test the rest of the app. vzm check still validates the manifest side (auth: "device" routes, file_limits, min_platform 5.70).
Fixtures: { data: {key: value}, db: {table: [rows]}, orm: {model: [records]}, collections: {name: [values]}, apps: [{key, version, ready, provides}], user: {name, groups, is_admin…}, appState: "ready" }.
Live data is emulated too. Vizum.orm.watch (and so orm.live, refresh.on and the @vizum/vapp-query live bindings) fires when a create, write or unlink changes the domain's record count or last write — only then, as the desktop host does. Vizum.data.collection has the server's semantics (rev_conflict on a stale update, 500 rows a page). A data.set or collection change fires the app's subscribe callbacks only for a data: global app — the server pings nothing for per-user data, and neither does the emulator. See live-data.md.
A run that finds no tests fails — zero tests is not a pass.
vzm dev
npx vzm dev # a no-build app: serves the app's own folder
npx vzm dev --dir dist # a built app
npx vzm dev --fixtures fixtures.json --port 5178
Opens the app in your browser inside a host page that runs the emulator: the app's iframe is sandboxed exactly like on the desktop (an opaque origin, no allow-same-origin), it loads the real SDK from the path every vApp already uses, and the side panel lists every bridge call with its result, switches the app offline, sets the app's state (disabled, waiting for approval) and makes the next calls fail with any error code. Vizum.ui.confirm() asks you. In the console, vzm is the emulator (vzm.calls, vzm.fail(...)).
Libraries the SDK lazy-loads from an instance (charts, office formats, 3D) are not served by vzm dev; those calls report that they come from a Vizum instance.
SDK & server
Reliability — webhooks, limits, metrics, retries
*Since capability 5.67.*
Inbound webhooks you can trust
Every delivery to one of your app's webhook URLs is recorded in a delivery ledger, and the row the app reads from hook_<name> carries its id. Webhooks need permissions.data: "global", and the hook_* queue holds raw third-party payloads, so only an administrator or the app's job user can read it — drain it in a manifest job, which runs as that user:
# native.script.hooks → "drain", run by a job ("jobs": [{"name": "drain", "script": "drain", "cron": "*/5 * * * *"}])
queue = V.data.collection('hook_orders')
for row in queue.list():
v = row['value'] # {ts, body, delivery, attempt}
if handle(v['body']): # your function, from native.script.lib
V.webhooks.ack(v['delivery']) # processed
else:
V.webhooks.fail(v['delivery'], 'could not apply') # queued again as a NEW row
queue.remove(row['id']) # always: ack/fail settle the ledger, not the row
The same ack / fail / deliveries exist in the browser (Vizum.webhooks.*) for a screen an administrator uses. They are as privileged as reading the hook_<name> queue: an administrator, or the app's job user — any other user of the app is refused (ACCESS_DENIED). Remove each row you settled: ack and fail change the ledger only, a fail queues the delivery again as a new row once the original row is gone (while it is still queued, no second copy is added), and acking an already processed delivery is refused (VALIDATION_FAILED).
| Status | Means |
|---|---|
received | queued for the app |
processed | the app acknowledged it |
failed | the app reported a failure; it is queued again |
dead_letter | it failed max_attempts times; kept 30 days from when it was received, for an administrator to inspect and retry |
rejected | refused at the door — bad or missing signature, stale timestamp, too large; never queued |
Replays. A delivery whose provider id was already seen — X-GitHub-Delivery, X-Shopify-Webhook-Id, Webhook-Id / Svix-Id (Standard Webhooks), Stripe's evt_… event id, Idempotency-Key, X-Vizum-Delivery — is answered 200 duplicate (the provider stops retrying) and is not queued again. When the provider sends a timestamp (Stripe-Signature t=, Webhook-Timestamp, Svix-Timestamp, X-Slack-Request-Timestamp, X-Vizum-Timestamp), a delivery outside the hook's tolerance is rejected. Ids are remembered 14 days. Rejections are recorded up to 100 per hook per hour. Stripe's signed in-body event id wins over any header.
On a hook with a secret_name, the signature covers the body only — the id and timestamp headers are not signed — so the same body received again within those 14 days is also a duplicate, whatever delivery id it carries.
Signatures (secret_name) are verified for GitHub / Meta (X-Hub-Signature-256) and Shopify (X-Shopify-Hmac-Sha256); a signed hook refuses any other sender with 401. For Stripe or a Standard Webhooks / Svix sender, the id and timestamp headers above give replay protection on an unsigned hook.
"webhooks": [{ "name": "orders", "secret_name": "shop_secret",
"tolerance_seconds": 300, "dedupe": true, "max_attempts": 5 }]
Vizum.webhooks.deliveries(hook, {status, limit}) lists the ledger — metadata only, never the payload. Vizum.webhooks.retry(id) (installer or administrator) sends a failed or dead-lettered delivery back to the app.
The bridge rate limit
Each user may make 600 bridge calls a minute to one app (an administrator sets vizum_apps_platform.bridge_rate_per_minute, or a per-app bridge_rate_per_minute). Past it a call rejects with RATE_LIMITED, which is retryable — Vizum.errors.retry waits and tries again. The window is kept per server worker, so it bounds a runaway loop or a flood rather than metering exact usage.
The instance default is enforced for apps whose min_platform is 5.67 or later. An app published before (its SDK did not retry RATE_LIMITED) is logged when it passes the limit, not refused. A limit an administrator sets on the app itself (bridge_rate_per_minute) is always enforced.
Per-app budgets
An administrator can give one app its own ceiling, so a runaway job or a loop cannot spend what the whole instance has. Every cap is 0 — no cap — until it is set:
| Budget | Counts | Window |
|---|---|---|
ai_token_cap | AI tokens, all users of the app together | rolling 30 days |
net_requests_per_day | Vizum.net.fetch calls | UTC day |
storage_mb_cap | megabytes of Vizum.files, all users together | — |
Past a budget the call rejects with QUOTA_EXCEEDED, which is not retryable: waiting a minute does not help, the window has to move or the cap be raised. Catch it and tell the user:
try {
await Vizum.ai.chat({ prompt });
} catch (e) {
if (Vizum.errors.is(e, "QUOTA_EXCEEDED")) showBanner("This app has used its AI budget for now.");
else throw e;
}
The AI cap is as hard as the gateway and per-user caps it sits next to: every call reserves an estimate under a per-app lock before it reaches the provider, and is reconciled to the real usage after, so two concurrent calls cannot both take the last tokens. A call that costs nothing (your own key on a managed plan, a free trial) does not count. Image generation, OCR and transcription are metered per unit, not per token, and stay outside it. The network budget counts every call the app makes once it passes the permission check — one refused afterwards (a host outside the allowlist, a bad method or body) or one that failed after leaving counts too.
Administrators read and set budgets with vizum.app.api_budgets(key, values) — {ai_token_cap, net_requests_per_day, storage_mb_cap, bridge_rate_per_minute}, whole numbers, 0 for no cap (for bridge_rate_per_minute, 0 means the instance default: 600, or the vizum_apps_platform.bridge_rate_per_minute system parameter) — and see the usage against each in api_metrics(key).budgets. In the desktop, the Vizum Apps window's app detail has the same for an administrator: Usage & budgets in its Manage rail shows what the app used against each cap and edits the caps (only the ones changed are sent).
Retrying the right way — Vizum.errors.retry
const key = crypto.randomUUID();
const [id] = await Vizum.errors.retry( // create returns [id]
() => Vizum.orm.create("sale.order", vals, { idempotencyKey: key }), // the SAME key each time
{ retries: 3, baseMs: 250, maxMs: 4000 }); // retries: at most 10
Only errors the platform marks retryable are repeated (network, timeout, rate limit, concurrent update, offline); anything else rejects at once. Waits grow exponentially with jitter. A write repeated without an idempotency key may run twice — pass the same key on every attempt and the server runs it once.
Backend tasks that retry and time out
A Vizum.backend task runs one of your manifest actions in the background. Since 5.67 the action declares how its runs behave when something goes wrong:
"actions": [{
"name": "invoice.post", "model": "account.move", "method": "action_post",
"retry": { "max": 3, "backoff_seconds": 60 },
"timeout_minutes": 30
}]
retry— a run that fails with a *retryable* error runs again, up tomaxmore times (1–5). It waitsbackoff_seconds(10–3600, default 60), then twice that, then four times… at most 6 hours — and never less than the next pass of the task queue, which runs every 2 minutes. Retryable is the platform's own answer, the sameretryablean app sees in aVizumError:RATE_LIMITED,CONCURRENT_UPDATE, a database serialization conflict, a dropped connection. A validation, access or business error — orQUOTA_EXCEEDED— fails at once: it would fail the same way again. Declareretryonly for an action that is safe to run twice (see idempotency inplatform-contract.md); only you know.timeout_minutes(1–1440) — the run's deadline. A batch action reportingVizum.backend.progress()getscancel_requested: trueonce it passes and stops cleanly at that batch; a run still going after it is failed (last_error: "timed out").- cancel — a task waiting for its retry is
queued:Vizum.backend.cancel(id)cancels it for good. A cancelled run is never retried.
Limits on the queue and on method jobs
The queue is bounded, so one app or one user cannot bury it:
- Pending tasks are capped.
waiting_approvalandqueuedtasks together may not pass 500 per app nor 100 per user of that app.Vizum.backend.enqueue()beyond either limit rejects withQUOTA_EXCEEDED(not retryable: wait for the queue to drain, or batch the work). Running and finished tasks do not count. - An unapproved task expires. A task still
waiting_approvalafter 7 days becomescancelledwithlast_error: "approval expired". It is cancelled, never deleted, so the audit trail keeps it; enqueue it again if it is still wanted. Aqueuedtask (already approved, or waiting for a retry) is not on this clock. - A method job has a 300 second limit. A scheduled job that calls a native method is arbitrary Python and cannot be interrupted from outside, so the limit is *measured*, not enforced: a run that goes past 300 s is recorded on the job (
slow), in the audit row (over_limit_seconds) and in the app's health as the conditional checkjobs_time_limit(degraded; see lifecycle). Split a long batch into runs under the limit. A script job keeps its own 120 s budget, which does stop it.
The task row says where it is:
const t = await Vizum.backend.status(id);
// { state: "queued", status: "retrying", attempts: 1, max_attempts: 4, next_run_at: "…", last_error: "…" }
state keeps its six values (waiting_approval, queued, running, done, failed, cancelled), so code that compares it keeps working. Since 5.69 status names the cases it folds together:
status | state | Means |
|---|---|---|
retrying | queued | a run failed with a retryable error and the next attempt waits its backoff |
expired | failed | the run passed its timeout_minutes deadline |
dead_letter | failed | the action declared retry and every attempt failed |
| (the same word) | any other | waiting_approval, queued, running, done, failed, cancelled |
On an older platform status is absent; read state.
Without retry and timeout_minutes an action runs exactly as before: once, with no deadline.
Metrics — api_metrics(key, hours)
For the app's installer or an administrator, over the last 1–720 hours:
{ "bridge": {"calls": 18342, "refused": 12, "limit_per_minute": 600},
"runtime_errors": 3, "job_failures": 1,
"tasks": {"done": 220, "failed": 2, "cancelled": 0},
"webhook_deliveries": {"processed": 410, "dead_letter": 1, "rejected": 7},
"ai_tokens": {"input": 120400, "output": 30210},
"budgets": {},
"app": "com.acme.orders", "hours": 24, "since": "2026-10-02 08:00:00" }
webhook_deliveries counts the deliveries per status, listing only the statuses that occur; budgets is the usage against each budget an administrator set (above).
Calls are counted in memory and written per app per hour (kept 30 days); the rest comes from records the platform already keeps.
Tracing a job run
Every scheduled or retried run of a manifest job gets an operation id — job-<name>-<time>-<random> — used as the request id of the job's audit row and of every bridge call its script makes, exactly like the request id of a browser call (a Vizum.jobs.runNow run carries the calling request's id instead). The job's tick row (job:<name>) carries it as operation_id; Vizum.audit.search({request_id}) finds everything one run did.
SDK & server
Live data — queries that refresh themselves
*@vizum/vapp-query 0.2. Built on Vizum.orm.watch and the data pings, so nothing new on the server: it refreshes on the desktop and in Vizum Solo (feature watch). In Odoo's own menus the watch degrades — queries still load, and refresh only when they are fetched again.*
The platform already tells an app when its data changes: Vizum.orm.watch(model, domain, cb) is the desktop's fingerprint poll (it fires when the domain's record count or last write changes — created, updated or deleted by this user or another), and a data: global collection pings its subscribers on every change. @vizum/vapp-query binds those signals to TanStack Query, so a query refetches when, and only when, its data changed — no reload timers, no manual invalidation.
React
import { useQuery } from "@tanstack/react-query";
import { searchRead } from "@vizum/vapp-query";
import { useLiveModels } from "@vizum/vapp-query/react";
function Orders() {
useLiveModels(["sale.order"]); // every sale.order query on this screen stays fresh
const { data = [] } = useQuery(searchRead("sale.order", [["state", "=", "sale"]], ["name", "amount_total"]));
...
}
useLiveModels(models, { interval?, domains? }) watches while the component is mounted and stops on unmount; an inline array does not re-subscribe on every render. domains: {model: domain} narrows a watch to the records a screen shows, so a write elsewhere does not refetch it. useLiveCollection(name) does the same for a shared collection (collectionList(name)).
Without React
import { QueryClient } from "@tanstack/query-core";
import { liveModels, liveCollection } from "@vizum/vapp-query";
const queryClient = new QueryClient();
const live = liveModels(queryClient, ["res.partner", "sale.order"], { interval: 8 });
const orders = liveCollection(queryClient, "orders"); // a data: global collection
// …
live.stop();
orders.stop();
Any object with invalidateQueries({queryKey}) works as the client.
What refreshes, and what does not
| Change | Refetches |
|---|---|
| A record of a watched model is created, written or deleted — by anyone | its queries (keyFor(model)) |
A write outside a watch's domains entry | nothing |
A data: global collection changes | its queries (collectionKey(name)) |
| A per-user collection changes | nothing — only this user writes it, through this app: invalidate after your own write |
| The desktop tab comes back into view | the watched models' queries (the host does not poll a hidden tab) |
The watch polls every 8 seconds by default (4–60, interval), on the platform's cheapest query (count and last write date). It runs as the user and stops silently on a model the app can no longer read.
Testing it
The platform emulator (@vizum/vapp-cli/emulator, emulator.md) emulates the watch and the collection pings, so a live screen is testable in node:
// tests/live.test.mjs — run with `npx vzm test`, which points bootApp at your manifest.json
import test from "node:test";
import assert from "node:assert";
import { QueryClient } from "@tanstack/query-core";
import { bootApp } from "@vizum/vapp-cli/emulator";
test("a new partner refreshes the partner list", async () => {
const { Vizum, close } = await bootApp({ fixtures: { orm: { "res.partner": [{ id: 1, name: "Ada" }] } } });
// in the desktop the SDK script sets the global before your bundle loads; in node, set it
// before importing the package (it reads the global when it is imported)
globalThis.Vizum = Vizum;
const { liveModels, searchRead } = await import("@vizum/vapp-query");
const qc = new QueryClient();
const query = searchRead("res.partner", [], ["name"]);
await qc.fetchQuery(query);
const live = liveModels(qc, ["res.partner"]);
try {
await Vizum.orm.create("res.partner", { name: "Grace" });
await new Promise((r) => setTimeout(r, 50)); // the watch callback is a bridge event
assert.ok(qc.getQueryState(query.queryKey).isInvalidated);
} finally {
live.stop();
close();
}
});
The manifest must grant what the test calls (permissions.orm with read, search and create on res.partner here): the emulator enforces it as the platform does.
SDK & server
Action consent — an administrator's approval covers an app version's actions
*Since capability 5.149 (the number is assigned when the platform ships it), feature action-consent (Vizum.supports("action-consent")), desktop host.*
Until now the desktop asked every user, before every create, write, call:, chatter note or activity an app made on a model it does not own (the App action dialog), and remembered each «Allow» for that user for 30 days. A user walking nine screens of an app answered dozens of dialogs.
With action consent, the administrator who approves an app version also approves, for the companies they have selected, the actions that version declares. Users of those companies are no longer asked for them. Nothing changes in what the app may do: the approved grant, the Access Manager restrictions and the user's own Odoo rights decide that exactly as before.
What the approval covers
Everything the approved grant declares on a named model:
| action | how the app runs it |
|---|---|
create, write | Vizum.orm.create / write (and exec) |
call:<method> | Vizum.orm.call / callOn |
mail.log, activity.schedule, activity.done | Vizum.mail.log, Vizum.activity.* on a model the grant lets the app write |
| a named action | Vizum.actions.run of an approved actions[] entry |
What it never covers
The consent never covers these; the user is asked, and the user's own 30-day «don't ask again» works for them exactly as before (deletes are still asked every time):
- every
unlink(Vizum.orm.unlink); - every
mail.post(a chatter message that followers, customers included, are e-mailed); - a
call:or a named action whose method is on the fixed list: the whole name, one of its_-separated words, or a word that contains a list word of four letters or more —post, send, sent, notify, validate, confirm, done, cancel, approve, reverse, draft, reconcile, refund, capture, void, pay, paid, payment, payments, scrap, inventory, delete, create_invoices, action_invoice_create, js_assign_outstanding_line. Soaction_post,action_send_mail,button_validate,action_confirm,dsd_confirm_and_invoice,invoice_repostandaction_prepaymentalways ask;action_archiveorpayroll_reportdo not; - anything the manifest marks
"sensitive"(below); - a grant on the wildcard model
"*"; - an upload (
Vizum.orm.writeBlob) and an offline write (Vizum.offline.create/write, andVizum.offline.run): they reach the server through a path that cannot re-check the approval.
Your app's own tables (x_vzm_*) and declared read_calls never ask at all, as before.
The sensitive key
Mark what must always ask, even after an approval:
"permissions": {"orm": [
{"model": "sale.order", "ops": ["read", "write", "call:action_confirm", "call:action_quotation_send"],
"sensitive": ["call:action_confirm", "call:action_quotation_send"]},
{"model": "account.payment", "ops": ["create"], "sensitive": true}
]},
"actions": [
{"name": "invoice.post", "model": "account.move", "method": "action_post", "sensitive": true}
],
"min_platform": "5.149"
- On an orm grant:
true(every op of the grant,opsandgroup_ops) or a non-empty list of that grant's own ops. On a named action:trueonly. - Needs
min_platformat the feature's level: an older platform does not know the key. - Required for an app built for that level: every fixed-list
call:or named-action method on a model the app does not own must be marked. The platform never covers them anyway; marking them makes the Store review and the administrator's approval card say so.vzm checkand the install gate refuse the manifest with the same codes:
| code | when |
|---|---|
SENSITIVE_NEEDS_PLATFORM | the key is used and min_platform is below the feature's level |
SENSITIVE_INVALID | not true / not a non-empty list of that grant's own ops / anything but true on an action |
SENSITIVE_REQUIRED | built for the level, and a fixed-list action on a model the app does not own is not marked |
An app built for an older level keeps installing; its fixed-list actions are never covered either.
When the approval stops applying
One approval per (app, version, package digest, company). It applies to a request only when every company the user has selected holds one for the very version, package and trust state the app runs.
- A new version: an update that changes no server code and no grant carries the approval to the new version (its coverage intersected with the old one: it can only shrink). Server code means the script hooks (your
hooks/), the script library, native methods, routes, jobs, webhooks, computed / default / related fields, constraints, automations, migrations, evaluated UI, mail templates and named actions. Any other update drops it; a widened or code-changed update waits for the administrator's approval anyway, and that approval writes the new one. - A narrowed grant narrows what is covered at once.
- Uninstall, disable, a store revocation or a trust change: gone.
- An administrator can withdraw it, or approve the actions of a version that needed no approval, in Vizum Apps › the app › Manage. «Approve actions of all ready apps» is an explicit, audited action; nothing is approved automatically for apps installed before the feature.
- A company can keep asking for everything: Settings › Vizum › App actions › Ask users before app actions. An app can never change that switch.
What the server checks
The desktop marks every call it ran without asking (context.vizum_consent = {"version": …}). The server does not take its word: it checks the approval again — same companies, version, package and trust, the company not asking, the action still covered by the current approved grant — after the grant and Access Manager checks, and refuses otherwise with CONSENT_STALE. The desktop then learns the current approval, asks the user the normal way and retries once; your app sees CONSENT_STALE only if that retry is refused too. A read-only member (Access Manager) is refused before any of this, as before: the approval never widens what a user may do.
Every action run on the approval is audited as consent:action, one line per (app, user, action, hour) with a count and the first and last moment, naming the approving administrators. Each refusal is one consent:refused line. consent:granted, consent:carried, consent:dropped and consent:revoked record the approval's own life. The consent: prefix is reserved: an app cannot write such a line.
What your app does
Nothing: the consent is the host's. Vizum.supports("action-consent") lets an app word its own UI («you will be asked to confirm» only for what still asks). Mark your fixed-list and other decisive actions sensitive when you raise min_platform.
Only the desktop asks today; the Solo (Android) and native-view hosts never prompted and are unchanged.
SDK & server
OAuth provider configuration — an administrator confirms every change
*Since capability 5.156 (provisional: the number is assigned when the platform ships it), feature oauth-configure-confirmed (Vizum.supports("oauth-configure-confirmed")), desktop host.*
Vizum.oauth.configure(provider, conf) writes the provider's client_id, client_secret, scopes and endpoints into the database's OAuth registry. That registry holds one entry per provider for the whole database: every app that declares the provider and every account already connected through it use the same entry. Changing it changes them all — a new client ID stops every connected account at its next token renewal, a removed scope breaks whoever was granted it, and a re-pointed endpoint receives everyone's tokens.
So from 5.156 an app never changes it on its own say-so.
What happens when an app calls configure
- The server previews the change. The same gates as before apply (the provider is in the app's approved
permissions.oauth, the user is an administrator), plus the refusals below. Nothing is written. - The desktop host shows an administrator what the change does, in its own dialog — not the app's: the app and its key, the provider, the client ID, whether the client secret is set, replaced or unchanged, every scope added and removed, any endpoint that moves (from → to), the settings set by hand that the change removes, how many connected accounts use the configuration and which other apps use the provider. Every fact comes from the server and is shown whole — nothing is cut off; the secret is never shown. The dialog follows the rules of the step-up dialog (
identity.confirm), because the app chooses the moment it appears: one host dialog at a time, only over the active window of a visible tab, full screen ends before it renders (andui.fullscreenis refused while it is open), Cancel has the focus, and Confirm, named after the change, has no keyboard shortcut and is enabled only after a short pause with the window focused. - Only on confirmation does the host store the change — and only the change that was shown: if the configuration changed meanwhile (another app, another administrator), or the host asks to store anything but the change that was shown, the call rejects with
CONFLICTand nothing is written. Callconfigureagain to see the new state.
| outcome | what the app sees |
|---|---|
| stored | { ok: true, configured: true } |
nothing to change (same values; a blank client_secret keeps the stored one) | { ok: true, configured: true, unchanged: true } — no dialog |
| the administrator cancels | rejects with USER_CANCELLED (message UserDenied, as before) |
| the desktop locks, or the app's window is minimised or closed, while the dialog is open | the dialog closes; rejects with USER_CANCELLED |
a confirmation dialog (this one or identity.confirm's) is already open; the same app asks again less than 10 s after a cancel; or it opened 6 dialogs in the last 10 minutes in this tab | rejects with RATE_LIMITED — no dialog; details.retryAfter (seconds) when it is a cooldown |
| the app's window is not the active one, is minimised, the desktop is locked or the tab is hidden | rejects with PERMISSION_DENIED — no dialog |
| the configuration changed while the dialog was open | rejects with CONFLICT |
| refused by the server (below) | rejects with VALIDATION_FAILED, the reason in the message |
| not an administrator, or the provider is not declared | rejects with ACCESS_DENIED |
| Solo or the native host (no dialog) | rejects with UNSUPPORTED_CALL — open the app in the desktop |
What the server refuses, whoever confirms
These would break grants that already exist, so no confirmation makes them acceptable:
- a new
client_idwhile any connected account (of any app, any user) holds tokens — a provider refuses those tokens to any other client. Disconnect the accounts first. This holds too when the provider's entry was removed by hand while accounts still hold tokens; - a removed scope while any connected account holds tokens. Adding scopes is allowed; the accounts already connected keep what they granted until their users connect again. Scope order and separators (space or comma) do not count as a change;
- any move of a stored custom provider's endpoints (
auth_url,token_url,api_base) — the accounts connected through them and the client secret would go to the new ones. To move them, an administrator disconnects every account, removes the provider's entry from System Parameters (vizum_apps_platform.oauth_providers), and configures it again. A custom provider whose connections outlived its entry cannot be given endpoints until they are disconnected.
A built-in provider (Gmail, Outlook, OneDrive, Google Drive, the social networks) keeps each endpoint on that endpoint's own default host as before (P-1); its path may change (a Microsoft tenant, an API version). Its entry is rebuilt from the platform's defaults on every change, as before; the dialog names any setting set by hand that this removes.
A custom provider keeps what its entry was stored with: the endpoints when a call leaves them out, and the settings an administrator set by hand in System Parameters (token_auth, pkce, extra_auth_params, revoke_url…), so rotating its secret leaves its accounts working.
Text the dialog cannot show whole is refused (VALIDATION_FAILED), never cleaned up or cut: a client ID, scope or endpoint with a line break, a control or an invisible character; a client ID, scope or endpoint with a space inside; a client ID or scope over 256 characters, an endpoint over 2048; more than 64 scopes.
Rotating the client secret is allowed — Microsoft Entra secrets expire and must be replaced — through the same confirmation. Every connected account stays connected and uses the new secret at its next token renewal; if the new secret is wrong, renewals fail until it is corrected.
The trail
Each stored change and each refusal leaves an oauth:configure line in the app's audit trail: the provider, the outcome (applied; refused:client_id_locked / scopes_locked / endpoints_locked; refused:stale when the confirmed change no longer applies, refused:unconfirmed without a confirmation, refused:host_confirmation_required for the bridge's own op), whether the client ID changed, whether the secret was set or replaced, the scopes added and removed, the endpoints that moved and the number of connected accounts. Never the secret, a token or a provider's text. oauth: lines are the installation's: an app cannot write one, and the app's managers read them.
Why there is no way around the dialog
The bridge's own oauth.configure op no longer writes (an older desktop host that sends it is refused). The two host-only methods the desktop uses are in no bridge op table and are forbidden call: targets, refused at install (vzm check too) and at call time, and vizum.app.oauth is a denied broker model. It is the pattern of the host settings dialog (settings.open).
For an app's admin screen
try {
const res = await Vizum.oauth.configure("outlook", { client_id, client_secret, scopes });
if (res.unchanged) { /* nothing to save */ }
} catch (e) {
if (e.code === "USER_CANCELLED") { /* the administrator said no — keep the form as it is */ }
else if (e.code === "CONFLICT") { /* reload the provider status, then let them try again */ }
else if (e.code === "UNSUPPORTED_CALL") { /* tell them to open the app in the desktop */ }
else if (e.code === "RATE_LIMITED") { /* wait e.details?.retryAfter seconds; never loop */ }
else { /* VALIDATION_FAILED / ACCESS_DENIED: show e.message */ }
}
Leave client_secret blank to keep the stored one; type it only to set or rotate it.
SDK & server
Signing an action — step-up authentication
*Since capability 5.79. Needs min_platform 5.79. No manifest permission.*
Some actions are not just clicks: approving a controlled document, releasing a batch, closing a period. A regulation such as FDA 21 CFR Part 11 or ISO 13485 wants the person to prove who they are again, at the moment they sign, and wants a record of who signed what, when, and why.
Vizum.identity.confirm is that step. Your app asks the host (the desktop, or a vApp opened as an Odoo window) to re-confirm the user. The host shows its own dialog and the user types the Odoo account password there. Your app never sees it. What comes back is a single-use token that your server code spends with V.identity.consume, which hands you a receipt to store on your signature record.
// in the page
const { token } = await Vizum.identity.confirm({
action: "qms.doc:42:approve", // what is being signed: put the record in it
meaning: "approval", // Part 11 §11.50: approval, review, authorship...
reason: "Approve SOP-017 revision C", // plain text, shown to the user as "The app says"
});
await Vizum.orm.callOn("x_qms_doc", "x_sign", [[42], token]);
# native method x_sign on the model, or a script hook
def x_sign(self, token):
receipt = V.identity.consume(token, "qms.doc:%d:approve" % self.id)
self.write({
"x_signed_by": receipt["user_name"],
"x_signed_at": receipt["confirmed_at"],
"x_meaning": receipt["meaning"],
"x_signature_id": receipt["id"], # links your record to the platform's evidence
"x_state": "approved",
})
What the user sees
A dialog in the host DOM, over a backdrop that dims the whole desktop, dock and top bar included. An app's iframe can never paint over the dock, so a box that does not dim it is not Vizum's. The dialog shows:
- the app's icon, name and publisher from the installed record, never from your call;
- "Signing as Full Name (login)";
- the action in monospace, the meaning, and your reason in quotes ("The app says");
- a password field and the line *"Vizum asks for your password only in a dialog that dims the whole desktop. Never type it inside an app window."*
The dialog is closed by the lock screen and by closing the window: your promise rejects with USER_CANCELLED and no token exists.
confirm({ action, reason, meaning })
| Field | Rule |
|---|---|
action | Required. 1 to 96 characters of a-z 0-9 _ . : / -, starting with a letter or digit. The token is bound to this exact string, so put the record in it: "qms.doc:42:approve". |
reason | Optional plain text, at most 200 characters. Bidirectional and control characters are removed. |
meaning | Optional plain text, at most 64 characters. The "meaning of the signature" of Part 11 §11.50. |
Anything else you pass is dropped by the SDK and again by the host. Resolves with { token, expiresIn, action } (expiresIn is 120 seconds). The server checks the three fields again; it is the authority.
Errors (all VizumErrors from the catalogue):
| Code | When |
|---|---|
USER_CANCELLED | The user closed the dialog, or the desktop locked. |
RATE_LIMITED | The user is locked out (details.retryAfter seconds), or the app asked too often (see below). |
PERMISSION_DENIED | A portal or public user, an app the user cannot run, a device page, or the app is not the active window. |
UNSUPPORTED_CALL | Vizum Solo (see below). |
VALIDATION_FAILED | A malformed action, reason or meaning. |
V.identity.consume(token, action)
Server code only: a method, a trigger hook or native.script.lib. Returns the receipt:
{"id": 118, "user_id": 7, "user_name": "Ada Lovelace", "login": "ada",
"action": "qms.doc:42:approve", "meaning": "approval", "reason": "Approve SOP-017 revision C",
"confirmed_at": "2026-10-05T18:12:44.120000+00:00"} # UTC
id is the row in the platform's ledger. Store it with the name, the time and the meaning on your signature record: that is the link Part 11 §11.70 asks for between a signature and its record.
It raises a UserError with one fixed text, "This confirmation is invalid, expired or already used. Confirm again.", for every reason it can fail: not a token, used already, expired, minted for another app, user, session or action, or called from a place that cannot sign. The real reason goes to the server log only, so the error teaches an attacker nothing.
- One spend. A token is spent by one atomic statement. Two calls at once: one wins.
- It is the user's session. Only a call that comes from the user's own browser session can spend a token. A job, a cron, a webhook and a device route cannot (there is no session to bind).
- It rolls back with you. If your method fails after
consumeand the transaction rolls back, the token is unspent again (it expires in 120 s anyway). The signature and the burn commit together or not at all. - Any mismatch is a refusal. The app key comes from the server, not from an argument: a token minted for app A is refused in app B's code.
Security model
| Threat | Defence |
|---|---|
| A fake password box inside an app window | The real dialog is in the host DOM and dims the dock. The app identity comes from the installed record. vzm check and the Store warn (PASSWORD_INPUT_IN_BUNDLE) on an input of type password in a bundle. Residual risk: a maximised window that draws a perfect fake; the dimmed dock is the cue, and the guard line is the education. |
| An app calling the password route itself | The bridge op takes no password. The route refuses Origin: null, any other origin and a missing Origin, and the sandboxed iframe has no session cookie. The model is closed to V.orm, Vizum.orm, a method's env and call: grants. |
| Replaying or forging a token | 256 random bits, only the sha256 stored, single use, 120 s, bound to the user, the app, the action and the session. |
| Brute force | The failure counter of the session is shared with the desktop lock screen (5 failures, then a wait that doubles to 10 minutes); on top, 10 failures in 15 minutes lock the user for 15 minutes across sessions and workers. Every attempt is a ledger row, written on its own cursor so a rollback cannot erase it. |
| An API key standing in for the password | The check is interactive, as Odoo's own identity check: an API key is not a password. |
Users who sign in with an identity provider (OAuth) and have no local password get a clear message ("Set a password in Preferences > Security to sign") and the attempt does not count. 2FA users sign with the password alone in 5.79. Passkeys come later.
Full screen
A step-up confirmation always leaves full screen first (Vizum.ui.fullscreen, 5.85): the cue that the dialog is the host's is that it dims the whole desktop, and a window in full screen has none. The host ends any full screen before the dialog renders and refuses Vizum.ui.fullscreen() while it is open.
Prompt bombing
An app that opens dialogs in a loop is rehearsing a phishing page, so the host limits it: one dialog at a time per window; only for the active window while the tab is visible; after a cancel the same app is RATE_LIMITED for 10 seconds; at most 6 dialogs per app per 10 minutes per tab.
Where it works
| Host | Result |
|---|---|
| Desktop (Vizum window manager) | Served. |
| A vApp opened as an Odoo window | Served. |
| Vizum Solo (PWA, Android) | UNSUPPORTED_CALL. On a phone the app's iframe fills the screen, so no host chrome is left that an app could not imitate. Check Vizum.supports("step-up"). |
| A paired-device page | PERMISSION_DENIED: a device has no user. |
The evidence ledger
Every attempt is a row in vizum.app.stepup (the model is closed to every vApp): who, which app and version, the action, meaning and reason, the outcome (issued, failed, locked, refused), the IP and user agent, the time, and which code spent the token. Only an administrator can read it, and nobody can edit it through the ORM. Rows are kept forever by default; the system parameter vizum_apps_platform.stepup_retention_days sets a number of days (0 means forever). An unused token loses its hash a day after it expires. Uninstalling the platform module deletes the ledger: export it first if you owe those records to a regulator.
The app's own audit trail (Vizum.audit) also gets an identity.confirm line as a convenience, but that line is not evidence: an app can write any text to its own trail. The platform refuses an api_log action that starts with platform:, identity., trust: (the platform's package trust and store revocation records) or policy: (SDK 5.97, the re-checks of installed apps when the platform's rules tighten), so an app cannot imitate one.
Vizum.audit.search shows a member of the app only their own identity.confirm lines (and their own platform: lines); the app's managers — a Settings administrator, or a manager of the app's approved team (native.team.managers) — see everyone's, and the trust: / policy: lines.
What this is not
It does not make an app Part 11 compliant by itself. It gives you the three pieces only the platform can: a user who has just proved who they are, a tamper-resistant record of it, and a receipt to link to your signature. Retention of *your* records, the signature manifestation on a printed copy, and your SOPs stay with you.
SDK & server
Data feeds — Power BI and other BI clients, governed PRIVILEGED
Power BI refreshes a dataset outside your app. Power BI Desktop, or Microsoft's service on a schedule, runs the Power Query and calls Odoo directly. Before 5.89 the only door it had was Odoo's native /jsonrpc with the user's own API key: 5,000 rows × every column a page, several tables at the same time. On a 2-worker instance four refreshes held both HTTP workers for 48 s, and every other user waited about 4 s per click (Batch 22 reproduction, 2026-10-07). The bridge ceilings of 5.88 never see that traffic.
A data feed is the governed door. An administrator defines it from your app, issues it a key, and the BI client pages it:
GET https://<odoo>/vizum/feed/<app key>/<feed name>?$top=<n>&$skiptoken=<last id>
Authorization: Basic base64("feed:" + <feed key>) // or: Bearer <feed key>
// manifest.json: "min_platform": "5.89",
// "permissions": { "orm": [{ "model": "sale.order.line", "ops": ["search", "read"] }], "feeds": true }
if (Vizum.supports("data-feed")) {
const f = await Vizum.feeds.define("order-lines", {
model: "sale.order.line",
fields: ["order_id", "product_id", "product_uom_qty", "price_subtotal", "create_date"],
domain: [["state", "in", ["sale", "done"]]],
label: "Order lines",
});
f.cost; // { width: 8, pageSize: 5000, computed: [] } on this instance
const { secret, url } = await Vizum.feeds.rotateKey("order-lines");
// show `secret` ONCE to the administrator, with the instance's address + `url`: they paste both
// into Power BI. The platform keeps only the key's SHA-256.
}
| call | who | result |
|---|---|---|
Vizum.feeds.list() | administrator | DataFeed[] — definition, key hint, usage counts; never the key |
Vizum.feeds.define(name, {model, fields, domain?, label?}) | administrator, confirmed on the host | the feed plus cost |
Vizum.feeds.rotateKey(name) | administrator, confirmed on the host | the feed plus secret, shown once; any previous key dies at once |
Vizum.feeds.revoke(name) | administrator | the feed, with no key |
Vizum.feeds.remove(name) | administrator | true |
permissions.feeds is a privileged boolean grant (an administrator approves it, and gaining it in an update asks again). It needs permissions.orm: a feed serves only what your app's grant may search and read. vzm check reports FEEDS_WITHOUT_ORM otherwise, and the install refuses it. Every method is for base.group_system; the host shows the model and columns before define and rotateKey, in its own dialog.
What a feed serves
- One model, the declared columns, the feed's domain. Scalars (
char,text,html,selection,integer,float,monetary,boolean,date,datetime) andmany2one. Binary and x2many columns are refused atdefine. At most 200 columns, 50 feeds per app. - Read as the administrator who issued the key, through your app's ORM grant: the grant's field list and domain floor, the fields your app hides, Odoo's ACLs and record rules all apply, exactly as for a bridge call. If that administrator is archived or stops being an administrator, or your app loses
permissions.feeds, the feed answers 403 until another administrator rotates the key. - The key is not a password or an Odoo API key: it opens this one feed, read-only. Rotating it kills the old one. It is read from the
Authorizationheader only — never from the URL, where it would land in access logs.
The answer
{"value": [
{"id": 7, "order_id": 3, "order_id__name": "S00003", "product_uom_qty": 2.0,
"price_subtotal": 40.0, "create_date": "2026-10-07T10:11:12Z"},
...],
"@vizum.pageSize": 5000, "@vizum.count": 5000, "@vizum.next": 9172,
"@odata.nextLink": "https://<odoo>/vizum/feed/<app>/order-lines?$top=5000&$skiptoken=9172"}
idfirst, then the columns in the order you declared. Amany2oneis its id plus<field>__name. Odoo'sfalsefor an empty value isnull(a boolean keepsfalse). A datetime is ISO 8601 UTC, a date ISO 8601.- Pages are keyset:
id > $skiptoken, by id. A row inserted during a refresh cannot shift a page. The last page has"@vizum.next": nulland no@odata.nextLink. - It is shaped like an OData collection (
value,@odata.nextLink) so BI clients page it with habits they already have, but it is not an OData service: there is no$metadataand no$filter. UseWeb.Contents, notOData.Feed.
Limits, measured to the instance
| value | |
|---|---|
| rows per page | ceiling / 4 / width, between 50 and 5,000. width = 1 (id) + stored columns + 2 per many2one + 4 per non-stored computed column (measured 3.5× slower to read). The ceiling is 5.88's: 1,000,000 values, 500,000 on odoo.sh and small instances, or the administrator's vizum_apps_platform.bridge_value_ceiling |
$top | 1..5,000, never more than the page |
| time per page | a quarter of the smaller of limit_time_real and limit_time_cpu, between 5 and 30 s. A page that runs out ends at the last whole chunk with "@vizum.cut": "time" and a cursor to it: shorter, never wrong |
| pages at once on the instance | 1 on odoo.sh and small instances (prefork with 1–2 workers, a per-worker hard limit under 2 GiB, or at most 4 GiB of cgroup memory/RAM; threaded mode is judged by memory only), 2 otherwise, or vizum_apps_platform.feed_concurrency (1..8) |
| busy | 429 with Retry-After (whole seconds, 1..120) and X-Vizum-Feed-Position; waiting feeds are served first come, first served |
| wrong keys | 20 per source address in 10 minutes, then 429 |
The body is streamed one chunk of rows at a time: a worker never holds a page's rows and its JSON together.
Refusals are JSON {"error": "..."}: 400 (a bad $top / $skiptoken), 401 (unknown app, unknown feed or wrong key — one answer for all three, with WWW-Authenticate: Basic), 403, 429.
Power Query
Power Query's Web.Contents retries a 429 by itself up to three times and waits what Retry-After says (0.5 to 120 s, delta-seconds only) — see Microsoft's Handle status codes by using Web.Contents. A refresh therefore queues behind the others instead of piling onto the workers. For a long queue, add your own wait loop with ManualStatusHandling = {429} and Value.WaitFor (Wait-Retry pattern).
Keep the base URL a literal and pass the path in RelativePath and the cursor in Query, so the Power BI Service sees one static data source it can refresh; store the key as a Basic credential (any user name, the key as the password):
let
Base = "https://odoo.example.com",
Page = (after as number) =>
Json.Document(Web.Contents(Base, [
RelativePath = "vizum/feed/com.example.bi/order-lines",
Query = [#"$skiptoken" = Text.From(after)]])),
Pages = List.Generate(
() => Page(0),
each _ <> null,
each if _[#"@vizum.next"] = null then null else Page(_[#"@vizum.next"])),
Rows = List.Combine(List.Transform(Pages, each _[value]))
in
Table.FromRecords(Rows)
Power BI evaluates the tables of a model in parallel. On a small instance, also set Power BI Desktop's *Maximum number of simultaneous evaluations* to 1 or 2: the feed would queue the rest anyway.
What is counted
Each feed keeps counts only: pages and rows served, pages cut by time, requests refused busy, wrong keys, and the average page time. Never a row of data and never who asked. define, rotateKey, revoke and remove are written to the app's audit trail.
See also
- capabilities.md — 5.89, and 5.88's bridge ceilings (
QUOTA_EXCEEDEDwith the page that fits), whichVizum.supports("resource-governance")advertises.
SDK & server
Server metrics — CPU, memory, workers, HTTP and database, over time PRIVILEGED
A vApp for administrators can show how the Odoo server is doing: CPU, memory, workers, disk I/O, storage, HTTP traffic and the database, over the last 15 minutes or the last 30 days. Before 5.96 the only server signal was the round trip of Vizum.perf.ping(); the platform did not expose the server's own numbers. An admin module can read them, so a vApp can too (RULE V: a vApp runs like a native module).
// manifest.json: "min_platform": "5.96", "install_scope": "admin",
// "permissions": { "server_metrics": true }
if (Vizum.supports("server-metrics")) {
const info = await Vizum.server.info();
if (!info.collecting) { /* nothing is being recorded yet: show a notice, not a chart */ }
const now = Math.floor(Date.now() / 1000);
const m = await Vizum.server.metrics({ from: now - 3600, to: now, step: 60 });
m.ts; // [t0, t0 + 60, ...] Unix seconds, one per point
m.series.cpu_pct; // [12.4, 15.1, null, 9.8, ...] same length as ts
m.latest.disk_free; // bytes, or null
}
| call | who | result |
|---|---|---|
Vizum.server.info() | administrator | hosting, scope, worker configuration, retention, which metrics this hosting can read, whether collection is on |
Vizum.server.metrics({from, to, step, node?, series?}) | administrator | the series, one value per point |
Both are read-only, and both answer ACCESS_DENIED to anyone who is not base.group_system. Desktop and native hosts only (not Solo, not a paired-device page).
The grant
permissions.server_metrics: true is a privileged boolean grant. An administrator approves it, and gaining it in an update asks again (the update goes to needs_approval). It has two requirements, both enforced by the platform at install and both reported by vzm check:
install_scope: "admin". The server's numbers are for administrators only; a vApp installed for everybody cannot hold the grant.vzm checkreportsSERVER_METRICS_NOT_ADMINotherwise.min_platform: "5.96". A platform older than 5.96 ignores keys it does not know and would install the app with a grant it does not honour, so the manifest must ask for 5.96.vzm checkreportsMIN_PLATFORM_TOO_LOW; the install gate refuses it.
Gate the interface on Vizum.supports("server-metrics") when the app also runs on older platforms.
Collection
The platform collects only while an app holding permissions.server_metrics is installed and ready (an administrator can also turn it on or off in Settings > Vizum > Resources). While nothing collects, info().collecting is false and metrics() answers nulls. Samples are written about once a minute per process; history starts when the first such app is installed, not before.
Retention: one-minute points for 48 hours, then rolled up to hourly points for 30 days (info().retention: {minute_hours: 48, hour_days: 30}). Anything older is deleted.
Scope: what the numbers describe
Every system value comes from the narrowest scope the hosting can give, in this order: Odoo's own cgroup v2, its cgroup v1, the Odoo process tree, and only then the machine. info().scope says which one applies:
scope | meaning |
|---|---|
container | Odoo's own container or cgroup. This is the default wherever the hosting supports it |
process | The sum over Odoo's own processes (psutil). Used when no cgroup is readable |
host | The whole machine. Only when an administrator allowed host-wide values in Settings > Vizum > Resources, and never on a multi-tenant host |
A container-blind reader would report the host's CPU and RAM, which on a shared host is another tenant's load. The platform never reads host-wide files unless host scope is on.
A host counts as shared (and the switch is refused) when Odoo runs in a rootless container (a user namespace that maps root to another user) or on odoo.sh. A rootful container on a machine shared with other customers cannot be told apart from a machine of your own: there the switch is the administrator's statement that the machine is single-tenant, so leave it off.
info().hosting is the detected environment: docker, podman, odoo.sh, systemd, bare or unknown. info().mode is prefork (several worker processes) or threaded (workers = 0).
null means unavailable
A missing value is always null, never 0. A null appears when:
- there was no sample in that point (the server was idle, the process had just started, collection was off, or the point is older than the retention);
- the metric cannot be read on this hosting (see below);
- a counter was reset between two samples (a restart), so no rate can be computed.
Draw a null as a gap. A zero would be a claim that nothing happened.
info().available tells which metrics a hosting exposes and why not when it does not. Each entry is true, or {ok: false, reason}; for example available.iops is {ok: false, reason: "cgroup_io_not_delegated"} on a host that does not delegate the cgroup io controller to Odoo. Show the reason instead of an empty chart. All the series of an unavailable metric are null on every point.
| Hosting | memory | CPU | Odoo disk I/O | storage | workers | HTTP | database |
|---|---|---|---|---|---|---|---|
| Docker / podman with cgroup v2 | yes | yes | yes | yes | yes | yes | yes |
| Rootless podman under a user slice | yes | only if cpu is delegated | only if io is delegated | yes | yes | yes | yes |
| Odoo as a systemd service | yes | yes | yes | yes | yes | yes | yes |
| odoo.sh | process tree | depends on the platform | usually unavailable | yes | yes | yes | yes |
Threaded dev box (workers = 0) | yes | yes | yes | yes | one process | yes | yes |
Treat the table as an expectation; info().available is the truth for the instance in front of you.
Time frame, step and the 1,500-point cap
from and to are Unix seconds. step is the number of seconds per point and is one of 60, 300, 900, 3600, 21600, 86400. Points start on multiples of step.
- A call returns at most 1,500 points. A range that would need more is refused with
VALIDATION_FAILED, and the message names the smalleststepthat fits. - A range older than the one-minute retention (48 hours) needs
step >= 3600; the hourly rollup has no finer data. - A response is bounded, so a page can request any range the user picks and rely on the cap. The platform also caches identical requests for a few seconds, so several administrators with auto-refresh cost one query.
Suggested steps (the Monitor vApp uses these): 15 min to 6 h at 60 s, 24 h at 300 s, 7 days at 3600 s, 30 days at 21600 s.
partial_from is the minute from which data is still being written. Each process flushes a minute when it closes, so the last open minute can change between two calls; treat points at or after partial_from as provisional.
node selects one Odoo node. A deployment with several Odoo nodes on one database (the Vizum store runs two) reports per node: system series are per node, HTTP series add up across nodes. info().nodes lists the nodes that reported; show a picker only when there is more than one. Omit node for the server as a whole.
series limits the answer to some of the names below (smaller responses on slow links).
The series
Every series has one value per entry of ts.
| series | unit | meaning |
|---|---|---|
cpu_pct | % | CPU used by Odoo, as a percentage of its limit (the container's quota, else the CPUs it can see) |
cpu_throttled | count | Times the quota stopped Odoo from running during the point. A value above zero means the limit is too low |
mem_used | bytes | Memory in use by Odoo |
mem_limit | bytes | The memory limit. null when the container has none (never the host's RAM unless host scope is on) |
workers_live | count | Odoo worker processes alive (HTTP, cron and gevent; in threaded mode, 1) |
workers_rss | bytes | Memory (RSS) of all workers together |
io_rps, io_wps | ops/s | Odoo's disk read and write operations per second |
io_rbps, io_wbps | bytes/s | Odoo's disk read and write throughput |
http_rps | req/s | HTTP requests per second |
http_avg_ms | ms | Mean response time |
http_p95_ms | ms | 95th percentile of the response time, from a fixed histogram (10 ms to 30 s), not a sample |
http_max_ms | ms | Slowest response in the point |
http_peak_concurrency | count | Requests in flight in the busiest second of the point |
http_avg_concurrency | count | Mean requests in flight across the point |
http_errors | count | Responses with a 4xx or 5xx status |
db_conn | count | PostgreSQL connections to this database |
db_commits_ps | /s | Transaction commits per second on this database |
db_blks_read_ps | blocks/s | Blocks read from disk (not the cache) per second on this database |
db_cache_hit | % | Share of block reads served from PostgreSQL's cache |
latest carries the slow-moving values, each in bytes or null: disk_total and disk_free (the filesystem of the filestore; on a shared filesystem it is the shared volume's size, so label it "shared"), filestore_bytes (the logical size of the attachments, summed from the attachment table once an hour, not a disk walk) and db_bytes (pg_database_size, hourly), and sampled_at (Unix seconds of the newest sample).
top_apps lists the ten apps with the most bridge requests in the range, most first: {key, name, requests, avg_ms} (name is null for an app no longer installed, avg_ms the mean duration of its requests in milliseconds). It is aggregated over the whole range and every node; it never names a user, a URL or a record.
summary describes the range as a whole: requests (timed requests), excluded (long-lived and streaming requests, counted but not timed), status ({"2xx", "3xx", "4xx", "5xx"} counts), and the response-time histogram hist with its upper edges hist_bounds in ms (10, 25, 50, 100, 250, 500, 1000, 2500, 5000, 10000, 30000, and a last bucket that ends at the slowest request of the range). With no request in the range, status, hist and hist_bounds are null. latest.disk_shared says the filestore's filesystem is shared with other tenants.
node is the node whose system series (CPU, memory, workers, disk I/O, database) the answer carries: the one asked for, else this node when it reported in the range, else the newest one.
Things the numbers do not say
- Disk I/O is Odoo's, not PostgreSQL's.
io_*is the Odoo container's filestore, logs and sessions. The database's own I/O is in thedb_*series, which is the same on every hosting. - Concurrency has 1-second resolution. Each process records the milliseconds of every second it spent inside requests; adding them over all processes and dividing by 1,000 gives the mean number of requests in flight in that second (Little's law), exact in prefork and in threaded mode.
http_peak_concurrencyis the highest of those seconds. A burst shorter than one second shows as a smaller number. Label it "peak concurrency (1-second average)", and compare it withinfo().config.workers. - Long-lived connections are excluded from the HTTP series.
/websocket,/longpolling, the load balancer's/web/healthand static files do not count, so a chat tab open for hours does not distort the latency. http_p95_msis a bucketed estimate. It is the upper edge of the histogram bucket that holds the 95th percentile, not an exact value.
Privacy
The platform stores counters and gauges, nothing more. No URL, no query string, no user, no IP address and no record id is ever recorded or returned. HTTP traffic is aggregated by status class (2xx/3xx/4xx/5xx) and route kind, and per app only as a count and total time. The tables have no read access for any group except base.group_system, and the bridge refuses everyone else. An app cannot use Vizum.server to learn who did what.
Errors
| code | when |
|---|---|
ACCESS_DENIED | The caller is not an administrator, the app does not hold permissions.server_metrics, or the app is not ready |
VALIDATION_FAILED | step is not one of the allowed values, to is not after from, the range needs more than 1,500 points (the message names the step that fits), or a range older than 48 hours uses a step under 3600 |
RATE_LIMITED | The 5.67 bridge rate limit applies like any other call; poll no faster than every 15 seconds |
Poll politely: pause refreshes while the window is hidden (Vizum.onVisibility), and prefer 30 s or more for ranges up to 6 hours.
Testing
vzm dev and the emulator answer Vizum.server with a deterministic fake (a smooth series per metric, a gap at the first point, Odoo's disk I/O unavailable with reason: "cgroup_io_not_delegated"), for administrators only, with the platform's step and 1,500-point rules. Use emulator.stub("server", "metrics", fn) to feed a chart any series you need, nulls included.
SDK & server
Direct devices — claim an ESP32 at the Vizum broker, it enrols itself
SDK 5.129 (provisional number, fixed at merge). Features device-broker (the instance's side of the broker) and device-enrol (enrol on a device route, and an app with device routes but no page). Hosts: Desktop and native for Vizum.deviceBroker; the device itself uses plain HTTPS.
An ESP32 sensor has no screen, so it cannot show the PIN that paired-device routes use. Its owner scans the QR code on its label instead. The Vizum store keeps a broker: it knows which physical device belongs to which instance, and hands the device the instance's address plus a one-time enrolment token. After that the device talks to your instance directly; no readings pass through the store.
label QR -> your vApp: Vizum.deviceBroker.claim({device_id, code}) (administrator, instance side)
the platform tells the store; the store keeps who owns the device
device polls the store -> receives {instance_url, enrolment_token}
device -> POST {instance_url}/vizum/device/<slug>/_enrol {device_id, enrolment_token}
the platform burns the token and answers {device_token, ingest_url}
device -> POST {instance_url}/vizum/device/<slug>/ingest Authorization: VizumDevice <device_token>
Manifest
{
"min_platform": "5.129",
"install_scope": "admin",
"permissions": { "logic": "script", "device_broker": true },
"native": {
"service_user": true,
"script": { "hooks": [
{ "name": "device_ingest", "code": "..." },
{ "name": "device_enrolled", "code": "result = V.orm.create('x_vzm_box', {'name': V.payload['name']})['id']" }
] },
"routes": [
{ "name": "ingest", "kind": "json", "auth": "device", "hook": "device_ingest",
"enrol": { "hook": "device_enrolled" } }
]
}
}
permissions.device_brokeris a privileged boolean (its own line on the approval card: "Claim and release Vizum devices on this instance's behalf"). It needsinstall_scope: "admin"andmin_platform5.129;vzm checkand the install gate refuse less. Gaining it in an update is a widening.enrol: {"hook": ...}goes on oneauth: "device"jsonroute of the app (more than one is refused). It is part of the route's approved descriptor (...|enrol:<hook>), so adding it or pointing it at another hook asks the administrator again. The card says: "Devices may enrol with a one-time code issued by the Vizum store".- A device route does not need a
page. An app withauth: "device"json routes and no page serves them (a sensor has no screen). Everything that belongs to a device *page* stays page-only:_pair/*,_session, the page's worker, manifest and icon answer 404 for such an app._heartbeat,_sign,_storeand the json and file routes answer.
Vizum.deviceBroker (administrators, Vizum.supports("device-broker"))
const d = await Vizum.deviceBroker.claim({ device_id: "VZD-7K2M9Q4XT1", code: "7K2M-9Q4X-T1AB", name: "Walk-in cooler" });
// { id, device_id, name, model, state: "claimed", link, last_seen, enrolled_at, broker_state, broker_seen_at, expires_in }
await Vizum.deviceBroker.list(); // only this app's devices on this instance
await Vizum.deviceBroker.rearm(d.id); // the device lost its answer: a new code, the old one dies
await Vizum.deviceBroker.rename(d.id, "Cooler 2");
await Vizum.deviceBroker.link(d.id, boxId); // the record the device's data lands in (or null)
await Vizum.deviceBroker.unlink(d.id); // cut the credential now, then tell the store
The same calls exist in server code as V.device_broker.claim(device_id, code, name=None, link=None), rearm(id), unlink(id), list(), rename(id, name) and link(id, record_id). V.device_broker is None in every hook a device triggered (the enrol hook and a device route's hook): a device must never claim or release devices.
- Who may call. The server decides: the caller is in
base.group_systemand the app's *approved* permissions holddevice_broker. The host also asks the person beforeclaim,rearm,unlink,renameandlink, showing the device and name taken from the call; that dialog is only a courtesy on top of the server check. - What the app never gets. The instance's uuid and store token, and the enrolment token. The platform keeps only
sha256(enrolment_token)and its expiry.list()carries no hash, token or secret. - The instance's address handed to the device is the instance's own public base url (
web.base.url), never a value from the app. Aclaimis refused withbad_instance_url, before any call to the store, unless that url ishttps://on a public host. - One owner at a time. The same
device_idis live on one app of an instance at a time: a second claim, from this app or another, is refused withdevice_in_use. Afterunlinkthe id is free; the store then requires a fresh pairing (the button on the device) before it can be claimed again. - Errors are one word in the error message:
invalid_code(one answer for a wrong id, a wrong code, a device not released, already claimed...: never which part was wrong),bad_instance_url,quota_exceeded,rate_limited:<seconds>,device_in_use,not_found(also for another app's device),not_claimed,store_unavailable,not_registered,broker_malformed. stateisclaimed(waiting for the device),enrolled,unlinkedorrevoked(the store revoked it).broker_stateis the store's own last answer, orpending_unlinkwhile anunlinkwaits for the store.
What the platform does for you
unlinkrevokes the device token first (instantly), then tells the store. If the store is unreachable the call stays queued and is retried; the answer saysbroker_state: "pending_unlink".- After an enrolment the platform confirms it to the store (queued and retried).
- An hourly job (and one after each enrolment) asks the store for the state of this instance's devices and revokes any the store reports
revoked. A state for a device this instance does not hold is ignored. - Uninstalling the app drops its devices and tokens and queues an
unlinkfor each, which still goes out. Disabling an app makes its device routes answer 404 (tokens are refused until it is ready again). - A broker-enrolled device is a paired-device record of the app: the same table, the same token check, the same revocation (
Vizum.pairedDevices.revokealso releases it at the store).
The enrolment route (what the device calls)
POST /vizum/device/<slug>/_enrol, public, stateless, no cookie, body at most 4 KiB:
{ "device_id": "VZD-7K2M9Q4XT1", "enrolment_token": "<from the store>", "fw": "1.8.0", "chip": "esp32", "ts": 1760000000 }
Success: 200 {"ok": true, "result": {"device_token": "vzd_...", "server_time": 1760000000, "ingest_url": "/vizum/device/<slug>/ingest"}}. The device_token is minted exactly as PIN pairing mints it (hashed at rest, shown once). Take ingest_url from the answer: do not hard-code the slug. Then call it with Authorization: VizumDevice <device_token>; every answer is {"ok": true, "result": ...} or {"ok": false, "error": ...}; 401 means the credential is refused, 429 carries Retry-After.
- One answer for every refusal:
401 {"ok": false, "error": "unpaired"}for an unknown device, a wrong, spent or expired token, another app's enrolment, an unlinked or revoked device, a malformed body field. The device learns nothing about which part was wrong. A body that is not a JSON object is400, a body over 4 KiB is413. - Single use: the token is burned by one conditional
UPDATEunder the row lock; two presenters at once cannot both win. If the app's enrol hook fails, nothing is burned and the device gets503+Retry-Afterand tries again. - Limits (per 10 minutes): 60 attempts per source (IPv6 by /64) and 10 wrong tokens per source, then
429. Wrong tokens are charged to the source, never to the device, so a third party cannot lock a device out; the device's own budget (10 attempts with the right token) counts only after the token matched. Calls after enrolment use the paired-device limits (120 a minute per device). - The enrol hook runs as the job user with
V.deviceand the payload{id, device_id, name, fw, chip}. Whatever integer record id it answers becomes the device'slink(so the vApp can create the box row that will hold the device's readings). It cannot reachV.device_broker. - The device token lives like any paired-device token: a device with no authenticated call for seven days is refused (the offline lease); revoke, unlink or a store
revokedkill it at once.
Operator notes
web.base.urlmust be the instance's publichttps://address. Odoo rewrites it when an administrator logs in from another host unlessweb.base.url.freezeis set.- The url check is on the text of
web.base.url(https, no credentials, a public host name or IP, notlocalhost,.local,.internal...). It does not resolve DNS: a name that points at a private address is the administrator's own misconfiguration. - The instance must be registered with the Vizum store (it holds the store token); an unregistered instance answers
not_registered. The store url must behttps. - Pending decisions that live on the store and the vApp, not here: device pricing, the manufacturing key, geolocation residency, raw retention, batches per window.
SDK & server
Geocoding — Vizum.map.geocode resolves addresses on the platform server
*Since capability 5.143 (provisional: the number is fixed when it merges). Feature geocode.*
An app that turns an address into a point on a map used to ask a geocoding service from the browser: every customer address left the instance from the user's machine, to a host the administrator never chose. With Vizum.map.geocode the platform server sends the query, to the one provider the administrator configured, and the browser sends it nowhere.
{ "min_platform": "5.143", "permissions": { "geocode": true } }
// in the app's window
const hits = await Vizum.map.geocode("Calle 93 #11-27, Bogotá", { limit: 3, country: "co", lang: "es" });
// [{ lat: 4.6767, lon: -74.0483, display_name: "Calle 93, Chicó, Bogotá, Colombia",
// category: "highway", type: "residential", importance: 0.41 }, ...]
if (hits.length) marker.setLatLng([hits[0].lat, hits[0].lon]);
# in a hook, a job, a native method or a route of an internal user
hits = V.map.geocode(rec["street"] + ", " + rec["city"], limit=1, country="co")
if hits:
rec["x_vzm_lat"], rec["x_vzm_lon"] = hits[0]["lat"], hits[0]["lon"]
The call
Vizum.map.geocode(query, { limit, lang, country }) · V.map.geocode(query, limit=5, lang=None, country=None)
query | text, 2–200 characters after the platform collapses whitespace and normalises it (NFKC). Control and invisible characters are refused. |
limit | 1–10 results, default 5. |
lang | the language of display_name, a tag such as "es" or "pt-BR". |
country | one ISO 3166-1 alpha-2 code ("co") or a list of at most five. |
Any other option is refused by name. The answer is a list (possibly empty) of at most limit results, each with scalars only: lat, lon (numbers), display_name (at most 300 characters), category and type (the provider's class and type of the place, "" when it gives none), importance (a number, or null). Nothing else comes back: never the provider's URL, its key or its raw answer.
On the server, a call is one network call of the run's budget (like V.net.fetch), a cache answer included. V.map is refused inside a declared read call (permissions.orm[].read_calls, READ_CALL_WROTE), because the query leaves the transaction. A route answering a portal or public visitor cannot geocode.
Errors
| Code | When |
|---|---|
GEOCODE_NOT_CONFIGURED | The instance has no provider (Settings › Vizum › Geocoding is *None*, the default). Retrying does not help; show the address without a pin. |
ACCESS_DENIED | The app's approved manifest does not hold permissions.geocode (the window's gate answers PERMISSION_DENIED before that). |
VALIDATION_FAILED | The query or an option is not acceptable; the message names it. |
RATE_LIMITED | The app made more calls this minute than the administrator allows (60 by default). Retry later. |
QUOTA_EXCEEDED | The app made more calls in 24 hours than allowed (2,000 by default). |
GEOCODE_PROVIDER_FAILED | The provider could not answer: unreachable, an address the platform refuses, an error status or an answer it cannot read. Retryable. |
What the administrator configures
Settings › Vizum › Geocoding:
- Provider — *None* (geocoding off; the default), *Nominatim* (the base URL of a self-hosted Nominatim, or of a compatible service that needs no key:
https://nominatim.example.org), or *Key + URL template* for a commercial service: an https URL with{query}and{key}, and optionally{limit},{lang},{country}, for examplehttps://us1.locationiq.com/v1/search?key={key}&q={query}&format=json&limit={limit}. The template reads the three answer shapes geocoding services use: a Nominatim array, a GeoJSON FeatureCollection (Geoapify, Photon, Mapbox) and{"results": [...]}with lat/lon (Geoapifyformat=json, OpenCage). - Key — stored encrypted in the platform's secret store (the same as the AI gateway's provider key, its own key namespace) and never shown again. Only administrators read or write the configuration; no app can name it in an ORM grant.
- Limits per app — calls a minute (1–600, default 60) and a day (1–100,000, default 2,000), cache answers included; and how many days an answer is reused (0–365, default 30; 0 = no cache).
The provider's address is checked like every outbound call the platform makes: it is resolved once, every address is checked, and the connection goes to the checked address (DNS rebinding cannot swap it). Private, loopback and link-local addresses are refused unless the administrator switched on outbound private access for the platform (vizum_apps_platform.net_allow_private, the same switch a self-hosted AI server uses); cloud metadata addresses are refused always. Redirects are not followed; an answer above 1 MB or slower than 10 seconds fails.
Cache, rate limit and audit
- Cache — shared by the instance: two apps asking for the same address (same provider, same normalised query, same options; case does not matter) cost one call to the provider. An entry stores a hash and the results, never the address. Because it is shared, an app holding the grant can tell from the answer time whether some app asked for the same address in the last days; set the cache days to 0 where that matters more than the provider calls.
- Rate limit — counted per app, so one app cannot spend another's allowance; a call that fails later still counts.
- Audit — Vizum Apps Admin › Geocoding audit lists every answered call: app, user, time, window or server code, provider and host, the query exactly as it was sent, whether it left the instance (a cache answer did not), the outcome and the number of results. It is written in its own transaction, so a call whose caller rolls back is still there. Rows are kept 180 days, like the app audit trail.
Migrating an app that geocodes from the browser
vzm check warns GEOCODE_VIA_NET when permissions.net names a known geocoding host (nominatim.openstreetmap.org, LocationIQ, OpenCage, Geoapify, HERE, Google, Mapbox…) and GEOCODE_DIRECT_HOST when the code names one in a URL. Replace the fetch with Vizum.map.geocode, declare permissions.geocode, raise min_platform to 5.143, and drop the host from permissions.net. On an instance where the administrator has not chosen a provider the call fails with GEOCODE_NOT_CONFIGURED: keep the address visible and say that the map pin needs a provider.
Detect the capability where an older platform must keep working: Vizum.supports("geocode") in the window; in server code has_attr(V, "map").
SDK & server
Reminders and alarms — Vizum.schedule fires with the window closed
SDK 5.128 (provisional number, fixed at merge). Feature schedule. Hosts: Desktop and native.
A vApp's timers live in its iframe: a minimised window is torn down and a closed one runs nothing. Vizum.schedule keeps the reminder on the server, per app and per user. A cron runs every minute and turns each due schedule into a notification of the app (the existing inbox row, the live bus ping, a click action that reopens the app with your payload). No new delivery channel exists.
Manifest
"min_platform": "5.128",
"permissions": { "schedule": true }
permissions.schedule is a boolean and is not privileged: it reaches only the current user's own notifications of this app. An older platform ignores the key and every call fails, so min_platform 5.128 is required (vzm check and the install gate refuse a manifest that declares less).
Calls
// every weekday at 07:30 New York time, whatever DST does
await Vizum.schedule.set({
id: "wake",
repeat: "FREQ=WEEKLY;BYDAY=MO,TU,WE,TH,FR;BYHOUR=7;BYMINUTE=30",
tz: "America/New_York",
title: "Wake up", body: "Gym at 8", payload: { alarm: "wake" },
});
// once, at one instant (it must carry its offset)
await Vizum.schedule.set({ id: "tea", at: "2026-10-18T14:00:00Z", title: "Tea" });
await Vizum.schedule.list(); // this app's schedules for the current user
await Vizum.schedule.clear("tea"); // true, or false when there was none
const missed = await Vizum.schedule.missed();
await Vizum.schedule.ack(missed.map((m) => m.fire_id));
setcreates or replaces the scheduleid(1-64 characters: letters, digits and. _ : -).- Exactly one of
atandrepeat. at: an ISO-8601 instant withZor an offset. A local time without a zone is refused (it is ambiguous). A moment in the past is refused; so is one more than ten years away.repeat, either an RRULE subset or an object:"FREQ=DAILY|WEEKLY;BYDAY=MO,TU;BYHOUR=7,19;BYMINUTE=0,30". OnlyFREQ,BYDAY,BYHOURandBYMINUTEare accepted.INTERVAL,COUNT,UNTIL,BYMONTH, ordinals such as1MOand any other part are refused naming the part.WEEKLYneedsBYDAY;BYHOURis required;BYMINUTEdefaults to 0.{ days: ["mon", "fri"], time: "07:30" }(daysomitted = every day;timemay be a list).tzis an IANA zone (America/New_York) and is required withrepeat.
title1-120 characters,bodyup to 500,payloadany JSON up to 4 KB.
Time zones and DST
The server stores the rule and the zone and computes the next fire in UTC from the zone's rules, so a 07:30 alarm stays at 07:30 local across a DST change. A local time that does not exist (the spring gap, 02:30 in New York on the second Sunday of March) fires with the offset from before the gap, as RFC 5545 says: 02:30 EST is 07:30Z, which is 03:30 EDT. A local time that exists twice (the autumn hour) fires once, the first time.
Missed occurrences
The cron fires everything that came due while nobody was there, and the occurrence is recorded. missed() returns the occurrences fired in the last 24 hours that the app has not acknowledged, at most 20, oldest first: {fire_id, id, due, fired_at, title, body, payload}. Call ack(fire_ids) once the app has handled them. Older than 24 hours is not returned (the inbox row remains). When a server was down and several occurrences of one schedule are due at once, one notification is made, for the latest, and the schedule moves past now.
Limits
| Schedules per app and user | 50 (a 51st id is refused; replacing an existing id is free) |
| Payload | 4 KB of JSON |
| Smallest repeat step | 1 minute (the cron's own granularity) |
set calls | 120 per hour per app and user (RATE_LIMITED, retryable) |
A schedule is always the current user's; the bridge takes no user. Uninstalling the app removes its schedules. The administrator has a backend list (Vizum > Schedules) with app, user, next fire and title, and can delete a row. The body and the payload are not readable through the ORM by anyone, administrators included; only the owning app and user receive them through the bridge.
Where it rings (the honest matrix)
The server always records the occurrence. Whether anyone is told depends on the host.
| Host | Window open | Window closed or minimised | Whole browser closed |
|---|---|---|---|
| Desktop (a Vizum Desktop tab in Odoo) | toast, sound, browser notification if the user granted it | the same | nothing rings |
| Native (an Odoo client action) | notification row and bus ping; whether a plain Odoo tab shows a toast is not measured | not measured | nothing rings |
| Solo (PWA) | not available in this release | not available | nothing rings |
| Android APK | not available in this release (a separate APK change) | ||
| Device / Link pages | not available: no user session, a schedule is user-scoped |
There is no "guaranteed when closed" claim for any host. Web Push (an alarm with the browser fully closed) and Android local notifications are separate, later work.
Sound. Browsers block audio that no user gesture started, so a sound from a background tab can be silenced. The toast and the inbox row still appear. Play your own tone from the app on a click if the sound matters.
Tests
In an Odoo test, env["vizum.app.schedule"]._cron_fire() does what the cron does. vzm does not emulate schedules (the emulator answers UNSUPPORTED_CALL; stub it).
SDK & server
Guarded calls — run a native method only while the record is as the app saw it
SDK 5.135. Feature orm-expect (Vizum.supports("orm-expect")). Hosts: Desktop, native and Solo. Needs min_platform 5.135 in the manifest and the usual call:<method> grant. No new permission.
The problem
An app that drives a native workflow reads a record, decides, and calls a button method. Between the read and the call another user can confirm, post or cancel the record. Many Odoo button methods accept any state: Approval Center refused a purchase order whose state had moved on and called button_cancel, and a confirmed order was cancelled; a posted bill was reset to draft and cancelled. A second read just before the call narrows the window and does not close it: only the platform holds the transaction around the call.
The call
// Refuse a purchase order only if it is still waiting for approval
try {
await Vizum.orm.callOn("purchase.order", [poId], "button_cancel", [], {
expect: { state: "to approve" },
});
} catch (e) {
if (e.code === "STATE_CHANGED") {
const now = e.details.records[0].current.state; // what it holds now; nothing ran
// show "someone already moved this order to <now>", reload, let the user decide again
} else if (e.code === "BUSY") {
// the record is being changed right now; nothing ran; e.retryable === true
} else { throw e; }
}
Vizum.orm.call(model, method, args, { ids, expect }) is the same call. In a hook, job or method script: V.orm.call(model, ids, method, args, expect={"state": "to approve"}).
expect is {field: value}. In ONE transaction the platform:
- reads the records AS THE USER (their record rules apply; archived records count);
- locks them:
SELECT id FROM <table> WHERE id = ANY(ids) ORDER BY id FOR UPDATE, ids sorted so that two guarded calls never deadlock, waiting at most 5 seconds; - re-reads the expected fields as the user, with the cache dropped;
- if every record holds every expected value, runs the method on those records, still holding the locks. Otherwise it runs nothing, for any id (all or nothing).
Everything else about the call is the call you already had: the same grant, the same ACLs and record rules, the same allowlist and write ceiling, no new method name. An app that does not send expect is unaffected.
Answers
| code | when | retryable | details |
|---|---|---|---|
STATE_CHANGED | a record does not hold an expected value, is missing, or the user cannot read it. Nothing ran. | no: re-read and decide again | {model, records: [{id, current: {field: value}}]} |
BUSY | another transaction still held the rows after 5 s. Nothing ran. | yes | {reason: "lock_timeout", wait_ms} |
VALIDATION_FAILED / ACCESS_DENIED | expect is malformed or names a field the app may not compare (see below). Nothing ran. | no |
current has the expected fields only, per id. A record that is missing and one the user cannot read answer the same, null for every field: the call is not a way to ask whether a record exists. An empty value is null (an empty char, a many2one without a record); a boolean is true or false. Dates and datetimes come back as YYYY-MM-DD and YYYY-MM-DD HH:MM:SS.
In a script the exception is StateChanged or Busy, both subclasses of UserError, so except Exception as e: catches them. For StateChanged, str(e) ends with the current values as JSON.
BUSY and LOCK_TIMEOUT
BUSY and LOCK_TIMEOUT (5.103) are both retryable conflicts about a lock that did not come free, and they are different things. LOCK_TIMEOUT is raised by advisory_lock(key, timeout_ms) in an app's own server method: the app chose the key and the wait (default 5 s, up to 30 s), and it can catch the exception and go on. BUSY is raised by the platform for the row locks of a guarded call: the wait is fixed at 5 s, the rows belong to Odoo's own model, and the call is rejected as a whole (nothing ran). Handle each where it is raised; an app that retries on error.retryable treats both the same.
Limits
idsis required withexpect(there is nothing to lock without them): 1 to 100 positive ints, each once (a repeated id would make a method that iterates the records run twice on one of them).- At most 10 expected fields.
- Each field must be stored on the model's own table (not a computed field, not inherited from a delegation parent such as
res.users.name, not company-dependent: its value can come from a default the row lock does not cover), of type selection, char, text, integer, float, monetary, boolean, date, datetime or many2one (compared by id), inside the grant'sfields(every one named, understrict_paths; the grant must hold thereadop: the STATE_CHANGED answer is a read) and not hidden from the user. html, binary, reference, properties, json and the x2many fields are refused. - A value must be what the field holds: text for selection, char and text; a whole number for integer and many2one; a number for float and monetary;
trueorfalsefor boolean; a date as text;nullfor empty. - Archived records are guarded like any other, except under a grant that has a
domain: the platform's existing domain check on ids (the same forread,writeand a plaincall) searches active records only, so an archived record fails it closed before the guard runs. The guard itself reads and locks archived rows. - A declared
read_callscall cannot be guarded (it runs read-only). write_dateis just another field:expect: { write_date: "2026-10-18 12:00:00.123456" }is a version check that is also locked.
Retries with an idempotency key
A retry under the same idempotencyKey on the same path (orm.call / callOn) returns the recorded result of the first call and does not check expect again: the operation already happened, and the record holds a different state precisely because of it. A call that failed (STATE_CHANGED, BUSY, any error) leaves no record under its key, so the retry runs the guard afresh. The same holds when the key was recorded by the declared action that runs the same method (actions.run, 5.109): the recorded result is answered, nothing runs, so there is nothing to guard.
What it does not do
- It does not make the native method idempotent or safe. It guarantees only that the records are still in the state the app saw when the method starts.
- It cannot protect against a method that commits by itself (rare in Odoo): the commit ends the transaction and its locks.
- It does not lock the rows of other models the method touches.
Vizum.orm.write(..., {expect})(5.43, compare-and-set) is a different, older option forwriteand answers{updated, current}; a guarded call raises instead, because a method that did not run must not look like one that did.
Testing without a platform
VizumMock.orm.current = { "purchase.order": { 7: { state: "purchase" } } } makes the mock's call and callOn compare expect against it and reject with a STATE_CHANGED error (code, details); without it the mock resolves as before. vzm check validates the shape of a literal expect (a non-empty object, at most 10 keys, scalar values) and that min_platform is at least 5.135.
SDK & server
Background jobs and resource limits — big exports and rebuilds
One call to the bridge is limited to a number of values, measured for each instance (5.88): by default 1,000,000, and 500,000 on odoo.sh and on small instances. A read or grouped read over that limit is refused with QUOTA_EXCEEDED, and the error message gives the page size that fits.
For work that needs more than one call can carry, use a background job. Examples are exporting a whole table, or rebuilding a dataset or report from every record. A background job runs on the instance's cron worker, never on the HTTP worker that serves the people using Odoo.
// manifest.json: "min_platform": "5.90", "permissions": { "orm": [{ "model": "sale.order.line", "ops": ["search", "read"] }] }
if (Vizum.supports("bulk-jobs")) {
let job = await Vizum.bulk.export("sale.order.line", {
fields: ["order_id", "product_id", "product_uom_qty", "price_subtotal"],
domain: [["state", "=", "sale"]],
format: "csv", // or "jsonl"
name: "order-lines.csv", // one of your app's files
});
while (!["done", "failed", "cancelled"].includes(job.state)) {
await new Promise((r) => setTimeout(r, 3000));
job = await Vizum.bulk.status(job.id); // {state, position, rows, total, progress, ...}
}
if (job.state === "done") {
const blob = await Vizum.files.getBlob(job.file);
}
}
| call | needs | result |
|---|---|---|
Vizum.bulk.export(model, {fields, domain?, format?, name?}) | search + read on the model in permissions.orm | the job; when it is done, job.file is the name of one of your app's files |
Vizum.bulk.rebuild(model, {fields, domain?, hook, params?}) | the same, plus permissions.logic: "script" | the job; your server hook hook gets {rows, chunk, last, job: {id, params}} for each chunk |
Vizum.bulk.status(id) | the job is yours | {id, kind, model, state, position, rows, total, progress, chunks, file, error, …} |
Vizum.bulk.list() | — | your last 20 jobs for this app |
Vizum.bulk.cancel(id) | the job is yours | a waiting job is cancelled at once; a running one stops at its next chunk |
More than 5,000 records in one search (5.92)
One search or search_read returns at most 5,000 records. Before 5.92 a bigger limit was cut to 5,000 without a word, so the rest were simply missing. From 5.92, an app whose manifest declares min_platform 5.92 or later is refused with QUOTA_EXCEEDED, and error.details = {kind: "limit_too_large", asked, page: 5000}. An app that declares less keeps the old cut, so nothing shipped breaks. Read more in pages:
async function all(model, domain, fields, order = "id") {
const out = [];
for (let offset = 0; ; offset += 5000) {
const page = await Vizum.orm.searchRead(model, domain, fields, { limit: 5000, offset, order });
out.push(...page);
if (page.length < 5000) return out;
}
}
The order must end on id, so that every row has one fixed place. vzm check reports a literal limit above 5,000 (LIMIT_ABOVE_PAGE, also a quoted "limit" key and the hooks written inline in the manifest): a warning below 5.92, an error from 5.92. A literal that only reaches the bridge through your own paging helper is reported too — the check reads the number, not the helper. For a whole table, use a background job instead.
Where the rule applies, for an app that declares 5.92:
| Path | Above 5,000 |
|---|---|
orm.search, orm.searchRead (every host: Desktop, Solo/PWA and Android, apps opened from an Odoo menu) | QUOTA_EXCEEDED, details.kind = "limit_too_large" |
orm.call of name_search with a limit | the same |
a server hook's V.orm.search / V.orm.search_read | the same (the hook's error) |
searchRead answered from the device's offline copy (no connection) | the same, before the copy is read |
orm.read by ids, orm.readGroup | not a 5,000 rule: the value ceiling below, QUOTA_EXCEEDED without details.kind |
offline datasets, Vizum.bulk, data feeds | paged by the platform; a dataset may declare up to 20,000 rows |
How a job runs
- As you. It reads through your app's ORM grant, with your ACLs and record rules, exactly like
Vizum.orm. The columns can be scalars or many2one fields. A many2one gives two values, the id and<field>__name, as in a data feed. - In chunks. Each chunk is a keyset page (
id > last), sized so rows × values stay within a quarter of the instance's value ceiling, at most 5,000 rows. Every chunk is committed. If the server restarts, the job resumes after the last committed chunk. A cron pass spends at most half of the cron's time limit (10 to 120 s) and then hands over to the next pass. - One at a time. The instance runs one job at a time, first come, first served.
job.positionis the job's place in the queue. - Back-pressure. At most 3 jobs can be waiting or running for one app, and 10 on the whole instance. One more is refused with
RATE_LIMITED, which is retryable: try again when a job has finished. - Exports write one of your app's files, under the same name rules and quotas as
Vizum.files. A file over your app's quota makes the job fail with that reason. - Rebuilds call your own server hook (declared in
native.script.hooks) for each chunk.lastistrueon the final call, which can have no rows. Write what you build into your app's own models.
Who is told when something is too big
When a bridge read or grouped read is refused for its size, or a background job fails:
- the user gets a desktop toast, at most once per app and model per hour;
- the administrators get one Odoo activity (a to-do) per app and kind. A new one is not scheduled while one is still open, and never more than once an hour. The activity sits on a *Vizum App Resource Event* record that counts the events;
- e-mail goes to the administrators only when one of them turned it on in *Settings › Vizum › Resources*;
- telemetry gets an aggregated daily count (
rgov.refused.read,rgov.refused.read_group,rgov.job.done,rgov.job.failed,rgov.feed.busy), with no app, no user and no data.
When an export finishes, the user also gets a toast.
Settings › Vizum › Resources
Administrators see what the platform measured on the server and the limits that follow:
- whether Odoo runs in prefork or threaded mode;
- the number of workers;
- the hard memory limit per worker;
- the memory available to the instance (its cgroup, else the machine);
- whether it runs on odoo.sh.
From there they can set:
| setting | system parameter | 0 / empty |
|---|---|---|
| Values per bridge read | vizum_apps_platform.bridge_value_ceiling (10,000–5,000,000) | 1,000,000; 500,000 on odoo.sh and small instances |
| Data-feed pages at once | vizum_apps_platform.feed_concurrency (1–8) | 1 on odoo.sh and small instances, 2 otherwise |
| E-mail about over-budget events | vizum_apps_platform.rgov_notify_email | off |
An instance counts as small when any of these applies:
- a worker's hard memory limit is under 2 GiB;
- the instance has at most 4 GiB of memory;
- it runs prefork with one or two HTTP workers.
Threaded mode (workers = 0) is judged by memory only.
See also
- data-feeds.md: Power BI and other BI clients that refresh outside your app.
- capabilities.md: 5.88 (the ceilings), 5.89 (feeds), 5.90 (this page).
SDK & server
Offline data — keep working with no coverage PRIVILEGED
A field user opens Vizum with no coverage. The shell and the app open. The app reads its route, customers, products and stock from a copy kept on the device, and records deliveries, sales, collections, notes, photos and signatures. When coverage returns, the work goes to the server in order, exactly once, through the same permission and validation checks as a live call. Anything the server refuses, and anything someone else changed in the meantime, appears in a list the user settles.
This page is the reference. The shorter story of what works offline today, on each host, is offline-pwa-mobile.md. The template offline-field-app (vzm new offline-field-app my-app) is a working app that uses all of it, with tests.
Hosts: the desktop and Vizum Solo (the PWA and the Android app). The native client-action host has no offline layer. Gate on min_platform: "5.80".
The three parts
| Part | What it is | Declared in |
|---|---|---|
| Datasets | named, approved slices of the user's own Odoo data that the device keeps and Vizum.offline.query reads with no network | offline.datasets |
| Queued writes | creates and writes the app may record with no network, replayed when the network is back | offline.writes |
| Actions | approved business actions queued with Vizum.offline.run (older than 5.80, unchanged) | actions |
The manifest
"min_platform": "5.80",
"permissions": {
"orm": [
{ "model": "x_vzm_dsd_visit", "ops": ["read", "write"], "fields": ["name", "x_state", "x_note"] },
{ "model": "sale.order", "ops": ["read", "create"], "fields": ["partner_id", "order_line", "amount_total"] }
]
},
"offline": {
"datasets": [
{ "name": "stops", "model": "x_vzm_dsd_visit", "fields": ["name", "x_state", "x_seq"],
"domain": [["x_driver_id.x_user_id", "=", "{uid}"], ["x_scheduled", ">=", "{today-1}"]],
"order": "x_seq", "limit": 500 }
],
"writes": [
{ "model": "x_vzm_dsd_visit", "ops": ["write"], "fields": ["x_state", "x_note"] },
{ "model": "sale.order", "ops": ["create"], "fields": ["partner_id", "order_line"], "expect": ["amount_total"] }
],
"fallback": false,
"max_mb": 20
}
The rules, enforced when the app installs (vapp_validation.py), when you run vzm check, and when the Store accepts an upload:
- Every dataset and write model is one the app has an orm grant for (the grant must carry
readfor a dataset, and the op for a write), or one of its own native models. The"*"wildcard grant does not count: an offline copy is an explicit decision about a model. A grant whose extra ops are scoped to groups (group_ops, 5.78) counts for those ops. - Dataset
fieldsstay inside the grant's field list. Writefieldsstay inside its write ceiling. Achatter_onlygrant has no field to write offline. - At most 12 datasets (names
[a-z][a-z0-9_]{1,40}, unique),limitat most 20 000,max_mbat most 100 (default 20), at most 24 write entries, at most 40 conditions in a domain. - A dataset
domainuses only these placeholders, each as a whole value:{uid},{company_ids},{today},{today-N},{today+N}. The server expands them. Any other{...}is refused. expectbelongs to a write entry withcreate. It names computed fields (a total, a price) the server compares at replay.min_platformis5.80or later.- Every
actions[].modelis an own native model, a model the app adds native fields to, or has an orm grant. (An action on an ungranted model was refused on every run before this check existed.)
offline is part of the approved permission snapshot. The approval screen lists the datasets. Any edit of it sends the app back to needs_approval; removing it only narrows.
The API
const sets = await Vizum.offline.datasets(); // [{name, model, fields, limit, count, lastSync, stale}]
await Vizum.offline.sync(); // every dataset; sync("stops") for one
const { records, count, as_of, stale } = await Vizum.offline.query("stops", {
domain: [["x_state", "=", "todo"]], order: "x_seq", limit: 50, fields: ["name", "x_state"],
});
const groups = await Vizum.offline.readGroup("stops", { groupby: ["x_state"], aggregates: ["x_seq:sum"] });
const made = await Vizum.offline.create("sale.order", { partner_id: 7 }, { expect: { amount_total: 120 } });
// -> { id: "vzm-tmp:...", queued: true, opId } made.id is a TEMP id
await Vizum.offline.write("x_vzm_dsd_visit", [4, made.id], { x_note: "gate code 4471" });
// -> { queued: true, opId, opIds }
const issues = await Vizum.offline.conflicts();
await Vizum.offline.resolve(issues[0].id, "keep_mine"); // or "keep_server", "discard"
Vizum.offline.onSync((r) => refresh()); // a dataset finished syncing
Vizum.offline.onConflict((c) => badge()); // a queued change came back as a conflict
Vizum.offline.onAppUpdated((u) => saveDraft()); // SDK 5.95: {running, installed}, see "Versions"
Plus the older status(), on(), run(), pending(), flush(), onFlushed().
Vizum.offline exists in every app — its presence grants nothing. The SDK defines the namespace for every app; the host refuses create / write unless the app's approved offline.writes declare that model and op, and datasets / sync / query / readGroup / conflicts / resolve without approved offline.datasets. The refusal is PERMISSION_DENIED in the browser, before any server request. So if (Vizum.offline) await Vizum.offline.create(…) is wrong; decide from the approved caps that connect() resolves with — the snapshot the host gates with:
const { caps } = await Vizum.connect();
const queued = (caps.offline?.writes || []).some((w) => w.model === model && w.ops.includes("create"));
const id = queued ? (await Vizum.offline.create(model, vals)).id : (await Vizum.orm.create(model, vals))[0];
This matters most for ONE bundle served under two manifests (a desktop Panel and a Field companion): CattlePro 1.13.0 queued every Panel write offline and «Load sample data» answered PermissionDenied. vzm check reports such a call as BRIDGE_GATE_REFUSED (cli).
Reads. query answers from the device: = != < <= > >= in not in like ilike =like =ilike, & | !, order, limit, offset, fields. A dotted path (partner_id.country_id), child_of and any are refused rather than answered wrongly. A field the dataset does not hold is refused: asking for one would return the wrong rows. readGroup takes one or two groupby terms (date fields with :day, :week, :month, :quarter or :year) and the aggregates field:sum|avg|min|max|count|count_distinct. A row with a queued change carries pending: true.
Writes. create and write need a matching entry in offline.writes (model, op, every field) and, on the desktop, ask the host's write confirmation a live write asks (Solo relies on the server's check at replay). They return as soon as the op is safely on the device's disk. If the device cannot store it (storage full, op over 1 MB) the call rejects and nothing is queued. A write of several ids queues one op per id.
Read-your-writes. A queued create or write is applied to the local rows at once, so the next query shows it. If the server refuses it, the change is rolled back and the refusal is listed.
Temp ids. create returns vzm-tmp:.... Use it in later write calls and in later values (a many2one that points at the new record). The server never sees one. When the create replays, the host replaces the temp id with the real id in every later queued op, in order. A later op that needs the id of a create the server refused is listed as dependency and not sent.
Actions keep the order of the queue. Vizum.offline.run(action, params) while online goes straight to the server only when the app has nothing waiting. If it still has queued changes (creates, writes or earlier actions), or params holds a temp id (vzm-tmp:...), the action joins the queue behind them, so it never overtakes what it follows and the server never sees a temp id (the replay swaps in the real id). The host then drains the queue at once and the call answers like a live run, with the server's { ok, result }, when the drain reached the action in that call. When it could not (no connection, an expired session, another tab or the Android worker holds the queue), it answers { queued: true, id } and the action replays later, in order. A server refusal of the action itself rejects the call, as a live run does. On the desktop the single confirmation names that path ("runs after your N earlier changes waiting to be sent").
flush() means everything queued before the call has been attempted. A flush() made while a drain is already running waits for that drain and then drains what was queued meanwhile, once. A drain that kept its ops (offline, expired session) is not repeated by the wait; the retry timer owns them.
params.expect on offline.run is not part of the API in 5.80. The replay forwards a non-empty object under that name to the server as the action's expect check, and also leaves it inside the action's params; only the queued path does that, a live run sends it as an ordinary param. Do not rely on it. Use expect on Vizum.offline.create, which is declared in the manifest.
Fallback (opt-in). With offline.fallback: true, Vizum.orm.searchRead, read, searchCount and readGroup on a model a dataset covers answer from the device when there is no connection (or the connection drops mid-call), and the result carries a hidden meta property {offline: true, as_of}. Only reads the copy can answer faithfully are served: the fields asked for must be in the dataset, and a stale copy never answers. Everything else fails as it always did.
What syncing does
The host syncs on start, when the network returns, when the app opens (at most once a minute per app), when the Android app resumes, when a hidden page comes back to the screen (unless a run started a minute ago), and every 15 minutes while the page is visible. A run that could not pull because of the connection tries again after about 30 seconds, then 1, 2 and 5 minutes (each spread by 20%, the last repeating), and stops when a run succeeds. A change you make while online is sent at once, and one that failed in transit is retried after 5 seconds, then 15 s, 1 min and 5 min. A run first sends the queue, then pulls the datasets.
The device sends a dataset name and a token. The server reads model, fields and domain from the approved snapshot and runs the read as the user (grant, field ceiling, record rules, multi-company). Nothing from the client shapes the query. The first pull is full; later pulls bring only the rows written since the last one (with a ten minute overlap), and the answer always carries the complete list of ids of the domain, so a row that was deleted or left the domain, or that a rule now hides, is removed from the device. The token is signed and bound to the user, the app, the dataset, the approved manifest, the grant and the user's companies and groups. Change any of them and the next pull is full.
What the server code learns about a replay (5.110)
A method, hook or route that runs because of a queued op can call V.offline_replay(): the server's receive time and the first time it saw the op's key. It does not prove the device was offline — a client can label any call as a queued replay. See server-side.md, «V.offline_replay()».
Conflicts
A queued write carries base: the values the device had for the fields it changes (the values the server last sent, not an unsent edit). Before writing, the server reads those fields under a row lock and compares them, type-aware (floats with the field's digits, money with the currency's rounding, many2one by id, x2many by sorted ids, dates normalised, text by equality). If any differs it answers CONFLICT and writes nothing. A field the write does not change never conflicts. The comparison never uses write_date.
A queued create with expect is checked after it ran, inside a savepoint: if a computed value differs from what the device showed, the create is undone and answers CONFLICT.
Every queued op is sent with its id as the idempotency key, and the server remembers each create, write and action that succeeded (30 days for a queued op). A page that reloads while the queue is sending, or two flushers working on the same queue (the Android background worker and the open app), can send an op again whose answer was lost. If that op already landed, the server answers with the result it remembered and runs nothing, even when later ops have moved the record on since (the write set "arrived", a later one set "done"): a re-sent write never conflicts with work that is already done. Only a success is remembered: a write the server refused with CONFLICT is checked again on every re-send. The same key sent with different values is refused with IDEMPOTENCY_MISMATCH. A live write (not from the queue) is not remembered: it runs every time.
Sent online first, queued if the answer is lost (5.109). An app that sends something online with Vizum.orm.callOn(model, ids, method, args, {idempotencyKey: k}) and, when that answer is lost, queues the same thing as the declared action whose target is that method (Vizum.offline.run(action, {ids, ...}, {idempotencyKey: k})) sends ONE operation with min_platform 5.109: whichever reaches the server second answers what the first one did and runs nothing — even when the first one changed the record so that the user could no longer change it (a proof that seals its stop). The two match on the records and on the arguments bound to their names by the method's own def ([payload] and {payload} are the same call of def method(self, payload=None)); a method that takes *args / **kwargs never matches (vzm check warns, IDEM_UNBINDABLE). The second path still runs its own checks — the grant, the action's approval, the online call's «App action» answer, the grant's domain, and READ on the records — so a user who can no longer read the record gets the refusal, not the stored result. The full rule is in platform-contract.md, «Request ids, retries and idempotency».
A conflict leaves the queue and goes to the list Vizum.offline.conflicts() returns, and to the shell's panel:
{ id, model, ids, method: "write" | "create" | "action",
reason: "conflict" | "rejected" | "dependency" | "deleted", // "deleted": SDK 5.86
message, ts, fields: { x_state: { base: "todo", server: "cancelled", mine: "done" } },
canRecreate? } // only with reason "deleted"
The row on the device shows the server's value. The user chooses:
| Choice | Effect |
|---|---|
keep_mine | the same change is queued again over what the server holds now (a new base, a new expect) |
keep_server | the change is dropped; the server's value stays |
discard | the change is dropped |
A record deleted on the server (SDK 5.86, Vizum.supports("offline-deleted")). A queued write (or action) on a record that no longer exists answers CONFLICT with details.deleted; it is never recreated silently and never lost silently. The device removes the row it held (no ghost row until the next sync), the op leaves the queue, and the issue is listed with reason: "deleted" and an empty fields. The choices:
| Choice | Effect on a deleted issue |
|---|---|
keep_mine | recreate: one create from the row as the user last saw it, edits included, queued under a new temp id. Only when the app declares create on that model in offline.writes (canRecreate); otherwise it is refused with PERMISSION_DENIED and the issue stays |
keep_server, discard | the record stays deleted and the change is dropped |
Several edits queued for the same deleted record are one decision: resolving one issue settles all of them, and a recreate makes one record, not one per edit. On a platform older than 5.86 the same case is listed as rejected.
Relation fields written with commands (many2many / one2many) travel as written: [[6, 0, ids]], [[4, id]], [[3, id]], [[5, 0, 0]], [[0, 0, vals]]. The device shows the result of 6, 5, 4, 3 and 2 at once (a 0 makes a record whose id it cannot know, so the list shows no new id). The base of a relation is its id list, and the server applies the commands over it to tell a retry of a write that already landed (no conflict) from a relation someone else changed (conflict). A temp id inside a command is replaced like any other, and the rows on the device that held it are rewritten with the real id as soon as the create lands.
Any other permanent refusal (validation, access) is listed with reason: "rejected" and the server's message; keep_mine retries it. A lost connection, a timeout, a rate limit and an expired session are not refusals: the op stays queued and the queue waits.
When the world moved while the device was offline
| What changed | What the replay does |
|---|---|
| the record was deleted | a deleted issue (above) |
| the user lost the model's access, or a record rule now forbids the create | ACCESS_DENIED, a rejected issue; nothing is created |
| the user lost access to the company the op was queued in | ACCESS_DENIED, a rejected issue. The device remembers the company the copy was last read in and sends it with every op it queues; the replay runs in that company, so a create never lands in whichever company is first today. Actions queued with offline.run carry no company (they run in the company active at replay) |
| the app was updated and waits for an administrator's approval | the queue waits: the server answers CONCURRENT_UPDATE (retryable), the ops and their local changes are kept, and they replay against the new approved declaration once it is approved. Nothing is rejected on the way |
| the app was updated and the declaration changed | the replay reads the approved declaration of the moment: an op naming a field, model or op that left it is refused whole, never half-written. Since 5.95 the refusal is typed (OFFLINE_STALE, see Versions below); before, it was ACCESS_DENIED / VALIDATION_FAILED and a rejected issue |
| a dataset was removed or its definition changed | the device drops the copy of a removed dataset; one whose definition changed (5.95: its sig, which covers the fields and their types, the domain, order, limit and grant; before, the model and field list) keeps only the rows a queued change still rules and reads as empty and stale until the next sync, which is full (the token is bound to the declaration and, since 5.95, to the field types) |
| another user signs in on the device | their queue and issues are in their own database and never replay as anyone else; the rows of every other user, in this Odoo database or another, are removed |
Every replay outcome is audited on the server (platform:offline.applied, .conflict, .failed, .mismatch: counts per app, the kind, the model and method, the exception class on a failure; never a value, a record id or a message).
Versions: when the app moved while the device was offline (SDK 5.95)
The required case: a device works offline on app v1 and queues changes; meanwhile an administrator updates the app to v2 (fields, models, permissions or dataset definitions changed), or a native module migrates; the device reconnects.
- Every op says what it was made under. A queued
createorwritecarries the app version the page ran and the type of each field it sets, as the server described them (configlistsapp_versionand, per write entry,types). The replay sends both. - The replay checks the op against the server as it is now, one op at a time:
details.reason | when |
|---|---|
model_removed | the model no longer exists |
field_removed | a field the op sets no longer exists on the model (removed or renamed: the server cannot tell a rename, and applying the old name is never safe) |
field_type_changed | a field the op sets has another type than when the op was queued: its value would not mean what the user entered |
unstamped | the op carries no version stamp, or no types entry for a field it sets (a queued action is stamped by its version alone, which the host puts on the op and sends in the context, never as an action parameter) (queued before the device was updated to 5.95, or by a client that omits the stamp): the server cannot tell which schema it was made under, so it refuses before anything runs. Rollout grace: an op with no version at all keeps syncing until the app it targets is installed, updated or repackaged (version or package digest) after the instance first ran 5.95, or a field it sets has another Odoo type than in a snapshot taken once at that instant (a native module upgrade counts); every other check still runs (audited platform:offline.unstamped_grace) |
action_removed | a queued offline.run action the new version no longer defines |
permission_narrowed | the app's offline writes, its grant, its field ceiling, its approved actions, or the user's access rights no longer allow the op (they are read at replay, not at queue time) |
An op still valid under v2 is applied, normally (and its audit row names the version it was queued under). One that is not is refused with OFFLINE_STALE and that reason: never applied with the old meaning, never retried, never dropped silently. Ops behind it keep going.
- The device lists it in Sync issues. The local change is undone and
Vizum.offline.conflicts()lists the op with itsreason,stale: {fields, queuedVersion, installedVersion}andvalues(what the user entered: the only copy left). Sending it again would meet the same refusal, soresolveaccepts onlydiscard; the desktop dialog and the Solo panel offer Export (a.jsonfile with the values and both versions) and Discard. An app can offer its own export fromvalues. - The datasets follow. Each dataset carries
sig, the server's signature of its definition. A copy read under anothersigis dropped (rows a queued change still rules stay) and refetched in full. - The page learns it is out of date. When the device reconnects and syncs, the host learns the version installed now. If the page runs another one, the app receives
offline:app_updated(Vizum.offline.onAppUpdated(cb),{running, installed}) and the host offers the user a reload, once per version: v1 code must not keep working on v2 data. Save drafts there.
Not in 5.95 (gated decisions of the Master Program audit, A9 and ADR-0003): queued files and encryption at rest.
Upgrading to 5.95. Ops a pre-5.95 client queues without a version stamp keep syncing until the app they target is updated, or a field they set changes type, on that instance. After that they land in Sync issues (unstamped; Export/Discard). On instances with Android Solo users, do not update apps until APK 1.34 is installed on their devices.
Users, logout and what stays on the device
- Every replay carries the user it was queued under (
context.vizum_offline.uid). If another user's session answers, the server raisesOFFLINE_USER_MISMATCH; the host keeps the op and pauses the queue until the same user is signed in again. Nothing replays as someone else. - Logout and a change of user remove the dataset rows and their metadata. The queue and the unresolved conflicts stay, tied to that user, and finish the next time they sign in on the device. At start, the rows of every other user on the device are removed.
- Logout warns and never discards. On Solo (the PWA and the Android app) and in the Odoo web client's Log out menu, signing out with changes still waiting to be sent or sync issues not yet resolved shows a warning with both counts, says they stay on the device and are sent when the same user signs in again, and signs out only when the user confirms. Choosing to stay keeps the session and the copy. With nothing pending there is no warning. A sign-out that starts elsewhere (the desktop's strict lock screen, a direct visit to
/web/session/logout) shows no warning, but discards nothing either. - A dataset whose last successful sync is older than 7 days reads as empty and
stale: trueuntil a sync succeeds. - The device keeps nothing the user may not read: sync is the user's own read.
- There is no app-level encryption in 5.80. Rely on device encryption, the per-user wipe and the 7 day limit. A shared, unlocked device shows one user's data to the next person who opens the app before logout.
Limits
| Limit | Value |
|---|---|
| datasets per app | 12 |
| rows per dataset | limit, at most 20 000 |
| space per app and user | max_mb, default 20, at most 100 (rows, queue and issues together) |
| one queued op | 1 MB (shrink photos to about 100 KB before queuing) |
| records per queued write | 1 (a write of N ids queues N ops) |
| age before a copy is hidden | 7 days |
| idempotency memory on the server | 30 days for a replayed op (8 days for a live call) |
A pull that does not fit in max_mb is refused (QUOTA_EXCEEDED) and the old copy stays. The queue is never refused to make room for rows.
Errors
| Code | When |
|---|---|
PERMISSION_DENIED | the call is not in the approved offline declaration (model, op, field, expect) |
VALIDATION_FAILED | a field outside the dataset, an operator the device cannot evaluate, an op over 1 MB, a malformed call |
NOT_FOUND | a dataset that is not declared; a conflict that is already resolved |
OFFLINE | sync with no connection |
QUOTA_EXCEEDED | the dataset does not fit, or the device could not store the op |
CONFLICT | (server, at replay) the server's values differ from base or expect, or the record was deleted (details.deleted, SDK 5.86) |
CONCURRENT_UPDATE | (server, at replay) the app waits for approval of an update: the op is kept and sent later |
OFFLINE_USER_MISMATCH | (server, at replay) the op was queued for another user |
OFFLINE_STALE | (server, at replay, SDK 5.95) the server moved while the device was offline; details.reason says why (see Versions above) |
Android and Solo
On Solo (the PWA and the Android app) the layer works as described. On Android (app 1.32 and later), offline.run actions and the creates and writes made with Vizum.offline.create and write are also copied to the app's own storage and sent by a background worker when the app is closed, in the order you made them, with temp ids replaced by the real ids of the creates before them. What the server refuses, and a change someone else made meanwhile, reach your app as conflicts the next time it opens. See offline-pwa-mobile.md.
The background worker sends the 5.95 version stamp from the next Android app build (after 1.33), and lists a stale refusal with its typed reason. With 1.33 and earlier it sends ops without the stamp: the server still refuses a removed model or field and a narrowed permission per op, but cannot tell a field whose type changed, and the worker lists any such refusal as rejected.
An op that already ran under its idempotency key (a create, a write or an action whose answer was lost) replays its recorded result after an update; it is never refused as stale.
Testing
vzm test runs your app against the emulator, which keeps an in-memory device store:
await Vizum.offline.sync();
emulator.offline(true); // no network
await Vizum.offline.write("res.partner", [1], { phone: "mine" }); // queued, applied locally
emulator.serverChange("res.partner", 1, { phone: "theirs" }); // someone else, meanwhile
emulator.offline(false); await emulator.settled(); // the queue replays
emulator.serverUpdate(v2Manifest, { removedFields: { "res.partner": ["comment"] } });
// SDK 5.95: the "server" moves to v2 while the page runs v1; queued ops replay against it
// (typed issues for what it refuses, offline:app_updated when the emulator comes back online)
const [issue] = await Vizum.offline.conflicts(); // a conflict, with both values
emulator.offlineState() returns the queue, the issues and the rows. fixtures.computed[model] is a function (vals) => ({field: value}) that plays the server's computed fields for expect. The emulator does not model the 7 day limit, the quota, storage failures, the scheduler (call sync() yourself) or record rules.
vzm check refuses an offline declaration or an action model the platform refuses, and any Vizum.offline.<data method> call below min_platform 5.80.
SDK & server
Files — checksums, checked types, expiring links
*Since capability 5.67. Vizum.files itself: since SDK v2; public links since 5.27.*
Vizum.files keeps an app's binary files (per user, or shared when the app's data scope is global): 64 MB per file, 50 files, 128 MB in total. Since 5.67 the platform also knows what each file is, and a public link no longer lives forever. Since 5.70 an app may ask for more (permissions.file_limits, up to 200 MB a file; since 5.71 up to 512 MB, see *Large files, above 200 MB* below) and upload large files streamed with Vizum.files.putBlob(name, blob) — see Paired-device routes. A file a paired device must play goes into a record's binary field instead, with Vizum.orm.writeBlob(model, id, field, blob) (same streaming; a device file route streams binary fields, not Vizum.files). The server holds an upload's body in memory (up to the app's max_mb) and allows one streamed upload per user at a time; the host refuses a file above the approved max_mb before it sends it.
Checksums
Every stored file has its sha256, recorded when it is stored and returned by put, get and list. Send the checksum you computed, and a file damaged on the way is refused instead of stored:
const bytes = new Uint8Array(await blob.arrayBuffer());
const digest = await crypto.subtle.digest("SHA-256", bytes);
const sha256 = [...new Uint8Array(digest)].map((b) => b.toString(16).padStart(2, "0")).join("");
let bin = "";
for (const b of bytes) bin += String.fromCharCode(b);
const { mimetype } = await Vizum.files.put("scan.pdf", btoa(bin), { sha256 }); // base64 of the same bytes
A file stored before 5.67 lists sha256: null until it is next read; get computes and keeps it.
The bytes must be what the name says
For the types whose bytes the platform can recognise — PNG, JPEG, GIF, WebP, BMP, AVIF, HEIC, ICO, SVG, PDF, ZIP (and DOCX/XLSX/PPTX/ODT/ODS/EPUB, which are ZIP), GLB, gzip, MP4 — put checks the content against the name (the first 16 KB: an SVG after a long XML prolog and a PDF with a preamble are recognised):
| Stored as | Content | Result |
|---|---|---|
photo.png | a PNG | stored as image/png |
photo.png | a WebP or JPEG | stored, typed by its content (image/webp) — it still displays |
photo.jpg | a HEIC (an iPhone photo) | stored, typed image/heic |
photo.png | HTML, SVG, a PDF, or nothing recognisable | refused, VALIDATION_FAILED |
photo.tiff (any name the host's mimetype table calls image / audio / video / font / model) | markup (HTML / SVG) | refused — a media name never holds markup |
notes.txt, data.json, model.stl | anything else | stored with the type the host's mimetype table gives the name, as before |
The content check is one signature table (models/vizum_app_file.py), the same on Odoo 17, 18, 19 and 20; Odoo's own sniffer is not used for it, because it answers differently per line and per host. Only the names outside that table rely on the host's mimetype table (Python's mimetypes, which reads the system's /etc/mime.types): .tiff is a media type everywhere, .stl only where the system lists it.
Public links expire
publicUrl (permission files_public) now mints a link that expires:
const { url, expires_at } = await Vizum.files.publicUrl("post-42.png", { expires_in: 3600 });
expires_inis in seconds, from 60 to 2,592,000 (30 days). The default is 86,400 (one day).- A file has one live link. Minting again returns the same link, and its end moves to the later of the two, so a link you already handed out never dies earlier than promised.
- An expired link is refused when it is opened. A cron then removes its token (every 15 minutes), and minting after expiry gives a new token: the old URL never works again.
revokePublicstill kills the link at once.- Overwriting the file ends its link (VZ-SEC-40). A
put, aputBlobor a chunked upload over an existing name removes the token: the old URL is refused from then on, even when the new bytes are identical, andpublicUrlmints a new link that serves the new bytes. Before, the link kept working and served whatever the name held now — a document stored under a reused name reached whoever held the link to the previous one. An app that reuses names (invoice.pdf,latest.png) callspublicUrlagain after everyput; one that hands out links to separate documents gives them separate names. - A server script mints the same link with
V.files.public_url(name, expires_in=3600): the same op, the same bounds and the same refusal (server-side.md).
Migration. Before 5.67 a link lived until it was revoked. Links minted before the upgrade keep working until revoked. The one-day default applies to apps whose min_platform is 5.67 or later; an app published before that asks for no expires_in keeps getting a link that lives until revoked (expires_at: null), so a link it stored and reuses later keeps working. Any app that passes expires_in gets that expiry. If a link must outlive a day, ask for it — up to 30 days — and mint again before it ends.
Files served through a link carry Odoo's Content-Security-Policy: default-src 'none' and X-Content-Type-Options: nosniff (17, 18, 19 and 20): an HTML or SVG file behind a link renders as a document that cannot run script.
Large files, above 200 MB (since 5.71)
5.70 reads one request body into a worker, so 200 MB is its ceiling. 5.71 adds three things, all driven by the desktop host (the app's iframe has no session, so it keeps calling the same Vizum.files.* methods):
"permissions": { "data": "global",
"file_limits": { "max_mb": 400, "total_mb": 8192 } }
An app that asks for max_mb above 200 sets min_platform to "5.71" and a total_mb of at least max_mb (the default is 128, which could never hold such a file): the platform and the Vizum Store refuse the manifest otherwise.
One file per name (dedupe). From 5.71 a unique index holds one stored file per (app, name). Two puts of the same *new* name at the same moment no longer leave two rows: the second request is replayed, finds the first one's file and replaces it. A 5.70 database could already hold such duplicates, so the 5.71 upgrade keeps the lowest-id row under the original name and renames every other copy <name>.dup-<id> (nothing is deleted; they show in Vizum.files.list() for the user to inspect and delete).
1. A 512 MB ceiling that needs its own approval. file_limits.max_mb may go up to 512, but a value above 200 is a separate grant the platform derives (permissions.large_files). The approval card reads it out as *"This app asks for files up to 400 MB, 8.0 GB per user"*, and gaining it later (or asking for a higher max_mb) sends the app back to the administrator. Without that approval the effective limit stays 200 MB, whatever the manifest says. And a file above 200 MB is refused (QUOTA_EXCEEDED; the grant and the cap are checked again at commit) unless the administrator also set an explicit storage cap for the app (Apps Manager → Usage & budgets, storage_mb_cap). The operator can also set an instance-wide ceiling on every app's files together, the system parameter vizum.apps.storage_total_mb_cap (MB; unset or 0: no ceiling, behaviour unchanged). It is checked on every put, and counts the bytes other uploads have reserved.
2. Chunked upload. Vizum.files.putBlob(name, blob, opts) uploads a blob larger than 32 MB in parts (the host sends 8 MB parts; the server accepts up to 32 MB; a smaller blob is the single request of 5.70). Nothing changes in the app:
const result = await Vizum.files.putBlob("lobby-loop.mp4", file, {
sha256, // optional; hashed in blocks by the host if omitted
onProgress: ({ phase, sent, total }) => bar(phase === "upload" ? sent / total : 0), // phase: "hash" | "upload"
}); // { name, size, mimetype, sha256 }
What the platform does, in order (/vizum/apps/files/<key>/upload/…, the same session and same-site proof as putBlob):
begin {name, size, sha256}returns a token bound to this user, this app, that size and that sha256; it expires one hour after its last accepted part (six hours at most since begin, so a slow link keeps going), and a user has at most two uploads open.beginalso refuses when the server's disk cannot hold every open upload plus a margin. The declared size is reserved at begin against every quota (per-file ceiling, count, per-user total, the app's storage cap, the instance ceiling), so parallel uploads cannot together exceed them (the checks serialise on row-writing locks, which hold under PostgreSQL's repeatable read). The reservation ends with the upload: commit, abort or expiry.part?token&ntakes the parts in order (a part out of order, empty, over 32 MB, or taking the total past the declared size is refused). They are appended to a temp file under the database's filestore (<filestore>/vizum_uploads/<token>), not held in memory. The temp file lives on one server: behind a load balancer the upload needs a sticky route, as the session does.- Each part is a streamed request with the guarantees of every 5.70 upload: the route refuses a body above 32 MB before it runs (
max_content_length), the platform checks the app, the open upload and the part order before it reads the body, a user has one streamed body in flight at a time (the host sends the parts one after the other; a second concurrent upload answers 429 until the first part finishes), the read is bounded and kept for Odoo's retry of the endpoint, and a body whose length differs fromContent-Length— or that has none (chunked transfer, which the host never uses) — is refused, never appended short or cut. The host sends a part again, a few times afterRetry-After, when it meets that slot (429), before it aborts the whole upload. status?token(since 5.72) answers{received, next_part, size, sha256, expires_at}for an open upload, behind the same gate as the other calls (this user, this app, not expired; anything else is the same refusal). It does not extend the expiry; it waits for a part still in flight (the row lock a part holds), so it never answers with the state from before that part.- A part the upload is not waiting for is refused with the same message as always, plus
"code": "out_of_order"and"expected": N(the part the server wants next). commit?tokenchecks size and sha256 and stores the file;abort?tokendrops the upload and frees its reservation.
A dropped connection does not lose the upload (since 5.72). When a request fails with a network error (no HTTP answer at all), the host keeps the token and sends the same part again with exponential backoff (up to 5 tries, each wait at most 10 s). If the part still fails it asks status and goes on from next_part: parts the server already holds are not sent again, and a part whose reply was lost is skipped (the resend of such a part is refused as out_of_order, which the host reads as "already received", never as a failure). After three such rounds without progress, or on any HTTP refusal, the host gives up and calls abort explicitly, so the quota reservation and the upload slot are freed at once instead of at expiry. An abort that cannot reach the server is kept and sent again when the browser fires online, or after the next request that gets through, at most five times in all; it never delays the rejection the app receives. An upload does not survive a page reload (the token is held in memory). The app calls the same putBlob; set min_platform to "5.72" to rely on the behaviour.
commit?tokenchecks that the size and the sha256 are the declared ones; on a mismatch the temp file is deleted and the upload refused. Otherwise the temp file becomes the stored file (it is hard-linked into Odoo's filestore under its sha1 and renamed into place, atomically; the file is never read whole into memory). The content check ofput(the bytes are what the name says) runs on the first 16 KB.abort?tokendrops it. A cron removes expired uploads and any temp directory no live upload owns.
If any step fails the host aborts the upload, so nothing stays reserved.
Per Odoo line. Odoo 17, 18, 19 and 20 store a file at <filestore>/<sha1[:2]>/<sha1>. Odoo 17 to 19 write it with a plain open(path, "wb"), so the move above is the same write without the bytes in memory. Odoo 20 writes into an upload/ staging directory and renames the file into the same final path, so the result on disk is identical. Both behaviours were read from the Odoo sources of each line. A database whose attachments are not plain filestore files (storage set to db, or a module that replaces _file_write, such as an S3 store) cannot take that move: there the ceiling stays 200 MB whatever was approved, and a chunked upload of up to 200 MB is stored by Odoo's own writer (database or object store), with the bytes read from the verified temp file, exactly as 5.70 would have stored them. Vizum.supports("large-files") is true on the desktop and native hosts, not on Solo (offline).
3. Streamed read.
const blob = await Vizum.files.getBlob("lobby-loop.mp4"); // Blob typed with the stored mimetype
const head = await Vizum.files.getRange("lobby-loop.mp4", 0, 65536); // Uint8Array, at most 32 MB
The host fetches GET /vizum/apps/files/<key>/get?name=… with the user's session and hands the app a Blob (structured clone, no base64). The gate is the one get uses (the app must be visible to the user, and the file is in the user's own scope, or the app's shared one when data is global): another user's file is *file not found*. The response is for that user only: Cache-Control: private, no-store, X-Content-Type-Options: nosniff, a download disposition and a sandbox CSP, so opened by hand in a tab it never runs as a page of the site. The server answers Range (so getRange moves only the bytes asked for). Nothing is tracked about reads.
Vizum.files.get(name) (base64 inside a JSON answer) keeps working for files up to 200 MB. For a larger file it answers *this file is larger than 200 MB: read it with Vizum.files.getBlob or Vizum.files.getRange*: the JSON path would hold the file about four times over in one worker.
vzm check raises MIN_PLATFORM_TOO_LOW for max_mb above 200 or getBlob / getRange with a min_platform below 5.71, and FILE_LIMITS_TOTAL_BELOW_MAX as an error for a max_mb above 200 whose total_mb is lower. Feature flag: Vizum.supports("large-files").
SDK & server
Network — any public website, and images PRIVILEGED
*Since capability 5.74. Vizum.net.fetch and named hosts in permissions.net: since SDK v2.*
An app reaches the internet only through the platform's server-side proxy, and only the hosts its manifest names in permissions.net. Two things arrived in 5.74: a way to say "any public website", and a mode that downloads an image instead of an API response.
What the browser itself may reach (report-only today, enforced later)
permissions.net is a rule for the server proxy. A bundle's own JavaScript runs in the browser, and until now nothing limited what it could send out directly (fetch, an <img>, a beacon, a form post). Since the platform build of 2026-10-06 every HTML page of a vApp is also served with a Content-Security-Policy-Report-Only header, so the platform can measure what real apps contact before it blocks anything. Nothing is blocked yet, and this needs no change in your app. It will be enforced in a later platform release, behind a new min_platform; an app that keeps an older min_platform is never enforced.
What the policy says, so you can already build to it:
| The app | connect-src, img-src, media-src |
|---|---|
declares no permissions.net | the platform itself ('self'), data: and blob: only |
| declares hosts | those exact hosts on https, plus the platform. host:port names that port; *.x.com names x.com too |
declares "*" | any https: host (the approval card already says so) |
has ocr or audio | also the hosts the SDK downloads its models from (cdn.jsdelivr.net, huggingface.co) |
frame-src is 'self' blob: (no third-party frames inside the app), form-action is 'none', default-src is 'none', script-src and sandbox are unchanged. The policy uses only the hosts of the approved manifest: a host in a pending update opens nothing.
So an app that loads an image, a map tile or a JSON file directly from the browser from a host it does not list in permissions.net will be reported now and blocked later. Two ways to be ready: name the host in permissions.net (the administrator approves it, as for any host), or fetch through the platform (Vizum.net.fetch, Vizum.net.fetchImage, Vizum.map.tileUrl() for map tiles, Vizum.map.geocode() for addresses — see geocode.md). Violations are counted per app, directive and blocked origin (never a path or a query); administrators read them in the model vizum.app.csp.report. Details and the plan: docs/adr/0004-vapp-csp-network.md.
Ports of a named host (5.74)
A host named in permissions.net is reachable on ports 80 and 443. A host on any other port must say so in the manifest, as host:port:
{ "permissions": { "net": ["api.example.com", "reports.example.com:8443", "*.cdn.example.com:9000"] } }
api.example.com is then reachable on 80/443 only (https://api.example.com:8080/ is refused: *port 8080 is not declared for host api.example.com in permissions.net*); reports.example.com on 8443 only; two entries for one host add up. The approval card shows the entry as written (reports.example.com:8443) with *port 8443 only*, or *ports 80 and 443 only* for a bare host. A host an administrator typed into an app setting ($setting:<key>) keeps whatever port the setting holds. A host reached only through "*" is held to 80/443 whatever the manifest says.
A redirect from a named host to a host the manifest does not name is judged as a host nobody named: refused when the app has no "*" grant, and under the "*" rules (public addresses, ports 80 and 443) when it has one. net.fetch never follows a redirect.
Older apps keep the old port behaviour. The port rule starts at min_platform 5.74. An app with a lower min_platform still reaches a named host on any port, so nothing that worked breaks; vzm check warns it (NET_PORT_UNDECLARED: *permissions.net names X without a port*) so you declare host:port for the host you call on a non-standard port before you raise min_platform. An app at 5.74 or later with a bare host and a call to port 8443 fails at run time with *port 8443 is not declared*.
Cloud metadata is never reachable. The instance-metadata addresses (169.254.169.254, 169.254.170.2, fd00:ec2::254, 100.100.100.200, 168.63.129.16, 192.0.0.192) are refused in every mode, for a named host and for "*", whatever net_allow_private says.
permissions.net: ["*"] — download from any public website
{ "min_platform": "5.74", "permissions": { "net": ["*"] } }
"*" is a privileged permission, like large_files. The approval card reads it out as *"This app can send requests to, and download from, any public website. Data it can read may be included in those requests (never a private network)"*, and an update that gains it is sent back to the administrator. The manifest must declare min_platform 5.74 or later, or the platform refuses it (and vzm check reports MIN_PLATFORM_TOO_LOW).
What it is, plainly. The app can put any data it can read into the URL of a request to any public host. Approve it only for an app whose job is to fetch from the open web (a photo importer, a link preview), not for one that also holds sensitive data and has no need to go out. To make that visible and bounded, an app holding "*" also gets a default cap of 500 network calls per day (an administrator's own net_requests_per_day on the app wins; the instance parameter vizum_apps_platform.net_any_daily_cap changes the default; only 0 switches it off, a negative or unreadable value means the default).
It is download-oriented and always public-only. For a host that only "*" allows (no hostname or *.wildcard in the manifest names it):
| Rule | What happens |
|---|---|
| Addresses | Only public addresses. Private, loopback, link-local, carrier-grade NAT (100.64.0.0/10), cloud metadata, multicast and reserved addresses are refused, whatever net_allow_private says. The host name is resolved once, every answer is checked, and the connection goes to the checked IP (a DNS rebind cannot swap it). |
| URLs | http and https only, no credentials in the URL, no IP literals in any notation (a dotted quad, 134744072, 0x08080808, [::1]). |
| Ports | 80 and 443 only. |
| Methods | GET and HEAD only; no multipart upload. |
| Headers | Only Accept, Accept-Language, Range, If-None-Match, If-Modified-Since and User-Agent. Any other header is refused (not dropped), so X-HTTP-Method-Override cannot turn a GET into a DELETE and no header carries data out. |
| Secrets | A $secret:name in the URL or a header is refused: a credential must never travel to a host nobody named. Name the host in permissions.net to use one. |
| Job webhooks | A job's webhook host must still be named; "*" does not cover it. |
A host named in the list (an exact name or a *.wildcard) keeps the rules it always had, also when "*" is listed next to it.
$setting: hosts next to "*". An app can write its own settings, so a host that comes from a $setting: entry is not treated as named when "*" is granted: it gets the rules above. A setting value is used only when it is a plain host name or address; a value such as * or https://*/ is ignored (it can never become the "*" grant, which needs its own approval).
Time limit. Every fetch has one absolute deadline (timeout, default 15 s, at most 20 s) that covers name resolution, connecting, the response headers and the body. A server that sends a byte at a time cannot extend it: when it passes, the connection is closed under the read. The deadline also covers the TLS handshake.
Release note (5.74): which hosts get the total deadline. The absolute deadline applies to every host reached through "*" and to image mode (Vizum.net.fetchImage). A host that the manifest names, in an app that calls Vizum.net.fetch (every app written before 5.74), keeps the behaviour it always had: timeout limits each socket read, so a slow API (an LLM endpoint, a report export) that keeps sending is not cut at 15 to 20 s. Those calls only have a generous total of 120 s, which ends a call nobody can still be waiting for.
Public routes and portal visitors. A route hook that a portal visitor can call must not be an anonymous proxy to the open web: V.net.fetch_image refuses a portal user's route call. Fetch on behalf of an internal user, or from a job.
Image mode — fetchImage / fetch_image
net.fetch never follows a redirect and returns 2 MB at most. To bring in a product photo from a CDN, use the image mode:
// in the iframe (Vizum.net.fetchImage, since 5.74)
const img = await Vizum.net.fetchImage("https://cdn.example.com/p/123.jpg", { max_bytes: 4_000_000 });
// -> { content_type: "image/jpeg", size: 183204, sha256: "…", base64: "…" }
# in a server hook (V.net.fetch_image, since 5.74)
img = V.net.fetch_image(url, {"max_bytes": 4000000})
V.files.put("photo.jpg", img["base64"])
What the platform does, in this order, for the first request and for every redirect:
- Checks the URL:
http/https, no credentials, the host is inpermissions.net(or"*"). - Resolves the name and connects to a validated public IP (always public-only; image mode ignores
net_allow_private). - Follows at most 3 redirects; each target goes through steps 1 and 2 again. A redirect from
httpstohttpis refused. - Streams the body and stops as soon as it passes the cap (
max_bytes, default 10 MB), or the one deadline above (timeout). It does not read the whole body first. - Looks at the first bytes, not at
Content-Type: only PNG, JPEG, GIF and WEBP are returned. SVG, HTML and everything else are refused.
max_bytes is clamped to the instance ceiling: the Odoo system parameter vizum_apps_platform.net_image_max_bytes (default 10 MB, never above 16 MB). A script run may bring in at most 64 MB of images in all, results together. Image mode sends no custom headers and accepts no $secret:. One call counts as one network call: against the run's 50-call budget in a script, and against the app's daily network budget an administrator may set (QUOTA_EXCEEDED), like net.fetch.
A refusal rejects with a platform error whose code says what kind it is: VALIDATION_FAILED (blocked address, too many redirects, too large, not an image, time limit), ACCESS_DENIED (a host outside permissions.net, including on a redirect, or a header or method the "*" grant does not allow) or QUOTA_EXCEEDED (the daily network budget). The message says what failed and never includes the address or the body.
vzm check raises MIN_PLATFORM_TOO_LOW for Vizum.net.fetchImage or V.net.fetch_image below min_platform 5.74. On an older platform the call does not exist.
Cloud providers — V.net.provider (5.138)
Some clouds speak a signed protocol: a login that returns a session, a key to encrypt the payload, a checksum on every request. A script cannot do that itself, because it holds no key (there is no HMAC in a script, server-side.md) and net.fetch only substitutes $secret: placeholders. The provider adapter speaks the protocol on the server, so the script never holds a key, a password hash or a session. Today there is one provider, hvac_cloud: an HVAC cloud provider (the vendor cloud of a family of air conditioners, dehumidifiers and thermostats).
# server-side only: native methods, hooks and jobs. There is no Vizum.net.provider in the iframe.
res = V.net.provider("hvac_cloud", "list_appliances", {"account": "1"})
if res["ok"]:
for a in res["appliances"]:
V.log(a["id"], a["name"], a["online"])
else:
V.log(res["code"]) # a cloud error is a dict, it never raises
Manifest
"min_platform": "5.148",
"permissions": {
"providers": ["hvac_cloud"],
"secrets": ["hc_acct_1_user", "hc_acct_1_pass"]
}
permissions.providers: a list of provider names; the only value is"hvac_cloud".vzm checkand the platform refuse any other name, and the schema a repeated one. Privileged: the install card shows the line «Sign in to an HVAC cloud provider with the account you configure» with the detail «Server-side cloud sign-in; the password and the session stay on the server», and gaining the key on an update asks again.- The provider brings its own hosts (5.148, feature
net-provider-supplied-hosts). The platform reaches the provider's fixed, pinned host list itself and never readspermissions.netfor it: list nothing there for the provider. Declaring a provider grants noV.net.fetchto its hosts —fetchstill answers only to whatpermissions.netnames. An app that already lists the provider's hosts keeps working unchanged. A platform below 5.148 does not supply them and answersHOST_NOT_ALLOWED, so an app that lists none setsmin_platformto 5.148 (vzm check:PROVIDER_HOSTS_FLOOR). - No install floor. A platform older than 5.138 ignores
permissions.providers, so the app can ship with a lowermin_platformand probe the adapter at run time (see Feature detection). A platform from 5.138 that predates the public idhvac_cloudrefuses to install an app that declares it (its validator does not know the name): the install fails, the app never runs there.
Arguments and operations
Common args:
| arg | type | meaning |
|---|---|---|
account | str, [a-z0-9_]{1,40} | the app's account key |
app | str, optional | reserved: leave it out. The adapter signs in to the provider's default cloud app; another value answers BAD_INPUT or CLOUD_ERROR with UNSUPPORTED_APP |
user_secret, pass_secret | str, optional | the NAMES of the secrets to read instead of the defaults |
Secrets are read by name. By default the adapter reads hc_acct_<account>_user and hc_acct_<account>_pass; pass user_secret / pass_secret to use other names. Every name used must be declared in permissions.secrets; a missing secret answers NOT_CONFIGURED. The values never reach the script.
| op | extra args | returns |
|---|---|---|
login | none | {ok: True, app, region?}. Forces a fresh login and stores the session in the vault |
list_appliances | none | {ok: True, appliances: [{id, name, type: '0xAC'|'0xA1'|'0x..', model, online: bool}]}. The serial number is never returned |
send_frame | appliance_id (str of digits), frame_hex (hex of the bare device frame, at most 512 bytes) | {ok: True, replies: [hex, ...]}. Each reply is the appliance's reply frame with the cloud's 40-byte header removed. A reply shorter than 50 bytes before the strip is CLOUD_ERROR with cloud_code: 'SHORT_REPLY' |
logout | none | {ok: True}. Drops the session |
frame_hex is the bare device frame (start byte, length, appliance type, body, checksum). The adapter wraps it for the cloud (the packet header with the appliance id, the length, the fingerprint, the AES layer).
Errors
An error comes back as {ok: False, code, message}; it is never raised:
| code | meaning |
|---|---|
BAD_CREDENTIALS | the cloud refused the account |
SESSION_EXPIRED | only after the one automatic re-login also failed |
RATE_LIMITED | the cloud is throttling |
REGION | reserved; the provider's API does not produce it today |
NETWORK | the host could not be reached |
NOT_CONFIGURED | a secret is missing |
HOST_NOT_ALLOWED | the provider's host failed the platform's network checks (below 5.148: it is not in permissions.net) |
PERMISSION | the app does not declare the provider |
BAD_INPUT | an argument is wrong |
UNIT_OFFLINE | the unit is offline or did not answer — a normal state, not a cloud fault; also carries cloud_code (feature net-provider-thermostat-json-offline; below its capability these answer CLOUD_ERROR) |
CLOUD_ERROR | anything else the cloud said; also carries cloud_code |
message never contains a secret, a sessionId, an accessToken, a dataKey, a loginId or a password. Any unexpected failure inside the adapter answers {ok: False, code: "CLOUD_ERROR", cloud_code: "INTERNAL", message: "The platform could not complete the cloud request"}; nothing of the underlying error reaches the script.
Not an error dict: running out of the run's network budget (50 calls) or of the app's daily network budget raises, exactly as V.net.fetch does.
The session
The session (sessionId, accessToken, dataKey) lives on the server, encrypted, scoped to the app and the account. It never reaches the script, the iframe, the listing, an export or a log.
logoutdrops it (it takes the same sign-in lock, so it may raiseLockTimeout); uninstalling the app drops it; changing a credential drops it (settinghc_acct_<account>_useror_passagain starts a fresh login next time).- When the cloud answers
SESSION_EXPIRED, the adapter re-logs in once and retries once. Only if that also fails does the script seeSESSION_EXPIRED. V.net.provideris refused inside a declared read call, likeV.net.fetch(the wholenetbridge is), before any request: call it from a method that is allowed to write.- The session is stored in the caller's transaction: a run that rolls back loses a session it just obtained, and the next call signs in again.
- A sign-in is serialized per app and account. When two workers sign in at once, the second one's request is replayed by Odoo (a serialization failure), and a replayed request can send a frame again: send idempotent commands (set a temperature, not "one degree up"). A sign-in that waits more than 20 s for another worker's raises
LockTimeout(like a budget error, not a dict). The replay needs the exception to leave the script: do not catch a broadExceptionaroundV.net.providerunless you re-raise it. - A refused password pauses automatic sign-in for 10 minutes:
list_appliancesandsend_frameanswerBAD_CREDENTIALSwithout calling the cloud, so a poll job cannot lock the account. Callinglogin, or changing the secret, lifts it at once. - A session belongs to the secret names it was obtained with: calling with other
user_secret/pass_secretnames signs in again.
Metering
Calls are metered like V.net.fetch: each HTTP request the adapter makes is one network call, against the run's 50 and against the app's daily network cap. A login is two requests; a re-login retry adds up to three more.
Feature detection
The adapter exists from capability 5.138. An app that wants to work on both sides probes it and shows «needs Vizum platform update» otherwise. Feature detection works in native-method code only (methods and their library): hasattr is not a builtin in the script sandbox, and native methods have the helper has_attr:
if has_attr(V.net, "provider"):
res = V.net.provider("hvac_cloud", "login", {"account": "1"})
else:
res = {"ok": False, "code": "NEEDS_PLATFORM_UPDATE"}
A plain hook or job (safe_eval) has neither helper: if it calls V.net.provider itself, set min_platform to 5.138; otherwise let it call a native method that probes.
vzm check
| code | level | when |
|---|---|---|
PROVIDER_NOT_DECLARED | error | server code uses V.net.provider and permissions.providers is empty or absent, or the first argument is a string literal that permissions.providers does not name |
UNKNOWN_PROVIDER | error | permissions.providers names anything but hvac_cloud |
MIN_PLATFORM_TOO_LOW | error | V.net.provider with min_platform below 5.138 |
PROVIDER_FEATURE_DETECTED | warning | the same case, but the app's server code contains hasattr(V.net, "provider") or has_attr(V.net, "provider") (either quote): the app detects the adapter itself |
PROVIDER_HOSTS_FLOOR | warning | a provider is declared and min_platform is below 5.148 (or 5.148 is still pending): an older platform does not supply the provider's hosts |
PROVIDER_ID_MIXED | error | thermostat_query / thermostat_control called with a provider id other than hvac_cloud |
The adapter is a private server method: there is no api_* entry, no bridge op and no Vizum.net.provider for the iframe.
Cloud thermostat, phone-free — thermostat_query / thermostat_control (5.145; sensors 5.147)
send_frame is the raw floor: the app builds the device frame itself. Two higher-level ops read and set a cloud thermostat without a phone and without the app touching a frame. The adapter transcodes the intent through a second, ephemeral provider session (the provider's transcode service) and delivers the frame through the same device channel, so the script only ever sees a safe dict.
# server-side only (native method, hook or job). Reads the thermostat, no phone, no frame.
res = V.net.provider("hvac_cloud", "thermostat_query", {"appliance_id": "100000000000001", "account": "home"})
if res["ok"]:
V.log(res["power"], res["mode"], res["setpoint_F"], res.get("current_F"))
else:
V.log(res["code"]) # UNSUPPORTED_OP below 5.145; a dict, never raised
# set the setpoint (whole °F; the device rounds). Confirms the applied state before it answers.
res = V.net.provider("hvac_cloud", "thermostat_control",
{"appliance_id": "100000000000001", "account": "home",
"payload": {"power": "on", "mode": "cool", "temperature": 72}})
Both ops gate on the contract feature net-provider-thermostat-json (since 5.145). Below that they fail closed: {ok: False, code: "UNSUPPORTED_OP"}, and no cloud request is made. The op names go with the provider id hvac_cloud (vzm check: PROVIDER_ID_MIXED).
Extra manifest for the JSON ops
The transcode is a second session on another of the provider's own hosts (supplied by the platform, like the sign-in host), with its own two secrets, on top of the provider basics:
"permissions": {
"providers": ["hvac_cloud"],
"secrets": ["hc_acct_home_user", "hc_acct_home_pass", "hvac_cloud_app_id", "hvac_cloud_secret"]
}
hvac_cloud_app_idandhvac_cloud_secretare the second session's app id and sign secret, read by name from the vault. Either one missing answersNOT_CONFIGURED(the message names both). These names are fixed — unlike the account credentials, there is no*_secretoverride arg.
thermostat_query — read
| arg | type | meaning |
|---|---|---|
appliance_id | str of digits, ≤ 32, fits in 64 bits | the thermostat, as list_appliances returns it |
account | str, [a-z0-9_]{1,40} | the app's account key (and the optional user_secret / pass_secret of the base provider) |
Base return (op present, sensors capability not assigned):
{ "ok": True, "appliance_id": "100000000000001",
"power": "on", # "on" | "off"
"mode": "auto", # "auto" | "cool" | "heat" | "emergency_heat" | "fan"
"setpoint_F": 72.0, # whole °F
"current_F": None } # present, but null until the sensor capability is assigned
Under platform capability 5.147 (feature net-provider-thermostat-json-sensors) the same query also returns the live sensors — current_F populated, plus three keys that do not exist below 5.147:
| field | type | meaning |
|---|---|---|
current_F | int or null | current temperature, °F. Already in the base shape; populated here |
humidity | int or null | relative humidity, % |
fault | bool or null | a fault is reported now |
sensors_at | float or null | epoch seconds of the last successful current_F/humidity read — for an "as of hh:mm" label |
Absent vs. null — the one rule to read. A sensor field is absent (not a key at all) when the platform is below min_platform 5.147: the platform does not know the field. A sensor field is present with null when the platform knows it but the sensor read failed, was skipped or is stale — "unknown now", not "unsupported". So test "humidity" in res for capability, and res["humidity"] is None for a missing reading; the two are different. (current_F is the exception: it is always present, because it is part of the base shape — null below 5.147, then populated or null.)
faultis always a fresh read ornull— it is never carried forward.current_Fandhumiditycarry the last known value only while recent; past the staleness window they gonull(an hours-old reading is never shown as current).sensors_atgives the age of the value you hold, so the app can label or discard it.- The enrichment is refreshed at most once every few minutes per
(app, account, appliance); in between, the last cached values are served, and it is skipped while a control is in flight for the appliance. The basepower/mode/setpoint_Fare read live every call regardless, and a failed sensor sub-query never disturbs them.
thermostat_control — set
| arg | type | meaning |
|---|---|---|
appliance_id, account | as above | |
payload | dict | one or more of power, mode, temperature; any other key is BAD_INPUT |
payload fields: power ("on"/"off"), mode (the five names above), temperature (a whole °F number; the device rounds to the nearest 0.5 °F). A setpoint outside the device's °F range is rejected as BAD_INPUT (*temperature must be between …*), never silently clamped — a Celsius-looking 22 is refused, not applied.
The op applies the intent, then reads the state back and confirms it before it answers. A success returns the confirmed read-back:
{ "ok": True, "appliance_id": "100000000000001", "power": "on", "mode": "cool", "setpoint_F": 72.0 }
A mode or power intent implies power on; if the read-back does not match, the op fails closed with {ok: False, code: "CLOUD_ERROR", cloud_code: …} where cloud_code is POWER_UNCONFIRMED, MODE_UNCONFIRMED or SETPOINT_UNCONFIRMED. Nothing is reported as applied that the device did not confirm.
Control is rate-limited per thermostat. A second control within the short window answers RATE_LIMITED without calling the cloud. The limit is keyed on the appliance_id, so a different account label pointing at the same thermostat is still throttled. Send idempotent commands (set a temperature, not "one degree up"): a sign-in that Odoo replays after a serialization failure can resend a frame (see The session).
Errors specific to the thermostat ops
On top of the provider error codes above:
| code / cloud_code | meaning |
|---|---|
UNSUPPORTED_OP | the op's platform capability (5.145) is not assigned — both ops fail closed |
NOT_CONFIGURED | hvac_cloud_app_id or hvac_cloud_secret is missing |
HOST_NOT_ALLOWED | below 5.148: the platform does not supply the provider's hosts and permissions.net does not list them |
RATE_LIMITED | a control was sent to this thermostat moments ago, or the cloud returned HTTP 429 |
BAD_INPUT | a bad appliance_id, an unknown payload key, a non-numeric or out-of-range temperature, a bad power/mode |
CLOUD_ERROR + cloud_code: POWER_UNCONFIRMED / MODE_UNCONFIRMED / SETPOINT_UNCONFIRMED | the read-back did not confirm the intent |
CLOUD_ERROR + cloud_code: BAD_REPLY | the transcode service kept answering an HTML error (throttled) after the retries |
As for every provider call, running out of the run's 50 network calls or the app's daily network cap raises (it is not a dict); each HTTP request the adapter makes — including the transcode, the device delivery and each sensor sub-query — is one network call.
SDK & server
AI tools — what each tool does, declared
*Contracts: capability 5.67. AI tools themselves: since 5.3.*
An app lets the AI Assistant drive it by declaring ai_tools in its manifest. Since 5.67, each tool can also say what it does to the app's content, and the platform acts on that, so the model doesn't have to guess:
"ai_tools": [
{ "name": "add_card", "description": "Adds a card. values: {title, column}",
"risk": "create", "idempotent": false },
{ "name": "clear_board", "description": "Removes every card from the board.",
"risk": "destructive", "side_effects": "deletes every card on the board" }
]
| Field | Values | Means |
|---|---|---|
risk | read · create (default) · update · destructive | What the tool does to existing content |
side_effects | text, ≤ 300 characters | One sentence the user reads before approving. Required for update and destructive |
confirm | boolean | Validated only: update and destructive tools are always confirmed, and confirm: false is refused for them. It changes nothing for read / create tools |
idempotent | boolean | Running it twice with the same values does no more than running it once |
What the platform does with it
- Install. The contract is validated (
vapp_validation._valid_ai_tool_contract), and an invalid one refuses the install. A tool withmodel(the platform creates the record for it) is alwayscreate. - The model is told. The Assistant's prompt lists each
update/destructivetool with itsside_effectsand an instruction to use it only when the user clearly asked for that change. Idempotent tools are marked as safe to retry. - The user confirms. When the Assistant calls an
updateordestructivetool, the host asks the user before your app is opened, showingside_effects. If the user declines, the call rejects withUSER_CANCELLEDandVizum.ai.tools.handlenever fires. The check is in the host, on every way one app opens another with a tool call —Vizum.apps.openandVizum.ui.openApp(the Assistant's) — on the desktop (VappHost.confirmAiTool, reading the contract from its app catalogue) and in Odoo's own menus and Vizum Solo (core/vapp_ops.js, asking the serverapi_ai_tool_contract). On every host, a tool with no contract to read — a catalogue loaded before an update, a name the app never declared — is asked about as if it changed content. It is not in the Assistant's UI, which a prompt could steer around.
Your handler does not change:
Vizum.ai.tools.handle("clear_board", async () => {
// reached only after the user approved "deletes every card on the board"
await Vizum.data.collection("cards").clear();
});
Compatibility
- A tool the manifest declares without a
riskiscreate, exactly as before. A tool name the manifest does not declare at all is confirmed. - A platform older than 5.67 does not confirm.
vzm checktherefore reportsMIN_PLATFORM_TOO_LOWwhen a manifest declares a contract withmin_platformbelow 5.67: raisemin_platform, and an older platform refuses to install the app instead of running its tools unconfirmed. - Every host confirms (
ai-tool-contracts: desktop, native, solo).
Checking it before you upload
vzm check applies the same rules as the installer (INVALID_AI_TOOL_CONTRACT), with the risk lists pinned to the platform's by a test. The manifest schema (manifest.schema.json) and the typings (Vizum.AiToolContract, Vizum.AiToolRisk) describe the same fields.
SDK & server
AI vision — send an image with the question
*Image blocks in Vizum.ai.chat. No capability of its own and no manifest key beyond permissions.ai: the gateway has accepted them since June 2026 (P2.1), so every platform at 5.98 or later serves them.*
messages[].content is a string, or a list of content blocks. A block is text or an image:
const photo = await toBase64(file); // base64 only, no "data:image/jpeg;base64," prefix
const r = await Vizum.ai.chat({
system: "You turn photos of paper forms into JSON. Answer with JSON only.",
messages: [{
role: "user",
content: [
{ type: "image", mime: "image/jpeg", data: photo },
{ type: "text", text: "List every question on this checklist." },
],
}],
max_tokens: 4000,
ref: "forms.import", // names the consumer on the audit row (5.34)
});
console.log(r.content);
The type is Vizum.AiContentBlock ({type: "text", text} or {type: "image", mime, data}); until the typings carried it, AiMessage.content was typed string and an app had to cast.
What the server accepts
| Rule | Value |
|---|---|
| Image formats | PNG, JPEG, WebP, GIF. The server reads the bytes: a JPEG labelled image/png still works, and HEIC or SVG is refused with "unsupported image format ... convert it to PNG or JPEG" |
| Images per call | at most 5 |
| Size of one image | at most 5 MB decoded; "image too large ... use a smaller picture" otherwise |
| Roles | user and assistant may carry blocks; a system turn is text only (its blocks are flattened to text) |
| Where it works | both AI modes: the instance's own keys (the gateway), and Managed AI (the store's proxy, which repeats the same checks). Nothing for the app to branch on |
Shrink a phone photo before sending: a 12-megapixel JPEG is 3 to 6 MB and a model reads a checklist just as well at 1600 pixels on the long side (about 300 KB). Send a scanned PDF page as an image, or use Vizum.ocr (below); a PDF file itself is not an image block.
Which model reads it
Only a vision model can take an image. The gateway picks one from the models the app and the user may use, or checks the one you named:
| Error | Meaning | What to do |
|---|---|---|
no_vision_model | no model this app may use reads images | fall back to OCR, or tell the administrator |
model_has_no_vision | you named a model that does not | drop model and let the gateway choose, or fall back to OCR |
AccessErrors travel to the app as a VizumError; match the text, or e.message.includes("vision").
The fallback: OCR, then text
Vizum.ocr.recognize(file, { want: "tables" }) (grant permissions.ocr) reads text and tables from an image or a PDF (local Tesseract, or managed OCR on credits, per the administrator). Feed its text to ai.chat as a normal string. Vizum Forms does exactly this for "New template from a photo or PDF": vision first for an image, OCR then text for a PDF or when no model reads images.
Cost and privacy
An image costs input tokens like text (the provider's own count) and is metered the same way; a managed call returns charge. The image leaves the instance for the provider of the model, like any prompt. Ask the person before sending something they did not choose to share.
Examples
Worked example — a sticky note, per user ZERO-RISK
The minimal real app: one KV key, theme-aware, ~60 lines. Ships with the platform
as demo_apps/sticky_todo/.
"permissions": { "data": "user" }
(async function () {
await Vizum.connect();
let todos = (await Vizum.data.get("todos")) || [];
const save = () => Vizum.data.set("todos", todos);
function render() {
list.innerHTML = "";
todos.forEach((t, i) => {
const row = el("div", "todo" + (t.done ? " done" : ""));
const cb = el("input"); cb.type = "checkbox"; cb.checked = t.done;
cb.onchange = () => { t.done = cb.checked; save(); render(); };
const span = el("span"); span.textContent = t.text; // textContent, always
const del = el("button"); del.textContent = "✕";
del.onclick = () => { todos.splice(i, 1); save(); render(); };
row.append(cb, span, del); list.append(row);
});
}
render();
})();
Because Vizum.data is isolated per user, two colleagues running this snippet never see each other's notes — without you writing a single line of access control. That is a property of the KV store, not of the Sticky Todo app in the Store: that one keeps its lists in a native Odoo model and can share them with your team, with record rules deciding who sees what.
Examples
Worked example — Top Customers PRIVILEGED
A narrow, read-only window into res.partner with two UI capabilities —
the smallest useful shape for a privileged read.
This is a teaching example, not the shipped app. Top Customers in the Store ranks by
invoiced revenue net of credit notes, aggregating Odoo's invoice analysis
(account.invoice.report) by the commercial partner server-side.
customer_rank is a counter Odoo bumps when an order is confirmed: fine for
ordering a demo, but it is not money, and in most databases it is 1 for nearly everyone.
"permissions": {
"data": "user",
"orm": [{ "model": "res.partner", "ops": ["search", "read"],
"fields": ["name", "email", "city", "customer_rank"] }],
"ui": ["notify", "openRecord"]
}
const rows = await Vizum.orm.searchRead(
"res.partner", [["customer_rank", ">", 0]],
["name", "email", "city", "customer_rank"],
{ limit: 15, order: "customer_rank desc" });
rows.forEach((r) => {
const row = card(r); // build DOM with createElement…
row.onclick = () => Vizum.ui.openRecord("res.partner", r.id, r.name);
});
createElement + textContent —
never interpolate them into innerHTML.
Examples
Worked example — two apps talking
A “team scoreboard” pattern: shared data + instant updates, across apps and across users.
"permissions": {
"data": "global",
"events": { "emit": ["score.changed"], "listen": ["score.changed"] }
}
await Vizum.connect();
async function addPoint() {
const n = ((await Vizum.data.get("score")) || 0) + 1;
await Vizum.data.set("score", n); // global scope → broadcast to all users
Vizum.events.emit("score.changed", { n }); // same-desktop apps react instantly
}
Vizum.data.subscribe("score", refresh); // cross-user updates (server bus)
Vizum.events.on("score.changed", refresh); // same-desktop updates (no roundtrip)
Examples
Worked example — Rent Roll PRIVILEGED
A property-management board — rental units, tenant leases, occupancy KPIs and one-click rent invoicing — that ships as demo_apps/rentroll/.
Everything earlier in this guide stored a vapp's data in the Vizum store. Rent Roll is different: its two business entities are declared as real native Odoo models in the manifest, so units and leases become first-class records that accounting, reporting and other apps can read directly. Each native.models entry named x is materialised as a model whose technical name is x_vzm_rentroll_<x>, and each field f becomes a column x_f. The relations are the point: a lease carries a many2one to a unit and another to a stock res.partner (the tenant).
"native": {
"models": [
{
"name": "unit", "label": "Rental Unit", "rec_name": "name",
"fields": [
{ "name": "name", "type": "char", "label": "Name" },
{ "name": "addr", "type": "char", "label": "Address" },
{ "name": "size", "type": "float", "label": "Size (m²)" },
{ "name": "rent", "type": "float", "label": "Asking rent" }
],
"access": [ { "group": "base.group_user", "ops": ["read","write","create","unlink"] } ]
},
{
"name": "lease", "label": "Lease",
"fields": [
// many2one whose relation is the SIBLING native model …
{ "name": "unit_id", "type": "many2one", "relation": "x_vzm_rentroll_unit", "label": "Unit" },
// … and one to a STOCK Odoo model (real contacts)
{ "name": "tenant_id", "type": "many2one", "relation": "res.partner", "label": "Tenant" },
{ "name": "start", "type": "date", "label": "Start" },
{ "name": "end", "type": "date", "label": "End" },
{ "name": "rent", "type": "float", "label": "Monthly rent" },
{ "name": "last_invoiced", "type": "char", "label": "Last invoiced (YYYY-MM)" }
],
"access": [ { "group": "base.group_user", "ops": ["read","write","create","unlink"] } ]
}
]
}
Because the columns are physically named with the x_ prefix, the app reads through Vizum.orm.searchRead against the full technical model name and then normalises each row back to a clean in-memory shape. The crucial detail for relations: a many2one comes back as a [id, display_name] pair (or false when empty), so a tiny helper collapses it to a bare id.
const M_UNIT = "x_vzm_rentroll_unit";
const M_LEASE = "x_vzm_rentroll_lease";
const LEASE_FIELDS = ["x_unit_id","x_tenant_id","x_start","x_end","x_rent","x_last_invoiced"];
// many2one arrives as [id, "Name"] | false -> collapse to a plain id
const m2oId = (v) => (Array.isArray(v) ? v[0] : (v || 0));
const normLease = (r) => ({
id: r.id,
unit_id: m2oId(r.x_unit_id), // -> the sibling unit's id
tenant_id: m2oId(r.x_tenant_id), // -> the res.partner id
start: r.x_start || "", end: r.x_end || "",
rent: r.x_rent != null ? +r.x_rent : 0,
last_invoiced: r.x_last_invoiced || "",
});
async function reload() {
DB.units = (await Vizum.orm.searchRead(M_UNIT, [], UNIT_FIELDS, { order: "x_name" })).map(normUnit);
DB.leases = (await Vizum.orm.searchRead(M_LEASE, [], LEASE_FIELDS)).map(normLease);
// resolve tenant display names from the real contacts in one read
const ids = [...new Set(DB.leases.map((l) => l.tenant_id).filter(Boolean))];
(await Vizum.orm.read("res.partner", ids, ["name"])).forEach((p) => { TENANTS[p.id] = p.name; });
render();
}
Creating a lease writes the relation the other way: the dialog yields a bare unit id and a tenant id (picked with a real res.partner search field), and the app posts them back into the prefixed x_unit_id / x_tenant_id columns. Odoo enforces the foreign keys, so a lease can never point at a unit or contact that doesn't exist.
await Vizum.orm.create(M_LEASE, {
x_unit_id: v.unit_id, // FK -> x_vzm_rentroll_unit
x_tenant_id: v.tenant_id, // FK -> res.partner
x_start: v.start || false,
x_end: v.end || false,
x_rent: +v.rent || 0,
x_last_invoiced: "",
});
The payoff of native records is interoperability with the rest of Odoo. The "Invoice rent" button doesn't fake a document — it creates a genuine draft customer invoice (account.move) addressed to the lease's tenant, with a single rent line, then opens the real record. A short field on the lease records which month was billed so the button locks after one click per unit per month.
async function invoiceRent(unit, lease) {
const id = await Vizum.orm.create("account.move", {
move_type: "out_invoice",
partner_id: lease.tenant_id, // the same res.partner the lease points at
invoice_date: todayStr(),
invoice_line_ids: [[0, 0, {
name: "Rent — " + unit.name + " — " + monthLabel(new Date()),
quantity: 1,
price_unit: lease.rent,
}]],
});
// remember the billed month so we don't double-invoice
await Vizum.orm.write(M_LEASE, [lease.id], { x_last_invoiced: thisMonth() });
Vizum.ui.notify("Draft invoice created for " + tenantName(lease), "success");
Vizum.ui.openRecord("account.move", id, "Rent invoice"); // open the real Odoo form
}
base.group_user CRUD), enforce real foreign keys to res.partner, and are immediately visible to accounting, BI/reporting and any other app. That's exactly why "Invoice rent" can spin up a genuine account.move against the lease's tenant in one click.
Examples
Worked example — Work Orders PRIVILEGED
A field-service status board backed by a real native Odoo table with per-row team sharing and one-click invoicing — ships as demo_apps/work_orders/.
This is the most advanced app in the catalog. Instead of storing rows in Vizum's per-app key/value store, the manifest declares a native.models block: Vizum provisions a genuine Odoo model, x_vzm_workorders_wo, so every work order is a first-class record that Odoo reports, BI, and other native modules can read. The app owns that table outright (full CRUD via Vizum.orm, no extra grant), and it reproduces a three-level sharing model — private / shared with specific people / public — directly on native columns. A drag-and-drop board moves cards between statuses, and finished orders post straight to account.move as a draft invoice.
The model is declared once in the manifest. Vizum prefixes every field with x_ and the model name with x_vzm_<app>_, so the wo model becomes x_vzm_workorders_wo and status becomes x_status:
{
"native": {
"models": [{
"name": "wo", // -> native model x_vzm_workorders_wo
"label": "Work Order",
"rec_name": "number",
"fields": [
{ "name": "number", "type": "char", "label": "Number" },
{ "name": "customer", "type": "char", "label": "Customer" },
{ "name": "customer_id", "type": "many2one", "relation": "res.partner",
"label": "Customer (contact)" },
{ "name": "status", "type": "selection", "label": "Status",
"selection": [["new","New"],["assigned","Assigned"],
["in_progress","In Progress"],["on_hold","On Hold"],
["done","Done"]] },
{ "name": "total", "type": "float", "label": "Total" },
{ "name": "visibility", "type": "selection", "label": "Visibility",
"selection": [["private","Only me"],["shared","Specific people"],
["public","Everyone"]] },
{ "name": "shared_with", "type": "many2many", "relation": "res.users",
"label": "Shared with" }
],
"access": [
{ "group": "base.group_user", "ops": ["read","write","create","unlink"] }
]
}]
}
}
Sharing is modeled with two native columns and Odoo's own ownership metadata. x_visibility is a selection of private / shared / public; x_shared_with is a many2many to res.users listing exactly who may see a shared row. The row owner is Odoo's built-in create_uid — the app never stores an owner field of its own. Because base.group_user can technically read the whole table, the app scopes what it shows in the read domain rather than relying on row-level security alone.
const MODEL = "x_vzm_workorders_wo";
const myUid = () => (window.Vizum && Vizum.user && Vizum.user.id) || 0;
async function loadOrders() {
const uid = myUid();
// my own rows (any visibility) + everyone's public rows + rows shared with me
const domain = ["|", "|",
["create_uid", "=", uid],
["x_visibility", "=", "public"],
["x_shared_with", "in", [uid]]];
const rows = await Vizum.orm.searchRead(MODEL, domain, WO_FIELDS,
{ order: "id desc", limit: 1000 });
rows.forEach((r) => { orders[r.id] = normWo(r); }); // map x_* -> app shape
}
The app owns this model, so no additional permissions.orm grant is needed to read or write it — the grant in the manifest covers other models the app touches (res.users, account.move for invoicing). Saving sharing is a single write to those two columns. The many2many is replaced wholesale with Odoo's (6, 0, ids) command, so the new audience exactly replaces the old one:
async function saveSharing(w, visibility, users) {
// only list explicit people when visibility === "shared"
const grant = visibility === "shared" ? users.slice() : [];
await Vizum.orm.write(MODEL, [w.id], {
x_visibility: visibility,
x_shared_with: [[6, 0, grant]], // (6,0,ids): replace the whole set
});
// reflect the new annotations onto the in-memory row (badge + board filter)
if (orders[w.id]) {
orders[w.id]._visibility = visibility;
orders[w.id]._shared_with = grant;
}
Vizum.ui.notify("Sharing updated", "success");
}
Dragging a card to another board column is just as direct — a one-field write that any Odoo report immediately sees:
async function setStatus(id, status) {
// columns are statuses; dropping a card writes the native selection
await Vizum.orm.write(MODEL, [id], { x_status: status });
}
When a row comes back from searchRead, the app derives its sharing state — not from a bespoke permissions table, but from native data: _mine = (create_uid === myUid()) marks the owner, _visibility mirrors x_visibility, and _shared_with mirrors the x_shared_with id list. The owner always edits and deletes; a recipient on a shared or public row sees it (the read domain let it through) but _can_edit / _can_delete fall out of _mine, so collaborators view without clobbering. The whole policy lives on real columns of x_vzm_workorders_wo, which is why Odoo's BI, list views, and any other module can honor and report on it without ever loading the vapp.
Any app that declares a native.* block is privileged: it asks Odoo to create real schema, so installing it requires admin approval rather than self-service. And because a brand-new native model adds tables/columns to the database, the Odoo server must be restarted once after the first install so the running registry picks the model up — until then, ORM calls against x_vzm_workorders_wo will fail. Subsequent updates need no restart.
Examples
Worked example — real-time collaborative board PRIVILEGED
A shared kanban where every teammate's open board refreshes the instant someone changes it — ships as demo_apps/taskboard/.
Taskboard is the most complete pattern in the SDK: it combines all three pillars at once. Each board is a row in a native Odoo model (x_vzm_taskboard_board, declared in manifest.native.models) so the data is real, queryable, and survives outside the app. Per-row sharing is expressed with two fields — x_visibility (private/shared/public) and a many2many x_shared_with on res.users — and the read domain folds them into one query. On top of that sits Vizum.realtime: a lightweight pub/sub channel that nudges every open board to reload when the model changes. The same channel pattern drives Rent Roll (Vizum.realtime.channel("rentroll")), where a shared units/leases board refreshes live across the team.
Open one channel at boot, subscribe to a "changed" event, and reload from the native model whenever a ping arrives. The channel name ("taskboard") is the shared namespace — every client running this app joins the same room.
await Vizum.connect();
// One shared room for the whole app — same name on every client.
const CH = Vizum.realtime.channel("taskboard");
// A ping is just a nudge: re-read the source of truth (the native model).
CH.on("changed", () => reload());
await reload(); // initial paint from x_vzm_taskboard_board
The read domain is what makes sharing work: a single OR query returns boards you own, boards explicitly shared with you, and public boards. Per row you then derive the capability flags — _can_edit is owner-or-in-sharees, _can_delete is owner-only — so the UI and the write path can both gate on them.
async function reload() {
// owner OR shared-with-me OR public — one query, three visibilities.
const domain = ["|", "|",
["create_uid", "=", UID],
["x_shared_with", "in", [UID]],
["x_visibility", "=", "public"]];
const rows = await Vizum.orm.searchRead(MODEL, domain, BOARD_FIELDS, { order: "id desc" });
boards = {};
rows.forEach((r) => {
const mine = m2oId(r.create_uid) === UID;
const sw = Array.isArray(r.x_shared_with) ? r.x_shared_with : [];
boards[r.id] = {
id: r.id, name: r.x_name || "", data: r.x_data || "",
_can_edit: mine || sw.includes(UID), // owner or in x_shared_with
_can_delete: mine, // owner only
};
});
render();
}
Publish on write, not on read. The save path writes the board JSON to the native model, then fires one CH.publish("changed", {}) so every other open board reloads. The whole thing is gated behind _can_edit, so a sharee who only has read can never reach the write — and never broadcasts a phantom change.
async function flush() {
const r = boards[current];
if (!r || !r._can_edit) { return; } // read-only sharees never write
try {
await Vizum.orm.write("x_vzm_taskboard_board", [r.id], {
x_name: r.name,
x_data: JSON.stringify(board), // columns + cards as JSON
});
CH.publish("changed", {}); // nudge every open board to reload()
} catch (e) {
Vizum.ui.notify("Save failed: " + e.message, "warning");
}
}
The realtime channel is ephemeral — the payload is empty ({}), it carries no board state, and a missed ping costs nothing because the next reload re-reads everything. The source of truth is always the native model: Vizum.orm.write persists, CH.publish merely tells other clients "go look again." That split keeps sharing honest. A sharee with read-only access sees every update the moment it lands (they receive the ping and reload()), but _can_edit is false for them, so flush() short-circuits — they can watch the board move but never write to it or broadcast a change. Deletion is even stricter: _can_delete is owner-only.
Examples
The reference app — 5.67, used the way an app should
A small orders app that uses what the platform added in 5.67, the way an app should, with the tests that prove it — all on the platform emulator, no Odoo needed:
node --test sdk-next/examples/reference-orders/tests/orders.test.mjs
| What | Where | Read |
|---|---|---|
An optional dependency on a contract (customers:v1) | manifest.json requires | composition.md |
| Events with a declared, versioned shape; an emit that breaks it is refused | event_contracts, createOrder | events.md |
A collection every user shares (data: global), refreshed live in every open copy | index.html | live-data.md |
A concurrent change handled: a stale rev is read again and retried once | cancelOrder | platform-contract.md |
| A file stored with the checksum of its bytes | attachReceipt | files.md |
| An AI tool that changes content declares it; the host asks the user first | ai_tools, registerAiTools | ai-tools.md |
Offline and other failures arrive as a VizumError with retryable | tests | reliability.md |
The manifest and src/orders.js pass vzm check — each function types its SDK parameter (@param {typeof Vizum} V), so the check follows every call | tests | cli.md |
This is an example. It is never packaged or published: a vApp reaches an instance through the Store (RULE 0B), and this one only exists to be read and run.
Ship it
Packaging, installing, updating
cd my-app/
zip -r ../com.yourcompany.appname.vapp . -x '.*'
| Action | How |
|---|---|
| Install | Dock 🧩 → “+ Install .vapp” → pick the file. Zero-risk apps are ready instantly. |
| Update | Same id, higher semver, install again. Widened permissions wait for the admin; otherwise it swaps in immediately (reopen the window to load fresh files). In the Store the Update button first shows a review of what changes. A first install whose requires app is missing waits in “needs approval” (waiting for <app>) until that app is installed — see Composition. |
| Uninstall | Apps manager → Uninstall (installer or admin). App windows close, KV data is removed. |
Developing comfortably
console.log from your app shows in the browser console.
Files are cached for an hour — close and reopen the window after updating.
Test permission failures on purpose: call something you did not declare and make
sure your UI degrades gracefully.
Ship it
Publish on the Vizum Store
Reach every Vizum desktop with one upload — free apps install in one click, paid apps are licensed per database with signed tokens.
Your vizumapps.com account
One account does everything: buying apps for your database and
publishing your own. Create it without leaving the desktop — the 👤 button
in the Vizum Store window opens Sign in / Create account. The desktop
stores only an opaque token (never your password); an administrator links the
account once and every purchase from that database attaches to it. Your card is
entered at checkout through Stripe's embedded payment element — the publisher
never sees card numbers. The same login opens the developer portal
(vizumapps.com/my/vizum-apps) where you submit and
manage your published vapps.
| Step | What happens |
|---|---|
| 1 · Create the app | On the publisher backend (Vizum Store → Apps): key, summary, category, industries, screenshots, pricing (free / one-time per database / subscription). Suggest your industries in the manifest with "industries": ["restaurants"] — see Store listing. |
| 2 · Upload a version | Drop the .vapp — version, minimum platform and the permission summary are extracted and validated from the manifest automatically. |
| 3 · Publish | The app appears in every connected desktop's Store window. Updates: upload the next version, users see an “Update” pill. Pressing it opens a review sheet that shows what the version changes (data loss, irreversible steps, server code, permissions, migrations) before anything is installed. |
| Paid apps | One purchase licenses the whole customer database. Licenses are ed25519-signed tokens verified offline by the platform; subscriptions get a 14-day offline grace. |
Before you submit: the publish gate
Run npx vzm check --gate publish in the project. It answers one question —
does the app work on every Odoo line its manifest installs on? — with ok (exit 0),
blocked (exit 1: a field, model or method missing on a target line, a call the bridge refuses, a
grant the code needs; fix it or exclude that line with odoo_exclude /
max_odoo) or pending (exit 3: what the checks cannot verify, such as an
ORM call whose model is a parameter; acknowledge each item with a note in
vzm-compat.json, which is never packaged). The Vapp Creator's «Submit to
Store» runs the same check. For now the store reports the verdict to its reviewer and refuses
nothing for it. Every code: The publish gate.
Ask for no more than the code uses: npx vzm grants computes the smallest
permissions.orm your calls need (never "*") and
lists what to add and what to narrow. --write applies the proven narrowing;
a widening, which an administrator approves again on update, needs --allow-widen.
See The grant your code needs.
Signing your package
Every package you upload must be signed (from the date Vizum sets, the store refuses a new version
that carries no valid signature from its developer; versions already published are not affected).
Sign what you upload: npx vzm keygen --publisher yourname once, then
npx vzm pack dist --sign vzm-signing.key — or, on a finished package,
npx vzm sign app.vapp --key vzm-signing.key — and check it with
npx vzm verify app.vapp (it must say publisher_signed).
The refusal message names these same commands. A signature is
integrity — it proves the bytes are yours and unchanged, and the store pins your app to your
key so nobody else can ship a version of it. It is not a security review. Keep the key secret and
backed up. The com.vizumapps.* namespace is reserved for Vizum's own
apps (they need the Vizum publisher signature); use your own reverse-DNS prefix. Full rules:
Package signing.
Who trusts what
An instance installs unsigned packages by default (allow_unsigned, the
launch default). After the first signed install the instance pins the publisher key, so every
update must carry a signature by that same key, and the store's own signature never replaces it.
Administrators can tighten the policy to require_signed or
require_store after a preview. The full model, for administrators and
developers: Package signing, "The trust model".
When the platform's rules tighten (SDK 5.97)
A version you already published, and every copy already installed, is judged again when the platform's rules change — by the same checks that judged your upload. On the store, a gate that tightens re-runs on every version it still serves: you and the store administrators get an email naming what fails. The version stays published; only a high-severity, exploitable finding (server code the vetter now refuses, a secret in the package) lets a store administrator withdraw it. On each instance, the platform re-checks the installed apps after an upgrade and once a day, and does the mildest thing that removes the risk:
| What changed | What happens to the installed app |
|---|---|
| A validator rule your package no longer passes, or an ORM grant the platform now refuses | Reported on the app's card (policy health check); the app keeps running |
| A permission you hold became privileged | The app keeps running with a red finding until an administrator approves it again or disables it |
| A view, route or hook is now refused as unsafe | That one part is held (view off, route 404, hook raises); the rest of the app runs |
| The installed files no longer match the verified package | Disabled, as the package trust policy always did |
A re-check that would act on more than 5 apps, or 20 % of the running ones, changes nothing
until an administrator confirms. Every decision is in the app's audit trail under
policy:. Publishing a version that passes resolves it. Details:
sdk-next/docs/lifecycle.md, “When the platform's rules tighten”.
Ship it
Store listing — industries
*Store-side, since vizumapps.com store 19.0.1.15.0 (2026-10-11). No platform capability: the platform ignores the key, so it is safe in a manifest for any min_platform.*
The Vizum Store groups apps two ways:
| what it says | who sets it | how many | |
|---|---|---|---|
| category | what the app *does* — productivity, sales, operations, inventory, accounting, integrations, games… | the store, when the app is created | one |
| industries | which kind of business it *serves* — restaurants, HVAC, distribution… | the store administrators, from your suggestion | several |
Customers browse by industry on vizumapps.com/apps (a chip per industry and a page per industry, /apps/industry/<slug>) and in the Desktop's Store window (the chip row under the search).
Suggest your industries in the manifest
{
"id": "com.acme.kitchen",
"name": "Kitchen Board",
"industries": ["restaurants", "hospitality"]
}
| slug | industry |
|---|---|
general | General — useful to any business (documents, mail, AI, dashboards, timers…) |
restaurants | Restaurants, cafés and bars |
hvac | Heating, ventilation, air conditioning and refrigeration |
supermarkets | Supermarkets and grocery stores |
distribution | Wholesale and distribution, van sales |
logistics | Warehousing, fleets, drivers |
retail | Shops and online stores |
manufacturing | Shop floor, quality, ISO |
field-service | Technicians in the field |
rental | Equipment and vehicle rental |
real-estate | Agencies, landlords, rent roll |
hospitality | Hotels and holiday lets |
professional-services | Law firms, accountants, agencies |
How the store treats it, on every upload:
- A suggestion, not a setting. The store applies it only while your app has *no* industry yet. Once it has one — from an earlier upload or chosen by a store administrator — later manifests change nothing; ask the store to change it.
- At most 3. Name the industries the app is really built for;
generalis for apps any business uses. Extra slugs are dropped with a warning. - Unknown slugs are warnings, never a refusal. The store's administrators can add industries after this list was written; a slug the store does not know is reported in the version's validation report and skipped.
- A version that fails validation applies nothing.
npx vzm check warns about the same things before you upload: INDUSTRY_UNKNOWN (a slug outside the list above) and INDUSTRY_TOO_MANY (more than 3). Neither blocks a build or the publish gate. A value that is not a list of strings is a malformed manifest, reported by the schema check like any other wrong type (the store itself ignores it with a warning).
What the catalogue API returns
GET /store/api/v1/catalog — each row carries industries: ["restaurants", …] (active industries only, in the store's order); the payload carries industries: [{slug, name: {en, es, fr}, icon, sequence, count}], limited to the industries of the rows the caller may see and counted over them, so a customer's private app never reveals an industry to anyone else. icon is a Font Awesome 4 class (fa-cutlery).
Ship it
The publish gate — vzm check --gate publish
Before an app goes to the Vizum Store, one question: does it work on every Odoo line its manifest installs on? The publish gate answers it with one verdict — ok, pending or blocked — computed by one function, compatVerdict() in @vizum/vapp-cli/lib/compat-gate.mjs. vzm check --gate publish runs it, the Vapp Creator's «Submit to Store» runs the same function in the browser, and the Store reports what it says. Same files in, same bytes out.
npx vzm check --gate publish # exit 0 ok · 1 blocked · 3 pending items not acknowledged
npx vzm check --gate publish --json # the whole verdict, for CI, an agent or the Creator
npx vzm check --gate publish --acks path/to/vzm-compat.json
vzm: blocking ODOO_FIELD_MISSING_ON_LINE: sale.order.line.tax_id does not exist on Odoo 19, 20, a line this manifest installs on — there it is tax_ids (src/app.js:4)
vzm: pending ODOO_UNVERIFIED_CALL: orm.searchRead: the model of this call is not a name the reading can follow (a parameter, a wrapper, a value built at run time), so its Odoo model, fields and grant are not verified (src/load.js:12) — fp 63f390908c100c0e
vzm: publish gate BLOCKED for com.example.app 1.4.0 on Odoo 17 (atlas), 18 (atlas), 19 (atlas), 20 (atlas): 1 blocking, 1 pending, 0 acknowledged, 0 waived
Plain vzm check does not change: its Odoo schema errors still do not block (--strict-odoo makes them), because they fail when the code runs, not at install. The gate is where they block — only for publication. Installing on your own instance and downloading the .vapp are never gated.
Three categories
| Category | What it is | What Submit needs |
|---|---|---|
| blocking | A verified defect: the platform refuses the package, or the code fails on a line the manifest installs on | Fix it, or stop installing on that line (odoo_exclude, max_odoo, min_odoo). There is no override |
| pending | Something the checks cannot verify. Never silent | A written acknowledgement per item in vzm-compat.json (below) |
| info | Worth knowing, never gating | Nothing |
verdict is blocked when anything blocks; else pending when a pending item has no acknowledgement; else ok.
Blocking
| Code | When |
|---|---|
ODOO_MODEL_UNKNOWN, ODOO_MODEL_MISSING_ON_LINE | A model no target line has, or that one target line lacks (code, or native.fields[].model / relation) |
ODOO_FIELD_UNKNOWN, ODOO_FIELD_MISSING_ON_LINE | The same for a field (tax_id on 19/20 → tax_ids) |
ODOO_METHOD_UNKNOWN, ODOO_METHOD_MISSING_ON_LINE | The same for a method a call: names (name_get exists on 17 only). Odoo's BaseModel methods (action_archive, copy, name_search…), the base model's and the app's own native.methods count as present |
BRIDGE_CALL_REFUSED | A call the bridge never runs: a private _method, an @api.private method, a CRUD primitive as call:, a call the ORM policy closes; on the Creator also what this instance's bridge refuses (live) |
GRANT_MISSING | Code that does what permissions.orm does not grant (no entry, an op not listed, a write outside the ceiling). vzm grants --write --allow-widen writes the missing entries (grants) |
Every error of plain vzm check blocks too, under its own code (SCHEMA, NATIVE, ORM_GRANT_REFUSED, NATIVE_MODEL_WRITE_UNGUARDED, MIN_PLATFORM_TOO_LOW, QWEB_T_RAW, BRIDGE_GATE_REFUSED…): they already make vzm check exit 1. BRIDGE_GATE_REFUSED is a bridge call the host's own gate refuses for the manifest before any server request (CattlePro 1.13.0's Vizum.offline.create on a manifest with no offline writes: «Load sample data» answered PermissionDenied); like the bridge codes above, no waiver silences it.
permissions.orm names that a line lacks stay info: the platform drops them from the grant on that line. A field read outside permissions.orm.fields is info too: the bridge drops it, it does not refuse.
Pending
| Code | What cannot be verified |
|---|---|
ODOO_UNVERIFIED_CALL | An ORM call whose model (or, for a call:, method) the reader cannot name: a value built at run time, a wrapper it cannot follow. A model passed to the app's own helper is followed to the helper's call sites (Odoo schema checks): the item is the call site that cannot name it, not the helper. Each call is listed at its place |
ODOO_NOT_READ | A file that names the SDK but is too deep or too much work to read |
ODOO_NO_DATA | A model whose fields or methods the code uses, on a target line the atlas has no data for (a model outside the atlas' module set, an addon in odoo_requires it does not know). One per model. A line where no call on the model runs — each one sits behind a direct Odoo version guard that excludes it — is not counted |
ODOO_MODEL_MISSING_ON_LINE_GUARDED, ODOO_MODEL_UNKNOWN_GUARDED | A blocking existence finding on a call inside a try / .catch, or (*_MISSING_ON_LINE) inside a condition that is no direct Odoo version guard: the app may handle it, the reader cannot tell |
ODOO_FIELD_MISSING_ON_LINE_GUARDED, ODOO_FIELD_UNKNOWN_GUARDED | The same, for a field |
ODOO_METHOD_MISSING_ON_LINE_GUARDED, ODOO_METHOD_UNKNOWN_GUARDED | The same, for a method |
A call behind a direct Odoo version guard (if (odooVersion() >= 20), an early return) is judged only on the lines it runs on — no finding at all when the guard explains it (odoo-schema).
Info
| Code | What |
|---|---|
GRANT_UNUSED | A permissions.orm entry for an Odoo model nothing of the app names (code, any string of the sources, the rest of the manifest). vzm grants (grants) lists it as a remove_model and writes it with --write once that removal is proven |
COMPAT_ACK_STALE | An acknowledgement that matches no pending item any more: the code moved on or was fixed |
COMPAT_ACK_INVALID | A vzm-compat.json or entry the gate cannot use (bad JSON, no fingerprint, a note under 10 characters) |
COMPAT_ACK_REFUSED | An acknowledgement of a blocking finding: blocking findings cannot be acknowledged |
COMPAT_WAIVER_REFUSED | A waiver of a code that cannot be waived (below) |
COMPAT_WAIVER_INVALID | A waiver without a reason of at least 10 characters |
COMPAT_WAIVER_UNUSED | A waiver that matches nothing at its line or the next |
The warnings and infos of plain vzm check (PERMISSION_UNUSED, NATIVE_ANCHOR_UNLABELLED, FEATURE_DETECTED…) are info here, under their own codes.
Acknowledging a pending item: vzm-compat.json
At the project root, next to manifest.json. Never packaged: vzm pack leaves it out, and the Creator keeps it with the project. The Store's report lists the pending items itself.
{
"schema": 1,
"acks": [
{ "fp": "63f390908c100c0e", "code": "ODOO_UNVERIFIED_CALL", "file": "src/load.js",
"note": "load() is only ever called with res.partner and sale.order, both granted", "at": "2026-10-11" }
]
}
fpis the finding's fingerprint, printed byvzm check --gate publishand in--json:sha256(code | file | model | name | the source line, whitespace collapsed), first 16 hex characters. It survives lines added above (no line number in it) and changes when the code at that place changes — then the item is pending again and the old entry is reportedCOMPAT_ACK_STALE.noteis required, at least 10 characters: why the item is fine. The reviewer reads it.- An
ODOO_NO_DATAfingerprint names the model and the lines, not a place: it survives any edit of the code and changes when the target lines do. - Blocking findings cannot be acknowledged (
COMPAT_ACK_REFUSED). - The JSON schema is
lib/vzm-compat.schema.json.
Waiving an atlas false positive
When the atlas is wrong about an Odoo name — a field a module outside Odoo's own adds that you cannot declare in odoo_requires — waive it where it is reported, with a reason:
// vzm-compat-ignore ODOO_FIELD_MISSING_ON_LINE: x_ref comes from the customer's own studio module on 19+
const rows = await Vizum.orm.searchRead("sale.order.line", [], ["x_ref"]);
- On the finding's line, or alone on the line above.
//and/* */both work. - The reason after the colon is required, at least 10 characters. Waived findings are listed (
waived) with their reason, counted, and shown to the Store's reviewer; each one should become an atlas or checker fix. - Only
ODOO_*findings of the atlas or the reader (the existence codes, their_GUARDEDforms,ODOO_UNVERIFIED_CALL,ODOO_NO_DATA). NeverBRIDGE_CALL_REFUSEDorGRANT_MISSING— the bridge refuses the call, or needs the grant, on every line — and never a finding from the instance itself (live). A refused waiver leaves the finding in place and addsCOMPAT_WAIVER_REFUSED.
Live data and the atlas: the same verdict everywhere
The atlas part is computed the same way in vzm, the Creator and the Store. On an instance, the Creator adds what only the instance knows, for its own line (a call its bridge refuses, later what a dry-run install says); live data never removes an atlas blocker. So the Creator's verdict contains the CLI's, and the CLI's contains the Store's. With live data off, the Creator's verdict is byte-identical to vzm check --gate publish --json for the same files: the test fixtures in sdk-next/packages/vapp-cli/test-fixtures/compat-gate/ (ok, blocked, pending, acknowledged, waived, each with its verdict.json) are what both sides are checked against.
Paths in the verdict are relative to the project root (src/app.ts), as the Creator holds them.
In code
import { compatVerdict, compatVerdictJson, verdictExitCode, ackEntry } from "@vizum/vapp-cli/lib/compat-gate.mjs";
const v = compatVerdict({
manifest, // parsed manifest.json
sources: [{ file: "src/app.ts", text }], // project-relative paths
ctx: { contract, schema, i18n, odoo: { atlas, parser, live: null } },
acks: vzmCompatJsonText, // or null
});
v.verdict; // "ok" | "pending" | "blocked"
v.blocking; v.pending; v.acknowledged; v.waived; v.info; // findings: {code, category, severity, source, message, file?, line?, model?, name?, lines?, fp?}
verdictExitCode(v); // 0 / 1 / 3
ackEntry(v.pending[0], "why this is fine", "2026-10-11"); // an entry for vzm-compat.json
In Node, publishGateInputs(manifestPath) from lib/contract.mjs reads a project exactly as vzm check --gate publish does.
The Store: report-only for now
First-party publication stages the verdict beside the package (node tools/compat_gate_report.mjs <app dir> --out <PUBDIR>/<key>.compat.json, which exits 0 whatever the verdict), and tools/publish_apps_targeted.py prints it and stores it on the uploaded version (validation_report.compat_gate) for the reviewer. It refuses nothing: the Store starts enforcing only after two weeks with zero unexplained blockers over its catalogue, and a version already published is never withdrawn for this gate.
What the gate said on 2026-10-11 over the 135 apps of this repository (node tools/compat_gate_corpus.mjs), first with the reader stopping at a helper's parameter, then following it to the call sites:
| before | after | |
|---|---|---|
| Verdicts | 0 blocked · 79 pending · 56 ok | 9 blocked · 42 pending · 84 ok |
ODOO_UNVERIFIED_CALL | 472 (78 apps), almost all a model passed to the app's own helper | 277 (41 apps): call sites whose model is built at run time (isocompliance's own-model map, a model the user picks), factories with several returns (rentalpro), classic scripts |
ODOO_NO_DATA | 5 | 50: models the code now names whose module the atlas does not cover (pos.*, helpdesk.*, documents.*, mrp.eco…) |
_GUARDED existence findings | 2 | 22 |
| Blocking | 0 | 24 in 9 apps: names the atlas proves missing on a target line (16 fields, 5 models — most on Odoo 20; some on paths the app never takes there, each said so) and 3 GRANT_MISSING |
On 2026-10-11 the schema atlas grew to the modules the catalogue uses (point_of_sale, pos_restaurant, loyalty, hr_attendance, hr_contract, repair, stock_picking_batch, spreadsheet_dashboard; Enterprise helpdesk, documents, sign, mrp_plm, hr_payroll — odoo-schema): ODOO_NO_DATA fell from 50 to 9 items, and one true blocker surfaced (vizumdesign sent mrp.eco.type, which Odoo 20 dropped; fixed in 3.1.1).
The blockers were there before, behind the helper: each is listed with its reason in tools/compat_gate_corpus_expected.json, and tools/compat_gate_corpus.test.mjs keeps the blocking list equal to it — a blocker not listed there, or listed and no longer reported, is red.
Ship it
The grant your code needs — vzm grants
vzm grants reads the app's code the way vzm check does (the Odoo references of sdk-next/docs/odoo-schema.md) and computes the smallest permissions.orm that lets every call it can name through the bridge. It then compares that grant with the one in manifest.json and lists what would change, model by model. The Vapp Creator's «Grants» tab runs the same function (@vizum/vapp-cli/lib/grant-assembler.mjs), so both give the same answer for the same files.
npx vzm grants # the diff; the manifest is not touched
npx vzm grants --json # everything, as JSON
npx vzm grants --write # applies the proven narrowing and what does not widen
npx vzm grants --write --allow-widen # …and the widening too
Exit codes: 0 the grant is the assembled one · 1 the code needs something the grant lacks (with --write: a widening was left out because --allow-widen was not given) · 3 only narrowing is on offer. --allow-widen without --write is a usage error.
What it proposes
Definition: the assembled grant is the smallest permissions.orm under which the GRANT_MISSING check (grantGap in lib/odoo-schema.mjs) passes for every reference the reader names, plus a call:<method> op for every method the bridge accepts.
For each Odoo model the code reaches:
| Key | Proposed value |
|---|---|
ops | The ops of the calls: searchRead / read → read, search / searchCount / watch → search, readGroup → read_group, create, write, unlink, and call:<method> for each method called. An op that the rule's group_ops already gives is not proposed again |
fields | Proposed only when every read of the model names its fields (a non-empty list of names the reader follows) and every create/write names its values. Then fields = the fields read ∪ the fields written. display_name, create_date and the other magic fields a read names stay in the list, because the bridge drops them from a read whose grant lists fields without them. id is never listed (the bridge always returns it). When one read has no field list (searchRead(model, domain), [], a variable) the read ceiling is not proposed: it would cut what that read returns. A note says which read |
write_fields | The fields the create/write values name, when every one of them is a closed object (no spread, no computed key) that nothing changes after its literal: a const vals = {…} is not closed once the code assigns or deletes one of its keys (vals.x = …, vals[k] = …, delete vals.x), hands it to any other call (Object.assign(vals, …), fill(vals), a second ORM call; an app helper the reader follows is fine when the helper itself does not change its parameter), binds it to another name, a return, a property or an array, or exports it. Reading a value (vals.note) or copying it ({ ...vals }) keeps it closed. Otherwise the declared ceiling is kept as it is, never invented |
domain, chatter_only, group_ops, any other key | Kept exactly as written: the assembler cannot read intent |
A field renamed between Odoo lines (res.users groups_id / group_ids) brings its other name: the bridge translates either. The app's own native fields on a model (native.fields) are never listed: the platform adds them to any ceiling.
What it never proposes
"*". A manifest with the wildcard is offered the explicit rules instead. Each rule that replaces what"*"served is markedcovered: "*". It is still a new model to the platform, so an update that takes it is approved again by an administrator (below).- What the platform refuses (VZ-SEC-30, read from the contract's
orm_policy): the credential models (ir.config_parameter,vizum.app.oauth,vizum.app.secret… — any op), anything but reading the identity models (res.users,res.groups…), writes on the code models (ir.filters,ir.default,base.automation…) andir.actions.*, anycall:onir.ui.view/mail.template/ir.actions.reportexceptget_combined_arch/send_mail/report_action,create/unlinkand fields beyond the scheduling ones onir.cron,vizum.*outside its allowlist, the store's own models, and thevizum.appcalls reserved for a first-party app (VZ-SEC-31). A method the bridge refuses (private, a CRUD primitive,@api.private) is not proposed either. Each one is listed under refused with its place in the code. - The app's own models (
x_vzm_<app>_*, anyx_*,native.models): the platform grants them; a rule the manifest already has for one is left as written. A model the code reaches only through the app's own native fields, with read / search / write, needs no rule at all (the implicit grant): a note says so. - A guess. A call whose model or method the reader cannot name (a parameter, a wrapper, a value built at run time) is listed under unverified with its file and line, and its grant is not computed. These are the
ODOO_UNVERIFIED_CALLitems of the publish gate.
The diff
Each entry names a model and a kind:
kind | Means |
|---|---|
add_model | The code uses a model the grant does not name |
add_op | A missing op on a granted model |
add_field | A field the code reads or writes is outside the declared fields |
add_write_field | A field the code writes is outside the declared write_fields |
unrestrict_fields / unrestrict_write_fields | The ceiling would have to go |
remove_model | A model nothing the reader follows uses ("*" included) |
remove_op | An op no call needs |
narrow_fields | A tighter read ceiling (fields: the new list, or remove: the names that go) |
narrow_write_fields | A tighter write ceiling |
remove_duplicate | A second rule for one model: only the first one binds |
widening: true means the platform asks an administrator again when an installed app updates to it. The rule is the platform's own (vapp_validation._orm_widened): a new model, a new op, a wider fields or write ceiling. Every add_* entry is widening. An add_* entry that is not covered: "*" is a true GRANT_MISSING: the code calls something the grant does not let through. An add_field with silent: true is a magic field that vzm check does not report, and that the bridge drops anyway.
certain: true on a narrowing means it is proven: nothing the reader cannot follow may need what it removes. A narrowing is not proven (certain: false, with why) while any of these holds:
- a call whose model the reader cannot name, or a file it could not read;
- an ORM-shaped call (
.search(,.write(…) on a receiver it cannot follow (a wrapper, an injected client); - a library whose ORM calls take the model at run time (
@vizum/vapp-forms,@vizum/vapp-query); - server code (
native.methods,native.script): itsenv[...]is governed by the same grant; - the model named somewhere outside a call the reader follows: in the code (outside comments; for example
Vizum.ui.openRecord("res.users", id)) or in the manifest outside the grant and the app's native schema (hooks, triggers, actions, offline datasets, routes); - a one2many / many2many value written on another model whose commands reach this one (an order's
order_line): those records answer to this model's grant, and the reader does not follow the commands.
Writing it
Without --write nothing is written. With --write:
- proven narrowing and every non-widening change are applied;
- widening is refused unless
--allow-widenis given, one line per refused entry; - a narrowing that is not proven is never written, whatever the flags: remove it by hand once you know that nothing else uses it.
Only permissions.orm changes in the file: every other byte stays as it was, and the new array is written with the file's own indentation.
In code
import { assembleGrants, applyGrantDiff } from "@vizum/vapp-cli/lib/grant-assembler.mjs";
import { loadContract, loadOdooContext } from "@vizum/vapp-cli/lib/contract.mjs";
const r = assembleGrants({ manifest, sources: [{ file: "src/app.ts", text }], ctx: { contract: loadContract(), odoo: loadOdooContext() } });
r.proposed; // the assembled permissions.orm
r.diff; // [{model, kind, op?, field?, widening, certain, why?, covered?, silent?}]
r.refused; // what the code asks and the platform refuses: never proposed
r.unverified; // calls whose grant is not computed, with their place
const { orm, applied, skipped } = applyGrantDiff(r, { allowWiden: false });
r.residual lists any GRANT_MISSING that the proposed grant still leaves. It is always empty; an entry there is an engine defect. The corpus test (grant-assembler.test.mjs) fails on any entry.
The corpus report — every vApp of the repository, equal / narrower / missing: node tools/grant_assembler_corpus.mjs (--json, --app <dir>).
call: on another app's method (#4387, platform 5.139)
A call:<method> grant on a native model of another app (x_vzm_<other>_*) reaches that method only when one of these holds:
- the method is
public: truein its owner'snative.methods; - both apps are verified as published by the same publisher: the key whose own signature covers the installed bytes (
publisher_signed_key), or the key the Vizum Store names for a store-verified package, with the bytes still intact. An id prefix (com.psi.*) or the manifest's ownpublisherstring proves nothing; an unsigned, sideloaded (local) or unverified package has no publisher and is never trusted; - the owner lists the caller in
native.methods[].callable_by.
{ "model": "alarm", "name": "rm_signage_states", "code": "...", "callable_by": ["com.partner.signage"] }
callable_by is 1-16 distinct app ids (not the app's own; not on a public method or an override), surfaced as permissions.callable_methods ("<model>.<method>><app id>"): naming one more app re-approves the update. A vApp that declares it sets min_platform 5.139 (vzm check says so).
Otherwise the call is refused with an AccessError naming both apps: at every call (orm.call, orm.callOn, an action, and V.orm.call / a record method inside a script) and at install, update and approval when the owner is installed (an owner installed later is caught at call time). Being a user of the owning app does not open it. Odoo's own methods (button_confirm), overrides of them and the app's own methods keep their rules. vzm check warns (FOREIGN_APP_METHOD) on such a grant: the CLI sees only this manifest, not the owner's publisher. tools/audit_cross_app_private_calls.py lists the grants an instance would refuse.
Grant keys added for the AboveCG Panel (SDK 5.121)
ops: []withgroup_ops. A grant may have an empty plainopslist whengroup_opscarries every op. Use it for a model only one group may touch: a placeholder op inopsis granted to every user of the app, and Solo mints a native read ACL for the whole Solo group from any plaincall:op.create_fields. The field ceiling of a create, apart fromwrite_fields(a write), both narrowed byfields. Without it a create is held to the write ceiling, as before. Use it when an app files a record with several fields but may only re-file it afterwards (a photo: create with project/file/caption, writelog_idonly). It needs thecreateop; widening it re-gates an update.strict_paths: true. Opt-in per grant. Every path of a search domain on the model (each hop, and the sub-domain ofany/not any) must name a field in thefieldsof the model it sits on, and the user needs a grant on every model crossed. The same walk applies to read_groupgroupbyand aggregates (field:granularity,field:sum), toorder(search, search_read, read_group) and to thehavingand domain offormatted_read_group/name_search, because a group key or a sort position over an unreadable column is the same oracle; otherwise the call is refused (an empty answer would still be an oracle:vizum_contract_ids.contract_total > 0overproject.projectbinary-searches an amount the app may not read). A path that ends on a many2one or x2many compared by anything but ids (ilike,like, a string with=,inwith strings, an ambiguous value) also needs the comodel's_rec_namegranted, and a group-by or order on a many2one needs it too (Odoo groups and sorts by the related name). A many2one in a read, search_read or read_group answer comes back as[id, false](the id, no name) when the comodel's_rec_nameis not a granted field of the user, or the record is outside the comodel grant'sdomain; with the name granted AND the referenced record inside the comodel grant'sdomainit stays[id, display_name](one batched search per comodel over the ids, with the grant domain, as the user; a failed check hides every name). Clients read the id first (v[0]) and treat a falsy name as empty.name_searchis refused unless the model's_rec_nameand the fields its display name depends on are granted, because it answers[id, display_name]for every match. A hop into a related model keeps that model's grantdomain: each path leaf is rewritten to(hop, any, <grant domain of the comodel> + leaf), nested per hop (anyexists on Odoo 17 to 20), sopartner_id.credit_limit > 0counts only partners the grant exposes; a name comparison (ilike,=a string,instrings) on such a comodel is rewritten onto its_rec_nameand a negated or hierarchical one is refused. Agroupbyororderkey that crosses a model whose grant has adomainis refused (no rewrite can scope it). Every bridge entry that takes a domain (api_ormsearch / search_count / search_read / read_group / name_search / formatted_read_group,api_watch_hash) runs the guard;read_progress_bar,search_panel_select_*,web_*and similar domain-taking methods are refused ascall:targets on a strict grant. Dropping the key re-gates an update. Not on by default because older apps search by unlisted fields.- Per-app grants on platform models.
vapp_validation.PLATFORM_APP_OWN_GRANTSnames, per app id, the exact ops (call:targets included) onvizum.*models that app alone may hold. It opens nothing to any other app. Mirrored in the contract (orm_policy.platform_app_models), the Store gate and the CLI.
Ship it
Troubleshooting
| Symptom | Cause → fix |
|---|---|
| “manifest field 'x' is required” | Fill every required field from §04. |
| “illegal path in package” | Your zip contains ../ or absolute paths — zip from inside the app folder. |
| “Version x.y.z already installed” | Bump version. |
| App stuck “needs approval” | It declares orm/net/data:"global" — an administrator must approve it (dock 🧩 → Approve). |
PermissionDenied at runtime | The call isn't covered by the approved manifest. Widening needs a version bump + re-approval. |
PermissionDenied and the instance log shows no request | The host's gate refused the call in the browser: a namespace being present grants nothing. Vizum.offline exists in every app, yet offline.create/write need the model in your approved offline.writes; Vizum.ui.notify needs "notify" in permissions.ui. Decide from the caps that Vizum.connect() resolves with (the snapshot the host gates with), never from if (Vizum.offline) or Vizum.supports(). vzm check reports it as BRIDGE_GATE_REFUSED. |
| Reads return fewer fields than asked | Field filtering at work — only declared fields (+id) come back. |
| Unexpectedly empty search results | A manifest domain, or Odoo record rules for that user, narrow the query. |
| Styles ignore dark mode | Use the CSS variables from §07 instead of hardcoded colors. |
Vizum.net.fetch rejects | The host is missing from permissions.net (exact hostname or *.wildcard), or the app update widened it and awaits re-approval. |
| App back in Needs approval after an update that added no permission | Since SDK 5.69 a change to server code — a script hook, the script library, a model method, a route, a job or a webhook — is approved like a wider grant. The app's detail lists what changed under Server code changed; an update with the same code keeps the app running. |
| “needs approval — waiting for <app>” | The app requires another app that is not installed (or out of range). Install it, then approve; even a zero-risk first install waits. See Composition. |
Vizum.net.fetch “port 8443 is not declared” | From min_platform 5.74 a named host is reachable on 80/443 only. Write "host:8443" in permissions.net. vzm check warns NET_PORT_UNDECLARED before you raise min_platform. |
| A paired screen shows the PIN again after a week offline | The server-side lease is 7 days without an authenticated call: pair it again. See Device routes. |
| A job or trigger fails with “cannot create <model>” and a hint about the job user | The job user lacks, in Odoo's own access control, an operation a job or trigger hook uses through V.orm (a literal V.orm.<op>('<model>', …) is what the platform can check). An administrator fixes the user in Settings › App Permissions › Job user; administrators are notified once a day and after every install, update and approval. |
| «Use the app's service user» shows groups, or refuses with “needs an administrator to <op> <model>” | The service user is given the fewest Odoo groups (never an administrator group) that cover what your job and trigger hooks do through V.orm on Odoo's own models; the administrator sees them before saving. When only an administrator group grants one of those operations, the platform refuses: ask for less in permissions.orm, or leave that work to the frontend, which runs as the viewing user. |
| The app's service user shows as archived | That is its normal state: the platform keeps service users archived so they never count as an Odoo user licence, and the app runs with it archived. Only the app's own service user is accepted archived; nobody can unarchive it. Settings › App Permissions › Service users lists them. |
| Device calls answer 503 | The app has device routes but no job user: declare "native": {"service_user": true}. |
| “needs min_platform 5.7x / 5.8x or later” | You used a newer API than your min_platform says: V.tr 5.77, group_ops 5.78, V.identity 5.79, offline 5.80, a unique on x_company_id or jobs with data: "user" 5.81, V.vector.shared_namespaces 5.83. vzm check tells you before the installer does. |
| Pack or install refuses “declares a language without its catalogue” | The manifest's i18n lists a language that has no i18n/<lang>.json (I18N_CATALOGUE_MISSING). Add the file or drop the language; for a Vite build, vzm check --dist dist reads the build output. |
| Odoo's own screens (field labels, menus, selection options) stay in English | Since 5.84 they come from the same i18n/<lang>.json, matched by the English text or an @native: key; a missing key stays English and a translation an administrator typed by hand is kept. vzm check lists the strings the catalogue does not cover; vzm i18n import-po converts an existing module's .po. |
| A server message arrives in English on Solo or in a job | The host must send the user's language: Desktop, native windows and Solo all do (Vizum.supports("server-i18n")); a job speaks its job user's language, and V.tr(..., lang=partner.lang) chooses one. |
| A guard cannot tell the user's write from your own method's | Read rec["su"] (5.81): True for a superuser environment such as sudo(). V.is_service_user() says the code runs as the app's own job user. |
Vizum.identity.confirm refused | Not available on Solo (UNSUPPORTED_CALL) and a device page gets PERMISSION_DENIED. The token lives 120 s and is single use: spend it with V.identity.consume(token, action) in server code. See Signing an action. |
An offline write answers CONFLICT or OFFLINE_USER_MISMATCH | The server record changed since the user saw it (base/expect differ), or the queue belongs to another user. Conflicts wait in Vizum.offline.conflicts() for resolve(); a mismatched replay is kept, never applied. See Offline data. |
| A user cannot do a write that an administrator can | Check group_ops (5.78): the extra ops reach only users in the listed groups, and ops stay what everyone gets. |
Vizum.vector.moveScope refused | Administrator or the app's service user only; user-to-global is administrator only. Managed RAG storage lists but cannot move. Nothing is re-embedded or metered. |
| A screen's volume or setting is lost on reload | An opaque-origin frame has no localStorage. Keep it with Vizum.device.store (5.82: 64 keys of 4 KB each per device) and steer a YouTube/Vimeo player with the setVolume / mute / unmute / play / pause of the embed() handle. |
| “already installed for the company …” or another company cannot see an app | An app a developer installs is available to the installer's company. A Settings administrator makes it available to every company, or to one, in Apps Manager (vizum.app.api_set_app_company, audited as platform:company_scope). Store apps and the first-party defaults are global by policy. |
Ship it
Package signing — who made a .vapp, and whether it changed
*Since capability 5.67.* A .vapp is a zip. Until now nothing in it said who built it or whether anyone changed it on the way. Now a package can carry signature.json, every install checks it, and the app's detail shows who vouches for it.
The short version
npx vzm keygen --publisher acme # once: writes vzm-signing.key (mode 600)
npx vzm pack dist --sign vzm-signing.key # pack the built dir + sign, in one step
npx vzm verify com.acme.app.vapp # what an instance will see
Upload the signed package to the store as usual. The store refuses every new version that is not signed by its developer, from the date Vizum sets (sign now) (see "What the store does with it"). Keep the key file secret and backed up: the store pins your app to that key at its first signed version, and every later version must be signed with it — so an attacker who gets into your store account cannot ship a version signed by someone else, and cannot drop the signature either. To rotate keys, ask the store.
What a signature gives you. A signature is *integrity*: it proves the bytes are the ones the key holder signed and that nobody changed them on the way. It is not a review and it does not make an app safe; permissions are still approved by the administrator. As a third-party developer you sign with your own key (vzm keygen, vzm pack --sign), your app id is anything under your own reverse-DNS (com.yourcompany.*), and you never need Vizum's key.
com.vizumapps.* is reserved for Vizum. Apps in that namespace (the first-party apps) get their first-party standing only when the package is delivered by the store and carries a publisher signature made with the Vizum publisher key pinned in the platform (VIZUM_PUBLISHER_KEYS; a list so the key can rotate). That publisher signature must verify with the pinned key and cover exactly the bytes the instance installs: the store's own statement naming the Vizum key does not count, so the store's key alone never makes a package first-party (VZ-SEC-33). The store refuses a com.vizumapps.* upload that is unsigned or signed by another key, and an instance does not give such an app its first-party methods on the strength of from_store alone. A third-party package under that namespace is refused at upload; choose your own prefix.
Uploading needs a signature; installing does not (yet). The store refuses a new version without its developer's signature (D-K10) from the date Vizum sets in vizum_store_publisher.signature_required_from. Versions uploaded before that date, and every app already published, are not judged again: they stay downloadable and installable. On an instance, an unsigned package still installs under the default policy, unless an administrator demands signatures (below).
What an instance does with it
Every install verifies the package's signature before any other install gate runs (after the manifest itself is validated and the revocation list is checked):
| The package… | Result |
|---|---|
| has a signature that does not hold (a file changed, added, removed or renamed; signed for another app or version; a forged statement) | refused, always |
| was delivered by the Vizum Store | store_verified — the store signs exactly the bytes it delivers |
| is signed by its developer only (a private deployment) | publisher_signed |
| is store-signed, but this instance has no store key configured | unverified — or publisher_signed when your own signature still covers exactly these bytes |
has no signature.json | unsigned |
| was built here (AI Creator build, a bundled first-run app, a restore or rollback) | local |
The state, the package digest, the publisher and its key fingerprint are recorded on the app and returned by the installed-app detail (trust).
An administrator chooses what the instance accepts, in the system parameter vizum_apps_platform.package_trust:
| Value | Installs |
|---|---|
allow_unsigned (default — also when the parameter is absent) | everything that is not tampered |
require_signed | store_verified, publisher_signed, local |
require_store | store_verified, local |
A value that is none of these is read as require_store: a typo fails closed.
Tightening the policy disables what no longer meets it. The policy is not only read at install: writing the parameter re-checks every running app against the new value, using the trust recorded when it was installed (nothing is downloaded). An app that does not meet it is set to disabled — it is not uninstalled and none of its data is touched — an audit row trust:policy_disabled records the policy and the reason, every administrator gets a notification, and the app's health report says why it is disabled. Apps installed before signing existed count as unsigned.
- Preview before tightening. An administrator can call
vizum.app.api_package_trust_preview(policy)(anAccessErrorfor anyone else). It returns[{key, name, trust_state, reason}]for the running apps thatpolicywould disable and changes nothing; it uses the same evaluation as the enforcement, so the two cannot differ. - Loosen and reactivate. There is no settings screen for this parameter, so no vApp can lock the administrator out: change it in Settings > Technical > Parameters (key
vizum_apps_platform.package_trust) or withset_param. Loosening re-enables nothing by itself. After loosening, approve each app again (Apps Manager > the app > Approve); approving an app the current policy still refuses is rejected.
Under require_signed or require_store, a local package is accepted only from an administrator, and never replaces an installed app that came signed (an autoupdate rollback is the exception: its snapshot must still hash to the digest of the version it was taken from).
An update keeps its publisher key. When the installed version names a publisher key, a signed update must be signed by the same key, or the install is refused with both fingerprints. An administrator who expects the change accepts it explicitly. A signature proves the bytes are intact and who holds the key — not who the publisher is.
Store keys are a list. The instance verifies store signatures (packages, revocation lists, licence tokens) against every accepted key: the configured vizum_apps_platform.store_pubkey, the extra keys in vizum_apps_platform.store_pubkeys_accepted (a JSON list), and the next key the platform ships ahead of a rotation. Rotating the store's key therefore breaks nothing for instances that already carry the next one.
What the store does with it
- At upload the signature is mandatory (from the date Vizum sets, see below; for every new version of every app, from the first version of a new app to the next version of an old one). The store checks your signature against the bytes you uploaded, records your publisher name and key fingerprint, and pins the app to the key (first signed version that passes). A version with no signature, a signature that does not hold, or one made by another key than the pinned one fails validation and is never served; the report says what is missing and how to sign. An app that is already live stays live on its last passed version. An upload that carries a *store* signature is refused: upload the package you built, not one you downloaded.
- The date. The store parameter
vizum_store_publisher.signature_required_from(YYYY-MM-DD) switches the rule on. While it is not set the rule is off for apps that have no pinned key, so apps published unsigned today keep publishing; Vizum sets the date once publishing signs everything. A value that is not a date is read as a typo and the rule is on from 2026-10-16, never off. An app already pinned to a key refuses an unsigned version whatever the parameter says. - How to fix a refusal. Create a key once with
npx vzm keygen --publisher <name>. Sign the package you upload withnpx vzm pack dist --sign vzm-signing.key, or sign a finished package withnpx vzm sign app.vapp --key vzm-signing.key. Runnpx vzm verify app.vapp: it must saypublisher_signed. This applies to every way in: the developer portal and the store API. The web builder is the one exception, because it holds no developer key: see "Packages built in the web builder" below. - Whose key counts. Until the publisher key registry exists (design D-K1), the key is the one the store pinned at your app's first signed version; a brand new app pins whichever valid key signs it first. Together with the instance-side pin (below), the store's package key alone no longer suffices to update an app that was signed by its publisher.
- The date. The store parameter
- Packages built in the web builder (
/my/vizum-apps/build, "Submit to store") are accepted without your signature and carry a store-built marker: the store vouches for the bytes with its own key and names the reserved publisherstore-builtwith no publisher key. The marker is *never* a publisher signature:publisher_signed_keystays empty, the app is not pinned to anyone, and it has no publisher level. The store card and the Desktop's install dialog show it as an unverified publisher (L0) with the line "Built in the Vizum web builder". Only the builder's submit can set the marker; an upload that carries a store block, a publisher namedstore-builtor any other marker is refused, and the web builder cannot update an app already pinned to a publisher's key (build and sign it locally withvzm pack --sign). To publish under your own identity, sign the package yourself; your first signed version then pins the app to your key. - At download it signs the package it hands over and binds downloads to the database that asked for them, in one of two ways:
- a rewritten
manifest.json— every paid and private download: the store addslicensed_db(andrequires_licensefor a paid app) to your manifest. Your signature then no longer covers the delivered bytes; the store's signature does, and carries yours along as provenance. store-binding.json— free public downloads, once the store operator switches it on (vizum_store_publisher.package_binding:offby default;negotiatedfor a Desktop that sends the request headerX-Vizum-Package-Binding: 1;alwaysonce every Desktop enforces it). Your files reach the instance byte for byte as you uploaded them, plus one store-written file naming the database, so your own signature still covers everything you built and the instance records your key as the one that signed the installed bytes.
- a rewritten
Either way a copy bound to one database refuses to install on another. Paid and private downloads never use the file: the header is not authenticated, and a bound copy carries your original bytes, so removing the binding and the store's signature leaves a package your own signature still covers.
store-binding.jsonis reserved. The store refuses an upload that contains it,vzm packrefuses to pack it andvzm signrefuses to sign a package that carries one: sign the package you built, not one you downloaded.
Revocation — when a published version must be withdrawn
The store can revoke a version (a store administrator, with a reason written for instance administrators). From then on:
- the store no longer serves it (download answers
410 version_revoked) and no longer advertises it as the latest; - every instance that has the store's public key configured checks the store's revocation list every hour (an unreachable store changes nothing). The list is signed by the store — only the store can switch an app off, and a list older than the last one applied is ignored, so an old list cannot be replayed to undo a revocation. The signed list names the instance it was made for (its database uuid; null for the anonymous list): a list made for another instance is refused. An instance that has the version installed disables the app, marks it withdrawn, and notifies its administrators with the reason;
- a withdrawn app cannot be re-enabled, and the revoked version cannot be installed again. Installing a newer version clears the withdrawn mark; the app stays disabled until an administrator approves it again. A revocation is lifted only when the store lists the version under
lifted: a version merely leaving the list (the app was unpublished, or the instance's token was refused for a while) lifts nothing. After a lift the mark goes away and an administrator may enable the app again — it is never re-enabled automatically.
A revocation is listed whatever the app's release status (an app unpublished after a version was withdrawn still has that version disabled where it is installed). A private app's revocation is only listed to the instances that may see that app.
The trust model, for instance administrators and developers
Decision D-K9 of the publisher-identity design, 2026-10-16. This is the whole model in one place. Three rules apply, from the loosest to the strictest.
1. Unsigned is allowed by default, and it stays the default at launch. An instance with no vizum_apps_platform.package_trust parameter (or the value allow_unsigned) installs every package that is not tampered, signed or not. Pilots, local development and private deployments rely on it. The platform reads an absent parameter as allow_unsigned, the install gate never seeds another value, and a guard test pins that default so that changing it is a deliberate act, never an accident. Whether new installations should start from require_store is a separate decision for a later version; it is not made yet.
2. After the first signed install, the publisher is pinned. Once an installed version carries the publisher's own signature, the instance remembers that publisher key (the publisher_signed_key of the app). From then on, whatever the policy is:
- an update must carry a publisher signature by that same key, over exactly the update's bytes. The store's own signature never substitutes: it proves the store delivered the bytes, and the key it names is only its statement, so a compromised store package key cannot update a pinned app (TR8, VZ-SEC-46);
- these updates are refused: unsigned ones, store-signed-only ones, a replayed signature block from another version, one signed by another key, and a package built on the instance (
local); - a verified rollback to a version that already ran on the instance is the one exception, and it keeps the pin;
- the pin outlives the app: when a user who is not an administrator uninstalls a pinned app, the instance remembers the key, and the next install of that app id meets the same pin. An administrator's own uninstall is deliberate and clears it;
- an administrator who expects the change (a publisher rotating its key) accepts it explicitly, with the context
vizum_accept_publisher_key_change. There is no settings screen for it; each acceptance leaves the audit rowtrust:publisher_key_changedwith both fingerprints.
An app that was never signed has no pin. The store, for its part, refuses to publish a new version of any app without its developer's signature (see "What the store does with it"), so the apps you install from the store from now on can be pinned.
3. Administrators can tighten the policy. Set vizum_apps_platform.package_trust (Settings > Technical > Parameters, or set_param) to:
| Value | Installs | Choose it when |
|---|---|---|
allow_unsigned (default) | everything not tampered | pilots, development, mixed sources |
require_signed | store_verified, publisher_signed (and local from an administrator) | every app you run has a publisher, and you want no anonymous packages |
require_store | store_verified (and local from an administrator) | production instances that take apps only from the Vizum Store |
Before tightening, call vizum.app.api_package_trust_preview(policy): it lists the running apps the new policy would disable and changes nothing. Tightening disables those apps (data untouched, an audit row and a notification for every administrator); loosening again re-enables nothing by itself, you approve each app again. A value that is none of the three is read as require_store, so a typo never loosens anything. Details and the exact rules are in "What an instance does with it" above.
What each side should do. For administrators: leave allow_unsigned on pilots; on production, run the preview and move to require_signed or require_store when nothing important is listed; read the trust field of an app's detail to see which state it holds. For developers: sign every package, keep the key backed up (the pin makes the key your identity: an update signed by a different key is refused on every instance until each administrator accepts it, and the store pins the app to it too), and never rely on the store's signature to vouch for you.
What this model does not do. A signature proves who holds a key, not who the publisher is and not that the app is safe; the publisher name is whatever the developer typed. Apps the store rewrites per customer (paid or private apps) are signed by the store over the rewritten bytes, so they carry no publisher pin until the key registry of the publisher-identity design (K2) exists.
The format
vzm implements it; you only need this to write your own tooling.
- Digest — SHA-256 over one line per file, sorted by the path's UTF-8 bytes:
<path> NUL <sha256 hex of the file> LF.signature.jsonis left out. Writtensha256:<hex>. signature.json—{"format": 1, "publisher": {...}, "store": {...}}, at least one of the two blocks. Each block is{"statement": {...}, "signature": "<base64>"}.- Publisher statement — exactly
format(1),app,version(must equal the manifest'sidandversion),digest,publisher(your name: letters, digits,. _ @ + -),public_key(base64 raw Ed25519),signed_at(unix seconds). - Store statement — exactly
format,app,version,digest,publisher,publisher_key(ed25519:+ the first 32 hex of the SHA-256 of your public key, or null),signed_at. - What is signed — Ed25519 over a domain prefix plus the statement's canonical JSON (sorted keys, no whitespace, UTF-8):
vizum.package.v1\npublisher\norvizum.package.v1\nstore\n. - Store binding —
store-binding.json, written only by the store, only next to a store block:{"format": 1, "statement": {...}, "signature": "<base64>"}. The statement has exactlyformat,app,version,digest(the content digest: the digest above withstore-binding.jsonalso left out),licensed_db(the database uuid),requires_license(true/false),issued_at(unix seconds) andkey_id(the store key's fingerprint), signed overvizum.package.v1\nbinding\nplus its canonical JSON. The publisher statement is checked against the content digest; the store statement against the full digest (binding included), so the binding cannot be removed without breaking the store's signature, and it cannot be moved to other contents. An instance refuses a binding for another database, a binding without a store block, and one whose signature does not verify with an accepted store key; with no store key configured it still refuses a binding for another database. - Revocation list —
{"format": 1, "issued_at", "instance", "key_id", "revocations": [{app, version, reason, revoked_at}], "lifted": [{app, version}], "signature"}, signed overvizum.revocations.v1\nplus the canonical JSON of everything butsignature.key_idis the fingerprint of the store key that signed it.
The reference vector every implementation must reproduce: vizum_apps_platform/tests/data/package_trust_vector.json (its keys come from fixed seeds — never use them for anything).
Not yet
- Publisher certificates — today your *key* is your identity (pinned per app by the store); the
publishername is what you typed. - Tightening the package trust policy re-checks the apps that are already installed (
_enforce_package_trust_policy): an installed app that no longer meets the policy is disabled, and the daily policy pass catches files that changed after they were verified. The other rules that tighten are covered by the policy reconciliation of SDK 5.97; see "When the platform's rules tighten" inlifecycle.md.