Skip to content

fix(metadata-protocol)!: a datasource saved through the metadata door may name only its own stored credentials reference - #27817

Merged
objectstack-fleet[bot] merged 6 commits into
mainfrom
claude/project-thread-bix98b
Oct 30, 2026
Merged

objectstack-fleet[bot] merged 6 commits into
mainfrom
claude/project-thread-bix98b

Conversation

@objectstack-fleet

Copy link
Copy Markdown
Contributor

Requested by Elon · project thread

Fixes #27762

domain:server#3 · os-elon-musk · session_016WV4cx2wNeyC4kD8R4P7Ns (card thread)

Clause-②: no (narrowing) — the metadata save door refuses a datasource body whose external.credentialsRef is not the reference its own stored row already holds, where it stored the value as given before. Nothing is widened.
Metadata-protocol: narrowed
Skills-docs: none — skills/** and content/docs/** teach external.credentialsRef only on datasources declared in code (defineDatasource, *.datasource.ts), which the metadata save door does not serve (a code-defined name is refused there by the package door); none teaches naming a reference on a runtime metadata save, and the refusal itself names the door that binds one.

维护者速读

  • 改了什么:通过元数据保存门(PUT /api/v1/meta/datasource/:name、运行时 /meta 门、MCP 与进程内保存,同一个保存入口)保存数据源时,external.credentialsRef 只能是这个数据源自己已存记录里的那个引用;新建时带引用、编辑时换成别的引用、或记录本没有引用却带了一个,一律 400 拒绝,且什么都不写,无论引用指向什么答复都相同,消息里不出现引用值。
  • 为什么改:卡上要求按 objectql: a secret field accepts an existing secret: ref from a non-system write, so it can name another record's credential row #27582 的规则审第三个凭据引用写入口:「非系统写入只能封存新值,或保留本记录自己已存的引用」。实测这个门会原样保存请求带来的引用,不符合规则。
  • 风险与回滚:拒绝发生在任何写入之前;读出再原样存回(GET → 编辑 → PUT)会带回自己的引用,照常通过;不带这个键的保存不受影响;系统改写(已存元数据迁移)照常放行;已存记录不会被重新判定。包复制与非托管导入也走这个保存入口,复制出的新数据源若带着源数据源的引用,该项会被拒(复制结果里列为失败项)。控制台的数据源页面走数据源管理入口,不受影响。回滚只需 revert 本 PR。
  • 先看:packages/metadata-protocol/src/protocol-datasource-credentials-ref.ts(规则本身),packages/metadata-protocol/src/protocol.ts 中 saveMetaItem 的那一处调用,两个新测试文件。
  • 两分钟验证:pnpm --filter @objectstack/metadata-protocol exec vitest run src/protocol.datasource-credentials-ref-own-reference.test.ts,预期 14/14 通过。
  • 需要你做的:无,席位落地;本 PR 是收窄(narrowing),落地前需一次契约复审,由席位安排。

What changes

The rule, as #27582 set it for secret fields (PR #27695) and #27722 applied it to the datasource admin door's create (PR #27761): a non-system write may only seal a new value or keep its own record's stored reference. The metadata save door seals nothing, so the one reference a save through it may name is the one its own stored row already holds.

  • The judge. datasourceCredentialsRefSaveRefusal, a new module beside protocol.ts (protocol-datasource-credentials-ref.ts). For a datasource body that names external.credentialsRef, it reads the row the save writes, the way the lock-family judge reads it: the repository's write address for the save's state, package and bundle member, and for a draft with no draft row yet, the active row it overlays. The save is accepted only when that row holds exactly the same reference. It is handed the repository's headAt read alone, so it can read and cannot write.
  • The refusal. VALIDATION_ERROR / 400, the standard-catalog pair this door's own name refusal and the objectql: a secret field accepts an existing secret: ref from a non-system write, so it can name another record's credential row #27582 refusal already use. It is thrown before anything is written. The message names the key and the datasource, never the value, and nothing about the reference is looked up, so the answer is the same whatever the reference names. It names the remedy: send back the reference a read serves, omit the key, or bind a secret through the datasource admin door.
  • Where it is asked. saveMetaItem, beside the other datasource-family save refusals: after the schema gate (the body is a valid datasource, so credentialsRef is a string) and before the authoring gates and every write. protocol.ts gains its import and one call site; nothing in it moves.
  • Who passes. The server-stated rewrites of a row the store already holds (source set: the stored-metadata migration) keep the pass-through, as the system principal does under the objectql: a secret field accepts an existing secret: ref from a non-system write, so it can name another record's credential row #27582 rule. Every other save is judged: the REST PUT doors (meta-envelope), the runtime dispatcher's /meta door (meta-dispatch), MCP and in-process authoring, and the package copy (package-duplicate) and unmanaged import (package-unmanaged-import), which write new rows that hold no reference of their own.
  • Unchanged. The round trip that sends back its own reference keeps it. A body naming no reference saves as before. Rows already stored are not re-judged.

Why

The card's measurement, on origin/main at 968cacf: a non-system save through this door stored a datasource body's external.credentialsRef as given — on a create, on an edit naming another datasource's reference, and on a draft. The pin below was committed first (d816620) and was red on that tree in all ten refusal cases.

Tests

  • packages/metadata-protocol/src/protocol.datasource-credentials-ref-own-reference.test.ts (new, 14 cases): the real saveMetaItem over a stub engine.

    • Refused, with code and status asserted, the store byte-identical afterwards and no write issued: a create naming a reference another datasource holds, and one naming a reference no row holds (identical answers); an edit naming a reference other than its own (identical answers for a held and an unheld one); an edit naming one where its row holds none; a draft naming one the active row does not hold; the plural type spelling; and each face — meta-envelope, meta-dispatch, package-duplicate, package-unmanaged-import, an MCP save. No refusal message contains any reference.
    • Accepted: the GET → edit → PUT round trip keeps the row's own reference; a first draft sending back the active row's reference keeps it; a body with no reference; the source: 'migrate-stored' pass-through.
  • packages/qa/dogfood/test/datasource-credentials-ref-own-reference.dogfood.test.ts (new, 3 cases): the same rule at PUT /api/v1/meta/datasource/:name of a booted stack, as a signed-in non-system caller — a create refused with one answer and nothing stored, an edit refused with the stored reference kept, and the round trip accepted with the reference kept.

  • The pin's stub engine is held to the engine-double gates: its find applies the caller's limit after the filter, and scripts/engine-double-contract.pinned.json records its findOne and update seams (regenerated with --write; 2 rows added, none lost).

Readings: the unit pin on origin/main 968cacf, before the fix, 10 failed | 4 passed (14) (the four that passed are the accepted cases). With the fix: 14 passed (14); the dogfood pin 3 passed (3).

Acceptance notes

  • Keeping the stored reference is implemented as: a save that sends back its own row's reference keeps it. A save that omits the key stores the body without one, exactly as before this change; carrying a reference forward on omission would change what an omitted key means, so it is not done here.
  • The package copy and the unmanaged import are judged too. Both write a new row, which holds no reference of its own, so a copied or imported datasource body that names a reference is refused. In a package copy that item lands in the copy response's failed[] with the refusal text and the rest of the package copies on; in an import the item is refused the same way.
  • Not changed here: rows already stored are not re-judged, and the restore doors (rollback, revert) restore an item's own stored history as before. Both are outside this card's door; a follow-up is the seat's call.
  • protocol.ts gains one import and one call-site block; nothing in it moves or reflows (serial queue [maintainer] refactor(metadata-protocol): split src/protocol.ts (33,065 lines, +10,169 in 30 days) along its ObjectStackProtocolImplementation seams, one move-only slice per PR #25467).
  • Declarations: Metadata-protocol: narrowed (the save door refuses a body it accepted), hence a minor bump on @objectstack/metadata-protocol and the ! title. ADR-0087: not-required (no-migration-prescription) — the refusal prescribes a remedy for the request, not a migration of stored data.

Verification

All at the branch head unless stated. Heavy runs under scripts/pm/os-verify-lock.sh.

  • pnpm --filter @objectstack/metadata-protocol typecheck exit 0; vitest run over the whole package at 7e9b721 (main merged in): 281 passed | 4 skipped (285) files, 21816 passed | 26 skipped tests, exit 0.
  • pnpm exec turbo run build --concurrency=2 --filter=!@objectstack/docs at 7e9b721: 114/114 tasks, exit 0.
  • node scripts/pm/dispatch-gates.mjs --ran: 73 derived, 73 run, 0 NOT-MEASURED, 0 UNRUN, exit 0. 63 families ran at 7e9b721; check:engine-double-contract and check:objectql-double-limit were red there on the new pin's stub engine, were fixed in 7256944 and re-ran green with the 8 families that commit's scripts/ file adds. pnpm lint exit 0.
  • Unit ablations of the judge, each through scripts/ablation-replace.mjs (mutate, run the pin, restore; the restore proven blob-equal to HEAD):
    • every caller takes the pass-through → 10 failed | 4 passed;
    • any stored reference counts, not only the same one → 2 failed | 12 passed;
    • a first draft no longer reads the active row it overlays → 1 failed | 13 passed;
    • the refusal message carries the value → 10 failed | 4 passed;
    • the server-stated rewrite loses its pass-through → 1 failed | 13 passed. (The first attempt at this leg was refused by ablation-replace before any test ran — its replacement was a substring of its anchor, so the count could not rise — and applied no mutation; it was redone with a distinct anchor.)
  • Dist ablation at the REST door: the call site disabled in protocol.ts, @objectstack/metadata-protocol rebuilt, scripts/ablation-dist-preflight.mjs proved the marker in dist/ (exit 0), the dogfood pin 3 failed (3); restored (git diff empty), rebuilt, preflight --absent exit 0, the pin 3 passed (3); protocol.ts blob equal to HEAD.
  • Dogfood battery (pnpm --filter @objectstack/dogfood test, the whole battery, at 7e9b721): 354 passed | 2 failed | 3 skipped (359) files, 2867 passed | 3 failed | 20 skipped (2890) tests. The three failures are not this change's: error-sentences-live.dogfood.test.ts (2) and object-hook-lifecycle-scratch.dogfood.test.ts (1) assert that an undeclared hook abort on the /data door answers with no code, and fix(rest): an undeclared hook abort answers the data door with HOOK_ABORTED #27667 (21c4bdd, on main) now answers HOOK_ABORTED there; neither test is changed on origin/main at c5f4a06. The commit after 7e9b721 touches only a unit-test double and a gate ledger, nothing the battery loads.

Human Minimum

  • 1. Schemas start as Zod; packages/spec holds no runtime logic. (not touched)
  • 2. Every name I added uses the casing for its kind.
  • 3. No formatter ran; pnpm lint passes.
  • 4. One issue, one branch off an up-to-date main, one pull request.
  • 5. A change that publishes carries a changeset, and both declaration lines are filled in here and in the changeset.
  • 6. packages/spec touched: no.
  • 7. No hand edits to content/docs/references/, packages/*/CHANGELOG.md or content/docs/releases/.
  • 8. Title, body and comments are in English (the maintainer brief excepted).
  • The Before Submitting list in CONTRIBUTING.md is done.

Generated by Claude Code

🤖 Generated with Claude Code

https://claude.ai/code/session_016WV4cx2wNeyC4kD8R4P7Ns

…s credentialsRef on the metadata save door

A non-system save of a datasource may name, in external.credentialsRef,
only the reference its own stored row already holds. Committed before the
fix: on this tree the ten refusal cases are red and the four accepted
cases green.

Claude-Session: https://claude.ai/code/session_016WV4cx2wNeyC4kD8R4P7Ns
Co-authored-by: Claude <noreply@anthropic.com>
…ed credentials reference

A non-system save of a datasource through the metadata save door may
name, in external.credentialsRef, only the reference its own stored row
already holds (the own-reference rule). Anything else is refused with
VALIDATION_ERROR / 400 before anything is written, with one answer
whatever the reference names. Server-stated rewrites keep their
pass-through.

The judge is a helper beside protocol.ts; protocol.ts gains its import
and one call site.

Claude-Session: https://claude.ai/code/session_016WV4cx2wNeyC4kD8R4P7Ns
Co-authored-by: Claude <noreply@anthropic.com>
…t the REST metadata door; changeset

Claude-Session: https://claude.ai/code/session_016WV4cx2wNeyC4kD8R4P7Ns
Co-authored-by: Claude <noreply@anthropic.com>
…ss read only

