Repository navigation
Commit 7591c60
fix(metadata-protocol)!: a datasource restored or published from a stored body may name only the credentials reference its row holds now (#27860)
<!-- claude-projects-attribution: {"github_login":"os-elon-musk"} -->
_Requested by **Elon** · [project
thread](https://claude.ai/code/project/chan_01WedDzV26nniwxv7jC88B9D?thread=cmsg_01WedDzV26nniwxv7jC88B9DQpvKc4PUUMCDS64Cs8KqKi)_
Fixes #27818
domain:server#3 · os-elon-musk · session_016WV4d1qRCNJhpDgVXFoXkp (card
thread)
Clause-②: no (narrowing) — the restore doors and the draft publish
refuse a stored `datasource` body whose `external.credentialsRef` is not
the reference the active row they write into holds now, where they wrote
it back as stored before. Nothing is widened.
Metadata-protocol: narrowed
Skills-docs: none — `skills/**` and `content/docs/**` teach
`external.credentialsRef` only on datasources declared in code, as an
opaque handle the secret store mints; none teaches restoring or
publishing a stored datasource version or draft that names one, and the
refusal itself names the door that binds a credential.
## 维护者速读
- 改了什么:把已存的数据源定义重新变成生效版本的三个入口(按历史版本回滚 `POST
/api/v1/meta/datasource/:name/rollback`、按提交还原 `POST
/api/v1/packages/:id/commits/:commitId/revert` 的恢复分支、草稿发布 `POST
/api/v1/meta/datasource/:name/publish` 及整包发布),现在和 #27817 的保存入口用同一条规则:写回的
`external.credentialsRef` 只能是它要写入的那条生效记录现在持有的引用;不是就 400
拒绝,什么都不写,无论引用指向什么答复都相同,消息里不出现引用值。
- 为什么改:卡上第 1
项要求实测恢复入口。实测三个入口都会把历史版本或草稿里存着的引用原样变成生效值,包括别的数据源的引用、无人持有的引用、本数据源轮换前的旧引用,不符合
#27582 的规则。第 2 项(修复前已存的数据)的只读审计已作为卡上评论发出。
-
风险与回滚:拒绝发生在任何写入之前;写回的版本或草稿若带着现在这条记录持有的同一个引用,照常通过;不带这个键的照常写回(不绑定凭据,和保存时省略该键一样)。会被拒的两种正常情形:回滚或还原到一个引用已被轮换掉的旧版本,以及引用轮换后再发布之前存下的草稿;补救是省略该键重新保存,或用数据源管理入口重新绑定凭据。回滚只需
revert 本 PR。
-
先看:`packages/metadata-protocol/src/protocol-datasource-credentials-ref.ts`(判定本身,新增入口措辞),`packages/metadata-protocol/src/protocol-authoring-gate.ts`
中 `refuseRestoredBodyTheSaveDoorRefuses`
的一处调用,`packages/metadata-protocol/src/protocol.ts` 中
`promoteDraftForPublish` 的一处调用。
- 两分钟验证:`pnpm --filter @objectstack/metadata-protocol exec vitest run
src/protocol.datasource-credentials-ref-restore-doors.test.ts`,预期 21/21
通过。
- 需要你做的:无,席位落地;本 PR 是收窄(narrowing),落地前需一次契约复审,由席位安排。
## What changes
The rule is the one #27582 set and #27762 (PR #27817) applied to the
metadata save door: **a non-system write may only seal a new value or
keep its own record's stored reference.** The doors that make a stored
body the active row seal nothing, so the one reference the body they
write may name is the one the active row they write into holds now.
- **The judge is #27817's, not a copy.**
`datasourceCredentialsRefSaveRefusal` gains an optional `door: 'restore'
| 'publish'`, which changes only the refusal's words. The save door's
words are byte-identical. Each new door asks it as an active write
(`mode: 'publish'`) into the row it lands in, with no `source`, so the
comparison is with that row's current reference.
- **The restore doors.** `rollbackMetaItem` and `revertCommit`'s restore
limb both reach the restore path's save-door refusal family,
`refuseRestoredBodyTheSaveDoorRefuses` in `protocol-authoring-gate.ts`.
The judge is asked there, after `unknownDatasourceSaveRefusal`, in the
save door's order, against `binding`, the package binding of the active
row the version is restored into. That family runs inside
`restoreVersion` before the active row is read and before the put, so a
refused version writes nothing. `protocol.ts` gains one host line
(`getOverlayRepo`) and one docblock bullet.
- **The draft publish.** `promoteDraftForPublish`, shared by the
per-item publish and the package publish, re-asks the save door's
refusal family on the draft body. The judge is asked there after
`unknownDatasourceSaveRefusal`, against `promoteInto`, the binding the
active row is written under, before `repo.promoteDraft`. One block in
`protocol.ts`; nothing in it moves.
- **Who passes.** No server-stated restore or publish exists: the
restore doors are reached only from the REST rollback route and the
runtime package revert and rollback routes, so none passes through. The
save door's own rule and its `source` pass-through are unchanged.
- **Unchanged.** A version or draft naming the reference the row holds
now is written and keeps it. One naming no reference is written as it
stands, with no credential bound, as a save omitting the key is. Rows
already stored are not re-judged at rest.
## Why
The card's item 1, measured on `origin/main` fec60e0 with #27817's head
(4258efa) merged in: each of the three doors wrote a stored datasource
body as the active row with whatever `external.credentialsRef` it held.
The pin below was committed first and was red on that tree: **15 failed
| 6 passed (21)**, every refusal case at every door, with the six
accepted cases green.
Item 2, the read-only audit of rows stored before the rule, is on the
card (comment 6512713420): what `os secret orphans` covers, what it
misses, and the operator's reading. It proposes no migration and no
repair.
## Tests
-
`packages/metadata-protocol/src/protocol.datasource-credentials-ref-restore-doors.test.ts`
(new, 21 cases): the real `rollbackMetaItem`, `revertCommit` and
`publishMetaItem` over a stub engine that stores rows and history, the
double of `protocol.restore-doors-save-refusal-family.test.ts`.
- Refused at each door, with `code` and `status` asserted, the store
byte-identical afterwards and the row's reference kept: a body naming a
reference another datasource holds, one no row holds, the reference its
row held before and holds no longer, and one where its row holds none
now. The four answers are identical, each names the datasource and the
key, and none contains any reference. `revertCommit` reports it in
`failed[]` with `code: 'VALIDATION_ERROR'`.
- Accepted at each door: a body naming the reference its row holds now
keeps it; a body naming none stores none.
-
`packages/qa/dogfood/test/datasource-credentials-ref-restore-doors.dogfood.test.ts`
(new, 4 cases): the same rule at `POST
/api/v1/meta/datasource/:name/rollback` and `/publish` of a booted
stack, as a signed-in non-system caller — a refused rollback and a
refused publish, each with the active row unchanged, and one accepted
case at each door.
- `scripts/engine-double-contract.pinned.json`: the unit pin's stub
engine recorded (`--write`; 3 rows added, none lost).
## Acceptance notes
- **The draft publish is in scope.** The card names the restore doors;
the dispatch order adds "any other restore path that writes a
`datasource` row without passing `saveMetaItem`'s judge". The repository
has three write sites from the protocol: the save, the draft promotion
and the version restore. The draft promotion is the one left, so it is
judged too. The seat agreed before the fix.
- **A refused publish after a rotation.** A draft saved while its row
held one reference cannot be published once the row's reference has
moved. Saving the draft again with a different reference is refused too,
because the save door judges a draft against its own draft row. The
remedy the refusal names is to save the draft with the key omitted, or
discard it, and to bind the credential through the datasource admin
door.
- **The package rollback.** `rollbackToPackageCommit` runs
`revertCommit` per commit and counts a commit as reverted when it does
not throw. A refused item lands in that inner revert's `failed[]`, which
the package rollback does not surface. That is how every per-item
refusal of the restore limb already behaves; it is not changed here.
- **`protocol.ts`** gains one host line, one docblock bullet and one
call-site block; nothing in it moves or reflows (serial queue #25467).
The restore call site is in `protocol-authoring-gate.ts`, the restore
helper beside it.
- **`scripts/engine-double-contract.pinned.json`** is outside the
declared file surface: the gate requires the new pin's engine double in
the ledger.
- **Declarations:** `Metadata-protocol: narrowed` (three doors refuse a
body they wrote), 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 fd02314: **282 passed | 4
skipped (286) files, 21842 passed | 26 skipped (21868) tests**, exit 0.
- `pnpm exec turbo run build --concurrency=2
--filter=!@objectstack/docs` at fd02314: **114/114** tasks, exit 0.
- `node scripts/pm/dispatch-gates.mjs --ran` at fd02314: **73 derived,
73 run, 0 NOT-MEASURED, 0 UNRUN**, exit 0. `check-plugin-teardown-shape
--self-test` first exited 3 (its fixture commit is outside the shallow
clone); after fetching that commit it exited 0. `pnpm lint` exit 0.
- **Unit ablations** of the call sites, each through
`scripts/ablation-replace.mjs` (mutate, run the pin, restore; the
restore proven blob-equal to HEAD with `git diff HEAD` empty):
- the restore call site never throws → 10 failed | 11 passed (every
refusal at both restore doors);
- the restore call states a `source`, taking the pass-through → 10
failed | 11 passed;
- the publish call site never throws → 5 failed | 16 passed (every
refusal at the draft publish);
- the publish judges against the draft row instead of the active row it
writes into → 5 failed | 16 passed.
- **Dist ablation** at the REST doors: both call sites disabled,
`@objectstack/metadata-protocol` rebuilt,
`scripts/ablation-dist-preflight.mjs` proved both markers in `dist/`
(exit 0), the dogfood pin **2 failed | 2 passed (4)** (the two
refusals); both files restored (blob equal to HEAD), rebuilt, preflight
`--absent` exit 0 for both markers with the tree clean, the pin **4
passed (4)**.
- **The save door's words** are unchanged: the message the judge builds
with no `door` was compared with #27817's at 4258efa, byte for byte
(equal).
- **Dogfood battery** (`pnpm --filter @objectstack/dogfood test`, the
whole battery, at fd02314): **354 passed | 3 failed | 3 skipped (360)
files, 2871 passed | 4 failed | 20 skipped (2895) tests**. The four
failures are not this change's; they are red on `main`'s code too:
- `error-sentences-live.dogfood.test.ts` (2) and
`object-hook-lifecycle-scratch.dogfood.test.ts` (1): the hook-abort
answer, tracked as #27806;
- `platform-app-object-entry-views.test.ts` (1): a Setup entry names
`sys_exchange_rate`, which neither source the test reads declares (the
object landed in 194e66c).
- To show it, `packages/metadata-protocol/src` was put back to the merge
base 7b8bcfe (this change's runtime code absent from the rebuilt
`dist/`) and the three files read **4 failed | 56 passed (60)**, the
same four tests. The tree was then restored (`git diff HEAD` empty,
status clean) and rebuilt.
## Human Minimum
- [x] 1. Schemas start as Zod; `packages/spec` holds no runtime logic.
(not touched)
- [x] 2. Every name I added uses the casing for its kind.
- [x] 3. No formatter ran; `pnpm lint` passes.
- [x] 4. One issue, one branch off an up-to-date `main`, one pull
request.
- [x] 5. A change that publishes carries a changeset, and both
declaration lines are filled in here and in the changeset.
- [x] 6. `packages/spec` touched: no.
- [x] 7. No hand edits to `content/docs/references/`,
`packages/*/CHANGELOG.md` or `content/docs/releases/`.
- [x] 8. Title, body and comments are in English (the maintainer brief
excepted).
- [x] The *Before Submitting* list in CONTRIBUTING.md is done.
---
_Generated by [Claude
Code](https://claude.ai/code/session_016WV4d1qRCNJhpDgVXFoXkp)_
🤖 Generated with [Claude Code](https://claude.com/claude-code)
https://claude.ai/code/session_016WV4d1qRCNJhpDgVXFoXkp
---------
Co-authored-by: Claude <noreply@anthropic.com>1 parent 04740b7 commit 7591c60
7 files changed
Lines changed: 664 additions & 8 deletions
File tree
- .changeset
- packages
- metadata-protocol/src
- qa/dogfood/test
- scripts
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
| 1 | + | |
| 2 | + | |
| 3 | + | |
| 4 | + | |
| 5 | + | |
| 6 | + | |
| 7 | + | |
| 8 | + | |
| 9 | + | |
| 10 | + | |
| 11 | + | |
| 12 | + | |
| 13 | + | |
| 14 | + | |
| 15 | + | |
| 16 | + | |
| 17 | + | |
| 18 | + | |
| 19 | + | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
48 | 48 | | |
49 | 49 | | |
50 | 50 | | |
| 51 | + | |
51 | 52 | | |
52 | 53 | | |
53 | 54 | | |
| |||
114 | 115 | | |
115 | 116 | | |
116 | 117 | | |
117 | | - | |
| 118 | + | |
118 | 119 | | |
119 | 120 | | |
120 | 121 | | |
| |||
131 | 132 | | |
132 | 133 | | |
133 | 134 | | |
| 135 | + | |
| 136 | + | |
| 137 | + | |
| 138 | + | |
| 139 | + | |
| 140 | + | |
134 | 141 | | |
135 | 142 | | |
136 | 143 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
40 | 40 | | |
41 | 41 | | |
42 | 42 | | |
| 43 | + | |
| 44 | + | |
| 45 | + | |
| 46 | + | |
| 47 | + | |
| 48 | + | |
| 49 | + | |
| 50 | + | |
| 51 | + | |
| 52 | + | |
| 53 | + | |
| 54 | + | |
43 | 55 | | |
44 | 56 | | |
45 | 57 | | |
| |||
69 | 81 | | |
70 | 82 | | |
71 | 83 | | |
| 84 | + | |
| 85 | + | |
| 86 | + | |
| 87 | + | |
| 88 | + | |
| 89 | + | |
72 | 90 | | |
73 | 91 | | |
74 | 92 | | |
| |||
83 | 101 | | |
84 | 102 | | |
85 | 103 | | |
86 | | - | |
87 | | - | |
88 | | - | |
89 | | - | |
90 | | - | |
91 | | - | |
92 | | - | |
| 104 | + | |
| 105 | + | |
| 106 | + | |
| 107 | + | |
| 108 | + | |
| 109 | + | |
| 110 | + | |
| 111 | + | |
| 112 | + | |
| 113 | + | |
| 114 | + | |
| 115 | + | |
| 116 | + | |
| 117 | + | |
| 118 | + | |
| 119 | + | |
| 120 | + | |
93 | 121 | | |
94 | 122 | | |
95 | 123 | | |
| |||
0 commit comments