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

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)

Metadata

Metadata

Assignees

No one assigned

    Labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions