Skip to content

fix(metadata-protocol): enforce the field overlay lock at the live route (#7743) - #7897

Merged
huangyiirene merged 1 commit into
mainfrom
claude/issue-7743-field-overlay-lock
Aug 12, 2026
Merged

fix(metadata-protocol): enforce the field overlay lock at the live route (#7743)#7897
huangyiirene merged 1 commit into
mainfrom
claude/issue-7743-field-overlay-lock

Conversation

@huangyiirene

Copy link
Copy Markdown
Collaborator

Part of #7743

Ships the refusal. The card recorded two entangled defects; the second (the accepted write is inert) is measured, proven independent, and filed as #7893 rather than folded in. A third defect found while writing the pin — the plural URL spelling walks around this same lock — is filed as #7894 and is why this is Part of rather than Fixes.

The defect

The registry declares field with allowOrgOverride: false, and a field a code package ships is an artifact. Yet, with an admin bearer on a booted showcase:

PUT /api/v1/meta/field/showcase_task.title {name:'title', label:'Tampered', type:'text'}
→ 200 {"success":true,"state":"active","message":"Saved field 'showcase_task.title' (env-wide…)"}
GET /api/v1/meta/field/showcase_task.title
→ {"item":{…,"_diagnostics":{"valid":true}}}

Reproduced on showcase_task.status. The door answered success twice over — accepted, and stored valid — for a write the registry forbids.

Premise: verified at the branch point, and refined

The card located the root cause as suspected. Verified at bcea363, and it holds — with one refinement that matters for where the fix goes.

field is the ONE type in DEFAULT_METADATA_TYPE_REGISTRY whose artifacts are not standalone registry items. Its filePatterns (**/*.field.ts) match nothing in any app, because fields are authored inside the object (ObjectSchema.fields, a z.record(name, FieldSchema)). So the object's loader registers one object item and no field items at all, and getArtifactItem('field', 'showcase_task.title') misses on a field the package unambiguously ships. With the lookup empty the write is classified as a runtime-only create, and field carries allowRuntimeCreate: true — so allowOrgOverride: false is never consulted.

The refinement: the card pointed at saveMetaItem's own NOT_OVERRIDABLE block, but that block is behind environmentId !== undefined and the flagship showcase boots with noenvironmentId (a host config with instantiated plugins). On that topology the block is skipped entirely, and the site that actually refused object in the same run is SysMetadataRepository.assertAllowed, whose intent (override-artifact vs runtime-only) the protocol picks from the very same predicate. Measured: object was refused with the repository's sentence ('object' is not allowOrgOverride in the registry. Overlay-allowed: view, dashboard, report, translation, email_template.), not the protocol's.

Both doors read isArtifactBacked, so making that predicate truthful closes both. That is the fix.

The change

isArtifactBacked now falls back to isNestedArtifactField for field: split <object>.<field> at the first separator, resolve the parent through the artifact-only lookup (so a plain-key overlay row can neither manufacture nor mask an artifact — ADR-0010 §3.3), and answer whether the shipped object carries that field.

It returns a boolean rather than routing through lookupArtifactItem, deliberately: a field sub-document is a bare {name, label, type} with no _packageId/_lock envelope, and lookupArtifactItem's other callers (lock resolution, mergeArtifactProtection, the layered read) consume that envelope. Handing them a field body would make them assert provenance nobody stamped. The authorization question is answerable without one.

fields is read in its one canonical form (a record keyed by name); no array fallback — Prime Directive #12.

Scope: field-specific, and that was measured, not assumed

The dispatch asked whether this is one type or a class. Measured across the whole registry on a booted showcase — every other declared type either registers its artifacts standalone with a _packageId, or genuinely ships none:

standalone artifacts (package-stamped)no artifacts at all
action 70, page 33, permission 16, dataset 9, doc 9, hook 4, report 4, mapping/book/email_template 1position, tool, skill, seed, translation, external_catalog

field is the only name where the registry's answer and the shipped artifact disagree: 2 items listed under /meta/field, both written by this session's probes, 0 package-stamped — while showcase_task alone ships 24 fields.

action is the instructive one: it is also nested inside the object document, yet it IS registered standalone, so it was already refused correctly (403) in the same run. The class has one member, so the diff has one member.

Verification

Reproduced live, fixed, re-measured live. Same seven contrasts, before and after:

writebeforeafter
field/showcase_task.title (artifact override)200, row persisted, valid=true403 NOT_OVERRIDABLE
field/showcase_task.status200403 NOT_OVERRIDABLE
field/showcase_task.zz_new_probe (brand-new)200200 — unchanged
object/showcase_task (full body)403 NOT_OVERRIDABLE403 — unchanged
view/showcase_task.in_progress200, overlay effective200 — unchanged
dashboard/system_overview200200 — unchanged
job/showcase_health_sweep403 code-only403 — unchanged
action/showcase_mark_done, page/sys_user_detail403403 — unchanged
position/contributor200200 — unchanged

The object contrast used the full body — a trimmed one hits DESTRUCTIVE_CHANGE first and never reaches the registry gate.

The pin is at the LIVE ROUTE, which is the whole lesson of this card

packages/objectql/src/overlay-precedence.test.ts already pins this denial with 27 passing cases — at the protocol level, calling saveMetaItem directly. The reported symptom sailed straight past all 27 because the live field route is not in their coverage. A 28th assertion at that layer would have been green and blind in exactly the same way.

So packages/runtime/src/meta-field-overlay-lock.test.ts drives the real HttpDispatcher.handleMetadata, the real ObjectStackProtocolImplementation and the real SysMetadataRepository, then reads the stored row. Nothing stubs saveMetaItem — a double cannot see this defect, because the defect is which INTENT the protocol derives before the repository is called. The registry double reproduces the real miss faithfully: it serves the object artifact and has no field collection at all.

Both topologies are driven, because they refuse at different sites: environmentId: undefined (the showcase, where the repository gate is the enforcement point) and environmentId: 'env_1' (where saveMetaItem's own gate fires first). Both assert 403andNOT_OVERRIDABLE — ADR-0112: a refusal is pinned by code and status, never by "it threw".

Reverse verification — with the rebuild

Direction predicted in the file header before running. packages/runtime resolves @objectstack/metadata-protocol through its dist, and stack traces are source-mapped back to src, so a source-only revert measures nothing while looking like it measured something. The fix was committed first, then reverted with git checkout origin/main -- packages/metadata-protocol/src/protocol.tsand rebuilt (grep -c isNestedArtifactField dist/index.js0), measured, then restored and rebuilt (→ 3).

Predicted 3 red / 7 green; measured 3 red / 7 green.

The seven greens are not slack. Four are the negative direction the card demanded — object/view/dashboard/job were measured as already correct, so a fix that tightened any of them is over-reach and must fail here. Two are the legitimate field write: allowRuntimeCreate: true is real, and a fix that refused every field PUT would pass a one-directional test while breaking the feature. The seventh is the known gap below.

Two defects reported rather than folded in

#7893 — the accepted write is inert. The card predicted that refusing the override would destroy this evidence. Measured: it does not. The inertness has two halves and only one is behind the new refusal. The override half is now unreachable, as expected — but the create half is a write that stays entirely legal under allowRuntimeCreate: true:

PUT /api/v1/meta/field/showcase_task.zz_new_probe → 200, valid=true (after the fix)
GET /api/v1/meta/object/showcase_task → 24 fields, 'zz_new_probe' absent

So #7893 ships with a live repro on main, not archived evidence. It needs a maintainer decision (implement the composition / retire the write channel under ADR-0049 / document it), which is why it is a card and not a rider here.

#7894 — the plural URL spelling bypasses this lock. Found by writing the plural case expecting 403 and measuring 200, then confirming live:

PUT /api/v1/meta/field/showcase_task.title → 403 NOT_OVERRIDABLE ← this PR
PUT /api/v1/meta/fields/showcase_task.title → 200 "Saved fields '…'" ← still open

canonicalMetaType folds plural→singular through PLURAL_TO_SINGULAR, which is the manifest collection map and has no fields key (nor seeds, external_catalogs, translations). An unmapped spelling is read as an unregistered plugin type, which every gate treats as permissive by construction — correct for a type that really is plugin-registered; 'fields' just is not one. The row lands under type='fields', a second namespace, which is the #4432 defect class.

⛔ The one-word patch — teaching isNestedArtifactField to accept 'fields' — was rejected: it is a spelling-tolerant lookup below the boundary, the exact pattern canonicalMetaType's own doc comment rejects, and it would still mint the second namespace. The remedy belongs at the boundary map and spans four types.

It is pinned as an explicitly-labelled KNOWN GAP test that asserts today's behaviour, names #7894, and states that it goes red when #7894 lands so whoever fixes it flips it. Hiding it would have been the alternative.

Gates

gateresult
@objectstack/metadata-protocol72 files / 1066 passed
@objectstack/objectql (incl. the 27 protocol-level overlay-precedence cases and the protocol doubles path-scoped derivation misses)187 files / 3312 passed
@objectstack/client21 files / 282 passed
@objectstack/runtime137 files / 2093 passed
pnpm check:durability-log-level
pnpm check:nul-bytes✓ (7216 files)
eslint on changed filesclean

check:error-code-casing is not applicable — no new error code; the refusal reuses NOT_OVERRIDABLE, which is the sentence this condition already carries.

Changeset: .changeset/field-overlay-lock-enforced.md.


Generated by Claude Code

…route (#7743)
`PUT /api/v1/meta/field/showcase_task.title` answered 200 `state:'active'` and
persisted a row reading back `_diagnostics.valid=true`, for an override the
registry declares forbidden (`field`, `allowOrgOverride: false`).
`field` is the one declared type whose artifacts are not standalone registry
items — fields live inside `ObjectSchema.fields`, so
`getArtifactItem('field', '<object>.<field>')` missed and the write was
classified as a runtime-only create, where `allowRuntimeCreate: true` admitted
it. `isArtifactBacked` now resolves the parent object's artifact and answers
about the field it actually ships, which closes both doors that read it:
`saveMetaItem`'s own gate and `SysMetadataRepository.assertAllowed`'s intent.
The runtime-create tier is untouched: a brand-new field, and a field of a
runtime-created object, are still accepted.
Pinned at the LIVE ROUTE (real dispatcher + protocol + repository) on both
topologies, with the `object`/`view`/`dashboard`/`job` contrasts asserted
unchanged. The 27 protocol-level cases in `overlay-precedence.test.ts` stayed
green through the whole life of this defect because the route was not in their
coverage.
Filed rather than folded in: #7893 (the accepted write is inert) and #7894 (the
plural `/meta/fields/` spelling bypasses the gate for four types).
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CZNzPZmS8EHXFfqbmnA6Jh
@vercel

vercelBot commented Aug 12, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

1 Skipped Deployment
ProjectDeploymentActionsUpdated (UTC)
objectstackIgnoredIgnoredAug 12, 2026 2:49am

Request Review

@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/metadata-protocol.

3 hand-written doc(s) reference the affected code and may need an implementation-accuracy re-verification:

  • content/docs/concepts/metadata-lifecycle.mdx(via @objectstack/metadata-protocol)
  • content/docs/kernel/services-checklist.mdx(via @objectstack/metadata-protocol)
  • content/docs/protocol/kernel/http-protocol.mdx(via @objectstack/metadata-protocol)

1 release-owned page(s) also reference the affected code. These are read-only:

  • content/docs/releases/v9.mdx(via @objectstack/metadata-protocol)

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.

Advisory only. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs origin/main → pass the list as args.docs.

@github-actionsgithub-actionsBot added documentation Improvements or additions to documentation tests tooling labels Aug 12, 2026
@huangyiireneClaude

Copy link
Copy Markdown
CollaboratorAuthor

Gate table — final, all green

Two suites first reported file-level failures with zero test failures (metadata-protocol 7 files / 0 tests; dogfood 27 files / 0 tests). Both were measured while a build was rewriting dist/ underneath the running suite. Re-run clean, alone, both are fully green — recorded here rather than quietly dropped.

gateresult
@objectstack/metadata-protocol72 files / 1066 passed
@objectstack/objectql187 files / 3312 passed
@objectstack/client21 files / 282 passed
@objectstack/runtime137 files / 2093 passed
@objectstack/dogfood92 passed + 1 skipped / 588 passed, 3 skipped
build closure (--filter @objectstack/runtime...)✓ exit 0
pnpm check:durability-log-level
pnpm check:nul-bytes✓ (7216 files scanned)
eslint on changed filesclean

check:error-code-casing is not applicable — no new error code. pnpm typecheck on @objectstack/metadata-protocol reports no typecheck script (its ledger entry), so it is not a gate this diff can run.


Generated by Claude Code

@huangyiirene
huangyiirene marked this pull request as ready for review August 12, 2026 03:15
@huangyiirene
huangyiirene added this pull request to the merge queueAug 12, 2026
Merged via the queue into main with commit b201ca8Aug 12, 2026
26 checks passed
@huangyiirene
huangyiirene deleted the claude/issue-7743-field-overlay-lock branch August 12, 2026 03:31
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentationImprovements or additions to documentationsize/lteststooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants

@huangyiirene@claude