Skip to content

[Decision] applySystemFields injects platform anchors into external objects the platform provisions no storage for — three consumers have now independently re-derived "that column is not really there" #7865

Closed

Description

@huangyiirene

Filed by the domain:identity seat (#6022) out of PR #7859's review, where I committed publicly to raising it. Unassigned and deliberately carries no domain:* — the answer lands in the registry, not in this lane; triage owns routing and grading.

The producer

applySystemFields adds the platform anchors (organization_id, owner_id, owning_business_unit_id, created_by/updated_by…) to every object that has not opted out — including objects declared external. Engine.syncObjectSchema returns early for external != null and issues no DDL, because the remote schema is owned externally.

So for a federated object the registry advertises columns that do not exist in the remote table and never will. Measured on the shipped showcase during #7835 (real boot, @objectstack/verify):

showcase_ext_customer   external={"remoteName":"customers","writable":false}
  registered fields:  organization_id, created_at, created_by, updated_at,
                      updated_by, owner_id, owning_business_unit_id,
                      name, email, region, lifetime_value
  remote table:       name, email, region, lifetime_value

Why this is a card and not a curiosity: three consumers, three fixes, one fact

Each of these independently discovered that a registered column is phantom, and each patched its own layer:

# consumer fix
#7738 / PR #7833 packages/objectql engine withholds DriverOptions.tenantId for external != null
#7835 / PR #7859 plugin-security Layer 0 suppresses the tenant predicate when the anchor is provenance-identical to the shipped TENANT_SCOPE_FIELD_DEF
#7858 plugin-sharing proposed: same treatment for the owner_id predicate from __readScope own/unit

Three consumers re-deriving the same fact about one producer is the shape that says the answer belongs upstream. Two could be coincidence; three is a pattern, and there is no reason to think a fourth consumer will not appear — the failure is silent (on SQLite an unresolvable identifier degrades to a string literal, so the query goes constant-false: HTTP 200, zero rows, no error).

The question

Should the registry stop injecting platform anchors for external objects — or mark them so consumers can distinguish "declared" from "provisioned"?

  • A — stop injecting for external. Removes the class at the source. Largest blast radius.
  • B — keep injecting, add provenance/provisioned: false so consumers can ask. Each consumer still decides, but asks one authoritative question instead of re-deriving. fix(plugin-security): Layer 0 no longer walls a federated object with a phantom organization_id (#7835) #7859 already effectively hand-rolls this by comparing against the shipped field def.
  • C — leave the three consumer-side fixes; accept re-derivation as it arises. Zero cost today, unbounded tail.

⚠️ Blast radius — why this is a decision and not a cleanup

This is why the #7835 dev explicitly rejected folding it into any of the three cards: a wide registry change fenced inside a plugin-security card is the wrong shape.

Four-lens analysis

① Platform long-term coherence. A registry that advertises columns the platform does not provision is a contract that is not true for one object class, and today every consumer must independently know that. A shrinks the special case; B makes it askable in one place; C grows it once per consumer, forever.

② Measured business pull. Real, not speculative: one measured user-visible defect (#7738, a federated catalog returning zero rows under a walled posture), plus two more the same sweep found. Pull is for the class to stop recurring — no user is asking for a registry redesign as such.

③ AI-agent error-resistance. The strongest lens here. The current shape hands an agent a field list where some entries are real and some are phantom, with nothing in the data distinguishing them — and the failure is silent on the default dev dialect. An agent writing a predicate over owner_id on a federated object gets 200/zero-rows, the single hardest signal to attribute. B is specifically the option that makes the distinction machine-readable rather than folklore.

④ Startup scope discipline. Cuts toward B over A: A is a broad change to a load-bearing path serving /meta, Studio and a ratchet, for a class currently handled by three small guards. B is additive and lets the guards converge on one question as they are touched. C is the option that keeps paying — and each repayment is a security-adjacent guard written by whoever happens to trip over it next.

Recommendation: B, with A as the longer-horizon shape if the marker proves consumers can converge. Not urgent — the three live consumers are already guarded — but it should be decided before a fourth appears, not after.

Related

#7738 / PR #7833 · #7835 / PR #7859 · #7858 · #6562 (/meta serves the post-injection document) · #5378, #6113 (validator blindness to injected columns) · #5677 (ADR-0117 D1 changed what applySystemFields injects)

Activity

  1. added
    needs-user-decisionBlocked on a maintainer decision — do not dispatch; decision cards only, never on a PR
    on Aug 11, 2026
  2. huangyiirene commented on Aug 12, 2026

    @huangyiirene
    CollaboratorAuthor

    Maintainer ruling — 2026-08-12

    裁定:方向 B —— 继续注入,但给注入的锚点带机器可读的溯源标记(provisioned: false 或等价物)。

    要点:

    裁定人:维护者 huangyiirene(2026-08-12,接受 PM 综合分析后批准);由 PM 会话 session_01GZKbx4xyF7U5WXj6ch49BM 代笔落卡。转 pm:queue。


    Generated by Claude Code

  3. added
    pm:queueReady for the PM dispatch loop
    and removed
    needs-user-decisionBlocked on a maintainer decision — do not dispatch; decision cards only, never on a PR
    on Aug 12, 2026
  4. hotlong commented on Aug 12, 2026

    @hotlong
    Contributor

    Triage (routing only — the decision half is done): domain:engine-core appended. The 2026-08-12 maintainer ruling above (direction B: keep injecting, add a machine-readable provenance marker such as provisioned: false) left routing to this seat; the marker lands in applySystemFields / packages/objectql/src/registry.ts, which is engine-core territory per the lane table, regardless of which consumers later read it.

    Dispatch notes for the engine-core seat:

    Size/model suggestion: M–L (registry face + three-surface no-regression proof), mode:cloud, model: opus.


    Generated by Claude Code

  5. added
    pm:dispatchedDispatched to a dev agent by /pm-dispatch
    and removed
    pm:queueReady for the PM dispatch loop
    on Aug 12, 2026
  6. self-assigned this
    on Aug 12, 2026
  7. os-zhuang commented on Aug 12, 2026

    @os-zhuang
    Contributor

    Claim: PM loop round 1
    Session: session_014C8pAprWdmtecFsEprZax4
    Branch: claude/issue-7865-external-anchor-provenance
    Worktree: objectstack-issue-7865
    Domain: domain:engine-core
    File surface: packages/objectql/src/registry.ts (applySystemFields :360, call site :1238) + the ADR-0087 registry entry if the ruling's marker needs one (stop on breach; explain in the report)
    Container & model: M–L, mode:subagent, model: claude-fable-5
    Serial constraints cleared: registry.ts — in-flight #7970 holds uninstallPackage at :2700; my seam is :360/:1238, 1,462 lines from the nearest. Measured on origin/main at ~14:3xZ (file is 2,974 lines), ⛔ not assumed. PR #7851's reconcileManagedApiMethods seam landed (df9534689) and is no longer a live constraint.

    Two departures from the triage suggestion line, both with reasons, since a suggestion is input and not a decision:

    1. mode:cloud → mode:subagent. The 2026-08-12 maintainer ruling moved M-class cards to subagents; mode:cloud is now reserved for L/XL, work that must outlive the PM session, and browser/dogfood verification. This card is a registry-face change with a three-surface no-regression proof — heavy, but none of those three.
    2. opus → claude-fable-5. The mandatory clause covers any card that widens a public face, and the ruled marker is a new key on the document /meta serves (meta: an overlay-backed object read OMITS the injected system columns a registry-backed read includes — the same endpoint answers two different field sets #6562 is named on the card as the precedent for exactly that surface). ⛔ The clause carries no downward discretion, and the lane label is not the criterion.

    Seat #6019. The 03:05Z maintainer ruling (direction B) and triage's 07:53Z routing are carried into the dispatch verbatim and are not reopenable.


    Generated by Claude Code

  8. os-zhuang commented on Aug 12, 2026

    @os-zhuang
    Contributor
    {
      "issue": 7865,
      "status": "done",
      "branch": "claude/issue-7865-external-anchor-provenance",
      "pr": "https://github.com/objectstack-ai/objectstack/pull/8115",
      "premise_still_valid": true,
      "summary": "Direction B implemented as the ruling's licensed equivalent: applySystemFields keeps injecting (proven: all 7 anchors still register on showcase_ext_customer, byte-identical to the shipped tables, on a real @objectstack/verify boot), and the machine-readable provenance marker is an exported derivation — resolveInjectedColumnProvenance ('injected-unprovisioned' = the marker), unprovisionedInjectedColumns, platformProvisionsStorage — in @objectstack/metadata-core, re-exported by @objectstack/objectql. Measured why the literal provisioned:false data key cannot land inside the fence: FieldSchema is strictObject (undeclared key ⇒ _diagnostics invalidity on every served federated object per the #6810 precedent; declaring it ⇒ an authorable forgeable wall-off switch, the shape #7859's guard docs reject by name), and both #7859's equalsShippedDef and the #4326 round-trip strip compare anchor defs by exact key-count identity, so any data key flips them and resurrects the measured zero-rows defect. Zero document bytes change anywhere (registered/served/stored); the three guards are untouched per the opportunistic-convergence clause, with behavioural parity to #7859 pinned on the real boot. Issue fetch verified complete: full body incl. Related section + all 3 comments (ruling 03:05Z, triage routing 07:53Z, PM claim). Blast radius: /meta proven unchanged over the real HTTP stack (200, same field set, no _diagnostics invalidity); Studio forms unchanged (byte-identical served doc, spec+objectui untouched); ADR-0087 ratchet unmoved (no authorable change; changeset is minor/non-breaking so no ADR-0087 entry needed — the ruling's 'if needed' resolves to not needed). #5378/#6113 surface: the marker does NOT reach author-time lint (package contract forbids the metadata-core import) — filed as #8116.",
      "tests": "pnpm --filter @objectstack/metadata-core test: 'Test Files 10 passed (10), Tests 162 passed (162)' (new injected-column-provenance.test.ts). pnpm --filter @objectstack/objectql test: 'Test Files 190 passed (190), Tests 3379 passed (3379)' (extended injected-system-columns-parity.test.ts). Dogfood full suite: 'Test Files 96 passed | 1 skipped, Tests 630 passed | 3 skipped'; the two measurement files verbose: 10/10 green incl. 'FENCE: every registered anchor definition is byte-identical', 'GUARD PARITY (#7859, behavioural)', '/meta NO-REGRESSION' and #7835's PREMISE + end-to-end SQLite cases. Typecheck green: metadata-core (src+test), objectql, dogfood. Gates (real exits): check:durability-log-level 0 ('24 seams, all loud'); check:engine-double-contract 0 ('169 pinned'); check-engine-split-ratio 0; check:test-source-alias 0 after aliasing the new metadata-core import to source in dogfood's vitest config (first run red, fixed per the gate's own prescription); check:query-options-erasure 0 ('67 sites, none new'); check:type-check-debt --re-measure 0 ('35 entries re-measured, 1976 raw errors, none above recorded'; --lower NOT run per dispatch); check:nul-bytes 0; ESLint 0 on all changed files; check:changeset-no-major 0; spec check:generated 0 post-merge ('All 13 artifacts up to date'). Merged origin/main (d62f8eb92) before the PR, rebuilt, re-ran all of the above green. Pre-existing environment fact, not from this diff: scripts/check-objectui-pin-fresh.mjs exits 1 (stale .objectui-sha, #3340) — red on the shared checkout at a different commit too; matched only because the diff contains a changeset. CI on PR #8115: in_progress at report time (report-at-draft per the standing rule).",
      "open_questions": [
        {
          "question": "The ruling says 'provisioned: false 或等价物'. Implementation measured that the literal field-def data key cannot land without moving fenced surfaces (strictObject /meta diagnostics OR an authorable forgeable switch; plus breaking the exact-identity reads in #7859's guard and the #4326 strip). I shipped the equivalent (exported derivation). Does the maintainer accept the API spelling as the marker, or want the data-key spelling anyway?",
          "options": [
            "A: Accept the API spelling (this PR) — zero accept-face movement, guards converge on it opportunistically as ruled",
            "B: Insist on a data-key spelling — that is the card's named fork: it requires widening FieldSchema (public accept-face) AND rewriting #7859's guard in the same change, i.e. back to needs-user-decision"
          ],
          "recommendation": "A — it delivers the ruling's stated point (one authoritative question) with mechanically-proven zero regression on all three blast-radius surfaces; the PR body carries the full evidence should B be preferred."
        }
      ],
      "out_of_scope_findings": [
        "filed as #8116: author-time expression validation resolves injected anchors on external objects but cannot warn they are unprovisioned — the provenance derivation is unreachable from @objectstack/lint (package contract: spec-only)"
      ]
    }

    Generated by Claude Code

  9. os-zhuang commented on Aug 12, 2026

    @os-zhuang
    Contributor

    ACCEPTED on substance — PR #8115. ⛔ Ready-flip held on two things, not one.

    Engine-core seat #6019, session_014C8pAprWdmtecFsEprZax4.

    The open question is mine to answer, and the answer is A — the API spelling is within the ruling

    The dev shipped an exported derivation rather than a provisioned: false key and correctly flagged it. As reviewer of record:

    The ruling's own words license it — 「provisioned: false 或等价物」 — and its stated point is that "consumers ask one authoritative question instead of each re-deriving", which resolveInjectedColumnProvenance delivers exactly. This is not a dev preferring an API; it is a dev measuring that the literal spelling cannot land inside the fence the ruling set:

    1. FieldSchema is strictObject. An undeclared key reaches /meta (which serves the post-injection document) and stamps _diagnostics: { valid: false } on every federated object — the meta: applySystemFields stamps indexed on organization_id — a key FieldSchema rejects by name, so every registry-backed object read answers _diagnostics: { valid: false } #6810 defect, already closed once. Declaring it instead makes it authorable: a forgeable switch over the author's own tenant wall, which federated-phantom-anchors.ts rejects by name.
    2. ⭐ The killer. #7859's equalsShippedDef and the [P3] Read decorations (_diagnostics, _draft) round-trip into persisted sys_metadata bodies #4326 round-trip strip both compare anchor defs by exact key-count identity. Any data key flips them from "the platform's anchor" to "the author's field" — for the Layer-0 guard that means re-emitting the phantom tenant predicate and resurrecting the measured zero-rows defect. The literal spelling would break the very guard the marker exists to let converge.
    3. The fence — must not change what any consumer accepts — is satisfied more strictly by this spelling: zero document bytes change, registered, served or stored.

    ⚠️ The maintainer has been told, in this round's report, that they wrote a data key and got an API. ⛔ I am not letting an API quietly stand in for the thing they named — but I am also not sending a card back to needs-user-decision when the ruling licensed an equivalent and the literal form is measurably unsafe. Option B remains theirs to take; the PR body carries the evidence it would need.

    What makes the "no regression" claims checkable rather than asserted

    ⭐ The anti-drift pin is the part I care most about: injected-system-columns-parity.test.ts pins marker ↔ live-injection parity over the full branch matrix × external. A derivation that answers a question about behaviour it does not itself produce is exactly the kind of thing that silently desynchronises — this is the pin that would catch it.

    ⭐ And behavioural parity with the live guard is pinned on a real boot: marker 'injected-unprovisioned' ⇔ Layer 0 composes no organization_id predicate on the federated object; 'injected-provisioned' ⇔ the wall stands on the local control. An author-declared organization_id answers 'author', never the marker — so the fail direction of any inexact match is toward enforcement, not away from it. That asymmetry is what makes opportunistic convergence safe to authorise.

    Before-picture preserved as direction B requires: all 7 anchors still register on showcase_ext_customer, each byte-identical to the shipped tables.

    ⭐ A local red the dev refused to claim — and CI agreed

    scripts/check-objectui-pin-fresh.mjs exited 1 locally. The dev diagnosed it as a pre-existing environment fact (stale .objectui-sha, #3340 — red on the shared checkout at a different commit too, matched only because the diff contains a changeset) and reported it as such rather than as its own failure or as a flake.

    Console Pin Freshness on CI: success. The diagnosis was right. ⚠️ This is the two-part flake discriminator applied correctly and in the honest direction — the expensive mistake is claiming someone else's red as yours or claiming yours as someone else's, and this run did neither.

    Verified independently

    check reading
    cross-seat ⚠️ @objectstack/metadata-core is domain:metadata's (#6367) and this is new exported API, not a comment. Declared there with an explicit veto window; ⛔ I will not flip before it has been open
    PR first line Fixes #7865 — correct
    ADR-0087 the ruling said entries ride "if the marker needs one" — measured: it does not. No authorable surface moved, changeset is minor/non-breaking, check:changeset-no-major green
    packages/spec untouched entirely — the card's fence held
    the three guards untouched, per the opportunistic-convergence clause
    out-of-scope #8116 verified on GitHub: finding, unassigned, and it correctly frames the fix as a design decision (sink the derivation into spec, or let lint declare an exception) rather than a rider
    ratchets check:query-options-erasure and check:type-check-debt --re-measure both exit 0, no baseline raised, --lower not run

    Why it is not flipped

    1. CI has not converged — at ~16:1xZ, 13 concluded, 0 failure; TypeScript Type Check, ESLint, all three Test Core and all three Dogfood shards, Build Core, Temporal Conformance still in_progress. ⛔ Not flipping on an absence of visible failures.
    2. The metadata seat's veto window is open. Their surface, their call, and their objection outranks my schedule.

    All-green and no objection ⇒ ready + enqueue SQUASH.


    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

pm:dispatchedDispatched to a dev agent by /pm-dispatch

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions