Skip to content

fix(lint): make validateSecurityPosture's surfaceReason a measured claim (#7576) - #7886

Merged
os-zhuang merged 2 commits into
mainfrom
claude/issue-7576-security-posture-surfaces
Aug 12, 2026
Merged

fix(lint): make validateSecurityPosture's surfaceReason a measured claim (#7576)#7886
os-zhuang merged 2 commits into
mainfrom
claude/issue-7576-security-posture-surfaces

Conversation

@os-zhuang

Copy link
Copy Markdown
Contributor

Part of #7576

Not Fixes, deliberately. The card's stage 2 — moving the block onto the runtime publish surface — did not land, on measurement rather than preference. The card must stay open while that remainder escalates as its own strictness-rollout card. See Stage 1 measurement below for what made that call.

What changed

filechangebehaviour
packages/lint/src/authoring-rules.tsvalidateSecurityPosture's surfaceReason rewritten to a measured claim (+ the measurement as a docblock above the entry)none — still surfaces: CLI_ONLY
packages/lint/src/runtime-gate.tsTYPE_TO_STACK_KEYseed: 'seeds'seed: 'data'none today (no rule declares seed)
packages/lint/src/validate-security-posture.runtime-surface.test.tsnew — keeps every load-bearing number in the reason executablenew tests

The claim that was false

The block is registered once, so all 13 rule ids inherit one surfaces / surfaceReason. The reason read:

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.

Both halves are false, and both were re-measured at work time on origin/mainbcea363:

  • Coverage — 1 of 13.object-posture-gate.ts (141 lines) reads exactly sharingModel and externalSharingModel through a local OWD_WIDTH, and never fields, permissions, books or data. It covers security-external-wider-than-internal (its R2) and nothing else. Its other half, R1 (env-tighten-only, ADR-0086 D1), corresponds to no lint rule, so it is not coverage in the other direction either. The card's table reproduced exactly.
  • Double-reporting is structurally impossible.saveMetaItem runs assertRuntimeAuthoringRules (this table → 422 invalid_metadata, protocol.ts:10105) beforerunAuthoringGate (the ADR-0094 gate → 403 owd_external_wider, protocol.ts:10146), and both refuse by throwing. The first to fire ends the write, so an author earns one refusal either way — and where they overlap the two are complementary, not duplicate: the posture gate's R2 additionally catches an external dial wider than an unset internal (it baselines to private), where this block reports security-owd-unset instead.

So the stated cost of moving was imaginary. The reasons it has not moved are the measured ones below, and they are now what the field says.

Stage 1 measurement (the triage care note's first deliverable)

Corpus scanned — the four shipped stacks, loaded through their real objectstack.config.ts and judged by the real rule:

stackobjectspermissionsbookspositionsappsseeds (records)
examples/app-showcase23 (202 fields)819118 (130)
examples/app-crm6 (49 fields)20315 (28)
examples/app-todo1 (20 fields)00011 (8)
packages/create-objectstack/src/templates/blank100000

packages/platform-objects ships no seed metadata (grep for records: / SeedSchema under its src/ returns nothing), so it contributes no rows to the two ADR-0091 rules.

Two probes over that corpus: A = the whole-stack call the three CLI commands already make; B = the per-write differential the runtime gate performs (runtime-gate.ts), one simulated write per item per collection.

rule idseverityA (CLI, whole stack)B (runtime, per write)
security-owd-unseterror00
security-owd-aliaserror00
security-external-wider-than-internalerror00
security-controlled-by-parent-no-relationerror00
security-wildcard-vamaerror00
security-anchor-high-privilegeerror00
security-role-worderror00
security-fls-unqualified-keyerror00
security-grant-expired-at-authoringerror00
security-delegation-missing-reasonerror00
security-master-detail-ungrantedwarning438 (all on permission writes)
security-private-no-readscopeinfo22 (on permission writes)
security-book-audience-unknown-setwarning00

Zero error findings anywhere. No shipped app trips, so the #4001 rollout pattern is not engaged by the corpus.

Every zero row has a positive control — the probe does catch these when they are there. validate-security-posture.test.ts's REACHABILITY_CORPUS already carries one deliberately-violating fixture per rule id and proves all 16 emit sites reachable; this PR adds the runtime-shaped controls (an OWD-less object write refused, an expired seed grant refused, an undocumented delegation refused, a controlled_by_parent object with no relation refused) so the zeros are measured silence, not an unfired probe. The single row with no shipped instance either way is security-book-audience-unknown-set: the one shipped book (app-showcase) declares audience: 'public', not { permissionSet }, so nothing in the corpus can reach it — its control is the fixture.

Why stage 2 did not land

Blocker 1 — object is a strictness rollout, and its repair sites are outside this package.

Declaring object in runtimeTypes puts security-owd-unset on the write path. ObjectSchema.sharingModel is .optional() with no default, so any object published without an authored OWD is refused 422. Measured by actually making the change and running the suite:

26 refusals → 48 failing tests across 8 files of @objectstack/metadata-protocol's own suite (protocol.read-decorations, protocol.save-receipt-wording, protocol.injected-system-columns, protocol.audit-field-governance, protocol.org-scoped-write-refused, protocol.runtime-authoring-gate, protocol-publish-drafts-endpoint-gate, protocol-publish-drafts-org-scope) — every one from that single rule id, nothing else fired.

That is not stale fixtures: METADATA_CREATE_SEEDS.object — the spec's own "authoritative minimal shape a freshly-created item should carry", the body Studio's create-object flow starts from — is { name, label, pluralLabel, fields: {} } with no sharingModel. The platform's runtime create door emits exactly the shape the rule would refuse.

The refusal is arguably correct (os build has rejected that body since ADR-0090 D7, and the wiring guard's own words are "the two publish verbs must not disagree") — which is precisely why it is a rollout and not a wiring fix. Its repair sites are packages/spec/src/kernel/metadata-create-seeds.ts and eight metadata-protocol test files, both ⛔ outside this card's charter.

Blocker 2 — permission / book need a snapshot the gate does not build.

RuntimeStackContext carries objects and nothing else. Three rules compare one collection against another:

rulereadsmissing at the gate
security-master-detail-ungrantedobjects × permission setsall sibling permission sets
security-private-no-readscopeobjects × permission setssame
security-book-audience-unknown-setbooks × permission setssame

The per-write verdict is therefore not a narrower version of the whole-stack verdict — it is a different and wrong one: with a single permission set in the snapshot, every detail object the tenant's other sets grant reads as ungranted. Hence 38 against 4. security-book-audience-unknown-set fails identically and would flag every { permissionSet } audience ever written at runtime, since the snapshot declares no sets at all. That is the existing RUNTIME_NEEDS_FULL_SNAPSHOT reason, tracked as #4463 P2 — a snapshot change in the protocol package, not a runtimeTypes edit here.

A second, independent confirmation: permission and book have no TYPE_TO_STACK_KEY entry at all, so declaring either without adding one fails the wiring guard's every runtime-gated metadata type maps to a stack key case. Pinned as a test.

On the PM's load-bearing premise. It survives, with a correction. The plumbing can reach non-object collections — all six candidate types (object, permission, position, app, book, seed) carry allowRuntimeCreate: true, and runtime-gate.ts's snapshot builder is written generically ({ objects, [stackKey]: [item] }). What it cannot do is carry the sibling collections three of the rules compare against. So this is not "the plumbing cannot reach them"; it is a bounded, named context gap, and the fork is reported rather than forced.

Why not land a partial slice anyway. The two ADR-0091 seed rules (security-grant-expired-at-authoring, security-delegation-missing-reason) read only stack.data[], are fully self-contained, trip nothing measured, and would cross the wall together as a whole sub-family — a genuinely ready one-line runtimeTypes: ['seed'] edit. It is left for the rollout card rather than taken here so the block crosses in one deliberate decision. What is not available at any point is adding position / app for security-role-word: that rule judges six collections, and wiring the two that need no snapshot would split one rule id across the wall — a door where a position named sales_role is refused and an object named sales_role is waved through. That is the #7220 failure this table already refuses to build.

The latent defect the measurement found

TYPE_TO_STACK_KEY mapped seed → 'seeds'. A stack has noseeds key — seeds live on data (ObjectStackDefinitionSchema.data: z.array(SeedSchema)), which is also what the two ADR-0091 rules read and what PLURAL_TO_SINGULAR declares (no mapping onto seeds exists at all).

It was inert rather than harmless, and it is the #4449 shape one surface over: the wiring guard asks only that a declared type has a mapping, never that the mapping names a key some rule reads. It would have stayed green while the gate built { objects, seeds: [item] } for every seed write, every rule reading stack.data saw nothing, and rulesRun reported the rules as having run. Corrected here, with the measurement that found it, rather than left for the rollout card to trip over. Inert today — nothing declares seed in runtimeTypes.

Layer-sensitivity (#7815) — measured, not assumed

The dispatch asked this to be measured rather than presumed nil, and it is nil, structurally:

#7815 is untouched here — it remains a separate, ungraded finding.

Tests

  • pnpm --filter @objectstack/lint run test71 files / 1929 tests, all pass (was 70/1922; +1 file, +7 tests). authoring-rule-wiring.test.ts and validate-security-posture.test.ts both green unchanged.
  • pnpm --filter @objectstack/lint run typecheck — clean.
  • pnpm --filter @objectstack/metadata-protocol run test72 files / 1066 tests, all pass, against a rebuilt @objectstack/lint dist. This is the behaviour-neutrality proof: the same suite showed 48 failures with the surface move in, and zero with only what this PR lands.
  • eslint --no-inline-config on the three touched files — clean.

No suite pin was weakened.validate-security-posture.test.ts's expect(pushedRuleIds()).toHaveLength(16) (16 emit sites, 13 distinct rule ids) and the wiring test's runtime publish surface block are untouched and still green — this PR changes no rule behaviour and no surface, so neither pin had anything to say about it. The new file's assertions name the rule id and the severity explicitly, never a bare count.

The new test deliberately mirrors the gate's differential rather than calling it, because no rule declares these types — that is the state the card is about, so runRuntimeAuthoringRules correctly answers "nothing". the mirror still matches the real gate pins the mirror against a type that is wired (flow), so a snapshot change in runtime-gate.ts that the mirror did not follow fails loudly instead of silently invalidating the measurement.

Not touched


Generated by Claude Code

…aim (#7576)
The `validateSecurityPosture` block's `surfaceReason` said the ADR-0094 `object`
authoring gate "enforces the same OWD posture rules", so running the linter at
the runtime publish surface too "would double-report one refusal in two
vocabularies". Both halves were false.
`object-posture-gate.ts` reads exactly `sharingModel` + `externalSharingModel`
through a local `OWD_WIDTH` and never `fields` / `permissions` / `books` /
`data`: it covers 1 of the block's 13 rule ids (`security-external-wider-than-
internal`, its R2). And double-reporting cannot happen — `saveMetaItem` runs
`assertRuntimeAuthoringRules` (422) before `runAuthoringGate` (403) and both
refuse by throwing, so a write earns one refusal either way.
The surface move itself is NOT taken here, on measurement rather than
preference (#7576 stage 1):
- the four shipped stacks (showcase, CRM, todo, the `blank` template) are clean
of `error` findings at both surfaces — no shipped app trips;
- but declaring `object` makes `security-owd-unset` refuse every OWD-less
runtime object publish: 26 refusals / 48 failing tests across 8 files of
`@objectstack/metadata-protocol`'s own suite, and `METADATA_CREATE_SEEDS.object`
carries no `sharingModel` either. That is a strictness rollout (#4001), and
its repair sites are outside this package;
- `permission` / `book` fail differently: the gate carries `objects` and nothing
else, so the three cross-collection rules judge a snapshot missing what they
compare against — measured at 38 invented `security-master-detail-ungranted`
warnings against the whole-stack run's 4.
Also corrects a latent, currently-inert defect the measurement found: the gate
mapped the `seed` metadata type to a stack key `seeds`, which no stack has and
no rule reads (seeds live on `data`), so the first rule to declare the type
would have been wired onto an empty collection.
Refs #7576
@vercel

vercelBot commented Aug 12, 2026

Copy link
Copy Markdown

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

1 Skipped Deployment
ProjectDeploymentActionsUpdated (UTC)
objectstackIgnoredIgnoredAug 12, 2026 2:25am

Request Review

@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

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

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

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

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

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

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

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

…w byte
`check:nul-bytes` (which the ESLint job also runs) caught 3 raw 0x00 bytes in
the new test file: the fingerprint helper mirrors `runtime-gate.ts`'s separator,
and the escape sequence was materialised into the actual byte while copying it
— the exact slip #4763 / #4890 / #5460 describe.
The separator semantics were already right; only the spelling was wrong. Writing
the escape text restores grep-ability (a raw NUL makes ripgrep treat the whole
file as binary and return ZERO matches) and makes the mirror byte-identical to
the gate's own line, which is what its drift guard wants.
Refs #7576
@os-zhuang
os-zhuang marked this pull request as ready for review August 12, 2026 02:39
@os-zhuang
os-zhuang added this pull request to the merge queueAug 12, 2026
Merged via the queue into main with commit fc87586Aug 12, 2026
26 checks passed
@os-zhuang
os-zhuang deleted the claude/issue-7576-security-posture-surfaces branch August 12, 2026 02:55
hotlong pushed a commit that referenced this pull request Aug 13, 2026
…fixture
Re-measured today through the publish gate's call site: 56 active object
writes across 10 test files, every one of which security-owd-unset would
refuse once #8310 declares 'object' in runtimeTypes (up from PR #7886's
26/8 — the suite grew). Each fixture now authors sharingModel: 'private',
so the suite passes both before and after the #8310 flip.
Part of #8308.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Euoy6wyfzgiWtgCg4s6JK2
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentationImprovements or additions to documentationsize/mteststooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant

@os-zhuang