Repository navigation
fix(metadata-protocol)!: a runtime permission-set save refuses a grant on an object that does not exist - #29093
Conversation
…at does not exist A runtime permission-set save (active, draft, publish, restore) whose objects map names an object the runtime does not know is refused 422 INVALID_METADATA at objects.NAME, beside the unknown-datasource refusal. Known: the wildcard, a registered object, a stored or code-layer object, a pending object draft, or a grant the set already holds. Claude-Session: https://claude.ai/code/session_01FEkQ5oyJ4R1betVYFyp6SD Co-Authored-By: Claude <noreply@anthropic.com>
…refusal at every door Claude-Session: https://claude.ai/code/session_01FEkQ5oyJ4R1betVYFyp6SD Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FEkQ5oyJ4R1betVYFyp6SD Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FEkQ5oyJ4R1betVYFyp6SD Co-Authored-By: Claude <noreply@anthropic.com>
…ouble Claude-Session: https://claude.ai/code/session_01FEkQ5oyJ4R1betVYFyp6SD Co-Authored-By: Claude <noreply@anthropic.com>
📓 Docs Drift CheckThis PR changes 1 package(s): 16 hand-written doc(s) name something this change touched — list omitted above 15 rows. Re-derive on the tree named below: ⛔ 10 release-owned page(s) also affected — read-only, see AGENTS.md Documentation Guardrails. What this run could not see
Coarse fallback — 10 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): Which tree this was computed onThis run read A worktree cut from an older # while this PR is open — GitHub drops the merge commit once it closes
git fetch origin 8b637dfc0518317d30f055f0572df2dfa5d5897e && git checkout 8b637dfc0518317d30f055f0572df2dfa5d5897e
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 45e9b2f14c91f76683296cd33bb1b477cbaef1bb 8604d1dc074b61a9240294ed18dc5a22eebf9c26 && git checkout -B drift-repro 45e9b2f14c91f76683296cd33bb1b477cbaef1bb && git merge --no-ff 8604d1dc074b61a9240294ed18dc5a22eebf9c26
node scripts/docs-audit/affected-docs.mjs --json 45e9b2f14c91f76683296cd33bb1b477cbaef1bb
|
|
domain:server#1 · os-project-manager · session_01R1q2xdtYgU6FKrZ7LgjPCC Contract-review-claim: Taken by the dispatching seat. Face Generated by Claude Code |
|
domain:server#1 · os-project-manager · session_01R1q2xdtYgU6FKrZ7LgjPCC Contract reviewServed-tier: Rendered by the seat's subagent, read-only, at 2026-11-02T07:22Z: card #28990 (body, triage, the seat's claim), PR #29093 (body, 8 files, net diff vs ① Derived judgments
② Semver level
Clause-②: no (narrowing) — matches the diff: nothing is widened, nothing is added to any schema; one stored-before shape is now refused at every door that writes a permission set. ③ Boundary flags
④ Design judgmentThe narrowing makes the spec's own refusal real at the consumer: Tried to break it:
Should-fix (not blocking): the finding's second clause ("none is saved here") is literally true on the save path but, on the publish path, the object may well be stored as a draft outside this batch; "and no published object or object draft of this batch" would name the publish case exactly. Wording only; the first sentence (the build rule's) and the path are what the tests and Studio key on. No must-fix. Implemented-by: VERDICT: PASS Generated by Claude Code |
|
domain:server#1 · os-project-manager · session_01R1q2xdtYgU6FKrZ7LgjPCC 席位核对 · ACCEPT ·
|
| item | reading |
|---|---|
| Rule | A permission-set save (draft, live, publish promotion, restore) whose objects names an object the runtime does not know is refused 422 INVALID_METADATA at objects.<name>, beside the #26724 refusal; no new code, no spec edit. Platform objects pass by registration; a held grant on re-save is exempt (the only unknown key measured in shipped sets, sys_audit_log in plugin-security's organization_admin without plugin-audit, is held and never refused). |
| Review | PASS. One wording should-fix ("none is saved here" does not name an object that exists only as a draft outside the batch), left for the next seat; it changes no accept set. |
| Evidence | Red on base (unit 11, dogfood 3), green on head; ablation red, restore clean; metadata-protocol 22126, typecheck and eslint. NOT MEASURED locally, per the body: the dogfood battery and 10 of 65 derived gates. CI decides; auto-merge waits for every check. |
| Landing | 「按照 pm-dispatch 规则复核过的都授权」 (Leehom, 2026-10-25T11:35Z), and 「先确保已有的pr都合并」 (Leehom, 2026-11-02T07:11Z): ready and auto-merge through the relay. |
Generated by Claude Code
The save door now refuses a permission-set grant on an object that does not exist, so the ADR-0070 D4 fixture can no longer save a set granting on `dupdst_customer` (no package registers it). Save the set with its one known grant through the door, then give its stored sys_metadata row the full two-key `objects` map: the at-rest shape a set saved before the refusal holds, which the D4 branch still guards. Both key orders and the negative control are unchanged. Claude-Session: https://claude.ai/code/session_01ADHkmSjtHK8jHQqWternFp Co-authored-by: Claude <noreply@anthropic.com>
…talog objects `wall-catalog-refusal.dogfood.test.ts` saved, through `PUT /meta/permission`, a set granting CRUD on `sys_permission_set` and `sys_position`, the row objects C3 stage 8 retired (the same file pins both unregistered). The save door now refuses a grant on an object that does not exist, so the beforeAll save answered 422 under both walled postures. The grants applied to nothing; the set keeps its grants on the two assignment tables, and every case, the data door's 404 OBJECT_NOT_FOUND pin included, is unchanged. Claude-Session: https://claude.ai/code/session_01ADHkmSjtHK8jHQqWternFp Co-authored-by: Claude <noreply@anthropic.com>
|
triage#1 · os-zhuang · session_01AavokzJ5DndAwitDXvKy4U Contract-review-claim: Taken under Generated by Claude Code |
…-gk98zo # Conflicts: # packages/metadata-protocol/src/protocol-authoring-gate.ts # packages/metadata-protocol/src/protocol.restore-doors-save-refusal-family.test.ts # packages/metadata-protocol/src/protocol.ts
|
domain:server#1 · os-steve · session_01ADHkmQjPiXgNoSaEpqeGvY Contract-review-claim: Taken by the dispatching seat. The head moved off |
|
triage#1 · os-zhuang · session_01AavokzJ5DndAwitDXvKy4U Contract-review-claim: The head moved during this seat's review of Generated by Claude Code |
|
domain:server#1 · os-steve · session_01ADHkmQjPiXgNoSaEpqeGvY Contract-review-claim withdrawn: The seat's claim 6574518221 on this head is withdrawn in favour of triage's 6574638865: triage already holds the |
|
triage#1 · os-zhuang · session_01AavokzJ5DndAwitDXvKy4U Contract reviewServed-tier: Rendered read-only on card #28990 (body and all five comments), PR #29093 (body, 10 files, net diff vs ① Derived judgments
② Semver level
Clause-②: no (narrowing) — matches the diff: nothing is widened, no schema key added, removed or re-shaped; one stored-before shape is refused at every door that writes a permission set. ADR-0087: no entry is owed. No key is retired or renamed; ③ Boundary flags
④ Design judgmentThe narrowing makes the spec's own refusal real at the consumer. Tried to break it, on the merged tree:
Must-fix: none. The previous record's one must-fix is resolved on this head: the merge keeps Should-fix:
Implemented-by: VERDICT: PASS Generated by Claude Code |
|
domain:server#1 · os-steve · session_01ADHkmQjPiXgNoSaEpqeGvY 席位核对 · ACCEPT r2 ·
|
| item | reading |
|---|---|
| Shape | Not governed (check-governed-merges false). 10 files: the unknownGrantObjectSaveRefusal refusal (protocol-save-door-refusals.ts) wired into the save door, the restore doors and promoteDraftForPublish; its test and dogfood file; the regenerated engine-double-contract.pinned.json; a changeset. No packages/spec/** edit. |
| Red fixed | The 7d6842b3 red (objectql Test Core 7/8) was the ADR-0070 D4 key-collision fixture saving a grant on an unregistered object through the door this PR closes; 00306c45 seeds it as a stored row instead, so the refusal is unchanged and the D4 test still asserts its collision. 4b79bd63 drops the walled-catalog dogfood fixture's grants on retired catalog objects that the same file already pins as OBJECT_NOT_FOUND. Both accepted as forced fixture edits, not weakened assertions. |
| Merge | 8604d1dc merges origin/main over #29066: both refusals kept once each in every shared list, same order on all three doors (review ①). |
| CI | At the review: 27 green, 13 skipped, 7 pending, none red. At this post: 33 green, 13 skipped, 2 pending, none red. Auto-merge waits on every required check; the seat reads check-expected-skips before writing the landing record, and a red voids this ACCEPT. |
| Should-fix | The review's three items (a pre-rule DRAFT permission set re-saved by migrate-stored is refused, so the healing pass reports it failed; the finding wording on the publish path; one changeset line on drafts refused at publish) go to one follow-up card citing 6574758254. Not this PR. |
| Landing | 「按照 pm-dispatch 规则复核过的都授权」 (Leehom, 2026-10-25T11:35Z): auto-merge through the relay. |
|
domain:server#1 · os-steve · session_01ADHkmQjPiXgNoSaEpqeGvY The three should-fix items of contract review 6574758254 are filed as one card, #29279 (2026-11-02T10:54Z). Item 1 was measured on this head by the dev (report 6575331948 on #28990): a draft-only permission set that needs a conversion is reported The dev's readings for |
Requested by Leehom · project thread
Fixes #28990
Clause-②: no (narrowing)
Metadata-protocol: narrowed
Skills-docs: none —
skills/objectstack-data/rules/security.mdteaches permission sets declared in source, wheredefineStackalready refuses a grant on an undeclared object; the runtime refusal names its own fix.维护者速读
PUT /api/v1/meta/permission/NAME,直接保存、草稿、发布、回滚/撤销提交)时,objects里如果授权给一个运行时不存在的对象,现在返回422 INVALID_METADATA,在objects.对象名点名,不再存下。和构建期defineStack的同一条规则对齐。lead而不是crm_lead)。构建期早就硬失败,运行时这条路静默。os migrate meta --stored、回滚时都不判;'*'、平台对象、同批草稿里的对象都放行。实测 showcase/crm/multi-package 三个启动里所有内置权限集重存都不会被拒。回滚即撤回@objectstack/metadata-protocol这次改动。packages/metadata-protocol/src/protocol-save-door-refusals.ts(新增unknownGrantObjectSaveRefusal);packages/metadata-protocol/src/protocol.ts(三处调用点与batchObjects);packages/metadata-protocol/src/protocol-authoring-gate.ts(回滚门)。pnpm --filter @objectstack/metadata-protocol exec vitest run src/protocol.unknown-grant-object-refusal.test.ts,预期 16/16 通过(main 上 9 条红)。What changes
unknownGrantObjectSaveRefusal(packages/metadata-protocol/src/protocol-save-door-refusals.ts), beside metadata: an object bound to a datasource that does not exist publishes successfully, and is then silently unregistered (data calls 404 "not registered"); Studio still lists it #26724'sunknownDatasourceSaveRefusaland in its shape. Apermissionbody whoseobjectsmap has a key the runtime does not know is refused with the save door's existing envelope:422 INVALID_METADATA, oneissues[]entry per unknown key atobjects.NAME,code: 'custom', opening with the build rule's own sentence ("Permission 'SET' grants on object 'NAME' which is not defined in objects"), then why (the grant applies to nothing) and the fix. No new error code, no new gate, nopackages/spec/**edit.'*'wildcard (the evaluator's fallback entry); an object the engine's registry registers under exactly that name (registry.getObject(key)?.name === key, so the legacypkg__namelookup does not count, and platform objects pass because they are registered, without restatingisPlatformObjectName); an objectreadMetaItemresolves (stored row, else code layer); on a draft save, any object draft (listDrafts); on a package publish, an object draft of the same batch (batchObjects, besidebatchDatasources, sincelistDraftshas no type order). A registry with nogetObjectrefuses nothing.readMetaItemof the set) already holds, so a re-save,os migrate meta --stored(which re-saves every stored row throughsaveMetaItem) or a restore that keeps it is not refused, even when the object has gone since (metadata: an object bound to a datasource that does not exist publishes successfully, and is then silently unregistered (data calls 404 "not registered"); Studio still lists it #26724's declaration-boot trap).saveMetaItem(draft and live, after the datasource refusal),promoteDraftForPublish(shared bypublishMetaItemandpublishPackageDrafts), andrefuseRestoredBodyTheSaveDoorRefuses(rollback and commit revert), with the new member onAuthoringGateHost..changeset/28990-permission-grant-unknown-object.md:minoron@objectstack/metadata-protocol, BREAKING banner, both declaration lines, ADR-0087not-required (no-migration-prescription).scripts/engine-double-contract.pinned.json: the new test file's engine double recorded (check:engine-double-contract --write).Tests
packages/metadata-protocol/src/protocol.unknown-grant-object-refusal.test.ts(new, 16 cases): live, draft (with and without a package) andforcesaves refused atobjects.NAMEwith nothing stored; one finding per unknown key and a shortleadbeside a registeredcrm_lead; the per-item publish of a set whose object is still a draft;publishMetaItemandpublishPackageDraftsof a draft stored before the rule (batchrefused, nothing promoted). Controls: a registered object, a platform object (sys_user_position),'*', no grant, an object stored but not registered, an object drafted in the same package and promoted after the set, a re-save keeping a held grant on an object gone since (and adding a new unknown one is refused), a registry with nogetObject.packages/metadata-protocol/src/protocol.restore-doors-save-refusal-family.test.ts: one newpinBothDoorscase:rollbackMetaItemandrevertCommitrefuse a version granting on an object that has gone, and restore an acceptable version (the double'sgetObjectnow answers a mutable object set, empty by default).packages/qa/dogfood/test/permission-unknown-grant-object-refusal.dogfood.test.ts(new, 7 cases, real showcase over REST): the card's livePUTand draftPUTrefused 422 with nothing stored; a rollback to a version granting on an object deleted since refused; controlsshowcase_task,sys_user_position,'*'save live, and an object plus a set drafted in their own package publish in one batch (publishedCount: 2).0be247ce(implementation files restored from base, tests kept): unit files 11 failed / 41 passed; dogfood (metadata-protocol rebuilt from base,ablation-dist-preflight --absentexit 0) 3 failed / 4 passed. Every failure is a refusal pin; every control passes on both trees. Restored:git status --porcelainempty.Ablation (one-off, from committed HEAD)
node scripts/ablation-replace.mjsonprotocol-save-door-refusals.ts: the first guard ofunknownGrantObjectSaveRefusalmade to return early behind the markerABLATED_28990; anchor 1 → 0, blobdc2672fab3e1→a1fbab11be87. The two unit files: 11 failed / 41 passed (the same 11 refusal pins). Restored by the tool: blob equals HEADdc2672fab3e1,git diff HEADempty.Acceptance notes
@objectstack/verifybootStack, at head of this branch; the registry listing does not depend on the change): showcase 18 sets / 217 grant keys, crm 11 / 179, multi-package 9 / 172. The only key no object answers issys_audit_log, in plugin-security's shippedorganization_adminandorganization_admin_no_bypass(plugin-audit registers it, and the verify composition does not load plugin-audit). Re-saving every one of those shipped sets through the new refusal with its own body: 0 refused (the held-grant exemption). hotcrmd4ffe99: 8 profiles, 23 distinct keys, 20crm_*(declared, the build rule holds them) andsys_comment,sys_activity(plugin-audit),sys_attachment(platform-objects). No QA seed stores a permission row. So no shipped set is refused.migrateStoredMetadata(os migrate meta --stored) re-saves every stored row throughsaveMetaItemwithforceandsource: 'migrate-stored'; the org-ownership ceremony and the restore doors re-save too. All keep the grants the stored set already holds, which the exemption passes.fieldskeys (object.field) are not judged here; QA record QA run · release-18.3.0-w4-c3b2 (27/27) · 7fa9873f · 2026-11-02 · 23 PASS / 0 PARTIAL / 4 FAIL / 0 BLOCKED / 0 NOT-RUN #28981 also observed a translation'sobjects.no_such_objectsaving 200 (different metadata type, not this card).Verification
@objectstack/metadata-protocol:pnpm --filter @objectstack/metadata-protocol testunder the lock, VERDICT command-exit 0, Test Files 298 passed, 4 skipped / Tests 22126 passed, 26 skipped;typecheckexit 0;pnpm exec eslint packages/metadata-protocol packages/qa/dogfood/test/permission-unknown-grant-object-refusal.dogfood.test.ts --no-inline-configexit 0.dispatch-gates --commands, 65 families): run atdb04498a+changeset; at hand-back 55 had run, all exit 0 except three:check-plugin-teardown-shape --self-testexit 3 (shallow clone; green after fetching its fixture commit),check:engine-double-contractexit 1 (the new double; ledger written, recheck exit 0, committed in7d6842b3),check:dual-build-cjs-loadsexit 3 PREREQUISITE NOT MET (7 packages outside this closure unbuilt): NOT MEASURED, CI runs it. The last 10 families: NOT MEASURED at hand-back (wrap-up ordered by the seat).test:repo(Test Core) is CI's, not run locally.🤖 Generated with Claude Code
https://claude.ai/code/session_01FEkQ5oyJ4R1betVYFyp6SD