Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .changeset/datasource-config-driver-contract.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ package. So `config: { hostname: 'db.internal' }` (the key is `host`) was
accepted in silence and the datasource connected to `localhost` while the parse,
the save and the connection probe all reported success.

`DatasourceSchema` now parses `config` — and each `readReplicas` entry — against
`DatasourceSchema` now parses `config` against
the contract for the declared driver, and `DatasourceAdminService`
(create/update/test, the Setup wizard's path) applies the same check. Both read
one registry in `@objectstack/spec/data`, which also projects each contract to
Expand Down
49 changes: 49 additions & 0 deletions .changeset/datasource-read-replicas-removed.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
---
'@objectstack/spec': major
---

`datasource.readReplicas` is removed (#4468, ADR-0049 enforce-or-remove)

It described replica connections nothing ever opened. `ConnectableDatasource`
and `DatasourceConnectionSpec` carry no replicas field, the driver factory never
reads the key, and no query path distinguishes a read from a write — the
platform has no read/write splitting at all, so every statement always went to
the primary no matter what was declared here.

**Migration.**

| Wrote | Write instead |
| --- | --- |
| `readReplicas: [{ host: 'replica-a', … }]` | delete the key |
| `replicas: [ … ]` (the alias) | delete the key |

There is no target to move to, because there is no read-replica routing to move
to. If you need replica reads today, front them behind a single endpoint —
pgpool, ProxySQL, an RDS reader endpoint — and point `config` at that endpoint.
That is the one read-scaling path that works, and it worked before this key was
removed too.

Run `os migrate meta --from 16` to strip it from your sources; the
`datasource-read-replicas-removed` conversion emits one notice per datasource.
Authoring it now fails the parse with the same prescription.

**Why this one is worth reading about.** #4410 closed the `datasource.config`
gap and, in passing, extended the new per-driver validation over each
`readReplicas` entry — reasonably, since replicas carry the same shape. The
result was a slot that had every marker of a working feature: declared with a
doc comment, `.strict()`-guarded against typos at the top level, and
field-by-field validated against the driver's contract underneath. A replica
block with a misspelt `hostname` was rejected by index, naming the canonical
key.

None of that is evidence of a consumer, and all of it reads like one. That is
the specific trap ADR-0049 exists for: rigor is cheap to add to a dead slot and
expensive to distinguish from life. Two independent surfaces had drawn the
wrong conclusion — this validation, and objectui's datasource preview, which
rendered a "2 read replicas" pill confirming the config to the author while
nothing routed a single read. The preview goes with the key (objectui side,
same change); `packages/spec/liveness/README.md` has the standing rule it
violated ("an authoring/preview renderer is NOT a runtime consumer").

Read-replica routing remains unbuilt. It is tracked as a feature request rather
than left as a schema key that looks like one.
8 changes: 6 additions & 2 deletions .changeset/unknown-key-strictness-data-step.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,8 +26,12 @@ Deliberately still tolerant:
runtime shape the engine hands a handler. Strictness there would turn an
engine-internal enrichment (as `provenance` was in #3712) into a breaking
change for anyone parsing a context they were given.
- `datasource.config` and `readReplicas` — per-driver by construction; the
driver's own `configSchema` validates them.
- `datasource.config` — per-driver by construction (a sqlite `filename` and a
postgres `host`/`port` share no shape). Left open here and closed one level
down instead: #4410 parses it against the contract for the declared driver.
This bullet used to say "the driver's own `configSchema` validates them",
which was not true when it was written — the field existed and nothing read
it.

Errors are self-fixing: connection keys written one level too high (`host`,
`port`, `filename`, `url`, …) are prescribed into `config`; a top-level
Expand Down
20 changes: 18 additions & 2 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -106,8 +106,24 @@ this is mandatory, not a preference (Prime Directive #11), and a PreToolUse hook
blocks edits made while on the shared `main` branch. Working in the shared `main`
checkout is *not* a supported fallback: branches get switched and shared files —
including ones you just wrote — get reset *under you* mid-task (a full session's
work was silently reverted twice before this rule was enforced). Even inside your
own worktree, operate defensively:
work was silently reverted twice before this rule was enforced).

**Claim the issue BEFORE you write any code.** Assign it to yourself
(`gh issue edit <n> --add-assignee @me`, or the `issue_write` MCP tool with
`assignees`) as the *first* action of the task — before the worktree, before the
first read. An unassigned issue reads as an open invitation, and several agents
work this repo at once: two that both start on it burn the same hours twice and
then race to land conflicting shapes for the same problem, which is worse than
either one alone. If it is already assigned to someone else it is taken — pick
another, or say so and ask; never reassign it to yourself.

The claim is also what makes the *finding* rule (Prime Directive #10) safe to
follow. Once out-of-scope discoveries become issues, the issue list is a real
queue other agents read, and a claim is the only thing separating "someone is on
this" from "nobody has looked yet". File it unassigned when you are merely
recording a finding; assign it at the moment you actually start.

Even inside your own worktree, operate defensively:

1. **Only touch the files your task needs.** Don't "fix" unrelated diffs,
reverts, or other agents' in-flight edits, and don't try to manage the whole
Expand Down
15 changes: 13 additions & 2 deletions CLAUDE.md
Original file line number Diff line number Diff line change
@@ -1,8 +1,19 @@
# CLAUDE.md

**[AGENTS.md](./AGENTS.md) is the source of truth for working in this repo — read it.**
Its Prime Directives are binding. Do not rely on this file alone; the one rule that must
never be missed is inlined here because missing it corrupts other agents' work.
Its Prime Directives are binding. Do not rely on this file alone; the two rules that must
never be missed are inlined here because missing either one wastes or corrupts other
agents' work.

## ⛔ Claim the issue before you write any code

Assign the issue to yourself (`gh issue edit <n> --add-assignee @me`, or `issue_write`
with `assignees`) as the **first action of the task** — before the worktree, before the
first read. Several agents work this repo at once and an unassigned issue reads as an
open invitation: two that both start on it burn the same hours twice, then race to land
conflicting shapes for one problem. Already assigned to someone else? It is taken — pick
another or ask; never reassign it to yourself. File findings unassigned when you are only
recording them; assign at the moment you start.

## ⛔ Worktree-first — before your FIRST file edit (AGENTS.md Prime Directive #11)

Expand Down
1 change: 0 additions & 1 deletion content/docs/references/data/datasource.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,6 @@ const result = Datasource.parse(data);
| **driver** | `string` | ✅ | Underlying driver type |
| **config** | `Record<string, any>` | ✅ | Driver specific configuration |
| **pool** | `{ min: number; max: number; idleTimeoutMillis: number; connectionTimeoutMillis: number }` | optional | Connection pool settings |
| **readReplicas** | `Record<string, any>[]` | optional | Read-only replica configurations |
| **capabilities** | `{ transactions: boolean; queryFilters: boolean; queryAggregations: boolean; querySorting: boolean; … }` | optional | Capability overrides |
| **healthCheck** | `{ enabled: boolean; intervalMs: number; timeoutMs: number }` | optional | Datasource health check configuration |
| **ssl** | `{ enabled: boolean; rejectUnauthorized: boolean; ca?: string; cert?: string; … }` | optional | SSL/TLS configuration for secure database connections |
Expand Down
22 changes: 15 additions & 7 deletions content/docs/releases/v17.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -412,12 +412,13 @@ schema to the two highest-risk authorable surfaces, per the triage in
- **Datasources** — `DatasourceSchema` with its `pool` / `healthCheck` / `ssl` /
`retryPolicy` blocks, the ADR-0015 `external` federation settings and their
`validation` policy, `DatasourceCapabilities`, and `DriverDefinitionSchema`.
`config` and `readReplicas` stay **open** records: their shape is per-driver.
Nothing validates *inside* them — an earlier version of this note said the
driver's own `configSchema` did, which was wrong; the per-driver schemas exist
(`PostgresConfigSchema` and siblings) but nothing parses `config` against
them, tracked as #4410. So a misspelling one level *down* is still silent
today. That openness is why the
`config` stays an **open** record: its shape is per-driver. What it no longer
is, is unvalidated — #4410 wired the per-driver schemas
(`PostgresConfigSchema` and siblings) into `DatasourceSchema`'s refinement, so
a misspelling one level *down* is now rejected with the canonical key named.
An earlier version of this note said the driver's own `configSchema` did that,
which was wrong for two releases: the field existed, nothing read it. That
openness is why the
top level had to close — a connection key written one level too high (`host`
next to `driver` instead of inside `config`) was stripped, and the datasource
then connected on driver defaults rather than failing. Those keys now
Expand Down Expand Up @@ -970,6 +971,7 @@ import or the authored key.
| `DEFAULT_DISPATCHER_ROUTES` | dead route table |
| Aspirational config on Theme / Translation / Webhook | still-dead after #3494 |
| `ChartInteraction.zoom` / `.clickAction` | never implemented (#3752) |
| `datasource.readReplicas` | replica connections nothing ever opened — no driver reads the key and no query path splits reads from writes, so every statement went to the primary. #4410 had just taught the schema to validate each entry against the declared driver's contract, which made a dead slot look rigorously alive (#4468) |

The Console side follows: `@object-ui/types` drops its
`ObjectStack`/`ObjectOS`/`ObjectQL`/`ObjectUI` Capabilities re-exports, which
Expand Down Expand Up @@ -1979,7 +1981,13 @@ covers are folded into the list below rather than left to the changelog.)
an object fails the gate even at zero violations.
- **Datasources:** verify every declared datasource connects in every
environment — a bound datasource that cannot connect now fails the boot
instead of failing every later query.
instead of failing every later query. Delete `readReplicas` (`os migrate meta`
does it). Nothing ever opened those connections, so read throughput is
unchanged by removing them; if you need replica reads, front them behind one
endpoint (pgpool, ProxySQL, an RDS reader endpoint) and point `config` there.
Also re-check what you wrote under `config`: it is parsed against the driver's
contract now, so a key that used to be ignored — and left the datasource on
driver defaults — is rejected by name.
- **Sharing rules:** rewrite `sharedWith.type: 'group'` → `'team'`; drop `guest`
and owner-type rules; expect `accessLevel: 'full'` to convert to `'edit'`.
**A rule must state its criteria** — authoring one without is rejected, and a
Expand Down
4 changes: 2 additions & 2 deletions docs/audits/2026-07-unknown-key-strictness-ledger.md
Original file line number Diff line number Diff line change
Expand Up @@ -184,8 +184,8 @@ tightening (the #4001 "sharing-rule lesson": candidates, not verdicts).
| `field.zod.ts` | 11 | authorable | partially strict |
| `filter.zod.ts` / `query.zod.ts` | 11+5 | open | query dialect — user data flows through; validated semantically elsewhere. `query.zod.ts` dropped one site in #4196: `FieldNodeSchema`'s nested-select object form was declared-but-inert and narrowed to `z.string()`, so the union's second member is gone. Four more left in #4286 with the `joins`/`windowFunctions` removals: `JoinNodeBaseSchema`, `WindowFunctionNodeSchema`, and `WindowSpecSchema`'s two blocks (outer + `frame`) were deleted with their clusters. Class unchanged |
| `driver-nosql.zod.ts` / `driver.zod.ts` / `driver-sql.zod.ts` | 10+9+2 | wire | driver capability contracts |
| `datasource.zod.ts` | 9 | authorable | **strict as of #4001 data step** — all 9: `DatasourceSchema` (+ `pool` / `healthCheck` / `ssl` / `retryPolicy`), `ExternalDatasourceSettingsSchema` (+ `validation`), `DatasourceCapabilities`, `DriverDefinitionSchema`. `config` + `readReplicas` stay `z.record` **at this level** by construction (per-driver shapes), but are no longer unchecked: **#4410** made `DatasourceSchema`'s refinement parse both against the contract for the declared driver (`driver/config-registry.zod.ts`), so the openness here is a shape this level cannot express rather than the absence of one. This row used to add "the driver's own `configSchema` validates them", which was false until #4410 landed the parse site it names |
| `driver/memory.zod.ts` / `driver/mongo.zod.ts` / `driver/postgres.zod.ts` | 6+1+1 | authorable | The per-driver shapes for the `config` slot — what an author actually writes under `datasource.config` (`host`, `port`, `filename`). **Undeclared here until the coverage walk went recursive** (see below): a subdirectory was invisible to the gate, so these sites sat outside the map while the map reported full coverage. **Strict as of #4410**, which is also what unblocked them: this row previously read "strictness here would enforce nothing" because nothing parsed `datasource.config` against these schemas and both `*DriverSpec.configSchema` literals were `{}`. Now `DatasourceSchema` parses `config` (and each `readReplicas` entry) against them, and the same schemas project onto `configSchema` and onto the Studio connection form. `postgres.zod.ts` drops a site: its `ssl` was a `boolean | {ca, cert, key, …}` union, and the object arm is gone — certificates now live in the datasource-level `ssl` block (declared, strict, and until #4410 read by nobody), leaving `config.ssl` as the on/off shorthand. That narrowing is forced by the same projection: the Studio form renders anything that is not boolean/enum/number as a TEXT INPUT, so a union here would have produced a wizard whose every `ssl` value the new gate rejects. `memory.zod.ts` keeps 6 but loses two KEYS — `indexes` / `maxRecordsPerObject`, which `InMemoryDriverConfig` has no field for, removed under ADR-0049 rather than blessed by the new gate |
| `datasource.zod.ts` | 9 | authorable | **strict as of #4001 data step** — all 9: `DatasourceSchema` (+ `pool` / `healthCheck` / `ssl` / `retryPolicy`), `ExternalDatasourceSettingsSchema` (+ `validation`), `DatasourceCapabilities`, `DriverDefinitionSchema`. `config` stays `z.record` **at this level** by construction (per-driver shapes), but is no longer unchecked: **#4410** made `DatasourceSchema`'s refinement parse it against the contract for the declared driver (`driver/config-registry.zod.ts`), so the openness here is a shape this level cannot express rather than the absence of one. This row used to add "the driver's own `configSchema` validates them", which was false until #4410 landed the parse site it names. #4410 extended the same parse to each `readReplicas` entry; **#4468 retired that key** — no driver ever opened a replica connection and no query path splits reads from writes, so the entries were being checked against a contract nothing would apply. Strictness makes a dropped key loud; it cannot make a slot live, and a *precisely validated* dead slot is the more convincing lie |
| `driver/memory.zod.ts` / `driver/mongo.zod.ts` / `driver/postgres.zod.ts` | 6+1+1 | authorable | The per-driver shapes for the `config` slot — what an author actually writes under `datasource.config` (`host`, `port`, `filename`). **Undeclared here until the coverage walk went recursive** (see below): a subdirectory was invisible to the gate, so these sites sat outside the map while the map reported full coverage. **Strict as of #4410**, which is also what unblocked them: this row previously read "strictness here would enforce nothing" because nothing parsed `datasource.config` against these schemas and both `*DriverSpec.configSchema` literals were `{}`. Now `DatasourceSchema` parses `config` against them, and the same schemas project onto `configSchema` and onto the Studio connection form. (#4410 also ran the parse over each `readReplicas` entry; #4468 retired that key outright — see the row above.) `postgres.zod.ts` drops a site: its `ssl` was a `boolean | {ca, cert, key, …}` union, and the object arm is gone — certificates now live in the datasource-level `ssl` block (declared, strict, and until #4410 read by nobody), leaving `config.ssl` as the on/off shorthand. That narrowing is forced by the same projection: the Studio form renders anything that is not boolean/enum/number as a TEXT INPUT, so a union here would have produced a wizard whose every `ssl` value the new gate rejects. `memory.zod.ts` keeps 6 but loses two KEYS — `indexes` / `maxRecordsPerObject`, which `InMemoryDriverConfig` has no field for, removed under ADR-0049 rather than blessed by the new gate |
| `driver/mysql.zod.ts` / `driver/sqlite.zod.ts` | 1+2 | authorable | The rest of the `config` contract, added by #4410. `mysql.zod.ts` and `sqlite.zod.ts` (sqlite + sqlite-wasm) are shapes that **never existed** — both driver ids were offered by the connection form and buildable by the shared factory, with no config contract anywhere, so `driver: 'sqlite'` + a misspelled `filename` was an ephemeral `:memory:` database reported as configured. All three sites strict, same error factory as the rest of the campaign. (Their sibling `driver/common.zod.ts` holds shared enums and prescription strings and has no `z.object(` site, so the coverage gate skips it) |
| `analytics.zod.ts` | 8 | mixed (p) | |
| `document.zod.ts` | 8 | wire (p) | |
Expand Down
Loading
Loading