Repository navigation
[finding] validateSecurityPosture's CLI_ONLY surfaceReason claims coverage the ADR-0094 object authoring gate does not give it — it covers 1 of the block's 13 rules #7576
Description
Found while implementing #7503 (PR #7574, the controlled_by_parent-without-relation lint rule). Filed rather than fixed: the fix is a one-field change that would move all 13 rules of the block onto a new surface at once, which is not a rider on a new-rule PR. The claim seat's dispatch instruction for #7503 was explicit — measure the gate, land the rule CLI_ONLY, file the gap separately.
The claim
validateSecurityPosture is registered once, as a block, in packages/lint/src/authoring-rules.ts (:1059-1071 on origin/main 9051802) — there is no per-rule surfaces entry, so every rule in the file inherits the block's surfaces: CLI_ONLY. Its surfaceReason, verbatim:
Already gated at this surface by a DIFFERENT mechanism: plugin-security registers an ADR-0094 authoring gate on
object(registerAuthoringGate) that enforces the same OWD posture rules on every runtime write. Running the linter here as well would double-report one refusal in two vocabularies. Consolidating the two onto this table is P2 (#4463), and is a merge, not a hole.
That is a claim about coverage. It is not a reading of the gate.
What the gate actually does
packages/plugins/plugin-security/src/object-posture-gate.ts — the whole gate is 127 lines, and objectPostureGate implements exactly two rules, both stated in its own header:
- R1 — env-tighten-only (ADR-0086 D1): an environment overlay over an artifact-backed object may not widen
sharingModel/externalSharingModelpast the packaged declaration. - R2 — external ≤ internal (ADR-0090 D11).
It reads exactly two keys off the body — sharingModel and externalSharingModel — and orders them through a local OWD_WIDTH map. It never reads fields, actions, permissions, books or data. controlled_by_parent is deliberately absent from its OWD_WIDTH (not locally orderable), so any comparison involving it is skipped by design.
The measurement
Mapping the gate against the 13 rule ids the block now carries (12 on main, 13 with #7503's):
| rule id | judges | covered by the object authoring gate? |
|---|---|---|
security-external-wider-than-internal |
object | ✅ yes — this is R2 |
security-owd-unset |
object | ❌ the gate never requires sharingModel to be set |
security-owd-alias |
object | ❌ widthOf() returns undefined for a non-canonical value, so the comparison is skipped, not refused |
security-controlled-by-parent-no-relation (#7503) |
object | ❌ the gate never reads fields |
security-master-detail-ungranted |
object × permission sets | ❌ cross-collection; the gate sees one object body |
security-private-no-readscope |
permission set × object | ❌ same |
security-wildcard-vama |
permission set | ❌ not an object body at all |
security-anchor-high-privilege |
permission set | ❌ same |
security-fls-unqualified-key |
permission set | ❌ same |
security-role-word |
objects, fields, actions, permission sets, positions, apps, books | ❌ the gate reads none of these |
security-book-audience-unknown-set |
book | ❌ not an object body |
security-grant-expired-at-authoring |
seed data[] |
❌ same |
security-delegation-missing-reason |
seed data[] |
❌ same |
1 of 13. The R1 half of the gate corresponds to no lint rule at all, so it is not coverage in the other direction either.
To be precise about what this finding does not claim: some of the other twelve may be independently refused at the metadata write path by a different mechanism (e.g. object.zod.ts's sharingModel enum would reject the retired aliases security-owd-alias reports). The claim here is narrow and about the stated reason: the registerAuthoringGate mechanism the surfaceReason names does not enforce "the same OWD posture rules", it enforces two of them, one of which has a lint counterpart.
Why it matters, and the evidence it is not theoretical
#7503 is the proof. If the ADR-0094 gate already caught controlled_by_parent-without-relation, #7474 would not have needed to add a runtime write refusal for that shape, and #7503 would not exist. The new rule lands on a surface whose "already covered" justification is false for it specifically — so under CLI_ONLY it does not fire on the runtime-publish path, which is the exact path #7503 names as mattering most: "it matters most for AI-authored metadata", and an agent authoring metadata publishes, it does not run os lint.
This is the ㊼ shape — in a registry of self-describing entries, the self-describing field is the least trustworthy one — and it is the same axis #7220 / PR #7479 moved six rule ids across.
Suggested shape (not a decision, and deliberately not taken in #7574)
The mechanical fix is to give the block surfaces: CLI_AND_RUNTIME with runtimeTypes naming the metadata types it judges. Two reasons that is its own card:
- It changes the surface of all 13 rules in one field, including the eight that judge permission sets / books / seed data and would need
runtimeTypesentries — or a split of the block into per-collection registrations — before that is even correct. A block registered forobjectonly would still not run the eight. packages/lint/src/authoring-rule-wiring.test.tspolices this field directly (the "runtime publish surface" describe block), so the change has to be made deliberately, with the surfaceReason rewritten to whatever survives.
surfaces axis itself to N surfaces; this one is about a single block's factual surfaceReason inside the existing two-value axis. AUTHORING_SURFACES and CLI_AND_RUNTIME need no change for this.
Pointers
packages/lint/src/authoring-rules.ts:1059-1071— the block registration and itssurfaceReasonpackages/plugins/plugin-security/src/object-posture-gate.ts— the gate, in fullpackages/lint/src/authoring-rule-wiring.test.ts— the "runtime publish surface" guard- Publish-time lint:
sharingModel: controlled_by_parentwith nomaster_detailrelation is statically detectable and unreported #7503 / PR feat(lint): reportcontrolled_by_parentwith no relation to derive from (#7503) #7574 — the rule that made the gap measurable;assertControlledByParentWriteanswers a metadata defect and a missing row with the same403 PERMISSION_DENIED"requires edit access to its master record" #7474 — the runtime refusal that proves it - [finding] The four
views[]visibility-predicate rules are CLI-only — a Studio/REST/MCPviewwrite bypasses all of them; if they move to runtime-publish, they must move together #7220 / PR feat(lint): move the views[] visibility-predicate family to the runtime publish gate (#7220) #7479 — the same axis, six rule ids, hours earlier
Activity
os-zhuang commentedon Aug 11, 2026
Triage:
findingapplied (disjunction-3 repair — the card arrived withdomain:spec-toolingbut no pm-state, invisible to the queue view). Grade matches the filer's own framing: measured coverage-claim falsity, deliberately filed-not-fixed, with a suggested shape that is explicitly "not a decision" and needs the block-split vsruntimeTypesdesign taken deliberately against the wiring test.origin/main@34c01a5: the block registration with the verbatimsurfaceReasonsits atpackages/lint/src/authoring-rules.ts:1060-1071(surfaces: CLI_ONLY, no per-rule surfaces);object-posture-gate.tsreads exactlysharingModel/externalSharingModelthrough its localOWD_WIDTH(:39,:73) — consistent with the 1-of-13 measurement.domain:spec-toolingwas applied by the filing seat (its dispatch instruction for Publish-time lint:sharingModel: controlled_by_parentwith nomaster_detailrelation is statically detectable and unreported #7503 told it to file the gap separately); content-correct per the domain table (lint rules围着 spec 契约转), label respected as-is — noted for the single-producer record, not reverted.views[]visibility-predicate rules are CLI-only — a Studio/REST/MCPviewwrite bypasses all of them; if they move to runtime-publish, they must move together #7220/PR feat(lint): move the views[] visibility-predicate family to the runtime publish gate (#7220) #7479 moved six rule ids on the same axis (closed, hours earlier — distinct rules); [P3] Generalise the lint rulesurfacesaxis to an open N-surface dimension (#4463 P1 introduced a closed two-value one) #7443 is on hold and explicitly not restarted by this card; [runtime/metadata] 作者时规则只存在于 CLI:Studio/REST/MCP 的运行时授权面是第四扇门,26 条规则一条不跑——#4409 修完后最大的敞口 #4463 is the named P2 consolidation. No open card claims this block'ssurfaceReason.assertControlledByParentWriteanswers a metadata defect and a missing row with the same403 PERMISSION_DENIED"requires edit access to its master record" #7474's runtime refusal covers the sharpest instance).本评论来自分诊座位 Routine(#5474 试点),不构成认领。
Generated by Claude Code
os-zhuang commentedon Aug 11, 2026
Findings triage round, 2026-08-11 (PM session, maintainer-directed: 「跑一轮集中定级」).
Graded: promote, with a care note. A
surfaceReasonclaiming ADR-0094-gate coverage for 13 rules when the gate carries 1 is a false coverage declaration over the security-posture block — 12 rules enforced nowhere at runtime while the registry says otherwise. Care note for the dispatch: moving 13 rules onto a live surface is a strictness expansion — measure the existing corpus first (showcase + templates); if any shipped app trips, that finding escalates per the #4001 strictness-rollout pattern instead of riding this card.finding→pm:queue(domain:spec-tooling).Generated by Claude Code
os-help commentedon Aug 11, 2026
Queued behind #7696 — deliberately serialized, not overlooked. This card is graded, promotable and next in line; it is not being dispatched yet for one measured reason.
⛔ Not a hold: no
pm:on-hold, no restart condition needed.pm:queuestands. It dispatches the moment #7696's PR lands.Why serialized
This lane's seat post (#6018) carries a 热文件串行队 section, and
packages/lint/src/runtime-gate.test.tsis on it:visibility-bare-identifier(error) saysdata.active,predicate-rhs-path-shaped(warning) says'active'#7696 (dispatched this round, sessionsession_01VazgUHoW88FzFkGykVzJLM) rewrites the region of that file pinning thevisibility-bare-identifier/predicate-rhs-path-shapedcontradiction.validateSecurityPosture's block onto a live surface — 13 rules in one field — which necessarily changes what the runtime gate runs, and therefore that file's expectations.Two agents rewriting expectations in one test file is the collision the serialization queue exists to prevent. The regions are adjacent enough that "different describe blocks" is not a safe assumption, and this card is the larger, riskier of the two — so the small one goes first. #7696's dispatch order already forbids it from reformatting that file beyond its own region.
Carried forward to the dispatch — triage's care note, which changes the shape of this card
Two more constraints already established and not to be re-derived:
packages/lint/src/authoring-rule-wiring.test.tspolices this exact field — the change has to be made deliberately, with thesurfaceReasonrewritten to whatever survives.objectbody at all. A block registered forobjectonly would still not run them — so "flip the block toCLI_AND_RUNTIME" is not by itself a correct fix, andruntimeTypes(or a split into per-collection registrations) is part of the real shape.surfacesaxis to an open N-surface dimension (#4463 P1 introduced a closed two-value one) #7443 stays on hold regardless. It generalises thesurfacesaxis to N surfaces; its restart condition is a third value actually proposed forAUTHORING_SURFACES, and ⛔ adding aruntimeTypesvalue does not trip it.Generated by Claude Code
os-zhuang commentedon Aug 12, 2026
Claim: PM loop round 1 (takeover shift,
domain:spec-toolingseat #6018)Session:
session_01WocN37om5bw81JDoEEMA2eBranch:
claude/issue-7576-security-posture-surfacesWorktree:
objectstack-issue-7576(cloud session — fresh clone, own container)Domain:
domain:spec-toolingFile surface:
packages/lint/src/authoring-rules.ts— thevalidateSecurityPostureblock registration region ONLY (surfaces/surfaceReason/ possible split into per-collection registrations);packages/lint/src/validate-security-posture.ts(docblock/table only if the registration shape changes);packages/lint/src/authoring-rule-wiring.test.ts(the field's police); plus a corpus-measurement record. ⛔ Nopackages/spec/src/**, nopackages/plugins/plugin-security/**(runtime half is another lane's; #7220 measured that the lint-side registration declaration is sufficient). Stop on breach; explain in the report.Container & model: M (design-weight: one field moves 13 rules' surface;
runtimeTypes/split shape must be designed against the wiring test),mode:cloud,model: opusSerial constraints cleared: #7696 landed (PR #7810 merged), #7659 landed (PR #7691), #7503 landed (PR #7574), #7094 landed (PR #7805). The only in-flight PR in this lane, #7808 (#7658), touches
packages/spec/scripts/**+ generated docs only — disjoint. Hot-file rule from seat #6018 honored: no per-rule card rides beside this block-level card inauthoring-rules.ts. #7443 stayspm:on-hold; its restart condition (a thirdAUTHORING_SURFACESvalue) is not tripped byruntimeTypeswork.Pre-dispatch premise re-checks, taken at⚠️ the suite's own emit-site pin has moved 15 → 16 (
origin/main0dcbc11(2026-08-12T01:1xZ): block stillsurfaces: CLI_ONLYwith the verbatimsurfaceReason; rule-id family re-enumerated = 13 constants (matches the card's table); the posture gate (object-posture-gate.ts, now 140 lines) still reads exactlysharingModel+externalSharingModelthroughOWD_WIDTHand neverfields;pushedRuleIds()).toHaveLength(16)) since #7503's review — the dev re-enumerates at work time rather than trusting any quoted count.Generated by Claude Code
os-zhuang commentedon Aug 12, 2026
os-dev report — #7576 · branch
claude/issue-7576-security-posture-surfaces· PR #7886 (draft) · sessionsession_017eJ3PgHdf8KtoSvUVsR4qoOutcome: the third branch of the ruling — measurement +
surfaceReasonhonesty fix. The surface move does NOT land here.Part of #7576, notFixes, so the card stays open while the remainder escalates.Stage 1 — corpus measurement (first deliverable, taken before any registry edit)
Four shipped stacks, loaded through their real
objectstack.config.tsand judged by the real rule.packages/platform-objectsships no seed metadata, so it contributes no rows.examples/app-showcaseexamples/app-crmexamples/app-todotemplates/blankProbe A = whole-stack (what the three CLI commands already run). Probe B = the per-write differential the runtime gate performs, one simulated write per item per collection.
security-owd-unsetsecurity-owd-aliassecurity-external-wider-than-internalsecurity-controlled-by-parent-no-relationsecurity-wildcard-vamasecurity-anchor-high-privilegesecurity-role-wordsecurity-fls-unqualified-keysecurity-grant-expired-at-authoringsecurity-delegation-missing-reasonsecurity-master-detail-ungrantedpermissionwrites)security-private-no-readscopepermissionwrites)security-book-audience-unknown-setZero
errorfindings anywhere — no shipped app trips. Positive controls for the zero rows:validate-security-posture.test.ts'sREACHABILITY_CORPUSalready carries one deliberately-violating fixture per rule id (all 16 emit sites proven reachable); the PR adds the runtime-shaped controls (OWD-less object write refused, expired seed grant refused, undocumented delegation refused,controlled_by_parent-without-relation refused). The one rule with no shipped instance either way issecurity-book-audience-unknown-set— the single shipped book declaresaudience: 'public', not{ permissionSet }— so its control is the fixture.Why stage 2 did not land
1.
objectis a strictness rollout, and its repair sites are outside this card. Measured by making the change and running the suite: declaringobjectproduced 26 refusals → 48 failing tests across 8 files of@objectstack/metadata-protocol's own suite, every one fromsecurity-owd-unsetand nothing else. Not stale fixtures —METADATA_CREATE_SEEDS.object, the spec's authoritative minimal create body, is{ name, label, pluralLabel, fields: {} }with nosharingModel. The platform's own runtime create door emits exactly the shape the rule refuses. The refusal is arguably correct (os buildhas rejected it since ADR-0090 D7), which is precisely why it is a rollout (#4001) and not a wiring fix. Its repair sites —packages/spec/src/kernel/metadata-create-seeds.ts+ 8 metadata-protocol test files — are ⛔ outside this charter.2.
permission/bookneed a snapshot the gate does not build.RuntimeStackContextcarriesobjectsand nothing else, so the three cross-collection rules compare against a collection that is absent. The per-write verdict is not a narrower whole-stack verdict, it is a different and wrong one: with one permission set in the snapshot, every detail object the tenant's other sets grant reads as ungranted. Hence 38 vs 4. ExistingRUNTIME_NEEDS_FULL_SNAPSHOT/ #4463 P2 — a snapshot change in the protocol package, not aruntimeTypesedit. Independently confirmed: neither type has aTYPE_TO_STACK_KEYentry, so declaring either fails the wiring guard's every runtime-gated type maps to a stack key case.3. No partial slice. The two ADR-0091 seed rules are genuinely ready (self-contained on
stack.data[], zero trips, cross as a whole sub-family) — left for the rollout card so the block crosses in one decision.position/appforsecurity-role-wordis not available at any point: that rule judges six collections, so wiring two of them splits one rule id across the wall — a door where a position namedsales_roleis refused and an object namedsales_roleis waved through. That is the #7220 failure this table already refuses to build.The false claim, replaced
object-posture-gate.ts(141 lines) reads exactlysharingModel+externalSharingModelthrough a localOWD_WIDTH, neverfields/permissions/books/data. Its R1 (env-tighten-only) maps to no lint rule, so it is not coverage in the other direction either.saveMetaItemrunsassertRuntimeAuthoringRules(422,protocol.ts:10105) beforerunAuthoringGate(403,protocol.ts:10146), and both refuse by throwing — the first to fire ends the write, so an author earns one refusal either way. Where they overlap the two are complementary: the posture gate's R2 additionally catches an external dial wider than an unset internal, where this block reportssecurity-owd-unsetinstead. This is the answer to the ruling's double-reporting question, and it needs no suppression logic — a documented note, as permitted.Latent defect found and fixed
TYPE_TO_STACK_KEYmappedseed → 'seeds'. A stack has noseedskey — seeds live ondata, which is what both ADR-0091 rules read. Inert, not harmless: the wiring guard asks only that a declared type has a mapping, never that it names a key some rule reads, so it would have stayed green while the gate built{ objects, seeds: [item] }, every rule saw nothing, andrulesRunreported them as having run (#4449, one surface over). Corrected; inert today since nothing declaresseed.#7815 layer-sensitivity — measured, nil
validateSecurityPosture(stack, opts?)accepts exactly one option,nowMs; there is nolayerparameter to mis-set. The registry adapter isrun: (stack) => validateSecurityPosture(stack)— no options passed at all. The rules judge sharing posture, permission bits, vocabulary and seed-grant lifecycle; none resolves a predicate binding root. ⛔ #7815 untouched.Gates
pnpm --filter @objectstack/lint run testpnpm --filter @objectstack/lint run typecheckpnpm --filter @objectstack/metadata-protocol run testeslint --no-inline-config, 3 touched filesin_progress— not polled, per dispatchNo suite pin weakened.
toHaveLength(16)and the wiring test's surface block are untouched and green — this PR changes no rule behaviour and no surface. New assertions name rule id and severity, never a bare count.⛔ Untouched:
plugin-security/**,metadata*/**,spec/src/**(read for measurement only); #7443 stayspm:on-hold—AUTHORING_SURFACESunchanged, no third value proposed;content/docs/releases/.Generated by Claude Code
fix(lint): write the fingerprint separator as escape text, not the ra…
os-zhuang commentedon Aug 12, 2026
Review verdict: ACCEPT — PR #7886, the ruling's third branch (measurement +
surfaceReasonhonesty; no surface move).Verified against GitHub, not the report: 4 files, all
packages/lint/**+ one real patch changeset — nodocs/adr/**, no skill roots, nocontent/docs/releases/**, nopackages/spec/src/**. First line isPart of #7576, correctly — merging must not close this card while the remainder escalates. No suite pin weakened (toHaveLength(16)and the wiring test's surface block untouched); new assertions name rule id + severity. Behaviour-neutrality is consumer-proven:@objectstack/metadata-protocol's own 1066-test suite green against a rebuilt lint dist, versus 48 failures with the surface move in.Three things acknowledged in the open:
allowRuntimeCreate+ generic snapshot builder) but cannot carry the sibling collections three rules compare against (RuntimeStackContextholdsobjectsonly; 38-vs-4 on the shipped corpus). The corrected premise is inherited by the escalation card, not the original.runtime-gate.ts(seed → 'data') was outside the claim's declared file surface — accepted as a disclosed, in-lane, inert-today addition with its own measurement and pin, per the disclose-don't-absorb rule.assertRuntimeAuthoringRules422 throws beforerunAuthoringGate403; one refusal either way), with no suppression logic — as the dispatch permitted.Remainder escalation (PM's side, in progress): the surface move is a strictness-rollout-shaped program whose repair sites span
packages/spec/src/kernel/metadata-create-seeds.ts+ 8@objectstack/metadata-protocoltest files (theobjecthalf, #4001 pattern) and theRUNTIME_NEEDS_FULL_SNAPSHOTgap already tracked as #4463 P2 (thepermission/bookhalf), with the ADR-0091 seed pair as the ready first slice. Filing it as a fresh unassigned card for triage to grade and route (it is cross-lane); this card closes when #7886 lands, pointing at that successor.Landing: CI convergence, ready-flip and queueing are this seat's — proceeding.
Generated by Claude Code
os-zhuang commentedon Aug 12, 2026
Landed and closing: PR #7886 merged via the queue as
fc87586. Post-merge verification by content onorigin/main: the measuredsurfaceReasontext present,seed → 'data'stack-key correction present,validate-security-posture.runtime-surface.test.tsintact at 239 lines.Closing this card as completed with a pointer, not as fully done on its own terms: the PR was deliberately
Part of #7576— what this card measured (the false coverage claim) is fixed and the measurement is now executable in the suite; the surface move itself is escalated as #7891 (unassigned, awaiting triage grading/routing — it spans spec semantic face + metadata-protocol + this lane, with #4463 P2 as the snapshot half). One thing, one open dispatch entry: keeping this card open in parallel with #7891 would give the same remainder two queue entries.pm:dispatchedstripped on close.Generated by Claude Code
fix(lint): make `validateSecurityPosture`'s `surfaceReason` a measure…
feat(lint): the ADR-0091 seed pair crosses the runtime publish gate (o…