Repository navigation
[finding] three live consumers still read reference_to as a tolerated alias of reference — the fourth and fifth doors #11567's "one key, one answer" never reached #13250
Description
Activity
- addedbugSomething isn't workingSomething isn't workingpriority:p2Medium: important, M3Medium: important, M3
on Aug 29, 2026 PM triage — seat
session_01LZbWd2jNV1FErXTPSS4Dry(PM seat #6367,domain:enginelane). Gradedbug/finding/priority:p2/pm:queue/domain:engine. Routed to this lane because the relationship-key family is where it lives,⚠️ though the three readers spanlint,plugin-securityandverify— see the split below.The sharpest sentence in this card is the one about
data-model-rules.ts:167: the one component whose job is to tell an author their metadata is wrong is the component that accepts the wrong spelling. A lint rule tolerating a key the spec refuses is not breadth, it is the gate reporting green on the thing it exists to catch.⛔ Not dispatchable as one unit, and the card is right about why
The three readers have different populations, so a single "narrow them all" dispatch would be a guess dressed as consistency:
reader population can the alias actually appear? lint/src/data-model-rules.ts:167authored source No — FieldSchemarefuses it upstream. Tolerance here is pure defect.verify/src/derive.ts:103possibly raw registered metadata Maybe — registerObjectdeliberately skips Zod (#3896)plugin-security/src/security-plugin.ts:5846possibly raw registered metadata Maybe — same route So the lint reader is a clean narrowing with a settled precedent (
validate-security-posture.ts:208was narrowed to canonical-only on purpose, with the reason recorded in-file). The other two need someone to measure whether raw-registerObjectmetadata carryingreference_toactually reaches them,⚠️ because narrowing a reader that legitimately sees legacy stored data would turn a tolerated read into a silent miss — the inverse defect.Whoever takes this should expect to deliver the lint narrowing plus a measurement for the other two, and report rather than narrow if the measurement says the alias is reachable.
Bearing on #13222
⚠️ Worth reading alongside #13222, which is now atneeds-user-decision. This card is the same "one key, one answer" gap at three more doors, and it strengthens the general case without settling #13222's specific question — #13222's blast radius comes from the published README having taught the key to external users, which none of these three readers share.⛔ Do not fold this into #13222 or vice versa.
Generated by Claude Code
Claim — dev agent, session
session_01LZbWd2jNV1FErXTPSS4Dry, branchclaude/issue-13250-narrow-lint-reference-alias.Scope per PM triage: (1) narrow the lint reader
packages/lint/src/data-model-rules.ts:167to canonicalreferenceonly, matching the recorded precedent invalidate-security-posture.ts:208; (2) measure — not narrow — whether rawregisterObjectmetadata carrying the rejected alias can actually reachpackages/verify/src/derive.ts:103andpackages/plugins/plugin-security/src/security-plugin.ts:5846. If the measurement shows the alias is reachable at either, that reader stays tolerant and the evidence is reported.Staying out of
packages/drivers/driver-mongodb/**entirely (#13222 is at needs-user-decision; PRs #13224 and #13238 are in flight there).Generated by Claude Code
Generated by Claude Code
- added a commit that references this issue
on Aug 30, 2026 os-dev-report
{ "issue": 13250, "status": "done", "branch": "claude/issue-13250-narrow-lint-reference-alias", "pr": "https://github.com/objectstack-ai/objectstack/pull/13322", "premise_still_valid": true, "summary": "Narrowed the ONE reader the card and PM triage settled: `refOf` in packages/lint/src/data-model-rules.ts went from `def?.reference || def?.reference_to` to canonical-only, matching the deliberate narrowing already recorded in-file for `refOf` in packages/lint/src/validate-security-posture.ts (including its `typeof r === 'string'` guard, which also makes the declared `string | undefined` return type true). MEASURED the other two instead of narrowing them, and BOTH are reachable, so per the dispatch neither was touched. plugin-security/src/security-plugin.ts:5846 reads `ql.getSchema()`, i.e. SchemaRegistry.getObject; a real registerObject/getObject round-trip preserves `reference_to` verbatim and the reader resolves it (registerObject skips Zod by design, #3896, and engine.ts states the engine never re-parses). verify/src/derive.ts:103 reads the config `loadConfig()` returns, and loadConfig does not parse — measured through the real loadConfig() on a config file that calls no `define*` helper, and through `defineStack(cfg, {strict:false})`; deriveCrudCases read the alias into a relationalRef in both. Extra finding for triage: narrowing the security reader would NOT be a silent miss but a loud outage — resolveCbpRelation returning null is fail-closed (RLS_DENY_FILTER = 0 rows for non-admins on read, MasterDetailRelationMissingError thrown on write), so a raw-registered controlled_by_parent object spelled with the alias would flip from 'access derived from master' to 'everything denied'. PR uses `Part of #13250` so the card stays open for those two.", "tests": "UNION RUN AT FINAL COMMIT 03c514700 (git rev-parse --short HEAD from that same run). pnpm --filter @objectstack/lint build exit=0; pnpm --filter @objectstack/lint exec vitest run --maxWorkers=2 exit=0 -> 'Test Files 82 passed (82) / Tests 2327 passed (2327)'; pnpm --filter @objectstack/cli exec vitest run --maxWorkers=2 test/data-model-rules.test.ts exit=0 -> 'Test Files 1 passed (1) / Tests 56 passed (56)'; lint typecheck exit=0; cli typecheck exit=0; check:nul-bytes exit=0 -> 'check-nul-bytes: OK (scanned 7368 text file(s) ... no raw ASCII control bytes)'. ABLATION (both legs rebuilt @objectstack/lint and proved the change on disk AND in dist before any reading): mutation leg — on disk current-guard=0/alias-branch=1 as required, ablation-dist-preflight 'marker present in 2 built files -- the ablation is live in the artifact the suite consumes', vitest exit 1, 'Tests 3 failed | 53 passed (56)', the three failures being exactly the three new assertions (R1 fires on `reference_to`; the resolved target NAME comes from `reference`; a non-string `reference` is not a target). Restore leg — git checkout HEAD -- ABSOLUTE_PATH, hash-object f71636000c21b411eff9f89186b566d8e6b14f95 == the HEAD blob, `git diff HEAD -- packages/lint/src/data-model-rules.ts` empty, on disk current-guard=1/alias-branch=0, ablation-dist-preflight --absent 'marker absent from all 14 built files', vitest exit 0, 56/56. The mutation script carried trap RESTORE EXIT INT TERM with an absolute REPO_ROOT path. REACHABILITY MEASUREMENT (real dist artifacts, with positive controls): control 0 — FieldSchema.safeParse({...reference_to}) success:false codes ['unrecognized_keys'], canonical success:true. Reader A — registerObject/getObject field keys ['type','label','reference_to','required'] and the reader expression `f?.reference ?? f?.reference_to ?? f?.referenceTo` resolves 'p_obj'; canonical control resolves 'p_obj' too. Reader B — defineStack(cfg) strict THROWS with the spec's own unrecognized_keys verdict; defineStack(cfg,{strict:false}) keeps ['type','label','reference_to','required']; deriveCrudCases returns relationalRefs [{field:'parent',target:'p_obj',required:true,multiple:false}] for the alias via the non-strict door, via a plain-object config, AND via the real loadConfig() on a real objectstack.config.ts — identical to the canonical positive control. GATES: re-derived from the actual diff with `node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack` (no paths passed; 3 paths vs merge base 56c093c4d). All 29 derived + convention-triggered gates exit 0 (incl. cross-package-test-inputs, test-source-alias, where-matcher, engine-double-contract, type-check-coverage, published-files, empty-changeset, adr-0087-registration, changeset-no-major, objectui-changeset, cli-test-child-env). ESLint was NOT narrowed: `pnpm exec eslint . --no-inline-config --format json` linted 5458 files, 0 errors, 0 warnings. NOT MEASURED (each by the gate's own refusal text, none of them a red): check-test-completeness.mjs 'PREREQUISITE NOT MET - this gate grades a saved turbo test log, and no log was named'; check-half-states.mjs bare form 'PREREQUISITE NOT MET - the token in the environment is not a valid GitHub credential' (the --self-test form CI runs, check:pm-half-states, exits 0); check:dual-build-cjs-loads and check:type-check-debt both 'Run pnpm build first. This is NOT a pass: nothing was measured' (full workspace build required — a repo-wide sweep left to CI). ONE HONEST COVERAGE GAP, pre-existing and ledgered: packages/cli/tsconfig.json is include:['src'], so packages/cli/test/** is outside every tsc program — measured, not assumed, via `tsc --noEmit --listFiles` reporting 0 hits for test/data-model-rules.test.ts. So 'cli typecheck exit=0' says nothing about the new test file. Ad-hoc typecheck of that file (NodeNext + strict, closure built) gives 26 errors, 0 of them in the added lines, and the TEST_DEBT ledger records this exact file at 'test/data-model-rules.test.ts x26' — so the addition moves the ratchet by zero. The ledger's own verdict is still CI's to give.", "mcp_calls": "0 — every GitHub read and write went through repo-scoped REST (probe: HTTP 200 on the issue endpoint from this container), so no MCP GitHub tool was invoked", "open_questions": [ { "question": "packages/plugins/plugin-security/src/security-plugin.ts:5846 — the alias IS reachable (raw registerObject never re-parses). Narrow it, or keep the tolerance and document it?", "options": [ "A. Keep the tolerant read, add an in-file note recording WHY it is deliberate (the validate-security-posture.ts precedent shape, inverted), plus a loud one-shot diagnostic when the alias is what resolved the relation — the author hears about it, no live deployment loses access.", "B. Narrow to canonical-only for one-key-one-answer consistency, accepting that any raw-registered controlled_by_parent object spelled with the alias flips to fail-closed: RLS_DENY_FILTER (0 rows for every non-admin) on read and MasterDetailRelationMissingError on write.", "C. Narrow, but only behind a migration: sweep stored/raw metadata for the alias first, then flip." ], "recommendation": "A, because the measured consequence of B is an availability break on a population that provably exists, and the defect the card is about (a tolerant consumer hiding an authoring mistake) is fully answered by making the tolerance LOUD rather than by removing it. The registry already prints a functional-completeness diagnostic for such a field, so the channel exists; the security reader is the wrong place to enforce spelling, because its failure mode is denial rather than a message. C is the only route to B and is a separate card." }, { "question": "packages/verify/src/derive.ts:103 — the alias IS reachable (loadConfig does not parse; a config need not call any define* helper, and defineStack(cfg, {strict:false}) is a documented public option). Narrow it?", "options": [ "A. Keep the tolerant read as-is.", "B. Narrow to canonical-only: an alias-spelled required relation then becomes blocked with the precise reason 'required lookup field ... has no `reference` target', which is loud and honest, at the cost of turning objects os verify exercises today into objects it reports as blocked.", "C. Narrow AND report it as a distinct verify finding (not a generic 'blocked') so the run says 'this app spells a rejected alias' rather than 'this object could not be derived'." ], "recommendation": "C. Unlike the security reader, derive's failure mode is a report line, not a denial — so narrowing here costs coverage, not availability, and the honest direction is available. But a verifier that silently under-verifies is the exact defect #5262 was about, so the narrowing is only safe if it reports the REASON rather than degrading to a generic skip. B alone would trade one silent gap for another." } ], "out_of_scope_findings": [] }
Generated by Claude Code
PM triage update — one door closed, two measured REACHABLE, and I had the risk backwards
PR #13322 (
Part of, notFixes— this card stays open) narrows one of the three readers:refOfinpackages/lint/src/data-model-rules.ts. Reviewed and accepted; held in draft until CI is complete and green.The dispatch carried a stop condition, verbatim: "⛔ If your measurement shows the alias IS reachable at either, do NOT narrow it. Report the finding with the evidence. That is a successful outcome for this dispatch, not a failure." It fired at both remaining readers. Neither was touched. Below is what came back, with the parts I verified myself at the PR head rather than accepting on report.
⚠️ Correcting my own triage on this cardWhen I triaged #13250 I wrote that narrowing a reachable reader "would turn a tolerated read into a silent miss — the inverse defect." That is wrong for reader A, and wrong in the more dangerous direction. I checked the two lines myself at
03c514700:packages/plugins/plugin-security/src/security-plugin.ts:5959—if (!rel) return { ...RLS_DENY_FILTER };packages/plugins/plugin-security/src/security-plugin.ts:6152—if (!rel) throw new MasterDetailRelationMissingError(object, operation);
resolveCbpRelationreturning null is fail-closed. Narrowing that reader on a population that carries the alias does not produce a quiet wrong answer — it produces zero rows for every non-admin caller on read and a throw on every write. An availability outage, not a missed lint. Anyone weighing this decision against my earlier framing should weigh it against this one instead.Reader A —
plugin-security/src/security-plugin.ts:5846— REACHABLEIts population is
this.ql.getSchema(object)→SchemaRegistry.getObject. Measured through the real registry: aregisterObject→getObjectround-trip preserves the field keys verbatim as["type","label","reference_to","required"], and the reader expressionf?.reference ?? f?.reference_to ?? f?.referenceToresolves"p_obj"— with the canonical spelling resolving"p_obj"too as the positive control, so the alias result is not an artefact of a broken fixture.The repo says the same thing in its own words at
packages/objectql/src/registry.ts:1057-1058: the registry is "the one choke point EVERY door goes through … including the doors that skip Zod and lint entirely" — and it names #3896 and cloud'srowColor.mappingas shipped instances. The population is not hypothetical.Question 1: narrow it, or keep the tolerance and make it loud?
- A. Keep the tolerant read; add an in-file note recording why it is deliberate (the
validate-security-posture.tsprecedent shape, inverted), plus a one-shot diagnostic when the alias is what resolved the relation. The author hears about it; no live deployment loses access. - B. Narrow for one-key-one-answer consistency, accepting
RLS_DENY_FILTER/MasterDetailRelationMissingErroron any raw-registeredcontrolled_by_parentobject spelled with the alias. - C. Narrow, but only behind a migration: sweep stored/raw metadata for the alias first, then flip.
The dev recommends A, and I agree with the reasoning: the defect this card is about — a tolerant consumer hiding an authoring mistake — is fully answered by making the tolerance loud, and the security reader is the wrong place to enforce spelling because its failure mode is denial rather than a message. C is the only honest route to B, and it is a separate card, not a rider on this one.
Reader B —
verify/src/derive.ts:103— REACHABLEloadConfig()does not parse — itbundleRequires the module and returnsmod.default; parsing happens only if the authored module itself calls adefine*helper. Measured three ways, each against a canonical positive control that produced an identicalrelationalRefsrow: throughdefineStack(cfg, { strict: false })(a documented public option), through a plain-object config, and through the realloadConfig()on a realobjectstack.config.ts.deriveCrudCases()read the alias into arelationalRefin all three. The strict door does refuse, correctly and loudly — it is simply not the only door.Question 2: narrow it?
- A. Keep the tolerant read as-is.
- B. Narrow: an alias-spelled required relation becomes
blocked: 'required lookup field "parent" has no \reference` target'`. - C. Narrow and report it as a distinct verify finding, so the run says "this app spells a rejected alias" rather than "this object could not be derived."
Recommendation C. Unlike reader A, derive's failure mode is a report line, not a denial — narrowing here costs coverage, not availability, so the honest direction is actually available. But a verifier that silently under-verifies is the exact defect #5262 was about, so this is only safe if it reports the reason; B alone would trade one silent gap for another.
Card state
Staying open and
pm:dispatchedwhile #13322 is in flight. When it lands I move this card toneeds-user-decision— the two questions above are dispositions, not defects, and ⛔ no seat should proxy-decide them. In particular, reader A is a security-surface availability call and I am not going to make it on a maintainer's behalf.
Generated by Claude Code
4 remaining items
PR #13322 landed. Card →
needs-user-decision: one door closed, two measured REACHABLE and left alone.pm:dispatched→needs-user-decision(get_labelsread first;bug/priority:p2/domain:engineuntouched).Verified by content on
origin/main, ⛔ not by themergedboolean:reading result canonical-only guard typeof r === 'string' && r ? r : undefined1 the old alias chain def?.reference || def?.reference_to0 the bound const nonStringReferencefixture (the CI fix)1 control — refOfpresent in the file4 What is decided, and what is not
Decided and shipped:
refOfinpackages/lint/src/data-model-rules.tsreads only the canonicalreference. That was the one clean narrowing —@objectstack/lintruns over a schema-parsed stack, so the alias could not legitimately reach it, and the tolerance maderelationship/missing-reference— the rule whose whole job is to catch a relationship with no target — report a valid target for a field that has none.⛔ Not decided, and not for a seat to decide: the two remaining readers. The dispatch's stop condition fired at both, and neither was touched.
The two questions, restated for the maintainer
Q1 —
plugin-security/src/security-plugin.ts:5846. The alias IS reachable (registerObjectskips Zod by design, #3896; the registry's own comment names those doors and cites two shipped instances). Options: A keep the tolerant read and make it loud; B narrow to canonical-only; C narrow behind a migration that sweeps stored/raw metadata first.⚠️ The consequence of B is the thing to weigh, and I had it backwards when I triaged this card. I originally wrote that narrowing a reachable reader would produce "a silent miss — the inverse defect." It would not. Verified at the head::5959—if (!rel) return { ...RLS_DENY_FILTER };→ 0 rows for every non-admin caller:6152—if (!rel) throw new MasterDetailRelationMissingError(object, operation);
resolveCbpRelationreturning null is fail-closed. A raw-registeredcontrolled_by_parentobject spelled with the alias would go from "access derived from its master" to everything denied, writes throw — an availability outage on a population that provably exists, not a quiet wrong answer. Recommendation A; C is the only honest route to B and is a separate card.Q2 —
verify/src/derive.ts:103. Also reachable:loadConfig()does not parse, and bothdefineStack(cfg, { strict: false })and a plain-object config let the alias through toderiveCrudCases(). Options: A keep as-is; B narrow (an alias-spelled required relation becomesblocked); C narrow and report it as a distinct verify finding.Recommendation C. Unlike Q1, derive's failure mode is a report line rather than a denial, so narrowing costs coverage, not availability — but a verifier that silently under-verifies is the exact defect #5262 was about, so it is only safe if it reports the reason. B alone would trade one silent gap for another.
⛔ Both are dispositions on live behaviour, and Q1 is a security-surface availability call. No seat should proxy-decide either.
Generated by Claude Code
zhuangjianguo commented
on Aug 30, 2026 CollaboratorMore actions⚖️ RULED — 维护者,2026-08-30,第 5 场总监席决裁批 #4,verbatim「同意」
裁定:两个剩余读者分别处置 —— Q1(
plugin-security/security-plugin.ts:5846)采 A(保留容忍读 + 变响亮);Q2(verify/derive.ts:103)采 C(收窄 + 报为具名 verify finding)。lint 读者已由 PR #13322(MERGED)收窄,不动。- Q1 = A(保留容忍,一次性响亮诊断):别名可达(
registerObject跳过 Zod,POST /data/sharing/rules绕过SharingRuleSchema:criteria缺失或拼错静默变成"共享该对象全部记录",与 ADR-0049 "never seeded as a permissive match-all" 直接冲突 #3896;registry 自己的注释点名两个 shipped 实例)。⚠️ dev 复核推翻 triage 原判「收窄=静默漏」:resolveCbpRelation返 null 是 fail-closed(:5959RLS_DENY_FILTER 非 admin 零行、:6152抛MasterDetailRelationMissingError)⇒ 收窄会让 raw-registered、别名拼写的controlled_by_parent对象从「access derived from master」翻成everything denied、写抛异常,是可证存在人群上的可用性停机。A 用「让容忍响亮」(别名解析出关系时一次性诊断,作者听得到、无部署失去访问)完全回答本卡缺陷(容忍消费者藏作者错误),⛔ 收窄的正解是 C(先扫存量/raw 元数据再翻),它是独立卡不是本卡的 rider。 - Q2 = C(收窄 + 具名 finding):别名可达(
loadConfig不 parse;defineStack(cfg,{strict:false})与 plain-object config 都让别名进deriveCrudCases)。verify 的失败模式是报告行不是拒绝,收窄 costs coverage 不 costs 可用性 ⇒ 收窄安全,但 verifier 静默 under-verify 正是 普查:plugin-auth 之外还有 6 处把被降级的 OS_MULTI_ORG_ENABLED 当「是不是多组织」在读 —— 只设 OS_TENANCY_POSTURE 的部署会静默走错分支 #5262 的缺陷 ⇒ 必须报原因(「this app spells a rejected alias」而非「could not be derived」),⛔ B 单独会把一个静默缝换成另一个。 - 落点:Q1
security-plugin.ts:5846+ 诊断通道;Q2verify/derive.ts:103+ verify finding 类型。
性质:Q1 是安全面可用性判断(收窄在存在人群上停机)⇒ 人工地板;Q2 是 verify coverage 处置。席位边界(裁不派):录裁 + 转
pm:queue(domain:engine),两读者可折为一 PR 或串行由车道定;C 之外的迁移(Q1 真收窄)若日后要做另立卡。同笔摘needs-user-decision、释放 assignee。裁决入本场追认摘要表。
Generated by Claude Code
- Q1 = A(保留容忍,一次性响亮诊断):别名可达(
zhuangjianguo commented
on Aug 30, 2026 CollaboratorMore actionsClaim: PM loop round R6
Session:session_01F3jdziLbAPGeceVNmSox5L
Branch:claude/issue-13250-tolerant-alias-readers
Worktree:objectstack-issue-13250
Domain:domain:engine
Container & model:M,mode:subagent,model: opus.Picked up from
pm:queueper the ruling's own 席位边界 line — 裁不派. Ruling recorded at5468957088, 第 5 场总监席决裁批 #4, maintainer verbatim「同意」.Landing points, from the ruling: Q1 =
packages/plugins/plugin-security/src/security-plugin.ts:5846+ a diagnostic channel; Q2 =packages/verify/src/derive.ts:103+ a verify finding type. ⛔ The lint reader is already narrowed by PR #13322 (merged) and is not touched.One PR or two — the ruling leaves it to the lane, and I say one, with a condition. The two landing points are in different packages with no file overlap, so a single PR means one changeset and one review pass.
⚠️ The condition: if the executor finds the two halves interact — most plausibly through a shared diagnostic or finding-emission seam — it splits them and says why, rather than letting one half's difficulty hold the other hostage.Clause ② — split answer, and the second half is a live question rather than a flat no:
- Path limb: no. Nothing lands under
packages/spec/src/**. - Content limb: OPEN for Q2. The ruling requires a named verify finding, and a new finding type may widen
@objectstack/verify's published output surface.⚠️ I have not measured whether verify's finding types are a published contract or an internal enum. The executor measures it: if adding the named finding widens a published surface, that trips the content limb ⇒ stop and report before landing, and the PR goes through the review chain rather than this seat's judgment.
Serial constraints — stated as PARTIAL.
packages/plugins/plugin-security/**andpackages/verify/**are not touched by anything this lane has in flight (the lane has nothing in flight — #13475, #13527, #13529 all landed today).⚠️ I did not enumerate open PRs' file lists across the repo; the executor re-derives its write set against the merge base and confirms the single-writer path before its first edit.
Generated by Claude Code
- Path limb: no. Nothing lands under
os-dev-report
{ "issue": 13250, "status": "done", "branch": "claude/issue-13250-tolerant-alias-readers", "pr": "https://github.com/objectstack-ai/objectstack/pull/13541", "premise_still_valid": true, "summary": "Both remaining readers landed per the ruling, in ONE PR (Zone 2 A HELD: different packages, no file overlap, and no shared seam appeared -- the Q1 diagnostic rides plugin-security's existing SecurityReportSink and the Q2 finding rides verify's existing free-form strings, so neither half touched the other). Q1 = A: resolveCbpRelation STILL reads `reference ?? reference_to ?? referenceTo` -- the tolerance is intact and pinned by a test that reddens if anyone narrows it. It is now LOUD: when the alias is what resolved the relation, the plugin reports it through `this.logger.warn?.()`, the same channel, tag shape and console-backed default as every other report site in the file. No new channel. `refKey` was introduced so the report and the resolution cannot name different keys (ref is DERIVED from it, not a second spelling of the ?? chain); the present-but-null fall-through is pinned. Q2 = C: relationTarget narrowed to canonical-only, WITH the named finding beside it, so an alias-spelled relation reports the rename rather than the generic 'has no `reference` target'. TWO findings filed unassigned, `finding` label, no pm:queue, after one targeted MCP search found no existing card (the search returned #13250 itself as a live control): #13542 records the deferred Q1 narrowing behind a stored-metadata migration (Zone 1.5's separate card -- NOT done here) and what must be measured first; #13543 records a FOURTH tolerant reader found in passing, packages/objectql/src/master-detail.ts:58 (`def?.reference ?? def?.referenceTo`), which unlike the three on this card DECLARES its tolerance in its own type with a stated population ('referenceTo is the stored-row spelling') -- asserted but never measured, so it is filed as an observation, not a defect claim. COORDINATOR POINTER ANSWERED, both pages read: content/docs/ui/forms.mdx:260 does NOT teach the alias -- it is the `publicPicker.object` table row, naming `reference` first and the aliases as 'a legacy `referenceTo` / `target` / `options.objectName` on a pre-fold stored row', which accurately describes the still-tolerant picker reader at packages/rest/src/rest-server.ts:8934; it is not #13539's class and nothing is owed. content/docs/protocol/objectql/types.mdx:736-747 is the opposite of a teaching site -- the whole callout says `reference_to` is 'a key the spec REFUSES' and 'refused at the driver's door'. I also READ the three release-owned pages rather than assuming: none is made false by this change (implementation-status matches only on SecurityPlugin/RBAC prose; v15:774 is a historical v15 entry; v17:389-414 shows the alias only while stating an authored `referenceTo` has always been a hard parse rejection). Nothing to file, and I edited no docs.", "clause_2_answer_for_q2": "MEASURED, and it does NOT trip -- the named finding widens NO published surface. @objectstack/verify IS a published package (publishConfig.access public, single '.' export from dist/index.d.ts), and CrudCase IS published as a type from src/index.ts. But the finding lands in two fields that are already FREE-FORM strings on that type: `blocked?: string` and `skippedFields?: Array of {name, type, reason: string}`. Neither is an enum and no value set is enumerated anywhere -- a repo-wide sweep for the existing reason literals (relation-missing-reference, relation-target-external, unsynthesizable-optional) finds them ONLY inside derive.ts itself, with no gate, doc or test pinning the vocabulary; verify's README documents the `skipped` VERDICT generally and no reason strings. The one published union that does exist, ObjectVerifyResult.status, is untouched -- a blocked case still surfaces as status 'skipped' with the string in `reason`, and formatReport prints it verbatim. So: no new exported identifier, no changed type, no new status, no .d.ts change. INTERNAL, with evidence. check:verify-stand-in (which reconciles verify's exported functions in BOTH directions and was NOT in the derived family list) exits 0, confirming the export set is unchanged.", "reachability_reverification": "BOTH re-verified on today's main as RUNTIME measurements with positive controls -- I did not rely on the previous round. Zone 2 B HELD. Q1, through the REAL SchemaRegistry from built objectql dist: a raw registerObject of a controlled_by_parent object whose master FK spells `reference_to` serves the field back from getObject() as keys [\"name\",\"type\",\"required\",\"reference_to\"] -- canonical undefined, alias 'crm_account' verbatim. CONTROL: the same round-trip with canonical `reference` also survives, so this is about the alias and not about a registry that drops everything. Side observation, now recorded in the code: the registry ALREADY prints 'field/relationship-without-reference ... the consumer silently skips each one' for that field, which is measurably FALSE for this consumer (the relation resolves and the object is enforced normally) -- so the new report is not a duplicate of it. Q2, through built spec dist: the SAME fixture parses clean with `reference` (CONTROL A), is refused by the default strict parse with exactly one issue -- 'Unrecognized key(s) on this field: `reference_to`. Did you mean ...' (CONTROL B, so the alias is the ONLY reason it fails), and survives verbatim through both unparsed doors: defineStack(cfg, {strict:false}) returns 'company' on reference_to, and a plain-object config carries it untouched. loadConfig re-read on main and it still does no validation (bundleRequire then mod.default; its own header says 'The CLI never had a validation step to reorder: the gate lives in the loaded module').", "q1_diagnostic_granularity": "ONCE PER OBJECT, PER CACHE GENERATION -- and that is cbpRelCache's own lifecycle rather than a second one invented beside it (Zone 2 D: I chose, and the justification is from the code around it). The report sits inside resolveCbpRelation's resolution body, which runs ONLY on a cbpRelCache miss, so it inherits the cache for free. Justification, both halves measured: (a) NOT per read -- this is under the per-request RLS path; 25 resolutions of one object produce exactly 1 report (pinned). A per-read log would be a noise defect of its own, and a channel operators filter out would make the tolerance silent again by a longer route. (b) It RE-ARMS on a metadata change, because start()'s metadata.watch('*') subscription clears cbpRelCache -- pinned by driving the REAL subscription callback the plugin installed: 2 resolutions give 1 report, then a metadata change, then a 3rd resolution gives 2. That is exactly when a Studio / AI-authoring author is listening; a process-lifetime 'already warned' set would go silent at the one moment it matters, or would have to reproduce this invalidation by hand and rot the day another cache-clearing door is added. Two objects give two reports (pinned), canonical gives zero (pinned), and canonical winning over a stale alias on the same field gives zero (pinned). TOLERANCE CONFIRMED INTACT: `reference_to` and `referenceTo` each still resolve to {fk:'account', master:'crm_account'}, and ablation 1 (narrowing refKey to canonical-only) turns those tests RED -- the one thing the ruling forbids cannot land silently.", "q2_finding_text": "REQUIRED relation (CrudCase.blocked, reaching the operator verbatim as '- OBJECT skipped: REASON' via formatReport): 'required lookup field \"company_id\" spells the rejected alias `reference_to` instead of `reference` -- `reference` is the only relationship spelling @objectstack/spec declares, so this app's target \"company\" was not derived; rename the key'. It says WHY (a rejected alias was spelled), names the key, and echoes the target the app MEANT so the rename reads as mechanical. OPTIONAL relation: skippedFields reason 'relation-rejected-reference-alias:reference_to' instead of the generic 'relation-missing-reference'. The load-bearing NEGATIVE is pinned: the blocked text must NOT match /has no `reference` target/, so a later degradation to the generic sentence reddens rather than passing. A field with NO target at all keeps the generic reason, and an EMPTY alias value is not a finding -- the two findings stay distinct in both directions.", "one_pr_or_two": "ONE, as the PM proposed, and the condition for splitting never fired. The two halves are in different packages with no file overlap, and the feared interaction -- a shared diagnostic or finding-emission seam -- did not exist: Q1 reuses plugin-security's own SecurityReportSink and Q2 reuses verify's existing free-form CrudCase strings, so neither half needed a mechanism the other could constrain. One changeset, one review pass, and neither half held the other hostage.", "zone2_assumptions": "A HELD (one PR; see above). B HELD (both reachability measurements reproduce on today's main -- re-measured rather than trusted). C HELD WITH A REFINEMENT worth stating: verify does have a finding SHAPE to extend, so this was adding a case and not inventing a mechanism -- but the shape is a free-form `string`, not a typed finding union, so 'a named finding' means a distinct STRING VALUE. That is what makes the clause-2 answer come out internal; had it been an exported union, the same ruling would have tripped the content limb. D HELD (granularity chosen and justified). NONE BROKE OUTRIGHT. One surprise OUTSIDE the lettered set, since it cost the most: @objectstack/verify carries a TEST_DEBT ratchet whose measurement lifts the package tsconfig's `**/*.test.ts` exclusion, so appending tests drifted it 8 -> 19 and went RED. Cause and remedy were exactly what AGENTS.md predicts -- one relative import missing its `.js` under NodeNext does not resolve, making every symbol it names `any`; with the extension the whole file is clean at 3.", "paths_touched_beyond_the_two_landing_points": [ "packages/plugins/plugin-security/src/controlled-by-parent-reference-alias.test.ts -- NEW, the Q1 suite (11 tests)", "packages/verify/src/derive.test.ts -- appended the Q2 suite (8 tests), and fixed its own `./derive` import to `./derive.js` (see below)", ".changeset/tolerant-reference-alias-readers.md -- NEW; plugin-security patch, verify minor (an accept-set narrowing)", "scripts/check-type-check-coverage.mjs -- TEST_DEBT @objectstack/verify lowered 8 -> 3 via the gate's own `--lower`, and its `note` re-tallied to the composition actually left (TS2835 x3 in three harness test files). A ratchet TIGHTENING; raising is maintainer-only and was not done. The script's own `--self-test` (47+65+43+28+19 cases) passes, and `git grep` found no test pinning the ledger's contents -- the 12 files naming this script only discuss it in prose." ], "tests": "ALL AT HEAD 5c467362cd. Dependency closures then the WHOLE workspace built first (turbo: 32/32 then 70/70 successful), so nothing below is a verdict about a stale dist/. Every exit code captured BEFORE any pipe (redirect-then-capture). SUITES: @objectstack/plugin-security 'Test Files 90 passed (90) / Tests 1665 passed (1665)' exit 0; @objectstack/verify 'Test Files 9 passed (9) / Tests 48 passed (48)' exit 0; downstream consumer @objectstack/dogfood test/derive-topology.test.ts -- which reads exactly the `blocked` and `skippedFields` I changed, through the PUBLISHED dist -- 'Test Files 1 passed (1) / Tests 10 passed (10)' exit 0. TYPECHECK exit 0 for plugin-security, verify, cli, driver-turso and dogfood (the last three are verify's only direct dependents), each script name echoed in the output so none was a zero-match no-op. REPO-WIDE: `pnpm lint` (eslint . --no-inline-config, the whole repo) exit 0 and silent -- a FULL scan, so no narrowing needs declaring. `pnpm check:nul-bytes` exit 0, 'scanned 7515 text file(s) ... no raw ASCII control bytes'; plus a self-scan of my 6 changed files for control bytes (grep exit 1, none). GATES: 39 derived families run -- 30 from the first change set and 9 more RE-DERIVED after the gate-script edit changed the set (bare-root-worklist, agent-test-spelling, bash32-floor, cli-command-ids, entry-guard, parse-guard, pm-dispatch-gates, pnpm-filter-targets, watch-hint-literal), all exit 0 -- plus the convention-triggered ones. Verdict lines: check:type-check-debt '29 ledger entr(ies) re-measured in 210.7s, 1542 raw tsc error(s) total, none above its recorded number' AND 'surplus: none -- every entry sits exactly at its measurement, so any new error is red'; check:doc-authoring '831 pinned site(s) across 231 file(s), 80472 string(s) read ... no growth, no burn-down unrecorded'; check:dual-build-cjs-loads '102 ... 66 ... 610' at or above every floor; check:engine-double-contract, check:where-matcher, check:cross-package-test-inputs ('24 package(s) read outside themselves, all declared'), check:i18n ('9 package(s) -- all bundles in sync'), check:i18n-stale-fill, check:type-check-coverage all exit 0. TWO GATES I RAN THAT THE DERIVATION DID NOT NAME, both exit 0: `node packages/lint/scripts/check-reference-carrier-shape.mjs` (+ --self-test) -- the package-local script outside the root check:* namespace that bit this card's previous round; and `pnpm check:verify-stand-in`, which reconciles @objectstack/verify's exported set in both directions ('2 stand-in check(s) guarded, 7 candidate(s) exempt, 10 call site(s) reached, 0 asserted driver arguments'). ONE GATE FOUND A REAL DEFECT AND IT WAS FIXED AS PRESCRIBED, NOT WORKED AROUND: check:doc-authoring refused the tracker id inside the diagnostic's RUNTIME string ('A runtime string reaches authors, operators and generated surfaces -- none of whom can resolve #NNNN'). The tag became a resolvable name and the ruling anchor moved to an adjacent comment; re-run exit 0. I did NOT add a baseline entry (that path is maintainer-only and the gate says so). ABLATION -- 3 legs, run TWICE (once at ea7afa4b07 and again at the FINAL head after the tag rename and the import fix, because either could have made an assertion vacuous), both times red in the predicted direction. Implementation committed FIRST. Each mutation proven on disk by grepping BOTH the injected marker (1) and the deleted text (0) -- never a bare git diff. Each restore proven by `git hash-object` == the HEAD blob (3553d4e3... for security-plugin.ts, 64064b32... for derive.ts) plus an EMPTY whole-tree `git status --porcelain` and empty `git diff HEAD`. Under `trap RESTORE EXIT INT TERM` with an ABSOLUTE repo root, and restore spelled `git checkout HEAD -- ABSOLUTE_PATH` (never the bare form, which reads from the polluted index). Baselines 11/11 and 12/12. Leg 1, narrow the Q1 tolerant read: exit 1, 8 failed, including 'reference_to resolves the relation -- narrowing it would deny access'. Leg 2, delete the Q1 diagnostic call: exit 1, 'Tests 5 failed | 6 passed (11)'. Leg 3, restore Q2's ?? chain: exit 1, 'Tests 4 failed | 8 passed (12)'. NO dist rebuild was needed and that is stated rather than assumed: both suites reach their subject through a SAME-PACKAGE RELATIVE specifier ('./security-plugin.js', './derive.js'), not through the package exports map, so vitest reads the mutated source directly.", "not_measured": "Stated as NOT MEASURED, never folded into green. (1) @objectstack/verify's OWN `typecheck` script does NOT cover its test files -- packages/verify/tsconfig.json excludes '**/*.test.ts', and `tsc --listFiles` counts my derive.test.ts 0 times in that program. Its green says nothing about my tests. The TEST_DEBT ratchet DOES read that layer (it is what caught the 8 -> 19 drift), and there the file is now clean, so the tests ARE measured -- just not by the script that advertises it. By contrast plugin-security chains tsconfig.test.json and --listFiles counts my new suite 1 time, so that one is genuinely covered. The verify tsconfig exclusion is the anti-pattern AGENTS.md names, and the prescribed remedy is a sibling tsconfig.test.json -- I did NOT fix it: it fails condition 1 of the bounded in-place exemption (different defect class from this card), so it is left alone rather than ridden in. (2) check-test-completeness: 'PREREQUISITE NOT MET -- this gate grades a saved turbo test log, and no log was named' (exit 3, its own text says it is not a red); CI tees one. (3) bare scripts/pm/check-half-states.mjs: 'PREREQUISITE NOT MET -- the token in the environment is not a valid GitHub credential' (exit 3); the --self-test form CI runs, check:pm-half-states, exits 0. (4) CI itself is NOT waited on, per the report-at-draft-PR contract. (5) The stored/raw-registered metadata POPULATION for either alias is unmeasured -- that is precisely what #13542 exists to hold, and it is why Q1 was not narrowed. (6) I did not run the other ~150 unconditional CI steps; the derivation names no family for 27 of them and they are identical for every card.", "mcp_calls": "1 -- one targeted mcp search_issues for the pre-filing dedup, after the repo-scoped REST probe returned 200 but /search/issues returned 403 ('sessions are bound to their configured repositories'). Every other GitHub read and write (issue body, all 8 comments, PR create, PR read-back, both issue filings, the title repair, this comment) went through repo-scoped REST.", "out_of_scope_findings": [ "filed as #13542: the deferred Q1 narrowing behind a stored-metadata migration -- the ruling's own separate card, with the population measurement it must start from; unassigned, `finding`, NOT dispatched", "filed as #13543: a fourth tolerant reader, packages/objectql/src/master-detail.ts:58 -- declares `referenceTo` in its own type with an asserted-but-unmeasured stored-row population; unassigned, `finding`" ], "open_questions": [] }
Generated by Claude Code
zhuangjianguo commented
on Aug 30, 2026 CollaboratorMore actionsLANDED — verified by content on
origin/main, ⛔ not by themergedbooleanPR #13541 merged through the queue.
origin/main=50cf2940b9.discriminating probe before now .changeset/tolerant-reference-alias-readers.mdABSENT PRESENT Q2 — relationTargetreads canonical only (const ref = f?.reference;)0 1 Q2 — the named finding relation-rejected-reference-alias0 1 the ratchet — @objectstack/verifyTEST_DEBTerrors: 8errors: 3⭐ The check that only a post-merge reading can make
Narrowing Q1 is the one thing the ruling forbids, and a merge is exactly where a bad rebase would quietly undo a deliberate non-change. Re-read on
mainafter the merge:REFERENCE_SPELLINGS in security-plugin.ts → 2 occurrences the array still lists `reference_to` → 1The tolerance survived.
resolveCbpRelationstill resolves an alias-spelledcontrolled_by_parentmaster, so no raw-registered deployment loses access — and the loud half rides the plugin's existingSecurityReportSink, once per object percbpRelCachegeneration, re-arming on a metadata change so it speaks at the moment an author is actually listening.What the two dispositions bought
Q1 = A. The defect this card named — a tolerant consumer is where AI-authored metadata mistakes hide and spread — is answered by making the tolerance audible rather than by removing it. Removing it would have been an availability outage:
resolveCbpRelationreturning null is fail-closed (RLS_DENY_FILTERon read,MasterDetailRelationMissingErroron write), on a population the previous round proved exists. A test now reddens if anyone narrows it, so the forbidden change cannot land silently.Q2 = C.
verify/derive.tsreads canonical-only and says why: the blocked line names the alias, the target that was not derived, and the remedy. ⭐ The load-bearing pin is a negative — the message must not match/has no \reference` target/` — so a future degradation back to the generic sentence reddens instead of passing. That is what stops this from trading one silent seam (#5262's defect: a verifier quietly under-verifying) for another.Clause ② was open at dispatch and came back measured, not inferred: the finding lands in
blocked?: stringandskippedFields[].reason: string, both already free-form, with no gate, doc or test pinning the vocabulary;check:verify-stand-in— a gate the derivation did not name — confirms the export set is unchanged. Internal, so the content limb never fired.Left open on purpose
pm:dispatchedstripped; the card auto-closed, which is right — the ruling's scope was exactly these two readers.- [finding] the deliberate
reference_totolerance in plugin-security has no measured stored-metadata population — the prerequisite for ever narrowing it #13542 carries the deferred Q1 narrowing behind a stored-metadata migration, which the ruling named as a separate card, not a rider. - [finding] a fourth tolerant alias reader —
master-detail.tsacceptsreferenceTo, with its stored-row population asserted but never measured #13543 records a fourth tolerant reader (packages/objectql/src/master-detail.ts:58). Unlike the three on this card it declares its tolerance in its own type with a stated population — but that population is asserted and never measured, so it is filed as an observation, not a defect claim.
Generated by Claude Code
- [finding] the deliberate
- added a commit that references this issue
on Sep 1, 2026
Found while measuring #13222 (driver-mongodb's lookup index gate). Filed unassigned, out of scope for that card's PR.
What is there
#11567 landed the ruling that
referenceis the only relationship spelling@objectstack/specdeclares, andreference_tois a rejected alias —FieldSchemaanswersunrecognized_keyswith "Did you meanreference_to→reference?". Its changeset put it as "One key, one answer, on both doors." It closed the authoring door and the SQL DDL door.Three other live consumers still read the rejected alias as an accepted fallback:
packages/lint/src/data-model-rules.tsreturn def?.reference || def?.reference_to;packages/plugins/plugin-security/src/security-plugin.tsconst ref = (f) =. f?.reference ?? f?.reference_to ?? f?.referenceTo;packages/verify/src/derive.tsconst ref = f?.reference ?? f?.reference_to ?? f?.referenceTo;(The arrow in the middle row is written
=.so the body survives GitHub's sanitizer; the source spells it as a normal arrow function.)Why this is a defect and not harmless breadth
The repo already made the opposite call one door over, deliberately and in writing.
packages/lint/src/validate-security-posture.ts:208records it:— i.e. one lint reader was narrowed on purpose to canonical-only, and the reason is in that file. The three above were not, so the same repo now answers one key two ways depending on which consumer sees it.
Concretely,
data-model-rules.ts:167is a lint rule: it exists to catch authoring mistakes, and it currently treats a field spelledreference_toas having a valid relationship target. So the one component whose job is to tell an author their metadata is wrong is the component that accepts the wrong spelling.This is the shape the platform's own contract-first rule names: a tolerant consumer is where AI-authored metadata mistakes hide and spread.
reference_tois exactly a key AI authors reach for — the published@objectstack/driver-mongodbREADME taught it until #13224 (in flight).Measured
Run against
@objectstack/spec@distbuilt from9e0ba21a1.Not proposed here
Whether to narrow all three, or to narrow some and keep a documented tolerance where stored legacy metadata is genuinely in play, is a triage call — each reader has a different population.
verify/src/derive.tsandsecurity-plugin.tsmay be reading raw registered metadata (registerObjectdeliberately skips Zod, #3896), which is the one population where the alias can actually appear;data-model-rules.tsis a lint rule over authored source, where it cannot.Related
#11567 (the ruling) · PR #12287 (where it landed) · #13222 (driver-mongodb's own copy of the same defect) · #13224 (removing the README that taught the key)
Generated by Claude Code