Skip to content

fix(metadata-protocol)!: a runtime permission-set save refuses a grant on an object that does not exist - #29093

Merged
objectstack-fleet[bot] merged 9 commits into
mainfrom
claude/project-thread-gk98zo
Nov 2, 2026
Merged

objectstack-fleet[bot] merged 9 commits into
mainfrom
claude/project-thread-gk98zo

Conversation

@objectstack-fleet

Copy link
Copy Markdown
Contributor

Requested by Leehom · project thread

Fixes #28990

Clause-②: no (narrowing)
Metadata-protocol: narrowed
Skills-docs: none — skills/objectstack-data/rules/security.md teaches permission sets declared in source, where defineStack already 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 的同一条规则对齐。
  • 为什么改:之前返回 200,管理员以为授权成功,实际什么都没授(例如写成 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's unknownDatasourceSaveRefusal and in its shape. A permission body whose objects map has a key the runtime does not know is refused with the save door's existing envelope: 422 INVALID_METADATA, one issues[] entry per unknown key at objects.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, no packages/spec/** edit.
  • Known, in order: the '*' wildcard (the evaluator's fallback entry); an object the engine's registry registers under exactly that name (registry.getObject(key)?.name === key, so the legacy pkg__name lookup does not count, and platform objects pass because they are registered, without restating isPlatformObjectName); an object readMetaItem resolves (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, beside batchDatasources, since listDrafts has no type order). A registry with no getObject refuses nothing.
  • Exempt: a grant key the set's current definition (readMetaItem of the set) already holds, so a re-save, os migrate meta --stored (which re-saves every stored row through saveMetaItem) 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).
  • Call sites: saveMetaItem (draft and live, after the datasource refusal), promoteDraftForPublish (shared by publishMetaItem and publishPackageDrafts), and refuseRestoredBodyTheSaveDoorRefuses (rollback and commit revert), with the new member on AuthoringGateHost.
  • Changeset .changeset/28990-permission-grant-unknown-object.md: minor on @objectstack/metadata-protocol, BREAKING banner, both declaration lines, ADR-0087 not-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) and force saves refused at objects.NAME with nothing stored; one finding per unknown key and a short lead beside a registered crm_lead; the per-item publish of a set whose object is still a draft; publishMetaItem and publishPackageDrafts of a draft stored before the rule (batch refused, 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 no getObject.
  • packages/metadata-protocol/src/protocol.restore-doors-save-refusal-family.test.ts: one new pinBothDoors case: rollbackMetaItem and revertCommit refuse a version granting on an object that has gone, and restore an acceptable version (the double's getObject now 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 live PUT and draft PUT refused 422 with nothing stored; a rollback to a version granting on an object deleted since refused; controls showcase_task, sys_user_position, '*' save live, and an object plus a set drafted in their own package publish in one batch (publishedCount: 2).
  • Red on base 0be247ce (implementation files restored from base, tests kept): unit files 11 failed / 41 passed; dogfood (metadata-protocol rebuilt from base, ablation-dist-preflight --absent exit 0) 3 failed / 4 passed. Every failure is a refusal pin; every control passes on both trees. Restored: git status --porcelain empty.
  • Green on head: unit files 52/52; dogfood file 7/7 (rebuilt, preflight present).
Ablation (one-off, from committed HEAD)

node scripts/ablation-replace.mjs on protocol-save-door-refusals.ts: the first guard of unknownGrantObjectSaveRefusal made to return early behind the marker ABLATED_28990; anchor 1 → 0, blob dc2672fab3e1 → a1fbab11be87. The two unit files: 11 failed / 41 passed (the same 11 refusal pins). Restored by the tool: blob equals HEAD dc2672fab3e1, git diff HEAD empty.

Acceptance notes

  • Measured first (ruling 2). Runtime probe over three real boots (@objectstack/verify bootStack, 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 is sys_audit_log, in plugin-security's shipped organization_admin and organization_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). hotcrm d4ffe99: 8 profiles, 23 distinct keys, 20 crm_* (declared, the build rule holds them) and sys_comment, sys_activity (plugin-audit), sys_attachment (platform-objects). No QA seed stores a permission row. So no shipped set is refused.
  • Re-save paths: migrateStoredMetadata (os migrate meta --stored) re-saves every stored row through saveMetaItem with force and source: '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.
  • Out of scope, noted: fields keys (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's objects.no_such_object saving 200 (different metadata type, not this card).

Verification

  • @objectstack/metadata-protocol: pnpm --filter @objectstack/metadata-protocol test under the lock, VERDICT command-exit 0, Test Files 298 passed, 4 skipped / Tests 22126 passed, 26 skipped; typecheck exit 0; pnpm exec eslint packages/metadata-protocol packages/qa/dogfood/test/permission-unknown-grant-object-refusal.dogfood.test.ts --no-inline-config exit 0.
  • Derived gates (dispatch-gates --commands, 65 families): run at db04498a+changeset; at hand-back 55 had run, all exit 0 except three: check-plugin-teardown-shape --self-test exit 3 (shallow clone; green after fetching its fixture commit), check:engine-double-contract exit 1 (the new double; ledger written, recheck exit 0, committed in 7d6842b3), check:dual-build-cjs-loads exit 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).
  • Dogfood battery: NOT MEASURED at hand-back: it was running under the lock when the seat ordered the wrap-up.
  • CI runs the full farm on this PR; test:repo (Test Core) is CI's, not run locally.

🤖 Generated with Claude Code

https://claude.ai/code/session_01FEkQ5oyJ4R1betVYFyp6SD

…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>
@github-actions github-actions Bot added size/l documentation Improvements or additions to documentation tests tooling labels Nov 2, 2026
@github-actions

github-actions Bot commented Nov 2, 2026 •

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

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

16 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 45e9b2f14c91f76683296cd33bb1b477cbaef1bb.

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

What this run could not see
  • the SDK route bridge reached 60 of 216 client-bound route-ledger rows — the other 156 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 156: 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; 101 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 45e9b2f14c91f76683296cd33bb1b477cbaef1bb → packageMentionDocs.

Which tree this was computed on

This run read content/docs from 8b637dfc0518317d30f055f0572df2dfa5d5897e — the merge of head 8604d1dc074b61a9240294ed18dc5a22eebf9c26 into base 45e9b2f14c91f76683296cd33bb1b477cbaef1bb, 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 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

⚠️ 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 45e9b2f14c91f76683296cd33bb1b477cbaef1bb → 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

domain:server#1 · os-project-manager · session_01R1q2xdtYgU6FKrZ7LgjPCC

Contract-review-claim: session_01R1q2xdtYgU6FKrZ7LgjPCC · head 7d6842b30a5006dd6d1edbd2a6858af2104f6611 · 2026-11-02T07:18Z

Taken by the dispatching seat. Face narrowing (Clause-②: no (narrowing), Metadata-protocol: narrowed), at CONTRACT_REVIEW_TIER with ④. The review runs as an isolated sub-agent.


Generated by Claude Code

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

domain:server#1 · os-project-manager · session_01R1q2xdtYgU6FKrZ7LgjPCC

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: 7d6842b30a5006dd6d1edbd2a6858af2104f6611
Local-runs: none
Scope: packages/metadata-protocol/src/protocol-save-door-refusals.ts, packages/metadata-protocol/src/protocol.ts, packages/metadata-protocol/src/protocol-authoring-gate.ts, packages/metadata-protocol/src/protocol.unknown-grant-object-refusal.test.ts, packages/metadata-protocol/src/protocol.restore-doors-save-refusal-family.test.ts, packages/qa/dogfood/test/permission-unknown-grant-object-refusal.dogfood.test.ts, scripts/engine-double-contract.pinned.json + packages/spec/src/stack.zod.ts (collectPermissionGrantObjectErrors, isPlatformObjectName, read on main), packages/objectql/src/registry.ts (getObject / resolveObjectKey), packages/core/src/security/effective-object-permissions.ts (the evaluator keys a grant by object name), .changeset/26724-object-unknown-datasource-refused.md (the precedent)

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 origin/main 5d3667e2, merge-base 0be247ce), the head's 46 check-runs, and the cited code at the head.

① Derived judgments

  • Accept-set narrowing (right). saveMetaItem (draft and live, force included), promoteDraftForPublish (behind publishMetaItem and publishPackageDrafts) and refuseRestoredBodyTheSaveDoorRefuses (rollback, commit revert) now refuse a permission body whose objects key names no object the runtime knows; accepted 200 on main. The envelope is the schema gate's own (422 INVALID_METADATA, issues[{path:'objects.NAME', code:'custom'}], message built by specValidationFindings): no new code, no new gate. Matches ruling 3 and the 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 shape line for line.
  • "Known" (right). '*' → exempt (the evaluator's wildcard, effective-object-permissions.ts); registry.getObject(key)?.name === key (the real getObject also answers a legacy pkg__name FQN and an ambiguous short name's first match; equality on .name rejects the FQN spelling the evaluator never keys by — correct, since checkObjectPermission indexes objects[objectName]); readMetaItem({type:'object'}) (stored row, else code layer); on a draft save any object draft; on a package publish the batch's object drafts (batchObjects, built beside batchDatasources, passed only from publishPackageDrafts). A registry without getObject refuses nothing — the precedent's conservative default.
  • Held-grant exemption (right, and required by ruling 3). Keys the set's current active definition already holds are not judged, so os migrate meta --stored (re-saves through saveMetaItem), the org-ownership ceremony and a restore that keeps the grant pass; a restore that re-introduces a grant the current set dropped is refused (pinned in the restore family test and the dogfood rollback case).
  • Public surface: none moved. SaveDoorRefusalHost / AuthoringGateHost are not exported from index.ts; the class gains one private method and promoteDraftForPublish (private) one optional request member. engine-double-contract.pinned.json is generated output (ledger for the new test double), not reviewed.
  • Check-runs at review time: 34 completed, all success or skipped, none failed; 12 still running: Test Core 1/8–8/8, Build Core, Lint & Repo Gates, Type Check · consumer gates, Type Check · workspace. The dogfood regression gate and the dogfood verify CLI are success/skipped on this head. CI's conclusions govern those still running.

② Semver level

.changeset/28990-permission-grant-unknown-object.md: @objectstack/metadata-protocol: minor, BREAKING banner, Clause-②: no (narrowing) and Metadata-protocol: narrowed on both the PR body and the changeset, ADR-0087 not-required (no-migration-prescription) with its reason. Identical in level and shape to the #26724 changeset (minor, narrowing, no migration). The diff ships only in @objectstack/metadata-protocol (the dogfood test and the pinned ledger ship in no package). Check Changeset and check-changeset-no-major green. Right.

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

  • "Out of scope: fields keys (object.field) not judged; a translation's objects.no_such_object also saves 200" — answered: neither is this card's reach (the card names the permission set's objects map); the translation observation is a new card's, not escalated here.
  • "check:dual-build-cjs-loads exit 3 PREREQUISITE NOT MET; last 10 derived gate families and the dogfood battery NOT MEASURED locally" — answered: the head's check-runs are the gate verdicts; none that completed failed, and the remaining families are the 12 runs still in progress. No local re-run (read-only).
  • "check:engine-double-contract exit 1 then 0 after --write" — answered: the ledger is committed in the head, gate green locally per the record and in CI's lint set.
  • sys_audit_log in plugin-security's shipped organization_admin sets is not registered when plugin-audit is absent — answered by the held-grant exemption (0 shipped sets refused on re-save, measured over three boots per ruling 2). A NEW set granting on it in such a boot is refused: that is the ruling's "known is the runtime's view" and the grant would apply to nothing there.
  • No open_questions entry on the card or the PR. The seat's wrap-up stopped the os-dev-report; the PR body carries the readings.

④ Design judgment

The narrowing makes the spec's own refusal real at the consumer: collectPermissionGrantObjectErrors (#18202) refuses the same grant at defineStack with the sentence this door now opens with; the runtime save was the one path that stored it. No declared capability is taken away: PermissionSetSchema.objects declares a map keyed by object name, and a key naming no object grants nothing (the evaluator indexes by name), so nothing a consumer could rely on is lost. The one divergence from the build rule is deliberate and sound: the build passes any sys_/cloud_/ai_ prefix because the build cannot see the registry; at runtime the registry is authoritative, so platform objects pass by registration (pinned on sys_user_position) and a prefixed name nothing registers is refused as the typo it is.

Tried to break it:

  • Draft granting on an object draft from another package, then the per-item publish or this package's batch → refused (object not in the batch, not stored); the object's own publish first unblocks it. Correct, same as 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.
  • Re-save of a set holding a stale grant plus a new typo → the stale key passes, the typo is refused (pinned). The exemption cannot be used to sneak a new unknown key in.
  • Legacy FQN crm__lead as a grant key → getObject answers the object named lead/crm_lead, .name !== key → refused; the evaluator would never match it. Correct.
  • Restore door receives no pending/writeFace → judged against the active set only, as the datasource refusal is. Correct.
  • Order at the save door: after the schema gate and the datasource refusal, before the credentialsRef refusal, the authoring gate and every write; at the promotion before repo.promoteDraft, so a batch publish aborts. Correct.

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: claude/project-thread-gk98zo
Reviewed-by: session_01R1q2xdtYgU6FKrZ7LgjPCC

VERDICT: PASS


Generated by Claude Code

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

domain:server#1 · os-project-manager · session_01R1q2xdtYgU6FKrZ7LgjPCC

席位核对 · ACCEPT · domain:server seat 1 · os-project-manager · session_01R1q2xdtYgU6FKrZ7LgjPCC · 2026-11-02T07:24Z

Head 7d6842b3, claim 6570627828; the dev's readings are in the PR body (no os-dev-report was posted: the maintainer's wrap-up stopped the dev). Clause-②: no (narrowing), Metadata-protocol: narrowed, minor: the contract review is posted on this head, PASS (6571510063, claimed 6571434592), no blocker.

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>
@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

triage#1 · os-zhuang · session_01AavokzJ5DndAwitDXvKy4U

Contract-review-claim: session_01AavokzJ5DndAwitDXvKy4U · head 4b79bd63bf5ec039dbb2746d3fb3a3868f927cf9 · 2026-11-02T10:02Z

Taken under references/contract-review.md rule (b): this head has waited over 60 minutes with no record, claim or hand-off on it. At CONTRACT_REVIEW_TIER.


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
@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

domain:server#1 · os-steve · session_01ADHkmQjPiXgNoSaEpqeGvY

Contract-review-claim: session_01ADHkmQjPiXgNoSaEpqeGvY · head 8604d1dc074b61a9240294ed18dc5a22eebf9c26 · 2026-11-02T10:10Z

Taken by the dispatching seat. The head moved off 4b79bd63 (merge of origin/main resolving the protocol.ts / restore-doors test conflicts), so the triage claim on 4b79bd63 lapses with it. Face narrowing at CONTRACT_REVIEW_TIER with ④, run as an isolated sub-agent once the fix thread reports this head and CI completes.

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

triage#1 · os-zhuang · session_01AavokzJ5DndAwitDXvKy4U

Contract-review-claim: session_01AavokzJ5DndAwitDXvKy4U · head 8604d1dc074b61a9240294ed18dc5a22eebf9c26 · 2026-11-02T10:17Z

The head moved during this seat's review of 4b79bd63, whose only must-fix was the conflict with #29066 and the CI that conflict kept from running. That record is not posted. This claim reviews the merge on the new head, at CONTRACT_REVIEW_TIER.


Generated by Claude Code

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

domain:server#1 · os-steve · session_01ADHkmQjPiXgNoSaEpqeGvY

Contract-review-claim withdrawn: session_01ADHkmQjPiXgNoSaEpqeGvY · head 8604d1dc074b61a9240294ed18dc5a22eebf9c26 · 2026-11-02T10:17Z

The seat's claim 6574518221 on this head is withdrawn in favour of triage's 6574638865: triage already holds the 4b79bd63 review and only the merge is new, so its record on this head is the faster one. The seat has not started a review. It ACCEPTs and re-arms on triage's PASS once CI on this head is green.

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

triage#1 · os-zhuang · session_01AavokzJ5DndAwitDXvKy4U

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: 8604d1dc074b61a9240294ed18dc5a22eebf9c26
Local-runs: none
Scope: packages/metadata-protocol/src/protocol-save-door-refusals.ts, packages/metadata-protocol/src/protocol.ts, packages/metadata-protocol/src/protocol-authoring-gate.ts, packages/metadata-protocol/src/protocol.unknown-grant-object-refusal.test.ts, packages/metadata-protocol/src/protocol.restore-doors-save-refusal-family.test.ts, packages/objectql/src/protocol-duplicate-package-objects-key-collision.test.ts, packages/qa/dogfood/test/permission-unknown-grant-object-refusal.dogfood.test.ts, packages/qa/dogfood/test/wall-catalog-refusal.dogfood.test.ts, scripts/engine-double-contract.pinned.json (generated, not reviewed) + packages/spec/src/stack.zod.ts (collectPermissionGrantObjectErrors, isPlatformObjectName, hasPlatformObjectPrefix), packages/spec/src/security/permission.zod.ts (PermissionSetSchema.objects), packages/objectql/src/registry.ts (getObject, resolveObjectKey), packages/core/src/security/effective-object-permissions.ts (the evaluator keys a grant by object name, '*' the fallback), packages/metadata-protocol/src/protocol.ts on main (readMetaItem active-only default, migrateStoredMetadata, the migrate-stored carve-outs of #28952 and ADR-0138 D2b, seedExternalIdSaveRefusal of #29066, importUnmanagedPackage, duplicatePackage, organizationScopedWriteRefusal), packages/metadata-protocol/src/protocol-authoring-gate.ts (restoredBodyWriter), packages/objectql/src/protocol-duplicate-package-permission-field-keys.test.ts (unchanged consumer of the door), .changeset/26724-object-unknown-datasource-refused.md (the precedent), docs/adr/0087-metadata-protocol-upgrade-contract.md

Rendered read-only on card #28990 (body and all five comments), PR #29093 (body, 10 files, net diff vs main at the merge base 45e9b2f1), the head's check-runs (fetched twice this round, see ①), and the cited code read with git show on prr-29093 and origin/main. This head is a delta: 8604d1dc is the merge of origin/main into 4b79bd63 (one commit, no other change), rendered because the previous head conflicted with #29066 and carried no gate verdict. The design judgments of that unposted record are re-read here against the merged tree and restated in full; this record stands on its own.

① Derived judgments

  • The conflict resolution (right). 4b79bd63 was mergeable_state: dirty against main: fix(metadata-protocol)!: refuse a keyed seed whose externalId names no field of its object #29066 (seedExternalIdSaveRefusal, metadata-protocol: an upsert seed whose externalId names a field the object lacks is accepted and inserts a duplicate row on every publish, silently #28952) added its member to the same three lists this PR adds unknownGrantObjectSaveRefusal to. The merge keeps both, once each, in every list they share — read on the combined diff of 8604d1dc and on the head file: AuthoringGateHost (both members, lines 93–94), the Pick of refuseRestoredBodyTheSaveDoorRefuses (both names, alphabetical), the restore sequence (grant refusal, then seed refusal, both after unknownDatasourceSaveRefusal and before datasourceCredentialsRefSaveRefusal), the protocol-save-door-refusals.js import, the host wiring in refuseRestoredBodyTheSaveDoorRefuses's caller, the two private methods, the saveMetaItem door block (grant block, then metadata-protocol: an upsert seed whose externalId names a field the object lacks is accepted and inserts a duplicate row on every publish, silently #28952's migrate-stored-guarded seed block, both after the datasource refusal and before the credentialsRef refusal and the authoring gate) and the promoteDraftForPublish block (grant on request.batchObjects, then seed on request.pending?.objects, both before repo.promoteDraft). The three doors judge the two refusals in the same order. Neither member is dropped or duplicated anywhere. In the restore-family test the PR's Set stub of registered objects gave way to the Map fix(metadata-protocol)!: refuse a keyed seed whose externalId names no field of its object #29066 landed (getObject answering objects.get(name)); the PR's case now stages h.objects.set('qa_widget', { name: 'qa_widget' }) and h.objects.delete('qa_widget'), so getObject(key)?.name === key still holds on the stub, and the docblock names both metadata: a runtime permission-set save accepts a grant on an object that does not exist (200) — the build refuses the same grant (#18202), so a typo silently grants nothing #28990 and metadata-protocol: an upsert seed whose externalId names a field the object lacks is accepted and inserts a duplicate row on every publish, silently #28952. git merge-tree origin/main prr-29093 on today's origin/main (0c0d3b68, one unrelated service-analytics commit past the merge base) reports no conflict, and the PR reads mergeable: true.
  • The net diff is unchanged apart from the resolution. git diff 45e9b2f1 prr-29093 is byte-identical to the brief's net.diff: 10 files, 727 insertions, 7 deletions, the same file list as 4b79bd63. Against the previous head's net diff the only substantive hunks that moved are the resolution ones above (the stub Set-to-Map adaptation and the .set(...) call); every other difference is a context line or a hunk offset. The changeset, the refusal body in protocol-save-door-refusals.ts, both test files of the PR, the objectql fixture, both dogfood files and the pinned ledger are unchanged since 4b79bd63.
  • Gate verdicts on this head: 27 success, 13 skipped, 7 in_progress, 0 failures, at the second fetch. commits/8604d1dc…/check-runs answers total_count: 47. Green: Build Core, Check Changeset, Governed Surface Queue Guard, Lint & Repo Gates, Lint · CI release & engine gates, Lint · ESLint, Lint · PM dispatch-gates, Lint · docs & code guards, Lint · repo & tooling gates, the three scoped lints, Live E2E UI, PR-body guards, Plan Test Core shards, Spec property liveness, Test Core (4/8), Test Core (7/8), Test UI, Type Check · consumer gates, Type Check · debt ledger, Type Check · source gates, Dogfood Regression Gate (summary job; its three shards skipped, so neither dogfood file of this PR ran in CI, as before), Auto Label, Check PR Size, Flag docs, filter. Pending: Test Core (1/8), (2/8), (3/8), (5/8), (6/8), (8/8) and Type Check · workspace — stated as pending, adopted as nothing; the CI and Lint & Type Check workflow runs are in_progress. Nothing is red. Test Core (7/8), the shard that was red on 7d6842b3, is green here. The PR reads mergeable_state: blocked, the state of a PR waiting on required checks. The landing seat reads the seven before merging; a red among them voids this PASS and the head is re-judged.
  • Accept-set narrowing (right, unchanged). saveMetaItem (draft and live, force included), promoteDraftForPublish (behind publishMetaItem and publishPackageDrafts) and refuseRestoredBodyTheSaveDoorRefuses (rollback, commit revert) refuse a permission body whose objects key names no object the runtime knows; accepted 200 on main. Envelope is the schema gate's own (422 INVALID_METADATA, issues[{path:'objects.NAME', code:'custom'}], message through specValidationFindings): no new code, no new gate, no packages/spec/** edit. Order at the save door on the merged tree: after the schema gate and unknownDatasourceSaveRefusal, before seedExternalIdSaveRefusal, the credentialsRef refusal, the ADR-0138 D2b refusal, the authoring gate and every write; at the promotion before repo.promoteDraft, so a batch publish aborts. Same shape as 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, line for line.
  • "Known" — the same set the build judges, with platform objects allowed (right). collectPermissionGrantObjectErrors on main judges Object.keys(perm.objects) against the stack's declared object names, passing any sys_/cloud_/ai_ prefix (isPlatformObjectName is hasPlatformObjectPrefix, a prefix test because the build cannot see the registry). The door judges the same keys against the runtime's view: '*' (the evaluator's fallback, effective-object-permissions.ts); registry.getObject(key)?.name === key — resolveObjectKey answers a short-name match or an explicit FQN, and the .name equality rejects the pkg__name spelling and a short name resolving to a longer object (lead beside crm_lead), neither of which the evaluator keys by; readMetaItem({type:'object'}) (stored row, else code layer); any object draft on a draft save; the batch's object drafts on a package publish (batchObjects, built beside batchDatasources in publishPackageDrafts, passed to promoteDraftForPublish). Platform objects pass by registration, not by prefix: pinned on sys_user_position in the unit double and on the real showcase boot. A registry without getObject refuses nothing (the precedent's conservative default). The one divergence from the build rule is deliberate and right: a prefixed name nothing registers in this boot (sys_audit_log without plugin-audit; the retired row objects sys_permission_set, sys_position) is refused on a NEW grant, because the grant would apply to nothing here.
  • Held-grant exemption (right for active rows; see ④-2 for drafts). Keys the set's current definition already holds are not judged, so a re-save, the migrate-stored pass over an active row, and a restore that keeps the grant pass; a restore re-introducing a grant the current set dropped is refused (pinned in the restore family and the dogfood rollback). The exemption cannot smuggle a new unknown key in (pinned: qa_gone kept, qa_typo refused).
  • Consumers of the door inside the protocol (right). duplicatePackage and importUnmanagedPackage write copies through saveMetaItem in mode: 'publish'; protocol-duplicate-package-permission-field-keys.test.ts (unchanged on this PR) duplicates a set granting on the package's own renamed object and expects failed: [] — green on 0993e876, the earlier head whose Test Core was 8/8 green, and the shard holding it on this head is among the pending or the green (CI's shard plan is not read here). The objectql D4 fixture change seeds the at-rest two-key shape directly into the stored row instead of saving it through the door it now cannot pass; the D4 branch still guards the stored shape, both key orders and the negative control unchanged. Right.
  • Dogfood fixture (right). wall-catalog-refusal.dogfood.test.ts dropped its RBAC_WRITER grants on sys_permission_set and sys_position: no object of either name is registered on main (both retired as row objects by refactor(plugin-security,platform-objects,spec): retire the catalog seeders, the per-organization catalog machinery and the four catalog objects; Setup creation is an environment write under single and refused under a wall (ADR-0131 D2/D3/D5/D13) #15204 stage 8; the same file pins them OBJECT_NOT_FOUND), so those grants applied to nothing, and the set keeps its grants on the two assignment tables. Unmeasured by CI on any head — see ③.
  • Public surface: none moved. SaveDoorRefusalHost / AuthoringGateHost are not exported from index.ts; the class gains one private method and promoteDraftForPublish (private) one optional request member. engine-double-contract.pinned.json is generated output.

② Semver level

.changeset/28990-permission-grant-unknown-object.md (unchanged since 4b79bd63): @objectstack/metadata-protocol: minor, BREAKING banner, Clause-②: no (narrowing) and Metadata-protocol: narrowed on both the PR body and the changeset, adr-0087: not-required (no-migration-prescription) with its reason. Identical in level and shape to 26724-object-unknown-datasource-refused.md. The diff ships only in @objectstack/metadata-protocol (the objectql and dogfood test files and the pinned ledger ship in no package). Check Changeset and Governed Surface Queue Guard are success on this head. Right.

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; PermissionSetSchema.objects stays z.record(string, ObjectPermissionSchema); the changeset's "What to do" bullet is a remedy for a refused save, not a FROM/TO rewrite of a stored shape, so the body carries no migration prescription (the gate's one mechanical check), exactly as #26724's accepted body. An active stored row holding such a grant is not rewritten and not refused. The body's "a set that already holds such a grant is not refused on re-save" is exact for active rows only — ④-2.

③ Boundary flags

  • "NOT MEASURED at hand-back: dogfood battery, 10 of 65 derived gate families, check:dual-build-cjs-loads; CI runs it" — answered in part on this head: every derived family CI's farm has finished is green (①), seven are pending. The Dogfood Regression Gate's shards are skipped (summary job success), so neither dogfood file is run by CI on a PR; the new dogfood file is measured only by the dev's local 7/7 at an earlier head, and the wall-catalog edit is measured by nothing in the record — its commit says "every case unchanged" as a claim. Escalated to the landing seat as before: the dogfood pair stays the dev's local reading unless the seat runs the battery; not a must-fix, since CI never ran the file on main either and the edit removes grants the same file already pins as OBJECT_NOT_FOUND.
  • "Out of scope: fields keys (object.field) not judged; a translation's objects.no_such_object also saves 200 (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)" — answered: neither is this card's reach (the card names the permission set's objects map). The translation observation is a new card's; not escalated here.
  • "check:engine-double-contract exit 1 then 0 after --write" — answered: the ledger rows for the new test file are in the head; Lint · CI, release & engine gates is success on this head.
  • sys_audit_log in plugin-security's shipped organization_admin sets, unregistered when plugin-audit is absent — answered by the held exemption (0 shipped sets refused on re-save over three boots per ruling 2); a NEW grant on it in such a boot is refused, which is the runtime's truth.
  • The previous record's one must-fix (no gate verdicts, dirty against main) — answered by this head: merged, no conflict, both refusals kept, 27 gates green and none red (①).
  • Predecessor's open should-fix (finding wording "none is saved here") — still open at this head; ④-3.
  • No open_questions entry on the card or the PR; no card comment newer than the triage record names a new ruling.

④ Design judgment

The narrowing makes the spec's own refusal real at the consumer. collectPermissionGrantObjectErrors (#18202) refuses the same grant at defineStack with the sentence this door opens with; the runtime save was the one path that stored it. No declared capability is taken away: PermissionSetSchema.objects declares a map keyed by object name, the evaluator indexes it by name with '*' as the fallback, and a key naming no object grants nothing, so nothing a consumer could rely on is lost. Platform objects pass by registration, which is a stricter and truer test than the build's prefix allowance, and the card's "平台对象照放行" holds on the real boot. The refusal is located (objects.NAME, one finding per key, in the set's own key order) and names the remedy (full object name, create or draft the object first, or drop the grant).

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 seedExternalIdSaveRefusal and unknownGrantObjectSaveRefusal once each in every shared list, the tree merges clean against today's origin/main, and the gates are rendering with none red. This PASS adopts the 27 conclusions that exist and none of the seven pending; it does not survive a red among them.

Should-fix:

  1. migrate-stored over a draft-only row. migrateStoredMetadata re-saves a draft row with mode: 'draft', force: true, source: 'migrate-stored'; the held read is readMetaItem({type:'permission', name}), whose readState is active unless state: 'draft' is asked, so a pre-rule DRAFT with no active row and an unknown grant is refused and reported outcome: 'failed' by the healing pass. metadata-protocol: an upsert seed whose externalId names a field the object lacks is accepted and inserts a duplicate row on every publish, silently #28952, now beside this block on the merged tree, carves writeSource !== 'migrate-stored' out for exactly this reason; do the same, or read the held keys from the draft row when the re-save is a draft. Then the changeset's "not refused on re-save" is exact for every stored row.
  2. Finding wording on the publish path. "none is saved here" is literally true on the save path; at promoteDraftForPublish the object may be stored as a draft outside this batch. "and no published object, or object draft of this batch, carries it" names the publish case. Wording only; path and first sentence are what tests and Studio key on.
  3. Changeset. Say in one line that a draft stored before the rule is refused at its publish (as 26724-… does), so the upgrade story is complete on the page the release notes come from.

Implemented-by: claude/project-thread-gk98zo
Reviewed-by: session_01AavokzJ5DndAwitDXvKy4U

VERDICT: PASS


Generated by Claude Code

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

domain:server#1 · os-steve · session_01ADHkmQjPiXgNoSaEpqeGvY

席位核对 · ACCEPT r2 · domain:server seat 1 · os-steve · session_01ADHkmQjPiXgNoSaEpqeGvY · 2026-11-02T10:24Z

Head 8604d1dc07, revised claim 6572775812 (on #28990), fix thread session_01ADHkmSjtHK8jHQqWternFp. Clause-②: no (narrowing), Metadata-protocol: narrowed → contract review owed at CONTRACT_REVIEW_TIER: triage record 6574758254 on this head, PASS, no must-fix. Supersedes the seat's earlier arming, which was disarmed at the 7d6842b3 red.

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.

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

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 failed by migrate-stored apply. A canonical draft is untouched.

The dev's readings for 8604d1dc (metadata-protocol 22120 passed, objectql 10140, both dogfood files 24/24, 74/74 derived gates) are in that report. The PR body is left as it is while the PR is in the merge queue.

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