Uh oh!
There was an error while loading. Please reload this page.
fix(lint): make validateSecurityPosture's surfaceReason a measured claim (#7576) - #7886
Conversation
…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
The latest updates on your projects. Learn more about Vercel for GitHub. 1 Skipped Deployment
|
📓 Docs Drift CheckThis PR changes 1 package(s): 3 hand-written doc(s) reference the affected code and may need an implementation-accuracy re-verification:
⛔ 1 release-owned page(s) also reference the affected code. These are read-only:
|
…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
Uh oh!
There was an error while loading. Please reload this page.
…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
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
packages/lint/src/authoring-rules.tsvalidateSecurityPosture'ssurfaceReasonrewritten to a measured claim (+ the measurement as a docblock above the entry)surfaces: CLI_ONLYpackages/lint/src/runtime-gate.tsTYPE_TO_STACK_KEYseed: 'seeds'→seed: 'data'seed)packages/lint/src/validate-security-posture.runtime-surface.test.tsThe claim that was false
The block is registered once, so all 13 rule ids inherit one
surfaces/surfaceReason. The reason read:Both halves are false, and both were re-measured at work time on
origin/mainbcea363:object-posture-gate.ts(141 lines) reads exactlysharingModelandexternalSharingModelthrough a localOWD_WIDTH, and neverfields,permissions,booksordata. It coverssecurity-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.saveMetaItemrunsassertRuntimeAuthoringRules(this table → 422invalid_metadata,protocol.ts:10105) beforerunAuthoringGate(the ADR-0094 gate → 403owd_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 toprivate), where this block reportssecurity-owd-unsetinstead.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.tsand judged by the real rule:examples/app-showcaseexamples/app-crmexamples/app-todopackages/create-objectstack/src/templates/blankpackages/platform-objectsships no seed metadata (grepforrecords:/SeedSchemaunder itssrc/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.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, 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'sREACHABILITY_CORPUSalready 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, acontrolled_by_parentobject with no relation refused) so the zeros are measured silence, not an unfired probe. The single row with no shipped instance either way issecurity-book-audience-unknown-set: the one shipped book (app-showcase) declaresaudience: 'public', not{ permissionSet }, so nothing in the corpus can reach it — its control is the fixture.Why stage 2 did not land
Blocker 1 —
objectis a strictness rollout, and its repair sites are outside this package.Declaring
objectinruntimeTypesputssecurity-owd-unseton the write path.ObjectSchema.sharingModelis.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: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 nosharingModel. The platform's runtime create door emits exactly the shape the rule would refuse.The refusal is arguably correct (
os buildhas 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 arepackages/spec/src/kernel/metadata-create-seeds.tsand eight metadata-protocol test files, both ⛔ outside this card's charter.Blocker 2 —
permission/bookneed a snapshot the gate does not build.RuntimeStackContextcarriesobjectsand nothing else. Three rules compare one collection against another:security-master-detail-ungrantedsecurity-private-no-readscopesecurity-book-audience-unknown-setThe 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-setfails identically and would flag every{ permissionSet }audience ever written at runtime, since the snapshot declares no sets at all. That is the existingRUNTIME_NEEDS_FULL_SNAPSHOTreason, tracked as #4463 P2 — a snapshot change in the protocol package, not aruntimeTypesedit here.A second, independent confirmation:
permissionandbookhave noTYPE_TO_STACK_KEYentry 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) carryallowRuntimeCreate: true, andruntime-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 onlystack.data[], are fully self-contained, trip nothing measured, and would cross the wall together as a whole sub-family — a genuinely ready one-lineruntimeTypes: ['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 addingposition/appforsecurity-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 namedsales_roleis refused and an object namedsales_roleis waved through. That is the #7220 failure this table already refuses to build.The latent defect the measurement found
TYPE_TO_STACK_KEYmappedseed → 'seeds'. A stack has noseedskey — seeds live ondata(ObjectStackDefinitionSchema.data: z.array(SeedSchema)), which is also what the two ADR-0091 rules read and whatPLURAL_TO_SINGULARdeclares (no mapping ontoseedsexists 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 readingstack.datasaw nothing, andrulesRunreported 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 declaresseedinruntimeTypes.Layer-sensitivity (#7815) — measured, not assumed
The dispatch asked this to be measured rather than presumed nil, and it is nil, structurally:
validateSecurityPosture(stack, opts?)accepts exactly one option,nowMs(the injected clock for the ADR-0091 expiry rule). There is nolayerparameter to mis-set.run: (stack) => validateSecurityPosture(stack)— it passes no options at all, so evennowMstakes its production default.validateVisibilityPredicatesat itsruntimelayer default for schema-bound metadata forms, so a correctlydata.-rooted form is told to userecord.#7815 is about.⛔ #7815 is untouched here — it remains a separate, ungraded finding.
Tests
pnpm --filter @objectstack/lint run test— 71 files / 1929 tests, all pass (was 70/1922; +1 file, +7 tests).authoring-rule-wiring.test.tsandvalidate-security-posture.test.tsboth green unchanged.pnpm --filter @objectstack/lint run typecheck— clean.pnpm --filter @objectstack/metadata-protocol run test— 72 files / 1066 tests, all pass, against a rebuilt@objectstack/lintdist. 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-configon the three touched files — clean.No suite pin was weakened.
validate-security-posture.test.ts'sexpect(pushedRuleIds()).toHaveLength(16)(16 emit sites, 13 distinct rule ids) and the wiring test'sruntime publish surfaceblock 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
runRuntimeAuthoringRulescorrectly answers "nothing".the mirror still matches the real gatepins the mirror against a type that is wired (flow), so a snapshot change inruntime-gate.tsthat the mirror did not follow fails loudly instead of silently invalidating the measurement.Not touched
packages/plugins/plugin-security/**,packages/metadata*/**,packages/spec/src/**— other lanes' charters. Read for measurement only.surfacesaxis to an open N-surface dimension (#4463 P1 introduced a closed two-value one) #7443 stayspm:on-hold.AUTHORING_SURFACESis unchanged and no third value is proposed; its restart condition is not tripped.content/docs/releases/— untouched; the changeset is this PR's input to the release notes.Generated by Claude Code