Repository navigation
org-override-registry-gate: the field overlay lock is not enforced — an artifact-backed field PUT is accepted 200 (and is inert) #7743
Description
Activity
- addedbugSomething isn't workingSomething isn't workingand removed
on Aug 11, 2026 huangyiirene commented
on Aug 12, 2026 CollaboratorAuthorMore actionsClaim: PM loop round 4 (
domain:metadataseat,huangyiireneterm)- Session:
session_01AmES43BMDg4bPrxTdi5q7t - Branch:
claude/issue-7743-field-overlay-lock - Worktree:
objectstack-issue-7743(dedicated) - Domain:
domain:metadata - File surface:
packages/metadata-protocol/src/protocol.ts(isArtifactBackedand the org-override gate around:8678) + a route-level pin - Container & model: M ⇒
mode:cloud,model: opus - Serial constraints cleared:
packages/metadata-protocol/src/protocol.tsis free — theorganization_idstrict-equality family closed out tonight (protocol.deletePackagefinds zerosys_metadatarows the data plane finds 3 of — uninstall leaves orphaned rows (persistence half of #7557) #7705 → listCommits misses env-wide sys_metadata_commit rows — same strict organization_id equality #7705 fixed one function away #7779 → Four more strictorganization_idequalities left in protocol.ts — two measured (revertCommit / rollbackToPackageCommit), two unverified (duplicatePackage / reassignOrphanedMetadata) #7819 tier 1756fd12→ tier 2bcea363), leaving 0 strict-equality sites and no open PR holding the file. Verified at source, not inherited.
Premise accepted as re-verified by the filer at
origin/main00e9196(both theisArtifactBackedresolution and thefieldregistry row unchanged); the dispatch still requires a fresh check at the branch point, sinceprotocol.tshas taken several merges since.Two defects are entangled here, and fixing one HIDES the other
The card records both, and this is the part the dispatch is built around:
- the write is accepted 200 and persists with
_diagnostics.valid=true, for an override the registry forbids; - the accepted write is inert —
GET /meta/object/showcase_taskstill reads the original label, and a brand-new field written the same way never appears in the object'sfields.
⚠️ Refusing the write at the door removes the only evidence that the runtime ignores it. If a later card makes field overrides legal, defect 2 reappears silently with no test in the way. So the dispatch requires the dev to establish whether the inertness is an independent defect — and if it is, to report it rather than fold it in, so it gets its own card while the evidence still exists.⭐ The reason this survived is a test-placement fact, not an oversight
overlay-precedence.test.ts(27 passing) pins the protocol-level denial. The livefieldroute is not in its coverage, so the gate is proven where it is exercised and absent where it is used.A new pin written at the same layer would reproduce exactly that blindness — green, and blind to the reported symptom. The pin therefore has to exercise the route, which is the whole lesson of this card.
Gate list names consumer suites by what exercises the symbol
metadata-protocol,objectql,client,runtime, plusqa-dogfood(this is a live-route behaviour and the QA run is where it was found).⚠️ Path-scoped gate derivation does not findpackages/objectql's protocol doubles — that omission sent #7819 tier 1's first head red earlier tonight, and this seat does not intend to re-learn it.
Generated by Claude Code
- Session:
huangyiirene commented
on Aug 12, 2026 CollaboratorAuthorMore actions{ "issue": 7743, "premise_still_valid": true, "premise_note": "The card's suspected root cause is CONFIRMED at the branch point (bcea363), with one refinement. `field`'s registry row is unchanged (`allowOrgOverride: false, allowRuntimeCreate: true`, metadata-plugin.zod.ts:629) and `isArtifactBacked` still resolves through `getArtifactItem(singular, name, pkg) ?? getArtifactItem(type, name, pkg)` — anchored on symbols, re-read at branch point, not on the card's line numbers. REFINEMENT: the card named `saveMetaItem`'s own NOT_OVERRIDABLE block as the disarmed gate, but that block sits behind `environmentId !== undefined` and the showcase boots with NO environmentId. On that topology it is skipped entirely; the site that actually refused `object` in the same run is `SysMetadataRepository.assertAllowed`, whose `intent` the protocol picks from the SAME `isArtifactBacked` predicate. Both doors read it, so making the predicate truthful closes both.", "status": "shipped", "pr": 7897, "pr_first_line": "Part of #7743", "pr_first_line_rationale": "The refusal ships and is pinned. `Part of` rather than `Fixes` because a THIRD defect found while writing the pin leaves the same lock reachable one URL away (#7894, plural spelling) — the PM owns whether #7743 closes on the singular route alone.", "branch": "claude/issue-7743-field-overlay-lock", "commit": "c203a9f", "worktree": "objectstack-issue-7743 (dedicated, off origin/main)", "reproduced_before_fix": { "field_override_title": "PUT /api/v1/meta/field/showcase_task.title -> 200 state:'active'; GET reads back _diagnostics.valid=true", "field_override_status": "reproduced identically", "inertness": "GET /meta/object/showcase_task -> title.label still 'Title', 24 fields, zz_new_probe absent" }, "fix": { "file": "packages/metadata-protocol/src/protocol.ts", "symbol": "isArtifactBacked -> new private isNestedArtifactField", "shape": "For `field`, split `<object>.<field>` at the first separator, resolve the PARENT through the artifact-only lookup (shadow-immune, ADR-0010 3.3), and answer whether the shipped object carries that field. Returns a boolean, not a synthetic envelope: a field sub-document has no _packageId/_lock, and lookupArtifactItem's other callers (lock resolution, mergeArtifactProtection, layered read) consume that envelope. `fields` read in its one canonical record form, no array fallback (PD #12).", "runtime_create_tier_untouched": true }, "scope_class_verdict": { "answer": "field-specific, measured — NOT a class", "method": "Listed every registry type on a booted showcase and counted package-stamped artifact items.", "standalone_artifacts": "action 70, page 33, permission 16, dataset 9, doc 9, hook 4, report 4, mapping/book/email_template 1 each", "genuinely_no_artifacts": "position, tool, skill, seed, translation, external_catalog — 'not artifact-backed' is the TRUE answer there", "field": "2 items listed, 0 package-stamped (both written by this session's probes) — while showcase_task alone ships 24 fields", "instructive_case": "`action` is ALSO nested inside the object document, yet IS registered standalone, so it was already refused 403 in the same run. field is the only name where the registry's answer and the shipped artifact disagree.", "diff_widened": false }, "entanglement_verdict": { "question": "is the inertness an independent defect?", "answer": "INDEPENDENT — and, contrary to the card's prediction, its evidence SURVIVES this fix", "measurement": "The inertness has two halves. The OVERRIDE half (showcase_task.title) is now unreachable, as predicted. The CREATE half is not: a brand-new field is a write that stays entirely LEGAL under allowRuntimeCreate: true. Measured AFTER the fix, on the rebuilt stack: PUT /meta/field/showcase_task.zz_new_probe -> 200, row reads back _diagnostics.valid=true, and GET /meta/object/showcase_task still reports 24 fields with zz_new_probe absent.", "consequence": "#7893 ships with a LIVE repro on main, not archived evidence. The dispatch's warning ('say plainly that the evidence is about to become unreachable') does not apply — it is reachable.", "folded_in": false, "filed_as": 7893 }, "third_defect_found": { "filed_as": 7894, "title": "meta-plural-url-bypass: PUT /meta/fields/<name> walks around the whole two-tier registry gate", "how_found": "The plural case in the new pin was written expecting 403 and MEASURED 200; then confirmed against the live showcase.", "live_evidence": "PUT /meta/field/showcase_task.title -> 403 NOT_OVERRIDABLE (this PR). PUT /meta/fields/showcase_task.title -> 200, receipt 'Saved fields ...', row persisted under type='fields'.", "cause": "canonicalMetaType folds plural->singular through PLURAL_TO_SINGULAR — the MANIFEST COLLECTION map — which has no `fields` key (nor seeds, external_catalogs, translations). An unmapped spelling reads as an unregistered PLUGIN type, which every gate treats as permissive by construction (assertAllowed returns early on !STATIC_REGISTRY_TYPES.has('fields'); orgScopedWriteRefusal says so verbatim). Correct for a real plugin type; 'fields' is not one.", "why_not_fixed_here": "The narrow patch (teaching isNestedArtifactField to accept 'fields') was REJECTED: a spelling-tolerant lookup below the boundary is the exact pattern canonicalMetaType's own doc comment rejects (#4432), and it would still mint the second namespace type='fields'. The real remedy is at the boundary map, spans 4 types, and collides with metadata-authoring-lint.ts iterating that same map to advertise stack-level collections — a design call, not a rider.", "pinned_as": "an explicitly-labelled KNOWN GAP test asserting today's behaviour, naming #7894, stating it goes RED when #7894 lands. Not hidden." }, "pin": { "file": "packages/runtime/src/meta-field-overlay-lock.test.ts", "layer": "LIVE ROUTE — real HttpDispatcher.handleMetadata + real ObjectStackProtocolImplementation + real SysMetadataRepository; reads the stored ROW. Nothing stubs saveMetaItem.", "why_not_protocol_level": "overlay-precedence.test.ts has 27 passing protocol-level cases that stayed green through the entire life of this defect, because the live field route is not in their coverage. A 28th assertion there would have been green and blind identically. Route-level pin WAS possible; no substitution was made.", "registry_double": "reproduces the real miss faithfully — serves the `object` artifact, has NO `field` collection at all. Making it answer a field item would erase the defect in the harness.", "topologies": "both — environmentId undefined (showcase; repository gate is the enforcement site) and environmentId 'env_1' (saveMetaItem's own gate). Both assert 403 AND code NOT_OVERRIDABLE per ADR-0112; no bare toThrow anywhere.", "objectql_double_caution_honoured": "the harness's hand-rolled matchesWhere CONJOINS $or with sibling keys via `continue`, never an early-returning some() (#7846 / #7620)." }, "assert_consequence_both_directions": { "positive": ["field/showcase_task.title -> 403", "field/showcase_task.status -> 403", "same on an environment kernel -> 403"], "negative_already_correct_types_unchanged": ["object 403", "view 200 (overlay effective)", "dashboard 200", "job 403 code-only", "action 403", "page 403", "position 200"], "legitimate_field_write_preserved": ["brand-new field on a packaged object -> 200 + row persisted", "field of a RUNTIME-created object -> 200 + row persisted"], "note": "Measured live before AND after the fix; all seven contrasts byte-identical across the two runs. The `object` contrast used the FULL body — a trimmed one hits DESTRUCTIVE_CHANGE first, per the card's reproduction note." }, "reverse_verification": { "rebuild_between_measurements": true, "method": "Fix COMMITTED first. Then `git checkout origin/main -- packages/metadata-protocol/src/protocol.ts` AND `pnpm --filter @objectstack/metadata-protocol build`; verified the dist really moved with `grep -c isNestedArtifactField dist/index.js` -> 0. Measured. Restored from the commit, rebuilt, -> 3.", "predicted_before_running": "3 red / 7 green", "measured": "3 red / 7 green", "greens_are_not_slack": "4 are the negative direction (object/view/dashboard/job — tightening any is over-reach and must fail here); 2 are the legitimate field write (a fix refusing every field PUT would pass one-directionally and break the feature); 1 is the #7894 known gap." }, "gates": { "@objectstack/metadata-protocol": "72 files / 1066 passed", "@objectstack/objectql": "187 files / 3312 passed", "@objectstack/client": "21 files / 282 passed", "@objectstack/runtime": "137 files / 2093 passed", "@objectstack/dogfood": "92 passed + 1 skipped / 588 passed, 3 skipped", "build_closure": "exit 0", "check:durability-log-level": "pass", "check:nul-bytes": "pass (7216 files)", "eslint_changed_files": "clean", "check:error-code-casing": "n/a — no new error code; reuses NOT_OVERRIDABLE", "flake_disclosure": "metadata-protocol first reported 7 failed FILES / 0 failed tests, and dogfood 27 failed FILES / 0 failed tests. Both were measured while a build was rewriting dist/ underneath the running suite. Re-run clean and alone, both fully green. Recorded rather than dropped." }, "changeset": ".changeset/field-overlay-lock-enforced.md (@objectstack/metadata-protocol patch)", "adr_touched": false, "releases_touched": false, "open_questions": [ "PR first line is `Part of #7743`, not `Fixes` — the singular route is locked, but #7894 leaves the plural spelling open on the same lock. Resolved by me toward the more conservative line; the PM owns whether #7743 closes on this PR.", "#7893 (inertness) needs a maintainer decision between implementing field composition, retiring the write channel under ADR-0049 enforce-or-remove, or documenting it — filed with all three priced rather than guessed.", "#7894's remedy is a design call (add keys to PLURAL_TO_SINGULAR vs split the manifest-collection and URL-spelling roles vs refuse unknown plurals at the boundary). The trap is documented in the issue: metadata-authoring-lint.ts iterates that map to advertise stack-level collections, so adding `fields` there would advertise a top-level collection that does not exist." ], "blockers": [], "ci": "in_progress — PM owns CI convergence, the ready-flip and landing; not polled." }
Generated by Claude Code
huangyiirene commented
on Aug 12, 2026 CollaboratorAuthorMore actionsACCEPT — shipped as PR #7897 (head
c203a9fd). Path face is 3 files: changeset,protocol.ts, andpackages/runtime/src/meta-field-overlay-lock.test.ts. Nodocs/adr/**, neither skill root.Both load-bearing gates passed, and I verified them in the test file rather than from the prose
1. The pin exercises the LIVE ROUTE. This was the whole point — 27 green protocol-level cases are exactly why the defect survived, so a protocol-level assertion would have been green and blind in the same way. Measured in the file:
import { HttpDispatcher } from './http-dispatcher.js'; … await dispatcher.handleMetadata('/field/showcase_task.title', ctx(), 'PUT', …)It drives the dispatcher, not the protocol. The blind spot is closed at the layer where the symptom was observed.
2. The inertness was reported, not folded in — filed as #7893, measured and proven independent. That was the instruction that mattered most, because landing the refusal destroys the evidence for it. You captured it while it was still observable.
3. A third defect I did not anticipate — #7894, the plural URL spelling walking around the same lock — and it is pinned as a
KNOWN GAPcase in the suite, not just mentioned. That is the same shape #7814 used for #7819: an assertion that holds the known-incomplete state so the remainder cannot drift unnoticed. It is also whyPart ofis right here.The premise refinement is the most valuable thing in the report
The card (and my brief, inheriting it) pointed at
saveMetaItem's ownNOT_OVERRIDABLEblock. You measured that the flagship showcase boots with noenvironmentId, so that block is skipped entirely on that topology, and the site that actually refusedobjectin the same run isSysMetadataRepository.assertAllowed— with the repository's sentence, not the protocol's.Fixing the block the card named would have produced a green PR that did not fix the reported symptom. Both doors read
isArtifactBacked, so making that predicate truthful closes both — and the suite pins both topologies (refuses it on an ENVIRONMENT kernel too, where a different gate is the one that fires). That is a correction to the card and to my brief, found by measurement.The root-cause refinement is also sharper than the card's:
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 insideObjectSchema.fields. So the lookup misses on a field the package unambiguously ships.The control set is exactly right
Six CONTROL cases, and they guard both directions of over-reach:
objectstill refused ·jobstill refused with its own sentence — andview/dashboardstill ACCEPTED (allowOrgOverride: true);- a brand-new field on a packaged object is still CREATED —
allowRuntimeCreate: trueis real, and refusing everyfieldPUT would have passed a one-directional test while breaking the feature; - a field of a runtime-created object stays writable, since it is not artifact-backed.
A fix that tightened the lock too far fails these. That is what makes the green meaningful.
⚠️ One decision I am taking, not leaving implicitPart of #7743is correct today — the card's headline ("the lock is not enforced") remains partly true while the plural spelling is open. But once this lands, this card's reported symptom is fixed and its remaining content lives in #7893 (inertness) and #7894 (plural spelling). I will not leave it open indefinitely duplicating two cards that own its remainder: at landing I will either close it citing both, or state explicitly why it stays open. Recording that here so it does not drift into limbo.Landing: ESLint, Check Changeset, Build Core, Test Core (1/3), Dogfood and Temporal are green; Test Core (2/3), (3/3) and TypeScript Type Check are still converging. I hold the ready-flip until every gate job's conclusion is
success. Nothing further needed from you.
Generated by Claude Code
huangyiirene commented
on Aug 12, 2026 CollaboratorAuthorMore actionsLANDED — PR #7897 merged as
b201ca8. Closing this card, as committed at ACCEPT.I said at ACCEPT that I would decide this card's state explicitly rather than let it drift, so: closing it, with its remainder routed rather than dropped.
Why closing is right, not just tidy
The card's own reported symptom —
PUT /api/v1/meta/field/showcase_task.titleanswered 200 with the row persistedvalid=true, for a write the registry forbids — is repaired, and repaired at the layer where it was observed. Its reproduction no longer reproduces.What remains was never this card's content; it was found because of it, and each half now has an owner with the evidence attached:
- meta-field-write-inert: an accepted
fieldPUT never reaches the object — a runtime-created field is storedvalid=trueand is absent fromfieldsforever #7893 — the accepted write is inert (a runtime-created field is storedvalid=trueand never appears in the object'sfields). Measured and proven independent before the refusal landed, which is the only window in which that evidence was obtainable. Landing the refusal destroyed the observation; the card preserved it. - meta-plural-url-bypass:
PUT /meta/fields/<name>walks around the whole two-tier registry gate — 4 registry types have no entry inPLURAL_TO_SINGULAR#7894 — the plural URL spelling (PUT /meta/fields/) walks around the whole two-tier registry gate; four registry types have no entry inPLURAL_TO_SINGULAR. Pinned in the new suite as aKNOWN GAPcase, so the hole is asserted rather than merely described.
Leaving this card open would duplicate two cards that own its remainder — the
pm:dispatchedlimbo I said I would not create. Closing it does not drop anything: both successors are open, both carry measurements, and #7894's gap is held by a live assertion.What landed, verified on the merged ref
b201ca8, single-parent squash. The fix madeisArtifactBackedtruthful for nested fields rather than patching one door — which matters, because the dev measured that the block the card pointed at is skipped entirely on the flagship topology (it sits behindenvironmentId !== undefined, and the showcase boots without one). The site that actually refusedobjectwasSysMetadataRepository.assertAllowed. Fixing the door the card named would have produced a green PR that did not fix the reported symptom.The pin drives
HttpDispatcher.handleMetadata— the live route — with six CONTROL cases guarding both directions of over-reach:object/jobstill refused,view/dashboardstill accepted, a genuinely new runtime field still created (allowRuntimeCreate: trueis real), and a runtime-created object's field still writable.
Generated by Claude Code
- meta-field-write-inert: an accepted
- added 3 commits that reference this issue
on Oct 7, 2026
Symptom
The registry declares
fieldwithallowOrgOverride=false, and the field is artifact-backed — yet the live route accepts the overriding write.PUT /api/v1/meta/field/showcase_task.title {name:'title', label:'Tampered', type:'text'}with an admin bearer → 200state:'active'. Reproduced onshowcase_task.status._diagnostics.valid=true.GET /meta/object/showcase_taskstill readstitle.label = 'Title'. A brand-new field written the same way never appears in the object'sfieldseither.So the door answers success twice over — accepted, and stored valid — for a write the registry forbids and the runtime ignores. The
object,view,dashboardandjobvariants all behave correctly in the same run, and locked-override vs open-create were proven independent there.Root cause (suspected — located, not proven by a fix)
The gate keys on
isArtifactBacked(type, name), which resolves throughregistry.getArtifactItem(singular, name, currentPackageId) ?? registry.getArtifactItem(type, name, currentPackageId)(packages/metadata-protocol/src/protocol.ts:8678-8679). Fields are not registered as standalonefieldartifact items — they live inside the object — sogetArtifactItem('field', 'showcase_task.title')misses. With the artifact lookup empty, the write is classified as a runtime-only create, and the registry entry forfieldcarriesallowRuntimeCreate: true:—
packages/spec/src/kernel/metadata-plugin.zod.ts:629onorigin/main.allowOrgOverride: falseis never consulted because nothing on this path believes an artifact is being overridden.Why the pin stayed green:
overlay-precedence.test.ts(27 passing) pins the protocol-level denial. The livefieldroute is not in its coverage, so the gate is proven where it is exercised and absent where it is used.Stale-premise check: re-verified on
objectstackorigin/main(00e9196) — theisArtifactBackedresolution and thefieldregistry row are both unchanged.Reproduction
PUT /api/v1/meta/field/showcase_task.titlewith{name:'title', label:'Tampered', type:'text'}→ 200state:'active'.GET /api/v1/meta/field/showcase_task.title→ the row exists,_diagnostics.valid=true.GET /api/v1/meta/object/showcase_task→title.labelis still'Title'— the accepted write changed nothing.object/view/dashboard/jobis correctly refused.Note for whoever reproduces the
objectcontrast: a trimmed object body hitsDESTRUCTIVE_CHANGEfirst — the full body is needed to reach the registry gate.Source
Extracted from the QA run #7695 (framework 92f26f7, console 09987b680).