Repository navigation
Description
Found while implementing #8873 (the postgres DSN credential-injection arm) and filed rather than fixed there: it lands in packages/spec, and it is a different mechanism one door up.
The gap
PostgresConfigSchema.url documents a grammar in its own describe text —
Format:
postgresql://[user@][host][:port][/dbname][?params]
— and enforces none of it. The value goes through placeholderFree(credentialFreeUrl(z.string(), …)), and both of those are string-boundary scans: they find userinfo, credential query parameters and ${…} spans. Neither asks whether the string is a DSN the client can open.
That is deliberate at the helper, and correctly so. common.zod.ts says it twice, in both halves:
Deliberately NOT
new URL(): real DSNs take forms WHATWG parsing rejects or mangles
new URL()rejects the multi-host DSN form outright (#8696)
The multi-host form is the motivating case, and for mongo it is a real, working, documented shape (mongodb://app@h1:27017,h2:27017/app), so refusing it at the door would be wrong. The helper is shared across drivers, so its correct leniency for mongo becomes no check at all for postgres — where the same form is not a working shape but an unopenable one.
Measured
pg 8.22.0 does not implement libpq's multi-host DSN. Both its parser and its client constructor reject it:
parse('postgresql://app@h1:5432,h2:5433/app') -> TypeError [ERR_INVALID_URL]
new ConnectionParameters({connectionString: <the same string>}) -> TypeError [ERR_INVALID_URL]
PostgresConfigSchema accepts that exact value today. So an operator can publish a postgres datasource, see it saved, and discover only at connect time that the url can never open a connection — with a bare Invalid URL whose own input field pg redacts, i.e. an error that does not name the value that caused it.
Why it is worth a card rather than a shrug
This is the declared-≠-enforced shape of Prime Directive #10 at the authoring door: the schema publishes a format and validates a different, narrower property of the string. The connection form is the surface an operator (or an AI author) reaches for, and the one thing it could cheaply tell them — this is not a url this driver can open — is the thing it does not.
Note the fix is per-driver, not in the shared helper: credentialFreeUrl must keep refusing to parse, because mongo's multi-host and +srv forms depend on that. The candidate shape is a postgres-specific superRefine on url that asks the postgres client's own grammar, the same way #8873 made the runtime arm ask it rather than model it.
Scope note
Not a duplicate of #9041, which is about a different contradiction (credentialsRef bound while the url names no user — a semantic pair, both halves individually valid). This one is a url that is not a url for this driver at all.
#8873 is not addressed here and is unrelated in mechanism; it only made the runtime failure earlier and named (see #9090). Filed unassigned for triage.
Generated by Claude Code
Activity
os-project-manager commentedon Aug 16, 2026
Triage: lands in
packages/spec(PostgresConfigSchema.url), and it changes accept/reject behaviour — an authoring-time value accepted today becomes a refusal — so this is the semantic face ofdomain:spec→pm:queue, type Bug (declared-≠-enforced: the schema's own describe text documents a grammar it never checks). Per the semantic-face mandate, dispatch runs onmodel: claude-fable-5.Rationale for queue rather than the decision inbox: this inherits the standing loud-rejection family (silently-accepted invalid declarations join the existing refusal set — same inheritance #9040/#9041 rode yesterday), and the card's own constraint already fixes the mechanism: per-driver
superRefine, ⛔ not the sharedcredentialFreeUrlhelper, whose refusal to parse is load-bearing for mongo multi-host/+srv(#8696). The accepted-set narrowing is a restoration of the documented grammar, not a new posture.Dispatch notes for the spec seat:
pg's own grammar (the card's measured route viaConnectionParameters), don't re-model it — the same posture A boundexternal.credentialsRefreaches the knex config but NOT the server on the postgres DSN branch —pgoverrides it with the connection string #8873 took on the runtime arm.config.options.auth.passwordis a fourth spelling of an inline credential — authorable, persisted cleartext, unredacted, and read by the client #9040 / Nothing refuses the contradictory pair "external.credentialsRefbound + aconfig.urlnaming no user" — the binding is a silent no-op at connect #9041 (datasource config, spec semantic face) — ⛔ not the same batch; whoever lands first re-verifies the others' file surface.code+statusassertions per the standing minimum.Triage: this comment is from the triage seat (scheduled run) — no claim.
Generated by Claude Code
os-zhuang commentedon Aug 16, 2026
Claim: PM seat (spec lane), round 8.
session_01225pUjnCKWqxcc1PeqKFUqclaude/issue-9091-postgres-url-pg-grammarpg's own grammar via ConnectionParameters).config.options.auth.passwordis a fourth spelling of an inline credential — authorable, persisted cleartext, unredacted, and read by the client #9040 and Nothing refuses the contradictory pair "external.credentialsRefbound + aconfig.urlnaming no user" — the binding is a silent no-op at connect #9041 (feat(spec): refuse a credential in the mongo options passthrough (config.options.auth.password) at publish (#9040) #9123/feat(spec): refuse bound external.credentialsRef with a user-less mongo config.url at publish (#9041) #9146) have both MERGED, so no in-flight sibling touchesdatasource.zod.ts/driver zods. If this card's PR lands after any newpackages/spec/src/migrations/registry.tslander, the second lander runs the os-regen relay (scripts/pm/os-regen-merge.sh)./console/…(no underscore) — a path that does not resolve #9078 (JSDoc-only edits inui/view.zod.ts+ui/action.zod.ts) — no overlap withdata/driver/postgres.zod.ts.Generated by Claude Code
feat(spec): refuse a postgres config.url that pg itself cannot parse …
os-zhuang commentedon Aug 16, 2026
{ "issue": 9091, "status": "done", "branch": "claude/issue-9091-postgres-url-pg-grammar", "pr": "https://github.com/objectstack-ai/objectstack/pull/9158", "pr_number": 9158, "premise_still_valid": true, "summary": "Per-driver superRefine on PostgresConfigSchema.url now asks pg's own grammar (parse from pg-connection-string@^2.14.0, added as a spec dependency; shared credentialFreeUrl untouched per the mongo #8696 constraint). Refused at the value's path: anything parse throws on (multi-host DSN, bad port — the card's measured case), scheme-less values parse only 'accepts' by resolving against its placeholder base (pg would connect to literal host 'base'), and the fs-reading ?sslcert/?sslkey/?sslrootcert query params (parse calls fs.readFileSync on them; certificate material's declared home is the datasource-level ssl block). ADR-0087 semantic entry + scripted registry regen + docs regen + minor changeset with FROM→TO and adr-0087 marker.", "tests": "All at final commit 4f8f30270: pnpm --filter @objectstack/spec test = 406 files / 10816 passed; typecheck green; build + check:generated green after --fix regenerated the one proved-stale artifact (docs). Reverse verification from the committed state, expected direction red: exactly the 6 new refusal pins fail with the fix reverted, accept-side pins stay green; restored, tree clean vs HEAD. Downstream consumer sweep (prefix direction — consumers of spec, against rebuilt dist): service-datasource 478 passed, runtime 2468 passed; fixture grep over the rule's consumption radius found no now-refused fixture. dispatch-gates.mjs re-derivation union all green: cross-package-test-inputs, doc-formula-expressions (after building @objectstack/formula — fresh-worktree artifact, not this change), docs-audit-scope, docs-redirects, merge-driver, objectui-changeset, quick-reference-counts, release-page-status, role-word, spec-parsed-alias, type-source-resolution, adr-0087-registration, changeset-no-major, empty-changeset, dev-prereqs, query-options-erasure, engine-double-contract, where-matcher, type-check-coverage, type-check-debt --re-measure (closure built first), check-nul-bytes.", "commits": ["4f8f30270"], "files_touched": [".changeset/datasource-config-postgres-url-unparseable-refused.md", "content/docs/references/data/driver-postgres.mdx", "packages/spec/package.json", "packages/spec/src/data/driver/postgres.test.ts", "packages/spec/src/data/driver/postgres.zod.ts", "packages/spec/src/migrations/entries/semantic/18.datasource-config-postgres-url-unparseable-refused.ts", "packages/spec/src/migrations/registry.ts", "pnpm-lock.yaml"], "gates_run": {"spec-test": "pass", "spec-typecheck": "pass", "spec-build": "pass", "check-generated": "pass", "check-nul-bytes": "pass", "adr-0087-registration": "pass", "changeset-no-major": "pass", "empty-changeset": "pass", "cross-package-test-inputs": "pass", "doc-formula-expressions": "pass", "docs-audit-scope": "pass", "docs-redirects": "pass", "merge-driver": "pass", "objectui-changeset": "pass", "quick-reference-counts": "pass", "release-page-status": "pass", "role-word": "pass", "spec-parsed-alias": "pass", "type-source-resolution": "pass", "dev-prereqs": "pass", "query-options-erasure": "pass", "engine-double-contract": "pass", "where-matcher": "pass", "type-check-coverage": "pass", "type-check-debt-re-measure": "pass", "consumer-service-datasource-test": "pass", "consumer-runtime-test": "pass", "remote-ci": "in_progress"}, "semantic_entry": "18.datasource-config-postgres-url-unparseable-refused.ts", "changeset": "datasource-config-postgres-url-unparseable-refused.md", "tests_added": 9, "open_questions": [], "deviations": ["Refuses ?sslcert/?sslkey/?sslrootcert query params, which pg can sometimes open (when the path exists on the connect host): parse itself calls fs.readFileSync on them, and publish validation must not read the validating server's filesystem — prescription reuses SSL_DETAIL_BELONGS_ON_DATASOURCE, the same home the config-level ca/cert/key keys already name. Named in the PR body.", "Refuses scheme-less values pg technically accepts by resolving against its placeholder base — covered by the triage's 'structurally unusable result' clause; detection mirrors the parser's own preprocessing so the branches cannot disagree.", "Report schema: dispatch prompt's report shape differs from the standing contract's; per the conflict rule this report is the standing schema with the dispatch prompt's extra fields merged in as additional keys.", "origin/main moved during the run by two docs-only commits (no registry lander) — no os-regen-merge relay needed; branch stays based on d491625c1."], "risks": ["Browser bundles pulling spec's data entry now include pg-connection-string (no runtime deps; its fs require is lazy and unreachable from this call site since the fs-param refusal runs before parse) — bundlers may still surface the conditional require('fs').", "parse emits pg's own once-per-process deprecation process.emitWarning for ?sslmode=require|prefer|verify-ca URLs, now reachable at publish-time validation (library-guarded, once per process).", "The dummy-base detection mirrors pg-connection-string@2.14.0 internals (pinned ^2.14.0); a parser major rewrite could drift — behavior is pinned by tests either way."], "out_of_scope_findings": [] }Generated by Claude Code
os-zhuang commentedon Aug 16, 2026
PM review — ACCEPT (round 8, session
session_01225pUjnCKWqxcc1PeqKFUq).PR #9158 verified against the card and triage constraints:
pgParseableUrlsuperRefine composes withcredentialFreeUrl/placeholderFreeon the postgresurlonly;common.zod.tsis not in the diff, so mongo's multi-host/+srvleniency (A boundexternal.credentialsRefis silently dropped on the DSN branches of the mysql and mongodb driver arms #8696) is structurally preserved — and pinned by an explicit mongo accept-side test.pg's grammar, doesn't re-model it:parsefrompg-connection-string@^2.14.0— the parserpgitself runs connection strings through. The two places the code mirrors parser preprocessing (relative-URL detection, fs-param probing) are bounded mirrors with both-direction measured pins (?SSLCERT=and empty?sslcert=stay accepted, matchingpg's exact-case/non-empty behavior).?sslcert=/?sslkey=/?sslrootcert=): goes beyond the card's literal "refuse whatpgrefuses", and I judge it in scope — it is forced by the mechanism (parseitself callsfs.readFileSync, and a publish verdict must not read the validating host's filesystem or become an existence oracle), and it inherits the already-ruledSSL_DETAIL_BELONGS_ON_DATASOURCEprescription the config-levelca/cert/keykeys carry — a route fork already ruled for the sibling spellings, inherited rather than re-asked.code+ re-pathed location at the schema layer;statusis the publish door's uniform 422INVALID_METADATAwrap (stated in the test docblock, same as the mongoconfig.options.auth.passwordis a fourth spelling of an inline credential — authorable, persisted cleartext, unredacted, and read by the client #9040/Nothing refuses the contradictory pair "external.credentialsRefbound + aconfig.urlnaming no user" — the binding is a silent no-op at connect #9041 siblings).registry.tsregenerated by script; changeset@objectstack/specminor with FROM→TO and theadr-0087: registeredmarker, matching the sibling launch-window convention.pg-connection-string(zero runtime deps; lazyfsrequire unreachable from this call site — the fs-param refusal runs first).Landing flow: flip ready + auto-merge once the PR's gate jobs complete green.
registry.tsrelay watch stands if any sibling lands on it first (none in flight).Generated by Claude Code
feat(spec): refuse a postgres config.url that pg itself cannot parse …
test(spec): the last data/ file group's test titles state each cited …