Task — engineering-spec@1

"PORTAL scoped read-only views — PROJ/INV/DOC/CHAT filtered by Engagement membership + sync_class=client-visible + per-row redaction + GraphQL-style projection"

draftTASK-PORTAL-001
module portal · class product · priority p0 · created 2026-05-17 · shipped null
depends on TASK-TEN-101 · blocks TASK-PORTAL-007, TASK-PORTAL-008

§1 — Description (BCP-14 normative)

The PORTAL service MUST ship scoped read-only views at services/portal/src/views/ over the PROJ/INV/DOC/CHAT modules, filtered by Engagement membership AND sync_class='client-visible' per TASK-MEMORY-106, with per-row redaction, field projection, cursor pagination, full-text search, CSV/XLSX export, and 6 memory audit kinds.

  1. MUST define the closed portal_view_kind enum at migration 0014: ('projects','invoices','documents','channels','calendar') per DEC-1201. CI cardinality test asserts 5. The calendar view is a slice-2 stub returning 501.
  1. MUST create SQL views per portal_view_kind via migration 0014. Each view is a CREATE VIEW portal_view_<kind> AS SELECT ... FROM <source_table> WHERE sync_class IN ('client-visible','client-visible-redacted') AND tenant_id = current_setting('auth.tenant_id')::uuid AND engagement_id IN (SELECT engagement_id FROM engagement_memberships WHERE subject_id = current_setting('auth.subject_id')::uuid). RLS predicate inherits from each source table's RLS plus the engagement-membership join.
  1. MUST define portal_view_read_log at migration 0015: (id BIGSERIAL PRIMARY KEY, tenant_id UUID NOT NULL, engagement_id UUID NOT NULL, caller_subject_id UUID NOT NULL, view_kind portal_view_kind NOT NULL, resource_id UUID, action TEXT NOT NULL CHECK (action IN ('list','detail','search','export_initiated','export_completed')), filter_hash16 TEXT, result_count INT, trace_id CHAR(32), occurred_at TIMESTAMPTZ NOT NULL DEFAULT now()). Append-only per task-audit skill rule 12. RLS-scoped.
  1. MUST expose list endpoint GET /v1/portal/views/{view_kind}?engagement_id=...&filters=...&fields=...&cursor=...&limit=... per DEC-1205. Handler:
  1. MUST expose detail endpoint GET /v1/portal/views/{view_kind}/{id} per DEC-1207. Handler:
  1. MUST expose search endpoint POST /v1/portal/views/{view_kind}/search per DEC-1206 with body { query: <string>, filters?: {...}, fields?: [...], cursor?: ... }. Handler:
  1. MUST expose export endpoint GET /v1/portal/views/{view_kind}/export?format=csv|xlsx&filters=...&fields=... per DEC-1213. Handler:
  1. MUST apply per-row redaction per DEC-1203 + DEC-1215. Rows with sync_class='client-visible-redacted' returned with internal-only fields blanked:
  1. MUST support per-view kind-specific filters per DEC-1211. Filter param format ?filters=<base64-JSON> decodes to:
  1. MUST apply GraphQL-style field projection per DEC-1204. The fields query param decodes to a comma-separated list (or JSON array via base64). Handler:
  1. MUST enforce per-Engagement context per DEC-1209. engagement_id query param REQUIRED on every endpoint. Without it → 400 + engagement_id_required. Caller member of N Engagements switches via UI engagement-picker; views never aggregate.
  1. MUST apply cursor pagination per DEC-1219. The cursor is base64(JSON {last_id: uuid, last_sort_value: <value>}). Pagination handler:
  1. MUST include ETag + Cache-Control headers per DEC-1217 + DEC-1218. ETag = SHA-256 truncated 16 hex of canonical-JSON response body; If-None-Match match → 304. Cache-Control private, max-age=30 allows browser-side cache; per-user RLS prevents shared-cache leak.
  1. MUST rate-limit at 600 reads/min/caller per DEC-1210 + task-audit skill §8.2d derivative. Exceeded → 429 + Retry-After.
  1. MUST emit 6 memory audit row kinds per DEC-1214:
  1. MUST PII-scrub per task-audit skill rule 18. Audit rows carry filter_hash16 + resource_id (UUID; non-PII per TASK-PORTAL-004 §1 #18 rationale); raw filter values + result content NOT in chain.
  1. MUST thread trace_id end-to-end per task-audit skill rule 22-24.
  1. MUST stream exports in chunked-transfer encoding per §1 #7. Memory footprint bounded — no full-result-set buffering.
  1. MUST enforce read-only — view endpoints never accept POST/PUT/PATCH/DELETE (returns 405). Writes route through TASK-PORTAL-006 workflows.
  1. MUST validate sync_class filter is enforced at the SQL view level per DEC-1202 + TASK-MEMORY-106. Handler does NOT trust client-supplied sync_class filter; the view's predicate is the gate.
  1. MUST NOT auto-aggregate across Engagements per DEC-1209. Each request scoped to one engagement_id.
  1. MUST NOT allow shared HTTP cache (proxies, CDNs) per DEC-1217. Cache-Control: private mandatory; CDN edges MUST NOT cache.
  1. MUST NOT return rows with sync_class IN ('private','team-internal') per TASK-MEMORY-106. View definition enforces; handler-side check defense-in-depth.
  1. SHOULD observe per-Engagement read volume via OTel histogram portal_view_read_total{engagement_id, view_kind}.

§2 — Why this design (rationale for humans)

Why read-only at slice 1 (§1 #19, DEC-1200)? Read-only is simpler to make correct: no transaction complexity, no validation, no cascade revocation. Writes via PORTAL-006 workflows give us audit-trail control (client-initiated request → CHAT thread → CyberOS-side action). Read-only views + write workflows = clean separation of read scale from write trust.

Why sync_class='client-visible' enforced at SQL view (§1 #2, DEC-1202)? Defense-in-depth — handler-only enforcement leaks if handler bug. SQL view enforcement is the floor; handler is the ceiling. Both must agree; either alone catches bugs in the other.

Why per-row redaction (vs row omission) (§1 #8, DEC-1215)? Omitting a row "blinds" the client — they don't know the row exists. Showing a redacted row is honest: "we have data on X but you can't see all of it". Better UX + same data security.

Why per-Engagement context required (§1 #11, DEC-1209)? Multi-Engagement caller seeing one mega-list is confusing UX (which client is which?) + auditor's "who saw what" becomes unclear (one query → 50 Engagement rows = which one was the user actually looking at?). Forcing per-Engagement scope makes the audit answerable.

Why CSV + XLSX export but capped 10k (§1 #7, DEC-1213)? Clients legitimately want offline copies of their data (compliance backup, accounting reconciliation). CSV/XLSX are universal formats. 10k cap balances UX (covers 95% of legitimate needs) vs DOS prevention. DSAR (TASK-PORTAL-008) handles full-archive exports.

Why GraphQL-style field projection (§1 #10, DEC-1204)? Mobile clients on metered connections want minimal payload. Full row = 5-20 KB; projected row = 200-500 bytes. 90%+ bandwidth saving. Standard pattern (GraphQL, Sparse Fieldsets in JSON:API).

Why cursor pagination over offset (§1 #12)? Offset breaks at scale (slow scan past LIMIT offset N); cursor uses keyset (index lookup). Standard pattern for large datasets.

Why per-Engagement filter at SQL view + RLS (§1 #2)? Combined enforcement: tenant_id from current_setting (RLS) + engagement_id from join. Single-condition would leak if either piece is misconfigured.

Why default sync_class='private' (DEC-1216)? Default-deny is the security default. A new row appearing in a source module is invisible to clients until explicitly promoted. Default-allow would leak every internal note.

Why sampled view-read audit (§1 #15, DEC-1208)? Browsing = high-volume reads (every page render = 10s of view calls). Sampling at 1% gives statistical observability without exploding the chain. Detail reads (always emitted) + search (sampled 5%) provide the audit signal for specific resource access.

Why detail reads always emitted (§1 #15)? Detail = "user saw row X". Forensic: "did the client view document 12345 before suing?" must be answerable. Always-emit guarantees.


§3 — API contract

3.1 Postgres schema

-- 0014_portal_view_definitions.sql
CREATE TYPE portal_view_kind AS ENUM ('projects','invoices','documents','channels','calendar');

-- Example: projects view
CREATE VIEW portal_view_projects AS
SELECT p.id, p.tenant_id, p.engagement_id, p.title, p.status,
       p.owner_external_id, p.created_at, p.last_activity_at,
       CASE WHEN p.sync_class = 'client-visible' THEN p.internal_notes ELSE NULL END AS internal_notes,
       CASE WHEN p.sync_class = 'client-visible' THEN p.assignee_internal_id ELSE NULL END AS assignee_internal_id,
       p.sync_class
FROM projects p
WHERE p.sync_class IN ('client-visible','client-visible-redacted')
  AND p.tenant_id = current_setting('auth.tenant_id')::uuid
  AND p.engagement_id IN (
    SELECT engagement_id FROM engagement_memberships
    WHERE subject_id = current_setting('auth.subject_id')::uuid
  );

-- Analogous CREATE VIEW for invoices, documents, channels.
-- All views are RLS-aware via the WHERE clauses (no separate RLS policy needed; the view IS the policy).

-- 0015_portal_view_read_log.sql
CREATE TABLE portal_view_read_log (
  id BIGSERIAL PRIMARY KEY,
  tenant_id UUID NOT NULL,
  engagement_id UUID NOT NULL,
  caller_subject_id UUID NOT NULL,
  view_kind portal_view_kind NOT NULL,
  resource_id UUID,
  action TEXT NOT NULL CHECK (action IN ('list','detail','search','export_initiated','export_completed')),
  filter_hash16 TEXT,
  result_count INT,
  trace_id CHAR(32),
  occurred_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX idx_view_read_caller ON portal_view_read_log(caller_subject_id, occurred_at DESC);
ALTER TABLE portal_view_read_log ENABLE ROW LEVEL SECURITY;
CREATE POLICY portal_view_read_log_rls ON portal_view_read_log
  USING (tenant_id = current_setting('auth.tenant_id')::uuid)
  WITH CHECK (tenant_id = current_setting('auth.tenant_id')::uuid);
REVOKE UPDATE, DELETE ON portal_view_read_log FROM cyberos_app;

3.2 REST endpoints

GET    /v1/portal/views/{view_kind}                              (list; cursor pagination)
GET    /v1/portal/views/{view_kind}/{id}                          (detail + sub-resources)
POST   /v1/portal/views/{view_kind}/search                        (search; cursor pagination)
GET    /v1/portal/views/{view_kind}/export?format=csv|xlsx        (streaming; 10k cap)

All require engagement_id query param.


§4 — Acceptance criteria

  1. portal_view_kind cardinality — enum = exactly {projects, invoices, documents, channels, calendar}.
  2. List filtered by sync_class — projects with sync_class='private' NOT in response; only 'client-visible' or 'client-visible-redacted'.
  3. Engagement isolation — caller member of Eng X + Y; list query for X returns ONLY X's rows; Y rows absent.
  4. Field projection?fields=id,title returns only those fields; ?fields=internal_notes rejected with 400 if not in safe set.
  5. Per-row redaction — row with sync_class='client-visible-redacted' returns with internal_notes=NULL.
  6. Cursor pagination — first page returns next_cursor; cursor returns next page; round-trip stable.
  7. Search — POST search returns ranked results scoped by RLS.
  8. CSV export?format=csv returns text/csv stream with headers row.
  9. XLSX export?format=xlsx returns valid xlsx binary.
  10. Export 10k cap — 10001 rows → 413 + export_too_large.
  11. Detail with sub-resourcesprojects/{id} returns row + tasks + comments + status_history.
  12. engagement_id required — list without engagement_id → 400 + engagement_id_required.
  13. Cross-Engagement engagement_id — list with engagement_id NOT in membership → 403.
  14. ETag 304 — second list with If-None-Match → 304.
  15. Cache-Control private — header on every 200 response.
  16. Rate limit 600/min — 601st read → 429.
  17. Read-only — POST/PUT/DELETE on view endpoint → 405.
  18. Calendar slice-2 stub/v1/portal/views/calendar → 501.
  19. 6 memory audit kinds emitted — full browse + detail + search + export + redaction lifecycle covers all 6.
  20. Filter hash in audit — filter_hash16 = SHA256-16 of canonical filter JSON; raw filter NOT in chain.

§5 — Verification

5.1 view_projects_list_test.rs

#[tokio::test]
async fn list_returns_only_client_visible() {
    let ctx = TestContext::with_engagement_subject().await;
    ctx.seed_project(SyncClass::Private, "private-project").await;
    ctx.seed_project(SyncClass::ClientVisible, "shared-project").await;

    let r = ctx.get_view("projects", ctx.eng_id).await;
    let body: serde_json::Value = r.json().await.unwrap();
    let titles: Vec<&str> = body["rows"].as_array().unwrap().iter().filter_map(|r| r["title"].as_str()).collect();
    assert!(titles.contains(&"shared-project"));
    assert!(!titles.contains(&"private-project"));
}

5.2 view_engagement_isolation_test.rs

#[tokio::test]
async fn engagement_y_rows_not_returned_when_filtering_x() {
    let ctx = TestContext::with_subject_in_two_engagements().await;
    ctx.seed_project_in_eng(ctx.eng_x, SyncClass::ClientVisible, "x-proj").await;
    ctx.seed_project_in_eng(ctx.eng_y, SyncClass::ClientVisible, "y-proj").await;

    let r = ctx.get_view("projects", ctx.eng_x).await;
    let titles: Vec<String> = ctx.extract_titles(r).await;
    assert!(titles.contains(&"x-proj".into()));
    assert!(!titles.contains(&"y-proj".into()));
}

5.3 view_per_row_redaction_test.rs

#[tokio::test]
async fn redacted_rows_have_internal_fields_nulled() {
    let ctx = TestContext::with_engagement_subject().await;
    ctx.seed_project_with_redacted_class("redacted-proj", "secret note").await;
    let r = ctx.get_view("projects", ctx.eng_id).await;
    let row = ctx.find_row(r, "title", "redacted-proj").await;
    assert!(row["internal_notes"].is_null());
    assert_eq!(row["title"], "redacted-proj");

    let audit = ctx.memory_rows().await;
    assert!(audit.iter().any(|r| r.kind == "portal.view_redaction_applied"));
}

5.4 view_field_projection_test.rs

#[tokio::test]
async fn fields_param_limits_response_shape() {
    let ctx = TestContext::with_engagement_subject().await;
    ctx.seed_project(SyncClass::ClientVisible, "p1").await;
    let r = ctx.get_view_with_fields("projects", ctx.eng_id, "id,title").await;
    let row = ctx.first_row(r).await;
    assert!(row.get("id").is_some());
    assert!(row.get("title").is_some());
    assert!(row.get("status").is_none());

    let r2 = ctx.get_view_with_fields("projects", ctx.eng_id, "internal_notes").await;
    assert_eq!(r2.status(), 400);
}

5.5 view_pagination_test.rs

#[tokio::test]
async fn cursor_round_trip_stable() {
    let ctx = TestContext::with_engagement_subject().await;
    for i in 0..150 {
        ctx.seed_project(SyncClass::ClientVisible, &format!("p{i}")).await;
    }
    let r1 = ctx.get_view_limited("projects", ctx.eng_id, 50).await;
    let body1: serde_json::Value = r1.json().await.unwrap();
    assert_eq!(body1["rows"].as_array().unwrap().len(), 50);
    let cursor = body1["next_cursor"].as_str().unwrap();

    let r2 = ctx.get_view_cursor("projects", ctx.eng_id, cursor).await;
    let body2: serde_json::Value = r2.json().await.unwrap();
    assert_eq!(body2["rows"].as_array().unwrap().len(), 50);
}

5.6 view_export_csv_test.rs

#[tokio::test]
async fn csv_export_streams_with_headers() {
    let ctx = TestContext::with_engagement_subject().await;
    for i in 0..100 { ctx.seed_project(SyncClass::ClientVisible, &format!("p{i}")).await; }
    let r = ctx.get_export("projects", ctx.eng_id, "csv").await;
    assert_eq!(r.headers()["content-type"].to_str().unwrap(), "text/csv");
    let body = r.text().await.unwrap();
    let lines: Vec<&str> = body.lines().collect();
    assert!(lines[0].contains("id,title,status"));
    assert_eq!(lines.len(), 101);  // header + 100 rows
}

5.7 view_export_size_cap_test.rs

#[tokio::test]
async fn export_over_10k_rejected() {
    let ctx = TestContext::with_engagement_subject().await;
    for i in 0..10001 { ctx.seed_project_fast(SyncClass::ClientVisible, &format!("p{i}")).await; }
    let r = ctx.get_export("projects", ctx.eng_id, "csv").await;
    assert_eq!(r.status(), 413);
    let body: serde_json::Value = r.json().await.unwrap();
    assert_eq!(body["error"], "export_too_large");
    assert_eq!(body["use_dsar"], true);
}

5.8 view_detail_with_subresources_test.rs

#[tokio::test]
async fn detail_includes_tasks_and_comments() {
    let ctx = TestContext::with_engagement_subject().await;
    let proj_id = ctx.seed_project(SyncClass::ClientVisible, "p1").await;
    ctx.seed_task_in_project(proj_id, SyncClass::ClientVisible, "t1").await;
    ctx.seed_comment_in_project(proj_id, SyncClass::ClientVisible, "c1").await;

    let r = ctx.get_view_detail("projects", proj_id, ctx.eng_id).await;
    let body: serde_json::Value = r.json().await.unwrap();
    assert_eq!(body["title"], "p1");
    assert_eq!(body["tasks"].as_array().unwrap().len(), 1);
    assert_eq!(body["comments"].as_array().unwrap().len(), 1);

    let audit = ctx.memory_rows().await;
    assert!(audit.iter().any(|r| r.kind == "portal.view_detail_read" && r.severity == 2));
}

5.9 view_kind_enum_cardinality_test.rs

#[tokio::test]
async fn view_kind_has_5_values() {
    let ctx = TestContext::new().await;
    let labels: Vec<String> = sqlx::query_scalar(
        "SELECT unnest(enum_range(NULL::portal_view_kind))::text"
    ).fetch_all(&ctx.pool).await.unwrap();
    let mut labels = labels; labels.sort();
    assert_eq!(labels, vec!["calendar","channels","documents","invoices","projects"]);
}

5.10 view_etag_caching_test.rs

#[tokio::test]
async fn etag_304_on_match() {
    let ctx = TestContext::with_engagement_subject().await;
    ctx.seed_project(SyncClass::ClientVisible, "p1").await;
    let r1 = ctx.get_view("projects", ctx.eng_id).await;
    let etag = r1.headers()["etag"].to_str().unwrap().to_owned();
    let r2 = ctx.get_view_with_if_none_match("projects", ctx.eng_id, &etag).await;
    assert_eq!(r2.status(), 304);
}

§6 — Implementation skeleton

// services/portal/src/views/mod.rs
pub async fn list_handler(ctx: AppCtx, jwt: JwtClaims, view_kind: PortalViewKind,
                          engagement_id: Uuid, filters: Filters, fields: FieldSet,
                          cursor: Option<Cursor>, limit: u32) -> Result<ListResp, ViewError> {
    // Validations
    require_engagement_membership(&ctx, jwt.subject_id, engagement_id).await?;
    rate_limit_check(&ctx, jwt.subject_id).await?;
    filters.validate_for_kind(view_kind)?;
    fields.validate_for_kind(view_kind)?;

    // Set session vars for RLS
    let mut conn = ctx.pool.acquire().await?;
    sqlx::query("SET LOCAL auth.subject_id = $1").bind(jwt.subject_id).execute(&mut *conn).await?;
    sqlx::query("SET LOCAL auth.tenant_id = $1").bind(jwt.tenant_id).execute(&mut *conn).await?;

    let (rows, next_cursor) = match view_kind {
        PortalViewKind::Projects => projects::list(&mut conn, engagement_id, &filters, &fields, cursor, limit).await?,
        PortalViewKind::Invoices => invoices::list(/* ... */).await?,
        /* ... */
        PortalViewKind::Calendar => return Err(ViewError::SliceUnavailable),
    };

    // Apply per-row redaction
    let redacted_rows: Vec<_> = rows.into_iter()
        .map(|r| redaction::apply(view_kind, r))
        .collect();
    if redacted_rows.iter().any(|r| r.was_redacted) {
        emit_audit(&ctx, "portal.view_redaction_applied", json!({/*sampled*/})).await;
    }

    // Audit + ETag
    emit_audit(&ctx, "portal.view_read", json!({
        "engagement_id": engagement_id,
        "view_kind": view_kind,
        "filter_hash16": filters.hash16(),
        "result_count": redacted_rows.len(),
    })).await;

    let resp = ListResp { rows: redacted_rows, next_cursor };
    let etag = sha256_hex(&serde_json::to_vec(&resp)?)[..16].to_owned();
    Ok(ListResp { /* ... */ }.with_etag(etag).with_cache_control("private, max-age=30"))
}

§7 — Dependencies

Upstream (depends_on):

Cross-module (related_tasks):

Downstream (blocks):


§8 — Example payloads

8.1 List response

{
  "rows": [
    { "id": "0190f7c0-...", "title": "Q2 audit", "status": "in_progress",
      "owner_external_id": "alice@acme.com", "created_at": "2026-05-01T...",
      "last_activity_at": "2026-05-17T..." }
  ],
  "next_cursor": "eyJsYXN0X2lkIjoiMDE5MGY3YzAtLi4uIiwibGFzdF9zb3J0X3ZhbHVlIjoiMjAyNi0wNS0xNyJ9"
}

8.2 portal.view_detail_read memory row

{
  "kind": "portal.view_detail_read",
  "severity": 2,
  "tenant_id": "8a2f...",
  "actor_id": "user.subject.456",
  "trace_id": "0af7651916cd43dd8448eb211c80319c",
  "occurred_at": "2026-05-17T09:14:32.847Z",
  "payload": {
    "engagement_id": "0190...",
    "view_kind": "projects",
    "resource_id": "0190f7c0-..."
  }
}

§9 — Open questions

All resolved for slice 1. Deferred:


§10 — Failure modes inventory

FailureDetectionOutcomeRecovery
Engagement not in caller membershipjoin returns 0403 + engagement_access_deniedCaller switches engagement
Unknown view_kindenum validation400 + unknown_view_kindCaller fixes URL
Unknown filter keyper-kind validation400 + invalid_filter_key + valid_keysCaller fixes filter
Unknown field in projectionsafe-set check400 + unknown_fieldCaller fixes fields param
Cursor signature invalid (tampered)HMAC verify400 + cursor_invalidCaller starts fresh
Export > 10k rowscount pre-check413 + export_too_large; use_dsarDSAR via TASK-PORTAL-008
Rate limit hitcounter429 + Retry-AfterCaller backs off
Sync_class column missing on source tableview DDL fails at migrationMigration error; rollbackSource module adds sync_class column
RLS bypassed (auth.subject_id not set)view returns 0 rowsEmpty list responseHandler bug; fix session var
Calendar view requested at slice 1enum match → SliceUnavailable501 + slice_2_comingWait for slice 2
Detail row not foundview returns 0404 + not_foundInherent
ETag collision (rare)client sees stale 304Up to 30s stalenessCache TTL eventual consistency
Slow query on large filterOBS p95 latency alarmsev-3; investigate indexAdd index per filter pattern
Export stream cancelled mid-wayclient disconnectPartial CSV written + audit export_completed with partial countClient retries
Cross-tenant view via session var hackRLS rejects via current_setting0 rows; appears as empty listInherent isolation
Sub-resource missing for detail rowsource table doesn't have rowSub-resource field is empty arrayInherent
Search returns no resultstsvector miss200 + empty rows + next_cursor nullInherent
Field projection on sub-resource fieldnot in safe setRejected upfrontCaller uses detail endpoint
Filter date_range invalid formatparse error400 + invalid_date_formatCaller fixes
Postgres view definition out of sync with source table schemamigration testCI failsUpdate view + re-migrate
ETag computation overhead on huge responseOTel latencysev-3 if p95 > 500msCache ETag in Redis (slice 2)

§11 — Implementation notes

§11.1 SQL views chosen over materialised views at slice 1 — correctness > performance; slice 2 may add materialised view + refresh-on-source-change pattern.

§11.2 Cursor HMAC uses server-side secret rotated quarterly; key in KMS.

§11.3 ETag computation = SHA-256 truncated 16 hex; matches TASK-MCP-005 PRM pattern.

§11.4 Sub-resource queries (detail endpoint) hit additional tables; each requires its own RLS path. Slice 1 = sequential queries; slice 2 = parallel via tokio::join!.

§11.5 Field projection at SELECT level (not post-fetch filter) reduces row size + network bandwidth.

§11.6 Export streaming via axum::body::Body::from_stream; row batches of 100 to bound memory.

§11.7 XLSX export uses rust_xlsxwriter crate; streaming write to bytes buffer.

§11.8 Rate limit per-caller via Redis sliding-window (consistent with all other PORTAL tasks).

§11.9 SQL view RLS via WHERE clause + current_setting — not separate POLICY because views inherit base-table policies but adding view-specific RLS predicate is clearer.

§11.10 Cache-Control: private + max-age=30 is intentional — 30s allows fast navigation between pages without re-querying; per-user privacy maintained.

§11.11 The portal.view_redaction_applied audit is sampled 5% (higher than view_read's 1%) because redaction is forensically relevant — operators may want to see "is the tenant correctly classifying sync_class" patterns.

§11.12 ETag is computed over the canonical-JSON of the response (sorted keys) for determinism — same data on same request returns same ETag.

§11.13 The view definition aliases internal fields with CASE WHEN to NULL-out for redacted rows; client always sees the same JSON shape (avoids "this row has fewer fields" UX confusion).

§11.14 Calendar view stub at slice 1 returns 501 immediately; logged but no audit row (informational only).

§11.15 Export 10k cap is per-export; multiple exports can accumulate. Rate limit + DSAR alternative.


End of TASK-PORTAL-001 spec.