---
status: proposed
related:
  - backend/internal/controlplane/packspec/standard_objects_spec.json
  - backend/internal/controlplane/packs/platform_core_test.go
  - backend/internal/features/analytics/query_service.go
  - backend/internal/features/permissions/permission_seeder.go
  - backend/internal/features/permissions/service.go
  - backend/internal/features/permissions/record_access.go
  - backend/internal/interfaces/rest/sobject_describe.go
  - backend/internal/features/records/nonpoly_lookup_validator.go
  - shared/pkg/models/metadata.go
---

# Composite Object Access Policy Design

## Purpose

This design fixes the root cause behind `OpportunityContactRole` and similar
standard objects whose permission surface is not "generic ObjectPermissions on
self + generic FieldPermissions on self + row visibility from SharingModel".

The immediate consequence for PR #1993 is narrow scope: `OpportunityContactRole`
must stay out of the current metadata-only `ControlledByParent` cutover.

The durable fix is a new generic metadata capability that lets object and field
access be authored as a composition of existing canonical permissions.

## Salesforce Baseline and Candidate Paths

### Salesforce Baseline

Salesforce documents `OpportunityContactRole` access as a composite of
canonical object permissions:

- view OCR: `Opportunity.Read AND Contact.Read`
- add/edit OCR: `Opportunity.Edit AND Contact.Read`

Source:

- Salesforce Help:
  <https://help.salesforce.com/s/articleView?id=sales.sales_core_opp_contact_add_manage.htm&language=en_US&type=5>

The repo already recognized the same class of problem for `CaseComment`:
objects with a non-generic permission surface should be deferred until that
surface is modeled rather than silently opting into generic
`ObjectPermissions` / `FieldPermissions`
(`backend/internal/controlplane/packs/platform_core_test.go:548`).

### Candidate Paths Considered

1. Keep `OpportunityContactRole` as a normal `standard_object` with
   `sharingModel=ControlledByParent`.
   Rejected. This preserves only the row-level parent cascade and silently
   replaces Salesforce's composite object gate with an OCR-self gate.

2. Add a runtime special case such as
   `if objectName == "OpportunityContactRole"`.
   Rejected. That hardcodes a product fact into L0 and fails the admin
   self-service test.

3. Reuse `RequiredReadPermission` / `RequiredWritePermission`.
   Rejected. The current primitive only expresses one system-permission string
   and cannot encode `Opportunity.Read AND Contact.Read`
   (`shared/pkg/models/metadata.go:225`).

4. Add a generic metadata-authored composite access policy on `_System_Object`.
   Chosen. This keeps the fact in metadata, makes the runtime a generic
   evaluator, and supports the next standard object with the same shape.

## Repo Evidence Checked

- Generic object CRUD currently resolves against the target object's own
  effective `ObjectPermissions` row in
  `backend/internal/features/permissions/service.go:240` and
  `backend/internal/features/permissions/service.go:294`.
- Global object discovery (`GET /sobjects`) exposes an object if that same
  object-level `Read` check passes in
  `backend/internal/interfaces/rest/sobject_describe.go:72`.
- Analytics/query gates also use the same object-level check before building
  RLS in `backend/internal/features/analytics/query_service.go:64` and
  `backend/internal/features/analytics/query_service.go:77`.
- `ControlledByParent` today affects row-level visibility only. Access is
  derived from the parent record in
  `backend/internal/features/permissions/record_access.go:101`.
- Generic writes already route through the same object-level gate in
  `backend/internal/features/records/persistence_service.go:264`.
- Lookup validation can later fail a write if the referenced `Contact` is not
  accessible, but that is post-surface validation, not the product's primary
  object access rule
  (`backend/internal/features/records/nonpoly_lookup_validator.go:96`).
- Permission seeding currently creates self `ObjectPermissions` and self
  `FieldPermissions` rows for every schema passed to it in
  `backend/internal/features/permissions/permission_seeder.go:51`.

## Root Cause

The platform has no generic way to say:

- object `X` is visible when some predicate over other canonical permissions is
  true
- object `X` does not own an independent `ObjectPermissions` / `FieldPermissions`
  surface

Today `_System_Object` can express:

- self object CRUD through `ObjectPermissions`
- self field access through `FieldPermissions`
- setup-object gates through `RequiredReadPermission` and
  `RequiredWritePermission`
- row visibility through `SharingModel` and `IsSharingParent`

It cannot express a composite object surface such as
`Opportunity.Read AND Contact.Read`.

As a result, adding `OpportunityContactRole` to
`standard_objects_spec.json` silently opts it into the wrong product model.

## Design Goals

- keep the product boundary Salesforce-compatible
- keep the authoring site in metadata, not code
- ensure one fact has one evaluator
- avoid object-name branches in generic engines
- let the next standard object reuse the same capability without a code fork

## Non-Goals

- no changes to row-level sharing semantics in this design
- no query-only or UI-only patches
- no permanent runtime compatibility branches for historical bad rows
- no tenant-specific escape hatch

## Proposed Metadata Capability

Add an optional `AccessPolicy` column to `_System_Object` and surface it on
`models.ObjectMetadata`.

Default behavior stays unchanged:

- `AccessPolicy = null` means the object uses today's self
  `ObjectPermissions` / `FieldPermissions` model

When `AccessPolicy` is present, object and field access is evaluated from the
policy instead of self permission rows.

### Suggested Shape