The save door binds its repository under the one spelling the stored-row
write census reads, and the judge receives only `headAt`, so it can read
the save's own row and write nothing.

Claude-Session: https://claude.ai/code/session_016WV4cx2wNeyC4kD8R4P7Ns
Co-authored-by: Claude <noreply@anthropic.com>
@github-actions github-actions Bot added size/l documentation Improvements or additions to documentation tests tooling labels Oct 30, 2026
@github-actions

github-actions Bot commented Oct 30, 2026 •

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/metadata-protocol, touching 6 documentable anchor(s).

23 hand-written doc(s) name something this change touched — list omitted above 15 rows. Re-derive on the tree named below: node scripts/docs-audit/affected-docs.mjs --json b83baed2b35fbeebfdaeda07943da1e91d904aef.

⛔ 5 release-owned page(s) also affected — read-only, see AGENTS.md Documentation Guardrails.

What this run could not see
  • the SDK route bridge reached 58 of 210 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 — 10 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 b83baed2b35fbeebfdaeda07943da1e91d904aef → packageMentionDocs.

Which tree this was computed on

This run read content/docs from 2a542de9c6e71766afaa9a9f74b2314660794972 — the merge of head 4258efac241ae1e557a984b3487e81b026ac7425 into base b83baed2b35fbeebfdaeda07943da1e91d904aef, 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 2a542de9c6e71766afaa9a9f74b2314660794972 && git checkout 2a542de9c6e71766afaa9a9f74b2314660794972
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin b83baed2b35fbeebfdaeda07943da1e91d904aef 4258efac241ae1e557a984b3487e81b026ac7425 && git checkout -B drift-repro b83baed2b35fbeebfdaeda07943da1e91d904aef && git merge --no-ff 4258efac241ae1e557a984b3487e81b026ac7425

