ADR-0011: app.clearance GUC as a UX-projection primitive
- Status: Proposed (awaiting founder sign-off — new platform primitive)
- Date: 2026-04-22
- Decision owners: Principal Architect (proposer), Founder (sign-off), Backend SME (implementer), Frontend SME (consumer)
- Related: HLD #831, PRD #824 §5, D1.2 Backend LLD (forthcoming), migration 017 (
agent_audit_log),internal/gateway/scope.go(existingapp.*GUC wiring), client#110 (use-dashboard-datacanonical pattern)
Context
HLD #831 §3.3 chose a SQL view (activity_feed_v1) over agent_audit_log as the backing source for ListActivityFeed. The feed must be scoped per clearance:
| Clearance | Visibility (PRD #824 §5) |
|---|---|
| L1 | Own agent's actions |
| L2 | Own + direct-report agents' actions |
| L3 | Team-scoped actions |
| L4 | Pillar-scoped actions |
| L5 | Org-wide actions |
Today, the gateway scope middleware (internal/gateway/scope.go:setHTTPGUCs) sets 6 session-local GUCs on the pgx transaction that every RLS policy references:
app.org_id, app.pillar_id, app.team_id, app.agent_id,
app.caller_scope, app.caller_scope_id
Wave D (member/role work) added two more (app.member_id, app.global_role_ids) for the app_has_role() RLS helper.
The feed's clearance-aware projection needs one more piece of session-local state — the caller's clearance level (1–5) — so the view can expose WHERE clauses like:
CASE current_setting('app.clearance')::int
WHEN 5 THEN TRUE -- L5: all org rows
WHEN 4 THEN pillar_id = current_setting('app.pillar_id')::uuid
WHEN 3 THEN team_id = current_setting('app.team_id')::uuid
WHEN 2 THEN agent_id IN (...direct reports...) -- resolved at query time
ELSE agent_id = current_setting('app.agent_id')::uuid -- L1 default
END
Three alternatives were considered:
Option A — pass clearance as an RPC field
Every analytics RPC gains a clearance_level request field; the Go handler binds it into each SQL query.
- Con: the field is self-declared by the client. A malicious or buggy caller can request L5 visibility regardless of their real clearance. Defeats the purpose.
- Con: couples every handler to clearance plumbing. Every new analytics RPC must remember to thread it through; easy to forget.
- Con: doesn't work for views — a view can't accept a parameter. Forces us to drop the SQL-layer projection approach HLD §3.3 preferred.
Option B — JOIN against members inside the view
The view JOINs members on the caller's member_id (already in app.member_id) to look up their clearance.
- Pro: no new primitive.
- Con: adds a JOIN on every feed read. Page-1 reads are already hot;
membersis a medium table and the JOIN predicate is a UUID equality, so the plan should be cheap — but it's a new row-level dependency and a subtle schema coupling between the view and the members table. - Con: makes the view non-relocatable. Moving audit data to a different store (e.g. a future
activity_feedmaterialised table) would require re-plumbing the JOIN. - Con: the view now silently depends on
membershaving current RLS state. Any future Wave that touchesmembersRLS has to re-verify the feed view's projection.
Option C — set app.clearance alongside the other app.* GUCs in scope middleware (chosen)
The scope middleware already resolves clearance during tenant-context materialisation (internal/gateway/tenant_context.go:29, field ClearanceLevel int32, clamped 0–5). The GUC wiring just threads that value into one more set_config('app.clearance', ...) call on the same transaction.
- Pro: same wire pattern as every existing
app.*GUC. No new concept. - Pro: caller cannot forge the value — it's computed server-side from verified identity state (Clerk metadata + member DB row).
- Pro: view stays self-contained; projection logic lives in SQL and inherits RLS from
agent_audit_log. - Pro: future analytics views can reuse the same primitive for free — no per-caller parameter threading.
- Con: adds one more session-local GUC to the platform's contract. Every future reader of the view must run through the scope middleware (already true for all gRPC/HTTP call paths).
Decision
Adopt Option C. Add app.clearance as a session-local GUC set by setHTTPGUCs / setGUCs in the scope middleware, in the same transaction that sets app.org_id and the other scope GUCs. Value source: the existing authctx.ClearanceLabel / tenant_context.ClearanceLevel pipeline — no new identity plumbing.
Boundary — what this primitive is for
- IS: a UX-projection signal. Views and projections can read it to shape what rows the caller sees in their dashboard.
- IS: server-side-computed, caller-unforgeable, in the same verification chain as
app.org_idandapp.member_id. - IS NOT: a real authorisation gate. Whether a caller is allowed to perform an action (approve a budget, delete a team, promote a member) is still evaluated by Go-side clearance checks in the action handler — typically
clearance.Require(L5)or equivalent. Those checks remain the source of truth and do not move to SQL. - IS NOT: a substitute for RLS.
agent_audit_logRLS still enforcesorg_id = app.org_id.app.clearanceonly refines within-org visibility.
Failure mode
If the GUC is unset or malformed (e.g. empty string, non-integer, out-of-range), the view's CASE expression falls through to the default branch — agent_id = app.agent_id, i.e. the most restrictive L1 projection. Failing closed matches existing fail-soft conventions in setTenantGUCs (see internal/gateway/scope.go:206).
Consequences
Positive
- One canonical place to resolve clearance.
tenant_context.goalready clamps 0–5 and prefers the DB value over the Clerk claim; the GUC inherits that work. - Additive change — no impact on existing RLS policies or call sites. Views that don't read
app.clearanceare unchanged. - Scales to future analytics RPCs. Any future view that needs clearance-aware filtering (e.g. anomaly feed, audit export, approval queue) reuses the primitive.
- Matches the "data travels with the transaction" model the platform already uses for
app.org_id+ Wave D GUCs. No new mental model.
Negative
- One more piece of session-local state for agents to remember when reasoning about queries. Mitigation: documented here + in the view's header comment.
- If misused — e.g. a future contributor writes an action handler that reads
app.clearancedirectly in SQL and uses it as a write-gate — the primitive silently extends beyond UX projection. Mitigation: lint-style guard in review (architect + code review checklist); also prose boundary in the view header.
Neutral
- Admin portal reads that bypass the client-portal scope middleware (e.g. batch ops run under a service identity) will have
app.clearanceunset → view falls through to L1 projection → rows hidden. Acceptable: admin should not read the client-portal feed view; admin has its own audit reader that readsagent_audit_logdirectly with an explicitapp.clearance = '5'call or no projection at all.
Implementation notes (for D1.2 LLD)
- Extend
setHTTPGUCsininternal/gateway/scope.goto setapp.clearancefromauthctx.ClearanceLabel(ctx)(or the underlyingClearanceLevel int32). Mirror ininternal/gateway/scope/middleware.go:setGUCsif the gRPC middleware exists separately. - Default value when clearance is 0 / unknown:
'1'(most restrictive). Never leave empty — the view'sCASE ELSEbranch assumes a parseable int. - Integration-test coverage: extend
test/integration/context/setguc_test.go(or equivalent) to assertapp.clearanceis set on the transaction, clamped to 1–5, and defaults to 1 on missing input. - Documentation: the view's header SQL comment and this ADR reference each other by number.
Rollback
Revert the set_config('app.clearance', ...) line in setHTTPGUCs + the view's projection is dropped from the D1.2 migration's down-path. No downstream data changes; GUCs are session-local and leave no artefacts.
Out of scope
- Re-baselining existing analytics RPCs onto
app.clearance(they already filter by scope viascope_level/scope_idparams — sufficient for MVP). - Writing GUCs to admin-portal audit paths (admin uses its own service identity and reads the base table directly).
- Using
app.clearancein write-side authorisation. All write gates stay in Go.