diff --git a/.changeset/27747-odata-guide-readme-link.md b/.changeset/27747-odata-guide-readme-link.md
new file mode 100644
index 00000000000..13f95803f0c
--- /dev/null
+++ b/.changeset/27747-odata-guide-readme-link.md
@@ -0,0 +1,10 @@
+---
+'@objectstack/driver-odata': patch
+---
+
+docs(driver-odata): the README links the OData datasources guide
+
+Clause-②: no
+Metadata-protocol: none
+
+The package README now points to the OData datasources guide (`/docs/data-modeling/odata-datasources`), which covers checking a service, mounting the plugin, binding objects to entity sets, credentials through `external.credentialsRef`, what the service is asked behind the engine's floor, writes, and how failures reach the caller. No code changes.
diff --git a/content/docs/data-modeling/external-datasources.mdx b/content/docs/data-modeling/external-datasources.mdx
index 6ac3c8c601d..8d28e60472a 100644
--- a/content/docs/data-modeling/external-datasources.mdx
+++ b/content/docs/data-modeling/external-datasources.mdx
@@ -17,9 +17,11 @@ auto-connected, validated at boot, and queryable — with no application code.**
This guide is about reading/writing the *live* remote tables (federation). To
*copy* rows into ObjectStack-owned tables instead, see seed data / data sync. For
the plain multi-datasource routing of **managed** databases ObjectStack owns, see
-[Database Drivers](/docs/data-modeling/drivers). When the remote is not a database a
-built-in driver reaches — a REST API, a planning engine, a device gateway — serve the
-object from your own plugin driver instead: [Virtual objects](/docs/data-modeling/virtual-objects).
+[Database Drivers](/docs/data-modeling/drivers). When the remote is an OData v4 service
+(SAP S/4HANA, Dynamics 365), bind it through the shipped OData driver:
+[OData datasources](/docs/data-modeling/odata-datasources). When it is neither — a REST
+API, a planning engine, a device gateway — serve the object from your own plugin driver
+instead: [Virtual objects](/docs/data-modeling/virtual-objects).
## 1. Declare the datasource
@@ -312,6 +314,8 @@ default allows all connects (subject to the gating above).
- [Database Drivers](/docs/data-modeling/drivers) — managed multi-datasource routing,
[read-only via database privileges](/docs/data-modeling/drivers#read-only-grant-it-at-the-database-not-in-metadata),
and [why the platform does not route read replicas](/docs/data-modeling/drivers#read-replicas-the-platform-does-not-route).
+- [OData datasources](/docs/data-modeling/odata-datasources) — the same binding over an
+ OData v4 service, through the shipped OData driver.
- [Virtual objects](/docs/data-modeling/virtual-objects) — the same binding served by
a plugin driver you write, for a remote that is not a database.
- [Datasource reference](/docs/references/data/datasource) — every `defineDatasource` field.
diff --git a/content/docs/data-modeling/index.mdx b/content/docs/data-modeling/index.mdx
index e5025e0e6e8..b0b80795c3f 100644
--- a/content/docs/data-modeling/index.mdx
+++ b/content/docs/data-modeling/index.mdx
@@ -60,6 +60,7 @@ That one definition is enough to get a persisted table, CRUD + query endpoints,
+
diff --git a/content/docs/data-modeling/meta.json b/content/docs/data-modeling/meta.json
index 75c75e57905..0d68d3a7fbd 100644
--- a/content/docs/data-modeling/meta.json
+++ b/content/docs/data-modeling/meta.json
@@ -17,6 +17,7 @@
"seed-data",
"import-mappings",
"external-datasources",
+ "odata-datasources",
"drivers",
"virtual-objects",
"analytics"
diff --git a/content/docs/data-modeling/odata-datasources.mdx b/content/docs/data-modeling/odata-datasources.mdx
new file mode 100644
index 00000000000..da5122eb2d4
--- /dev/null
+++ b/content/docs/data-modeling/odata-datasources.mdx
@@ -0,0 +1,284 @@
+---
+title: "OData datasources — ERP records, live, with no driver of your own"
+navTitle: OData Datasources
+description: Declare a datasource over an OData v4 service such as SAP S/4HANA or Dynamics 365, bind objects to its entity sets, and read and write them live, with no code per entity set.
+---
+
+Many business systems already publish their records over **OData v4**: SAP S/4HANA,
+Microsoft Dynamics 365 and Dataverse, and any service that serves a CSDL 4.0
+`$metadata` document. For those you need no driver of your own. Mount
+`@objectstack/driver-odata` once per service, declare a datasource, and bind an object
+to each entity set you want. Every view, form, permission set, formula and hook then
+works on those records, and no row is stored on the platform side.
+
+The OData driver is a **plugin driver**, so everything [Virtual objects](/docs/data-modeling/virtual-objects)
+says about plugin drivers holds here too: the engine re-applies every query, a read
+matches at most 1000 rows, and the sharing model needs a deliberate choice. The one
+difference is that this driver is already written.
+[Which way in](/docs/data-modeling/virtual-objects#which-way-in-odata-a-plugin-driver-or-a-real-object)
+says when to choose it over a driver of your own or a synced copy.
+
+Everything on this page is what the driver's own tests assert, against an in-process
+OData service and behind the real engine
+(`packages/drivers/driver-odata/src/odata-driver.floor.test.ts` and
+`odata-driver.conformance.test.ts`). Read them when this page and the runtime seem to
+disagree.
+
+## Check the service first
+
+- **It speaks OData v4.** Open `$metadata`. The root element must say
+ `Version="4.0"`. Only the v4 dialect ships. A service that speaks only OData v2 needs a
+ v2 dialect, which this package does not include. Many older SAP Gateway services are
+ v2 only, so check each service you plan to bind.
+- **Each entity set you bind has a key.** The key becomes the record's `id`. An entity
+ type with no usable key is refused at the first request.
+- **You know how it authenticates.** The driver sends no credential, a bearer token, or
+ HTTP basic. It does not run an OAuth2 flow ([Credentials](#3-credentials)).
+
+## 1. Mount the plugin
+
+The plugin builds the driver from its options and publishes it as the service
+`driver.`. Mount it in the stack's `plugins`, one plugin per service:
+
+```typescript
+import { defineStack } from '@objectstack/spec';
+import { createODataDriverPlugin } from '@objectstack/driver-odata';
+import { erpSecretStore } from './secrets'; // your host's secret store; see Credentials
+
+export default defineStack({
+ // …the datasource and objects of step 2…
+ plugins: [
+ createODataDriverPlugin({
+ name: 'erp', // = the datasource name
+ serviceRoot: 'https://erp.example.com/odata/v4/sales/',
+ auth: { scheme: 'bearer' },
+ secrets: erpSecretStore,
+ }),
+ ],
+});
+```
+
+| Option | Default | Meaning |
+| :--- | :--- | :--- |
+| `name` | `odata` | The datasource it serves. It is also the driver name and the `driver.` service suffix. |
+| `serviceRoot` | (required) | The service root URL, http or https, with no credentials and no query string. |
+| `version` | `4.0` | The protocol version. `4.0` is the only value. |
+| `auth` | `{ scheme: 'none' }` | `none`, `bearer`, or `basic` with a `username`. The secret is the datasource's `external.credentialsRef`. |
+| `secrets` | (none) | Where `credentialsRef` is resolved: an object with `resolve(credentialsRef)`. Required for `bearer` and `basic`. |
+| `pageSize` | `500` | Rows asked for per server page (`Prefer: odata.maxpagesize`). |
+| `requestTimeoutMs` | `30000` | The timeout of each request. |
+
+The options are checked when the plugin is built. An unknown key, a `serviceRoot` with
+a user name or a query in it, or a version other than `4.0` refuses the boot with every
+problem named.
+
+## 2. Declare the datasource and bind objects
+
+{/* os:check */}
+```typescript
+import { defineStack } from '@objectstack/spec';
+
+export default defineStack({
+ manifest: { id: 'com.example.sales', name: 'Sales', version: '1.0.0', type: 'app' },
+ datasources: [
+ {
+ name: 'erp',
+ label: 'ERP sales service (OData v4)',
+ driver: 'erp', // the plugin's name, i.e. the `driver.erp` service
+ schemaMode: 'external',
+ config: {}, // the plugin's options configure the driver, not this
+ external: {
+ allowWrites: true,
+ credentialsRef: 'sys_secret:7c1e…', // the bearer token, as a secret-store handle
+ validation: { onMismatch: 'warn', checkOnBoot: false },
+ },
+ },
+ ],
+ objects: [
+ {
+ name: 'sales_order',
+ label: 'Sales order',
+ datasource: 'erp',
+ sharingModel: 'public_read_write', // see "Choose the sharing model" below
+ external: {
+ remoteName: 'SalesOrders', // the entity set
+ writable: true,
+ // remote property → local field; a property not named keeps its own name
+ columnMap: {
+ SalesOrderID: 'order_number',
+ CustomerName: 'customer_name',
+ NetAmount: 'net_amount',
+ OverallStatus: 'status',
+ DeliveryNote: 'delivery_note',
+ },
+ },
+ fields: {
+ order_number: { type: 'text', label: 'Order' },
+ customer_name: { type: 'text', label: 'Customer' },
+ net_amount: { type: 'number', label: 'Net amount' },
+ status: { type: 'text', label: 'Status' },
+ delivery_note: { type: 'text', label: 'Delivery note' },
+ },
+ },
+ ],
+});
+```
+
+What each piece does on this driver:
+
+- **Three names stay equal:** the plugin's `name`, the datasource `name`, and the
+ datasource `driver`. The connect step finds `driver.erp` already registered and uses
+ it as it is. `config` is left empty, because the plugin's options already configured
+ the driver.
+- **`remoteName` names the entity set.** Without it the driver looks for an entity set
+ named like the object.
+- **This driver applies `external.columnMap` itself**, in both directions: rows arrive
+ under your field names, and filters, sorts and writes go out under the service's
+ property names. That is a property of this driver, not of plugin drivers in general.
+- **`id` is the entity key.** A single-property key is read as text (`'4711'`). A
+ composite key is the body of its key predicate (`'OrderID=1,ProductID=7'`), which is
+ also what `GET /data/