A form in Odoo is never a single XML file. When the web client asks for the sale order form, the server takes the base view and applies every inheriting view from every installed module on top of it, then sends the result. Your customization is one more layer in that stack.
How the final view gets built
Odoo resolves a view in a fixed order. It starts from the primary view’s arch, then applies the inheriting views whose mode is extension, depth first: a child view, then that child’s own children, then its siblings. When several views inherit the same parent, their priority decides the order.
A view with mode set to primary works differently. It takes the fully resolved parent as its starting point but becomes a separate view, so the original stays untouched for everyone else. That’s the tool when you want a variant of a form for one action rather than a change to the form itself.
Inside a view, each spec finds its target with one of three locators: an xpath element with an expr, a field element matched by name, or any other element matched by tag and identical attributes. Every locator uses the first match only.
The positions
inside is the default and appends to the matched node. after and before insert next to it. replace swaps it out, and a text node containing only $0 inside your spec is replaced by the original node, which lets you wrap an element instead of deleting it. attributes sets or removes attributes. move takes an existing node from elsewhere in the view and puts it where your spec points.
An example
This targets sale.view_order_form, where commitment_date already carries readonly="state == 'cancel' or locked" in 17, 18 and 19:
<record id="view_order_form_delivery_window" model="ir.ui.view">
<field name="name">sale.order.form.delivery.window</field>
<field name="model">sale.order</field>
<field name="inherit_id" ref="sale.view_order_form"/>
<field name="arch" type="xml">
<xpath expr="//field[@name='commitment_date']" position="after">
<field name="delivery_window"/>
<field name="delivery_window_confirmed"/>
</xpath>
<!-- Odoo 18 and 19: combines with the existing expression -->
<xpath expr="//field[@name='commitment_date']" position="attributes">
<attribute name="readonly" add="delivery_window_confirmed" separator="or"/>
</xpath>
<xpath expr="//field[@name='delivery_window']" position="after">
<field name="client_order_ref" position="move"/>
</xpath>
</field>
</record>
The add with separator="or" is worth knowing. Since Odoo 18, add and remove on invisible, readonly, required, column_invisible and decoration-* treat the value as a Python expression and only accept and or or, producing (state == 'cancel' or locked) or (delivery_window_confirmed). Odoo 17 handles add and remove as plain string splitting on the separator, which mangles an expression, so on 17 you write the whole condition in the attribute body instead.
Version traps
Odoo 17 dropped attrs and states, and an inherited view still using them fails validation. Odoo 18 renamed the root of list views from tree to list, so any xpath like //field[@name='order_line']/tree stops matching. Both errors are loud on install, which is better than silent. So is anchoring on a label: //field[@string='Customer'] is rejected outright, because string is translated and Odoo won’t use translated attributes as selectors. Matching on @class only logs a warning, and the fix it suggests is hasclass().
The quieter problem is two addons that don’t depend on each other targeting the same node. Priority settles the order, but if one of them replaces or moves that node, the other can fail with “cannot be located in parent view” on a database where both are installed. Our post on _inherit vs _inherits lists more of the xpath mistakes we catch in review.
erpfly writes view changes this way, anchored on field names, and the Odoo customization page shows a larger inherited form. PDF reports use the same specs, since a report is a QWeb template stored as a view.