Skip to content

fix(spec)!: credential-shaped datasource config values are refused at write and redacted on every read door for drivers with no shipped contract - #21877

Closed
objectstack-fleet[bot] wants to merge 19 commits into
mainfrom
claude/issue-21840-datasource-secret-keys
Closed

objectstack-fleet[bot] wants to merge 19 commits into
mainfrom
claude/issue-21840-datasource-secret-keys

Conversation

@objectstack-fleet

@objectstack-fleet objectstack-fleet Bot commented Oct 5, 2026 •

Copy link
Copy Markdown
Contributor

Fixes #21840

Clause-②: yes (narrowing)

What changed

Credential material in the config of a datasource whose driver the platform ships no config contract for (a plugin-contributed driver such as com.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, memory and their aliases) is judged exactly as before, at both doors.

  • One walk, two doors (packages/spec/src/data/driver/contractless-credentials.ts): findContractlessCredentials walks a contractless driver's config and reports every credential position, with its config.<path>. The write door refuses each finding and the read door withholds each finding, so the two doors cannot disagree. isContractlessDriver decides which drivers it applies to: those with no registered config schema.
  • The key judgment (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, secretsManagerRegion and 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 is password, passwd, passphrase, secret or credential, when its last word is a token-type stem (token, pwd, jwt, cookie, auth, bearer, apikey, pfx, pkcs12, p12 and others), or when it names key material (apiKey, privateKey, sslKey, serviceAccountKey and others). A bare accessKey, primaryKey and partitionKey stay 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.
  • Non-ASCII keys: a key that holds non-ASCII text is credential-shaped only when that text sits inside or next to a credential word: it contains a credential word of another script (密码, パスワード, пароль 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öße and café are accepted. The same rule judges query, form and connection-string segment parameter names.
  • A bare key (or keys) 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 named key), but not in an element of a list under it, where key is a pair's label (headers: [{ key: 'Authorization', value }]); inside a TLS holder (a key with a word ssl, tls, mtls, x509, pfx, pkcs12, or a word starting with cert: 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.
  • Write door (packages/spec/src/data/datasource.zod.ts): DatasourceSchema adds one custom issue per finding, at the value's own config.<path> (array elements included, such as config.servers.0.password), naming the remedy: remove the inline credential and bind it as the datasource's secret (the connection form's secret field, or external.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 answers ok: false. What is refused:
    • a non-empty string, a number, non-empty bytes, or an array of these under a credential-shaped key;
    • the secret leaves of a credential-shaped object (credentials: {…}, auth: {…}), except leaves whose last word is a descriptor or an identity (user, clientId, host, scope and the like), so credentials: { type, clientId } is accepted whole. The exemption covers leaves only; an object below such a key is still judged as credential context;
    • the value of a pair object whose label (name, key, header or headerName) is credential-shaped, and the value of a [name, value] tuple or a flat raw-headers list whose name is credential-shaped (an Authorization, X-API-Key or Cookie header);
    • a credential inside a string anywhere: a URL userinfo password, or a userinfo username that looks like a secret; a credential URL query, fragment or form parameter, or a ;key=value tail 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-less user:password@host userinfo; a JSON-encoded object or array, walked by the same rules; PEM private-key armour (-----BEGIN … PRIVATE KEY-----, including RSA, EC, DSA, ENCRYPTED, OPENSSH and PGP PRIVATE KEY BLOCK), in a string or in bytes; and a Name: value header 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-line description: 'Password: …' is prose;
    • what is not read as a credential inside a string: a libpq or segment value that starts with a SQL bind placeholder ($1, ?, :name), as in WHERE 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 as user:password@host; and a time of day before an @ (12:30@);
    • a string or bytes longer than 65,536 characters or bytes (MAX_JUDGED_STRING_LENGTH), which is judged credential material without being read;
    • a subtree nested deeper than 16 levels (CONTRACTLESS_CREDENTIAL_WALK_DEPTH), and a Map or Set anywhere, which cannot be judged and are not accepted unjudged.
  • Still accepted at the write door: an empty string (the explicit way to clear a stored value), a boolean, a value made only of upper-case environment placeholders (${API_KEY}), plain array data with no credential-shaped key, and every other key. The config shape itself stays unjudged.
  • Read door (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 over sys_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 by os migrate audit-metadata-bodies --apply.
  • Edit round trip (packages/services/service-datasource/src/datasource-config-redaction.ts): restoreRedactedConfig resolves 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 renamed key: label is dropped. An untouched Save, where the patch equals the served projection, skips this second walk.
  • /meta carry-forward (packages/metadata-protocol/src/metadata-redaction.ts): the PUT /api/v1/meta/datasource/:name carry-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.
  • Migration planner (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 of nothing-to-migrate.
  • New @objectstack/spec/data exports: isCredentialShapedConfigKey, embeddedCredentialOf, redactEmbeddedCredentials, connectionStringCredentialKeys, findContractlessCredentials (with ContractlessCredentialFinding, whose embedded findings under a header-ish key carry headerish: true, and CONTRACTLESS_CREDENTIAL_WALK_DEPTH), withholdContractlessCredentials, looksLikeSecretValue, MAX_JUDGED_STRING_LENGTH, the EmbeddedCredentialOptions type (its headerish option is taken by embeddedCredentialOf and redactEmbeddedCredentials), and isContractlessDriver. api-surface/ and export-origins/ regenerated.
  • ADR anchor scripts/adr-anchors/packages__spec__src__data__datasource.zod.ts.json (ADR-0015, ADR-0062). The changeset .changeset/21840-contractless-datasource-credentials.md states 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 moving config.apiKey into 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 bare key judged only in context or by its value. Round 4 (head 5c405846a5, 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 from src.

  • 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, urn and 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; getDatasource and listDatasources withhold 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 as os serve mounts 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).
  • Consumer suites: metadata-protocol 217 files passed, 3 skipped / 27925 tests passed, 19 skipped. plugin-audit 39 files / 630 tests passed.
  • Ablations on the committed tree through scripts/ablation-replace.mjs, each anchor verified 1 to 0 on disk, each restore proven by blob equal to HEAD and an empty git 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 under ssl.key is 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.
  • Typecheck green: @objectstack/spec (including scripts and the test layer), @objectstack/service-datasource, metadata-protocol, plugin-audit, dogfood.
  • Gates: node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --ran: 99 derived, 98 run with exit 0, 1 NOT MEASURED. check:generated green after regenerating api-surface/ and export-origins/. check:type-check-debt timed 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/main when the gates ran; main was not merged in this round. This diff touches no fake engine (scripts/engine-double-contract.pinned.json changed on main).

Acceptance notes

  • The out-of-repo consumer population is not measured. No datasource in this repository uses a contractless driver; a plugin driver elsewhere that authored inline credentials sees the refusal on its next publish (the changeset states the breaking change and the remedy).
  • A plugin driver that needs more than one secret has no remedy for the second one today: the datasource secret binder fills exactly one slot. That is the existing one-slot limit, not changed here.
  • On a legacy contractless row that still holds a credential, an admin edit that changes config carries 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).
  • An inline value longer than 64 KiB under a contractless driver, such as a large CA bundle, is now refused at publish and served empty; reference such material by path instead.
  • Open decision: the placeholder exemption accepts only upper-case environment names (${API_KEY}); a lower-case ${…} is judged as written. The platform's own environment names are upper-case.
  • Known over-refusals, not leaks: a scheme-less user:x@host-shaped value that is not a credential is refused; a key such as secretsManager holding a provider name is judged credential-shaped; and credential words of other scripts match as substrings inside a key.
  • Known over-acceptance at the edge: a libpq or segment value that starts with :word is read as a SQL bind placeholder, not a credential.
  • A key-labelled pair placed directly under a header-ish key is served without its key label, because there key is read as the header named key.

Generated by Claude Code

claude added 8 commits October 5, 2026 11:39
…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>
@github-actions

github-actions Bot commented Oct 5, 2026 •

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 3 package(s): @objectstack/metadata-protocol, @objectstack/service-datasource, @objectstack/spec, touching 136 documentable anchor(s). ⚠️ 3 changed file(s) yielded no anchor (packages/spec/api-surface/data.json, packages/spec/export-origins/data.json, packages/spec/src/data/driver/index.ts), so the pages documenting them are NOT COVERED by this run — this is not a clean bill of health for those files.

1 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:

  • content/docs/automation/connectors.mdx (via headerName (literal, a string literal in PAIR_LABEL_KEYS))
What this run could not see
  • 3 changed file(s) yielded no anchor (packages/spec/api-surface/data.json, packages/spec/export-origins/data.json, packages/spec/src/data/driver/index.ts) — pages documenting those are invisible to this run
  • 11 name(s) were too generic to anchor anything (single lowercase words)
  • the SDK route bridge reached 54 of 206 client-bound route-ledger rows — the other 152 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 152: 0 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 55 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 97 are undecided (no in-repo declaration, on a ledger that has other in-repo registrars — absence and an unreadable spelling are not distinguishable here). The rows themselves: node scripts/docs-audit/affected-docs.mjs --bridge-coverage
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.
  • a key NAME is not a key, so the hand re-read the line above prescribes can land on the wrong schema. The same spelling is authorable on one governed type and a [REMOVED] tombstone on another for each of active, aria, joins, objects, template, tools and version (censused on [finding] tools is a key on BOTH AgentSchema (tombstoned, dead) and SkillSchema (live, cloud-attested), so a name-based search attributes skill examples to the agent key — it produced a false stop-the-line alarm on PR #19059 #19093 over the liveness ledger's governed types, top-level keys); nothing in a search result distinguishes the two, so a grep hit on a LIVE example reads as evidence about the DEAD key. Measured on fix(spec): the agent.tools liveness row says dead — it claimed live on a key the schema tombstoned #19059: content/docs/ai/agents.mdx was reported as contradicting the agent.tools tombstone over its tools: example at :161, which is inside the defineSkill({ block opened at :155 — the page was already correct. Settle ownership by PARSING the value against both schemas, never by the name: that literal PASSES SkillSchema, and as an AgentSchema it FAILS at tools with the tombstone prescription. ⛔ These names are not the whole class — a key retired through a .strict() guidance map leaves no tombstone in the walked shape and none of them here (tool.category, live as AIToolDefinition.category).

Coarse fallback — 139 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 cab639671528ef6f3a201e8995794378a4a28bfe → packageMentionDocs.

Which tree this was computed on

This run read content/docs from 1f565b01adcc7544dc38bc3e5b13499ba2f34e52 — the merge of head 5c405846a58ec2edb6299e9cf60c9a2b277596b5 into base cab639671528ef6f3a201e8995794378a4a28bfe, which is what actions/checkout gives a pull_request run. Not the PR head.

A worktree cut from an older main holds a different content/docs, so re-deriving there can legitimately return a different list — that is a different tree, not a wrong row. To answer on the same tree:

# 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

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

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 cab639671528ef6f3a201e8995794378a4a28bfe → pass the list as
args.docs, on the commit named under Which tree this was computed on.

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: 6a4fb54fcaf8ce62fb4c7f8ae80796e2e041dc8b
Local-runs: none

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 origin/main...6a4fb54fca; the 36 check-runs on this head. Nothing was built, run or re-run.

① Derived judgments

  1. DatasourceSchema accept set narrows, for contractless drivers only. Named RIGHT. Before this change, reportDriverConfigIssues returned early on known: false. It now calls reportContractlessInlineCredentials, which refuses three things, each at its own config path: a non-empty string under a credential-shaped key or under a credential-shaped enclosing object; a URL userinfo password or a credential query parameter; and a credential segment of a connection string. These stay accepted: empty strings, ${…} placeholders, non-string values, arrays, and every other key. The known: true branch is byte-for-byte unchanged, so a driver with a contract is judged exactly as before. The changeset states the predicate rule exactly as the code implements it (fragments, endings, locator-ending exclusions). Consumers therefore get an accurate statement of what is now refused.

  2. The read-door redactor redactDatasourceConfig now withholds more, for contractless drivers. Named RIGHT. This is an output narrowing, not an accept-set change. isHidden and redactString add the same predicate at every depth, plus connection-string segment stripping. The work happens inside the one existing redactor, so the triage's "no second redaction helper" condition holds. Read and write use the same predicate. The read side drops a credential-shaped subtree whole, and the write side refuses strings beneath one, so the two doors agree. The read side is stricter on non-string values, which is the safe direction.

  3. Published surface widens. Named RIGHT. @objectstack/spec/data is the ./data exports entry, and src/data/index.ts re-exports driver/index and datasource-credential-redaction. The new exports are isCredentialShapedConfigKey, connectionStringCredentialKeys, redactConnectionStringCredentials and isContractlessDriver. The api-surface/data.json and export-origins/data.json shards are regenerated to match: 4 additions, nothing removed or renamed.

  4. @objectstack/service-datasource planner behaviour. Named RIGHT. unbindableCredentialKeys now reports a contractless row's credential-shaped keys and credential-bearing connection strings as residue, instead of returning nothing-to-migrate. The function is internal, and the package's exports are untouched.

  5. Residual precision of the name predicate. Recorded, not blocking. The predicate is a closed heuristic: enumerated key-material endings and an enumerated locator/descriptor exclusion list. It errs in both directions:

    • Over-inclusive. Some descriptor keys contain a fragment but end in a word that is not on the exclusion list, for example a provider, source, method, policy or region selector. These are refused at write and dropped on read, and the bound-secret remedy does not fit them.
    • Under-inclusive. Some abbreviated credential spellings, and some key-material names that carry a value-format suffix, match neither a fragment nor an ending. They are neither refused nor withheld for contractless drivers, which is the state before this PR.

    Neither error mis-states the contract: the changeset publishes the exact rule. The PR is a strict improvement on main for the card's class, and the card's withheld positions are named in the changeset title and are covered. isCredentialShapedConfigKey is now public, so widening it later is itself a further accept-set narrowing. That later change will need its own Clause-②: yes (narrowing) changeset. The specific spellings are passed to the PM seat privately (RUNNER rule 2), and a follow-up card is recommended.

② Semver level

Clause-②: yes (narrowing)

  • @objectstack/spec: minor. This matches what the diff publishes: one accept-set narrowing (BREAKING) and four additive exports. The changeset carries the BREAKING banner and the Clause-②: yes (narrowing) line. It carries exactly one ADR-0087 marker, not-required (no-migration-prescription). That disposition is sound: no key, export or stored shape is retired or renamed, and re-homing a credential into the bound secret is an operator act, not a mechanical FROM to TO rewrite. The remedy is stated for authors (remove it from config, bind it via the connection form's secret field or external.credentialsRef). The title fix(spec)!: agrees. yes with at least minor satisfies AGENTS.md directive 3.
  • @objectstack/service-datasource: patch. Behaviour fix only, no export change. Correct.
  • @objectstack/dogfood and scripts/adr-anchors: tests and tooling only, no published surface.
  • The claim originally said Clause-②: no. Comment 5995372304 corrected it to yes (narrowing), and the PR body, the changeset and this record agree.

③ Boundary flags

  • open_questions[0], the Clause-② line. Answered. Option A, keep yes (narrowing), is correct per ② and was already adopted by the claim correction 5995372304.

  • open_questions[1], a plugin driver needing more than one secret. Answered with option A: accept it as the existing one-slot binder limit, and record it. No contractless driver exists in this repository, and the limit predates this change. Escalate to a decision card (option B) only if a real multi-secret plugin driver is named.

  • out_of_scope_findings[0], the verify harness not mounting the admin routes or the audit writer. Observation only. The dogfood file mounts both the way os serve does. No carrier is needed.

  • out_of_scope_findings[1], the legacy contractless row edit-then-refuse path. Disclosed in the PR's Acceptance notes and the changeset. Sending the key as an empty string clears it, so a remedy exists. Accepted.

  • Out-of-repo consumer population NOT MEASURED. Declared in both the changeset and the PR body. That is acceptable for a launch-window minor narrowing whose changeset names the remedy.

  • Dev NOT MEASURED items. check:dual-build-cjs-loads and the packages/spec/scripts/** tests were declared to CI. They are covered by the head's Build Core, Type Check and Test Core jobs, which are still pending (below).

  • Governed surface. The file list touches no governed path (docs/adr/**, skills/**, AGENTS.md, CLAUDE.md, .claude/**), so this record is not a landing key. Landing still waits on every check going green.

  • Check-runs on this head at review time.

    • Green: Type Check · source gates, Spec property liveness, Check Changeset (one run), Governed Surface Queue Guard, claim/single-writer guards, Check PR Size, Check Documentation Links, Flag docs affected, Auto Label, filter.
    • Skipped: Console Pin Gate, Build Docs, Packed-tarball smoke.
    • Still pending: Build Core, Lint & Repo Gates, Check Changeset (second run), Type Check · workspace / debt ledger / consumer gates, Test Core 1–6/6, Dogfood Regression Gate 1–3/3, Dogfood Verify CLI, Temporal Conformance.

    This verdict judges the contract. It does not stand in for any pending gate.

Implemented-by: claude/issue-21840-datasource-secret-keys
Reviewed-by: session_018zT8d8NpiQ1ExhuNd5TxY6

VERDICT: PASS

claude added 4 commits October 5, 2026 13:51
…airs, more spellings and connection-string forms

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>
claude added 2 commits October 5, 2026 18:05
…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>
claude added 3 commits October 5, 2026 21:14
…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 added 2 commits October 5, 2026 21:23
… 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>
@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

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 config are credentials, and no registration path is added. #21840 is closed as not planned. A one-sentence docs note lands separately.


Generated by Claude Code

@objectstack-fleet objectstack-fleet Bot closed this Oct 6, 2026
akarma-synetal pushed a commit to akarma-synetal/framework that referenced this pull request Oct 7, 2026
…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>
akarma-synetal pushed a commit to akarma-synetal/framework that referenced this pull request Oct 7, 2026
…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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

2 participants