Repository navigation
fix(spec)!: credential-shaped datasource config values are refused at write and redacted on every read door for drivers with no shipped contract - #21877
Conversation
…ction Claude-Session: https://claude.ai/code/session_018zT8d8NpiQ1ExhuNd5TxY6 Co-Authored-By: Claude <noreply@anthropic.com>
…epted Claude-Session: https://claude.ai/code/session_018zT8d8NpiQ1ExhuNd5TxY6 Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018zT8d8NpiQ1ExhuNd5TxY6 Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018zT8d8NpiQ1ExhuNd5TxY6 Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018zT8d8NpiQ1ExhuNd5TxY6 Co-Authored-By: Claude <noreply@anthropic.com>
…edential exports Claude-Session: https://claude.ai/code/session_018zT8d8NpiQ1ExhuNd5TxY6 Co-Authored-By: Claude <noreply@anthropic.com>
…/0062 on the write door Claude-Session: https://claude.ai/code/session_018zT8d8NpiQ1ExhuNd5TxY6 Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018zT8d8NpiQ1ExhuNd5TxY6 Co-Authored-By: Claude <noreply@anthropic.com>
📓 Docs Drift CheckThis PR changes 3 package(s): 1 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:
What this run could not see
Coarse fallback — 139 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): Which tree this was computed onThis run read A worktree cut from an older # while this PR is open — GitHub drops the merge commit once it closes
git fetch origin 1f565b01adcc7544dc38bc3e5b13499ba2f34e52 && git checkout 1f565b01adcc7544dc38bc3e5b13499ba2f34e52
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin cab639671528ef6f3a201e8995794378a4a28bfe 5c405846a58ec2edb6299e9cf60c9a2b277596b5 && git checkout -B drift-repro cab639671528ef6f3a201e8995794378a4a28bfe && git merge --no-ff 5c405846a58ec2edb6299e9cf60c9a2b277596b5
node scripts/docs-audit/affected-docs.mjs --json cab639671528ef6f3a201e8995794378a4a28bfe
|
Contract reviewServed-tier: Inputs read: card #21840 body and all four comments (triage 5990389094, claim 5990748393, os-dev-report 5995346311, claim correction 5995372304); PR #21877 body, its 11-file list, and the net diff ① Derived judgments
② Semver levelClause-②: yes (narrowing)
③ Boundary flags
Implemented-by: VERDICT: PASS |
Claude-Session: https://claude.ai/code/session_018zT8d8NpiQ1ExhuNd5TxY6 Co-Authored-By: Claude <noreply@anthropic.com>
…airs, more spellings and connection-string forms Claude-Session: https://claude.ai/code/session_018zT8d8NpiQ1ExhuNd5TxY6 Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018zT8d8NpiQ1ExhuNd5TxY6 Co-Authored-By: Claude <noreply@anthropic.com>
…dentials holding ; = : @, libpq ; values, header tuples; whole-word one-word keys Claude-Session: https://claude.ai/code/session_018zT8d8NpiQ1ExhuNd5TxY6 Co-authored-by: Claude <noreply@anthropic.com>
…tasource-secret-keys
…y identity Round-3 review fixes for the contractless-driver credential walk: - the key judgment is linear (the capital-run split looks ahead instead of capturing the run) and bounded: a key, segment key, parameter or header name longer than 256 characters after NFKC, or holding a non-ASCII character, is judged credential-shaped unread; - libpq pairs are found leniently, one pass; - header shapes: Name: value lines, raw-headers lists, a tuple under a header-ish key, tuples longer than two, every pair label; - key names: ssl/tls key material, privkey, the data/content/hex/string/ str/raw/hash qualifiers, basicauth; - embedded shapes: token-shaped URL usernames, sig and X-Amz-Signature parameters, JSON-encoded strings, form-encoded strings, URL fragments; - a descriptor key exempts leaves only; bytes are one value; Map and Set are judged whole; a bare key needs a secret-looking value or a credential-shaped or header-ish holder; - restoreRedactedConfig and the /meta carry-forward resolve every array hop by identity (unchanged array, else a unique served projection) and drop a withheld value whose element changed, is gone or is ambiguous; the /meta path now follows id-less elements and nested arrays. Claude-Session: https://claude.ai/code/session_018zT8d8NpiQ1ExhuNd5TxY6 Co-authored-by: Claude <noreply@anthropic.com>
…e read path still withholds it restoreRedactedConfig re-redacts the merged config and keeps only the grafts whose landing path is still withheld, repeating until nothing more drops (the /meta carry-forward's loop). An untouched Save skips the second walk: the merged config is the stored one. Claude-Session: https://claude.ai/code/session_018zT8d8NpiQ1ExhuNd5TxY6 Co-authored-by: Claude <noreply@anthropic.com>
… keys by words; prose and SQL stay accepted - PEM private-key armour is secret material in any string (and in bytes); a bare `key` under an ssl / tls / cert* holder is key material; `pfx`, `pkcs12` and `p12` are credential words. - A non-ASCII key is credential-shaped only when its non-ASCII text sits inside or next to a credential word (confusable letters, format characters, stand-ins, adjacency, credential words of other scripts); otherwise a non-ASCII run is a word of its own. - A single-line `Name: value` string is read as a header line only under a header-ish key; SQL bind placeholders are not libpq or segment values; opaque URI schemes and times of day are not scheme-less userinfo. - A string or bytes longer than 64 KiB is judged credential material unread. - A bare `key` also counts hex and digit-free base64 key material. Claude-Session: https://claude.ai/code/session_018zT8d8NpiQ1ExhuNd5TxY6 Co-authored-by: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018zT8d8NpiQ1ExhuNd5TxY6 Co-authored-by: Claude <noreply@anthropic.com>
…und-4 exports Claude-Session: https://claude.ai/code/session_018zT8d8NpiQ1ExhuNd5TxY6 Co-authored-by: Claude <noreply@anthropic.com>
… serves holds no finding A bare `key` under a header-ish holder is the header named `key` only in a header map, not in a list element (a pair's label). The contractless read projection is judged again until it holds no finding, so an untouched Save of what was served passes the write door. The dogfood edit-door case and the service tests use a plain-keyed `key:` pair. Claude-Session: https://claude.ai/code/session_018zT8d8NpiQ1ExhuNd5TxY6 Co-authored-by: Claude <noreply@anthropic.com>
|
Closing without merging, per the maintainer's ruling on #21921 (comment 6007092527 and the correction after it). The platform does not guess which values in a plugin driver's Generated by Claude Code |
…ctory (objectstack-ai#21919) Fixes objectstack-ai#21914 Clause-②: no ## What changes Every dogfood test file now runs in its own temporary working directory. The suite fails when any file leaves `.objectstack/data` in `packages/qa/dogfood`. - **`test/per-file-cwd.setup.ts`** (new) is a `setupFiles` entry, wired explicitly in BOTH projects of `vitest.config.ts`, because inline projects inherit nothing from the root block. `shared-showcase` keeps `isolate: false`; the module still runs once per file there. - At module top level, before the test file's own imports, it creates a directory under the run's temporary root and `chdir`s into it. - In `afterAll` it restores the previous cwd. That `afterAll` is also **the guard**: it THROWS when `packages/qa/dogfood/.objectstack/data` exists. The message names the directory, its entries and the remedy. It also says the named file may be a concurrent one on another worker rather than the writer, and whether the directory was already present when the file started. - **`test/per-file-cwd.global-setup.ts`** (new) is a root-level `globalSetup`. It runs once per run, covering both projects and each `OS_TEST_SHARD` slice (measured). - At the START it clears a stale `packages/qa/dogfood/.objectstack`, so a developer's earlier run never reds the suite. - It creates one temporary root for the run and hands it to the workers with `provide` / `inject`. - At the END it removes that root, which is **where the per-file directories are removed**. The removal is run-level, not per file, because the memoized `shared-showcase` boot keeps its SQLite handles open in the directory of the file that booted it. - The teardown judges nothing (see Evidence: a throwing teardown is a false green). - **`vitest.config.ts`** wires the two modules. A header section explains why there are two halves and why the guard is not in the teardown. - **`test/enterprise-organizations.ts`**: the module-level `probeOrganizations()` now passes this package's root as `hostRoot`, resolved from the module's location (`new URL('..', import.meta.url)`), not the cwd. This was measured to be needed; see Evidence. No per-file edits. The five files the card names, and the other 87 measured writers, are covered by the module with no change of their own. Test isolation only: `@objectstack/dogfood` is `private: true`, so no published package moves and there is no changeset (`skip-changeset`). ## The invariant for every dogfood author - **Each test file runs in its own temporary cwd.** Anything it writes relative to the cwd is its own, no other file sees it, and it is removed at the end of the run. A file needs no `mkdtemp` / `chdir` of its own. - **A package-relative read must resolve from the module's location** (`new URL('..', import.meta.url)`, `import.meta.dirname`), never from `process.cwd()`. The cwd is a temporary directory. - **A file that writes into `packages/qa/dogfood/.objectstack/data` fails the run.** That happens through an absolute path built from the package root, or through a `process.chdir()` back to the package directory before a boot. The fix is to write relative to the file's own cwd. - Files that already `chdir` into a temp dir of their own still work, because they restore to the per-file directory. Their own `chdir` is now redundant and harmless. ## Why (measured) A per-file probe over the whole suite measured 92 test files leaving `.objectstack/data/showcase_external.db` in the package directory, not the five the card names: - 7 leave the populated federated fixture (24576 B, 2 tables): the card's five, plus `showcase-demo-personas-loginable` and `showcase-demo-personas-membership`, which pass `onEnable` in the bundle. - 85 leave an empty SQLite file (4096 B, 0 tables). The showcase's declared external datasource has a cwd-relative filename, and its auto-connect creates the file on every showcase boot, `onEnable` or not. A later boot on the same runner found or missed the federated tables depending on which files ran before it, and that ordering is how PR objectstack-ai#21905 went red only on dogfood shard 3/3. The seat chose this route (one module) and this guard (comment `6004950414` on objectstack-ai#21914), on the dev's measurement (comment `6004909676`). ## Evidence All runs are at head `967ce88a`, under the shared verify lock, from a clean package directory. - **Whole suite**: `pnpm --filter @objectstack/dogfood test` gave `Test Files 205 passed | 1 skipped (206)` and `Tests 1591 passed | 9 skipped (1600)`. Afterwards `packages/qa/dogfood/.objectstack` does not exist, and no `os-dogfood-run-*` root is left in the temp dir. - **CI's three-shard split**: CI's dogfood leg exports `OS_TEST_SHARD=k/3` and `vitest.config.ts` turns it into vitest's `shard`. Here each shard ran as `OS_TEST_SHARD=k/3 pnpm --filter @objectstack/dogfood test`: the same vitest selection, without turbo, so no cached replay. Each exited 0 and left no `.objectstack`: | shard | Test Files | Tests | |---|---|---| | 1/3 | 69 passed (69) | 507 passed (507) | | 2/3 | 69 passed (69) | 461 passed, 1 skipped (462) | | 3/3 | 67 passed, 1 skipped (68) | 623 passed, 8 skipped (631) | The three add up to the whole run: 206 files, 1600 tests. - **Ablation (H4)** through `scripts/ablation-replace.mjs`, wrap mode. The central `process.chdir(...)` was replaced by the bare `mkdtempSync(...)`: anchor count 1 to 0, blob `0991eb9c` to `ee5a65be`. - With the chdir dropped, `showcase-external-autoconnect` and `showcase-search` ran: `Test Files 2 failed (2)`, `Tests 8 passed (8)`, exit 1. Each failed in the guard: `.../packages/qa/dogfood/.objectstack/data exists after this test file ran. Entries: showcase_external.db` (plus `-shm` / `-wal` for the shared-showcase file). - Restore was proven by the tool: blob after restore equals HEAD (`0991eb9c`), and `git diff HEAD` is empty. - The same two files then gave `2 passed`, exit 0, and left nothing. - No build step is involved: vitest loads the mutated module from source. - **Stale directory**: `.objectstack/data/x.db` was planted, then 9 files were run. Result: `Test Files 9 passed (9)`, exit 0, nothing left (the globalSetup cleared it). - **Census**: those 9 files are the 7 populated-fixture writers plus `showcase-search` and `showcase-permission-zoo`, both `shared-showcase` files. - **`hostRoot` line, measured both ways**, running `rls-multitenant`, `org-create-default-team` and `enterprise-organizations.test`: - Without the line (commit `4d07dc29`), the skip text read `not resolvable from /tmp/os-dogfood-run-.../file-...` and told the reader to declare the package in that temp directory's `package.json`. - With it (`967ce88a`), the text names `packages/qa/dogfood/`. - The verdict is the same both ways (skipped), because no framework package declares `@objectstack/organizations`. - **Guard placement**: a throwing `globalSetup` teardown was measured on vitest 4.1.11 to print `error during close` and still exit 0, a false green. So the guard is the per-file `afterAll`. (A teardown that sets `process.exitCode = 1` does exit 1, but the summary still reads all-passed.) - **Typecheck and lint**: `pnpm --filter @objectstack/dogfood typecheck` is green, and `tsc --listFiles` includes both new modules and `enterprise-organizations.ts`. `pnpm lint` exits 0. - **Gates**: 130 commands at `967ce88a`, the dispatch list plus `pnpm check:dispatcher-error-vocabulary` from `dispatch-gates --commands`. `dispatch-gates --ran`: `48 derived famil(ies) accounted for — 48 run, 0 NOT-MEASURED`. - `check:dual-build-cjs-loads` and `check:published-readme-exports` first exited 3 (dist prerequisite: 7 packages unbuilt). After building those 7, both exit 0. - The three PR-context scripts (`check-closing-target-claim`, `check-partof-closing-keyword`, `check-single-claim-paths`) are re-run with this PR's context; the results are in the report on the card. ## Open PRs that add dogfood files | PR | new file | boots the showcase | own `chdir` | under this PR | |---|---|---|---|---| | objectstack-ai#21864 | `showcase-public-form-withdrawal-layers.dogfood.test.ts` | yes | no | Covered with no author action. Without this PR it would leave `.objectstack/data` in the package directory. | | objectstack-ai#21917 | `organization-delete-federated-fixture.dogfood.test.ts` | yes, with `onEnable` | yes | Unaffected; its own `chdir` is redundant. | | objectstack-ai#21906 | `external-import-code-datasource-namespace.dogfood.test.ts` (also edits three `external-*` files) | yes, with `onEnable` | yes | Unaffected. None of its files is edited here. | | objectstack-ai#21877 | `datasource-contractless-credentials.dogfood.test.ts` | yes | yes | Unaffected. | | objectstack-ai#21897 | `flow-node-config-values-at-registration.dogfood.test.ts` | no (fixture stack) | no | Runs in its own temp cwd; it reads nothing relative to the cwd. | None of these files reads a package-relative path through `process.cwd()`. Only their own `prevCwd` captures do. ## Acceptance notes - **Observation, not filed.** The showcase's external datasource is declared read-only (`schemaMode: 'external'`, `allowWrites: false`). Its auto-connect CREATES a missing `.objectstack/data/showcase_external.db`, plus `-wal` / `-shm` (measured on 85 harness boots). - The declaration's own comment in `showcase-external.datasource.ts` says that if the fixture file cannot be opened, "the boot stops with that as the reason rather than serving a showcase whose federation pages are quietly dead". - It was measured only through the verify harness's `bootStack`, never at a public door (`os start` / `os dev`), so it stays here. - **Latent, unreachable today.** `bootStack(..., { multiTenant: true })` also defaults its `hostRoot` to the cwd: `rls-multitenant.dogfood.test.ts:79`, and `attachments-permission-matrix.dogfood.test.ts:766` through `bootFixture`. Both are gated on `organizationsAvailable`, which is false in this repository because no framework package may declare `@objectstack/organizations` (ADR-0132). A run that declares it in this package would need those boots to pass the package root too. Carrier: whoever declares it. - The own `chdir` in `external-validate-sees-runtime-save`, `external-import-destructive-remedy`, PR objectstack-ai#21906's file and PR objectstack-ai#21917's file is now redundant. It is left untouched and can be removed once objectstack-ai#21906 lands. Carrier: the `domain:cli` seat. - Attribution limit: under parallel workers, the guard can name a file that ran at the same time as the writer. The message says so, and says whether the directory was already present when the named file started. --- _Generated by [Claude Code](https://claude.ai/code/session_01RWZbGvPFcRKvUqASZtunCU)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
…asses the explicit system opt-in instead of no principal (objectstack-ai#21940) Fixes objectstack-ai#21913 Clause-②: yes (widening) This is a slice of objectstack-ai#21908: the services-lane producers. objectstack-ai#21908 stays open, because it builds the deny itself, last. ## What changes Every engine call in the card's named functions now passes the explicit system opt-in that exists today: `{ isSystem: true }` on the call's context. These calls used to reach the data engine with no context at all, so they had no principal and no opt-in. They got past the security middleware only through its principal-less hand-off (ADR-0096 E1), which objectstack-ai#21908 retires. This PR adds no new elevation API, changes nothing any door authorizes, and does not build the deny. | Row | Package | Function | Engine calls that now carry the opt-in | | :-- | :-- | :-- | :-- | | 7 | service-settings | `SettingsService.loadRows` | `find` on `sys_setting` | | 8 | service-settings | `SettingsService.upsertRow` | existence-probe `find` and `insert` on `sys_setting` (its `update` already had the opt-in) | | 8 | service-settings | `buildSettingAuditWriter` `write` | `insert` on `sys_setting_audit` | | 11 | service-datasource | `loadDatasourceRows`, `loadDatasourceRow` | `find` / `findOne` on `sys_metadata` | | 11 | service-datasource | `persistDatasourceRow`, `deleteDatasourceRow` | `findOne` + `insert` / `update` / `delete` on `sys_metadata` | | 11 | service-datasource | secret binder `bind` / `unbind` / `resolve` | `insert` / `delete` / `find` on `sys_secret` | | 12 | plugin-webhooks | `AutoEnqueuer.doRefresh` | `find` on `sys_webhook` | | 12 | plugin-webhooks | `createWebhookRedeliverGuard` | `findOne` on `sys_webhook` | | 13 | service-messaging | `SqlNotificationOutbox.claim` / `claimDigest` / `reapExpired` | candidate `find`, claiming `update`, read-back `find`; the reap `update` | | 13 | service-messaging | `SqlHttpOutbox.claim` / `reapExpired` | candidate `find`, claiming `update`, read-back `find`; the reap `update` | | 14 | service-messaging | `MessagingService.writeEvent` | `insert` on `sys_notification` | | 14 | service-messaging | inbox channel `send` + `writeDeliveredReceipt` | `insert` on `sys_inbox_message`, the recipient-locale `findOne` on `sys_user` (a helper only `send` calls), `insert` on `sys_notification_receipt` | | 14 | service-messaging | `PreferenceResolver.loadRows` | both `find`s on `sys_notification_preference` | | 14 | service-messaging | `RecipientResolver.resolveEmail` | `findOne` on `sys_user` | IDataEngine reads pass the opt-in in the trailing options argument, which is where the contract puts a read's context. Two package-local surfaces have a single options bag, and the opt-in goes there: `SettingsEngine`, and the `sys_secret` binder's engine slice. `SettingsEngine.find` and `.insert` and `SecretStoreEngineLike.delete` now declare the `context` they receive. No symbol is new on any package entry. The shared constants (`FAN_OUT_SYSTEM_CONTEXT`, `DISPATCHER_SYSTEM_CONTEXT`) live in package-internal modules. Rows 15 and 16 are not in this slice and wait for the maintainer. ## Measurement **Instrument (H2).** A local, uncommitted instrument sat at the security middleware. It recorded each principal-less, non-system context that reached the hand-off, with its stack. It recorded whether any of the six gates before the hand-off threw on such a call, and which of them matched the call's object and verb. It also recorded the outcome after `next()`: the result type, row count, key set, a hash of the non-volatile values, or the error code. In the AFTER leg it recorded the same outcome for each `isSystem` call whose stack ran through these four packages. Both legs covered the whole dogfood suite (206 files, 1590 tests passed, 9 skipped, identical in both legs) and a booted showcase dev composition. The boot covered seed-admin, a settings read plus two writes, a runtime datasource create / patch / read / delete, and admin and anonymous requests, then sat idle for 65 seconds so the dispatchers and the webhook refresh ticked. The instrument was then reverted, and the file's blob equals HEAD (`5b4ab28045af`). The plugin-security dist was rebuilt clean: `ablation-dist-preflight --absent` passes, and the marker had 3 hits in the instrumented dist. **Before and after, per function.** Columns: principal-less records BEFORE, principal-less records AFTER, and `isSystem` records AFTER. | Function | dogfood before / after / after-system | boot before / after / after-system | | :-- | --: | --: | | `SettingsService.loadRows` | 2090 / 0 / 2090 | 42 / 0 / 42 | | `SettingsService.upsertRow` (probe + insert) | 7 / 0 / 7 | 3 / 0 / 3 | | setting-audit `write` | 4 / 0 / 4 | 2 / 0 / 2 | | `loadDatasourceRows` | — | 1 / 0 / 1 | | `persistDatasourceRow` | — | 4 / 0 / 4 | | `deleteDatasourceRow` | — | 2 / 0 / 2 | | `AutoEnqueuer.doRefresh` | — | 3 / 0 / 3 | | `SqlNotificationOutbox.claim` | 8 / 0 / 8 | 56 / 0 / 56 | | `SqlNotificationOutbox.claimDigest` | 8 / 0 / 8 | 56 / 0 / 56 | | `SqlNotificationOutbox.reapExpired` | 1 / 0 / 1 | 7 / 0 / 7 | | `SqlHttpOutbox.claim` | 8 / 0 / 8 | 56 / 0 / 56 | | `SqlHttpOutbox.reapExpired` | 1 / 0 / 1 | 7 / 0 / 7 | | `MessagingService.writeEvent` | 8 / 0 / 8 | — | | inbox `send` (row insert) | 8 / 0 / 8 | — | | `writeDeliveredReceipt` | 8 / 0 / 8 | — | | `PreferenceResolver.loadRows` | 16 / 0 / 16 | — | | `RecipientResolver.resolveEmail` | 1 / 0 / 1 | — | Principal-less totals moved 35245 → 33077 in dogfood (Δ 2168) and 422 → 183 at boot (Δ 239). Each delta is exactly the sum of the rows above. No principal-less record attributed to any moved function remains. The hand-off still sees row 15 and every other lane's producers. No run reached these, so each is held by its unit pin instead: `loadDatasourceRow`, the secret binder (this repo wires it into no composition), the redeliver guard, the inbox recipient-locale read (template path), and the claim path's `update` and read-back (no pending rows in any run). **Gates before the hand-off (Zone 1).** Across 35245 dogfood and 422 boot principal-less records, the "gate threw" record fired 0 times. The package-managed, system-row, curated-capability and audience-anchor gates never matched an object or verb these producers touch. Neither did the delegated-administration gate. The engine-owned guard matched the bucket on the writes to engine-owned objects. On a context with no user id, its own `isUserContextWrite` predicate returns before it can refuse. **No producer is held back.** **What each call answers is unchanged.** Per function, call counts per object and verb are equal before and after. So are the outcome shapes (result type, row count, key set). There were 0 errors in either leg. Content hashes are equal for 11 of 14 functions in dogfood and 7 of 9 at boot. The rest differ only on values that change every run: the receipt's `at` timestamp (all 8), and inbox and notification payloads that carry a per-run record id or date (2 of 8 and 3 of 8, from the approval and sweep tests). At boot, the probe's own per-phase file path sits in the stored datasource record. The plugin-audit rows these writes produce (`sys_audit_log`, `sys_activity`) are written in equal numbers before and after. **H6, `loadRows`.** The call count is the same (2090 + 42), and the returned settings have equal hashes on every call. The opt-in adds one frozen context object. The middleware now exits at its system short-circuit instead of running the six gates and the hand-off. No wall-clock figure is quoted, because the container is shared. **H7, reads on another principal's behalf.** What these reads return (a user id for an address, a locale, preference rows) is consumed inside the fan-out. `emit()` answers the notification id, counts and per-delivery outcomes. Its three in-repo callers (approvals, the flow notify node and comment mentions) relay counts and the id only. The opt-in changes none of this, because the principal-less read returned the same rows. **One engine branch keyed on the flag stops running on these writes.** It is row 23 of the `isSystem` census page: the dangling-reference check is skipped for an `isSystem` write. Before the move, it ran 10 times nested under these producers (`writeEvent` 2, inbox `send` 2, setting-audit `write` 6), on the `actor_id` lookups, and resolved every time. After the move it does not run. A local probe (real ObjectQL and SQLite, deleted after the run) showed what that means for an `actor_id` that names no user. With no context, today's path refuses with `VALIDATION_FAILED` ("Actor: no sys_user record has id …"). Under `isSystem` the row is written. A real user is written both ways. That `actor_id` comes from `emit()`'s `actorId`, which a flow notify node can author. So the behaviour on measured traffic is unchanged, and a latent difference remains for an `actorId` that names no user. The Acceptance notes carry it. **H4 pins and ablations.** There is one pin per package. The engine double sits behind the package's real call path, proves the population ran, and asserts `isSystem` on every call. Each pin was ablated by dropping the opt-in through `scripts/ablation-replace.mjs` (the anchor must hit). Seven legs ran: settings `loadRows`, the fan-out constant, the dispatcher constant, the datasource `sys_metadata` constant, the secret-binder constant, and the two webhook constants. Every leg went red under the mutation, and the failure names the call, for example "find on sys_setting: expected undefined to deeply equal { isSystem: true }". Every leg was restored with blob equal to HEAD and an empty `git diff HEAD`, and went green again. The pins are package-local, imported from `src` with no dist in the path. **Census pages (H3).** The `isSystem` census (`check-system-context-census`) is OK, and `--fix` changed nothing: this change adds no elevation read site. The tenant-audit census did move, because the write sites now thread a context. It was regenerated with `tenant-audit-census.mjs --write`. On its page, the hand-written figures follow the census: the provable no-context, tenancy-enabled count went 9 → 2, unreadable 67 → 60, decidably elevated 114 → 121. **Serial (H5).** `origin/main` was merged twice. It now includes objectstack-ai#21906's squash, and the merge was clean. A `git merge-tree` against objectstack-ai#21877's head (`5c405846`, now closed as a draft) is clean. This PR edits neither PR's region: `datasource-admin-plugin.ts` and `datasource-secret-binder.ts` only, in `service-datasource`. ## Tests - At `10e77fef6f`, after merging `origin/main` `faf8dce482`. The next merge (`9dce635337`, which brings this PR to `62960ffa1a`) touches no file in these four packages. Typecheck of the four packages: exit 0. - Unit suites: service-settings 614 passed, service-messaging 510, service-datasource 743, plugin-webhooks 165. All exit 0, unchanged apart from the new pins and tests that came in from `main`. - ESLint, narrowed to the 19 changed TS files with `--no-inline-config --format json`: 19 files, 0 errors, 0 warnings. Those files are inside the config's own `packages/**/*.{ts,…}` population, and the config enables no type-aware linting, so this diff cannot move a verdict on any untouched file. The full `pnpm lint` run belongs to CI. - At `62960ffa1a`, the head this PR opens with, every one of the 105 commands `dispatch-gates --commands --repo objectstack-ai/objectstack` derives exited 0. `dispatch-gates --ran` reports: "105 derived famil(ies) accounted for — 105 run, 0 NOT-MEASURED". In an earlier pass, four of these went red on this branch, and they are now fixed. `check:tenant-audit-census` needed the census regenerated. `check:engine-double-contract` and `check:objectql-double-limit` needed the pin doubles routed through the shared dispatch asserts and holding a find's bound, with the ledger recording the new pinned coverage. `check:dual-build-cjs-loads` needed eight unrelated packages built first. ## Acceptance notes - **Producers in these packages that the card does not name.** A static read finds that they still reach the engine with no context. No run exercised them, so the measured table never listed them. Without a route, objectstack-ai#21908's deny breaks each one, so they are listed for the seat's closure rather than moved here: - service-settings: the `sys_secret` store the plugin builds (`insert` / `get` / `update`), and `SettingsService.readStoredHandle`. - service-messaging: `SqlNotificationOutbox` and `SqlHttpOutbox` `enqueue`, `ack` and `list`; the email and SMS channels' recipient reads; `RecipientResolver.resolveRole` / `resolveTeam` / `resolveOwnerOf`; the emit dedup lookup; the template renderer's read. - `resolveOwnerOf` reads a business object, and its posture is not neutral. Today the sharing middleware answers a principal-less read of a `private` object with a deny-all filter, so an `owner_of:` recipient on such an object resolves to nobody. Under the opt-in, that filter would be bypassed. - **Request-door producers that act on the caller's own rows, like rows 15 and 16** (report-only, for the maintainer's ruling): the inbox unread count, and mark-read / mark-all-read (`unreadNotificationIds`, `upsertReadReceipt`, `notificationOrganization`). - **The row-23 difference above:** the dangling-reference check stops running on the `actor_id` of `sys_notification`, `sys_inbox_message` and `sys_setting_audit`. - One posture question was noted on a request-door read and is held off-thread. It was not measured. ## Seat's append: patch round 1 at `57f738dfb1` (written by `domain:services` seat 1 from the dev's report `6009307655`; the dev does not edit this body) **What changed in the patch round** (seat verdict `6008259054`). The sections above describe `62960ffa1a`; where they differ, this append is current. - **`Clause-②: yes (widening)`.** The exported `SettingsEngine` (`find`, `insert`) and `SecretStoreEngineLike` (`delete`) gain an optional `context`, so `@objectstack/service-settings` and `@objectstack/service-datasource` take a `minor`. `service-messaging` and `plugin-webhooks` stay `patch`. Line 2 above, the changeset and the claim (`6003840075`) moved together. Nothing accepted or refused at any door changes. - **A user reference that names no user is still refused.** The engine skips its dangling-reference check for an `isSystem` write and has no option to keep it. So each producer that writes a user reference does one guarded `sys_user` read by id under the opt-in, then refuses an unknown id with the engine's own answer: `VALIDATION_FAILED`, one `reference_not_found` finding, and the same message. - The checked references are the `actor_id` of `sys_notification` (`writeEvent`), of `sys_inbox_message` (the inbox send) and of `sys_setting_audit` (the setting-audit writer), and the `user_id` of a user-scope `sys_setting` row on `SettingsService.upsertRow`'s insert. The last is the same difference, which this PR's opt-in introduced on that insert. - The refusal is built by `validationFailure` from `@objectstack/types`, already a runtime dependency of both packages, so neither package stamps the code itself and `check:error-code-provenance` is green with no spec row and no waiver. It is shape-identical to the engine's refusal but not `instanceof` objectql's `ValidationError`; the callers on these paths read the message or the code, and every door maps the shape to `400 VALIDATION_FAILED`. - A write that names no user is unchanged. A read that cannot run lets the write through, as the engine's check does. The cost is one extra `sys_user` read per write that names a user. - Differential pins over a real engine hold each producer's answer equal to the engine's own refusal of a context-less insert. Four ablations went red and were restored with blob equal to HEAD. - **objectstack-ai#21935 merged in** (`a3c2209a68`). `check:pm-dispatch-gates` exits 0. - **Gates at `57f738dfb1`:** 105 derived, 105 run, all exit 0. The 54 roster families: 51 exit 0, and 3 are NOT WIRED locally (they need a pull-request context; CI runs them). - **`service-settings/vitest.config.ts`** gains one anchored alias (`platform-objects/identity` → `src`) for the new pin, which `check:test-source-alias` asks for. **Carried, not filed here:** - Producers in these packages that the card does not name are recorded on objectstack-ai#21908's census (rows 24 onward). `resolveOwnerOf` is not neutral to move. - The inbox unread count and mark-read / mark-all-read join the maintainer's open ruling on rows 15 and 16. - One request-door posture question is held off-thread, at class level only. --- _Generated by [Claude Code](https://claude.ai/code/session_011K3zqE8Pv1Evw5hc8tZCnN)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
Fixes #21840
Clause-②: yes (narrowing)
What changed
Credential material in the
configof a datasource whose driver the platform ships no config contract for (a plugin-contributed driver such ascom.vendor.warehouse) is now refused at publish and withheld on every read door. A driver with a shipped contract (postgres,mysql,mongodb,turso,sqlite,sqlite-wasm,memoryand their aliases) is judged exactly as before, at both doors.packages/spec/src/data/driver/contractless-credentials.ts):findContractlessCredentialswalks a contractless driver'sconfigand reports every credential position, with itsconfig.<path>. The write door refuses each finding and the read door withholds each finding, so the two doors cannot disagree.isContractlessDriverdecides which drivers it applies to: those with no registered config schema.isCredentialShapedConfigKey): a key is NFKC-normalised, then judged on its whole name, split into words at separators and camel-case boundaries, case-insensitively. It matches whole words, never substrings. A key longer than 256 characters is credential-shaped without being read. A key whose last word is a locator, identifier or descriptor (credentialsRef,accessKeyId,tokenUrl,passwordFile,secretsManagerRegionand the like) is never credential-shaped, nor is a multi-word key that starts with a flag or count word (useDefaultCredentials,maxTokens). Otherwise it is credential-shaped when a word ispassword,passwd,passphrase,secretorcredential, when its last word is a token-type stem (token,pwd,jwt,cookie,auth,bearer,apikey,pfx,pkcs12,p12and others), or when it names key material (apiKey,privateKey,sslKey,serviceAccountKeyand others). A bareaccessKey,primaryKeyandpartitionKeystay accepted. A one-word key with no boundary left (APIKEY,dbpassword) is judged on its folded spelling by the same rules. The changeset has the full word lists.密码,パスワード,парольand others in the changeset); it reads as credential-shaped once invisible format characters are removed and Cyrillic and Greek look-alike letters are read as Latin; an ASCII credential word touches a non-ASCII character (password密码); or a credential word of four or more letters has at most one non-ASCII stand-in or insertion per four letters (tok€n). Otherwise a run of non-ASCII characters is a word of its own, so客户名称,Größeandcaféare accepted. The same rule judges query, form and connection-string segment parameter names.key(orkeys) in an object is credential material only in these places: inside a credential-shaped holder; directly in a header-ish holder's map (headers: { key }is the header namedkey), but not in an element of a list under it, wherekeyis a pair's label (headers: [{ key: 'Authorization', value }]); inside a TLS holder (a key with a wordssl,tls,mtls,x509,pfx,pkcs12, or a word starting withcert:ssl: { key, cert, ca }); or when its value looks like key material. Key material here means a secret-looking string (looksLikeSecretValue), 16 or more characters of hexadecimal holding a letter, digit-free base64 whose upper and lower case alternate like random text, or bytes. Camel-case names and paths stay names, so{ key: 'email' }and{ key: 'customerEmailAddress' }are accepted.packages/spec/src/data/datasource.zod.ts):DatasourceSchemaadds onecustomissue per finding, at the value's ownconfig.<path>(array elements included, such asconfig.servers.0.password), naming the remedy: remove the inline credential and bind it as the datasource's secret (the connection form's secret field, orexternal.credentialsRef). Every door that parses the schema is covered:defineStack({ datasources }),PUT /api/v1/meta/datasource/:name, and Setup → Datasources create and update. The connection test answersok: false. What is refused:credentials: {…},auth: {…}), except leaves whose last word is a descriptor or an identity (user,clientId,host,scopeand the like), socredentials: { type, clientId }is accepted whole. The exemption covers leaves only; an object below such a key is still judged as credential context;valueof a pair object whose label (name,key,headerorheaderName) is credential-shaped, and the value of a[name, value]tuple or a flat raw-headers list whose name is credential-shaped (anAuthorization,X-API-KeyorCookieheader);;key=valuetail property; the Oracle thin-driver userinfo; a credential segment of a semicolon-delimited connection string (quoted values honoured); a credential keyword of a libpq keyword/value string; a scheme-lessuser:password@hostuserinfo; a JSON-encoded object or array, walked by the same rules; PEM private-key armour (-----BEGIN … PRIVATE KEY-----, includingRSA,EC,DSA,ENCRYPTED,OPENSSHandPGP PRIVATE KEY BLOCK), in a string or in bytes; and aName: valueheader line with a credential-shaped name. A header line is read on any line of a multi-line string, but a single-line string is read as a header line only under a header-ish key, so a one-linedescription: 'Password: …'is prose;$1,?,:name), as inWHERE token = $1; the part after an opaque URI scheme (mailto:,sip:,sips:,tel:,urn:,xmpp:,news:,im:,pres:), which is read on its own instead of asuser:password@host; and a time of day before an@(12:30@);MAX_JUDGED_STRING_LENGTH), which is judged credential material without being read;CONTRACTLESS_CREDENTIAL_WALK_DEPTH), and aMaporSetanywhere, which cannot be judged and are not accepted unjudged.${API_KEY}), plain array data with no credential-shaped key, and every other key. The config shape itself stays unjudged.packages/spec/src/data/datasource-credential-redaction.ts): the one redactor,redactDatasourceConfig, now withholds every position the same walk reports for a contractless driver. A credential value is dropped (inside an array element too, without shifting its siblings), a credential embedded in a string is removed from it (redactEmbeddedCredentials), and a PEM private-key block is removed from its string. The served projection is then judged again, and redacted again, until it holds no finding, so an untouched Save of what was served passes the write door. A projection that has not settled after 8 passes is served empty. Every read exit already routes through this redactor:/api/v1/meta/datasource(item, list,/published,/layers, history),/api/v1/datasources(item and list), the data door oversys_metadata/sys_metadata_history, and the audit ledger's and activity feed's copies. No second redaction helper is added. Audit copies written before this release are projected through the same redactor byos migrate audit-metadata-bodies --apply.packages/services/service-datasource/src/datasource-config-redaction.ts):restoreRedactedConfigresolves each array hop by identity: the same index in an array left as served, otherwise the one element equal to the served projection, unique on both sides. A value whose element changed, is gone or is ambiguous is dropped, never carried onto another element. It then redacts the grafted config again and keeps a graft only where the read path would still withhold it, repeating until nothing more drops. So a pair's value beside a label renamed to a non-credential name, a deleted label, or a renamedkey:label is dropped. An untouched Save, where the patch equals the served projection, skips this second walk./metacarry-forward (packages/metadata-protocol/src/metadata-redaction.ts): thePUT /api/v1/meta/datasource/:namecarry-forward applies the same identity rule to id-less array elements and nested arrays, which it used to skip, so an untouched GET-then-PUT of a legacy row no longer drops values.packages/services/service-datasource/src/datasource-credential-migration.ts): for a contractless row, every top-level key that holds a finding of the same walk is reported as residue and refused with the remedy, instead ofnothing-to-migrate.@objectstack/spec/dataexports:isCredentialShapedConfigKey,embeddedCredentialOf,redactEmbeddedCredentials,connectionStringCredentialKeys,findContractlessCredentials(withContractlessCredentialFinding, whoseembeddedfindings under a header-ish key carryheaderish: true, andCONTRACTLESS_CREDENTIAL_WALK_DEPTH),withholdContractlessCredentials,looksLikeSecretValue,MAX_JUDGED_STRING_LENGTH, theEmbeddedCredentialOptionstype (itsheaderishoption is taken byembeddedCredentialOfandredactEmbeddedCredentials), andisContractlessDriver.api-surface/andexport-origins/regenerated.scripts/adr-anchors/packages__spec__src__data__datasource.zod.ts.json(ADR-0015, ADR-0062). The changeset.changeset/21840-contractless-datasource-credentials.mdstates the breaking change, the full refused and accepted lists, and the remedy.Why refuse at write rather than route into the secret store
ADR-0015 section 10: credentials never appear in metadata artefacts. ADR-0062 D3: the one route a credential has into a driver is the bound secret (
external.credentialsRef), resolved at connect and handed to the driver factory. For a contractless driver nothing says which key its factory reads its credential from, so silently movingconfig.apiKeyinto the single bound secret would hand the factory a secret it may never read and strip the key it does. That is the same reason the credential-migration planner already refuses to re-home such a row. Refusing is loud and names the working remedy; known drivers already refuse inline credentials the same way.Tests
Rounds 3 and 4 are described in the os-dev-reports on #21840. Round 3 (head
2320178c9d, comment 6002380150) added a linear key split with a 256-character cap on judged names, identity-based carry-forward, a lenient one-pass libpq scan, more header shapes and key spellings, embedded credentials in URL usernames, signed-URL signatures, JSON-, form- and fragment-encoded strings, leaf-only descriptor exemption, byte and Map/Set values, and a barekeyjudged only in context or by its value. Round 4 (head5c405846a5, comment 6004802884) added the re-judgment fixed point in the edit round trip, PEM private-key armour and TLS holders, the non-ASCII key rule, the header-line, SQL-placeholder, opaque-scheme and time-of-day readings, the 64 KiB unread cap, hex and base64 bare-key values, and a served projection judged again until clean. The figures below are from round 4.All runs are against the tree pushed as HEAD
5c405846a5. The tests import the subject fromsrc.packages/spec/src/data/datasource-contractless-credentials.test.ts: 434 tests, covering the key judgment in both directions, embedded credentials in strings, the write door and the read door per shape, and known-driver behaviour unchanged. Round-4 cases in both directions: PEM per armour type, TLS holders,pfx, the accepted and refused non-ASCII sets, parameter names, prose, SQL,mailto,sip,tel,urnand time-of-day values accepted with controls still refused, the 64 KiB cap with an at-cap control, hex and base64 bare-key values with name controls, bounded-time cases, and cases proving the served projection is accepted by the write door. Whole spec project: 619 files / 18884 tests passed (1 todo).packages/services/service-datasource/src/__tests__/datasource-contractless-credentials.test.ts: create and update refused per shape with nothing persisted and no secret minted;getDatasourceandlistDatasourceswithhold a legacy row's credentials; the untouched round trip carries them forward; the planner names the residue. Six new carry-forward tests: label renamed, label deleted,key:label renamed, renamed to another credential name (keeps the value), the untouched-Save control, and a re-read through the service. Whole package: 40 files / 762 tests passed.packages/qa/dogfood/test/datasource-contractless-credentials.dogfood.test.ts: the showcase composition with the admin routes and the audit writer mounted asos servemounts them, across a cold boot on one database file. Both write doors refuse and nothing is stored; the clean datasource saves; a row seeded at rest with the pre-refusal body is served by every read door without its credentials, with a positive-control marker present; the audit ledger's copy carries none. New case 4 drives the admin PATCH edit door on a seeded legacy row with renamed labels: the stored row and two read doors carry no withheld value, and the positive control (the edit reached the stored row) holds. Whole dogfood package in 4 batches: 204 files passed, 1 skipped (477 + 401 + 384 + 317 tests passed).metadata-protocol217 files passed, 3 skipped / 27925 tests passed, 19 skipped.plugin-audit39 files / 630 tests passed.scripts/ablation-replace.mjs, each anchor verified 1 to 0 on disk, each restore proven by blob equal to HEAD and an emptygit diff HEAD: (1) making the edit round trip keep every graft turned 4 service tests red (label renamed, label deleted,key:renamed, through the service), while the control and the renamed-to-credential case stayed green; (2) dropping the TLS-holder clause turned 1 test red (a PEM underssl.keyis still caught by the armour reading); (3) removing PEM detection turned 7 tests red. The restore runs re-ran 434/434 and 30/30 green. Two earlier attempts that never measured anything (one refused by the tool, one mangled by shell quoting) were discarded.@objectstack/spec(including scripts and the test layer),@objectstack/service-datasource,metadata-protocol,plugin-audit, dogfood.node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --ran: 99 derived, 98 run with exit 0, 1 NOT MEASURED.check:generatedgreen after regeneratingapi-surface/andexport-origins/.check:type-check-debttimed out at the 300 s per-gate budget and passed on a rerun (79/80 packages, debt unchanged).NOT MEASURED:
check:dual-build-cjs-loads, reason: PREREQUISITE NOT MET (exit 3; it needs a full repo build). Declared to CI.The branch was at least 3 commits behind
origin/mainwhen the gates ran; main was not merged in this round. This diff touches no fake engine (scripts/engine-double-contract.pinned.jsonchanged on main).Acceptance notes
configcarries the withheld value forward and is then refused; sending that key as an empty string clears it. A metadata-door save of the served body is not affected (its gate runs before the carry-forward).${API_KEY}); a lower-case${…}is judged as written. The platform's own environment names are upper-case.user:x@host-shaped value that is not a credential is refused; a key such assecretsManagerholding a provider name is judged credential-shaped; and credential words of other scripts match as substrings inside a key.:wordis read as a SQL bind placeholder, not a credential.keylabel, because therekeyis read as the header namedkey.Generated by Claude Code