Skip to content

org-override-registry-gate: the field overlay lock is not enforced — an artifact-backed field PUT is accepted 200 (and is inert) #7743

Description

@huangyiirene

Symptom

The registry declares field with allowOrgOverride=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 → 200 state:'active'. Reproduced on showcase_task.status.
  • The row is persisted and reads back with _diagnostics.valid=true.
  • And the accepted write is inert: GET /meta/object/showcase_task still reads title.label = 'Title'. A brand-new field written the same way never appears in the object's fields either.

So the door answers success twice over — accepted, and stored valid — for a write the registry forbids and the runtime ignores. The object, view, dashboard and job variants 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 through registry.getArtifactItem(singular, name, currentPackageId) ?? registry.getArtifactItem(type, name, currentPackageId) (packages/metadata-protocol/src/protocol.ts:8678-8679). Fields are not registered as standalone field artifact items — they live inside the object — so getArtifactItem('field', 'showcase_task.title') misses. With the artifact lookup empty, the write is classified as a runtime-only create, and the registry entry for field carries allowRuntimeCreate: true:

{ type: 'field', label: 'Field', …, allowOrgOverride: false, allowRuntimeCreate: true, … }

— packages/spec/src/kernel/metadata-plugin.zod.ts:629 on origin/main.

allowOrgOverride: false is 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 live field route 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 objectstack origin/main (00e9196) — the isArtifactBacked resolution and the field registry row are both unchanged.

Reproduction

  1. Boot the showcase with writable runtime packages; obtain an admin bearer.
  2. PUT /api/v1/meta/field/showcase_task.title with {name:'title', label:'Tampered', type:'text'} → 200 state:'active'.
  3. GET /api/v1/meta/field/showcase_task.title → the row exists, _diagnostics.valid=true.
  4. GET /api/v1/meta/object/showcase_task → title.label is still 'Title' — the accepted write changed nothing.
  5. Contrast: the same override attempt against object / view / dashboard / job is correctly refused.

Note for whoever reproduces the object contrast: a trimmed object body hits DESTRUCTIVE_CHANGE first — the full body is needed to reach the registry gate.

Source

Extracted from the QA run #7695 (framework 92f26f7, console 09987b680).

Activity

  1. self-assigned this
    on Aug 12, 2026
  2. huangyiirene commented on Aug 12, 2026

    @huangyiirene
    CollaboratorAuthor

    Claim: PM loop round 4 (domain:metadata seat, huangyiirene term)

    Premise accepted as re-verified by the filer at origin/main 00e9196 (both the isArtifactBacked resolution and the field registry row unchanged); the dispatch still requires a fresh check at the branch point, since protocol.ts has 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:

    1. the write is accepted 200 and persists with _diagnostics.valid=true, for an override the registry forbids;
    2. the accepted write is inert — GET /meta/object/showcase_task still reads the original label, and a brand-new field written the same way never appears in the object's fields.

    ⚠️ 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 live field route 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, plus qa-dogfood (this is a live-route behaviour and the QA run is where it was found). ⚠️ Path-scoped gate derivation does not find packages/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

  3. huangyiirene commented on Aug 12, 2026

    @huangyiirene
    CollaboratorAuthor
    {
      "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

  4. huangyiirene commented on Aug 12, 2026

    @huangyiirene
    CollaboratorAuthor

    ACCEPT — shipped as PR #7897 (head c203a9fd). Path face is 3 files: changeset, protocol.ts, and packages/runtime/src/meta-field-overlay-lock.test.ts. No docs/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 GAP case 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 why Part of is right here.

    The premise refinement is the most valuable thing in the report

    The card (and my brief, inheriting it) pointed at saveMetaItem's own NOT_OVERRIDABLE block. You measured that the flagship showcase boots with no environmentId, so that block is skipped entirely on that topology, and the site that actually refused object in the same run is SysMetadataRepository.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: 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 ObjectSchema.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:

    • object still refused · job still refused with its own sentence — and view / dashboard still ACCEPTED (allowOrgOverride: true);
    • a brand-new field on a packaged object is still CREATED — allowRuntimeCreate: true is real, and refusing every field PUT 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 implicit

    Part of #7743 is 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

  5. huangyiirene commented on Aug 12, 2026

    @huangyiirene
    CollaboratorAuthor

    LANDED — 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.title answered 200 with the row persisted valid=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:

    Leaving this card open would duplicate two cards that own its remainder — the pm:dispatched limbo 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 made isArtifactBacked truthful 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 behind environmentId !== undefined, and the showcase boots without one). The site that actually refused object was SysMetadataRepository.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/job still refused, view/dashboard still accepted, a genuinely new runtime field still created (allowRuntimeCreate: true is real), and a runtime-created object's field still writable.


    Generated by Claude Code

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

Metadata

Metadata

Assignees

Labels

bugSomething isn't working

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions