Skip to content

feat(metadata-protocol,objectql): open the declared override for a package's action (ADR-0146 D9) - #30497

Merged
objectstack-fleet[bot] merged 8 commits into
mainfrom
claude/project-thread-yd96eq
Nov 5, 2026
Merged

objectstack-fleet[bot] merged 8 commits into
mainfrom
claude/project-thread-yd96eq

Conversation

@objectstack-fleet

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

Copy link
Copy Markdown
Contributor

domain:server#3 · os-bill · session_01Utp6CYKQNa91PVtcbE6bGe

Requested by Bill · project thread

Fixes #30065

Clause-②: yes (widening)
Metadata-protocol: added
Skills-docs: follow-up #30505

维护者速读

  1. 改了什么:包内动作可以在配对的 .custom 包里用 overrides 声明覆盖,和 flow 一样走 ADR-0146 D9。原来这类保存一律 403 "not yet available",现在放行。
  2. 三个条件:每一种读(含对象读的 actions、运行门读的 getSchema、分层读 effective)都按基包的名字返回覆盖;删除覆盖后各处读回基包;开关账本按名字一行管住当前武装的定义。
  3. 脚本 body 换绑:引擎的 handler 改成栈,覆盖压在基包 handler 上面,恢复原版后基包 handler 还在,不需要重新注册。有一条专门的测试钉住"覆盖、恢复、再运行,跑的是基包 body"。
  4. 验收 3(席位选 A):覆盖不能扩大谁能运行。丢掉 requiredPermissions 里的能力、ai.exposed 从关到开、去掉 ai.requiresConfirmation: true,都会以新规则 declared_action_widens_access 返回 403 NOT_OVERRIDABLE。保存、发布、os validate 和安装门共用这一道闸门。
  5. 仍然允许的:行为字段整份替换,所以覆盖可以换掉实际运行的内容,包括在系统提权下运行的 body,但只对原本就能运行这个动作的人生效。这一条请走合同级复核。
  6. 范围:不改 packages/spec、skills/** 和其他受管面,ui/** 只读;skills 文档的补改由席位另开卡。changeset 对四个包都是 minor。

What changes

ADR-0146 D9 lists action as overridable "once its door is cut". This PR cuts that door, at the grain of the flow override (#28412).

Write gate (packages/metadata-protocol/src/protocol-packaged-base-refusal.ts)

  • action joins CONDITIONS_DELIVERED. A declared override from the base's paired .custom is admitted by the save, publish and install doors, os validate and os build. Only the pair declares: a dependent's declaration and the pair's undeclared same-named save stay refused, and their remedies are unchanged.
  • New rule declared_action_widens_access (403 NOT_OVERRIDABLE). An override may add a gate and never remove one (ADR-0066 D4). Refused when the override:
    • drops a capability the base's requiredPermissions names;
    • sets ai.exposed: true on an action the base keeps off the agent surface;
    • drops the base's ai.requiresConfirmation: true.
      The refusal names each widened gate. actionOverrideWidening(baseItem, overrideItem) is exported.
  • OverrideWriteClosure.baseItemOf lets each door hand the judge the base's own item: the protocol doors (from the shipped artifact), the CLI walk (from the base in the stack) and the managed install (from the base's held items).
  • The action switch route in the packaged-base refusal sentences is spelled POST /api/v1/actions/_activation/:object_or_global/:action. With the longer route, the new declared-save sentence for an 88-character action name ran to 520 characters and crossed the REST door's 500-character client-message bound. It is now 487.

Reads (protocol-lock-state.ts)

  • declinesStoredRow serves the pair's declared action row, so the layered read's effective (and the metadata service's by-name read) answer the override. The other reads already went through the served-override resolver once the type was open. The action keeps its bare registry entry, as a flow does, so SEALED_ROW_SERVED_OVERRIDE_TYPES is unchanged.

Script body rebinding (packages/objectql)

  • ObjectQL#registerActionOverride(objectName, actionName, handler, packageName) binds a handler over the one registered under the key and keeps it beneath (ActionHandlerEntry.beneath). Re-binding the same package's override keeps the same handler beneath.
  • removeActionsByPackage(packageName) removes the package's entries from each key's stack, so removing an override binds the handler beneath again. Removing the base under an armed override keeps the override.
  • resyncAuthoredActions no longer filters out a stored row that is the declared .custom override of an artifact-shipped action on the same object (judged by the same isServedOverrideRowOf the reads use). Such a row is bound with registerActionOverride, so one resync rebinds the name in either direction. A row whose base the registry does not hold yet is judged by its own declaration (isServedOverrideRowOf(row, overrides.package)) and bound as an override too.
  • registerAction of another package's handler under a key an override holds takes the place beneath the override (the entry is marked override) instead of replacing it. This is what keeps condition 2 after a restart for a marketplace-installed base: its handlers are bound by the install plugin's kernel:ready rehydrate (bindAppArtifactHandlers → registerAction(..., 'app:<base>')), which runs after ObjectQLPlugin's kernel:ready resync bound the override and announces no metadata:reloaded. Before this, the rehydrate replaced the override's handler, so every read served the override while executeAction ran the base's body.

Why

#30065: a customer should be able to rename, hide or adjust a button a package ships without cloning it. Before this PR the declared override was refused. And merely opening the type (measured with a temporary flip of the set) would have left two gaps. First, the layered read kept serving the base. Second, a script action would have kept running the base's body: the resync filtered the override row out as artifact-shipped, and binding it with registerAction would have lost the base's handler on restore. A third problem showed up in the same measurement: an override carrying requiredPermissions: [] was served as-is, so the override could remove ADR-0066 D4's capability gate from the base's button. That is what Acceptance 3 asked about. The seat chose to refuse the widening at the write gate.

What an override can still change: every behaviour field (label, type, target, body, params, visible and the rest) is replaced whole, so an override can change what runs, including a body that runs system-elevated. It only does so for people who could already run the action: the gates are the base's, or stricter.

Boot order: a base's handlers may be bound before the resync (a code-loaded package, in AppPlugin.start) or after it (a marketplace-installed base, in the install plugin's kernel:ready rehydrate). Both orders end with the override on top and the base's handler beneath it, and a re-bind of the base's handlers while the override is armed does too.

Out of scope (follow-ups)

All four are filed as #30542 (the two below, plus the self-declared binding before the base registers and the slot-beneath log level from the delta review).

  • The install door judges a re-install of .custom against the base's items as getMetaItems({ type, packageId: base }) serves them when the base is not in the same delivery. Once an override is armed, that read serves the override under the base's envelope, so a v2 that relaxes a v1 tightening back to the base's own gates is refused at install although the protocol door admits it. Over-refusal only (fails closed).
  • The protocol door matches an action override's base by name alone (ITEM_KEY_DISCRIMINATORS has no action row), while os validate and the install door key it <objectName>:<name>. An override may therefore move a base action to another object at the save door. It widens nothing (the handler is not rebound and the base's gates ride the declaration), but the doors disagree.

Verification

Tests at the flow override's grain:

  • packages/objectql/src/protocol-flow-custom-override.test.ts, new describe "[Action customization: open the declared override for a package's action, so a customer can rename, hide or adjust a button #30065] a declared .custom action override is the packaged action, at every door". It uses a real protocol over a real ObjectQL engine with a memory driver and covers:
    • condition 1: every read (by-name, by-name under the base, execution, layered effective, both lists, the object read's actions, the object list, getSchema) serves the override under the base's _packageId, and the run doors read the override's added capability;
    • condition 2: deleting the override serves the base everywhere;
    • condition 3: setActionActive({ name, packageId: BASE, active: false }) turns the armed override off, the object read's disabledActions lists it, and the switch survives the restore;
    • widening refused (code + status), nothing stored;
    • a dependent's declaration refused and, laid down directly, never served;
    • the pair's undeclared save refused, with a remedy that names the declaration and never a clone.
  • packages/objectql/src/plugin-authored-actions.test.ts, new describe on a real engine with the base handler registered as app:<id>. It pins override → restore → run again (the base body runs); a base bound after the override's resync, as a marketplace base's rehydrate binds it (the override runs, then a restore runs the base body); the base's handlers re-bound while the override is armed; re-binding; undeclared and dependent rows never binding, before and after the base is registered, and an other-base row never binding once the base is registered (before that, see the follow-up objectql: a declared .custom action override has four door and boot-order loose ends left by PR #30497 #30542); a body-less override; and the handler stack itself.
  • packages/metadata-protocol/src/protocol.packaged-base-refusal.test.ts: admitted narrowing, three widening refusals, a publish of a seeded widening draft refused.
  • packages/cli/src/utils/override-declarations.test.ts and packages/cloud-connection/src/marketplace-install-local-override-gate.test.ts: the action declaration is admitted, and a widening is refused at os validate and at the managed install (403, nothing written).
  • packages/rest/src/rest-meta-packaged-action-permission-refusal.test.ts: the 88-character name sentence stays under the bound, with the declare remedy.

Runs (local): full suites of metadata-protocol (22414 passed) and objectql (10998 passed at the rework head); targeted cli, cloud-connection and rest suites passed; tsc --noEmit clean on metadata-protocol, objectql and cloud-connection; eslint clean on the changed files; dispatch-gates --ran 65 of 65 run, none unmeasured; check:liveness and check:platform-checklist green.

Human Minimum

  • 1. Schemas start as Zod; packages/spec holds no runtime logic. (packages/spec 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: not touched.
  • 7. No hand edits to content/docs/references/, packages/*/CHANGELOG.md or content/docs/releases/.
  • 8. Title, body and comments are in English.
  • The Before Submitting list in CONTRIBUTING.md is done.

