Skip to content

feat(spec): refuse bound external.credentialsRef with a username-less composed mongo config at publish (#9147) - #9312

Merged
os-project-manager merged 1 commit into
mainfrom
claude/issue-9147-mongo-discrete-credentialsref-refusal
Aug 17, 2026
Merged

feat(spec): refuse bound external.credentialsRef with a username-less composed mongo config at publish (#9147)#9312
os-project-manager merged 1 commit into
mainfrom
claude/issue-9147-mongo-discrete-credentialsref-refusal

Conversation

@os-project-manager

Copy link
Copy Markdown
Collaborator

Fixes#9147

The composed-branch twin of #9041: external.credentialsRef bound while a mongo config authors no url and names no username is the same silent no-op, and is now refused at the same publish door.

Where this actually landed

The card and the dispatch both name packages/services/service-datasource. The refinement to extend is not there — PR #9146 landed #9041's recognizer in packages/spec/src/data/datasource.zod.ts (DatasourceSchema's superRefine), pinned in packages/spec/src/data/driver/driver-credential-refusal.test.ts. service-datasource holds the connect path the refusal is measured against. This PR extends the landed refinement in place — no second recognizer.

The defect, re-measured on origin/main @ b0fa4fc1a

driver: mongodbconfig: { database: events, host: mongo.internal }external: { credentialsRef: "sys_secret:01J9ZK4T2N" }

Parsed green, connected anonymously, bound secret never used, operator told nothing.

Mechanism, read off default-datasource-driver-factory.ts and pinned as a runtime test in this PR:

  • with no url, buildMongoUrlcomposes the URI, and the bound secret's only route into it is the userinfo written beside a username — const auth = user ? ... : ''. A falsy username closes that route;
  • the DSN branch's route is shut here by construction: buildMongoAuth opens with if (!url) return undefined, because the composed branch injects through the URI it builds rather than beside it.

So the secret has nowhere to go, and nothing can be fabricated to rescue it — a MongoDB handshake cannot authenticate from a password alone, the same measured asymmetry that made #9041's refusal the right answer rather than an unconditional injection.

Its own message, not #9041's

The remedy differs, and a refusal naming a remedy that does not apply is worse than no refusal — the failure mode this module's own history section documents (the pre-#4410belongsInConfig line). On the URL branch config.url supersedes the discrete username, so the only fix is the URL's userinfo. Here url is absent and config.username is the live field. The new message names both valid fixes and prescribes neither: add username to config, or remove the external.credentialsRef binding. It is pathed at config.username, and a test asserts #9041's URL remedy text is not copied in.

The fence — both directions pinned

Refused (new): mongodb arm only, url absent, username absent, credentialsRef bound. Legacy driver: 'mongo' is judged identically via resolveDriverId.

Every near-miss is pinned as still accepted, because the near-misses are what prove the fence:

near-missverdict
url present naming a user (condition 1 absent)accepted, byte-identical
url present naming no userexactly one refusal fires, and it is #9041's
discrete username present (condition 2 absent)accepted, byte-identical — the branch #8696 already works on
no binding (condition 3 absent)accepted — anonymous intent is legal
empty-string credentialsRefaccepted — mirrors the connect path's truthy check
postgres composed, no username, boundaccepted — #8873, not widened to
non-string usernamethe config gate's finding, not this one

Two judgment calls, named rather than buried

1. "names no username" is the falsy set (undefined or ''), not key-absence.username: '' composes the identical userinfo-free URI and drops the identical secret — measured, and now pinned in bound-secret-dsn-branches.test.ts. It refuses nothing that works today, so it does not widen past the measured no-op; excluding it would leave this refusal prescribing config.username while the platform still accepted the one spelling of config.username that keeps the binding silent. Note the deliberate asymmetry with #9041's present-but-empty userinfo carve-out: there MongoClient itself throws (URI contained empty userinfo section) so the shape is already loud, while here nothing throws. Each fence follows the measurement on its own branch. This is the one place the implementation reads the dispatch's three-condition fence as "names no username" rather than "key absent" — reversible in one token if the PM disagrees.

2. An empty config.url is the composed branch — an over-refusal in the landed #9041 arm, corrected here.buildMongoUrl opens if (explicit) return explicit;, so url: '' falls through and composes from the discrete fields, where a live discrete username makes the bound secret work. #9041's arm judged it as a URL "naming no user" and refused it — i.e. rejected at publish a datasource that connects authenticated at runtime, and a shape the Setup form can plausibly submit from an untouched empty "Connection URI" input (measured: refused on origin/main, accepted here). Both arms now split on the factory's own branch test, so each judges exactly the branch that will run. Same refinement, same defect class, same gate families, evidence pinned by buildMongoUrl itself — corrected in place rather than filed, and named here per the bounded in-place rule.

Verification

Union run at 2444d022b — the final commit, tree clean.

  • pnpm --filter @objectstack/spec test408 files / 10885 tests passed
  • pnpm --filter @objectstack/service-datasource test21 files / 480 tests passed
  • pnpm --filter @objectstack/spec typecheck — OK (test layer at its frozen debt, no drift)
  • pnpm --filter @objectstack/service-datasource typecheck — OK
  • Reverse verification (fix committed first, refinement reverted to origin/main, restored): 7 failures, exactly the predicted set — the 4 refusal-direction pins, the 2 url: '' routing pins, and the mongo config.options.auth.password is a fourth spelling of an inline credential — authorable, persisted cleartext, unredacted, and read by the client #9040 composition pin. The near-miss acceptance pins correctly stay green in both states; they pin the fence, not the fix.
  • Consumer sweep (downstream of the narrowed rule, credentialsRef across packages/apps/examples/content): no mongo fixture anywhere binds credentialsRef; every other binder is sqlite/postgres. No doc example carries the refused shape.

Gates, derived with node scripts/pm/dispatch-gates.mjs against the actual changed paths (merge-base diff) and all run green: check:migration-registry, check:changeset-gate-self-tests, check:cross-package-test-inputs, check:doc-formula-expressions, check:empty-state, check:liveness, check:strictness-ledger, check:variant-docs, check:merge-driver, check:objectui-changeset, check:spec-parsed-alias, check:test-source-alias, check:type-source-resolution, check-adr-0087-registration, check-changeset-no-major, check-cross-package-test-inputs, check-dev-prereqs, check-empty-changeset, check-affected-docs, check-nul-bytes; convention-triggered: check:engine-double-contract, check:where-matcher, check:query-options-erasure, check:type-check-coverage, check:type-check-debt --re-measure (33 ledger entries, none above its recorded number — run against the built workspace closure).

ADR-0087 semantic entry registered under protocol major 18 and the registry regenerated with gen:migration-registry; changeset added (@objectstack/spec minor, the launch-window convention #9041 used).


Generated by Claude Code

… composed mongo config at publish (#9147)
The composed-branch twin of #9041, widening the same datasource-level
refinement by one condition. With no `config.url` the factory composes the URI
and the bound secret's only route into it is the userinfo written beside a
username (`const auth = user ? … : ''`); `buildMongoAuth` returns early on
`!url`, so a falsy `username` leaves the secret with nowhere to go and the
datasource connects anonymously with the operator told nothing.
Its own message, not #9041's: here `config.username` is the live field, so
"add the username to the URL's userinfo" would name a fix this branch cannot
take. Fences: mongodb arm only (legacy 'mongo' alias-resolved), `username`
undefined or '' (the falsy set the composer actually tests), non-string left
to the config gate, empty-string ref not a binding, postgres not widened to
(#8873). Also corrects an over-refusal in the landed #9041 arm: `url: ''`
composes at connect, so it is judged by the composed arm rather than refused
as a user-less URL. ADR-0087 semantic entry + registry regen.
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/spec, touching 3 documentable anchor(s).

1 release-owned page(s) name something this change touched. These are read-only:

  • content/docs/releases/v17.mdx(via DatasourceSchema (symbol))

content/docs/releases/ is RELEASE-OWNED (AGENTS.md "Documentation Guardrails"): release
notes are written centrally at release time, and a code PR that edits them is the exact PR
that guardrail exists to stop. They are still audited — read-only. If one of them is actually
wrong, file an issue or open a dedicated docs-only PR; do not edit it here.

What this run could not see
  • 4 name(s) were too generic to anchor anything (single lowercase words)

Coarse fallback — 113 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json origin/mainpackageMentionDocs.

Advisory only, and a precision-first one (#9192): a page is listed because it names a
symbol, wire route or SDK method this diff touched — not because it mentions a changed
package. Each row says which anchor put it there, so a wrong row is reportable rather than
merely annoying. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs origin/main → pass the list as args.docs.

@os-project-managerClaude

Copy link
Copy Markdown
CollaboratorAuthor

CI triage: Test Core (1/3) red at 2444d022b is not this PR — it is #7282's watcher-timing flake family

Diagnosed rather than assumed; the turbo log is interleaved, so the real block was extracted from the job log.

The actual failure (run 32031835133):

❯ test/watch-write-registration.test.ts (1 test | 1 failed) 20050ms
× registers a put() path with the watcher without waiting for a poll 20046ms
AssertionError: expected [] to include 'anchor'
❯ packages/metadata-fs/test/watch-write-registration.test.ts:166:43

A chokidar watch-event wait (EVENT_WAIT_MS = 20_000) that expired with events still []. The other two shards, Test Core (2/3) and (3/3), were green, as were ESLint, TypeScript Type Check, Check Changeset and Spec property liveness. Note the earlier ELIFECYCLE Test failed under @objectstack/spec:test in the same log is that task being torn down mid-run once metadata-fs failed — spec printed no summary line because it never finished, not because it failed.

Why it cannot be this diff.@objectstack/metadata-fs declares exactly two dependencies — @objectstack/metadata-core and chokidar — so it does not consume @objectstack/spec at all, and grep -rn 'atasource' packages/metadata-fs/src returns nothing. There is no datasource surface for a DatasourceSchema refinement to reach. The failing test's own describe block is named for #7282, and the file is the pin PR #7336 added to close it; #7282 already recorded this same family ejecting #7261, "a one-file test-only change in packages/objectql with no dependency path to metadata-fs". Same shape as this PR.

Controls, both directions:

wherewhatresult
this branch @ 2444d022bfull metadata-fs package, 3 runs51 tests passed, 3/3
this branchthe failing file alone, 6 runs6/6 passed
clean origin/main @ e4e5c6e3cthe failing file alone, 6 runs6/6 passed
clean origin/mainsame file, 8 concurrent runs8/8 passed

So it is not deterministic on main either — the load-dependent, all-or-nothing suppression #7282 characterised, whose 20s deadline that card had already measured as spent. Recurrence filed as #9339 (unassigned). ⛔ Not fixed here: it is an unrelated defect in another package, and the refusal was not weakened to make a test pass.

Corrected consumer sweep. The original sweep grepped credentialsRef, which finds bindings but not fixtures that merely author a datasource the refinement now judges. Re-derived from the actual consumers of DatasourceSchema / defineDatasource (grep -rln across packages/apps/examples), then every one of them run:

packageresult
@objectstack/spec408 files / 10885 tests passed
@objectstack/objectql213 files / 3765 tests passed
@objectstack/runtime165 files / 2464 tests passed
@objectstack/metadata-protocol (registers the datasource type)117 files / 1617 tests passed
@objectstack/service-datasource21 files / 480 tests passed
@objectstack/downstream-contract2 files / 20 tests passed

~18,400 tests, all green. examples/app-crm and examples/app-showcase author datasources too; both are covered by the Dogfood Regression Gate shards, green on this run. Every mongo datasource artefact in the tree (grep -rln "driver: *['\"]?mongo") lives in packages/spec or packages/services/service-datasource — both in the table above — so no fixture outside the two edited packages feeds the narrowed rule.

No code change was warranted, so the branch is unchanged at 2444d022b. Failed jobs re-queued on the same run.


Generated by Claude Code


Generated by Claude Code

@os-project-manager
os-project-manager marked this pull request as ready for review August 17, 2026 22:01
@os-project-manager
os-project-manager added this pull request to the merge queueAug 17, 2026
Merged via the queue into main with commit 00777a0Aug 17, 2026
39 of 41 checks passed
@os-project-manager
os-project-manager deleted the claude/issue-9147-mongo-discrete-credentialsref-refusal branch August 17, 2026 22:20
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentationImprovements or additions to documentationprotocol:datasize/lteststooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Composed-branch twin of #9041: external.credentialsRef bound + discrete mongo fields naming no username is the same silent no-op, unrefused

1 participant

@os-project-manager