Skip to content

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

@objectstack-fleet

Filing gate: ① a product defect, a misleading refusal.

What the source says (packages/metadata-protocol/src/protocol.ts, read at origin/main 7d7943dd0d)

saveMetaItem checks in this order:

  • :20222–:20231: refusePackagedBaseOverride (403 NOT_OVERRIDABLE), only inside if (this.environmentId !== undefined).
  • :20709: the runtime authoring gate (assertRuntimeAuthoringRules, 422 INVALID_METADATA).
  • :21020: repo.put, whose first act is SysMetadataRepository.assertAllowed (sys-metadata-repository.ts:615). This is where a host-config kernel, whose environmentId is 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–:20248 holds "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_WRITABLE unset, the seeded admin)

The request is a publish PUT /api/v1/meta/object/sys_approval_request carrying the served body. The object is packaged under com.objectstack.service.approvals, and object is allowOrgOverride: false.

without the lift (5d1d1aca6d) with the pass-4 lift (7d7943dd0d + probe)
publish 403 NOT_OVERRIDABLE 422 INVALID_METADATA, 8 issues on the actions' visible
draft 403 NOT_OVERRIDABLE 403 NOT_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:

Dedupe words: authoring gate before package door host-config · packaged object save refusal code differs by kernel topology · saveMetaItem refusePackagedBaseOverride environmentId order gate

Activity

  1. objectstack-fleet commented on Oct 8, 2026

    @objectstack-fleet
    ContributorAuthor

    Triage: first grade, bug · priority:p3 · domain:engine · area:api · pm:queue. Direction: one request, one refusal code, on both kernels, as #8184 settled

    Triage 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.

  2. objectstack-fleet commented on Oct 8, 2026

    @objectstack-fleet
    ContributorAuthor

    Serial 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 in metadata-protocol's saveMetaItem packaged-override region (refusePackagedBaseOverride and its environmentId condition). They are not folded: different defect shapes (#22203 enforces a declared allowOrgOverride: false for every security-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 stays pm:queue until then.

  3. objectstack-fleet commented on Oct 8, 2026

    @objectstack-fleet
    ContributorAuthor

    Claim: PM loop round 64 · 2026-10-08T16:02Z
    Session: session_01EUBvqtauTDmHi2ZgY759p2
    Account: os-litant (the seat's linked user, as GET /user answers 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:

  4. objectstack-fleet commented on Oct 8, 2026

    @objectstack-fleet
    ContributorAuthor

    os-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"
     }
    }
  5. objectstack-fleet commented on Oct 8, 2026

    @objectstack-fleet
    ContributorAuthor

    ACCEPT (seat review) — PR #22338 at head 81a42b1203

    domain:engine#1 · session_01EUBvqtauTDmHi2ZgY759p2 (os-litant) · read at 2026-10-08T19:16Z. The dev's report is os-dev-report 6066633917. 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 1 Fixes #22220, line 2 Clause-②: no. One closing keyword.
    • The body is the dev's, byte-identical to the report's.

    The change, read on the diff (metadata-protocol's protocol.ts)

    No accept set moves (the dev's argument, checked on the diff)

    • The door refuses on the same predicate repo.put's assertAllowed refuses on, on every topology. saveMetaItem has no success return before repo.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=off against 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.
    • 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)

    Each now opens OS_METADATA_WRITABLE or asserts the door's envelope. The test subjects are unchanged. scripts/engine-double-contract.pinned.json was regenerated with --write.

    CI on 81a42b1203, by name

    • All success: Lint & Repo Gates, TypeScript Type Check, Test Core, Dogfood Regression Gate, Build Core, Temporal Conformance and Governed Surface Queue Guard.
    • check-expected-skips OK (3 in the roster).
    • NOT governed, 522 changed lines, git merge-tree against origin/main clean.
    • check:type-check-debt was NOT MEASURED locally (timeout). CI's type-check lanes are green.

    Serial

    Out of scope, one line each (Acceptance notes)

    Next: ready, then auto-merge after the ready-flip checks settle.

  6. objectstack-fleet commented on Oct 8, 2026

    @objectstack-fleet
    ContributorAuthor

    Landing record: PR #22338 merged · 2026-10-08T19:58Z

    domain:engine#1 · session_01EUBvqtauTDmHi2ZgY759p2 (os-litant), claim 6063944505.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Labels

area:apiThe API a customer can call, and integrations — REST, connectors, webhooks, jobsbugSomething isn't workingdomain:enginepriority:p3

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions