Skip to content

Commit e5ae4ef

Browse files
committed
Merge origin/main into claude/issue-9107-readonlywhen-supplied-values
2 parents a34961d + e4e5c6e commit e5ae4ef

11 files changed

Lines changed: 1150 additions & 70 deletions
Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
---
2+
"@objectstack/driver-sql": patch
3+
---
4+
5+
fix(driver-sql): a dialect error the driver cannot attribute leaves the read exits as an ADR-0112 backend-fault envelope instead of raw (#8931)
6+
7+
<!-- adr-0087: not-required (no-migration-prescription) Nothing authorable is
8+
added, renamed, retired or tombstoned — no metadata key, no spec surface, no
9+
declaration an author writes. The change is entirely in what a failing READ
10+
throws: an error that already declared no `status` and carried the compiled
11+
statement now declares `DATABASE_ERROR` / 500 and does not. There is no source
12+
file for a consumer to migrate and therefore no semantic-migration TODO to
13+
emit; the accept set is unchanged, since every condition below failed before
14+
this change and fails after it. -->
15+
16+
`SqlDriver.find()` / `findOne()` / `count()` had one exit that answered with the
17+
**database's own error object**: a `code` from the backend's vocabulary
18+
(`42P01`, `SQLITE_ERROR`, `42601`, `22P02`, …), **no `status`** at all, and a
19+
message opening with the compiled statement. Two things travelled out of it that
20+
should not have — the statement's shape, and on one measured row the caller's
21+
own value.
22+
23+
Ruled 2026-08-17 on #8931: the driver stops answering an unenveloped dialect
24+
error. Any dialect error the existing classification does not claim now leaves
25+
as a **generic backend-fault envelope**, `DATABASE_ERROR` / 500, asserting only
26+
*"the backend rejected this statement"*.
27+
28+
**Not a filter verdict, and that is the ruling rather than a preference.**
29+
Measured live on PostgreSQL 16.13, a dotted WHERE key and a table that was never
30+
created raise the *same* SQLSTATE:
31+
32+
```
33+
dotted key 42P01 missing FROM-clause entry for table "title"
34+
table not created 42P01 relation "no_such_object" does not exist
35+
```
36+
37+
An `INVALID_FILTER` here would tell an operator whose schema sync had not run
38+
that their *filter* was wrong. The signal cannot support the claim, so the
39+
envelope does not make it — and the driver still never inspects the caller's key
40+
for a `.` (that verdict is #8371's, and it landed there).
41+
42+
**Mechanism: a terminal catch-all, not a new recognizer.** No predicate learns
43+
`42P01`. `isUnresolvableColumnError` and `isMissingTableError` are untouched, so
44+
the #8790 refusal (`INVALID_FILTER` / 400 naming the column) still wins wherever
45+
it applies, and the #3821 projection / ORDER-BY recoveries still return rows.
46+
47+
**What now takes the envelope**, measured on live PG 16.13 and better-sqlite3:
48+
a table that was never provisioned; a dotted WHERE key on Postgres; a
49+
comparand-shape syntax fault; a value the column type rejects; and connection,
50+
pool-acquisition, timeout or permission failures.
51+
52+
**The disclosure this closes on a route nobody had named.** Postgres puts the
53+
caller's rejected VALUE in its own `22P02` diagnostic (`invalid input syntax for
54+
type integer: "…"`), *downstream* of everything knex parameterised — so no
55+
statement cut removes it. Withholding the dialect text whole is what closes it.
56+
(#8931's headline premise, a bound literal inlined on the *dotted* route, was
57+
measured false and pinned by #9108; this is the neighbouring row where a value
58+
really does travel.)
59+
60+
**The original error is kept as a non-enumerable `cause`.** That is load-bearing,
61+
not tidiness: `isMissingTableError` follows `cause`, and thirteen read paths use
62+
it to tell "the table was never provisioned" — a benign emptiness — from a
63+
failure that must stay loud. Non-enumerable so the statement cannot ride back
64+
out through `JSON.stringify(err)` or a spread.
65+
66+
**For callers.** At the REST boundary the wire answer for these conditions is
67+
materially unchanged — `mapDataError` already derived `500` + `DATABASE_ERROR`
68+
for them by sniffing the message; it is now *declared* by the producer that
69+
knows, per ADR-0112, and every non-REST consumer (an in-process ObjectQL caller,
70+
a plugin, an AI-authored action) gets the same declared answer instead of having
71+
to pattern-match a SQLSTATE that differs per backend. Two consequences worth
72+
naming: code that matched on the raw dialect `code` or message of a failing
73+
**read** must read `error.cause` instead; and a read against a **registered
74+
object whose table was never created** now answers `500 DATABASE_ERROR` where it
75+
previously answered `404 OBJECT_NOT_FOUND` with the body `Object 'x' is not
76+
registered` — a sentence that was false in exactly that state.
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
---
2+
"@objectstack/spec": patch
3+
---
4+
5+
fix(spec): correct `MongoConfigSchema.options`'s field description to state the actual refusal boundary — only `auth.password` is refused inline; `proxyPassword`, `tlsCertificateKeyFilePassword`, `key`, and `passphrase` are accepted, stored at rest in cleartext, and redacted only on read (#9254)
6+
7+
The old string claimed "credential material is refused" for the whole `options`
8+
passthrough. That was true for exactly one nested path
9+
(`options.auth.password`, `MONGO_OPTIONS_CREDENTIAL_PATHS` / #9040) — four
10+
other honoured, credential-shaped keys were never refused, only redacted when
11+
a datasource is read back (`PASSTHROUGH_SECRET_PATHS` in
12+
`datasource-credential-redaction.ts`). This string renders verbatim into
13+
`content/docs/references/data/driver-mongo.mdx` and the Studio "Add
14+
Datasource" connection form's field help text, so an author configuring a
15+
proxy password or a TLS key passphrase was told it would be refused when it
16+
would actually be accepted and stored in cleartext.
17+
18+
Describe-only: no schema shape or refusal-path change — every previously-valid
19+
`options` input still parses byte-identically. The corrected text agrees with
20+
the accurate statement #9124 landed in `content/docs/data-modeling/drivers.mdx`.
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
---
2+
"@objectstack/plugin-sharing": patch
3+
---
4+
5+
fix(sharing): `publicSharing.eligibility` binds declared fields through the canonical `materializeDeclaredFields` instead of a local copy (#8489)
6+
7+
`share-link-service.ts` carried its own `bindDeclaredFields` — a hand-written
8+
mirror of `@objectstack/objectql`'s `materializeDeclaredFields`, named as a copy
9+
in its own doc comment. It is retired; `assertEligible` now imports the
10+
canonical helper from `@objectstack/objectql/core` (already a runtime dependency
11+
of this package), with a spread at the call site because the canonical
12+
materialises in place.
13+
14+
**This changes eligibility verdicts on exactly one row shape**, and the change
15+
was accepted knowingly (maintainer ruling, 2026-08-16). The retired mirror bound
16+
a declared field by key PRESENCE (`!(name in record)`); the canonical binds by
17+
VALUE (`record[name] === undefined`). They agree on every other input class,
18+
including a missing or malformed `fields` map, where both return the record
19+
untouched. Where they differ is a declared field held as an own key whose value
20+
is `undefined` — a shape `InMemoryDriver` measurably produces (an explicit
21+
`undefined` on `create` survives to `find`) and `SqlDriver` structurally cannot
22+
(a SQL NULL arrives as `null`).
23+
24+
On that shape only, with a declared `status`:
25+
26+
| eligibility predicate | before | after |
27+
|:------------------------------|:-------------------------------|:-------------------------|
28+
| `record.status == null` | 422 `ELIGIBILITY_UNEVALUABLE` | **link is minted** |
29+
| `has(record.status)` | 422 `RECORD_NOT_ELIGIBLE` | **link is minted** |
30+
| `!has(record.status)` | **link was minted** | 422 `RECORD_NOT_ELIGIBLE` |
31+
| `record.status == 'published'`| 422 `ELIGIBILITY_UNEVALUABLE` | 422 `RECORD_NOT_ELIGIBLE` |
32+
33+
The first two rows widen acceptance: the predicate is now *answered* rather than
34+
faulting on a key CEL reads as absent, and on this fail-closed gate a fault was a
35+
refusal. The third row is the one that mattered for the decision — it **closes an
36+
over-acceptance**. `has()` guards an UNDECLARED key and never an empty value once
37+
bindings are materialised, so `!has(record.<declared field>)` is false; the
38+
mirror was minting share links there that every other server-side surface
39+
refuses. The fourth row keeps its direction and changes only its ADR-0112 `code`.
40+
41+
The eligibility pin is rewritten to discriminate (#9085): its previous
42+
declared-field case passed with the binder fully ablated, because every seeded
43+
row carried the field it claimed was absent. The replacements use a declared
44+
field the stored row genuinely does not carry, and fail in opposite directions
45+
under ablation.

content/docs/references/data/driver-mongo.mdx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -48,7 +48,7 @@ MongoDB Connection Configuration
4848
| **username** | `string` | optional | Authentication user |
4949
| **password** | `never` | optional | Set through the connection form's secret field or `external.credentialsRef` — encrypted into `sys_secret`, never stored in `config` (#7990) |
5050
| **authSource** | `string` | optional | Authentication database |
51-
| **options** | `Record<string, any>` | optional | Extra MongoClient options (replicaSet, tls, timeouts, …; credential material is refused — bind secrets via the connection form / external.credentialsRef) |
51+
| **options** | `Record<string, any>` | optional | Extra MongoClient options (replicaSet, tls, timeouts, …). Only `auth.password` is refused inline — bind it via the connection form / external.credentialsRef. `proxyPassword`, `tlsCertificateKeyFilePassword`, `key`, and `passphrase` are accepted and stored at rest in cleartext; they're redacted only when the datasource is read back, not refused at write. |
5252

5353

5454
---

0 commit comments

Comments
 (0)