Skip to content

managed datasource 没有任何只读闸门:external.allowWrites 只管联邦库,本地库声明只读无处可写(#4583 移除 capabilities.readOnly 后的正式立项位) #4584

Description

@os-zhuang

从 #4583 拆出,照 #4479 的先例:退役 PR 负责删掉假合规的键,不负责就地发明一个机制。

背景

datasource.capabilities.readOnly 在 #4583 里被移除——它读起来像安全属性,实际没有任何写路径读它,一个标着「只读副本」的 datasource 照常接受写入。

移除时给作者的处方是 external.allowWrites: false。但那个处方只覆盖一半场景。

缺口

ObjectQLEngine.assertWriteAllowed(packages/objectql/src/engine.ts):

const ds = this.datasourceDefs.get(dsName);
// No recorded definition, or an explicitly managed one ⇒ allow.
if (!ds || !ds.schemaMode || ds.schemaMode === 'managed') return;

const dsAllows = ds.external?.allowWrites ?? false;
const objAllows = object?.external?.writable ?? false;

闸门在第一个 if 就对 managed(以及没声明 schemaMode 的)datasource 直接放行。external.allowWrites 是联邦所有权闸门——它回答的是「这个外部库归谁写」,不是「这个连接是不是只读」。

所以对一个本地的、managed 的 datasource:

  • capabilities.readOnly —— 已删除,从来没生效过
  • external.allowWrites: false —— 不生效(在 managed 分支就返回了)
  • 没有第三个选项

examples/app-crm 里那个 crm_analytics(sqlite、无 schemaMode、label 写着 "CRM Analytics Read Replica")就是这个形状:#4583 之后它不再声称自己只读,但也依然无法只读。

要请的是决策

A. 建闸门。 让 assertWriteAllowed 认一个与联邦无关的只读位。设计要点:位放在哪(datasource 顶层 readOnly?复用 schemaMode?)、和 external.allowWrites 的关系(两个都要过,还是短路)、以及 isSystem / 迁移 / DDL 是否豁免——一个连 schema sync 都拦的只读库多半不是作者想要的。

B. 不建,明确记录。 承认「只读连接」不是平台提供的能力,只读靠数据库账号权限做(GRANT SELECT),并把这句话写进 datasource 文档。这也是一个诚实的答案——而且很可能是更诚实的那个:数据库侧的只读账号是真闸门,应用层的布尔位不是。

我倾向 B,除非有明确需求要应用层拦截。理由是 #4583 的教训本身:readOnly 之所以危险,不是因为它不存在,而是因为它看起来存在。再造一个只拦 ObjectQL 写路径、拦不住直连 / 迁移 / DDL 的位,会重演同一个形状——只是这次带着一个能跑的测试当背书。

如果选 A,请先回答「它拦不住什么」,并把答案写进 describe(),而不是等下一次审计来问。

参考

未指派 —— 这是决策而非补丁,认领前先在下面说结论。

Activity

  1. os-zhuang commented on Aug 3, 2026

    @os-zhuang
    ContributorAuthor

    裁决(PM 代决,维护者可否决):方案 B —— 不建平台层只读闸门,文档明确记录。

    两轴理由:

    执行(docs-only,一并处理):

    1. datasource 文档明确「managed 库只读 = 数据库账号权限」,并写入 读副本路由(read/write splitting):真要做的话,从查询路径开始,而不是从 datasource 上加个字段(#4468 移除 readReplicas 后的正式立项位) #4479 的对偶结论(读副本 = 代理端点,平台不路由);
    2. 清理 examples/app-crm/src/datasources/crm.datasource.ts 文件头「等 managed datasource 没有任何只读闸门:external.allowWrites 只管联邦库,本地库声明只读无处可写(#4583 移除 capabilities.readOnly 后的正式立项位) #4584 裁决」的注释。

    已入队待派。


    Generated by Claude Code

  2. self-assigned this
    on Aug 10, 2026
  3. os-zhuang commented on Aug 10, 2026

    @os-zhuang
    ContributorAuthor

    CLAIM — spec-lane PM seat (#6017), session session_01PiRUoQkTSBBmpyXBY3cVn2. Branch: claude/issue-4584-managed-readonly-docs. Dispatching a cloud dev session under the maintainer's 2026-08-10 acceleration instructions.

    Executing the standing 2026-08-03 ruling (comment 5163028174) verbatim — 方案 B, docs-only, no platform gate:

    1. The datasource docs state plainly that managed-database read-only = database account privileges (GRANT SELECT), the platform deliberately offers no gate (an ObjectQL-only gate would be the exact "looks-safe" false capability datasource 账本判定的 20 条死键至今无人处置:三个块整块无人读,其中 readOnly 让一个 shipped 示例的「只读副本」可写(ADR-0049 enforce-or-remove) #4583 removed), and record the 读副本路由(read/write splitting):真要做的话,从查询路径开始,而不是从 datasource 上加个字段(#4468 移除 readReplicas 后的正式立项位) #4479 dual conclusion (read replica = proxy endpoint, platform does not route).
    2. Clean up the examples/app-crm/src/datasources/crm.datasource.ts file-header comment that waits on this ruling.

    No schema change, no acceptance change. ⛔ content/docs/releases/ untouched as always.


    Generated by Claude Code

  4. os-zhuang commented on Aug 10, 2026

    @os-zhuang
    ContributorAuthor

    OS-DEV-REPORT

    {
      "issue": 4584,
      "outcome": "implemented",
      "branch": "claude/issue-4584-managed-readonly-docs",
      "pr_number": 7241,
      "premise_check": {
        "base": "origin/main @ f40c5b4",
        "docs_lack_ruled_statement": "HOLDS — no GRANT SELECT / database-privilege statement anywhere in content/docs/data-modeling/; grep for read-only there returned only the federation write gate and a UI-readonly table row",
        "crm_header_awaits_ruling": "HOLDS — crm.datasource.ts:36-38 read 'Whether a managed datasource should have a read-only gate at all is #4584 — until that is answered…'",
        "gap_is_real": "HOLDS — ObjectQLEngine.assertWriteAllowed (packages/objectql/src/engine.ts:3397) returns early on `!ds.schemaMode || ds.schemaMode === 'managed'`, before reading external.allowWrites",
        "unanticipated_finding": "packages/spec/src/data/driver/common.zod.ts (READ_ONLY_BELONGS_ON_DATASOURCE) ALREADY carried the ruled sentence verbatim — 'grant the connection SELECT-only at the database instead, which is a real boundary rather than an application-layer flag (#4584)'. Spec-side prose was ahead of the docs; only the docs were missing it."
      },
      "changes": [
        "content/docs/data-modeling/drivers.mdx — two new sections under Multi-Datasource. (a) 'Read-only: grant it at the database, not in metadata': managed read-only = database account privilege, worked PostgreSQL GRANT SELECT role + matching defineDatasource, the DDL/schema-sync consequence, 'Why the platform does not offer the flag' citing the exact capabilities.readOnly shape #4583 removed, 'The one enforced write gate is federation-only', and a what-actually-enforces-what table. (b) 'Read replicas: the platform does not route': the #4479 dual conclusion — no query path separates reads from writes; pgpool-II / ProxySQL / RDS reader endpoint, the correct answer not a stopgap.",
        "content/docs/data-modeling/external-datasources.mdx — §5 Writes (double opt-in) now states the gate is federation-only and cross-links; See also links both new sections.",
        "packages/spec/src/data/datasource.zod.ts — capabilities.readOnly tombstone said 'Tracked in #4584.'; now carries the answer. PROSE ONLY inside a guidance string.",
        "examples/app-crm/src/datasources/crm.datasource.ts — crm_analytics header comment records the ruling instead of waiting on it.",
        ".changeset/managed-datasource-readonly-documented.md — @objectstack/spec patch + @objectstack/example-crm patch."
      ],
      "gates": {
        "pnpm --filter @objectstack/spec build": "pass (run BEFORE regen — stale-dist trap #7122)",
        "pnpm --filter @objectstack/spec check:docs": "pass — 231 generated files in sync, no generated page moves",
        "node scripts/check-doc-authoring.mjs": "pass — 374 files clean",
        "pnpm docs:build (full Next build)": "pass — exit 0, all 391 doc pages prerendered; validates the new MDX/Callout blocks and headings",
        "vitest run src/data/datasource (spec)": "pass — 41",
        "vitest run src/conversions (spec)": "pass — 199",
        "vitest run lint-liveness-properties (lint)": "pass — 28",
        "tsc --noEmit (@objectstack/example-crm)": "pass",
        "check-empty-changeset / check-changeset-no-major / check-changeset-fixed": "pass"
      },
      "deviations": [
        "Touched packages/spec/src/data/datasource.zod.ts, which the dispatch scoped away from. Confined to PROSE inside the RETIRED_CAPABILITIES.readOnly guidance string — no key, shape, default or validation behaviour changed. Rationale: the string said 'Tracked in #4584' about a now-settled decision, which would have contradicted the docs this PR adds. Verified nothing pins the string (no test/snapshot match) and check:docs confirms no generated page moves.",
        "Added a changeset, which the dispatch made conditional on docs-only precedent. Precedent search found docs-only commits carry none (52c9cab), but this diff is NOT docs-only — it changes a published package's author-facing rejection message, and the repo's changeset-check job enforces on every PR lacking a skip-changeset label. Patch bumps only.",
        "Worktree: the container's primary checkout had already been created ON the target branch, so `git worktree add` refused the name. Detached the (clean, unmodified) primary HEAD, deleted the empty local branch, then created ../objectstack-4584 from origin/main. All edits made in the worktree."
      ],
      "docs_adr_touched": false,
      "special_inspection_notes": [
        "CAUGHT A DEFECT IN MY OWN FIRST DRAFT: the read-only example initially carried `external: { credentialsRef: … }` on a managed datasource. DatasourceSchema's superRefine (datasource.zod.ts:547) REJECTS an `external` block when schemaMode is 'managed'. Corrected to credentials in `config` from the environment, and the constraint is now stated in the docs — it is a real trap for anyone following the ruling.",
        "The ruling's 'no platform gate' has a consequence worth the maintainer's eye, now written into the docs: a GRANT SELECT account also refuses DDL, so a managed datasource on one cannot run boot-time schema sync or migrations. The docs state that plainly and route such a datasource to schemaMode: 'external' instead. Deliberately did NOT suggest config.autoMigrate as the lever — it is a dev-only loosen-only self-heal (SqlAutoMigrateSchema), not a schema-sync switch.",
        "content/docs/releases/** and docs/adr/** untouched. No schema shape change, no acceptance change, no platform gate built."
      ]
    }

    Generated by Claude Code

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions