Skip to content

fix(lint): three write rules ask anchor provenance before exempting a system column - #8996

Merged
os-project-manager merged 3 commits into
mainfrom
claude/issue-8663-implicit-fields-provenance
Aug 16, 2026
Merged

fix(lint): three write rules ask anchor provenance before exempting a system column#8996
os-project-manager merged 3 commits into
mainfrom
claude/issue-8663-implicit-fields-provenance

Conversation

@os-project-manager

@os-project-manager os-project-manager commented Aug 16, 2026

Copy link
Copy Markdown
Collaborator

Fixes #8663

This was a measure-first card: the filing asserted a structural resemblance to the read-axis gap and explicitly declined to guess the runtime behaviour ("I did not measure either"). The triage fence made the measurement deliverable #1 and said plainly that a no-gap result was the correct delivery. So the measurement came first, and it is reported here in full — including the part that does not match the card's framing.

Deliverable #1 — the measurement

Question. On an ADR-0015 external object with an injected (unprovisioned) anchor, does a hook/action/flow body write to that anchor degrade silently, or does the external write path already refuse it downstream?

Answer: neither. Measured end to end, on a write-enabled federated object (external.allowWrites + external.writable) bound to a real remote SQLite table that has no owner_id column.

layer genuinely undeclared field unprovisioned injected anchor
ADR-0015 Gate 3 (assertWriteAllowed) blocked without the double opt-in blocked without the double opt-in
engine record validation refused — INVALID_FIELD, on insert and on update; never reaches a driver passes — the anchor is in the registered schema
driver-sql to remote SQLite never reaches it SQLITE_ERROR: table fact_orders has no column named owner_id — whole statement aborted

Raw evidence:

A1 external object registered fields: ["amount","created_at","created_by","order_id",
    "organization_id","owner_id","owning_business_unit_id","updated_at","updated_by"]
A1 LOCAL control registered fields:   ["created_at","created_by","organization_id","owner_id",
    "owning_business_unit_id","title","updated_at","updated_by"]
A2 insert{order_id, owner_id} threw: NO
A2 driver received: [{"op":"create","object":"wh_order","data":{"order_id":"o1","owner_id":"usr_phantom"}}]
A3 insert{order_id, totally_bogus} threw: code=INVALID_FIELD  driver received: []
A4b update{totally_bogus}        threw: code=INVALID_FIELD  driver received: []

B1 insert threw: SqliteError code=SQLITE_ERROR status=undefined
   msg=insert into `fact_orders` (`amount`, `id`, `order_id`, `owner_id`) values (...)
       - table fact_orders has no column named owner_id
B3 update threw: SqliteError code=SQLITE_ERROR status=undefined - no such column: owner_id

Three things follow, and only the first was in the card:

  1. The gap is real. Nothing in the platform refuses the write. applySystemFields injects the anchors onto a federated object byte-identically to a local one (A1), so the name is a registered field and every membership test says yes.
  2. The harm is not the shape the card guessed. It is not the lint: view-filter / page-binding field checks resolve against the blanket SYSTEM_FIELDS union, so the #8116 unprovisioned-anchor warning cannot reach filter surfaces #8340 / lint: searchableFields is a FIFTH blanket-SYSTEM_FIELDS read site — a declared anchor on an external object becomes a $searchFields narrowing over a column with no storage #8404 constant-false silent degradation. It is the L2 hook body 写集不透明:把「写了目标对象上不存在的字段」从 accepted gap 变成作者时 lint 告警 #4271 driver split — loud on a SQL remote, silent on a schemaless one — which is exactly the harm this rule family already exists to move to author time.
  3. The anchor is privileged, and that is the real finding. A genuinely undeclared name is refused upstream by the engine's own write-path validator (INVALID_FIELD), on both verbs, and never reaches a driver at all. The injected anchor is the only payload key that gets past it. So the ugly driver-level failure the rule family was built to prevent is, on this path, reachable only through the blind spot — not "one more case of" it.

Note also that the rule's own header states validateRecord "continues past a key with no field def on update". Measured, update refuses with INVALID_FIELD too (A4b). Recorded, not acted on.

What changed

IMPLICIT_FIELDS (validate-hook-body-writes.ts:265) is object-independent: it answers "could this name be implicitly writable somewhere", never "did the platform provision a column for it on this object". Three rules consume it — validate-hook-body-writes, validate-action-body-writes and validate-flow-node-writes (the third one found during triage, so one set covers three surfaces). All three now ask the second question before going silent, via indexUnprovisionedAnchors — the per-stack helper #8340 built for exactly this and which no rule in this family had used yet.

Each surface gets a new, separately suppressible rule id at warning, matching #8340's flow-template-field-unprovisioned precedent exactly rather than reclassifying the existing finding:

  • hook-body-write-unprovisioned-anchor
  • action-body-write-unprovisioned-anchor
  • flow-node-write-unprovisioned-anchor

The cause and fix wording is the shared unprovisionedAnchorCause / unprovisionedAnchorHint; the consequence clause is new, shared across the three write surfaces, and every clause in it is a measured fact from the table above rather than an inference.

The flow-node rule advises, it does not gate. Its existence finding stays error — a literal key against a literal object is a certainty. The provenance finding is a claim about a remote schema the build cannot see, so promoting it to error would convert a currently-silent case straight into a build break, which is the ADR-0072 D1 false positive at the one severity that cannot be ignored. FlowNodeWriteSeverity widens from 'error' to 'error' | 'warning' accordingly — the same widening validateFlowTemplatePaths already carries, and the reference-integrity suite is severity-agnostic by contract.

An author-declared column of the same name is never flagged — on a federated object it maps a remote column the author vouches for (#7859's direction), so its provenance is author and both findings stay silent.

Candidate 2 — validate-translation-references.ts — measured, NO GAP, untouched

Fenced out of scope by the triage, and measured anyway so the disposition rests on evidence rather than on the filing's reasoning. The question is whether a bundle naming an injected anchor in a translation key is drift. It is not: applyInjectedSystemColumns adds the anchors to the served /meta document, ungated by provenance, so the field is served and rendered.

C1 served /meta fields, EXTERNAL object: ["amount","created_at","created_by","order_id",
    "organization_id","owner_id","owning_business_unit_id","updated_at","updated_by"]
C1 served /meta fields, LOCAL control  : [... identical modulo the authored fields ...]
C3 owner_id def served on external: {"type":"lookup","reference":"sys_user","label":"Owner",
    "readonly":false,"system":true,"description":"Record owner ..."}

The anchor is served with the platform's own label: "Owner". A bundle translating it is labelling a field the user actually sees, always empty. Warning there would be the ADR-0072 D1 false positive. That set is unchanged in this PR.

Verification

Union run at 3ff8c4a95, which is HEAD of this branch.

  • pnpm --filter @objectstack/lint test73 files, 2061 tests passed (14 new)
  • pnpm --filter @objectstack/lint typecheck — clean
  • Gates derived from the actual changed paths with scripts/pm/dispatch-gates.mjs, all green: check:nul-bytes, check:cross-package-test-inputs (both the lint.yml and ci.yml spellings), check:changeset-gate-self-tests, check:objectui-changeset, check-adr-0087-registration, check-changeset-no-major, check-empty-changeset, plus the test-file convention family — check:engine-double-contract, check:where-matcher, check:query-options-erasure, check:type-check-coverage, check:type-check-debt. Re-derived after the docs edit and added check:docs-audit-scope and check:role-word, both green.
  • check:type-check-debt initially refused (unbuilt closure — a refusal is NOT MEASURED, not "not applicable"), so the full workspace closure was built and it was re-run: 33 ledger entries re-measured, none above its recorded number. It reports @objectstack/lint at 19 against a recorded 20, i.e. one below the ceiling. The ledger is deliberately left alone: lowering it would bake a number measured on this branch, and the surplus it names is already tracked in [finding][devx] check:type-check-debt 的 ledger 余量会让新写的 pin 变哑:mongodb 曾有 33 条余量吞掉一次真实回退,另有 5 条目前带 4–19 余量 #6376.
  • Ratchet family and the full lint suite were re-run at 3ff8c4a95 after the docs-link fix below, not carried over from the earlier head.

Docs-link fix (round 2)

Check Documentation Links went red on the first head: the added line linked ADR-0015 to /docs/protocol/federation, which does not exist. Fixed by making the ADR id bare text — matching all eight existing ADR-0015 citations in content/docs — and moving the link onto the concept, pointing at the page that actually owns federation:

an ADR-0015 [`external` object](/docs/data-modeling/external-datasources)

One correction to the triage of that failure, since it changes what the convention actually is: linked ADR spellings do exist in content/docsgrep -roh '\[ADR-[0-9]*\]([^)]*)' content/docs/ returns ADR-0020, ADR-0039, ADR-0046, ADR-0063, ADR-0064, ADR-0101, ADR-0109 and others. They uniformly target the GitHub ADR source file (https://github.com/.../docs/adr/00NN-*.md), never a docs route. So the corpus has two conventions, not one: bare text for ADR-0015 specifically (8 sites), and GitHub-source links for linked ADRs generally. The spelling above keeps the first and does not introduce a new target class.

lychee is not installed locally, so the link was verified by replicating the gate's own resolution rule (--root-dir content/, --fallback-extensions mdx,md) over every root-relative link on the page — all five resolve. check:doc-anchors was also re-run green, but it is worth recording that it is the fragment gate and could not have caught this: a page-path link is a different check.

Ablation

Behavioural, not import-level, so the red is precise: the four new short-circuits were replaced by the original blanket continue while every export stayed in place.

× warns on a ctx.input write to an injected anchor the federated object has no storage for
× warns on the ctx.api surface too, naming the method
× warns when an action body writes an anchor the federated object has no storage for
× warns (does NOT gate) when a node writes an anchor the federated object has no storage for
AssertionError: expected [] to have a length of 1 but got +0
Test Files  3 failed | 70 passed (73)   Tests  4 failed | 2057 passed (2061)

Stated honestly, because this lane has been burned by tests that pass with and without the thing they pin: 4 of the 14 new tests are ablation-proven. The other 10 assert silence — the local twin, the author-declared column, the multi-target per-object branch, the non-anchor rule-local exemptions, and the unchanged existence findings — and by construction they pass in both directions. They are false-positive and regression guards, not evidence for the change. Restored with git checkout HEAD -- ... from the commit (committed before the ablation, so a restore point existed); git status --porcelain clean afterwards, no staged/unstaged split.

Out of scope, deliberately

  • The IMPLICIT_FIELDS set itself, and SYSTEM_FIELDS, are unwidened — the union's origin (lint: five hand-copied SYSTEM_FIELDS lists in packages/lint should derive from the spec's declarations #4330) is untouched. The fix pairs the membership test with a provenance test; it does not move a name between sets.
  • An external object declaring no fields at all stays unjudgeable and therefore silent, exactly as today. Its anchors are knowable even though its field map is not, so a warning there would be sound — but that widens the rules onto objects where they currently produce nothing, which is a different change with a different risk profile and was not the scoped one. Flagged for triage rather than taken.

Generated by Claude Code

claude added 2 commits August 16, 2026 04:44
… system column (#8663)

`IMPLICIT_FIELDS` is object-independent, so on an ADR-0015 `external` object it
exempted injected anchors the platform never provisioned storage for. Measured
end to end: the engine's write-path validator PASSES the anchor (it is in the
registered schema) while refusing an undeclared name outright with
INVALID_FIELD, so the anchor is the only payload key that reaches the remote
database raw — where SQLite answers an untyped `no such column` that aborts the
whole statement.

Each of the three consumers now emits a separate advisory finding on that path,
at `warning`, reusing the read-axis anchor wording. Author-declared columns of
the same name stay untouched.
@vercel

vercel Bot commented Aug 16, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

1 Skipped Deployment
Project Deployment Actions Updated (UTC)
objectstack Ignored Ignored Aug 16, 2026 5:11am

Request Review

@github-actions github-actions Bot added size/m documentation Improvements or additions to documentation tests tooling labels Aug 16, 2026
…s it (#8663)

The added line linked ADR-0015 to /docs/protocol/federation, which does not
exist — Check Documentation Links caught it. The ADR id now reads as bare text
(matching all eight existing ADR-0015 citations in content/docs) and the link
moves onto the concept, targeting the page that actually owns federation.
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/lint.

3 hand-written doc(s) reference the affected code and may need an implementation-accuracy re-verification:

  • content/docs/automation/hook-bodies.mdx (via @objectstack/lint)
  • content/docs/deployment/validating-metadata.mdx (via packages/lint)
  • content/docs/permissions/authorization.mdx (via @objectstack/lint)

1 release-owned page(s) also reference the affected code. These are read-only:

  • content/docs/releases/v17.mdx (via @objectstack/lint)

content/docs/releases/ is RELEASE-OWNED (AGENTS.md "Documentation Guardrails"): release
notes are written centrally at release time, and a code PR that edits them is the exact PR
that guardrail exists to stop. They are still audited — read-only. If one of them is actually
wrong, file an issue or open a dedicated docs-only PR; do not edit it here.

Advisory only. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs origin/main → pass the list as args.docs.

@os-project-manager
os-project-manager marked this pull request as ready for review August 16, 2026 05:15
@os-project-manager
os-project-manager added this pull request to the merge queue Aug 16, 2026
Merged via the queue into main with commit 192213f Aug 16, 2026
31 checks passed
@os-project-manager
os-project-manager deleted the claude/issue-8663-implicit-fields-provenance branch August 16, 2026 05:44
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation size/m tests tooling

Projects

None yet

2 participants