Uh oh!
There was an error while loading. Please reload this page.
fix(metadata-protocol): enforce the field overlay lock at the live route (#7743) - #7897
Conversation
…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
The latest updates on your projects. Learn more about Vercel for GitHub. 1 Skipped Deployment
|
📓 Docs Drift CheckThis PR changes 1 package(s): 3 hand-written doc(s) reference the affected code and may need an implementation-accuracy re-verification:
⛔ 1 release-owned page(s) also reference the affected code. These are read-only:
|
huangyiirene
commented
Aug 12, 2026
Gate table — final, all greenTwo suites first reported file-level failures with zero test failures (
Generated by Claude Code |
Uh oh!
There was an error while loading. Please reload this page.
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 ofrather thanFixes.The defect
The registry declares
fieldwithallowOrgOverride: false, and a field a code package ships is an artifact. Yet, with an admin bearer on a booted showcase: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.fieldis the ONE type inDEFAULT_METADATA_TYPE_REGISTRYwhose artifacts are not standalone registry items. ItsfilePatterns(**/*.field.ts) match nothing in any app, because fields are authored inside the object (ObjectSchema.fields, az.record(name, FieldSchema)). So the object's loader registers oneobjectitem and nofielditems at all, andgetArtifactItem('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, andfieldcarriesallowRuntimeCreate: true— soallowOrgOverride: falseis never consulted.The refinement: the card pointed at
saveMetaItem's ownNOT_OVERRIDABLEblock, but that block is behindenvironmentId !== undefinedand 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 refusedobjectin the same run isSysMetadataRepository.assertAllowed, whoseintent(override-artifactvsruntime-only) the protocol picks from the very same predicate. Measured:objectwas 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
isArtifactBackednow falls back toisNestedArtifactFieldforfield: 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/_lockenvelope, andlookupArtifactItem'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.fieldsis 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 assumedThe 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:action70,page33,permission16,dataset9,doc9,hook4,report4,mapping/book/email_template1position,tool,skill,seed,translation,external_catalogfieldis 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 — whileshowcase_taskalone ships 24 fields.actionis 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:
field/showcase_task.title(artifact override)valid=trueNOT_OVERRIDABLEfield/showcase_task.statusNOT_OVERRIDABLEfield/showcase_task.zz_new_probe(brand-new)object/showcase_task(full body)NOT_OVERRIDABLEview/showcase_task.in_progressdashboard/system_overviewjob/showcase_health_sweepaction/showcase_mark_done,page/sys_user_detailposition/contributorThe
objectcontrast used the full body — a trimmed one hitsDESTRUCTIVE_CHANGEfirst 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.tsalready pins this denial with 27 passing cases — at the protocol level, callingsaveMetaItemdirectly. The reported symptom sailed straight past all 27 because the livefieldroute 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.tsdrives the realHttpDispatcher.handleMetadata, the realObjectStackProtocolImplementationand the realSysMetadataRepository, then reads the stored row. Nothing stubssaveMetaItem— 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 theobjectartifact and has nofieldcollection at all.Both topologies are driven, because they refuse at different sites:
environmentId: undefined(the showcase, where the repository gate is the enforcement point) andenvironmentId: 'env_1'(wheresaveMetaItem's own gate fires first). Both assert403andNOT_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/runtimeresolves@objectstack/metadata-protocolthrough itsdist, and stack traces are source-mapped back tosrc, so a source-only revert measures nothing while looking like it measured something. The fix was committed first, then reverted withgit checkout origin/main -- packages/metadata-protocol/src/protocol.tsand rebuilt (grep -c isNestedArtifactField dist/index.js→0), 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/jobwere measured as already correct, so a fix that tightened any of them is over-reach and must fail here. Two are the legitimatefieldwrite:allowRuntimeCreate: trueis real, and a fix that refused everyfieldPUT 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: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:
canonicalMetaTypefolds plural→singular throughPLURAL_TO_SINGULAR, which is the manifest collection map and has nofieldskey (norseeds,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 undertype='fields', a second namespace, which is the #4432 defect class.⛔ The one-word patch — teaching
isNestedArtifactFieldto accept'fields'— was rejected: it is a spelling-tolerant lookup below the boundary, the exact patterncanonicalMetaType'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
@objectstack/metadata-protocol@objectstack/objectql(incl. the 27 protocol-leveloverlay-precedencecases and the protocol doubles path-scoped derivation misses)@objectstack/client@objectstack/runtimepnpm check:durability-log-levelpnpm check:nul-bytescheck:error-code-casingis not applicable — no new error code; the refusal reusesNOT_OVERRIDABLE, which is the sentence this condition already carries.Changeset:
.changeset/field-overlay-lock-enforced.md.Generated by Claude Code