Skip to content

Commit 22663ca

Browse files
docs(skills,adr): teach the code-defined datasource credential route (OS_DATASOURCE_NAME_PASSWORD behind the host switch) (#28432)
<!-- claude-projects-attribution: {"github_login":"os-project-manager"} --> _Requested by **Leehom** · [project thread](https://claude.ai/code/project/chan_01TenzsRopZj4jMbvjX8uz1X?thread=cmsg_01TenzsRopZj4jMbvjX8uz1X3FUiFyiLjMuyYfjHpVM9Ht)_ Fixes #28209 Clause-②: no — governed prose (two ADR amendment notes) and one published skills rules file. No schema, export, type, key, value, error code or runtime behaviour moves; nothing is newly accepted or refused. Metadata-protocol: none ## 维护者速读 - **改了什么**:三处文字补上 PR #28208 已落地的「代码声明的数据源在连接时读 `OS_DATASOURCE_NAME_PASSWORD`」这条路:`skills/objectstack-data/rules/datasources.md` 的凭据一节(变量名规则、主机开关 `OS_DATASOURCE_ENV_CREDENTIALS` 及其默认、已声明的 `credentialsRef`/`connector` 仍优先且不回落、给运维的信任提醒);ADR-0015 的 Amended-by 行、D7 注里「对数据库仍然成立」那句的行内更正、§10 凭据条目和一则带日期的 §10 修订注;ADR-0062 D3 下一则带日期的修订注(该槽在连接解析顺序里的位置、失败文字按 PR #28325 的样子)。另加一条 `@objectstack/skills` 的 patch changeset。 - **为什么改**:卡 #28209(分诊 6528463851 / 6538723756):#28208 落地后这三处文字说少了一条路,写数据源的 agent 会按旧文字去写一个代码数据源铸不出来的 `sys_secret:` 句柄。 - **风险与回滚**:无运行时改动。skills 文件在 token 棘轮内(909 → 910,上限 911),新增文字靠删同文件里的重复句付账,没有折行凑行;删掉的每一句在包内别处仍有落点(正文列出)。回滚即 revert。 - **先看**:`skills/objectstack-data/rules/datasources.md`(凭据一节与被删的五处);`docs/adr/0015-external-datasource-federation.md`(§10 下的修订注,含信任提醒);`docs/adr/0062-external-datasource-runtime.md`(D3 修订注)。 - **两分钟验证**:`node scripts/check-skills-token-ratchet.mjs` → `skills/objectstack-data/rules/datasources.md is 910 tokens (ceiling 911; headroom 1)`;`pnpm check:pm-skill-id-lint` 绿(skills 文字无 `#NNNN`)。 - **需要你做的**:Tier H(`skills/**` + `docs/adr/**`):PR 保持 draft,等你本人批准后由席位落地;另请裁定「skills 改动带 patch changeset 而非 `skip-changeset`」这一处与派发令的出入(见 Acceptance notes)。 ## What changes ### Measured on `origin/main` `5da3a8ce` before any edit What PR #28208 shipped, with PR #28325's two corrections, cited by file and line on that tree: | Fact | Where | | :--- | :--- | | Variable name rule: `OS_DATASOURCE_` + the datasource name upper-cased with every character outside `[A-Z0-9]` replaced by `_` + `_PASSWORD` | `packages/services/service-datasource/src/datasource-env-credential.ts:55-57` (`datasourceEnvCredentialVar`) | | Which names never map: `default` (an `asDefault` connect never reads the slot) | `packages/services/service-datasource/src/datasource-connection-service.ts:772` | | Which names never map: a name outside the host's code-datasource set (a Setup- or API-created datasource), or a connect whose trigger is not `declared-auto` | `datasource-connection-service.ts:936-941` (`envCredentialVarFor`) | | No two schema-valid names derive one variable: the name regex is lower-case `^[a-z_][a-z0-9_]*$`, on which upper-casing is injective | `packages/spec/src/data/datasource.zod.ts:652` | | The host switch name, `OS_DATASOURCE_ENV_CREDENTIALS` | `datasource-env-credential.ts:44` | | Its reading: unset or empty is off; `on` / `off` in any letter case; any other value throws | `datasource-env-credential.ts:65-78` (`resolveDatasourceEnvCredentialsSwitch`) | | The throw refuses the boot: the admin plugin reads the switch inside `init()` and wires the slot only when it is on | `packages/services/service-datasource/src/datasource-admin-plugin.ts:338` (`init`), `:421-429` (the `envCredentials` spread) | | `os serve` stamps `on` when the operator left it unset or empty, just before it builds the admin plugin | `packages/cli/src/commands/serve.ts:5287-5289`; `packages/cli/src/utils/datasource-env-credentials-default.ts:20-28` | | `os start` and `os dev` run `serve`, so they get the stamp | `packages/cli/src/commands/start.ts:528`, `packages/cli/src/commands/dev.ts:848` | | Library compositions: nothing else calls the stamp, and the service's `envCredentials` is optional and absent by default | `datasource-connection-service.ts:171`; `datasource-env-credential.ts:80-89` | | Resolution order inside `connect()`: `credentialsRef` (fail-closed) → the D7 connector borrow (fail-closed) → the env slot, only when neither is declared and not `asDefault` → the driver factory | `datasource-connection-service.ts:713-738`, `:749-757`, `:770-795`, `:805` (`factory.create`) | | The value becomes the build's `secret`; an empty or unset variable leaves `envCredentialVar` set so the failure can name it | `datasource-connection-service.ts:791-793` | | Collision refusal: a second live name deriving the same variable is refused `failed-credentials`, naming both datasources and the variable, before any read | `datasource-connection-service.ts:775-786` | | A failed connect releases its claim on the variable (PR #28325) | `datasource-connection-service.ts:911` | | Failure text as PR #28325 left it: the driver's error, then ` (note: datasource 'NAME' is defined in code and reads its credential from OS_DATASOURCE_NAME_PASSWORD at connect, which is not set on this host)`; verdict `failed-degraded` | `datasource-connection-service.ts:921-924` | | The code-datasource set's three producers: the boot artifact, `default`, and a package installed into the running kernel (the trust consequence) | `packages/runtime/src/app-plugin.ts:626`, `packages/runtime/src/default-datasource-plugin.ts:150`, `packages/runtime/src/declared-datasources.ts:188` | | The spec's shared sentence naming the route, appended to five credential refusals, the refused-key description and the two `password` guidance entries | `packages/spec/src/data/driver/common.zod.ts:103-108` (`CODE_DEFINED_CREDENTIAL_ROUTE`); 7 uses there, 2 in `packages/spec/src/data/datasource.zod.ts` | ### The three texts **`skills/objectstack-data/rules/datasources.md`** (reader: the customer's agent writing a `defineDatasource` or a package's `*.datasource.ts`; mistake prevented: inlining a password, or writing a `credentialsRef` handle nothing mints for a code datasource, or expecting a library-built kernel to read the variable). - The section is now "Credentials — fail-closed". Its opening paragraph says: a code-declared datasource reads its password at connect from `OS_DATASOURCE_NAME_PASSWORD` (NAME = the name upper-cased; exact for every schema-valid name, since the only non-alphanumeric character a valid name can hold is `_`); only while the host switch `OS_DATASOURCE_ENV_CREDENTIALS` is `on`; `os serve` / `os start` / `os dev` default it on, a library composition is off, and the switch takes `on` / `off` only; the operator's caution that an installed package may declare a datasource of any free name and so read that variable; and `external` may instead name one channel, which wins and never falls back to the variable. The `credentialsRef` and `connector` bullets and the fail-closed paragraph are kept. - Paid under the ratchet by deleting restatements in the same file, each with a home elsewhere in the bundle: - the "Full field reference" pointer to `datasource.zod.ts` — `skills/objectstack-data/references/_index.md:12` carries the same path; - the `// writable: true` comment in the example — the Writes section two screens down states the gate; - the "✅ / ❌ Column mapping" heading and its ✅ bullet — the example's `// columnMap: { cust_region: 'region' }, // remoteColumn → localField` line shows the same key with its direction; the ❌ `field.columnName` rule stays; - "(default: `'default'`)" on the object field — `skills/objectstack-data/SKILL.md:87`'s field table carries that default; - "and its federated objects registered," in the auto-connect sentence, "(at `defineStack`, else at connect)" on the connector refusal, and "Otherwise every write to the object is rejected." after "read-only by default … both gates must be on" — consequence and timing clauses whose rule survives in the sentence they hung on. - No re-wrap bought a line: every line that moved either lost a clause or is new text. - Two readings, lines and tokens (the ratchet's unit): the file 84 → 83 lines, 909 → 910 tokens against a ceiling of 911 (headroom 2 → 1); the whole shipped bundle 219807 → 219808 tokens; the sum of every `SKILL.md` 56506 → 56506 (none edited). No ceiling moves. - Not taught, on purpose: the collision refusal (no author can write two valid names that collide), the unset-variable failure text (the reason names the variable at runtime), and the per-request plugin-driver limit (no reader among the agents this file serves). All three are recorded in the ADR-0015 note. **`docs/adr/0015-external-datasource-federation.md`** - The `Amended by` header line gains the #28208 entry with the ruling record. - The D7 note's "§10 says credentials reach a datasource only through `credentialsRef`. That stays true for a database." gains an inline, dated correction in the ADR's own `*(amended …)*` form, pointing at the §10 note. - The §10 Credentials bullet gains a second inline `*(Amended …)*`, in the form the D7 amendment already used there. - A dated amendment note under §10, in the blockquote form every other note in this ADR uses: the route, the name derivation, where the value goes, then the guards as ruled (the host switch and its default per host; code origin by provenance, never `origin`; a declared channel wins and stays fail-closed; an unset variable leaves the connect as it was and the failure text as PR #28325 left it; the collision refusal and the released claim; the per-request plugin-driver limit), then the trust consequence stated for operators, the spec sentence, and what was not taken. **`docs/adr/0062-external-datasource-runtime.md`** - A dated amendment blockquote under D3, the runtime decision: the slot is a second resolution source at the same point, after `credentialsRef` and the D7 borrow and before the factory, with the five conditions under which it is taken, the switch and its wiring at `init()`, the failure text, the collision claim, and a pointer to ADR-0015 §10 for the operator's caution. The normative bullets are not edited, as the ADR's other amendments did not edit theirs. ### The changeset `.changeset/28209-skills-code-datasource-env-credential.md`: `@objectstack/skills` `patch`, carrying both declaration lines. `skills/**` is copied into that package's `dist/skills` by its build and ships in its `files[]`, so this diff publishes; see Acceptance notes for the conflict with the order. ## Tests No code changes; the gates below are the tests. The published surface was built: `pnpm --filter @objectstack/skills build` under the verify lock → `sync-catalog self-test: 9 cases pass`, `95 file(s) across 11 skill(s) copied from skills/** into dist/skills and read back byte-identical`, `VERDICT command-exit 0`. ## Acceptance notes - **The order ruled `skip-changeset`; the repo's rule says otherwise, and this PR follows the repo.** AGENTS.md's Post-Task Checklist: `skip-changeset` "is for a diff that publishes nothing from any released package". `scripts/check-empty-changeset.mjs` (its failure text and its GREEN 3 battery) states that a `skills/**` change ships in `@objectstack/skills` and "owes a changeset naming that package, never the `skip-changeset` label", and the precedent changesets for skills-only PRs (for example `.changeset/26475-skills-teach-queues-assignment-rules.md`) are `@objectstack/skills: patch`. `.claude/agents/os-dev.md` says that where an order and that file conflict, the file wins and the conflict is named, so: a patch changeset, no `skip-changeset`, and zero label writes at `pr_create` (the order named no other label). The seat relabels if the maintainer rules otherwise. - **Security.** No credential value, connection string or example password anywhere in the diff or this body; variable names only. The trust note is stated as an operator's caution in each text, with no recipe. - **Out of scope, reported, not edited:** `skills/objectstack-data/evals/hooks-security-seeds-datasources.json`, case id 3, still answers "A `defineDatasource` entry (`credentialsRef`, never inline credentials)" for a code datasource exposing a Postgres table — the same stale teaching this card corrects, in a fixture the card and the claim's file surface do not name. Its assertions do not require `credentialsRef`, so it stays green. Reported in the os-dev-report for the seat to file or fold in. - `content/docs/data-modeling/external-datasources.mdx` §4 (PR #28208) already teaches the full route; the skills text is deliberately the short form and names no `#NNNN`. - `pnpm lint` scoped to touched packages: the diff touches no package source; `@objectstack/skills` has no `lint` script and eslint reads no `.md`, so there is nothing to scope it to. Recorded, not run. ## Verification All on head `17988ade` (base `origin/main` `5da3a8ce`; no merge, `main` did not move under the branch). - `node scripts/pm/check-governed-merges.mjs --test skills/objectstack-data/rules/datasources.md docs/adr/0015-external-datasource-federation.md docs/adr/0062-external-datasource-runtime.md` → exit 3, `governed-surface predicate: 3 of 3 path(s) hit the register (13 surfaces govern objectstack). ⛔ GOVERNED — a human merge is the review record for this PR … landing tier: H(人合)`: `docs/adr/** ×2`, `skills/** ×1`. The PR stays draft; no seat readies, queues or arms it. - `node scripts/check-skills-token-ratchet.mjs` → exit 0: `skills/objectstack-data/rules/datasources.md is 910 tokens (ceiling 911; headroom 1)`; `56 authored bundle file(s) within their ceilings`; bundle total 219807 → 219808. - `pnpm check:pm-skill-id-lint` → exit 0: `105 file(s) clean (pattern /#[0-9]{3,}/g)`. `pnpm check:doc-authoring` → exit 0: `70 published skill files clean — no internal issue-id references`. - `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` on this diff: 49 families. Every one was run with its exit code captured before any pipe (`timeout 900 bash -c CMD; EXIT=$?`), and `--ran` reconciles to `49 derived famil(ies) accounted for — 47 run, 2 NOT-MEASURED (2 DERIVED from a recorded exit 3)`. Three first exited 3 (PREREQUISITE NOT MET, nothing measured). `pnpm --filter @objectstack/lint run check:doc-formula-expressions` wanted `@objectstack/formula` and then `@objectstack/lint` built: after `pnpm --filter '@objectstack/formula...' build` and `pnpm --filter '@objectstack/lint...' build` under the verify lock (VERDICT command-exit 0, 88s and 81s) it exits 0 — `9 @example(s) judged clean across 2321 packages/spec/src files`, `15 predicate(s) … judged clean`. The two left to CI's full build: `pnpm check:dual-build-cjs-loads` (needs `pnpm build`) and `pnpm check:lean-entry-closure` (needs the built entry points, `packages/objectql/dist/core.mjs` among them); neither reads a file in this diff. Among the 46 green: `check:adr-anchors`, `check:adr-index`, `check:adr-symbol-anchors`, `check-adr-links` (962 relative links resolve), `check-adr-0087-registration`, `check-changeset-no-major`, `check-empty-changeset` (1 declaring changeset added, none from the merge base modified), `check:pm-governed-merges`, `check:pm-changeset-deadline-census`, `check:pm-prior-rulings`, `check:published-files`, `check:skill-compatibility`, `check:skill-frame-sync`, `check:skill-identifier-liveness`, `check:dts-closure`, `check:nul-bytes` (23088 text files, no raw control bytes). The three diff-reading gates that ran before the commit (`check-adr-0087-registration --base`, `check-adr-links`, `check-changeset-no-major --base`) were re-run on the committed head: all exit 0. - `pnpm --filter @objectstack/skills build` under `scripts/pm/os-verify-lock.sh` → `sync-catalog self-test: 9 cases pass`; `95 file(s) across 11 skill(s) copied from skills/** into dist/skills and read back byte-identical`; `VERDICT command-exit 0 · held the lock 2s`. - `pnpm lint` scoped: nothing to scope to (no package source in the diff; `@objectstack/skills` has no `lint` script). - Ablation: none — no tested behaviour changes (docs and skills only). - Dogfood battery: n/a — no `!` in the title and no `(narrowing)` arm. ## Human Minimum - [x] 1. Schemas start as Zod; `packages/spec` holds no runtime logic. (`packages/spec` untouched.) - [x] 2. Every name I added uses the casing for its kind. (No new names; the variable names are the shipped ones.) - [x] 3. No formatter ran; `pnpm lint` passes. (No package source touched; see Acceptance notes.) - [x] 4. One issue, one branch off an up-to-date `main`, one pull request. - [x] 5. A change that publishes carries a changeset, and both declaration lines are filled in here and in the changeset. - [x] 6. `packages/spec` touched: n/a. - [x] 7. No hand edits to `content/docs/references/`, `packages/*/CHANGELOG.md` or `content/docs/releases/`. - [x] 8. Title, body and comments are in English (the 维护者速读 block is the required Chinese summary). - [x] The *Before Submitting* list in CONTRIBUTING.md is done. --- _Generated by [Claude Code](https://claude.ai/code/session_01FEkQ5phX4SAaFQKww3TfKb)_ Co-authored-by: Claude <noreply@anthropic.com>
1 parent 7e3822c commit 22663ca

4 files changed

Lines changed: 96 additions & 17 deletions

File tree

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
---
2+
'@objectstack/skills': patch
3+
---
4+
5+
docs(skills): objectstack-data teaches the code-defined datasource credential route (`OS_DATASOURCE_<NAME>_PASSWORD`, behind the host switch)
6+
7+
Clause-②: no
8+
Metadata-protocol: none
9+
10+
- **Before.** `rules/datasources.md` taught `credentialsRef` and `connector` as the only credential channels. A datasource defined in code has no `sys_secret:` handle to point at (the Setup form refuses a code-origin datasource, and nothing else mints one), so an agent following the rule wrote a reference the connect could never resolve.
11+
- **Now.** The credential section says a code-declared datasource reads its password at connect from `OS_DATASOURCE_<NAME>_PASSWORD` (the name upper-cased) while the host switch `OS_DATASOURCE_ENV_CREDENTIALS` is `on`; that `os serve`, `os start` and `os dev` default it on, a library composition leaves it off, and the switch takes `on` or `off` only; that a declared `credentialsRef` or `connector` still wins and never falls back to the variable; and the operator's caution that an installed package may declare a datasource of any free name and so read that variable.
12+
- The file stays under its token ceiling: the new text is paid for by deleting restatements in the same file (the field-reference pointer the references index already carries, the `writable` and `columnMap` restatements of their own sections, the `datasource` field default the SKILL.md field table carries, and two detail clauses). No ceiling moves, no frontmatter, schema or behaviour changes.

‎docs/adr/0015-external-datasource-federation.md‎

Lines changed: 67 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,11 +4,13 @@
44
**Deciders**: ObjectStack Protocol Architects
55
**Builds on**: [ADR-0005](./0005-metadata-customization-overlay.md) (one Zod source of truth per metadata type)
66
**Consumers**: `@objectstack/spec`, `@objectstack/runtime`, `@objectstack/objectql`, `@objectstack/plugins/driver-sql`, `@objectstack/services/service-ai`, `@objectstack/cli`, `../objectui`
7-
**Amended by**: [ADR-0143](./0143-outbound-connector-auth-is-open-tier.md) (2026-10-27), attribution only — see the note below. D7 (2026-10-29, ruling 6486136301 on #27298) — an API-backed datasource may borrow a declared connector instance's credential through `external.connector`; see the D7 note below. #27688 (2026-10-30), example only — §8's `credentialsRef` is a `sys_secret:` handle, the only form the shipped secret binder resolves; see the note under §8. #27917 (2026-10-30), dialect list only — §4.6 names the `odata` vocabulary the matrix gained with #27745.
7+
**Amended by**: [ADR-0143](./0143-outbound-connector-auth-is-open-tier.md) (2026-10-27), attribution only — see the note below. D7 (2026-10-29, ruling 6486136301 on #27298) — an API-backed datasource may borrow a declared connector instance's credential through `external.connector`; see the D7 note below. #27688 (2026-10-30), example only — §8's `credentialsRef` is a `sys_secret:` handle, the only form the shipped secret binder resolves; see the note under §8. #27917 (2026-10-30), dialect list only — §4.6 names the `odata` vocabulary the matrix gained with #27745. #28208 (2026-10-31, ruling 6525963988 on #27687) — a datasource defined in code reads its credential at connect from `OS_DATASOURCE_<NAME>_PASSWORD`, behind the host switch `OS_DATASOURCE_ENV_CREDENTIALS`; see the note under §10 and the correction in the D7 note.
88

99
> **Amendment note D7 (2026-10-29, ruling 6486136301, letter A) — a second credential channel.**
1010
> §10 says credentials reach a datasource only through `credentialsRef`. That stays true for a
11-
> database. An **API-backed** datasource (a plugin-contributed driver over a vendor API, such as
11+
> database *(amended 2026-10-31, #28208: a database datasource defined in code has a third route,
12+
> the environment variable derived from its name — see the note under §10)*. An **API-backed**
13+
> datasource (a plugin-contributed driver over a vendor API, such as
1214
> OData) may instead name a declared connector instance in `external.connector` and borrow that
1315
> instance's credential — whichever kind its `auth` declares (`ConnectorInstanceAuthSchema`:
1416
> `bearer`, `api-key` and `basic` today; ADR-0143 D2 adds the grant-bearing `oauth2`,
@@ -766,7 +768,9 @@ managed Postgres for low-latency joins.
766768
component that resolves the actual connection string. *(Amended by
767769
D7, see the note at the top: an API-backed datasource may instead
768770
borrow a declared connector instance's credential through
769-
`external.connector`.)*
771+
`external.connector`.)* *(Amended 2026-10-31, #28208, see the note below: a datasource
772+
defined in code, which has no handle to point at, reads its credential at connect from the
773+
environment variable derived from its name.)*
770774
- **Connection pools** default conservatively for external mode
771775
(`max: 5`) to protect the remote system; `pool` may be overridden.
772776
- **Timeouts** are enforced at the driver. AI tool calls additionally
@@ -787,6 +791,66 @@ managed Postgres for low-latency joins.
787791
otherwise federated objects fall back to poll-based change feeds.
788792
This is out of scope for v1.
789793

794+
> **Amendment note (2026-10-31, #28208 — ruling 6525963988 on #27687, option 5; the failure text and
795+
> the collision claim as PR #28325 left them) — a third credential route, for a datasource defined
796+
> in code.** A datasource declared in code (`defineDatasource`, a package's `*.datasource.ts`) has
797+
> no `sys_secret:` handle to point `credentialsRef` at: the Setup → Datasources form refuses a
798+
> code-origin datasource, and nothing else mints one. Its route is the runtime environment. At
799+
> connect, `DatasourceConnectionService` reads `OS_DATASOURCE_<NAME>_PASSWORD`, `<NAME>` being the
800+
> datasource name upper-cased with every character outside `[A-Z0-9]` replaced by `_` (`warehouse`
801+
> → `OS_DATASOURCE_WAREHOUSE_PASSWORD`;
802+
> `packages/services/service-datasource/src/datasource-env-credential.ts#datasourceEnvCredentialVar`),
803+
> and hands a non-empty value to the driver factory as the connection's `secret` — the password for
804+
> `postgres`, `mysql` and `mongodb`, the `authToken` for `turso`. The value is never logged, stored
805+
> or answered; every message names the variable only. The guards, each a ruled condition:
806+
>
807+
> - **A host switch, off unless the host opts in.** The slot is read only while
808+
> `OS_DATASOURCE_ENV_CREDENTIALS` reads `on` (`resolveDatasourceEnvCredentialsSwitch`, same file:
809+
> unset or empty is off; `on` / `off` in any letter case; any other value throws, and the
810+
> datasource-admin plugin reads it at `init()`, so the boot is refused). `os serve` stamps `on`
811+
> into its own environment when the operator left it unset or empty, just before it builds the
812+
> admin plugin (`packages/cli/src/commands/serve.ts`, the `adminMod` block, through
813+
> `packages/cli/src/utils/datasource-env-credentials-default.ts`); `os start` and `os dev` run
814+
> `serve`, so they get it too. Every other composition — a host that builds its own kernel, a cloud
815+
> runtime — never runs that line and stays off.
816+
> - **Code origin only, by provenance.** The slot serves a name in the host's code-datasource set
817+
> (the `code-datasource-names` service, filled by `@objectstack/runtime` from the boot artifact,
818+
> from `default`, and from a package installed into the running kernel) on the boot's
819+
> `declared-auto` connect — never a record's `origin`. A datasource created through Setup or the
820+
> API under the same name connects through `registerPool` and never reads it; `default` keeps
821+
> `OS_DATABASE_URL` (an `asDefault` connect never reads the slot).
822+
> - **A declared channel wins and stays fail-closed.** The slot sits in `connect()` after the
823+
> `credentialsRef` resolution (ADR-0062 D3) and the D7 connector borrow, and before the driver
824+
> factory; it is consulted only when neither is declared, so a declared reference that does not
825+
> resolve never falls back to the environment.
826+
> - **An unset variable changes nothing about the connect.** The driver is built without a secret;
827+
> when the database refuses, the verdict is the driver's own (`failed-degraded`) and its reason is
828+
> the driver's error followed by `(note: datasource 'NAME' is defined in code and reads its
829+
> credential from OS_DATASOURCE_NAME_PASSWORD at connect, which is not set on this host)`. A bound
830+
> datasource still fails the boot (ADR-0062 D5), and the thrown message carries that note.
831+
> - **Two live datasources that derive one variable are refused.** Valid names
832+
> (`^[a-z_][a-z0-9_]*$`) cannot collide, so the guard covers names a host hands `connect()`
833+
> directly: the second connect is refused `failed-credentials`, naming both datasources and the
834+
> variable, and its variable is never read. A connect that failed releases its claim, so it never
835+
> counts as the owner.
836+
> - **A plugin driver that resolves its credential per request** (the OData driver resolves
837+
> `credentialsRef` that way) does not read the slot: the slot feeds only the `secret` a driver
838+
> receives when its connection is built.
839+
>
840+
> **The trust consequence, stated for operators.** The derived name is the only key, so any code
841+
> datasource the host registers under that name reads it. Under `os serve` the code set includes
842+
> every package installed into the environment, so an installed package can declare a datasource of
843+
> any free name and so read the matching `OS_DATASOURCE_<NAME>_PASSWORD`. Install only packages you
844+
> trust with the credentials in that process's environment. On a host where several tenants'
845+
> artifacts share one process, a process-wide variable would be readable by every tenant artifact
846+
> that declares the name — which is why the switch is off outside the CLI host. The spec's five
847+
> credential refusals, the refused-key description and its two `password` guidance entries end
848+
> with one shared sentence
849+
> (`packages/spec/src/data/driver/common.zod.ts#CODE_DEFINED_CREDENTIAL_ROUTE`) that names this
850+
> route. Not taken: a variable named in metadata (authored metadata could then point the runtime at
851+
> an arbitrary host variable); an environment fallback behind a declared `credentialsRef`; a
852+
> per-driver table of authentication error codes to decide when the note applies.
853+
790854
---
791855

792856
## 11. Rollout Plan

‎docs/adr/0062-external-datasource-runtime.md‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -91,6 +91,10 @@ Auto-connect must not change apps that today declare datasources that are *decor
9191

9292
`DatasourceConnectionService` resolves `external.credentialsRef` (and any `secret` config fields) through the host-provided `SecretBinder` over `ICryptoProvider` **before** building the driver. Open-core default is `InMemoryCryptoProvider`; a datasource that needs a secret the host cannot decrypt **fails closed** (clear boot error, datasource left unconnected — not a silent skip). This reuses the exact mechanism the runtime-admin "Add Datasource" wizard already uses, so code- and runtime-origin secrets converge.
9393

94+
> **Amendment (2026-10-31, #28208 — ruling 6525963988 on #27687, option 5; the failure text and the collision claim as PR #28325 left them) — a second resolution source at the same point, for a datasource defined in code.** `credentialsRef` and the ADR-0015 D7 connector borrow keep the point above and keep failing closed. A datasource the host registers from code can carry neither: the Setup → Datasources form refuses a code-origin datasource, so no `sys_secret:` handle is ever minted for one. For it, `DatasourceConnectionService.connect()` has a third step at that same point — after the `credentialsRef` resolution and the connector borrow, before the driver factory — taken only when neither channel is declared, the connect is not `asDefault`, the trigger is `declared-auto`, the name is in the host's code-datasource set (the `code-datasource-names` service: provenance, never a record's `origin`), and the host opted in. The step reads the environment variable `OS_DATASOURCE_<NAME>_PASSWORD` (`datasourceEnvCredentialVar`, `packages/services/service-datasource/src/datasource-env-credential.ts`: the name upper-cased, each character outside `[A-Z0-9]` replaced by `_`) and hands a non-empty value to the factory as the connection's `secret`.
95+
>
96+
> The opt-in is the host switch `OS_DATASOURCE_ENV_CREDENTIALS`. The datasource-admin plugin wires the slot into the service only when the switch reads `on` (unset or empty is off; any other value refuses the boot at `init()`), and only the CLI host stamps `on` as its default — `os serve`, which `os start` and `os dev` run (`packages/cli/src/utils/datasource-env-credentials-default.ts`); a library composition never sees the slot. An unset variable leaves the build as it was: a driver refusal is `failed-degraded` with the driver's own error, followed by `(note: datasource 'NAME' is defined in code and reads its credential from OS_DATASOURCE_NAME_PASSWORD at connect, which is not set on this host)`, and a declared reference that fails never falls through to the variable. Two live connects whose names derive one variable are refused at the second (`failed-credentials`, naming both datasources and the variable; a connect that failed releases its claim). The value reaches nothing but the factory, and no message carries it. **Default behavior** below gains one line: a datasource defined in code connects with the credential its derived variable holds when the host opted in. Who may then read that variable — any installed package declaring the name — is recorded as an operator's caution in ADR-0015 §10's amendment note. Not taken: a variable named in metadata, and an environment fallback behind a declared reference.
97+
9498
### D4 — Visibility converges on the metadata registry (shipped, ratified here)
9599

96100
Code-defined datasources surface in `GET /api/v1/datasources`, `GET /api/v1/meta/datasource` and **Setup → Datasources** via the single in-memory metadata registry, stamped `origin: 'code'` (read-only in the admin UI). Shipped in #2157 (the in-memory metadata fallback was missing `registerInMemory`, silently skipping registration on the host-config boot path). This ADR ratifies that the metadata registry is the single source for datasource listing across all boot paths; the admin `listDatasources` and `protocol.getMetaItems` both read it.

‎skills/objectstack-data/rules/datasources.md‎

Lines changed: 13 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,12 @@
11
# Datasources & Federation
22

33
A **datasource** (`defineDatasource`, a `*.datasource.ts` file) is a
4-
connection to a data store. Objects route to one via their `datasource` field
5-
(default: `'default'`). `default` is host-reserved: the host connects it from
4+
connection to a data store. Objects route to one via their `datasource` field.
5+
`default` is host-reserved: the host connects it from
66
`OS_DATABASE_URL`, and a stack that declares a datasource named `default` is
77
refused. Declare a datasource under its own name only to read/write a
88
**separate** or **external** store.
99

10-
Full field reference: `node_modules/@objectstack/spec/src/data/datasource.zod.ts`.
11-
1210
## `schemaMode` — who owns the schema
1311

1412
| Mode | Meaning |
@@ -29,22 +27,18 @@ ObjectSchema.create({
2927
remoteName: 'customers', // remote TABLE (object name may differ)
3028
// remoteSchema: 'public', // optional schema/namespace (pg/mysql)
3129
// columnMap: { cust_region: 'region' }, // remoteColumn → localField
32-
// writable: true, // per-object write opt-in (see below)
3330
},
3431
fields: { id: { type: 'text' }, name: { type: 'text' }, region: { type: 'text' } },
3532
});
3633
```
3734

38-
### ✅ / ❌ Column mapping (ADR-0062 D7)
39-
40-
- ✅ Map remote columns with **`external.columnMap`**.
4135
- ❌ **`field.columnName` does not exist — on ANY object.** Authoring it is a
4236
parse error everywhere, not only on a federated object.
4337

4438
## Auto-connect (no `onEnable`)
4539

46-
A declared datasource is connected, and its federated objects registered,
47-
**automatically at boot** when it is **meaningfully addressed**:
40+
A declared datasource is connected **automatically at boot** when it is
41+
**meaningfully addressed**:
4842

4943
1. it is **external** (`schemaMode !== 'managed'`), **or**
5044
2. an object **explicitly** binds via `object.datasource === <name>`, **or**
@@ -60,9 +54,15 @@ object throws.
6054
> `ctx.drivers.register(driver)` in `onEnable` is only an escape hatch for a
6155
> runtime-built driver; idempotent with auto-connect.
6256
63-
## Credentials — one channel, fail-closed
57+
## Credentials — fail-closed
6458

65-
Never inline a password. `external` names **one** credential channel:
59+
Never inline a password. A code-declared datasource reads one at connect from
60+
`OS_DATASOURCE_<NAME>_PASSWORD` (`NAME` = the name upper-cased) while the host
61+
switch `OS_DATASOURCE_ENV_CREDENTIALS` is `on` (`os serve`/`start`/`dev` default
62+
it on; a library composition is off; `on`/`off` only). Operator caution: an
63+
installed package may declare a datasource of any free name and so read that
64+
variable. `external` may instead name **one** channel, which wins and never
65+
falls back to the variable:
6666

6767
- **`credentialsRef`** — a secret-store handle, resolved at connect, before the
6868
driver is built.
@@ -71,7 +71,7 @@ Never inline a password. `external` names **one** credential channel:
7171
with a `provider` and an `auth` other than `none`:
7272
`schemaMode: 'external', external: { connector: 'erp_api' }`. Refused beside
7373
`credentialsRef`, on a built-in driver, on a `managed` datasource, and naming
74-
an undeclared instance (at `defineStack`, else at connect).
74+
an undeclared instance.
7575

7676
No secret store, an unresolvable handle, a disabled instance or no automation
7777
service leaves the datasource **unconnected with a clear error**. A connector
@@ -81,4 +81,3 @@ credential not set yet answers `503` per request.
8181

8282
Federation is read-only by default. To write, **both** gates must be on:
8383
`datasource.external.allowWrites: true` **and** the object's `external.writable: true`.
84-
Otherwise every write to the object is rejected.

0 commit comments

Comments
 (0)