---
id: viewer-context
class: b2
sourced: 2026-07-12
source: "b2b-ux-patterns ch.4"
license: open
---
# Pass: access & viewer context

**Inputs:** `denialKind` (role | plan), `entitlementKind` (boolean | metered), `fieldLevelSecurity`, `requiredFieldHidden`, `existenceLeakRisk`, `viewerKind` (human | agent), `concurrentViewers`, `collaboration`, `environmentSeparation`.
**Owns:** `requiredMechanics`, `clarifications`.
**Output artifact:** the access model (who can see and do what, at which tier) and the viewer-context rules — what changes per viewer even among users with identical permissions. The governing axiom: **every render is a function of (data, viewer)**; no page, form, table, or button set is universal.

## The rule

**Access is a union, not a role lookup:** role baseline + additive permission sets + record-level sharing (ownership, hierarchy rollup, territory) + field-level security. Two users with the same role legitimately see different rows, fields, and actions — normal operation, not an edge case. Field-level security silently reshapes every surface: a hidden field vanishes from detail, form, column picker, filter builder, export, and API — so forms and tables render from schema + viewer access, never a hard-coded field list. The classic trap: a field *required* for the object but *hidden* from a role makes creation impossible for that persona — validate layouts per role. "Read-only" is often an access artifact, not a page property.

**Role-denied and plan-denied render differently.** Role-denied → hidden (or disabled where discoverability matters); never sell a user a feature their admin withheld. Plan-denied → visible but locked with an upgrade path; metered entitlements get the quota render ("4 of 5 seats used") with its own upgrade door. Conflating the two produces invisible revenue or unusable noise.

**The existence contract:** denied must never render as empty — users conclude the data is missing and re-create it. Decide per object whether a forbidden record admits existence (explicit no-access, ideally with a request path) or denies it (not-found, the GitHub-404 move) when even revealing existence leaks information.

**Collaboration is personalization of data display**, not a feature layer: ownership decoration, watch state and fresh-activity markers, viewer-relative filters ("Assigned to me" is typically the most-used view — a default, not a user-built filter), read/unread state, presence and edit-conflict warnings. Notification records are ordinary child objects emitted by watch/assign/mention verbs; their delivery matrix lives in `standard-pages`.

**Agent principals join the union** with their own permissions and audit trail, carrying a delegated-subject chain ("acting for whom") on every read and write — effective access is the intersection of the agent's grants and its delegating subject's, never a stale copy. "Delegated to my agents" is a distinct filter from "Assigned to me". Impersonation/view-as (with a persistent banner and full audit logging) is the only honest debugger once record- and field-level access exist; a test-vs-live environment fork needs a loud persistent banner — acting in the wrong environment is one of the costliest B2B errors.

```json decision-table
{"pass": "viewer-context", "rows": [
  {"when": {"denialKind": "role"}, "then": {"requiredMechanics": {"mustInclude": ["hidden-or-disabled-affordance"], "mustNotInclude": ["visible-locked-upsell"]}}, "reason": "role-denied-hides"},
  {"when": {"denialKind": "plan", "entitlementKind": "boolean"}, "then": {"requiredMechanics": {"mustInclude": ["visible-locked-upsell"], "mustNotInclude": ["hidden-or-disabled-affordance"]}}, "reason": "plan-denied-sells"},
  {"when": {"denialKind": "plan", "entitlementKind": "metered"}, "then": {"requiredMechanics": {"mustInclude": ["metered-quota-render", "upgrade-door"]}}, "reason": "metered-entitlement-render"},
  {"when": {"fieldLevelSecurity": true}, "then": {"requiredMechanics": {"mustInclude": ["schema-driven-rendering", "per-viewer-field-filtering"], "mustNotInclude": ["hard-coded-field-list"]}}, "reason": "fls-reshapes-every-surface"},
  {"when": {"requiredFieldHidden": true}, "then": {"requiredMechanics": {"mustInclude": ["per-role-layout-validation"]}}, "reason": "required-hidden-field-blocks-persona"},
  {"when": {"existenceLeakRisk": true}, "then": {"requiredMechanics": {"mustInclude": ["not-found-render"], "mustNotInclude": ["denied-renders-as-empty"]}}, "reason": "existence-itself-leaks"},
  {"when": {"existenceLeakRisk": false, "denialKind": "role"}, "then": {"requiredMechanics": {"mustInclude": ["explicit-no-access-render", "request-access-path"], "mustNotInclude": ["denied-renders-as-empty"]}}, "reason": "denied-never-reads-empty"},
  {"when": {"viewerKind": "agent"}, "then": {"requiredMechanics": {"mustInclude": ["agent-principal-access-union", "delegated-subject-chain", "agent-audit-trail", "delegated-to-my-agents-filter"]}}, "reason": "agent-is-a-governed-principal"},
  {"when": {"collaboration": "concurrent"}, "then": {"requiredMechanics": {"mustInclude": ["viewer-relative-filters", "assigned-to-me-default", "read-unread-state"]}}, "reason": "render-is-f-data-viewer"},
  {"when": {"concurrentViewers": true}, "then": {"requiredMechanics": {"mustInclude": ["presence-indicators", "edit-conflict-warning"]}}, "reason": "live-viewer-context"},
  {"when": {"environmentSeparation": true}, "then": {"requiredMechanics": {"mustInclude": ["environment-banner"]}}, "reason": "wrong-environment-costliest-error"}
]}
```