```json
{
  "mode": "derived",
  "objectRead": {
    "allOf": [
      { "kind": "objectPermission", "object": "Opportunity", "operation": "Read" },
      { "kind": "objectPermission", "object": "Contact", "operation": "Read" }
    ]
  },
  "objectCreate": {
    "allOf": [
      { "kind": "objectPermission", "object": "Opportunity", "operation": "Edit" },
      { "kind": "objectPermission", "object": "Contact", "operation": "Read" }
    ]
  },
  "objectEdit": {
    "allOf": [
      { "kind": "objectPermission", "object": "Opportunity", "operation": "Edit" },
      { "kind": "objectPermission", "object": "Contact", "operation": "Read" }
    ]
  },
  "fieldRead": { "inherit": "objectRead" },
  "fieldEdit": { "inherit": "objectEdit" }
}
```

### Term Types

The first version only needs generic terms that already map to canonical
platform concepts:

- `objectPermission`
  - `object`
  - `operation`
- `systemPermission`
  - `apiName`
- boolean composition
  - `allOf`
  - `anyOf`
  - `not`
- `inherit`
  - reuse the result of another operation (`fieldRead <- objectRead`,
    `fieldEdit <- objectEdit`)

This is enough for:

- `OpportunityContactRole` (`Opportunity` + `Contact`)
- future `CaseComment`-class objects (`Case` + system permission terms)

## Declared Delta from Salesforce

Salesforce does not expose its internal storage representation for this access
surface. NexusCRM will store the equivalent product contract as JSON metadata on
`_System_Object`.

That is an engine-storage delta, not a product-boundary delta. The external
contract remains:

- same required permissions
- same discoverability gate
- same create/edit gate
- same row-level visibility model

This delta is justified because the platform needs a generic admin-authored
representation of the same fact, and the current runtime has no reusable
primitive for it.

## Runtime Design

### 1. Metadata Parsing

Extend:

- `shared/pkg/models/metadata.go`
- `backend/internal/domain/schema/definitions.go`
- metadata scan / persistence structs for `_System_Object`

with a new `AccessPolicy` field and typed JSON model.

### 2. One Evaluator

Add one shared evaluator in the permissions subsystem:

```go
EvaluateObjectAccessPolicy(ctx, schema, operation, user) (bool, error)
```

Rules:

- if `schema.AccessPolicy == nil`, fall back to the current self
  `ObjectPermissions` / `FieldPermissions` behavior
- if `schema.AccessPolicy != nil`, evaluate the declared predicate tree
- nested `objectPermission` terms recursively call the same canonical object
  gate for the referenced object
- `systemPermission` terms reuse the existing system-permission evaluator

### 3. Existing Entry Points Reuse the Evaluator

The following surfaces should not gain object-name branches; they should keep
calling the same canonical object gate:

- `CheckObjectPermissionWithUser`
- global describe (`GET /sobjects`)
- query / analytics read gate
- retrieve / insert / update / delete gate

This preserves "same fact, same helper".

### 4. Field Access

If `AccessPolicy.mode == derived` and `fieldRead` / `fieldEdit` are omitted,
field access inherits object access and the object has no independent
`FieldPermissions` surface.

If a future object needs a distinct field predicate, it can author one
explicitly in metadata.

## Seeder Design

Seeder behavior must also read the same metadata fact:

- `AccessPolicy == nil`:
  seed self `ObjectPermissions` and self `FieldPermissions` rows exactly as
  today
- `AccessPolicy.mode == derived`:
  do not seed self `ObjectPermissions` or self `FieldPermissions` rows for that
  object

This prevents a split SSoT where the runtime reads a derived policy but the
database still advertises an independent self permission surface.

Historical self permission rows for migrated objects must be removed by a
`data-specs/` cleanup, not by long-lived runtime compatibility code.

## Current PR Impact

PR #1993 should not ship `OpportunityContactRole` as a generic
`ControlledByParent` standard object.

That PR may still carry metadata-only additions whose product boundary already
matches today's generic model, but OCR must wait for this capability.

## Rollout Plan

1. Narrow the current PR and keep `OpportunityContactRole` out of the cutover.
2. Add `_System_Object.AccessPolicy` with no behavior change for existing
   objects.
3. Add the shared evaluator and wire `CheckObjectPermissionWithUser` to it.
4. Teach the seeder to skip self permission rows for derived policies.
5. Add a data spec that deletes historical OCR self permission rows.
6. Re-introduce `OpportunityContactRole` using metadata-authored composite
   access policy.
7. Evaluate `CaseComment` and other delegated-surface objects on the same
   primitive instead of adding one-off code.

## What Changes for the Next Tenant

Nothing tenant-specific is added to platform code.

The next tenant, or the next Salesforce-standard object with a composite access
surface, gets a reusable authoring model:

- define the object's access surface in metadata
- do not add a Go branch
- do not modify query, describe, or write engines independently

Greenfield orgs that never use OCR are unaffected.

## Verification

The implementation PR must include:

1. evaluator unit tests for `allOf`, `anyOf`, `not`, `inherit`,
   `objectPermission`, and `systemPermission`
2. object-surface tests proving OCR:
   - is absent from `/sobjects` without `Contact.Read`
   - cannot be queried with `Opportunity.Read` alone
   - becomes queryable only with `Opportunity.Read AND Contact.Read`
3. write-path tests proving OCR create/edit require
   `Opportunity.Edit AND Contact.Read`
4. row-level tests proving that after object access passes, OCR record
   visibility still follows `Opportunity` through `ControlledByParent`
5. seeder tests proving OCR no longer receives self `ObjectPermissions` /
   `FieldPermissions` rows under a derived policy
6. a migration verification step proving the cleanup data spec removes legacy
   self rows and leaves no alternate live write path

Without those tests, the implementation remains unverified.
