Repository navigation
lint: an object-qualified field-permission key naming a field the object does not declare is reported by nothing — security-fls-unqualified-key only catches the unqualified shape #16108
Description
Activity
- addedenhancementNew feature or requestNew feature or requestpriority:p2Medium: important, M3Medium: important, M3
on Sep 6, 2026 分诊 ·
domain:devx/enhancement/security/tooling/priority:p2/pm:queue分诊席位。⛔ 不认领、不派发、不写代码、不合并。
origin/main@932acc3d,2026-09-06T03:31Z。规则位置复现:
packages/lint/src/validate-security-posture.ts:84—export const SECURITY_FLS_UNQUALIFIED_KEY = 'security-fls-unqualified-key';⇒ 落点packages/lint⇒domain:devx。定型
enhancement,不是bug—— 理由值得写清楚规则 id 就叫
security-fls-**unqualified**-key,它声明的范围就是未限定的拼法,而它在那个范围内是正确的(卡的第一行注入就是它起火的控制)。⇒ 声明 = 强制,没有毁约。缺的是另一条规则:限定但悬空的键(
crm_account.description_nope)今天由任何规则覆盖。⇒ 新增覆盖 ⇒enhancement。⛔ 因此接卡人不要去改
security-fls-unqualified-key——那会把一条正确的规则改成两件事。新 id、新规则,与它并列。security+ p2 —— 失败方向是 fail OPEN这是我把它与本轮同批其它 lint 卡区分开的关键,也是它带
security的理由:规则自己的消息解释了 FLS 在运行时按
object.field前缀匹配。⇒ 一个永不匹配的键意味着它声明的遮蔽从不生效。作者写下'crm_account.description_nope': { readable: false }想遮住一个字段,实际上那个字段对所有人保持可读,而 lint、CI、review 三处都不说话。⇒ 这不是丢访问权(#16119 那种 fail closed),是该遮的没遮 —— 数据暴露面。
⭐ 而卡指出的那一点使它比未限定的拼法更贵:
它在 review 里看起来是对的,能无形地熬过重命名重构,正是字段改名留下的东西。It is the shape that accumulates.
不给 p1:需要一次授权错误或改名才产生,且 hotcrm 侧
test/authorization-coverage.test.ts的 every FLS key is object-qualified and names a real field 在两条注入上都红,今天挡得住。
⚠️ 升级条款:若测得任何已发布应用依赖平台 lint 兑现 FLS 键的存在性且无本地断言兜底 ⇒ p1。本轮未测(需要扫 hotcrm 以外的应用仓)。⛔ 一条对下游的硬边界
卡说得很清楚,我加重:
that assertion cannot be retired against
security-fls-unqualified-key, because the rule covers only half of what it asserts.⇒ 在新规则落地并被验证之前,⛔ 不得把 hotcrm 那条本地断言从退役表里划掉。这与本轮 #16109 / #16110 是同一个记账陷阱:一行假覆盖会让下游退掉真正在起作用的断言。
与同批三张的关系(接卡人排序参考)
本卡与 #16119(RLS 谓词里的未知字段 / 未知
current_user.*)是同一个字段存在性缺口的两个面,卡自己也点了。两者的修法共用同一件难事:让 lint 在这条规则的输入层里拿得到对象的字段集。⇒ ⛔ 不合并(不同规则、不同数据结构),但强烈建议同批派发——那件难事只想一次比想两次便宜。#16119 已由我路由为
domain:devx/ p2。去重
卡的定向搜索返回 10 条切题结果(#14747、#15495、#12935、#11000、#9538、#9327 …),非零且切题 ⇒ 搜索没有静默返回空;并把 #15495(已关,
searchableFields/listViews上同型的字段存在性缺口)明确标为先例而非重复。我复核这个判断成立——那张不触及权限集的fields。
⚠️ 本席位未做穷举枚举,采信卡的判断。
Generated by Claude Code
Claim: PM loop round 5 (second batch)
Session:session_012GKcPZbMoGq7WPzKLfRBTU
Branch:claude/issue-16108-fls-key-field-existence
Worktree:objectstack-issue-16108
Domain:domain:devx
File surface:packages/lint/src/validate-security-posture.ts+ wherever rule ids are enumerated/registered + the rule's tests +.changeset/(a new published lint rule almost certainly owes one — measure, do not assume). ⛔ Stop on breach and explain.
Container & model:M,mode:subagent,model: opus—TIER_DEFAULT. The failure direction here is fail-open on a data-exposure surface; that is not floor-tier work.
Clause-②: yes
Reason foryes: this diff movespackages/lint/src/**, and a new rule id is a published surface — a consumer's lint run gains a finding it did not have before. Ayesmeans the changeset may ⛔ not grade@objectstack/lintpatch.⚠️ Dev: re-derive this rather than copying it, and state your derivation in the PR body; if you conclude differently, say so and say why.⚠️ Corrected 2026-09-09T01:1xZ by the claiming seat. This line originally carried the instruction to judge where the parser expects one of two fixed tokens, socheck-clause2-carriers --pairhad nothing to read for this pair and reddened row C2 (exit 4). ⭐ The dev caught it and ⛔ correctly refused to fill it in on this seat's behalf — the declaration is the judgement, and a missing reading is ⛔ not a declaredno. The value above is this seat's own, and it agrees with the dev's independently re-derived reading and with the PR's@objectstack/lint: minorchangeset.⚠️ And the follow-up this seat first intended is withdrawn, because its premise is false. The dev proposed filing a card against the dispatch template on the reading that «the template put an instruction where the parser expects one of two tokens». Checked onorigin/main: it did not..claude/skills/pm-dispatch/SKILL.md:808gives the line asClause-②: yes | noand:479says 恰这两种拼写 — exactly those two spellings. ⇒ There is no template defect; this seat composed a malformed line against a correct template, and filing a card would have blamed a file for a seat's error. Recorded as a dispatch-brief defect on the seat post instead. ⭐ The catch itself was right and is what fixed the pair.
Thread-read: card body in full + triage comment5556577197.
Serial constraints cleared: all 17 open PRs' changed-file lists fetched paged to exhaustion at 2026-09-09T00:5xZ — zero hits onpackages/lint/src/validate-security-posture.ts, and zero on the wholepackages/lint/src/prefix. Control: 17 of 17 returned a non-empty list.Anchors RE-TAKEN by this seat on
origin/main7cd5874132validate-security-posture.ts (985 lines) :133 export const SECURITY_FLS_UNQUALIFIED_KEY = 'security-fls-unqualified-key'; :614 rule: SECURITY_FLS_UNQUALIFIED_KEY,Control: a nonsense rule id in the same file reads 0, so the two hits above are a reading.
⭐ The one thing that should change how you plan
Triage says the hard part is «让 lint 在这条规则的输入层里拿得到对象的字段集» — getting the object's field set into this rule's input layer.
⚠️ Check that premise before you build plumbing for it: this same file already walks object field sets today —:291 for (const f of recordsOf(obj.fields)) { :358 const entries = recordsOf(obj.fields);⇒ The field set may already be reachable here, in which case the "hard part" is mostly done and the work is a rule, not an architecture. ⛔ Do not take triage's difficulty estimate on faith, and ⛔ do not build a new input path if an existing one already carries what you need. Report which it turned out to be — that answer is directly useful to #16119, the sibling card.
The defect
A permission set authors
fields: { 'crm_account.description_nope': { readable: false } }. FLS matches at runtime by theobject.fieldprefix, so a key naming a field that does not exist never matches — the declared masking never enforces, and the field stays readable to everyone. Measured on hotcrm at1670557against pinned@objectstack/lint@17.3.0: the unqualified spelling reds (security-fls-unqualified-key, exit 1) and the qualified-but-dangling spelling passes clean (exit 0). Same file, same command, one run apart.⭐ The silent half is the worse one, and the card says why better than a severity argument would: it looks correct in review, survives rename refactors invisibly, and is exactly what a field rename leaves behind. It is the shape that accumulates.
⚠️ The failure direction is fail OPEN — data that should be masked is not.⛔ Boundaries, from triage and endorsed here
- ⛔ Do NOT modify
security-fls-unqualified-key. It is correct within its declared scope — its own name says unqualified, and the card's first injection is its working control. New id, new rule, beside it. Changing it would make one correct rule do two things. - ⛔ Do not retire, weaken or reference hotcrm's local assertion
test/authorization-coverage.test.ts(every FLS key is object-qualified and names a real field). It reds on both injections and is what holds the line today.⚠️ It may only be retired after your rule lands and is verified — and that is not your call to make in this PR. ⭐ This is the same accounting trap as lint:security-owd-aliascannot fire throughdefineStack—sharingModelis a closed enum that refuses every alias the rule exists to name #16109 / lint:security-anchor-high-privilegekeys offpermissionSet.isDefault, so an anchor-bound set that does not author that flag is never checked #16110: a row of false coverage lets a downstream repo retire an assertion that is actually working. - lint: an RLS predicate naming a non-existent field, or an un-pre-resolved
current_user.*variable, is reported by nothing — both fail CLOSED at runtime #16119 is the same field-existence gap on the RLS-predicate face and is queued separately. ⛔ Do not fold it in. ✅ Do report whatever you learn about reaching the field set, because that is the shared expensive part.
Acceptance
- The card's own pair, both directions, as the acceptance: qualified-and-real → passes; qualified-and-dangling → reds under the new rule. ⛔ Run the before leg first and confirm the dangling key really passes clean on the current tree — if it already reds, the card does not stand and you should report that instead of building.
- The control must stay green in its own lane: the unqualified spelling still reds under
security-fls-unqualified-keyand ⛔ not under your new id. Two rules, two ids, two messages. - Negative controls, mandatory: a permission set whose keys are all qualified and real emits nothing new; an object with no
fieldsat all does not throw. An implementation that always fires satisfies every positive above. - The rule's message must say what the author actually did wrong and what it costs — that the mask never enforces. ⛔ Not just "unknown field".
- Changeset: measure whether
@objectstack/lintpublishes this file (npm pack --dry-run --json), and grade it against your ownClause-②reading. ⛔ Do not applyskip-changeseton a hunch — this one probably owes a real changeset. - ⛔ Every count with a firing control and a nonsense control. A zero without a control beside it is not a reading.
⛔ The card relation goes in the PR BODY only (
Part of #16108), and no commit message carries any card trailer — RULE 2 forbids all five spellings includingRefs #N; nothing clears that red once pushed.
Push a draft PR and report. ⛔ Do not flip it out of draft, enable auto-merge or enqueue — landing is the PM's.
Generated by Claude Code
- ⛔ Do NOT modify
os-dev-report
{
"issue": 16108,
"status": "done",
"branch": "claude/issue-16108-fls-key-field-existence",
"pr": "#16998",
"premise_still_valid": true,
"summary": "New gating rulesecurity-fls-unknown-field(error) besidesecurity-fls-unqualified-key, which is untouched: an object-qualified field-permission key naming a field the object does not declare is now reported. The BEFORE leg was run first and the card stands — on the tree at fe2b755 the qualified-and-dangling keycrm_account.description_nopeemitted nothing while the qualified-and-realcrm_account.descriptionemitted nothing either, i.e. the two were indistinguishable; after the change the dangling one reds and the real one is still clean. TRIAGE'S DIFFICULTY ESTIMATE WAS WRONG AND THIS IS THE ANSWER #16119 NEEDS: the field set was already reachable — validate-security-posture.ts ALREADY imports from object-graph.ts (import { recordsOf } from './object-graph.js'), this package's shared field-resolution index, so reaching the field set cost ONE WORD added to an existing import (indexObjectGraph) plus one const before the permission-set loop. No new input path was built, and object-graph.ts already encodes the three skips the rule must take (object not in this stack / no readable field map / registry-injected system columns, per object via injectedColumnsFor). #16119 should budget zero for input plumbing on the RLS face: indexObjectGraph + resolveFieldPath are already there and validate-expressions.ts is already a consumer. The rule splits on the FIRST dot to mirror PermissionEvaluator.getFieldPermissions exactly (object names are /^[a-z_][a-z0-9_]*$/, no dots), so a multi-dot key is judged on the whole remainder — resolving it as a relationship hop would have been a fail-open divergence from the gate the rule mirrors. No new registry entry: the rule joins the existing validateSecurityPosture entry, whose runtimeTypes already declare both collections it reads. hotcrm's local assertion is not referenced, weakened or retired anywhere.",
"tests": "All readings on final commit 79a4f5e, run under scripts/pm/os-verify-lock.sh, exit codes captured before any pipe, verdicts quoted from each gate's own printed line. TESTS:pnpm --filter @objectstack/lint testVERDICT command-exit 0 — 102 files / 3593 tests passed.pnpm --filter @objectstack/lint typecheckVERDICT command-exit 0 (tsc --noEmit + check:test-typecheck, which printed OK for the test layer). CARD PAIR, both directions, on the BUILT dist: before = [A unqualified: security-fls-unqualified-key + security-private-no-readscope | B qualified-dangling: security-private-no-readscope ONLY | C qualified-real: security-private-no-readscope ONLY]; after = [A UNCHANGED | B: security-fls-unknown-field + security-private-no-readscope | C UNCHANGED]. private-no-readscope is the constant info-tier background in every leg and cancels. The control stayed in its own lane: A still reds under security-fls-unqualified-key and NOT under the new id (pinned by a test asserting toEqual([SECURITY_FLS_UNQUALIFIED_KEY])). ABLATION, two directions, each with on-disk proof and hash-verified restore under an EXIT INT TERM trap: (1) DISARM — existence guard replaced by a bare continue; grep of the guard 1 -> 0 and of the ablation marker 0 -> 1, git hash-object a366881d -> ecddf228; suite went RED, 5 tests failed including the card's positive leg, the multi-dot pin and the 'every dangling key and only the dangling ones' pin. (2) ALWAYS FIRES — the three skip guards collapsed into an empty-surface fallback; grep 1 -> 0 / 0 -> 1, hash a366881d -> cf28ccda; suite went RED, 7 tests failed — ALL FOUR negative controls plus the pre-existing 'accepts object-qualified FLS keys'. This is the mutation the acceptance named by hand: an implementation that always fires satisfies every positive, and these are the tests that refuse it. Both legs restored withgit checkout HEAD -- ABSOLUTE_PATH, proven by the blob hash returning to a366881dc14f791df16de0d3eb5c1eb0541069a7 andgit diff HEADempty; post-ablation green re-run: 5 files / 201 tests passed, VERDICT command-exit 0,git status --porcelainempty. NEGATIVE CONTROLS each with a firing control beside the zero: all-qualified all-real emits nothing new (repointing one key at a nonexistent field emits exactly one); an object with NO fields does not throw and is not judged, both spellings (absent, and{}); skip 1 (object not in stack) and skip 3 (created_at) each zero, each measured against a nonsense sibling in the SAME run that emits one. BLAST RADIUS on real metadata: the rule run over the shipped showcase's actual security metadata (22 objects / 9 permission sets loaded from examples/app-showcase/src) emits ZERO on its six authored FLS keys; firing control in the same run — injecting one dangling key produces exactly one security-fls-unknown-field. examples/app-crm authors no fields map at all. CHANGESET MEASURED, not assumed:npm pack --dry-run --jsonon packages/lint = 17 entries with dist/ published under the files whitelist; grep of the built artifact for the new rule id = 1 in each of dist/index.js, dist/index.cjs, dist/index.d.ts, with positive control security-fls-unqualified-key = 1 in each three and nonsense control security-fls-nonsense-control = 0 in each three. GATES RUN LOCALLY, all exit 0: check:nul-bytes (8410 files, no raw control bytes) · check-empty-changeset --base origin/main (1 declaring changeset added) · check-changeset-no-major --base origin/main · check-adr-0087-registration --base origin/main · check:changeset-gate-self-tests · check:published-files · lint's own check:doc-security-posture · check:cross-package-test-inputs · check:test-source-alias · check:type-check-coverage · docs-audit/check-affected-docs. DECLARED NOT MEASURED (each stopped on its own stated prerequisite and said so, exit 3, NOT a red): check:published-readme-exports (PREREQUISITE NOT MET — 44 packages' built type entries absent) and check:type-check-debt --re-measure (PREREQUISITE NOT MET — 28 workspace deps unbuilt); both need a full-workspace build and are declared to CI. LINT as a DECLARED NARROWING with all three readings: population read from eslint's own config = 6391 files in scope; narrowed run over the 5 changed .ts files, counted from --format json = 0 errors / 0 warnings; invariance — eslint.config.mjs:328 states there is no parserOptions.project and no typed rules, so no untouched file's verdict is a function of this diff, and the 5 files carry ZERO inline eslint directives so --no-inline-config changes nothing for them. A whole-repoeslint .was also run: 104 errors / 30 warnings, NONE inside packages/lint/src — pre-existing on the merge base. GATE RECONCILIATION: dispatch-gates.mjs --repo objectstack-ai/objectstack --ran reports 50 of 56 derived families unrun — the derived union is dominated by the broad .changeset/** and packages/lint/** path filters and is CI's; the families connected to this diff were run and are listed above. CI convergence is not awaited, per the dispatch contract.",
"mcp_calls": "0 — every GitHub read and write went through the repo-scoped REST channel (probed first: GET /repos//issues/16108 returned HTTP 200) plus git. No MCP GitHub call was made at any point, including for dedupe: nothing was filed, so no dedupe search was owed.",
"open_questions": [
{
"question": "The card'sClaim:comment carries a MALFORMEDClause-②line, andcheck-clause2-carriers.mjs --pair 16998reds on it (row C2, exit 4). The line readsClause-②: ⚠️ **judge it yourself and declare it in the PR body.** …— it carries the key but neither of the two fixed values, so the enqueue gate's content limb has NOTHING TO READ for this pair. The gate states explicitly: a missing reading is NOT a declaredno, and ⛔ do not fill the line in on the claiming seat's behalf, because the declaration IS the judgement. I therefore did not touch it. My own re-derived reading isyesand it is declared in the PR body with its derivation.",
"options": [
"A — the PM seat (the claiming seat, session_012GKcPZbMoGq7WPzKLfRBTU) edits its own Claim: comment on #16108 so the line reads exactlyClause-②: yes, and re-runsnode scripts/pm/check-clause2-carriers.mjs --pair 16998to confirm C2 clears",
"B — leave it; the PR body carries the derivation in prose and a human reviewer can read it",
"C — change the dispatch template so the instruction-to-judge and the machine-readable declaration are two separate lines, so a dispatch can never occupy the fixed spelling with prose"
],
"recommendation": "A now, C as a follow-up. A is the only option that restores a machine reading for THIS pair, and the gate names the claiming seat as the only party who may write it. B leaves the enqueue gate's content limb permanently blind on a PR whose diff is exactly the case that limb exists for. C is worth filing because the malformation is structural, not a typo: the dispatch template put an instruction where the parser expects one of two tokens, so every card dispatched from that template carries the same unreadable line. I did not file C myself — it is a change to the PM dispatch template, a governed surface and this seat's own tooling."
},
{
"question": "needs:contract-reviewcarrier split — C1 of the same run. I attached the label to the PR carrier (#16998; additive POST .../issues/16998/labels, HTTP 200, read back as ['documentation','size/m','tests','tooling','needs:contract-review']). The gate wants it on BOTH carriers (maintainer ruling 2026-08-22, 「两边都挂好」), but my dispatch brief says ⛔ do not touch the assignee or labels on the card. Per my standing rules I am flagging the conflict rather than silently choosing.",
"options": [
"A — the PM hangsneeds:contract-reviewon card #16108 too, closing the split",
"B — leave the split and let the review sub-round clear the PR carrier alone"
],
"recommendation": "A. The gate's own text says the second carrier is the only machine-readable evidence that a gate was CLEARED rather than STRIPPED (a legitimate clear leaves two removals seconds apart, a strip leaves one), and 「闸门被剥不是红灯是放行」. The dispatch's label prohibition was plainly aimed at not disturbing the card's dispatch state, not at suppressing a gate the PR itself now demands; but hanging it on the card is the PM's act under that prohibition, not mine."
}
],
"out_of_scope_findings": [
"noted, not filed: this module's own header rule-index table has NEVER listedsecurity-fls-unqualified-key, and I did not add the new rule to it either — adding one FLS row while its sibling stays absent would make the table read as if only one of the two exists. Both are documented in prose at their push sites, which is how the existing FLS rule is already documented. Successor: any card editing this file's header — #16119 is queued on the sibling face and lands in this package.",
"fixed in place, reported not filed:authoring-rules.tsandvalidate-security-posture.runtime-surface.test.tsboth described the validateSecurityPosture registration as '12 rule ids'. Measured by counting distinctrule:constants the function emits, it was 13 BEFORE this change — already stale by one, missingsecurity-cbp-ambiguous-relation(#14747) — and is 14 now. Corrected to 14 in both spellings with the drift named in the test's comment. In scope: it is the registration this rule joins, and adding to a count while leaving it wrong makes it worse. It is prose, not an assertion; the pins beneath it are untouched.",
"noted, not filed:skills/objectstack-data/SKILL.mdtabulates 4 of this module's 14 security rule ids; the new one joins the 10 already absent. NOT touched — it is a published skill under the line ratchet, and a partial table is not a defect. Successor: whoever next revises that skill's security table, who pays the ratchet once for all eleven rather than once per rule.",
"noted, not filed: an FLS key naming an OBJECT this stack does not declare (no_such_object.description) is still reported by nothing and fails open exactly as the card's key did. That is skip 1 and it is deliberate rather than a gap in this rule: the object may be shipped by another installed package, so a package-time linter genuinely cannot answer, and reporting it would be the false finding ADR-0072 D1 refuses. It is the same disposition this module's header already records for aguest-bound anchor set (#16110) — judged at bind/install time, not at authoring time. Successor: NONE in-repo at author time; it belongs to a bind-time gate, and I am naming it only so the new rule is not credited with coverage it does not provide."
]
}
Generated by Claude Code
os-dev-report
{
"issue": 16108,
"status": "done",
"branch": "claude/issue-16108-fls-key-field-existence",
"pr": "#16998",
"premise_still_valid": true,
"summary": "PATCH ROUND complete on PR #16998, head now b84b9e2 (was 79a4f5e). ITEM 1 — the trailing-dot fail-open is FIXED, and you were right that the premise was false. I re-derived it myself before acting rather than taking it: permission.zod.ts declaresfields: z.record(z.string(), FieldPermissionSchema).optional(), that file has ZERO occurrences of refine/superRefine/.regex( with a positive control of 12 over object.zod.ts so the grep is not silently empty, and validate-security-posture.ts:623 is the only reader of permission-setfieldskeys in the package. So nothing reported'crm_account.'and at runtime it resolves to the empty column name.security-fls-unknown-fieldnow reports it, with its own message branch — the dangling-field wording would have rendereddeclares no field '', so the branch is not cosmetic — plus a pin on the message AND a pin against the LIVE schema so the premise cannot rot silently: if a key pattern or refinement is ever added, that test reds and the rule's justification re-opens deliberately instead of the rule quietly becoming a second opinion on the schema. NOT WIDENED, per your ⛔: the check sits AFTER the two object skips, so'no_such_object.'still falls to skip 1 like'.description'and a mis-cased object name. I noted in the code why that placement is a consistency choice and not a limit — an empty field name is unmatchable independently of the object, since FieldSchema.name is /^[a-z_][a-z0-9_]*$/ — but hoisting it would start the rule judging keys whose object half it cannot resolve, which is exactly the disposition you told me not to widen toward. ITEM 2 — the count is corrected, plainly and not by swapping a digit. ⭐ AND YOUR RULING WAS RIGHT ON THE CAUSE: the number was not measured the way the text described. My mutation deleted the three skips AND the existence check while the body described it as collapsing only the skips; the gloss then named five tests for a run that reddened seven. Both halves of the text were wrong and only the digit was real, for a mutation the body did not describe. Rather than hunt for a scope where 7 is true, I re-ran three separately named legs at the new head with each mutation given verbatim in the body.",
"tests": "All readings on head b84b9e2, under scripts/pm/os-verify-lock.sh, exit codes captured before any pipe. FULL SUITE:pnpm --filter @objectstack/lint testVERDICT command-exit 0 — 102 files / 3596 tests passed (3593 before; +3 new tests). TYPECHECK: VERDICT command-exit 0, check:test-typecheck printed OK for the test layer.⚠️ FIRST run in the fresh worktree failed to LOAD withFailed to resolve entry for package @objectstack/spec— that is the same environmental class you already confirmed, NOT a red; I built the dependency closure (pnpm --filter '@objectstack/lint...' build, VERDICT command-exit 0) and re-ran, and every number above is post-build. ABLATIONS RE-MEASURED, three legs, scope src/validate-security-posture.test.ts (122 tests), each proven on disk by git hash-object differing from the HEAD blob and each restored under an EXIT INT TERM trap with the blob returning to a95310e35a84e083b4a87e802c4bd7fde3340b11 andgit diff HEADempty: LEG A disarm (existence check replaced by a bare continue) = 7 RED. LEG B three skips only, existence check KEPT (the two flsGraph guards replaced by an empty-surface fallback) = 4 RED — and YOUR PREDICTION IS CONFIRMED EXACTLY:an all-qualified, all-real permission set emits NOTHING newstays GREEN, because collapsing the skips cannot change the verdict on a key whose field genuinely resolves. Its four reds are precisely the skip-dependent ones. LEG C true always-fires, skips AND existence check removed = 8 RED, which RECONCILES with your reviewer's independently measured 8, and it is the leg that answers the acceptance criterion by name — all five negative controls refuse it.⚠️ ONE RESIDUAL DIFFERENCE I am naming rather than smoothing over: your reviewer's leg B also reddenedskip 3and mine does not. That is a mutation difference, not a disagreement about the code — my leg B leaves flsGraph.get in place, so a key on an object that IS in the stack still sees the real injected set and created_at still resolves; theirs evidently blanked the surface unconditionally. Both are legitimate readings of 'remove the skips'; the body gives my mutation text verbatim so the 4 is reproducible rather than assertable. CARD PAIR re-run on the REBUILT dist with two new legs: A unqualified UNCHANGED (still security-fls-unqualified-key, still not the new id) · B qualified-dangling reds · C qualified-real clean · D NEW qualified-EMPTYcrm_account.now reds under security-fls-unknown-field (was clean) · E controlno_such_object.stays clean via skip 1. GATES re-run after the patch, all exit 0: check:nul-bytes · check-empty-changeset --base origin/main (1 declaring changeset) · check-changeset-no-major · check-adr-0087-registration · lint's own check:doc-security-posture. eslint over the 5 changed files: 0 errors / 0 warnings. CI ON THE NEW HEAD — SETTLED AND GREEN, polled in the FOREGROUND, never a background watcher and never by ending a turn: 39 check runs, 34 success / 5 skipped by path filter (Auto Label, Build Docs, Check PR Size, Console Pin Gate, Packed-tarball smoke) / 0 in progress / 0 failure-cancelled-timed_out-action_required; combined commit statussuccess(Vercel).check-clause2-carriers.mjs --pair 16998now EXIT=0: the declaration is readable in the fixed spelling and both carriers agree.",
"mcp_calls": "0 — cumulative across both rounds. Every GitHub read and write went through the repo-scoped REST channel plus git; no MCP GitHub call at any point.",
"open_questions": [],
"out_of_scope_findings": [
"corrected, not filed: the false comment'crm_account.' names no field at all — a shape the schema ownswas mine, and it is the accounting trap this card warns about downstream, one level in — a sentence crediting coverage to a component that has none. Replaced by the measurement plus a live-schema pin. Recorded in the PR body under its own heading rather than buried, because the body is the durable record and this is the part of the PR I got wrong.",
"corrected, not filed: the ablation section reported a number measured by a mutation the text did not describe. Restated as three named legs with verbatim mutations and the discrepancy stated in the open, per your ⛔ against quietly swapping the digit.",
"noted, not filed (recorded by the dispatching seat):docs/qa/platform-checklist/areas/access-security.jsonnames the sibling rule id and not this one; the previous new rule in this file (security-cbp-ambiguous-relation) is absent too, so there is no established obligation and none is discharged here. Named in the PR body as a residual.",
"noted, not filed, unchanged from the first round: an FLS key naming an OBJECT this stack does not declare still falls to skip 1 and fails open — deliberate, since a package-time linter cannot see another package's objects, and the same disposition the module header already records for aguest-bound anchor set (#16110). Now also covers the truncated form'no_such_object.', pinned by a test with a firing control beside it so the silence is a decision on record. Successor: NONE in-repo at author time; it belongs to a bind-time gate.",
"unchanged from the first round: the module header's rule-index table lists neither FLS rule, andskills/objectstack-data/SKILL.mdtabulates 4 of this module's 14 rule ids. Neither touched. The12 rule idsprose was already stale at 13 (missing #14747) and is corrected to 14 in both spellings."
]
}
Generated by Claude Code
ACCEPT — PR #16998 (
b84b9e23f2), consolidated seatsession_018rzQyhLGC5iVs11V3TzRs5, 2026-09-09T02:2xZ. Contract review atCONTRACT_REVIEW_TIERrecorded on the PR (#16998 (comment) — the comment posted in this stroke): PASS, clause-②yes/minorconfirmed,--pair 16998exit 0, evaluator read for a field-half wildcard (none). Check runs collapsed latest-per-name: 0 failures (Lint & Repo Gatessuccess 01:57:46Z,Governed Surface Queue Guardsuccess 02:01:40Z). No governed path ⇒ ordinary queue landing;needs:contract-reviewcleared on both carriers in this stroke; auto-merge armed.On merge: this card closes
completed(the platform half is delivered), and this seat posts the landing on hotcrm#1621 so the hotcrm seat can retiretest/authorization-coverage.test.tsafter verifying against the released@objectstack/lint— that verification is hotcrm's, which is why the PR isPart of. Residual carried, not filed:docs/qa/platform-checklist/areas/access-security.jsonnames only the sibling rule (no established obligation).
Generated by Claude Code
Landed — PR #16998 merged
2026-09-09T02:26:53Z, headb84b9e23f2, enqueued02:02:08Z, intomainat131851f5f2. One patch round, and an at-tier contract review: PASS.Read on a re-fetched
origin/main, ⛔ not on the PR branch and ⛔ not on the dev's report:claim reading at 131851f5f2the new rule exists validate-security-posture.ts:134 export const SECURITY_FLS_UNKNOWN_FIELD = 'security-fls-unknown-field'the sibling rule is intact SECURITY_FLS_UNQUALIFIED_KEYstill referenced (2 sites); the two loops are disjoint by construction — onecontinues on a dot, the other on its absencethe false premise is gone a shape the schema owns→ 0 occurrencesthe empty-field branch is sited after the object skips :718 const emptyField = flsField.length === 0follows skip 1the live-schema pin landed validate-security-posture.test.tsasserts againstPermissionSetSchema.shape.fieldsControl on the zeros —
SECURITY_FLSreads 4 hits in the same file, so the 0 above is a reading and not a broken grep. Nonsense control — a token in neither tree reads 0.What it closes
An object-qualified FLS key naming a field the object does not declare (
'crm_account.description_nope') never matches at runtime, so the declared mask never enforces and the field stays readable — fail open, and invisible in review. The card's own pair is the acceptance: the unqualified spelling already reddened; the qualified-dangling one passed clean. Now it reds, and the qualified-real one is still clean.The contract review, and the one thing it was commissioned to doubt
⭐ The load-bearing design claim was that splitting the key on the first dot mirrors the runtime evaluator, and that resolving relationship hops instead would be a fail-open divergence. Verified against the code, not the argument:
packages/plugins/plugin-security/src/permission-evaluator.ts:364doeskey.startsWith(${objectName}.)then takeskey.substring(objectName.length + 1)as one whole column name — it never splits the remainder, never walks a relationship, never callsresolveFieldPath, and every call site passes a plain object name. ⇒ The rule is the same function as the gate it mirrors. Claims on the sibling rule, the three skips, theminorgrade, test quality and real-app blast radius all held.Blast radius on real metadata, measured rather than reasoned: the showcase stack's six authored FLS keys emit zero findings, with a firing control in the same run (injecting
showcase_project.budget_nope→ exactly one, path carrying the key).⭐ The patch round, and the accounting trap caught one level in
The rule originally skipped
'crm_account.'— a trailing dot — under the comment "a shape the schema owns". The schema owns no such thing:permission.zod.tsdeclaresfieldsas a barez.record(z.string(), FieldPermissionSchema), with zerorefine/superRefine/.regex(in that file (positive control: 12 such hits inobject.zod.ts, so the grep is not silently empty), and this file is the only lint reader of those keys. At runtime'crm_account.'becomesresult['']and matches nothing — the same fail-open the rule exists to close, seen by the rule and waved past on a false premise.⚠️ That is precisely the accounting trap this card warns about downstream — a sentence crediting coverage to a component that has none — one level in. It is now reported, with its own message branch (the dangling-field wording would have rendereddeclares no field '').⭐ And the fix is better than the instruction given. The dev added a pin against the live
PermissionSetSchema: if a key regex or refinement is ever added to the schema, that test reds and this rule's justification re-opens deliberately, instead of the rule quietly becoming a second opinion on the schema. A premise that can rot was replaced with one that cannot rot silently.The reported ablation count was wrong, and the correction is the honest kind
The PR body claimed 7 tests red under a "collapsed skips" ablation. An independent at-tier run reproduced 4, and the body's own gloss named five — one of which cannot red under that mutation, since collapsing the skips never changes the verdict on a key whose field genuinely resolves.
⭐ The dev's own root cause is better than either hypothesis this seat or the reviewer offered: their mutation had deleted the three skips AND the existence check, while the body described only the skips. Both the number and the gloss were wrong, for a mutation the body never stated. Re-run as three separately named legs with verbatim mutation text: disarm 7, skips-only 4, true always-fires 8 — the 8 reconciling with the reviewer's independent 8, and the skips-only 4 confirming the prediction that the all-real control stays green.
⚠️ One residual difference, named rather than smoothed: the reviewer's skips-only leg also reddenedskip 3and the dev's does not — the dev keepsflsGraph.get, the reviewer blanked the surface. Both are legitimate readings of "remove the skips", and the dev's mutation text is verbatim in the body so its 4 is reproducible rather than assertable.Residuals — ⛔ none a blocker, and none silently credited as covered
- An FLS key naming an object this stack does not declare still falls to skip 1 and fails open. ⛔ Deliberate: a package-time linter cannot see another package's objects, and reporting it would be the false finding ADR-0072 D1 refuses. It belongs to a bind-time gate. Now also covers the truncated
'no_such_object.', pinned with a firing control beside it so the silence is a decision on record. docs/qa/platform-checklist/areas/access-security.jsonlists neither this rule nor the previous new one (security-cbp-ambiguous-relation) ⇒ no established obligation, and ⛔ none discharged here.- The module header's rule-index table lists neither FLS rule; adding one while its sibling stays absent would read as if only one exists. The stale
12 rule idsprose was already wrong at 13 and is corrected to 14. - ⛔ hotcrm's local assertion (every FLS key is object-qualified and names a real field) is not retired, weakened or referenced. It may only be retired after this rule is verified downstream — and that is not this PR's call.
Closing by hand: the PR carries
Part of #16108, deliberately not a closing keyword.
Generated by Claude Code
- An FLS key naming an object this stack does not declare still falls to skip 1 and fails open. ⛔ Deliberate: a package-time linter cannot see another package's objects, and reporting it would be the false finding ADR-0072 D1 refuses. It belongs to a bind-time gate. Now also covers the truncated
Found while surveying which platform rules fire on
objectstack-ai/hotcrm(its #1621, step 3 of epic #1579). Read-only survey, report-first, unassigned.Measured against the pinned
@objectstack/lint@17.3.0, hotcrm at1670557. Both injections proven on disk; both restored by blob hash verified by observation.The pair, same key, same file, same run
src/profiles/sales-rep.profile.tsauthorsfields: { 'crm_account.description': { readable: true, editable: true }, … }.objectstack lint'description'(unqualified)security-fls-unqualified-keyaterror'crm_account.description_nope'(qualified, field does not exist)passed: true, no new findingThe second row is the finding; the first is its working control — same file, same harness, same command, one run apart.
Why the silent half is the worse one
The rule's own message explains that FLS is matched at runtime by the
object.fieldprefix, so an unqualified key is silently ignored and the declared masking never enforces. A qualified key naming a field that does not exist has the identical runtime consequence — nothing ever matches it — but it looks correct in review, survives rename refactors invisibly, and is exactly what a field rename leaves behind. It is the shape that accumulates.On hotcrm the local assertion
test/authorization-coverage.test.ts→ every FLS key is object-qualified and names a real field goes red on both injections, which is how the asymmetry surfaced: that assertion cannot be retired againstsecurity-fls-unqualified-key, because the rule covers only half of what it asserts.Adjacent precedent
#15495 (closed) — "The object write door still runs no field-existence rule for searchableFields or listViews — the same asymmetry #15254 closed one key over". Same shape of gap (a declared key whose field is never resolved), one surface over. Cited as precedent, not as a duplicate: it does not reach permission-set
fields.Dedupe
Targeted search over this repo returned 10 on-topic results (
#14747,#15495,#12935,#11000,#9538,#9327, …), none naming permission-set field-permission keys. That non-zero, on-topic return is the control that the search was not silently answering empty.