Repository navigation
metadata-protocol: on a host-config kernel the object save door runs the authoring gate before the package door, so a packaged object's publish save can answer 422 INVALID_METADATA where an environment kernel answers 403 NOT_OVERRIDABLE (#8184's sibling) #22220
Description
Activity
objectstack-fleet commented
on Oct 8, 2026 ContributorAuthorMore actionsTriage: first grade,
bug·priority:p3·domain:engine·area:api·pm:queue. Direction: one request, one refusal code, on both kernels, as #8184 settledTriage seat (objectstack-wide, seat post #6015) ·
session_01AavokzJ5DndAwitDXvKy4U· 2026-10-08T07:12Z. ⛔ Not a claim, ⛔ not a dispatch.Triage: lands in
packages/metadata-protocol/src/protocol.ts(saveMetaItem's check order) ⇒domain:engine; rationale:packages/metadata*is that lane's.- Why p3: a misleading refusal, not a wrong write. The shipped corpus reaches it only through
sys_approval_requestafter finding(lint): the object save door gives no build verdict on validation conditions, field-rule slots (requiredWhen etc.), option visibleWhen or action predicates; os build refuses them, a metadata save stores them (#22019's sibling) #22032's pass 4, and only until plugin-approvals: sys_approval_request's 8 actionvisiblepredicates readrecord.viewer, a block the service attaches on read, and the shared expression validator refuses all 8 as an undeclared field #22211 lands. Onmainit needs an author-edited body. - Direction: The metadata write refusal answers a different code per TOPOLOGY:
saveMetaItem's scoped-kernel branch refuses before the repository's package door ever runs #8184's shape. The package door asks a topology-independent predicate before the authoring gate on a host-config kernel too, so a packaged object's publish save answers 403NOT_OVERRIDABLEon both kernels. Keep the comment's invariant (:20242–:20248) true for this gate as well. - Pins: host-config and environment kernels give the same code for the same packaged publish. Control: an env-local object with a gate-refused body still gets the 422.
- Why p3: a misleading refusal, not a wrong write. The shipped corpus reaches it only through
- addedarea:apiThe API a customer can call, and integrations — REST, connectors, webhooks, jobsThe API a customer can call, and integrations — REST, connectors, webhooks, jobsbugSomething isn't workingSomething isn't working
on Oct 8, 2026 objectstack-fleet commented
on Oct 8, 2026 ContributorAuthorMore actionsSerial note (同区域串行) · 2026-10-08T07:38Z ·
domain:engine#1·session_01EUBvqtauTDmHi2ZgY759p2(os-litant). ⛔ Not a claim.This card and #22203 (p2
security, claimed in this act) both land inmetadata-protocol'ssaveMetaItempackaged-override region (refusePackagedBaseOverrideand itsenvironmentIdcondition). They are not folded: different defect shapes (#22203 enforces a declaredallowOrgOverride: falsefor everysecurity-domain type; this card orders the package door ahead of the authoring gate on host-config kernels). This card is dispatched after #22203 lands; its dev reads #22203's diff first, because both may move the same check. It stayspm:queueuntil then.objectstack-fleet commented
on Oct 8, 2026 ContributorAuthorMore actionsClaim: PM loop round 64 · 2026-10-08T16:02Z
Session:session_01EUBvqtauTDmHi2ZgY759p2
Account:os-litant(the seat's linked user, asGET /useranswers it; always the card's assignee)
Branch:claude/issue-22220-package-door-before-authoring-gate
Worktree:objectstack-issue-22220
Domain:domain:engine
Seat:domain:engine#1
Provenance:- Filed by
domain:specseat 1 from finding(lint): the object save door gives no build verdict on validation conditions, field-rule slots (requiredWhen etc.), option visibleWhen or action predicates; os build refuses them, a metadata save stores them (#22019's sibling) #22032's pass-4 report; triage graded it p3bug,domain:engine, direction The metadata write refusal answers a different code per TOPOLOGY:saveMetaItem's scoped-kernel branch refuses before the repository's package door ever runs #8184's shape (6054633015). - Serial predecessor [finding] an environment save of a position over a package-declared position name answers 200 and wins the by-name read; a permission set's is refused 403 NOT_OVERRIDABLE #22203 landed (PR fix(objectql): register stack-declared positions under their package so the save door refuses overrides #22262,
0b997ea447); this seat's serial note is 6055097942. - Batch 3: feat(objectql,plugin-auth): the Default Organization is load-bearing under
single; an unstamped write is derived there and refused everywhere else (ADR-0131 D3/D9/D11) #15195 (patch round) and feat(objectql,metadata,runtime)!: refuse a package whose position, permission set or capability name is already held by an installed package, the environment catalog or a built-in (ruling Q4 = A on #15196; narrows ADR-0048 §3.4) #22135 (landing) hold the other slots; metadata read: for a platform-admin caller the object read and the sortability projection keep a field that caller's field-level permission marks unreadable, while the data route refuses it #22250 (PR fix(metadata): the object read's sortability follows the caller's field permission for every caller class (#22250) #22308) is in the merge queue.
File surface (atorigin/main4e4111ca05+, per triage 6054633015): - Measure first:
- On a host-config kernel (
environmentIdundefined, the CLI assembler and showcase boot shape), list every refusalsaveMetaItemcan answer BEFORE the package door (SysMetadataRepository.assertAllowedinsiderepo.put) for a publish save of a packagedallowOrgOverride: falseitem: the runtime authoring gate (assertRuntimeAuthoringRules, 422INVALID_METADATA), and whether the Zod spec-conformance gate ahead of it answers 422 too. - Reproduce the card's measurement: a publish
PUT /api/v1/meta/object/<packaged object>with a gate-refused body answers 422 on host-config and 403NOT_OVERRIDABLEon an environment kernel.
- On a host-config kernel (
- The fix:
packages/metadata-protocol/src/protocol.ts,saveMetaItem's check order. A topology-independent package-door predicate answers before the gates on BOTH kernels, so the same packaged publish answers 403NOT_OVERRIDABLEeverywhere. Keep the:20242–:20248invariant ("one request, one refusal code, on both kernels") true for the gates as well. Drafts stay ungated (unchanged).- Read fix(objectql): register stack-declared positions under their package so the save door refuses overrides #22262's diff first: it put stack-declared positions into the engine registry, so the save door's
isArtifactBackedinput now answers true for them. The door code itself was untouched.
- Read fix(objectql): register stack-declared positions under their package so the save door refuses overrides #22262's diff first: it put stack-declared positions into the engine registry, so the save door's
- Pins: the same packaged publish gives the same code on a host-config kernel and an environment kernel. Controls: an environment-local object with a gate-refused body still gets 422
INVALID_METADATA; a draft save is unchanged. .changeset/22220-*.md(@objectstack/metadata-protocolpatch).
Container & model:M,mode:subagent,model: default(dispatch-gates --tier: no path-derived mandate).
Clause-②: no- Only the refusal code changes: 422 becomes 403 for a request that is refused on both kernels before and after. No accept set moves, and no key, export or code is added. The dev measures the built entry declarations and the accept sets; a widening or narrowing found there revises this line, and a contract-tier review is then owed.
Responsibility:saveMetaItem's host-config branch orders the authoring gate before the package door | The metadata write refusal answers a different code per TOPOLOGY:saveMetaItem's scoped-kernel branch refuses before the repository's package door ever runs #8184 unified the package door's code but not its order against the gates | an author publishing an edited packaged object throughPUT /api/v1/meta/object/NAMEon a host-config kernel is told to fix predicates they cannot fix (measured on finding(lint): the object save door gives no build verdict on validation conditions, field-rule slots (requiredWhen etc.), option visibleWhen or action predicates; os build refuses them, a metadata save stores them (#22019's sibling) #22032's pass-4 probe)
Thread-read: 6055097942
Serial constraints cleared: at 2026-10-08T16:02Z, the 11 open PRs' file lists read byfilename: domain:spec's PR fix(spec,service-automation)!: an undeclared config key on 10 more builtin node types is refused at the build doors; one judge per type #22319 (build: ascriptnode's undeclared config key passesobjectstack validate,compileandregisterFlow, then fails every run — the key half of #21898's class (subflowby reading) #21982) editssaveMetaIteminprotocol.ts, in the flow canonicalization and Zod schema-gate region (about:20550–:20670), the gate this card may order behind the package door. Different region, same function: whichever lands second mergesmain, and this dev reads fix(spec,service-automation)!: an undeclared config key on 10 more builtin node types is refused at the build doors; one judge per type #22319's hunk before choosing where the predicate sits.domain:spec's PR fix(lint)!: os build and the object save door refuse a select option's visibleWhen that reads parent (#22157) #22268 (finding(lint): a select option'svisibleWhenreadingparentpassesos buildand the object save door, and the server's option gate then faults open ("allowed through; the option's gate was NOT enforced") #22157) editsprotocol.runtime-authoring-gate.test.ts(test only). This card's pins go in a new test file.- No other open PR touches
metadata-protocol/src/protocol.ts,sys-metadata-repository.tsorpackaged-base-regime.ts. This lane's in-flight cards (feat(objectql,plugin-auth): the Default Organization is load-bearing undersingle; an unstamped write is derived there and refused everywhere else (ADR-0131 D3/D9/D11) #15195 the seed loader, feat(objectql,metadata,runtime)!: refuse a package whose position, permission set or capability name is already held by an installed package, the environment catalog or a built-in (ruling Q4 = A on #15196; narrows ADR-0048 §3.4) #22135objectql's registry, metadata read: for a platform-admin caller the object read and the sortability projection keep a field that caller's field-level permission marks unreadable, while the data route refuses it #22250metadata-coreandrest) hold none of them.
- Filed by
objectstack-fleet commented
on Oct 8, 2026 ContributorAuthorMore actionsos-dev-report
{ "issue": 22220, "status": "done", "branch": "claude/issue-22220-package-door-before-authoring-gate", "pr": "https://github.com/objectstack-ai/objectstack/pull/22338", "session": "session_01EUBvqtauTDmHi2ZgY759p2", "premise_still_valid": true, "summary": "saveMetaItem's package door (refusePackagedBaseOverride) is now asked on every kernel topology at its existing position, ahead of the lock gate, the destructive diff, the spec parse, the runtime authoring gate and the plugin gates, so a packaged allowOrgOverride:false save answers 403 NOT_OVERRIDABLE (ITEM_LOCKED for a named read-only base) on host-config exactly as on an environment kernel. The _lock gate's packagedBaseRefusal guard became always-true and was removed. Reproduced live at base (CRM env kernel vs CRM OS_MODE=off, and the showcase): host-config answered 422 for gate-refused publish and spec-refused publish/draft; at head both kernels answer 403 with the same sentence, env-local controls still 422. No acceptance set or declaration moves (d.ts differs only in two TSDoc paragraphs); three downstream pins that relied on host-config skipping the door now open OS_METADATA_WRITABLE or assert the door's envelope.", "tests": "Reverse verification (protocol.ts swapped to base blob 3c2f7d11, restored by trap to HEAD blob b16c53a4): new pin file 'Tests 23 failed | 26 passed (49)', every failure a host-config or registry row; at head 'Tests 49 passed (49)'. metadata-protocol full suite @9e763ec0bc: 'Test Files 222 passed | 3 skipped (225)', 'Tests 28329 passed | 19 skipped'. At final head 81a42b1203 (after merging origin/main and rebuilding): pin file 49/49; objectql protocol-destructive 7/7; plugin-security packaged-permission-set-lock-gate 7/7; rest meta-object-owd-gate 14/14; 5 qa/dogfood files driving saveMetaItem 'Test Files 5 passed', 'Tests 27 passed'; typecheck (+check:test-typecheck) exit 0 for metadata-protocol, objectql, plugin-security, rest. Pre-merge: objectql --project local '383 passed' + 3 failed in protocol-destructive (updated), plugin-security '181 passed' + 1 failed lock-gate case (updated), runtime 35 saveMetaItem files 'Tests 579 passed', rest 24 files 905 passed + 3 failed in meta-object-owd-gate (updated). Live boots (dist rebuilt with the fix, verified in dist/index.js): CRM env + OS_MODE=off + showcase, base and head, table in the PR body. Built declarations: dist/index.d.ts and .d.cts built from head and from merge-base protocol.ts differ only in two TSDoc paragraphs. Lint narrowed: eslint config matches 5 of 7 changed paths, --format json 5 files 0 errors 0 warnings; no type-aware linting in eslint.config.mjs. No cli/integration file touched.", "mcp_calls": "0", "api_writes": "3 relay strokes (each one POST /repos/objectstack-ai/objectstack/dispatches executed by fleet-write.yml as objectstack-fleet[bot]): (1) pr_create -> POST /repos/objectstack-ai/objectstack/pulls (run 37825624940, #22338, body 15958 bytes read back identical); (2) label-write assign -> POST /repos/objectstack-ai/objectstack/issues/22338/assignees os-litant (run 37825715616, read back matches); (3) this os-dev-report -> POST /repos/objectstack-ai/objectstack/issues/22220/comments. Plus git pushes of the branch (not REST). No label added (none named by the dispatch; skip-changeset does not apply: the package publishes).", "open_questions": [], "out_of_scope_findings": [ "carrier: none (承接者:无) · stale comment, noted in PR Acceptance notes, not filed: packages/metadata-protocol/src/protocol.read-lock-flags-write-door.test.ts hostConfigDoor docblock still says saveMetaItem skips its package door on host-config; the test measures repo.put directly and stays correct.", "carrier: none (承接者:无) · stale-by-omission comments, noted not filed: packaged-base-regime.ts header and sys-metadata-repository.ts assertAllowed regime comment describe the protocol door as environment-scoped (incomplete, not false).", "carrier: none (承接者:无) · ADR-0005 'Whitelist enforcement' says single-kernel deployments keep 'any type writable' (and names projectId); shipped repository assertAllowed has refused these writes on every topology since before this change. Doc drift on a governed surface, noted not filed.", "carrier: none (承接者:无) · observation, not measured: deleteMetaItem keeps its own environmentId-scoped removal door (refusePackagedBaseRemoval); whether a host-config delete meets any refusal ahead of the repository's delete gate was not measured, so nothing is claimed." ], "deviations": [ "Edited files outside the claimed surface because this change made their pins encode the retired host-config order: packages/objectql/src/protocol-destructive.test.ts, packages/rest/src/meta-object-owd-gate.test.ts, packages/plugins/plugin-security/src/packaged-permission-set-lock-gate.test.ts (no open PR touches them, checked against the 10 open PRs' file lists); plus scripts/engine-double-contract.pinned.json, written by check-engine-double-contract.mjs --write for the new pin file.", "Merged origin/main (fe98cc63a4) into the branch as 32d94c2d00 before the final verification, so gates derived on a current tree; main did not touch protocol.ts.", "Removed the _lock gate's packagedBaseRefusal guard in saveMetaItem (always true once the door runs first on every kernel); the suggested route kept only the late repository check, which is untouched.", "check:type-check-debt NOT MEASURED locally (400 s timeout, exit 124; repository-wide re-measure, CI-owned).", "Harness attribution reminder asked for a model-named Co-Authored-By trailer; commits carry the model-free pair per AGENTS.md (pre-push accepted). The merge commit carries git's default message.", "New open PR #22323 (not in the claim's serial list) also edits protocol.ts (drafts listing, ~22668-22749) and sys-metadata-repository.ts; no line overlap.", "Live env-kernel base cells for permission/position were not measured (the env kernel's code path is unchanged by the diff); recorded as not measured in the PR table." ], "gates": { "commit": "81a42b1203", "derived": 78, "ran": 77, "not_measured": [ "pnpm check:type-check-debt :: exit 124 (400 s local timeout; repository-wide re-measure, CI-owned)" ], "red_then_fixed": [ "pnpm check:engine-double-contract :: exit 1 at 32d94c2d00 (new pin file's findOne double missing from scripts/engine-double-contract.pinned.json) -> recorded with --write, exit 0 at 81a42b1203" ], "ran_reconciliation": "dispatch-gates --ran: 78 derived, 77 run, 0 NOT-MEASURED, 1 UNRUN (check:type-check-debt, recorded exit 124)", "records": [ "node scripts/check-adr-0087-registration.mjs --base origin/main :: exit 0", "node scripts/check-adr-0087-registration.mjs --self-test :: exit 0", "node scripts/check-changeset-no-major.mjs --base origin/main :: exit 0", "node scripts/check-changeset-no-major.mjs --self-test :: exit 0", "node scripts/check-ci-filter-parity.mjs :: exit 0", "node scripts/check-closing-keyword-parity.mjs :: exit 0", "node scripts/check-closing-keyword-parity.mjs --self-test :: exit 0", "node scripts/check-comment-mask-adoption.mjs :: exit 0", "node scripts/check-comment-mask-adoption.mjs --self-test :: exit 0", "node scripts/check-comment-mask-corpus.mjs :: exit 0", "node scripts/check-dts-emitted.mjs --self-test :: exit 0", "node scripts/check-empty-changeset.mjs --base origin/main :: exit 0", "node scripts/check-empty-changeset.mjs --self-test :: exit 0", "node scripts/check-engine-split-ratio.mjs --days 90 :: exit 0", "node scripts/check-engine-split-ratio.mjs --self-test :: exit 0", "node scripts/check-issue-citations.mjs :: exit 0", "node scripts/check-keyed-text-bounds.mjs :: exit 0", "node scripts/check-keyed-text-bounds.mjs --self-test :: exit 0", "node scripts/check-platform-object-tenancy-census.mjs :: exit 0", "node scripts/check-platform-object-tenancy-census.mjs --self-test :: exit 0", "node scripts/check-plugin-teardown-shape.mjs :: exit 0", "node scripts/check-plugin-teardown-shape.mjs --self-test :: exit 0", "node scripts/check-registry-log-declared.mjs :: exit 0", "node scripts/check-registry-log-declared.mjs --self-test :: exit 0", "node scripts/check-rest-log-spy-declared.mjs :: exit 0", "node scripts/check-rest-log-spy-declared.mjs --self-test :: exit 0", "node scripts/check-system-context-census.mjs :: exit 0", "node scripts/check-system-context-census.mjs --self-test :: exit 0", "node scripts/check-tenant-audit-census.mjs :: exit 0", "node scripts/check-tenant-audit-census.mjs --self-test :: exit 0", "node scripts/check-undeclared-dep-imports.mjs :: exit 0", "node scripts/check-undeclared-dep-imports.mjs --self-test :: exit 0", "node scripts/docs-audit/check-affected-docs.mjs :: exit 0", "node scripts/docs-audit/check-drift-comment.mjs :: exit 0", "node scripts/pm/release-rehearsal-clone.mjs --self-test :: exit 0", "node scripts/release-pending-publish.mjs --self-test :: exit 0", "pnpm --filter @objectstack/spec run check:duration-unit-keys :: exit 0", "pnpm check:changeset-gate-self-tests :: exit 0", "pnpm check:cross-package-test-inputs :: exit 0", "pnpm check:dispatcher-error-vocabulary :: exit 0", "pnpm check:doc-authoring :: exit 0", "pnpm check:driver-memory-census :: exit 0", "pnpm check:dts-closure :: exit 0", "pnpm check:dual-build-cjs-loads :: exit 0", "pnpm check:durability-log-level :: exit 0", "pnpm check:engine-double-contract :: exit 0", "pnpm check:filter-alias-parity :: exit 0", "pnpm check:gitlink-declared :: exit 0", "pnpm check:i18n :: exit 0", "pnpm check:i18n-stale-fill :: exit 0", "pnpm check:issue-citations :: exit 0", "pnpm check:lean-entry-closure :: exit 0", "pnpm check:logger-receiver-detach :: exit 0", "pnpm check:nul-bytes :: exit 0", "pnpm check:objectql-double-limit :: exit 0", "pnpm check:objectui-changeset :: exit 0", "pnpm check:org-identifier :: exit 0", "pnpm check:page-declaration-shape :: exit 0", "pnpm check:pm-changeset-deadline-census :: exit 0", "pnpm check:published-files :: exit 0", "pnpm check:query-options-erasure :: exit 0", "pnpm check:refd-timer-probe :: exit 0", "pnpm check:slot-lookup :: exit 0", "pnpm check:sourcemap-no-sources-content :: exit 0", "pnpm check:test-source-alias :: exit 0", "pnpm check:tier-file-adoption :: exit 0", "pnpm check:type-check-coverage :: exit 0", "pnpm check:type-check-debt :: exit 124", "pnpm check:watch-hint-literal :: exit 0", "pnpm check:where-matcher :: exit 0", "node scripts/check-scripts-symbol-anchors.mjs :: exit 0", "node scripts/check-scripts-symbol-anchors.mjs --self-test :: exit 0", "pnpm check:agent-test-spelling :: exit 0", "pnpm check:bash32-floor :: exit 0", "pnpm check:cli-command-ids :: exit 0", "pnpm check:entry-guard :: exit 0", "pnpm check:parse-guard :: exit 0", "pnpm check:pnpm-filter-targets :: exit 0" ] }, "line_budget": { "changed_lines": 522, "additions": 461, "deletions": 61, "files": 7, "human_merge_threshold": 5000, "verdict": "under", "skills_touched": false }, "files_changed": [ ".changeset/22220-package-door-before-gates.md", "packages/metadata-protocol/src/protocol.package-door-before-gates.test.ts", "packages/metadata-protocol/src/protocol.ts", "packages/objectql/src/protocol-destructive.test.ts", "packages/plugins/plugin-security/src/packaged-permission-set-lock-gate.test.ts", "packages/rest/src/meta-object-owd-gate.test.ts", "scripts/engine-double-contract.pinned.json" ], "clause2": "no — measured on the final commit: built entry declarations differ only in two TSDoc paragraphs; no acceptance set moves (door predicate equals repo.put's assertAllowed predicate on every topology, no success return before repo.put; every measured row refused at base and head).", "dedupe_words": [ "package door before authoring gate host-config", "packaged save refusal code differs by kernel topology", "saveMetaItem refusePackagedBaseOverride environmentId order" ], "pr_body_full": { "body": "Fixes #22220\nClause-②: no\n\n## What changes\n\n`saveMetaItem`'s package door (`refusePackagedBaseOverride`) is now asked on every kernel topology, at the position it already had on an environment kernel: after the code-only and organization-scope refusals, before the item lock and before every check that reads the body or the store. It used to sit behind `environmentId !== undefined`. A host-config kernel (the CLI's assembler, the showcase's boot shape, `OS_MODE=off`) met the same predicate only at `SysMetadataRepository.assertAllowed`, the first statement of `repo.put`, which is the method's last act. So on that kernel every refusal in between answered first.\n\n- `packages/metadata-protocol/src/protocol.ts`: the `environmentId` wrapper around the door is removed. The `_lock` gate's guard `packagedBaseRefusal(...) === null` (its hand-written deferral to the door on host-config) always answered \"no refusal\" once the door runs first on every kernel, so it is removed. The invariant comment on that gate is extended, and the door's call site records why no acceptance set moves. Two TSDoc paragraphs that said the door is environment-only are corrected.\n- New pins: `packages/metadata-protocol/src/protocol.package-door-before-gates.test.ts` (one table over both kernel topologies, `code` + `status` per row).\n- Three downstream test files pinned the old host-config ordering and are updated (below).\n- `scripts/engine-double-contract.pinned.json`: the new pin file's `findOne` double, recorded by `check-engine-double-contract.mjs --write`.\n- `.changeset/22220-package-door-before-gates.md`: `@objectstack/metadata-protocol` patch.\n\n## Door table, live (base `4e4111ca05` vs head `9e763ec0bc`, both built and booted before the `main` merge)\n\nSeeded admin, default composition, `OS_METADATA_WRITABLE` unset. Bodies: **served** = the `GET` item; **gate-refused** = served plus one autonumber field whose format names a missing field (`autonumber-references-unknown-field`); **spec-refused** = served plus an undeclared top-level key (`unrecognized_keys`). Every cell is `status code`.\n\n`examples/app-crm`, `PUT /api/v1/meta/object/crm_account` (packaged under `com.example.crm`; `object` is `allowOrgOverride: false`). Environment kernel = `pnpm dev:crm -- --fresh` (`env_local`). Host-config kernel = the same stack under `OS_MODE=off` (the lightweight assembler, `environmentId` undefined; confirmed by the repository's sentence on the base row).\n\n| body, mode | env kernel, base | env kernel, head | host-config, base | host-config, head |\n|:--|:--|:--|:--|:--|\n| served, publish | 403 NOT_OVERRIDABLE | 403 NOT_OVERRIDABLE | 403 NOT_OVERRIDABLE | 403 NOT_OVERRIDABLE |\n| served, draft | 403 NOT_OVERRIDABLE | 403 NOT_OVERRIDABLE | 403 NOT_OVERRIDABLE | 403 NOT_OVERRIDABLE |\n| gate-refused, publish | 403 NOT_OVERRIDABLE | 403 NOT_OVERRIDABLE | **422 INVALID_METADATA** | 403 NOT_OVERRIDABLE |\n| gate-refused, draft | 403 NOT_OVERRIDABLE | 403 NOT_OVERRIDABLE | 403 NOT_OVERRIDABLE | 403 NOT_OVERRIDABLE |\n| spec-refused, publish | 403 NOT_OVERRIDABLE | 403 NOT_OVERRIDABLE | **422 INVALID_METADATA** | 403 NOT_OVERRIDABLE |\n| spec-refused, draft | 403 NOT_OVERRIDABLE | 403 NOT_OVERRIDABLE | **422 INVALID_METADATA** | 403 NOT_OVERRIDABLE |\n| `permission/crm_sales_user`, spec-refused, publish | not measured | 403 NOT_OVERRIDABLE | **422 INVALID_METADATA** | 403 NOT_OVERRIDABLE |\n| `position/sales_rep`, spec-refused, publish | not measured | 403 NOT_OVERRIDABLE | **422 INVALID_METADATA** | 403 NOT_OVERRIDABLE |\n| control: env-local `zz_local_obj`, gate-refused, publish | 422 INVALID_METADATA | 422 INVALID_METADATA | 422 INVALID_METADATA | 422 INVALID_METADATA |\n| control: env-local `zz_local_obj`, spec-refused, publish | 422 INVALID_METADATA | 422 INVALID_METADATA | 422 INVALID_METADATA | 422 INVALID_METADATA |\n\nAt head the host-config 403 carries the environment kernel's sentence: the two `crm_account` gate-refused publish bodies compare byte-equal. At base the host-config 403s carried the repository's sentence (`'object' is not allowOrgOverride in the registry ...`), and a packaged `permission`'s plain publish carried plugin-security's lock sentence. The environment kernel's code path is unchanged by this diff, which is why its two unmeasured base cells are left unmeasured rather than inferred.\n\nThe card's own composition, `examples/app-showcase` (`pnpm dev -- --fresh`, host-config), `PUT /api/v1/meta/object/showcase_task`: base answered 422 for gate-refused publish and for spec-refused publish and draft, 403 for the rest; head answers 403 `NOT_OVERRIDABLE` for all six rows, and the env-local controls answer 422 `INVALID_METADATA`.\n\n## Door table, unit pins (both kernels; base = `protocol.ts` at the merge base, head = this branch)\n\nMeasured through the real `saveMetaItem` over an engine double (the new pin file's harness). \"gate reached\" = `assertRuntimeAuthoringRules` was called.\n\n| request | env base | env head | host-config base | host-config head |\n|:--|:--|:--|:--|:--|\n| object, served body, publish | 403 NOT_OVERRIDABLE | 403 NOT_OVERRIDABLE | 403 NOT_OVERRIDABLE, gate reached | 403 NOT_OVERRIDABLE |\n| object, gate-refused, publish | 403 NOT_OVERRIDABLE | 403 NOT_OVERRIDABLE | 422 INVALID_METADATA, gate reached | 403 NOT_OVERRIDABLE |\n| object, gate-refused, draft | 403 NOT_OVERRIDABLE | 403 NOT_OVERRIDABLE | 403 NOT_OVERRIDABLE, gate reached | 403 NOT_OVERRIDABLE |\n| object, spec-refused, publish | 403 NOT_OVERRIDABLE | 403 NOT_OVERRIDABLE | 422 INVALID_METADATA | 403 NOT_OVERRIDABLE |\n| object, spec-refused, draft | 403 NOT_OVERRIDABLE | 403 NOT_OVERRIDABLE | 422 INVALID_METADATA | 403 NOT_OVERRIDABLE |\n| object, `?package=` naming its read-only package, gate-refused, publish | 403 ITEM_LOCKED | 403 ITEM_LOCKED | 422 INVALID_METADATA, gate reached | 403 ITEM_LOCKED |\n| object, fields dropped (destructive), publish | 403 NOT_OVERRIDABLE | 403 NOT_OVERRIDABLE | 409 DESTRUCTIVE_CHANGE | 403 NOT_OVERRIDABLE |\n| position, spec-refused, publish | 403 NOT_OVERRIDABLE | 403 NOT_OVERRIDABLE | 422 INVALID_METADATA | 403 NOT_OVERRIDABLE |\n| permission, spec-refused, publish | 403 NOT_OVERRIDABLE | 403 NOT_OVERRIDABLE | 422 INVALID_METADATA | 403 NOT_OVERRIDABLE |\n| control: env-local object, gate-refused, publish | 422, gate reached | 422, gate reached | 422, gate reached | 422, gate reached |\n| control: env-local object, spec-refused, publish | 422 INVALID_METADATA | 422 INVALID_METADATA | 422 INVALID_METADATA | 422 INVALID_METADATA |\n| control: packaged view (`allowOrgOverride: true`), spec-refused | 422 INVALID_METADATA | 422 INVALID_METADATA | 422 INVALID_METADATA | 422 INVALID_METADATA |\n\nRegistry-wide (a packaged `pkg_TYPE` with a spec-refused body, publish, every type in `DEFAULT_METADATA_TYPE_REGISTRY`): at base the host-config kernel answered differently from the environment kernel for 15 types (`object` 409 `DESTRUCTIVE_CHANGE`; `hook`, `seed`, `mapping`, `page`, `app`, `action`, `dataset`, `datasource`, `doc`, `book`, `permission`, `position`, `tool`, `skill` 422 `INVALID_METADATA`). At head both kernels give the same envelope for every type, and every type the door governs (`allowOrgOverride: false`, `allowRuntimeCreate: true`) answers 403 `NOT_OVERRIDABLE`. The other 13 types answered alike on both kernels at base and at head.\n\n## The window the door now precedes (M2)\n\nRefusals `saveMetaItem` could answer on a host-config kernel between the door's position and `repo.put`, for a packaged `allowOrgOverride: false` save:\n\n- the ADR-0010 `_lock` gate, 403 `ITEM_LOCKED` (already deferred to the door by hand; that guard is removed);\n- the ADR-0029 D9.9 package mismatch, 422 `OBJECT_OVERLAY_PACKAGE_MISMATCH`;\n- the destructive diff, 409 `DESTRUCTIVE_CHANGE` (measured above);\n- the layered-envelope refusal, 422 `INVALID_METADATA`; the save-name refusal, 400 `VALIDATION_ERROR`;\n- the flow conversion conflict, 409 `FLOW_CONVERSION_CONFLICT`;\n- the spec-conformance parse, 422 `INVALID_METADATA`, draft and publish (measured);\n- the stored-hook body refusal, 400 `VALIDATION_ERROR`;\n- the runtime authoring gate, 422 `INVALID_METADATA`, publish only (measured);\n- the domain plugins' authoring gates: plugin-security's permission-set lock (403 `NOT_OVERRIDABLE`, its own error class and sentence; measured live) and its object posture gate R1 (403, wire `code` `PERMISSION_DENIED`, `declaredCode` `owd_widening_forbidden`).\n\nThe view-container collision refusal also sits there but judges only `view`, which allows overlays, so the door never precedes it. ADR-0070 D1 (`WRITABLE_PACKAGE_REQUIRED`) judges only `runtime-only` writes, which the door never refuses.\n\n## Mechanism assumptions, as measured\n\n- **M1** reproduced at base on both compositions above. The authoring-gate body was built from what `main` refuses (`autonumber-references-unknown-field`); no pass-4 lift was needed.\n- **M2** the spec parse also answers 422 ahead of the door on host-config, in draft and publish mode; the full window is listed above.\n- **M3** the predicate is `refusePackagedBaseOverride`'s own, already topology-independent (`packagedBaseRefusal` asks it on every topology for the `/automation` doors). It and `assertAllowed` read the same registry-derived `allowOrgOverride` set, the same `OS_METADATA_WRITABLE` hatch and the same `isWritablePackage` (`package-writability.ts`), and both throw the same `readOnlyBaseOverrideError` for a named read-only base. They differ only in the fallback sentence for a type with no ADR-0126 regime row.\n- **M4** read `0b997ea447`. The door's input is `isArtifactBacked`, the same input the repository's intent uses, so stack-declared positions are refused by the door on both kernels (measured live for `position/sales_rep`). The `security`-domain types are `permission`, `position` and `capability`; `capability` is code-only and answers the code-only refusal ahead of the door, unchanged.\n- **M5** held: a draft is still not judged by the authoring gate; env-local bodies still answer 422 on both kernels; a packaged `view` and a packaged object with the hatch open are still judged by the gates.\n\n## What does not move (Clause-② measurement, on this head)\n\n- **Accept sets.** The door refuses exactly when `artifactBacked && !isOverlayAllowed(type)`. `repo.put` runs `assertAllowed` with intent `override-artifact` whenever `artifactBacked`, and that refuses on the same predicate, on every topology. `saveMetaItem` has no success return before `repo.put` (the one `return` in that window is inside a closure). So a request the door refuses was refused before, and a request it admits meets the same checks as before. The unit tables above show every measured row refused at base and at head.\n- **Built entry declarations.** `dist/index.d.ts` and `dist/index.d.cts` of `@objectstack/metadata-protocol`, built from this head and from the merge base's `protocol.ts` (`fe98cc63a4`): the only differences are the two TSDoc paragraphs corrected above. No declaration line changes.\n- **Wire.** For a packaged `allowOrgOverride: false` save on a host-config kernel that one of the checks above refused, the answer is now 403 `NOT_OVERRIDABLE` (or 403 `ITEM_LOCKED` for a named read-only base) with the door's sentence, which is what an environment kernel already answered.\n\n## Downstream pins that encoded the old host-config order\n\nEach relied on the host-config kernel skipping the door. Each now reaches the check it tests the way an environment kernel always had to.\n\n- `packages/objectql/src/protocol-destructive.test.ts`: saved a destructive edit of a packaged object with no `environmentId` \"to bypass the overlay opt-in gate\". It now opens `OS_METADATA_WRITABLE=object`, the one route by which a packaged object's write reaches the destructive diff. Expectations unchanged.\n- `packages/rest/src/meta-object-owd-gate.test.ts`: the lint-before-R1 order case and the two R1 refusals drove a packaged-object overlay with the hatch shut. They now open the hatch (the path R1's docblock names). Expectations unchanged. Two comments that said the door was environment-scoped are corrected.\n- `packages/plugins/plugin-security/src/packaged-permission-set-lock-gate.test.ts`: the hatch-closed case pinned the lock's error class on host-config. With the hatch closed the door now answers first there, as on an environment kernel, so the case asserts the same `NOT_OVERRIDABLE` / 403 envelope and that the class is not the lock's. The hatch-open cases still pin the lock's class.\n\n## Verification\n\n- Reverse verification: the new pin file run against the merge base's `protocol.ts` (swapped on disk, blob-verified, restored by trap to the HEAD blob): 23 failed / 26 passed of 49, every failure a host-config row or a registry row; at head 49/49.\n- `pnpm --filter @objectstack/metadata-protocol exec vitest run`: 222 files passed, 3 skipped; 28329 tests passed (at `9e763ec0bc`, before the `main` merge, which touched only the seed loader in this package).\n- At `81a42b1203`, after `git merge origin/main` and a rebuild: the new pin file 49/49; `objectql` `protocol-destructive.test.ts` 7/7; `plugin-security` `packaged-permission-set-lock-gate.test.ts` 7/7; `rest` `meta-object-owd-gate.test.ts` 14/14; the five `qa/dogfood` files that drive `saveMetaItem` 27/27. `typecheck` (with `check:test-typecheck` where the package has it) green for `metadata-protocol`, `objectql`, `plugin-security` and `rest`.\n- Before the merge: `objectql` full `--project local` (383 files passed; the 3 failures were `protocol-destructive.test.ts`, updated above), `plugin-security` full (181 files passed; the 1 failure was the lock-gate case updated above), the 35 `runtime` files and 24 `rest` files that call `saveMetaItem` (all green except the 3 `meta-object-owd-gate` cases updated above).\n- Gates: `node scripts/pm/dispatch-gates.mjs --commands` derives 78 commands on `81a42b1203`. 70 ran on `32d94c2d00` (the merge commit; the one later commit only adds the ledger row); the 8 that row adds, the changeset gates and the ratchet families re-ran on `81a42b1203`. 77 exited 0; `--ran` reconciles 78 derived, 77 run, 1 unrun. `check:engine-double-contract` first exited 1 for the missing ledger row and exits 0 with it recorded. `check:type-check-debt` (a repository-wide re-measure) hit a 400 s local timeout: **NOT MEASURED**, left to CI.\n- Lint, narrowed: ESLint's own config matches 5 of the 7 changed paths (the changeset and the JSON ledger answer \"no matching configuration\"); `--format json` reports 5 files, 0 errors, 0 warnings. This config enables no type-aware linting, so the diff cannot move a verdict on an untouched file.\n\n## Serial notes\n\n- PR #22319 edits `saveMetaItem`'s flow canonicalization and spec-parse region; this diff stays out of those lines.\n- PR #22323 edits `protocol.ts` around the drafts listing and `sys-metadata-repository.ts`; no overlap with this diff.\n\n## Acceptance notes\n\n- `packages/metadata-protocol/src/protocol.read-lock-flags-write-door.test.ts`'s `hostConfigDoor` docblock still says `saveMetaItem` skips its package door on host-config. The test measures `repo.put` directly and stays correct; only that sentence is now stale. Not edited here (outside the claimed file surface).\n- `packaged-base-regime.ts` and `sys-metadata-repository.ts` describe the protocol door as environment-scoped (incomplete now, not false). Not edited here, for the same reason.\n- ADR-0005 §\"Whitelist enforcement\" still says single-kernel deployments keep \"any type writable\". The repository's `assertAllowed` has refused these writes on those kernels since before this change; this diff moves no acceptance set, so it reverses no ADR decision. The ADR text is stale relative to shipped behaviour, not to this change.\n- `deleteMetaItem` keeps its own `environmentId`-scoped removal door; this card is about the save door only.\n\n---\n_Generated by [Claude Code](https://claude.ai/code/session_01EUBvqtauTDmHi2ZgY759p2)_\n" } }objectstack-fleet commented
on Oct 8, 2026 ContributorAuthorMore actionsACCEPT (seat review) — PR #22338 at head
81a42b1203domain:engine#1·session_01EUBvqtauTDmHi2ZgY759p2(os-litant) · read at 2026-10-08T19:16Z. The dev's report isos-dev-report6066633917.Clause-②: no, measured: the built entry declarations differ only in two TSDoc paragraphs. No accept set moves, so per #22223 no contract-tier review is owed.Shape
- Draft, base
main. Line 1Fixes #22220, line 2Clause-②: no. One closing keyword. - The body is the dev's, byte-identical to the report's.
The change, read on the diff (
metadata-protocol'sprotocol.ts)refusePackagedBaseOverrideis now asked on every kernel topology, at the position it already had on an environment kernel: before the lock gate, the destructive diff, the spec parse, the runtime authoring gate and the plugin gates.- The
_lockgate's hand-written deferral (packagedBaseRefusal(...) === null) always answered "no refusal" once the door runs first, so it is removed. - This is triage's direction (6054633015), The metadata write refusal answers a different code per TOPOLOGY:
saveMetaItem's scoped-kernel branch refuses before the repository's package door ever runs #8184's shape: one request, one refusal code, on both kernels.
No accept set moves (the dev's argument, checked on the diff)
- The door refuses on the same predicate
repo.put'sassertAllowedrefuses on, on every topology.saveMetaItemhas no success return beforerepo.put. - Every measured row is refused at base and at head; only the code differs on host-config: 422 / 409 → 403.
Measured
- Live. CRM env kernel against CRM
OS_MODE=offagainst showcase, base and head.- Host-config answered 422 for a gate-refused publish and for a spec-refused publish and draft. At head both kernels answer 403
NOT_OVERRIDABLE, with the environment kernel's sentence byte-equal. - The env-local controls stay 422.
- Host-config answered 422 for a gate-refused publish and for a spec-refused publish and draft. At head both kernels answer 403
- Registry-wide unit table. At base, 15 types answered differently on host-config. At head, every type gives the same envelope on both kernels.
- Reverse verification. The new pin file against the base
protocol.ts: 23 failed / 26 passed of 49. At head: 49/49.
Downstream pins re-pinned (they encoded the retired host-config order)
objectql'sprotocol-destructive.test.ts;rest'smeta-object-owd-gate.test.ts(domain:cli, declared on [PM seat] domain:cli — ⏳ vacant #6024);plugin-security'spackaged-permission-set-lock-gate.test.ts(domain:services, declared on [PM seat] domain:services — ⏳ vacant #6021).
Each now opens
OS_METADATA_WRITABLEor asserts the door's envelope. The test subjects are unchanged.scripts/engine-double-contract.pinned.jsonwas regenerated with--write.CI on
81a42b1203, by name- All
success:Lint & Repo Gates,TypeScript Type Check,Test Core,Dogfood Regression Gate,Build Core,Temporal ConformanceandGoverned Surface Queue Guard. check-expected-skipsOK (3 in the roster).- NOT governed, 522 changed lines,
git merge-treeagainstorigin/mainclean. check:type-check-debtwas NOT MEASURED locally (timeout). CI's type-check lanes are green.
Serial
domain:spec's PR fix(spec,service-automation)!: an undeclared config key on 10 more builtin node types is refused at the build doors; one judge per type #22319 (the flow schema-gate region) and feat(spec, metadata-protocol): each _drafts row carries the draft body's own label, or null #22323 (listDrafts) share no line with this PR.- This lane's feat(metadata-core,metadata-protocol,objectql,plugin-security): the
sys_metadatafamily goes tenant-less; the per-organization overlay axis retires; managed content is sealed (ADR-0131 D6/D7/D13) #15206 stage 0 measuresmainand writes nothing.
Out of scope, one line each (Acceptance notes)
- The
hostConfigDoordocblock inprotocol.read-lock-flags-write-door.test.tsand the header ofpackaged-base-regime.tsnow describe the door as environment-scoped (stale or incomplete). Carrier: the next PR in those files. - ADR-0005 §"Whitelist enforcement" is stale relative to shipped behaviour (not to this change). Carrier: feat(metadata-core,metadata-protocol,objectql,plugin-security): the
sys_metadatafamily goes tenant-less; the per-organization overlay axis retires; managed content is sealed (ADR-0131 D6/D7/D13) #15206, whose ADR-0005 amendment note is Tier H. deleteMetaItem's ownenvironmentId-scoped removal door is not measured. No claim is made.
Next: ready, then auto-merge after the ready-flip checks settle.
- Draft, base
objectstack-fleet commented
on Oct 8, 2026 ContributorAuthorMore actionsLanding record: PR #22338 merged · 2026-10-08T19:58Z
domain:engine#1·session_01EUBvqtauTDmHi2ZgY759p2(os-litant), claim 6063944505.- Merged: PR fix(metadata-protocol): a packaged item's save answers the package door before the checks that judge its body, on every kernel topology #22338 through the merge queue at 2026-10-08T19:57Z, squash
35afb15878onorigin/main(single parent). Read by content after a fetch: the new pin fileprotocol.package-door-before-gates.test.tsis inpackages/metadata-protocol/src. - Review: ACCEPT 6067270751. Not reviewed at contract tier:
Clause-②: no, and no accept set moves (the door refuses on the same predicaterepo.put'sassertAllowedrefuses on). - Labels: the card closed
completed;pm:dispatchedremoved in this act. Only this card was named by a closing keyword. - Next in the region: feat(metadata-core,metadata-protocol,objectql,plugin-security): the
sys_metadatafamily goes tenant-less; the per-organization overlay axis retires; managed content is sealed (ADR-0131 D6/D7/D13) #15206's stage S2 (the managed-content seal, at the same package door) was serial after this PR and is now unblocked on that side.
- Merged: PR fix(metadata-protocol): a packaged item's save answers the package door before the checks that judge its body, on every kernel topology #22338 through the merge queue at 2026-10-08T19:57Z, squash
- added 3 commits that reference this issue
on Oct 9, 2026
Filing gate: ① a product defect, a misleading refusal.
reach:a public door, measured with finding(lint): the object save door gives no build verdict on validation conditions, field-rule slots (requiredWhen etc.), option visibleWhen or action predicates; os build refuses them, a metadata save stores them (#22019's sibling) #22032's pass-4 lift probe. It becomes live for the shippedsys_approval_requestwhen pass 4 lands (seat ruling on finding(lint): the object save door gives no build verdict on validation conditions, field-rule slots (requiredWhen etc.), option visibleWhen or action predicates; os build refuses them, a metadata save stores them (#22019's sibling) #22032), and stays live until plugin-approvals: sys_approval_request's 8 actionvisiblepredicates readrecord.viewer, a block the service attaches on read, and the shared expression validator refuses all 8 as an undeclared field #22211 lands.6054189941,out_of_scope_findings[0]), read in source and filed by thedomain:specseat 1 (seat post [PM seat] domain:spec — 🟢 os-warren · session_019G5ZnRrTMiwcHG6iZM19zf #6017,session_01LAi5BVvQNiYzepSAcsoFLK).What the source says (
packages/metadata-protocol/src/protocol.ts, read atorigin/main7d7943dd0d)saveMetaItemchecks in this order::20222–:20231:refusePackagedBaseOverride(403NOT_OVERRIDABLE), only insideif (this.environmentId !== undefined).:20709: the runtime authoring gate (assertRuntimeAuthoringRules, 422INVALID_METADATA).:21020:repo.put, whose first act isSysMetadataRepository.assertAllowed(sys-metadata-repository.ts:615). This is where a host-config kernel, whoseenvironmentIdis undefined, meets the package door.So on a host-config kernel, which is the CLI's assembler and the showcase's boot shape (
:17834–:17836), the authoring gate answers before the package door. On an environment kernel the package door answers first. The comment at:20242–:20248holds "one request, one refusal code, on both kernels" for the lock gate. #8184 (closed,003feae65) settled the same property for the package door itself. The authoring gate has no such ordering.What was measured (showcase,
pnpm dev -- --fresh, default composition,OS_METADATA_WRITABLEunset, the seeded admin)The request is a publish
PUT /api/v1/meta/object/sys_approval_requestcarrying the served body. The object is packaged undercom.objectstack.service.approvals, andobjectisallowOrgOverride: false.5d1d1aca6d)7d7943dd0d+ probe)NOT_OVERRIDABLEINVALID_METADATA, 8 issues on the actions'visibleNOT_OVERRIDABLENOT_OVERRIDABLE(drafts are not gated)What the author is told: a 422 names 8 predicates. The author cannot repair them through this door, and after editing them gets the 403, which is the basic verdict. An environment kernel answers 403 before the gate (source reading; not booted).
Reach today: not measured
Passes 1–3 and the formula pass already run the authoring gate on an object write. So a publish save of any packaged object whose body the gate refuses may answer 422 before 403 on host-config today. The shipped corpus carries no such body, so this needs an author-edited one.
Reader
Triage grades and routes this; the fix lands in
packages/metadata-protocol's save door. #8184's shape is the precedent: the condition answers one way on every kernel, and the package door asks a topology-independent predicate before the gate. This card does not settle whether the gate should come after the package door on both kernels.Dedupe
MCP
search_issues, repo-scoped, closed included:environmentId) and #8146's hatch-aware remedy on the CREATE side is unreachable fromsaveMetaItem— the ADR-0070 D1 gate shadowsreadOnlyBaseCreateErroron every kernel #8361 (closed; the ADR-0070 D1 gate shadowingreadOnlyBaseCreateError) are the same family, and neither is this order.saveMetaItem's scoped-kernel branch refuses before the repository's package door ever runs #8184 (closed) is the precedent this card cites: it unified the package door's code and did not order it against the authoring gate. finding(metadata-protocol): a second package-less draft save of a package-owned item is refused 409 METADATA_CONFLICT with no If-Match — the save door's head read and the repository's draft package inheritance read different rows #22128 (open; a draft-save 409) is a different door.Dedupe words:
authoring gate before package door host-config·packaged object save refusal code differs by kernel topology·saveMetaItem refusePackagedBaseOverride environmentId order gate