🤖 Generated with Claude Code

https://claude.ai/code/session_01Utp6CYKQNa91PVtcbE6bGe

…s widening refusal (wip)

Claude-Session: https://claude.ai/code/session_01Utp6CYKQNa91PVtcbE6bGe
Co-authored-by: Claude <noreply@anthropic.com>
…t every door (wip)

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Utp6CYKQNa91PVtcbE6bGe
…al at the CLI and install doors (wip)

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Utp6CYKQNa91PVtcbE6bGe
@github-actions github-actions Bot added size/l documentation Improvements or additions to documentation tests tooling Fix lands in tooling, not a product package — enters pm:queue only with Unblocks: or a named surface labels Nov 5, 2026
@github-actions

github-actions Bot commented Nov 5, 2026 •

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 4 package(s): @objectstack/cli, @objectstack/cloud-connection, @objectstack/metadata-protocol, @objectstack/objectql, touching 24 documentable anchor(s). ⚠️ 1 changed file(s) yielded no anchor (packages/metadata-protocol/src/protocol-package-bindings.ts), so the pages documenting them are NOT COVERED by this run — this is not a clean bill of health for those files.

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

  • content/docs/concepts/metadata-lifecycle.mdx _(via /api/v1/actions/activation/:object/:action (route, a path literal in action))
  • content/docs/data-modeling/objects.mdx (via ObjectQLPlugin (symbol, a top-level class))
  • content/docs/kernel/services-checklist.mdx (via ObjectQLPlugin (symbol, a top-level class))
  • content/docs/kernel/services.mdx (via ObjectQLPlugin (symbol, a top-level class))
  • content/docs/permissions/authentication.mdx (via ObjectQLPlugin (symbol, a top-level class))
  • content/docs/plugins/packages.mdx (via ObjectQLPlugin (symbol, a top-level class))
  • content/docs/protocol/kernel/index.mdx (via ObjectQLPlugin (symbol, a top-level class))
  • content/docs/ui/actions.mdx (via registerAction (symbol, a method of class ActionsFacet))

⛔ 2 release-owned page(s) also name something this change touched. These are read-only:

  • content/docs/releases/v17/17-0.mdx (via ObjectQLPlugin (symbol, a top-level class))
  • content/docs/releases/v17/17-1.mdx (via ObjectQLPlugin (symbol, a top-level class))

content/docs/releases/ is RELEASE-OWNED (AGENTS.md "Documentation Guardrails"): release
notes are written centrally at release time, and a code PR that edits them is the exact PR
that guardrail exists to stop. They are still audited — read-only. If one of them is actually
wrong, file an issue or open a dedicated docs-only PR; do not edit it here.

What this run could not see
  • 1 changed file(s) yielded no anchor (packages/metadata-protocol/src/protocol-package-bindings.ts) — pages documenting those are invisible to this run
  • 1 anchor(s) matched too much of the corpus to be a work list: ObjectQL (symbol, 80 pages)
  • 4 name(s) were too generic to anchor anything (single lowercase words)
  • the SDK route bridge reached 61 of 219 client-bound route-ledger rows — the other 158 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 158: 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; 103 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 — 55 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 c32242196a19570415e337b27461f4ed44773b5c → packageMentionDocs.

Which tree this was computed on

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

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

⚠️ 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 c32242196a19570415e337b27461f4ed44773b5c → 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#3 · os-bill · session_01Utp6CbPxYq1wQmUNWvMZkC

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: 90624aa3145f420a910556ae645f687a391ae73e
Local-runs: none
Scope: packages/metadata-protocol/src/protocol-packaged-base-refusal.ts, packages/metadata-protocol/src/protocol-lock-state.ts, packages/metadata-protocol/src/packaged-base-regime.ts, packages/metadata-protocol/src/protocol-package-bindings.ts, packages/objectql/src/engine-actions.ts, packages/objectql/src/engine.ts, packages/objectql/src/plugin.ts, packages/cli/src/utils/override-declarations.ts, packages/cloud-connection/src/marketplace-install-local-plugin.ts + packages/metadata-protocol/src/served-override.ts#isServedOverrideRowOf, packages/metadata-protocol/src/protocol-artifact-lookup.ts#shippedArtifactsOf/#lookupArtifactItem, packages/metadata-core/src/item-key-discriminators.ts, packages/metadata-core/src/code-artifact-provenance.ts, packages/objectql/src/action-governance.ts#resolveActionHandlerKeys/#standaloneActionOwnerKey, packages/runtime/src/domains/actions.ts#handleActionsRequest/#shippingPackageOf, packages/runtime/src/action-execution.ts#actionPermissionError/#actionAiExposureError/#actionConfirmationRefusal/#systemObjectActionExposureError/#disabledActionRefusal/#resolveRouteActionDeclaration, packages/runtime/src/domains/mcp.ts#resumeAdmissionRefusal, packages/runtime/src/app-artifact-handlers.ts#bindAppArtifactHandlers, packages/cloud-connection/src/marketplace-install-local-plugin.ts#rehydrate/#bindArtifactHandlers, packages/core/src/kernel.ts (hook registration, plugin start order), packages/core/src/hook-dispatch.ts#dispatchHookPropagating, packages/cli/src/commands/serve.ts (plugin composition), packages/spec/src/ui/action.zod.ts (ActionAiSchema, visible/requiresFeature/requiresMembershipReach docblocks), docs/adr/0146 D2/D9, the flow precedent packages/objectql/src/protocol-flow-custom-override.test.ts.

Face: reviewFace over the PR's committed path list returns no schema, rules or narrowing hit (packages/spec/src/**, skills/**, docs/adr/**, .claude/** untouched); the changeset's Clause-②: yes (widening) line decides the declaration face, which alone owes TIER_DEFAULT. The seat raised the review to CONTRACT_REVIEW_TIER on card #30065 Acceptance 3 (a permission boundary: what an override may change about who may run an action), and that is the tier served here. The record is independent of the dev's self-report; every reading below is from the head's tree or origin/main.

