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)

Activity

  1. 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

  2. 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

  3. self-assigned this
    on Aug 12, 2026
  4. 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

  5. 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

  6. 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

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions