Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
45 changes: 45 additions & 0 deletions .changeset/object-owd-door-authored-required.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
---
"@objectstack/lint": minor
"@objectstack/plugin-security": minor
---

feat(lint): an authored OWD is required at the runtime object door — `runtimeTypes` gains `object`, completing the #7891 flip; the plugin gate's R2 `owd_external_wider` arm is retired as its duplicate (#8310, maintainer-ruled)

The security publish linter (`validateSecurityPosture`, ADR-0090 D7) now runs
for runtime-authored **object** publishes, alongside the `seed` /
`permission` / `book` types that crossed earlier in the #7891 rollout. An
active-state object publish — Studio publish, direct REST save, AI builders —
with **no authored `sharingModel`** is refused with `422 INVALID_METADATA`
(`security-owd-unset` in `issues`): absence is not a decision. Previously the
runtime door accepted OWD-less bodies and silently defaulted them to
`private` (ADR-0090 D1) while the CLI refused the same body — the runtime
door was permanently weaker than the build door on exactly the hottest
AI-author write path.

Door order at `saveMetaItem`, now pinned end-to-end: the **422 lint door
answers first** (all 12 rule ids of the D7 block, external ≤ internal
included), then the ADR-0094-seam plugin gate answers for what passes lint.
Consequences:

- `objectPostureGate`'s **R2 arm (`403 owd_external_wider`, external ≤
internal) is retired as a duplicate** of the lint door (maintainer ruling
on #8310; ADR-0094 amendment rides this change). An external-wider pair now
answers `422` / `security-external-wider-than-internal` instead of `403` /
`owd_external_wider`. R2's only non-shadowed refusals were false positives
(system objects, whose unset OWD is effectively PUBLIC at runtime, and
draft saves, which the lint discipline defers to the draft→active
promotion gate per #4463 D1).
- **R1 stays**: an environment overlay may still only TIGHTEN a packaged
object's posture (`403 owd_widening_forbidden`) — no lint rule can judge
the packaged baseline.
- Draft saves are ungated (work-in-progress may be dirty); the draft→active
promotion runs the same 422 gate, so no defective body reaches `active`.
- The `package-author` channel carve-out (#6710) is unchanged on both doors.

Migration: author `sharingModel` explicitly on every runtime-published object
body (`'private'` is the recommended default; `'public_read'`,
`'public_read_write'`, `'controlled_by_parent'` for master-detail children).
Stored metadata is untouched — the gate judges new writes only, and a clean
write is never blamed for a pre-existing OWD-less object in the environment
(the gate's baseline/candidate differential cancels context findings).
`OS_ALLOW_UNLINTED_METADATA_WRITES=1` remains the loud migration hatch.
52 changes: 31 additions & 21 deletions content/docs/permissions/authorization.mdx
Original file line numberDiff line numberDiff line change
Expand Up@@ -336,29 +336,39 @@ Five mechanisms — four CI-time, one runtime — make the security posture a
**checked artifact** rather than a belief:

- **Security publish linter** (ADR-0090 D7, `validateSecurityPosture` in
`@objectstack/lint`, gating `os compile`): unset OWD on custom objects,
retired OWD aliases, an external dial wider than internal, `'*'` wildcards
carrying View/Modify All outside the platform admin set, high-privilege
`isDefault` (everyone-suggested) sets, the reserved word "role" in
security identifiers, a `controlled_by_parent` object with no relation the
platform can derive access from (ADR-0055: no required `master_detail`, no
`master_detail` at all, and no required `lookup` — so the runtime denies
every read and refuses every write), and the ADR-0091 grant-lifecycle rules
(a seed grant already expired at authoring time; a delegation row missing
its mandatory `reason`) — every error rule mirrors a runtime gate.
`@objectstack/lint`, gating `os compile` — and, since the #7891 rollout
completed with #8310, the **runtime publish door** for `object` /
`permission` / `book` / seed writes, where a gating finding refuses the
save with `422 INVALID_METADATA` and the rule id in `issues`): unset OWD
on custom objects (`security-owd-unset` — an object publish with no
authored `sharingModel` is refused; absence is not a decision, at the CLI
and at the runtime door alike), retired OWD aliases, an external dial
wider than internal (`security-external-wider-than-internal`), `'*'`
wildcards carrying View/Modify All outside the platform admin set,
high-privilege `isDefault` (everyone-suggested) sets, the reserved word
"role" in security identifiers, a `controlled_by_parent` object with no
relation the platform can derive access from (ADR-0055: no required
`master_detail`, no `master_detail` at all, and no required `lookup` — so
the runtime denies every read and refuses every write), and the ADR-0091
grant-lifecycle rules (a seed grant already expired at authoring time; a
delegation row missing its mandatory `reason`) — every error rule mirrors
a runtime gate.
- **Runtime OWD posture gate** (#3050, `objectPostureGate` in
`@objectstack/plugin-security`, registered on the metadata protocol's
pre-persistence `registerAuthoringGate` seam): the two OWD rules the CLI
linter can only check at build time are also enforced on every
runtime-authored object body — Studio drafts, REST saves, AI builders.
An environment overlay of a **packaged** object may only *tighten*
`sharingModel` / `externalSharingModel`, never widen them beyond the
packaged declaration (`403 owd_widening_forbidden` — widen it in the
package source and publish instead; this closes the
`OS_METADATA_WRITABLE=object` escape hatch as an unvalidated widening
path, ADR-0086 D1), and `externalSharingModel ≤ sharingModel` (ADR-0090
D11) is rejected at save time (`403 owd_external_wider`). Write-path
only: stored metadata keeps loading unchanged.
pre-persistence `registerAuthoringGate` seam): the packaged-baseline rule
no lint rule can judge, enforced on every runtime-authored object body —
Studio drafts, REST saves, AI builders. An environment overlay of a
**packaged** object may only *tighten* `sharingModel` /
`externalSharingModel`, never widen them beyond the packaged declaration
(`403 owd_widening_forbidden` — widen it in the package source and
publish instead; this closes the `OS_METADATA_WRITABLE=object` escape
hatch as an unvalidated widening path, ADR-0086 D1). Write-path only:
stored metadata keeps loading unchanged. The gate's former second rule
(`403 owd_external_wider`, external ≤ internal) was **retired as a
duplicate** when the lint block crossed to the runtime door (#8310
maintainer ruling): the 422 lint door answers first for external-wider
and for unauthored-OWD bodies, and the 403 gate remains for
packaged-baseline widening only.
- **Access-matrix snapshot** (ADR-0090 D6, `buildAccessMatrix` /
`diffAccessMatrix`): with `access-matrix.json` committed next to the config,
`os compile` fails on any capability drift with semantic lines
Expand Down
35 changes: 35 additions & 0 deletions docs/adr/0094-sys-permission-set-pure-projection.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -449,3 +449,38 @@ structurally* — is identical; only the per-type **direction** differs. A
separate ADR would duplicate the rationale and split the classification from
the decision that motivates it. This addendum keeps the rule and its
applications in one place.

## Amendment (2026-08-14): the object posture gate's R2 arm is retired — the runtime lint door owns external ≤ internal

Maintainer ruling on #8310 (2026-08-13, accepting the escalated
recommendation in full). This amendment records the retirement of one of the
two rules the `object` authoring gate (`objectPostureGate`,
`@objectstack/plugin-security` — registered on this ADR's
`registerAuthoringGate` seam, #3050/#7674) carried:

- **R2 (`403 owd_external_wider`, external ≤ internal, ADR-0090 D11) is
RETIRED as a duplicate.** The #7891 rollout completed on #8310:
`validateSecurityPosture` (`@objectstack/lint`) now declares `object` in
its `runtimeTypes`, so every active-state object publish is judged by the
D7 lint block BEFORE this seam's gate runs (`saveMetaItem` runs
`assertRuntimeAuthoringRules` first). The lint door refuses the same
defect as `422 INVALID_METADATA` / `security-external-wider-than-internal`
— and refuses an unauthored `sharingModel` outright
(`security-owd-unset`): under the same ruling, **absence is not a
decision** at the runtime object door, where this gate's R2 had silently
resolved it to `private`. R2's only non-shadowed refusals were false
positives: a system object (`isSystem` / `sys_*`) with no authored
`sharingModel` is effectively PUBLIC at runtime
(`effectiveSharingModel`, plugin-sharing), so R2's hardcoded private
baseline refused pairs that are not external-wider at runtime; and
draft-state saves, which the lint discipline deliberately defers to the
draft→active promotion gate (#4463 D1) — nothing enforcement-reads a
draft body.
- **R1 (`403 owd_widening_forbidden`, env-tighten-only over a packaged
declaration, ADR-0086 D1) STAYS.** No lint rule can judge it: it compares
the write against the packaged DECLARATION, a deployment fact only this
seam holds.

Door order, as pinned in `packages/rest/src/meta-object-owd-gate.test.ts`:
the 422 lint gate answers first; this seam's 403 R1 door answers for writes
that pass lint.
49 changes: 16 additions & 33 deletions packages/lint/src/authoring-rules.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -1137,38 +1137,21 @@ export const AUTHORING_RULES: readonly AuthoringRule[] = [
// `security-master-detail-ungranted` per-write vs 4 whole-stack,
// PR #7886). `RuntimeStackContext` now carries `permissions`/`books` in
// BOTH differential passes and `TYPE_TO_STACK_KEY` maps both types.
// - #8310 (this state): `runtimeTypes` gains `permission` + `book`.
// `object` measured DIRTY and stays behind — see below.
//
// Why `object` is still not declared, re-measured on the #8308-repaired
// tree rather than inherited (#4001: zero breakage is demonstrated, never
// assumed). The OLD blocker is genuinely gone: with `object` declared, the
// full `@objectstack/metadata-protocol` suite passes (the 26-refusal
// measurement predates #8308's `METADATA_CREATE_SEEDS.object` repair) and a
// replay of every shipped-corpus object through the real gate refuses
// nothing. But one package over the same declaration still breaks the
// platform's own write paths — measured on this exact tree:
//
// - `@objectstack/objectql`: 83 tests across 13 files fail, every one
// `security-owd-unset` (85 refusals) — the suites publish objects with
// no authored `sharingModel` through `saveMetaItem`.
// - `@objectstack/rest`: 12 tests across 3 files — the same owd-unset
// class, PLUS two genuine CONTRACT collisions no fixture edit can
// honestly settle: `meta-object-owd-gate.test.ts` pins #7674's ADR-0094
// 403 `owd_external_wider` door, which this 422 gate now PREEMPTS for
// the same defect (`saveMetaItem` runs this table first), and it pins
// that a write with NO OWD keys at all SAVES (ADR-0094 reads absence as
// the D1 `private` default) — which `security-owd-unset` exists to
// refuse (absence must be an authored decision).
//
// So declaring `object` is not a wiring fix and not even only fixture
// repair in two packages outside this card's surface: it is a decision
// about which door answers for OWD defects (403 ADR-0094 vocabulary vs
// 422 lint vocabulary) and whether an unauthored OWD refuses at runtime.
// That decision is escalated on #8310; until it is ruled, `object` stays
// undeclared and the pins in
// `validate-security-posture.runtime-surface.test.ts` record both what
// WOULD happen (via the gate's own snapshot builder) and that it does not.
// - #8310 slice 1: `runtimeTypes` gains `permission` + `book` (PR #8546).
// `object` measured DIRTY on that tree and was escalated, not forced.
// - #8310 slice 2 (this state): `object` crosses under the maintainer
// ruling recorded on #8310 (2026-08-13, 「接受你的全部建议」): an
// authored OWD is REQUIRED at the runtime object door — an object
// publish with no authored `sharingModel` is refused with the 422 lint
// envelope (`security-owd-unset`); absence is not a decision. The ~16
// objectql/rest suite files that relied on OWD-less publishes were
// repaired honestly (fixtures author their posture), and
// `meta-object-owd-gate.test.ts` re-pins the door ORDER: this table
// answers first (`saveMetaItem` runs it before `runAuthoringGate`), the
// ADR-0094-seam 403 doors answer for what passes lint. The same ruling
// retired the plugin gate's R2 `owd_external_wider` arm as a duplicate
// of this door (R1 env-tighten-only STAYS — no lint rule covers it);
// see `object-posture-gate.ts` and the ADR-0094 amendment.
//
// `security-role-word` is NOT in this entry any more — that is what the
// `validateSecurityRoleWord` entry below records. It judges six collections
Expand All@@ -1195,7 +1178,7 @@ export const AUTHORING_RULES: readonly AuthoringRule[] = [
commands: ALL,
source: 'packages/lint/src/validate-security-posture.ts',
surfaces: CLI_AND_RUNTIME,
runtimeTypes: ['seed', 'permission', 'book'],
runtimeTypes: ['seed', 'permission', 'book', 'object'],
run: (stack) => validateSecurityPosture(stack),
},
// [ADR-0090 D3 / #8310] The vocabulary freeze, split out of
Expand Down
Loading
Loading