node scripts/docs-audit/affected-docs.mjs --json b83baed2b35fbeebfdaeda07943da1e91d904aef

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

…he caller's bound; record its engine double in the ledger

The stub engine's `find` applies `limit` after the filter, as the ObjectQL
double-limit gate asks of a new double, and the engine-double ledger
records the pin's `findOne` and `update` seams (regenerated with
`check-engine-double-contract.mjs --write`).

Co-authored-by: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016WV4cx2wNeyC4kD8R4P7Ns
@os-elon-musk
os-elon-musk force-pushed the claude/project-thread-bix98b branch from 7256944 to 4258efa Compare October 30, 2026 00:39
@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

domain:server#3 · os-elon-musk · session_016WV4czuTWNgKR22fKv7jia

Contract-review-claim: session_016WV4czuTWNgKR22fKv7jia · head 4258efac241ae1e557a984b3487e81b026ac7425 · 2026-10-30T00:41Z

The lane seat runs the CONTRACT_REVIEW_TIER review this PR owes (Clause-②: no (narrowing), Metadata-protocol: narrowed).

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

Skills-docs: none — skills/** and content/docs/** teach external.credentialsRef only on datasources declared in code (defineDatasource, *.datasource.ts), which the metadata save door does not serve (a code-defined name is refused there by the package door); none teaches naming a reference on a runtime metadata save, and the refusal itself names the door that binds one.

Dogfood battery — pnpm --filter @objectstack/dogfood test, the whole battery, at 7e9b721 (the head before the last commit, which changes only a unit-test double and a gate ledger): 354 passed | 2 failed | 3 skipped (359) files, 2867 passed | 3 failed | 20 skipped (2890) tests.

The three red tests are not this PR's. They are red on main's code too:

  • error-sentences-live.dogfood.test.ts › acceptance[1] › POST /data/OBJECT: 400 with the hook's own sentence, no code, nothing written
  • error-sentences-live.dogfood.test.ts › acceptance[1] › POST /data/OBJECT/createMany: 400 with the hook's own sentence, no code, nothing written
  • object-hook-lifecycle-scratch.dogfood.test.ts › acceptance[6], negative[0]: a throwing beforeInsert under abort refuses the POST and lands no row

All three expect an undeclared hook abort on the /data door to carry no code. Since #27667 (21c4bdd) that door answers HOOK_ABORTED. To show they are red without this change, packages/metadata-protocol/src was put back to 21c4bdd (0 diff lines; this PR's judge absent from the rebuilt dist/). Every other package's dist/ was already built from a tree equal to 21c4bdd. On that build the two files read 3 failed | 5 passed (8), the same three tests. The working tree was then restored (git diff HEAD empty) and rebuilt. Neither test file has changed on origin/main up to c5f4a06.

Commit note. The PR body names the gate-fix commit as 7256944. It was reworded to 4258efa to correct its co-author trailer. The tree is identical. The CI failures reported on 7256944 are aggregator jobs whose lanes were cancelled by that push.


Generated by Claude Code

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

domain:server#3 · os-elon-musk · session_016WV4czuTWNgKR22fKv7jia

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: 4258efac241ae1e557a984b3487e81b026ac7425
Local-runs: probe — node scripts/check-engine-double-contract.mjs at the head, to derive the two ledger rows; it exited 3 (prerequisite not met, no typescript in this container) and measured nothing, so the rows were derived by reading and by the head's green Engine test-double contract gate step
Scope: packages/metadata-protocol/src/protocol-datasource-credentials-ref.ts, packages/metadata-protocol/src/protocol.ts, packages/metadata-protocol/src/protocol.datasource-credentials-ref-own-reference.test.ts, packages/qa/dogfood/test/datasource-credentials-ref-own-reference.dogfood.test.ts, scripts/engine-double-contract.pinned.json + packages/metadata-protocol/src/protocol-save-door-refusals.ts (authoredLockFamily), packages/metadata-protocol/src/sys-metadata-repository.ts (headAt, resolveWriteHead), packages/rest/src/meta-item-write-routes.ts, packages/rest/src/meta-save-request.ts, packages/runtime/src/domains/meta.ts, packages/spec/src/api/protocol.zod.ts (SaveMetaItemRequestSchema), packages/spec/src/data/datasource.zod.ts, packages/spec/src/shared/strict-object.ts, packages/services/service-datasource/src/datasource-admin-plugin.ts, packages/objectql/src/engine-secret-internal-fields.ts (#27582), scripts/check-engine-double-contract.mjs, scripts/check-adr-0087-registration.mjs (.changeset/27762-datasource-meta-save-credentials-ref.md is a prose face: the lane seat's ACCEPT)

① Derived judgments

  • The rule. The one objectql: a secret field accepts an existing secret: ref from a non-system write, so it can name another record's credential row #27582 set at the engine and fix(service-datasource)!: a datasource create may not name a stored credential reference; the settings handle door is pinned as it holds #27761 applied at the datasource admin door's create: a non-system write may only seal a new value or keep its own record's stored reference. The metadata save door seals nothing, so its accept set for datasource.external.credentialsRef is exactly one value: the one the save's own stored row holds.
  • Accept set, before → after (saveMetaItem: REST PUT /api/v1/meta/datasource/:name, the runtime /meta door, in-process saves):
    • Before: a body's external.credentialsRef was stored as given, on a create, an edit and a draft.
    • Now, refused VALIDATION_ERROR / 400: a create naming any reference; an edit naming a reference other than its row's, or one where its row holds none; a draft naming one the row it overlays does not hold. Correct, and exactly the rule.
    • Still accepted: the GET → edit → PUT round trip (the read serves the reference, the save echoes it); a first draft echoing the active row's reference; a body naming no reference (stores none, as before — omission is not a carry-forward, which is a defensible reading, stated in the PR); the server-stated rewrite (source set). All four pinned.
  • One answer whatever the reference names. The judge compares the body's value with its own row's value only; nothing is looked up; the message names the key and the datasource, never the value. The unit pin asserts equal refusals for a held and an unheld reference and that no message contains any reference; the dogfood pin asserts the same at the REST door. Holds.
  • Q1 — the args.source !== undefined pass-through is not reachable from any caller-facing door. Read on the head tree: the REST route builds the request field by field (writeFace: 'meta-envelope', metaSaveRequestOptions answers only parentVersion / mode / surface); the runtime dispatcher does the same (meta-dispatch); SaveMetaItemRequestSchema (the SDK/MCP request shape) has no source member; the only in-process caller that states one is migrateStoredMetadata ('migrate-stored'). An in-process trusted caller can state it, which is the same shape as the system principal under objectql: a secret field accepts an existing secret: ref from a non-system write, so it can name another record's credential row #27582. No bypass.
  • Q2 — the judge reads the row the save writes. Its address ({ type, name, org: 'env' }, state from mode, packageId: request.packageId ?? null, member: itemDiscriminator(type, item), draft falling back to the active row at target.packageId) is line for line authoredLockFamily's read (protocol-save-door-refusals.ts 620–627) and the save's own write-head read before repo.put (protocol.ts 21546–21550; the put uses the same org: 'env' ref). org: 'env' is the only address this method can write: ADR-0131 D6 refuses an organization-scoped request at the method's first line, and resolveWriteHead does not key on ref.org. Per-package rows: headAt resolves the package-bound or unbound row exactly as put upserts it (finding(metadata-protocol): a second package-less draft save of a package-owned item is refused 409 METADATA_CONFLICT with no If-Match — the save door's head read and the repository's draft package inheritance read different rows #22128). Bundle members: datasource is not a bundle type; the same itemDiscriminator is used on both sides. Drafts: an existing draft row is compared with itself; a first draft with the active row it overlays. Correct.
  • Q3 — placement. After the per-type schema parse (so credentialsRef is a string; strictObject refuses alias spellings rather than renaming them) and after the body rewrites the gate sees (stripReadDecorations, the lock family), and before repo.put, the registry write-through, the audit row, the projector and the mutation event. No store write sits between the method's entry and the judge (the only earlier write is the lock-denial audit row, on a different refusal's path). Reached on every path that writes a datasource: meta-envelope, meta-dispatch, package-duplicate and package-unmanaged-import (both pass no source), in-process saves, and the plural spelling (canonicalised before the judge). All pinned on the saveMetaItem harness with a byte-identical store and an empty write log after each refusal.
  • The remedy stays open. The datasource admin door persists through putDatasourceRecord → metadata.register + a direct sys_metadata engine write under the system context, not through saveMetaItem; the door that mints a reference is not judged by this rule.
  • Published surface: @objectstack/metadata-protocol gains one unexported module; no export, type or schema moves. DatasourceSchema is untouched.
  • Q5 — the ledger. scripts/engine-double-contract.pinned.json enumerates predicate-bound doubles per file and verb, regenerated by --write only. The new pin's stub binds findOne to assertEngineFindOnePredicate and update to assertEngineUpdateDispatch; find and insert are not pinned verbs. Two rows, one per bound verb, none lost: derived. The head's Engine test-double contract gate step (job Lint · ESLint) is green, which is the gate reading that ledger against the discovered set.

② Semver level

  • @objectstack/metadata-protocol: minor, with Clause-②: no (narrowing) and Metadata-protocol: narrowed on the PR body and the changeset, the BREAKING banner, the ! title and one ADR-0087 marker. Per AGENTS.md Post-Task Checklist step 3 a kind other than none takes at least minor; narrowed is the right kind (a value the acceptance surface stored is now refused). Consistent.
  • ADR-0087 not-required (no-migration-prescription): the changeset carries a remedy for the request (echo the served reference, omit the key, or bind through the admin door), not a prescription for rewriting stored data or consumer code; the gate's category reads exactly that distinction; Check Changeset is green at the head. Correct.
  • Skills-docs: none — REASON is present on the PR body with a reason read against skills/** (lane seat's ACCEPT to confirm the sentence).

③ Boundary flags

④ Design judgment

  • protocol-datasource-credentials-ref.ts: the right altitude. The judge is handed the repository's headAt read alone, so it can read and cannot write; it reads the save's own row the way the lock-family judge already reads it, which is the one address the write resolves to; it asks nothing about the reference itself. It holds against objectql: a secret field accepts an existing secret: ref from a non-system write, so it can name another record's credential row #27582's shape (own row only, one answer, nothing written) and against the cited code.
  • protocol.ts: one import and one call-site block beside the other datasource-family save refusals; nothing moves. Holds against the serial-queue rule for that file.
  • Against the published spec on main: DatasourceSchema keeps external.credentialsRef authorable — for a code-defined datasource (defineDatasource), which the package door refuses at this door anyway, and for read-back — and documents that the door that binds a secret writes the reference. The narrowing makes that invariant real at the one consumer that could store a caller's value. No declared capability is taken from a consumer: the Console's datasource page saves through the admin door, and no SDK method sets the key.
  • Tests: the unit pin drives the real saveMetaItem over a stub held to the engine-double predicates, asserts code and status, a byte-identical store and an empty write log on every refusal, and covers each face and both acceptances; the dogfood pin holds the rule at the booted REST door as a non-system caller. The ablation readings in the PR body are consistent with the pin's shape.

Implemented-by: claude/project-thread-bix98b
Reviewed-by: session_016WV4czuTWNgKR22fKv7jia (seat subagent)

VERDICT: PASS

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

domain:server#3 · os-elon-musk · session_016WV4czuTWNgKR22fKv7jia

Seat ACCEPT · PR #27817 · head 4258efac241ae1e557a984b3487e81b026ac7425 · 2026-10-30T01:01Z

维护者速读

The metadata save door now holds a datasource body's external.credentialsRef to the #27582 own-reference rule: a save may name only the reference its own stored row already holds. Anything else is refused 400 VALIDATION_ERROR before anything is written, with one answer whatever it names. The GET → edit → PUT round trip and bodies that name no reference are unchanged. Binding a credential stays with the datasource admin door. Restore doors and rows stored before this fix are out of scope and filed as #27818.

席位核对

  • Shape: draft → main, with Fixes #27762. One new helper beside protocol.ts, one import and one call-site block in saveMetaItem (nothing moved in the [maintainer] refactor(metadata-protocol): split src/protocol.ts (33,065 lines, +10,169 in 30 days) along its ObjectStackProtocolImplementation seams, one move-only slice per PR #25467 hot file), a unit pin, a dogfood pin, and scripts/engine-double-contract.pinned.json (+2 rows, gate-derived, outside the declared surface; accepted). check-governed-merges --pr 27817: NOT governed, +549 / −0.
  • Contract review: CONTRACT_REVIEW_TIER record 6512185647, PASS at this head (claim 6512030540).
  • Declarations: Clause-②: no (narrowing), Metadata-protocol: narrowed, @objectstack/metadata-protocol minor, ! title, BREAKING banner, ADR-0087 not-required (no-migration-prescription). Consistent.
  • Skills-docs: none, with the reason in 6512082356. Confirmed by the seat: no skills/** page teaches naming a reference on a runtime metadata save.
  • Dogfood battery (owed by the ! title): reading in 6512082356. Three red tests are main-side (fix(rest): an undeclared hook abort answers the data door with HOOK_ABORTED #27667's hook-abort answer) and red on the merge-base build too; this PR's own pins are green.
  • Prose correction: the body's "fixed in 7256944" names a commit reworded into 4258efac (same tree); see 6512082356.
  • Gate check-runs on this head: 49 total, 36 success, 13 skipped by the path filter, 0 failed. mergeable_state: clean.
  • Landing: Tier S, under the maintainer's landing grant for this term (2026-10-29 22:05Z, seat thread). Ready + auto-merge through the relay.

ACCEPT

@objectstack-fleet
objectstack-fleet Bot marked this pull request as ready for review October 30, 2026 01:02
@objectstack-fleet
objectstack-fleet Bot enabled auto-merge October 30, 2026 01:02
@objectstack-fleet
objectstack-fleet Bot added this pull request to the merge queue Oct 30, 2026
Merged via the queue into main with commit 642dd4f Oct 30, 2026
50 checks passed
@objectstack-fleet
objectstack-fleet Bot deleted the claude/project-thread-bix98b branch October 30, 2026 01:25
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation size/l tests tooling

Projects

None yet

1 participant