① Derived judgments

  1. Admit. protocol-packaged-base-refusal.ts#CONDITIONS_DELIVERED becomes new Set(['flow', 'action']); isOpenOverrideType('action') therefore answers true, and judgeOverrideWrite judges a declared action override like a flow's: pair only (isCustomizationPairOf), base in the closure, base ships the name (closure.shippers), then the new widening check, then admit. The dependent's declaration (declared_outside_pair) and the pair's undeclared same-name save (undeclared_same_name, remedy "Declare ... or rename", never a clone) keep their refusals — pinned in protocol-flow-custom-override.test.ts and protocol.packaged-base-refusal.test.ts. The same judgeOverrideWrite is what os validate (cli/.../override-declarations.ts#findOverrideDeclarationRefusals) and the managed install (marketplace-install-local-plugin.ts ~L3688) call, so the three doors cannot drift. Correct against D2 and D9's action paragraph ("admitted, once its door is cut, under conditions 1–3").
  2. Condition 1, served over the code package's name. The by-name, execution and list reads already went through findServedOverlayRow/isServedOverrideRowOf once the type was open; the one gap, the layered read's effective, is closed in protocol-lock-state.ts#declinesStoredRow by (servesSealedOverrideRow(type) || canonicalMetaType(type) === 'action') && isServedOverrideRowOf(row, packagedArtifactOwner(...)). isServedOverrideRowOf (served-override.ts) judges the row's own package_id (must be the base's pair) and body (overrides.package === base), so a dependent's row and an undeclared pair row stay declined. The object read's actions and getSchema(obj).actions are the registry's bare entry, kept for action (the SEALED_ROW_SERVED_OVERRIDE_TYPES docblock change in protocol-package-bindings.ts is prose only). everyRead in the objectql test asserts nine reads answer [label, BASE], code.label stays SHIPPED, and run.requiredPermissions on the schema is the override's. The run doors read exactly that: action-execution.ts#resolveRouteActionDeclaration reads ql.getSchema(objectName).actions first (rung 1), then the protocol's by-name execution read. Correct.
  3. Condition 2, rebound in one reload / restore. isPairRowRemoval already keys on isOpenOverrideType, so the pair's delete is admitted and every read falls back to the artifact (test "condition 2"). The handler half: engine-actions.ts#ActionsFacet.registerActionOverride sets {handler, package, beneath} where beneath is the current entry (or the current entry's own beneath when re-binding the same package), and removeActionsByPackage walks each key's stack and drops only the named package's frames, so removing metadata-service re-exposes the base's app:<id> frame without re-registering it. plugin.ts#resyncAuthoredActionsNow lets a row through the artifact-shipped filter only when isDeclaredArtifactActionOverride(row, data) held at read time (same isServedOverrideRowOf, plus standaloneActionOwnerKey(artifact) === standaloneActionOwnerKey(action)), tracked in a WeakSet keyed on the parsed object (the same object is what byKey holds, so the lookup is sound), and binds it with registerActionOverride; everything else keeps registerAction. The resync's teardown-then-register order (remove metadata-service → base frame restored → override re-bound on top) gives one step in either direction. Correct for a base bound before the resync runs — see ③-1 for the case where it is not.
  4. Condition 3, one ledger row. disabledActionRefusal reads ql.isActionEnabled(actionDef.name) by name alone; the override keeps the name, so the switch refuses the armed override with 409 ACTION_DISABLED at both doors (REST handleActionsRequest after the D4 gate; MCP resumeAdmissionRefusal/run_action in the same order). domains/actions.ts#shippingPackageOf credits the row through isCodeArtifactBody(action) → action._packageId; the served override carries _packageId = BASE, _provenance = 'package' (asserted in test "condition 3" together with isActionEnabled false after setActionActive({ name, packageId: BASE }), disabledActions listing it, and the switch surviving the restore). Correct. Note: the PR does not itself assert the 409 at a door; the consult is pre-existing and name-keyed, so no new assertion is owed by this card.
  5. Acceptance 3 — actionOverrideWidening / declared_action_widens_access. The three keys the two run doors read to decide WHO may run are exactly the three judged: requiredPermissions (actionPermissionError, both doors), ai.exposed (actionAiExposureError and systemObjectActionExposureError, MCP), ai.requiresConfirmation (actionConfirmationRefusal, MCP). Absent-vs-empty: capabilities(undefined) and [] both read as no capability, so a base with no gate is never "widened", and an override that omits requiredPermissions against a gated base drops every base capability and is refused — matches actionPermissionError's Array.isArray(...) ? ... : []. ai.requiresConfirmation uses base === true && override !== true, which is the door's own !== true reading (an author's false and an absent key both mean no gate), so no default-on case is missed. ai.exposed uses base !== true && override === true, matching === true at the door. visible, disabled, requiresFeature, requiresMembershipReach are rendering predicates lowered into visible (action.zod.ts docblocks: "UI courtesy: the endpoint's own door stays the authority"); no runtime source file outside tests reads them — correctly excluded. The refusal rides overrideWriteRefusalError → code: 'NOT_OVERRIDABLE', status: 403; asserted as { code, status } in the objectql test (toMatchObject), the metadata-protocol test (shape(e) = {code, status}), and the install test (res.status 403, error.code, rule row); the CLI test asserts code and rule (a validate row carries no status). publishMetaItem re-judges a draft seeded before the gate (test), so the gate is not save-only. baseItemOf at the protocol door is shippedArtifactsOf(type, name, undefined, member).find(_packageId === base), i.e. the registry's code artifact (lookupArtifactItem → getArtifactItem, composite entries first), not the currently served override — so a re-save is judged against the true base. Correct.
  6. target / handler key. The widening gate compares gates, not handlers. A pre-existing item with the maintainer touches this area; this PR adds no new route to it, and under the gate the override's audience is never wider than the base's. ③-4.
  7. objectName. ITEM_KEY_DISCRIMINATORS has no action row, so at the protocol door the base is matched by name alone, while overrideWriteItemsOf (CLI, install) uses <objectName>:<name>. An override may therefore move a base action to another object at the save door. The handler is then not rebound (isDeclaredArtifactActionOverride requires equal owner keys) and the base's gates still ride the declaration, so it widens nothing; it is a consistency gap between doors. ③-3.
  8. Text changes. PACKAGED_BASE_REGIME.action.routes.switchOff now spells :object_or_global (a placeholder description, not a route change) to keep the declared-save sentence under the REST door's 500-character bound; the save sentence for a packaged action now names the declaration first (sealedDeclareOrRenameTail). Prose/message only; the pinned tests in metadata-protocol and rest are updated to the new spelling. No narrowing: every input declared_action_widens_access refuses was refused before under declared_type_not_yet_available; no previously admitted input is refused.
  9. Public face added. @objectstack/metadata-protocol: actionOverrideWidening, OverrideWriteClosure.baseItemOf?, OverrideWriteRule member declared_action_widens_access. @objectstack/objectql: ObjectQL#registerActionOverride; removeActionsByPackage is stack-aware (identical for entries without beneath); ActionHandlerEntry is exported from engine-actions.ts but not reachable from the package entry (ObjectQL.actions is private) — the changeset's mention of it slightly overstates the public face (prose; lane seat's call). @objectstack/cli and @objectstack/cloud-connection: behaviour at their doors. All additive. Metadata-protocol: added is right (a new accepted metadata shape). Clause-②: yes (widening) is right.

② Semver level

minor on @objectstack/metadata-protocol, @objectstack/objectql, @objectstack/cli, @objectstack/cloud-connection — consistent with the additive exports and the newly admitted shape, and with the flow precedent (#28412 shipped minor). @objectstack/rest is touched in a test file only (rest-meta-packaged-action-permission-refusal.test.ts); no shipped change, no changeset owed. No moved package is missing an entry. The changeset carries both declaration lines and Skills-docs: follow-up #30505 (open, titled for this card's docs).

③ Boundary flags

  1. Must-fix (condition 2 after a restart, marketplace-installed base). The PR body's "Known edge" assumes a base's handlers are bound only in AppPlugin.start, before the kernel:ready resync. That holds for a code-loaded package. For a marketplace-installed base — the ADR's primary case (D1: an override replaces an item of an INSTALLED, read-only base) — the handlers are bound by marketplace-install-local-plugin.ts#rehydrate → #bindArtifactHandlers → runtime#bindAppArtifactHandlers → ql.registerAction(objectKey, name, handler, 'app:<id>'), inside the install-local plugin's own kernel:ready hook. Hooks are pushed in registration order and dispatched sequentially (kernel.ts hook/trigger, hook-dispatch.ts#dispatchHookPropagating); serve.ts composes ObjectQLPlugin before MarketplaceInstallLocalPlugin, and the install-local plugin consumes the objectql service, so the ObjectQL resync runs first. Scenario, read from code (not run): (a) ObjectQLPlugin's kernel:ready resyncAuthoredActions runs while the base's manifest is not yet registered, so isDeclaredArtifactActionOverride is false (no artifact) and isArtifactShippedAction is false; the override row is bound as a plain registerAction(..., 'metadata-service'). (b) Rehydrate registers the manifest and registerAction(..., 'app:<base>') replaces the whole entry (ActionsFacet.registerAction sets the key unconditionally; its collision warning fires). (c) Rehydrate announces no metadata:reloaded (only install L4948 and uninstall L3051 do). Result: every read, the console button and rung 1 of the run door serve the override's declaration, but executeAction runs the base's body — the two definitions disagree indefinitely, until an unrelated action/object mutation or install triggers the next resync. That contradicts D9 condition 2 ("no window in which both definitions are bound or neither is") and the changeset's own sentence ("runs in place of the base's handler after one resync (kernel:ready, ...)") for script-body actions of installed packages; flow/api/update-typed actions (the card's crm_convert_lead) are unaffected since they carry no handler. The gates are the override's (never wider than the base's) and the body is the vendor's, so it is a correctness, not an access, failure. Smallest remedies: have rehydrate announce metadata:reloaded after binding (as install does), or run the authored-action resync after rehydrate, or let the binder slot beneath an armed metadata-service override instead of replacing it. Pin with a test that binds the base after the override's resync and runs.
  2. Install-door ratchet (fails closed). marketplace-install-local-plugin.ts#heldBy reads the base's items through getMetaItems({ type, packageId: base }) when the base is not in the same delivery; once an override is armed, that read serves the override under the base's envelope (_packageId = BASE), so a re-install of .custom is judged against the previous override, not the base. A v2 that relaxes a v1 tightening back to the base's gates is refused at install though the protocol door admits it. Over-refusal only; a follow-up, not a blocker.
  3. objectName not pinned at the protocol door (①-7): CLI/install identities carry <objectName>:<name>, the protocol door matches by name. No widening; door inconsistency. Follow-up.
  4. target seam (①-6): a pre-existing item with the maintainer; this PR adds no new route to it. Report only.
  5. Dev open_questions: none. security_findings: withheld — consistent with ③-4 being carried privately. Dev out-of-scope finding (registerAction over an armed override) is the mechanism of ③-1; its reach is wider than "named producer" because of the rehydrate path.
  6. PR author is objectstack-fleet[bot]; no relay-author note owed. Check-runs on this head were still in_progress (Test Core shards, Type Check · workspace, Lint · repo & tooling gates) at read time; the completed ones were success.

④ Design judgment

Against D2/D9 and ADR-0066 D4: option A (refuse widening at the write gate) is the right place for "cannot be widened without saying so" — the refusal names each widened gate, is one verdict shared by save, publish, os validate and install, and leaves behaviour fields replaceable whole as D2 requires. The three judged keys are the complete set of declaration keys the two dispatch doors read for authorization; the comparison semantics match the doors' own reads (absent/empty capability lists, !== true on confirmation, === true on exposure). The protocol door compares against the code artifact, not the served row, so the gate cannot be ratcheted or bypassed by a prior override there. The read-side admission reuses the one served-override predicate the flow uses, so no second judgement of the row exists. The handler stack is a sound primitive and the resync's teardown order gives one-step rebinding whenever the base is bound before the resync. The design fails its own condition 2 only in the boot ordering of ③-1, which is the deployment the ADR is written for; that is the must-fix.

Implemented-by: claude/project-thread-yd96eq
Reviewed-by: session_01Utp6CbPxYq1wQmUNWvMZkC

VERDICT: FAIL

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

domain:server#3 · os-bill · session_01Utp6CbPxYq1wQmUNWvMZkC

domain:server#3 · os-bill · session_01Utp6CbPxYq1wQmUNWvMZkC

Contract review (delta, round 1)

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: 6366e96ef9e845e55954dc6a10750ce34d9a6d85
Local-runs: probe — the delta changes handler-stack ORDER semantics (a later registerAction slotting beneath a marked override, a row judged by its own declaration when no artifact is registered), which reading alone cannot settle; one throwaway worktree at this head (pnpm install, pnpm --filter "@objectstack/objectql^..." build), then vitest run --project local src/plugin-authored-actions.test.ts (22/22), src/protocol-flow-custom-override.test.ts (13/13), tsc --noEmit in objectql (clean), an ablation (slot-beneath branch of ActionsFacet.registerAction removed → exactly the two new boot-order tests fail, ran:BASE BODY / ran:REBOUND BASE BODY where ran:OVERRIDE is expected; source restored), and a reviewer probe file (P1–P5 below) on the real engine + ObjectQLPlugin.resyncAuthoredActions; worktree removed afterwards.
Scope: .changeset/30065-declared-action-override.md, packages/objectql/src/engine-actions.ts, packages/objectql/src/engine.ts, packages/objectql/src/plugin.ts, packages/objectql/src/plugin-authored-actions.test.ts + packages/runtime/src/app-artifact-handlers.ts#bindAppArtifactHandlers (teardown-then-register), packages/cloud-connection/src/marketplace-install-local-plugin.ts#start (kernel:ready hook order) / #rehydrate / #installCustomizationPair (rows only, "Nothing is registered as code or ledgered") / #announceUninstall, packages/metadata-protocol/src/served-override.ts#isServedOverrideRowOf, packages/metadata-protocol/src/protocol-packaged-base-refusal.ts#judgeOverrideWrite (closure.shippers, several shippers admitted), packages/objectql/src/action-governance.ts#standaloneActionOwnerKey, packages/objectql/src/registry.ts (composite <packageId>:<name> slots, getArtifactItem), packages/spec/src/contracts/objectql-engine.ts (registerAction/removeActionsByPackage contract; registerActionOverride is not on the contract and the plugin probes for it), docs/adr/0146 D9 conditions 1–3 and the action paragraph.

Face: unchanged from round 0 — the committed path list still hits no schema, rules or narrowing path (packages/spec/src/**, skills/**, docs/adr/**, .claude/** untouched by the delta as well); the declaration face (Clause-②: yes (widening)) alone owes TIER_DEFAULT; the seat's elevation to CONTRACT_REVIEW_TIER on card #30065 Acceptance 3 is served here as in round 0. This record judges the delta 90624aa3..6366e96e (+85 −9 over five files; the PR total is +719 −62) and the code it touches; round 0's ①/②/④ findings stand where the delta does not touch them. Every reading is from the head's tree, origin/main, or the probe; none from the dev's self-report.

① Derived judgments

  1. Remedy chosen for ③-1 (round 0). Three pieces. (a) ActionHandlerEntry.override?: true is set by ActionsFacet.registerActionOverride and carried through removeActionsByPackage's ...rest spread, so the marker survives the removal of the handler beneath it and is dropped with the override itself (probe P3: after removeActionsByPackage('app:<base>') the entry is {package: 'metadata-service', override: true, beneath: undefined}; after removeActionsByPackage('metadata-service') the exposed base entry carries no override). (b) ActionsFacet.registerAction: when the current entry is marked override and the arriving packageName differs, the arriving handler becomes beneath (replacing any previous beneath) and the override stays on top; no collision warning (debug only). (c) ObjectQLPlugin.isDeclaredArtifactActionOverride: when registry.getArtifactItem('action', name) is absent, the row is judged by isServedOverrideRowOf(row, action.overrides?.package) — i.e. the row's own package_id must be the pair of the package its own body declares, type overridable; overrides absent ⇒ basePackageId undefined ⇒ false, so an undeclared row still binds as a plain metadata-service handler and a later base replaces it (test "before the base is registered, a pair row without the declaration, or a dependent's declaration, is no override"). Correct as read and as run.
  2. (a) Boot orders — condition 2. Code-loaded base (AppPlugin.start before the kernel:ready resync): artifact present at resync, exact judgement against _packageId and owner key, registerActionOverride over app:<base>; restore = resync teardown exposes the base (test "override, restore, run again"). Marketplace rehydrate (resync first, artifact absent): row self-judged, bound alone as override; rehydrate → bindAppArtifactHandlers → removeActionsByPackage('app:<base>') (no-op) → registerAction(..., 'app:<base>') slots beneath; override runs from the first request, reads serve the override once the manifest is registered (both inside the same kernel:ready hook, before the install plugin mounts its HTTP routes, so no request lands in between); restore exposes the base (test "a base bound AFTER…", load-bearing per the ablation). Base re-bind while armed (reinstall/upgrade): teardown drops the stale beneath, the new handler slots beneath, override stays; restore runs the NEW base body (test, load-bearing per the ablation). Uninstall/restore of the override: the pair's rows are the only representation of a .custom delivery (installCustomizationPair writes through saveMetaItem, registers nothing as code and binds no app:<base>.custom handler), so the row delete / deletePackage → emitMetadataMutation → resync → teardown of metadata-service exposes the base in the same step; the same-.custom deleteMetaItem path is pinned end-to-end in protocol-flow-custom-override.test.ts "condition 2" (13/13 locally). In none of the four orders is there a step with both bound or neither bound. ③-1 is closed.
  3. (b) Slot-beneath rule — reach. The rule keys on the marker alone: ANY other package's registration under a marked key goes beneath, and the marker is granted, when no artifact is registered, on the row's self-consistency. Probed: P1 — a row in com.example.other.custom declaring overrides.package: 'com.example.other' for showcase_task:showcase_task_close, resynced before the real base com.example.showcase registers, is bound as an override and STAYS on top when the real base binds (top: metadata-service, override: true, beneath: app:com.example.showcase, run answers the row's body), while every read judges that row by isServedOverrideRowOf(row, 'com.example.showcase') = false and serves the base's declaration. That is a run/read split of the ③-1 kind. Its reach: the row must exist in sys_metadata. Through any door it exists only if judgeOverrideWrite admitted it, which requires com.example.other in the closure AND among closure.shippers of that name — i.e. two installed packages ship the same <object>:<name>, the configuration the engine already names a misconfiguration ("Two owners share one handler key … rename one of them") and under which getArtifactItem and the reads are already ambiguous; or a stale pair row after the former base dropped the name and a different installed package adopted the identical key (basedOn is recorded, never judged — ADR-0146 D2 — and nothing cleans pair rows on a base upgrade); or a direct table write. No access widening: the body that runs is the deployment's own authored body, under gates the write door judged no wider than a shipper's. Flagged ③-7, not a must-fix. P4 (base registers but no longer ships the name): the self-declared row stands alone as an override and runs — identical to the pre-delta plain bind, no delta change. P2: an UNPACKAGED registerAction under an armed override slots beneath silently (0 warnings), where engine.ts's own docblock promises a warning for an unpackaged registration replacing a package's handler; benign, noted ③-8. P5: a plain registerAction(..., 'metadata-service') over its own override replaces whole and loses both marker and base; only resyncAuthoredActionsNow registers under that owner and it tears down first, so unreachable in the product.
  4. (c) removeActionsByPackage with the marker. The recursive without keeps override: true on a surviving entry and drops it with the removed one; base-then-override, override-then-base, uninstall-and-reinstall of the base under an armed override (P3), and "removing both unbinds the name" (PR test) all restore correctly; listRegisteredActions reports the top entry's package as before.
  5. Public face. No new export in the delta; ActionHandlerEntry gains an optional member (not reachable from the package entry, as round 0 noted); IObjectQLEngine unchanged. registerAction's contract gains one documented exception (its engine.ts docblock states it). Round 0 ①-9 stands.

② Semver level

Unchanged: minor on @objectstack/metadata-protocol, @objectstack/objectql, @objectstack/cli, @objectstack/cloud-connection; the delta adds no shipped change to any other package. Changeset: Clause-②: yes (widening) and Metadata-protocol: added lines intact; the ActionHandlerEntry sentence round 0 ①-9 called an overstatement is removed; the new sentence ("registerAction of another package's handler under a key an override holds takes the place beneath the override instead of replacing it: a marketplace-installed base binds its handlers in the install plugin's kernel:ready rehydrate, after the boot's resync bound the override, and after a restart the override still runs (and a restore runs the base's body)") is accurate against engine-actions.ts, marketplace-install-local-plugin.ts#start/#rehydrate and the pinned test. Skills-docs: follow-up #30505 still present. (d) holds.

③ Boundary flags

  1. ③-1 (round 0 must-fix) — closed. Mechanism, boot orders and restore as in ①-2; the two new tests are load-bearing (ablation).
  2. Install-door ratchet (round 0 ③-2) — unchanged, listed under the PR body's "Out of scope" first bullet. Follow-up.
  3. objectName not pinned at the protocol door (round 0 ③-3) — unchanged, listed under "Out of scope" second bullet. Follow-up.
  4. target seam (round 0 ③-4) — a pre-existing item with the maintainer; the delta adds no route to it.
  5. (e) PR body. The "Known edge" paragraph is gone; the "Boot order" paragraph (both orders end with the override on top, and a re-bind of the base's handlers while the override is armed does too) matches the code and tests. One verification sentence overstates: "undeclared, dependent and other-base rows never binding, before and after the base is registered" — the before-the-base test loops over the undeclared and the dependent row only; the other-base row is pinned only after the base is registered, and before it the row DOES bind (①-3, P1). Prose; the lane seat's ACCEPT should have the sentence corrected or the case pinned.
  6. Check-runs on this head at read time: Build Core, Type Check · workspace, Type Check · consumer gates, Test Core (1–8/8) still in_progress; every completed run success (Lint & Repo Gates, ESLint, Governed Surface Queue Guard, Dogfood Regression Gate, Check Changeset, Type Check · source gates included). Clause ② of the landing check (all green) is for the dispatching seat at landing time. PR author objectstack-fleet[bot]; no relay-author note owed. Dev open_questions: none.
  7. New, non-blocking — self-declared binding trusts the row when no artifact is registered. The split in ①-3/P1 is reachable only through a same-key collision between installed packages (already a warned misconfiguration) or a stale pair row whose key another package later adopts. Two cheap tightenings, either sufficient, for a follow-up card: have rehydrate announce metadata:reloaded after bindArtifactHandlers (as the install route and the uninstall do), so the resync re-judges every row against the now-registered artifact and the self-judgement is transitional only; or have registerAction slot beneath only when the arriving owner is the override's declared base (record the expected owner on the entry at registerActionOverride), falling back to the replace-and-warn path otherwise. Not a widening of who may run anything.
  8. New, cosmetic. An unpackaged registerAction under an armed override is slotted beneath at debug level (P2), where the method's docblock promises a warning for an unpackaged registration displacing a package's handler. Adjust the docblock or warn; lane seat's call.

④ Design judgment

Against D9 conditions 1–3 read for action: the stack with an explicit override marker is the right primitive for "rebound in one reload" when the base's handlers may arrive before OR after the override's — it makes the order irrelevant for the declared pair, and the resync's teardown-then-register keeps restore a single step in both orders. Judging the row by its own declaration when no artifact is registered is a necessary concession to the marketplace boot order, and it is exact for the pair whose base later arrives; its only imprecision (①-3) is confined to configurations the engine already refuses to make well-defined (two shippers of one key) or to stale rows the ADR explicitly leaves unjudged (basedOn recorded, not judged). The read side is untouched by the delta and still has one predicate (isServedOverrideRowOf); the write gate is untouched; the switch (condition 3) is untouched. The remedy closes round 0's must-fix without re-opening any read, write or access boundary.

Implemented-by: claude/project-thread-yd96eq
Reviewed-by: session_01Utp6CbPxYq1wQmUNWvMZkC

VERDICT: PASS

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

domain:server#3 · os-bill · session_01Utp6CbPxYq1wQmUNWvMZkC

维护者速读(席位终稿)

  • 改了什么:包内动作可以在配对的 .custom 包里用 overrides 声明覆盖,走 ADR-0146 D9,和 flow 一样。以前这类保存一律 403「not yet available」,现在放行。每一种读都按基包的名字返回覆盖;删掉覆盖就读回基包;开关账本按名字用一行管住当前生效的定义。
  • 为什么改:Action customization: open the declared override for a package's action, so a customer can rename, hide or adjust a button #30065。已安装的只读基包里的动作,客户要改只能复制改名;D9 已经裁定动作在三个条件下可以覆盖,这次把那道门打开。
  • 风险与回滚:覆盖不能扩大谁能运行:去掉 requiredPermissions 里的能力、把 ai.exposed 打开、去掉 ai.requiresConfirmation,都会被新规则 declared_action_widens_access 拒为 403。保存、发布、os validate 和安装门共用这一道闸。行为字段整份替换,所以覆盖可以换掉运行的 body,但只对原本就能运行这个动作的人生效。引擎 handler 改成栈,安装包重启后基包 handler 垫在覆盖下面。回滚即 revert。
  • 先看:protocol-packaged-base-refusal.ts 的 actionOverrideWidening;engine-actions.ts 的 registerActionOverride 与垫底分支;plugin.ts 的 isDeclaredArtifactActionOverride。
  • 两分钟验证:pnpm --filter @objectstack/objectql exec vitest run src/plugin-authored-actions.test.ts src/protocol-flow-custom-override.test.ts,全绿(22 + 13)。
  • 需要你做的:无。非受管面、781 行、非 BREAKING,席位落地;契约复核第 0 轮 6659858971 FAIL 已返工,增量复核 6661174869(Head-sha 6366e96ef9e845e55954dc6a10750ce34d9a6d85,PASS)。

席位意见:同意落地。三个条件都有测试钉住,重启顺序的缺口已补;剩余四处都不扩权,已立卡 #30542 跟进。

席位核对

ACCEPT · head 6366e96ef9e845e55954dc6a10750ce34d9a6d85 · card #30065 (claim 6657761602) · 2026-11-05T15:08Z

  • Shape: draft PR to main, relay author, attribution block then Fixes #30065; no other closing keyword. 17 files, +719 −62. check-governed-merges.mjs --pr 30497 → NOT governed, 781 ≤ 3000. No merge=os-regen path. mergeable_state clean. PR body revised by the seat through the relay twice (rework-1 draft from the dev; the objectql: a declared .custom action override has four door and boot-order loose ends left by PR #30497 #30542 pointer and one Verification sentence), read back identical.
  • Premise measured: on main a declared .custom action override answered 403 declared_type_not_yet_available; at this head it saves and serves under the base's name, and a widening override answers 403 NOT_OVERRIDABLE declared_action_widens_access (pinned at the protocol, CLI and install doors).
  • Gates (check-runs on this head): 56 runs, 39 success, 17 path-gated skipped, 0 failed; Lint & Repo Gates, TypeScript Type Check, Check Changeset, Governed Surface Queue Guard, PR-body guards all success. Dev battery at this head: dispatch-gates --ran 65/65 exit 0, 0 NOT MEASURED; objectql 10998 passed.
  • Accepted deviation: review raised to CONTRACT_REVIEW_TIER by the seat (Acceptance 3 is a permission boundary); ③-7 (self-declared binding before the base registers) accepted as non-blocking, because it is reachable only through a same-key collision, a stale pair row or a direct table write, and it widens nothing.
  • Out of scope: round 0 ③-2 and ③-3 and delta ③-7 and ③-8 are filed as objectql: a declared .custom action override has four door and boot-order loose ends left by PR #30497 #30542. A pre-existing item stays with the maintainer. Skills-docs: follow-up #30505.
  • Landing: not BREAKING (Clause-②: yes (widening)), not governed, ≤ 3000 ⇒ the seat lands it now.

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 Fix lands in tooling, not a product package — enters pm:queue only with Unblocks: or a named surface

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Action customization: open the declared override for a package's action, so a customer can rename, hide or adjust a button

1 participant