Skip to content

fix(metadata-protocol)!: the direct metadata save refuses an object without its package namespace prefix - #24702

Merged
objectstack-fleet[bot] merged 8 commits into
mainfrom
claude/issue-24547-active-save-namespace-prefix
Oct 20, 2026
Merged

objectstack-fleet[bot] merged 8 commits into
mainfrom
claude/issue-24547-active-save-namespace-prefix

Conversation

@objectstack-fleet

@objectstack-fleet objectstack-fleet Bot commented Oct 20, 2026 •

Copy link
Copy Markdown
Contributor

Fixes #24547
Clause-②: no (narrowing) (the active save door newly refuses an object whose name lacks its package's namespace prefix, which today it makes live; a narrowing owes one CONTRACT_REVIEW_TIER review of record before the queue)

Ruling-ref: 6278955141 (the maintainer: 「同意」 — pull forward into 18.0 as a refusal). Surface amendment: 6280587888 (the per-item publish door: same rule, same code, same judge).

What changes

ADR-0129 D2: a package declares its prefix once (manifest.namespace), and every object it defines is named namespace_shortName. The package draft publish (publishPackageDrafts) refused an unprefixed object draft with NAMESPACE_PREFIX. The direct (publish-mode) saveMetaItem and the per-item publish (publishMetaItem) made the very same name active under the very same package. Now all three doors ask one judge and give one answer.

  • packages/metadata-protocol/src/protocol.ts
    • New private objectNamespacePrefixViolation(name, packageId). It reads the package's declared namespace from the installed manifest and returns validateObjectNamespacePrefix's own sentence, or null. A package with no namespace, and a write bound to no package, are not judged.
    • publishPackageDrafts' pre-flight now calls that judge. This is a pure refactor: same namespace read, same verdict, same code.
    • saveMetaItem asks the same judge for an object in publish mode. It is judged against the package the save writes: the one it names, or the one a package-less publish save resolved into. On a violation it throws code: 'NAMESPACE_PREFIX', status: 422, with the verbatim prescription.
      • Position: after the ADR-0029 D9.9 refusal, and before the protection/lock gates and the body parse.
      • Not asked of draft saves: the door that promotes the draft judges it, the publish pre-flight batch-atomically and the per-item publish before its promotion writes.
      • Not asked of the server-stated rewrites of stored rows (source set: migrate-stored, the flow-credential move).
      • The package copies (package-duplicate, package-unmanaged-import) are asked, like any author.
    • promoteDraftForPublish, the per-item publish's promotion, asks the same judge for an object draft on promoteInto, the package the active row is written under. On a violation it throws the same NAMESPACE_PREFIX / 422 with the same sentence, before repo.promoteDraft writes, so nothing is promoted and the draft stays a draft. The exemptions are the same: no package, no namespace, sys_*, and sealed managed content (as for the D9.9 refusal beside it). publishPackageDrafts also promotes through here, but its pre-flight refuses the same name first.
    • A comment in duplicatePackage that called the direct save "a door no prefix gate reads" is corrected.
  • No new error code. NAMESPACE_PREFIX is already registered to @objectstack/metadata-protocol in ERROR_CODE_LEDGER (packages/spec/src/api/error-code-ledger.zod.ts), and the publish pre-flight already stamps it. No ADR edit and no packages/spec change.
  • Changeset: .changeset/24547-active-save-namespace-prefix.md. It is minor and BREAKING, has FROM / TO / Fix lines covering both doors (the direct save and the per-item publish), the Clause-②: no (narrowing) line and adr-0087: not-required (no-migration-prescription). check:adr-0087-registration accepts that disposition.

Measured on the wire (fresh showcase on this branch, after the fix: the table at 3b94654c58; the per-item rows, and a re-read of the direct PUT and publish-drafts with the same answers, at afb7f17447)

request answer
POST /api/v1/packages com.v1.rollup 201 (namespace derived: rollup)
PUT /api/v1/meta/object/v1_report?package=com.v1.rollup 422 {"error":"Object 'v1_report' is missing the package namespace prefix. Rename it to 'rollup_v1_report' (namespace = 'rollup').","code":"NAMESPACE_PREFIX"}; GET /api/v1/meta/object/v1_report then 404
same door, missing sharingModel (sibling refusal, for the envelope) 422 {"error":"object/rollup_no_owd failed author-time validation: …","code":"INVALID_METADATA","issues":[…]}
PUT …/meta/object/rollup_v1_report?package=com.v1.rollup (prefixed) 200 state: active
PUT …/meta/object/v1_loose (no package) 200 state: active
PUT …/meta/object/v1_report?package=com.v1.rollup&mode=draft 200 state: draft (unchanged)
POST /api/v1/packages/com.v1.rollup/publish-drafts 200 outcome: refused, failed[0].code: NAMESPACE_PREFIX, the same sentence (unchanged)
POST /api/v1/meta/object/v1_report/publish?package=com.v1.rollup (per-item publish of that draft) 422 {"error":"Object 'v1_report' is missing the package namespace prefix. Rename it to 'rollup_v1_report' (namespace = 'rollup').","code":"NAMESPACE_PREFIX"}. Then GET /api/v1/meta/object/v1_report 404 and POST /api/v1/data/v1_report 404 OBJECT_NOT_FOUND, and a following publish-drafts still lists the draft in failed[]
prefixed draft rollup_v1_report, then POST …/meta/object/rollup_v1_report/publish?package=com.v1.rollup 200, then GET 200

The /meta PUT door answers in the flat REST dialect, so the code is top-level code, beside error. That is the same place the door puts INVALID_METADATA.

Pins (packages/metadata-protocol/src/protocol.active-save-namespace-prefix.test.ts, 8 cases)

  1. An unprefixed object under a namespaced package is refused. The pin asserts code: NAMESPACE_PREFIX and status: 422 (the envelope, not a bare throw), the first sentence, the rename, and that no row is written.
  2. The prefixed name saves active.
  3. Control: a package that declares no namespace saves the unprefixed name.
  4. Control: a save naming no package saves the unprefixed name.
  5. A package-less publish save that resolves into the namespaced package (its active row is bound there) is refused, and the stored row is untouched.
  6. A draft save is unchanged. publishPackageDrafts refuses it with NAMESPACE_PREFIX, and publishMetaItem refuses it too. failed[0].error and the per-item refusal's message both equal the active door's message byte for byte, and the draft is still the only row.
  7. The per-item publish of an unprefixed object draft under a namespaced package is refused. The pin asserts code: NAMESPACE_PREFIX and status: 422, the first sentence and the rename. Nothing is promoted and the draft stays a draft.
  8. Control: the per-item publish promotes a prefixed object draft under the same package.

Reproduced first, on the unfixed code: cases 1, 5 and 6 red (the active save answered saved), cases 2–4 green.

Ablation (scripts/ablation-replace.mjs, on the committed fix): if (prefixViolation) { was changed to if (false && prefixViolation) {. The anchor went 1 → 0 and the blob 72a5cff9503e → 88f5360fcedf. Cases 1, 5 and 6 went red and 2–4 stayed green. Restore was proven: blob == HEAD and git diff HEAD empty. The direction was red, as expected. No dist build was needed: the suite imports ./protocol.js from source.

Second ablation, the per-item publish branch, on the committed fix afb7f17447.

  • Declared direction, before running: red on exactly cases 6 and 7, green on the other six.
  • const prefixViolation = this.objectNamespacePrefixViolation(request.name, promoteInto); was changed to const prefixViolation = false && this.objectNamespacePrefixViolation(request.name, promoteInto);. The anchor went 1 → 0 and the blob aecdeed0e1a2 → b1797c55d012.
  • Result: '2 failed | 6 passed', exactly cases 6 and 7.
  • Restore was proven: blob == HEAD and git diff HEAD empty.

Verification on the final head afb7f17447 (the per-item publish fix, on top of a merge of origin/main d1173825b7 through os-regen-merge.sh)

  • pnpm --filter @objectstack/metadata-protocol exec vitest run: 251 files passed (3 skipped), 20798 tests passed.
  • objectql, the whole suite on the fixed dist: 449 files and 8452 tests, all passed. In round 0, before the fixture change, its only 14 failures were the three adjusted files, each failing on the new refusal.
  • pnpm --filter @objectstack/metadata-protocol typecheck and pnpm --filter @objectstack/objectql typecheck: exit 0 (check:test-typecheck OK, ledger unchanged).
  • Derived gate union (node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack): 78 commands, all exit 0. --ran reconciliation: 78 derived, 78 run, 0 NOT-MEASURED, 0 UNRUN. check:dual-build-cjs-loads and check:lean-entry-closure first answered exit 3 (PREREQUISITE NOT MET: the fresh worktree had no full build yet). Re-run after the build: exit 0.
  • Dogfood, run once on 3b94654c58 (not re-run in this round), on the files that write objects under a package: package-first-authoring, flow-shipped-name-published-door and install-local-unmanaged-mode all passed. wall-environment-metadata-refusal had 2 failures, unrelated to this diff; see Acceptance notes.
  • Not run locally, left to CI: test:repo, which this diff does not touch, and the consumer packages' own suites outside the measured set below.

File surface beyond the claim (declared)

  • packages/objectql/src/protocol-duplicate-package-{unprefixed-object,names-in-step,permission-field-keys}.test.ts. These ADR-0070 D4 duplicate fixtures staged an unprefixed object under a namespaced package through the direct save. That is the exact shape this change refuses, so without this change Test Core goes red. Each file now stages those objects with a saveStoredBeforeRule helper, which hides the package's namespace for the one save and then restores it. That models a row stored before the door read the rule, so every case keeps what it pins. No assertion changed.
  • scripts/engine-double-contract.pinned.json: the check:engine-double-contract --write remedy for the new suite's pinned double. It adds 3 rows and loses 0.

Shipped-tree exposure (measured)

  • Unprefixed objects saved through the direct door under a namespaced package:
    • examples/: 0. Code packages pass defineStack, and no test does a package-bound object save.
    • packages/metadata-protocol tests: 0. The full suite is green.
    • packages/objectql tests: 4 staging sites in 3 files, adjusted as above.
  • Controls:
    • the runtime integration tests use com.probe.p / com.probe.q, from which deriveNamespaceFromPackageId derives null (a single-letter segment);
    • the dogfood package-bound saves use unregistered bases, or crm_-prefixed names.

Other doors that make an object live (enumerated; line numbers at 3b94654c58)

  • publishMetaItem (through promoteDraftForPublish; POST /api/v1/meta/:type/:name/publish) is now judged too, per the seat's surface amendment 6280587888 on the card. At 3b94654c58 it still made the unprefixed object live: 200, then GET 200 and a data insert 201. At afb7f17447 it answers 422 NAMESPACE_PREFIX (table above).
  • Covered through saveMetaItem: duplicatePackage (:27916), importUnmanagedPackage (:28629), and the datasource external import (packages/services/service-datasource/src/plugin.ts:206, which writes no package and runs its own prefix check).
  • Restore-only doors, which bring back a body that was already live: revertCommit (:29484), rollbackToPackageCommit (:29975) and rollbackMetaItem (:30046). loadMetaFromDb (:31403) hydrates stored rows at rest.
  • installPackage registers a manifest and makes no object live.

Acceptance notes

  • ADR-0129 D2 and the packages/spec/src/kernel/namespace-prefix.ts header name MetadataManager.publishPackage() as the runtime chokepoint. That method (packages/metadata/src/metadata-manager.ts:1879) runs no object-name prefix check. The runtime doors that do are publishPackageDrafts and, with this change, saveMetaItem. No edit here, because both files are outside this card's surface. Carrier: none.
  • importUnmanagedPackage now meets this refusal for a source package that declares no namespace and whose objects lack the prefix derived from its id. The refusal arrives mid-copy and the existing rollback undoes it, rather than at plan time. Not measured: no fixture has that shape. Carrier: none.
  • Unrelated dogfood red, reproduced on this head: packages/qa/dogfood/test/wall-environment-metadata-refusal.dogfood.test.ts, the "thirteen write doors" case, 2 failures under isolated and group. Its package publish of a crm_lead.* view is refused with list-view-object-outside-closure. That is the list-view closure refusal origin/main brought in, and nothing in this diff touches a view. The Dogfood Regression Gate does not run on PRs, so this surfaces on main's push battery.

For the contract-tier reviewer

  • The source !== undefined exemption (server-stated rewrites) is deliberate. The two package-copy faces are deliberately judged.
  • The refusal reads request.packageId after the package-less publish address resolution. Case 5 pins that.
  • The per-item publish branch sits after the D9.9 refusal on promoteInto and before repo.promoteDraft, with the same sealed-managed exemption as that refusal. Case 7 and the second ablation pin it.

Generated by Claude Code

…t its package namespace prefix

ADR-0129 D2 holds at both runtime doors that ask the rule: the direct
(publish-mode) saveMetaItem now refuses an object whose name lacks the
${namespace}_ prefix of the package it is written under, with the
NAMESPACE_PREFIX code and the authored prescription the publish-drafts
pre-flight already answers. One judge (objectNamespacePrefixViolation)
serves both doors.

Claude-Session: https://claude.ai/code/session_01AHeUBbe6S3JRawcpbLejXD
Co-authored-by: Claude <noreply@anthropic.com>
…ows stored before the save door read the prefix rule

The direct save now refuses an object whose name lacks its package's
namespace prefix (ADR-0129 D2), so the ADR-0070 D4 duplicate fixtures
stage those objects while the package declares no namespace and put the
derived one back, keeping what each case pins.

Claude-Session: https://claude.ai/code/session_01AHeUBbe6S3JRawcpbLejXD
Co-authored-by: Claude <noreply@anthropic.com>
…its package namespace prefix

Also corrects the duplicatePackage comment that named the direct save as
a door no prefix gate reads.

Claude-Session: https://claude.ai/code/session_01AHeUBbe6S3JRawcpbLejXD
Co-authored-by: Claude <noreply@anthropic.com>
…x suite's pinned engine double

Claude-Session: https://claude.ai/code/session_01AHeUBbe6S3JRawcpbLejXD
Co-authored-by: Claude <noreply@anthropic.com>
…ds the caller's bound

Claude-Session: https://claude.ai/code/session_01AHeUBbe6S3JRawcpbLejXD
Co-authored-by: Claude <noreply@anthropic.com>
…tive-save-namespace-prefix

# Conflicts:
#	packages/objectql/src/protocol-duplicate-package-names-in-step.test.ts
@github-actions github-actions Bot added size/l documentation Improvements or additions to documentation tests tooling labels Oct 20, 2026
@github-actions

github-actions Bot commented Oct 20, 2026 •

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

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

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

  • content/docs/concepts/metadata-lifecycle.mdx (via ObjectStackProtocolImplementation (symbol, a top-level class), saveMetaItem (symbol, a method of class ObjectStackProtocolImplementation))
  • content/docs/deployment/validating-metadata.mdx (via saveMetaItem (symbol, a method of class ObjectStackProtocolImplementation))
  • content/docs/kernel/cluster.mdx (via saveMetaItem (symbol, a method of class ObjectStackProtocolImplementation))
  • content/docs/kernel/services-checklist.mdx (via saveMetaItem (symbol, a method of class ObjectStackProtocolImplementation))
  • content/docs/permissions/authorization.mdx (via saveMetaItem (symbol, a method of class ObjectStackProtocolImplementation))

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

  • content/docs/releases/v15.mdx (via NAMESPACE_PREFIX (literal, a string literal in promoteDraftForPublish; a string literal in publishPackageDrafts; a string literal in saveMetaItem))
  • content/docs/releases/v16.mdx (via ObjectStackProtocolImplementation (symbol, a top-level class))
  • content/docs/releases/v17/17-0.mdx (via ObjectStackProtocolImplementation (symbol, a top-level class))
  • content/docs/releases/v17/17-1.mdx (via publishPackageDrafts (symbol, a method of class ObjectStackProtocolImplementation))

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
  • the SDK route bridge reached 54 of 206 client-bound route-ledger rows — the other 152 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 152: 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; 97 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 — 11 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 d1173825b709dfd8ad4ef383757a5df0a2e82d2a → packageMentionDocs.

Which tree this was computed on

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

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

⚠️ 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 d1173825b709dfd8ad4ef383757a5df0a2e82d2a → pass the list as
args.docs, on the commit named under Which tree this was computed on.

…t its package namespace prefix

The third door that makes an object live under a package now asks the
same judge as the direct save and the publish-drafts pre-flight, on the
package the promotion writes under, and refuses with NAMESPACE_PREFIX /
422 and the same sentence before anything is promoted.

Claude-Session: https://claude.ai/code/session_01AHeUBbe6S3JRawcpbLejXD
Co-authored-by: Claude <noreply@anthropic.com>
@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

席位核对 · ACCEPT · domain:engine seat 2 · session_01AHeUBbe6S3JRawcpbLejXD · 2026-10-20T01:29Z

Reviewed against GitHub on head afb7f17447, not against the reports. The CONTRACT_REVIEW_TIER review of record this narrowing owes is running in isolation. It covers the whole non-prose diff, and this PR is not armed until its PASS is on record. needs:contract-review goes on in this act.

  • Form: draft into main. The body's first line is Fixes #24547, and Clause-②: no (narrowing) sits at a line start. A closing-keyword scan of the whole body finds one hit, Fixes #24547. The body now carries the dev's 18 pr_body_edits pairs from the patch report (6281138518), each found exactly once and applied by this seat.
  • Scope: 7 files, +578 / −22. check-governed-merges --pr 24702: not governed, 600 changed lines.
    • Inside claim 6278973624's surface, plus amendment 6280587888: protocol.ts, the new suite protocol.active-save-namespace-prefix.test.ts and the changeset.
    • Accepted deviations: the three objectql protocol-duplicate-package-*.test.ts fixtures, which only stage an unprefixed object (the direct save now refuses one), and scripts/engine-double-contract.pinned.json, that gate's own --write remedy. All four are declared in the PR body.
  • Ruling 6278955141, as amended by 6280587888:
    • A refusal, in 18.0.
    • All three runtime doors that make an object live under a namespaced package ask one judge, objectNamespacePrefixViolation, and answer NAMESPACE_PREFIX / 422 with validateObjectNamespacePrefix's own sentence.
    • The per-item publish judges promoteInto after the D9.9 refusal and before repo.promoteDraft writes, so nothing is promoted.
    • ⛔ clauses hold: no new error code (NAMESPACE_PREFIX was already in the metadata-protocol ledger), no ADR edit, no packages/spec change.
  • Changeset, checked sentence by sentence against the diff:
    • FROM / TO / Fix name both newly refusing doors, including the package-less save and publish that resolve into a namespaced package. The suite's case 5 pins the save side, and the per-item branch reads promoteInto.
    • The "Unchanged" list matches the code: no package or no namespace, draft saves, sys_*, and stored rows.
    • The sealed-managed exemption on the per-item door is not listed there. That content's store refusal answers first, so no author meets a different verdict.
    • minor with BREAKING and the ADR-0087 not-required (no-migration-prescription) marker match a door verdict that moves no stored shape.
  • Measured by the dev:
    • Ablation of each branch went red on exactly the cases that pin it.
    • Live HTTP on a fresh showcase: the direct PUT and the per-item publish answer 422 with the same body, and the data door then answers 404. publish-drafts refuses the same name with the same sentence, and the prefixed control publishes.
    • Full metadata-protocol (20798) and objectql (8452) suites pass. CI on this head is all green or on the expected-skip roster.
  • Out-of-scope findings:
    • The wall-environment-metadata-refusal dogfood red is main's. It is carried by the hourly anchor hourly full run: red on main (CI) #24700 and attributed to fix(lint)!: the save door refuses a list view written into a package and bound outside its closure #24630 there (6280541230).
    • Acceptance notes: ADR-0129 D2 and the namespace-prefix.ts header name MetadataManager.publishPackage() as the runtime chokepoint, but it runs no prefix check. The three doors this PR judges are now named in the judge's docblock. Correcting the ADR text is a governed edit with no reader this round.
    • Dropped, no reach: importUnmanagedPackage would meet the refusal mid-copy for a namespace-less source. That is the dev's inference, and no fixture or measured producer has that shape.
  • Docs drift advisory: the pages it lists name saveMetaItem in passing. None states which doors run the namespace rule; validating-metadata.mdx:747 lists it among lint rules. No page is made wrong by this diff.
  • Landing: after the contract review's PASS on this head is adopted. Time-critical: this must land before the Version Packages PR chore: version packages #24524.

Generated by Claude Code

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: afb7f1744793bb0d074a2a0163600eef3fcaa5c8
Local-runs: none
Scope: packages/metadata-protocol/src/protocol.ts, packages/metadata-protocol/src/protocol.active-save-namespace-prefix.test.ts, packages/objectql/src/protocol-duplicate-package-unprefixed-object.test.ts, packages/objectql/src/protocol-duplicate-package-names-in-step.test.ts, packages/objectql/src/protocol-duplicate-package-permission-field-keys.test.ts, scripts/engine-double-contract.pinned.json + packages/spec/src/kernel/namespace-prefix.ts, packages/spec/src/api/error-code-ledger.zod.ts, docs/adr/0129-object-name-is-the-canonical-id.md, packages/metadata-protocol/src/package-unmanaged-install.ts, packages/metadata-protocol/src/sys-metadata-repository.ts, packages/rest/src/meta-item-write-routes.ts, packages/rest/src/meta-item-history-routes.ts, packages/rest/src/error-response.ts, packages/objectql/src/registry.ts, packages/runtime/src/domains/meta.ts, packages/runtime/src/domains/packages.ts, packages/runtime/src/domains/permission-set-clone.ts, packages/runtime/src/domains/automation.ts, packages/cli/src/commands/migrate/organization-ownership.ts, packages/cli/src/utils/organization-ownership-apply.ts, packages/services/service-automation/src/flow-credential-migration.ts, packages/services/service-datasource/src/plugin.ts, scripts/check-adr-0087-registration.mjs, scripts/check-changeset-no-major.mjs

Face: narrowing (Clause-②: no (narrowing) on the PR body and on the changeset; record-recognisers --paths reads no face off the paths and defers to that line). Inputs read: card #24547 body, all six comments (triage 6269150052, ruling 6278955141, claim 6278973624, round-0 report 6280530109, surface amendment 6280587888, round-1 report 6281138518), PR #24702 body, its file list, the net diff against main at the head (merge base d1173825b7, 7 files, +578/-22), the cited code at the head, and the head's check-runs: 40 success, 11 skipped (Dogfood matrix, Build Docs, Console Pin, Temporal, packed-tarball and the like, all PR-skipped by design), none failed, none pending at the second read (the Check Changeset re-run that was in progress at the first read concluded success).

① Derived judgments

Accept-set changes the diff implies, each judged against ADR-0129 D2 ("every object a package defines MUST be named namespace_shortName, platform-reserved sys_* exempt") and the ruling 6278955141 ("the refusal, not the ADR exemption; D2's MUST holds at the runtime save doors; in 18.0"):

  1. One judge, objectNamespacePrefixViolation(name, packageId) (private, protocol.ts:22917). RIGHT. It returns null for a missing or empty package id, else validateObjectNamespacePrefix(name, registry.getPackage(id)?.manifest?.namespace). The spec judge (packages/spec/src/kernel/namespace-prefix.ts) skips an empty namespace and every sys_* name, so "no package", "no namespace" and "sys_*" are exemptions of the shared rule, not of this door. The namespace read is the installed manifest's, which installPackage (protocol.ts:32370) derives from the id and persists when the author declared none, so com.v1.rollup reads rollup, as the card measured. The method is private: no export, no public member, no stored shape moves.

  2. Direct publish-mode saveMetaItem (protocol.ts:23415). RIGHT. Guard singularType === 'object' && mode === 'publish' && request.source === undefined; on a verdict it throws code: 'NAMESPACE_PREFIX', status: 422, message = the judge's sentence. Position verified: after the package-less publish address resolution (23300-23309 reassigns request.packageId to the package the save writes, so case 5 is judged against the resolved package), after the ADR-0029 D9.9 mismatch refusal (23325-23372, so a wrong-package binding is told which package owns it before it is told how to rename), and before protectionBlockRefusal, authoredLockFamily, the per-type parse and every put: a refused name writes nothing. mode defaults to publish unless the caller says draft (23076), so the unchanged draft-save exemption is exactly mode: 'draft'. The source !== undefined exemption covers exactly the two server-stated rewrites of rows the store already holds: migrateStoredMetadata (24939, source: 'migrate-stored') and the flow-credential move (packages/services/service-automation/src/flow-credential-migration.ts:222, source: 'migrate-stored', type flow); no other caller in the tree sets source (the source === 'declared' at 13954 is the search-field verdict, unrelated). The status/code envelope is the one the sibling 422 refusals use (INVALID_METADATA, OBJECT_OVERLAY_PACKAGE_MISMATCH); declaresClientRefusal (3879 region) keys on a 4xx status, and handleRouteError (packages/rest/src/error-response.ts:1073) carries status and code to the wire, which the PR's wire table confirms. NAMESPACE_PREFIX is already registered to this package in ERROR_CODE_LEDGER (packages/spec/src/api/error-code-ledger.zod.ts:665); no code is minted.

  3. Per-item publish through promoteDraftForPublish (protocol.ts:25607-25615). RIGHT, and it is the amendment 6280587888's shape exactly: guard draftForGate && singularType === 'object' && !this.isSealedManagedItem(...), judged on promoteInto (the package the active row is written under: request.packageId when the caller states a binding, else packagelessDraftAddress(...).into), same judge, same NAMESPACE_PREFIX / 422 / sentence, placed after the D9.9 mismatch block with the identical exemption set and before repo.promoteDraft (25700 region), so nothing is promoted and the draft stays a draft. promoteInto === null (unbound promotion) and a package with no namespace are skipped by the judge; sys_* by the spec rule; sealed managed content by the same predicate the D9.9 block uses. publishMetaItem (25309) is the only caller that reaches this branch with a violation: publishPackageDrafts promotes through here too (27017) but with packageId: d.packageId for drafts repo.listDrafts({ packageId }) listed under the request's package, and its pre-flight has already refused every such name before the batch loop (26725-26750 returns outcome: 'refused'), so the batch door's answer is unchanged.

  4. publishPackageDrafts pre-flight (protocol.ts:26655-26665). RIGHT, a pure refactor with the same verdict for every input: the old form read getPackage(request.packageId)?.manifest?.namespace once and ran validateObjectNamespacePrefix(d.name, ns) only when ns was truthy; the new form asks the judge per object draft with request.packageId, which performs the identical registry read and the spec judge's own empty-namespace skip. Same code NAMESPACE_PREFIX, same preflightViolations row shape, same ordering before the non-canonical-type and other pre-flight checks. The backfill plan (planPublishBackfill, 26303) adds field values, not drafts, so the batch's item set is still drafts and the pre-flight still covers everything the loop promotes.

  5. Other callers of saveMetaItem and promoteDraftForPublish, each read at the head, none newly refused where it must not be:

    • duplicatePackage (28622, writeFace: 'package-duplicate', mode: 'publish', no source): judged, correctly. The target is installed with namespace: targetNs (28457) and every object copy is renamed into ${targetNs}_... by the rename map (28512), so the copies pass by construction; the three ADR-0070 D4 fixtures prove it.
    • importUnmanagedPackage (28841, writeFace: 'package-unmanaged-import'): judged, correctly. planUnmanagedImport (packages/metadata-protocol/src/package-unmanaged-install.ts:435-477) installs the base under the source's declared-or-derived namespace and states that the base must own the prefix "for the save door's namespace rule to admit them". A source that declares no namespace and whose object names lack its id-derived prefix is now refused mid-copy and rolled back (see ③).
    • migrateStoredMetadata (24939): exempt by source. The flow-credential migration: exempt by source and type flow.
    • packages/runtime/src/domains/packages.ts:1828 saves type app; permission-set-clone.ts:353 type permission; domains/automation.ts:1830 type flow; the organization-ownership ceremony (packages/cli/src/utils/organization-ownership-apply.ts 731, 1560, 1798, 2019) saves position, permission, view and email_template, never an object (the 1798 conflict save fires only for conflict.type === 'view'). None is an object save.
    • packages/services/service-datasource/src/plugin.ts:206 (writeFace: 'external-import', object, no package) is judged only if the package-less publish address resolves the name into a namespaced package, that is, over an unprefixed row stored before the rule; it runs its own ADR-0028 prefix derivation for new names. Correct under D2.
    • publishMetaItem's only external caller is the REST per-item publish route (packages/rest/src/meta-item-history-routes.ts:714).
    • revertCommit, rollbackToPackageCommit, rollbackMetaItem and loadMetaFromDb do not call saveMetaItem (the head has exactly three internal call sites: 24939, 28622, 28841); installPackage registers a manifest and makes no object live; code packages load through the registry, not this door; seeds promote as type seed, not object. No migration, seed, install or restore path is newly refused.
  6. The three objectql fixtures. RIGHT. Each adds a saveStoredBeforeRule helper that deletes manifest.namespace on the live registry object (SchemaRegistry.getPackage returns the stored reference, packages/objectql/src/registry.ts:6102) for the one staging save and restores it in finally; the two replaced save calls per file are the staging sites the round-0 report measured red (ticket x8, dupsrc_ext x6). The net diff carries no removed or altered expect; the unprefixed-object file adds one assertion that the namespace is back to dupsrc after staging. Every pinned ADR-0070 D4 claim stands, and the staged rows model exactly what the changeset's "Unchanged" section says: a row stored before the door read the rule.

  7. scripts/engine-double-contract.pinned.json: three rows (delete, findOne, update, pinned 1) for the new suite, the gate's own --write remedy; no existing row moved. The new suite's stub applies assertEngineDeleteDispatch / assertEngineUpdateDispatch / assertEngineFindOnePredicate and bounds find by limit, as the doubles gates require.

  8. The new suite (8 cases) pins each door's refusal with code and status, the first sentence and the rename, byte-equality of the sentence across the three doors (case 6), nothing written or promoted, and the four controls (prefixed name, no-namespace package, no package, prefixed per-item publish). Both ablations reported in the PR body show the expected red on exactly the pinned cases with a proven restore.

Public surface: no export, no public method, no error code, no stored row shape, no REST route or payload key moves. The only observable change is the accept set at two doors, as declared.

② Semver level

.changeset/24547-active-save-namespace-prefix.md bumps @objectstack/metadata-protocol minor and marks the change BREAKING "under the launch-window convention": scripts/check-changeset-no-major.mjs refuses a major changeset on a PR, so minor plus the BREAKING marker is the declared level for an accept-set narrowing. Matches the diff: nothing moves in @objectstack/spec, @objectstack/objectql (test-only staging) or @objectstack/rest, so one package graded is right. Clause-②: no (narrowing) is present on the changeset and on the PR body and is accurate: no new key on any published payload.

FROM / TO / Fix: present and accurate for both newly refusing doors. FROM names the publish-mode save (PUT /api/v1/meta/object/:name?package=:id, saveMetaItem in publish mode, including the package-less save the door resolves into a namespaced package, which case 5 pins) and the single-draft promotion (POST /api/v1/meta/object/:name/publish?package=:id, publishMetaItem, including a package-less publish promoted into such a package, which promoteInto from packagelessDraftAddress is). TO states 422 NAMESPACE_PREFIX, the draft-publish sentence verbatim, "the save writes nothing" and "the publish promotes nothing, and the draft stays a draft": each verified in ① (the throw precedes every put and repo.promoteDraft). Fix names the ${namespace}_${shortName} rename and that the namespace is the declared one or the one derived from the id at install: accurate. The "Unchanged" list (no-namespace package and no-package writes not judged; draft saves store as before; sys_* exempt; stored rows not migrated or refused on read, the next direct save or per-item publish of one refused) matches the code.

ADR-0087 marker: adr-0087: not-required (no-migration-prescription) with a stated reason. Right disposition: the change moves no authorable key, spelling, export, error code or stored shape, reads or rewrites no stored row, and the changeset's body carries no consumer-code migration prescription (the Fix line is an author rename, not a code rewrite), so the script's statement-against-statement check holds; the other four categories are correctly argued closed in the marker. Check Changeset and the Lint lanes on the head are green.

③ Boundary flags

  • Round 0 open question (the per-item publish door still made an unprefixed object live): answered by the seat's surface amendment 6280587888 (option A, in this PR, reusing the parent ruling as a new branch of an existing refusal set). Implemented at the head: the promoteDraftForPublish branch, cases 6-8, the second ablation, the PR body's wire rows at afb7f17447, and the changeset's FROM/TO now naming both doors. Round 1 report: open_questions: []. Closed.
  • Deviation (both rounds): file surface beyond the claim, the three objectql fixtures and the pinned doubles ledger. Declared in the PR body and both reports, forced by the narrowing itself, test-only, no assertion moved; the claim's three prohibitions (no new code, no ADR edit, no packages/spec change) hold. Accepted.
  • Deviations on lock discipline and the harness attribution trailer: process notes with no bearing on the diff. Accepted.
  • Out-of-scope (round 0, class b): the per-item door, absorbed by the amendment. Closed.
  • Out-of-scope (class a): the wall-environment-metadata-refusal dogfood red is main's (list-view-object-outside-closure), carried on hourly full run: red on main (CI) #24700; this diff touches no view path. Not this PR's.
  • Out-of-scope, carrier none: ADR-0129 D2 and the namespace-prefix.ts header name MetadataManager.publishPackage() as the runtime chokepoint while the runtime doors that run the rule are now publishPackageDrafts, saveMetaItem and publishMetaItem. The claim forbade an ADR edit and the MUST itself is unchanged, so this is a stale citation in governed text, not a contradiction. ESCALATED to the owning seat for a follow-up card (an ADR-0129 amendment or a docs sweep), not a reason to hold this PR. Related, a reservation: rule 13's corollary asks that load-bearing ADR spots be anchored in scripts/adr-anchors/; no file there anchors any ADR-0129 D2 runtime site, before or after this PR (the three sites carry the ADR id in code). Same follow-up.
  • Out-of-scope, carrier none: importUnmanagedPackage of a namespace-less source whose objects lack their id-derived prefix now meets the refusal mid-copy and rolls back rather than at plan time. Inference, no fixture, and the plan's own text already names the save door's rule as the admission condition. Correct under D2; the plan-time refusal is an ergonomics improvement for a later card, not owed here.
  • Residual reservation, no action owed: in a batch publish the per-item branch is reachable only after the pre-flight, so the batch door cannot answer differently than before; verified by the loop's packageId: d.packageId and the pre-flight's early return, not by a pin.

Implemented-by: claude/issue-24547-active-save-namespace-prefix
Reviewed-by: session_01AHeUBbe6S3JRawcpbLejXD

VERDICT: PASS

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

Adopted · CONTRACT_REVIEW_TIER review 6281317496 (PASS) on head afb7f17447 · domain:engine seat 2 · session_01AHeUBbe6S3JRawcpbLejXD · 2026-10-20T01:41Z

  • Transcript audit of the isolated reviewer, before adoption:
    • 52 tool calls: 48 Bash, all reads (git show / git grep / git fetch, REST curl, record-recognisers --template), plus 2 Read.
    • 1 Write, its record file in its own pr-24702/ scratch space, and 1 post of that record.
    • No checkout, worktree, build, test run, gate re-run or ablation, so Local-runs: none holds.
    • The record carries Served-tier: CONTRACT_REVIEW_TIER, the head sha, a Scope: line at or above the narrowing floor, ①②③, and the independence pair.
  • ③ The reviewer's escalation, answered by the seat:
    • ADR-0129 D2 and the namespace-prefix.ts header still name MetadataManager.publishPackage() as the runtime chokepoint. This stays an Acceptance note (ACCEPT 6281195179), not a card: it passes none of the filing gate's four categories this round, and correcting governed ADR text is a human-approved edit with no reader named. It is put to the maintainer in the round report.
    • No scripts/adr-anchors/ row anchors a D2 runtime site. Adding one would be a new gate, and new gates default to no without the maintainer naming one, so none is added.
  • needs:contract-review comes off in this act. Landing follows: ready and auto-merge, after the pre-landing checks on this head.

Generated by Claude Code

@objectstack-fleet
objectstack-fleet Bot marked this pull request as ready for review October 20, 2026 01:44
@objectstack-fleet
objectstack-fleet Bot enabled auto-merge October 20, 2026 01:44
@objectstack-fleet
objectstack-fleet Bot added this pull request to the merge queue Oct 20, 2026
Merged via the queue into main with commit 0b4be0b Oct 20, 2026
55 checks passed
@objectstack-fleet
objectstack-fleet Bot deleted the claude/issue-24547-active-save-namespace-prefix branch October 20, 2026 02:37
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation size/l tests tooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Active metadata save stores an object without its package namespace prefix that publish-drafts then refuses (NAMESPACE_PREFIX)

2 participants