diff --git a/.changeset/managed-datasource-readonly-documented.md b/.changeset/managed-datasource-readonly-documented.md
new file mode 100644
index 0000000000..e24f48f9c9
--- /dev/null
+++ b/.changeset/managed-datasource-readonly-documented.md
@@ -0,0 +1,40 @@
+---
+"@objectstack/spec": patch
+"@objectstack/example-crm": patch
+---
+
+docs(spec): managed-datasource read-only is a database privilege, and the platform will not add a flag (#4584)
+
+#4583 removed `datasource.capabilities.readOnly` and left a gap open in its
+rejection message: `external.allowWrites: false` is the one enforced write gate
+and it covers only FEDERATED datasources, so a **managed** datasource had no
+read-only gate at all. The rejection pointed at #4584 and said "tracked". #4584
+is now answered, and the answer is that this stays so **on purpose**:
+
+> **方案 B —— 不建平台层只读闸门,文档明确记录**。
+> 一个只拦 ObjectQL 写路径、拦不住直连/迁移/DDL 的位,是「看起来存在的能力」——
+> #4583 刚删掉的 `capabilities.readOnly` 就是这个形状,不再造第二遍。真只读属于
+> 数据库账号权限(GRANT SELECT),那里没有绕行面。
+
+Read-only for a database ObjectStack owns is a **database account privilege** —
+`GRANT SELECT`. An ObjectQL-level flag would stop writes on one path and leave a
+direct `psql` session, a migration, a `syncSchema()` DDL statement and any
+process sharing the connection string untouched. A boundary that holds in one
+path is not a boundary, and one that looks like a boundary is worse than none
+because it gets trusted — which is exactly the defect #4583 removed.
+
+Documentation-only. No schema shape changes; the `capabilities.readOnly`
+tombstone now carries the answer instead of an open issue reference:
+
+- **Database Drivers** gains *Read-only: grant it at the database, not in
+ metadata* (a worked `GRANT SELECT` account, the DDL/schema-sync consequence, why
+ the platform declines the flag, and a table of what actually enforces what)
+ and *Read replicas: the platform does not route* — the #4479 dual conclusion:
+ no query path separates reads from writes, so put replicas behind pgpool /
+ ProxySQL / an RDS reader endpoint and point `config` there. That is the
+ correct answer, not a stopgap.
+- **External Datasources** now says plainly that the double opt-in write gate is
+ federation-only, and that the parse rejects an `external` block on a `managed`
+ datasource.
+- `example-crm`'s `crm_analytics` header comment recorded the ruling instead of
+ waiting on it.
diff --git a/content/docs/data-modeling/drivers.mdx b/content/docs/data-modeling/drivers.mdx
index f80e20083a..f0fd0ebe99 100644
--- a/content/docs/data-modeling/drivers.mdx
+++ b/content/docs/data-modeling/drivers.mdx
@@ -540,3 +540,110 @@ export const AuditLog = ObjectSchema.create({
fields: { /* ... */ },
});
```
+
+### Read-only: grant it at the database, not in metadata
+
+
+**A managed datasource has no platform-level read-only gate, and this is
+deliberate.** Read-only for a database ObjectStack owns is a **database account
+privilege** — `GRANT SELECT` — not a key on the datasource. There is no
+metadata you can write that makes a managed connection read-only.
+
+
+Point the datasource's `config` at an account that can only read:
+
+```sql
+-- PostgreSQL: a login that can read the schema and nothing else.
+CREATE USER analytics_ro PASSWORD '…';
+GRANT CONNECT ON DATABASE analytics TO analytics_ro;
+GRANT USAGE ON SCHEMA public TO analytics_ro;
+GRANT SELECT ON ALL TABLES IN SCHEMA public TO analytics_ro;
+ALTER DEFAULT PRIVILEGES IN SCHEMA public GRANT SELECT ON TABLES TO analytics_ro;
+```
+
+```typescript
+import { defineDatasource } from '@objectstack/spec/data';
+
+export const Analytics = defineDatasource({
+ name: 'analytics',
+ label: 'Analytics (read-only account)',
+ driver: 'postgres',
+ // The connection itself cannot write. Nothing in the app can talk past it.
+ config: { url: process.env.ANALYTICS_RO_URL },
+ active: true,
+});
+```
+
+(`external.credentialsRef` is a *federation* key — the parse rejects an
+`external` block on a `managed` datasource — so a managed connection carries its
+credentials in `config`, from the environment as above.)
+
+Note that a read-only account also refuses **DDL**, so such a datasource cannot
+run ObjectStack's boot-time schema sync or migrations. That is the honest
+consequence of a real boundary: a database you only read is a database you do
+not own the schema of. If you want ObjectStack to keep the schema in step, it
+needs a writable account — or the datasource belongs on the federation path
+(`schemaMode: 'external'`), where DDL is forbidden by design and the write gate
+is enforced (see below).
+
+#### Why the platform does not offer the flag
+
+The obvious-looking alternative — a `readOnly` boolean on the datasource — is
+the exact shape [#4583](https://github.com/objectstack-ai/objectstack/issues/4583)
+removed. `datasource.capabilities.readOnly` shipped for three releases, read as
+a safety property, and gated nothing: no write path consulted it, so a
+datasource labelled a read replica accepted inserts exactly like the primary.
+The shipped CRM example called one of its datasources a "Read Replica" on the
+strength of it.
+
+Rebuilding it as a *working* ObjectQL check would not fix the underlying
+problem, only make it harder to see. Such a gate stops writes that go through
+`ObjectQLEngine`; it cannot stop a direct `psql` session, a migration, a
+`syncSchema()` DDL statement, a background job holding its own driver handle, or
+any other process on the same connection string. A boundary that holds in one
+path and not the others is not a boundary — and a flag that *looks* like one is
+worse than no flag at all, because it is trusted. The database account has no
+such gap: there is no code path in ObjectStack, or anywhere else, that can write
+through a connection the server will not let write
+([#4584](https://github.com/objectstack-ai/objectstack/issues/4584)).
+
+#### The one enforced write gate is federation-only
+
+`external.allowWrites: false` **is** enforced, by
+`ObjectQLEngine.assertWriteAllowed` before every insert/update/delete — but it
+answers a question about *ownership*, not about connections: which side may
+write to a database ObjectStack does not own. You cannot reach for it on a local
+database: the parse rejects an `external` block whose `schemaMode` is `managed`,
+and the engine check itself returns early for `managed` (and for a definition
+that declares no `schemaMode`) before it ever reads `allowWrites`. See
+[Writes (double opt-in)](/docs/data-modeling/external-datasources#5-writes-double-opt-in).
+
+| What you want | What actually does it |
+| :--- | :--- |
+| A **managed** datasource that cannot be written | A database account with `SELECT` only. No metadata key. |
+| A **federated** datasource that cannot be written | `external: { allowWrites: false }` — the default — enforced by the engine. |
+| A federated datasource writable for **some** objects | `external.allowWrites: true` on the datasource **and** `external.writable: true` on each object. |
+
+### Read replicas: the platform does not route
+
+
+**There is no read/write splitting in ObjectStack.** No query path distinguishes
+a read from a write, so there is nothing to route to a replica. `datasource.readReplicas`
+was removed in 17.0.0 ([#4468](https://github.com/objectstack-ai/objectstack/issues/4468))
+because it described replica connections nothing ever opened.
+
+
+Put the replicas behind a single endpoint and let the database tier route:
+**pgpool-II**, **ProxySQL**, or an **RDS / Aurora reader endpoint**. Point
+`config` at that endpoint.
+
+This is the correct answer, not a stopgap
+([#4479](https://github.com/objectstack-ai/objectstack/issues/4479)). The hard
+parts of read/write splitting are not the replica connections — they are
+deciding what counts as a read (a `find` inside a transaction that just issued
+an `update` must go to the primary, or the app cannot read its own writes),
+declaring the staleness a query will tolerate, and ejecting a replica that falls
+behind. A proxy is a component built to do exactly that, and it does it better
+than a field on a datasource could. Should the platform ever need to pick a
+consistency level from business semantics, the schema shape will be decided by
+that routing path — it will not be bolted on ahead of it.
diff --git a/content/docs/data-modeling/external-datasources.mdx b/content/docs/data-modeling/external-datasources.mdx
index 2c95e566ea..1b950f860d 100644
--- a/content/docs/data-modeling/external-datasources.mdx
+++ b/content/docs/data-modeling/external-datasources.mdx
@@ -255,6 +255,21 @@ ObjectSchema.create({ /* ... */ external: { remoteName: 'orders', writable: true
With either gate off, insert/update/delete on the federated object is rejected.
+
+**This gate is federation-only — it does nothing on a managed datasource.**
+`allowWrites` answers *who owns this external database*, not *is this connection
+read-only*. You cannot even declare it on a local database — the parse rejects
+an `external` block whose `schemaMode` is `managed` — and
+`ObjectQLEngine.assertWriteAllowed` returns early for `managed` (or an absent
+`schemaMode`) before it reads the flag at all.
+
+A managed datasource has **no** platform read-only gate, deliberately: read-only
+for a database ObjectStack owns is a database account privilege (`GRANT SELECT`).
+See [Read-only: grant it at the database, not in metadata](/docs/data-modeling/drivers#read-only-grant-it-at-the-database-not-in-metadata)
+for why an application-layer flag is the wrong boundary
+([#4584](https://github.com/objectstack-ai/objectstack/issues/4584)).
+
+
## 6. Analytics over external objects
Dashboards and reports over a federated object aggregate against the **correct**
@@ -272,7 +287,9 @@ default allows all connects (subject to the gating above).
## See also
-- [Database Drivers](/docs/data-modeling/drivers) — managed multi-datasource routing.
+- [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).
- [Datasource reference](/docs/references/data/datasource) — every `defineDatasource` field.
- The `examples/app-showcase` `showcase_external` datasource — a runnable end-to-end demo.
- ADR-0015 (federation spec) and ADR-0062 (external-datasource runtime).
diff --git a/examples/app-crm/src/datasources/crm.datasource.ts b/examples/app-crm/src/datasources/crm.datasource.ts
index 7f6b560285..c0889de11c 100644
--- a/examples/app-crm/src/datasources/crm.datasource.ts
+++ b/examples/app-crm/src/datasources/crm.datasource.ts
@@ -33,9 +33,13 @@ export const CrmDatasource = defineDatasource({
*
* The label no longer promises read-only, because nothing here can deliver it:
* `external.allowWrites: false` is the one enforced write gate and it applies
- * only to FEDERATED datasources, while this one is local and managed. Whether a
- * managed datasource should have a read-only gate at all is #4584 — until that
- * is answered, the honest demo is routing, not a safety claim.
+ * only to FEDERATED datasources, while this one is local and managed. #4584
+ * settled that a managed datasource gets NO platform read-only gate, on purpose:
+ * read-only is a database account privilege (`GRANT SELECT`), because an
+ * ObjectQL-only check cannot stop a direct connection, a migration or DDL, and a
+ * gate that holds in one path is worse than none. So the honest demo here is
+ * routing, not a safety claim — see "Read-only: grant it at the database, not in
+ * metadata" in the Database Drivers guide.
*/
export const CrmAnalyticsDatasource = defineDatasource({
name: 'crm_analytics',
diff --git a/packages/spec/src/data/datasource.zod.ts b/packages/spec/src/data/datasource.zod.ts
index 66895cea55..d039341771 100644
--- a/packages/spec/src/data/datasource.zod.ts
+++ b/packages/spec/src/data/datasource.zod.ts
@@ -64,8 +64,11 @@ const RETIRED_CAPABILITIES: Record = {
+ 'so a datasource labelled a read replica accepted writes exactly like any other. The one '
+ 'enforced datasource-wide write gate is `external.allowWrites: false`, and it applies ONLY '
+ 'to a federated datasource (`schemaMode` other than `managed`) — for a managed datasource '
- + 'there is currently no read-only gate at all, so delete the key rather than trusting it. '
- + 'Tracked in #4584.',
+ + 'there is no read-only gate at all, so delete the key rather than trusting it. #4584 '
+ + 'settled that this stays so ON PURPOSE: grant the connection SELECT-only at the database '
+ + '(`GRANT SELECT`), which no direct connection, migration or DDL can talk past — an '
+ + 'application-layer flag holds in the ObjectQL path only, and one that looks like a boundary '
+ + 'without being one is worse than none.',
};
/**