Skip to content

Homepage Personalization Architecture โ€‹

Issue #763 extends the homepage delivered by issue #639 with per-user adjustments based on the signed-in user's UserLearningPreferences (role, deployment, cloud). Signed-out users see no change. Canonical URL / is unchanged for SEO/crawlers.

Spec: docs/superpowers/specs/2026-07-04-763-homepage-personalization-design.mdPlan: docs/superpowers/plans/2026-07-04-763-homepage-personalization.mdBase homepage: homepage.md


What ships in v1 โ€‹

PieceSurface
P1Verb-spine reorder (Row 2 tiles)
P2Row-5 tutorial teaser re-ranking
P3New "For you" row (between verb spine and events band)
P4Verb sub-page shelf reordering
P5Admin personaTags / personaWeight / personaHidden fields on HomepageShelves; new HomepageForYouCandidates entity
P6"Personalized ยท Adjust ยท See default" badge strip
P7Live cross-tab re-render via BroadcastChannel (with storage-event fallback)
P8Row-6 community-lane RSS soft filter by persona tags
P9Row-4 video-band soft filter by persona tags

Architecture overview โ€‹

Static Hugo shell + Vue-island hydration for the personalizable slots. No approuter-side SSR, no pre-baked per-persona variants.

text
GET /
  Static Hugo page
  Skeleton placeholders on data-personalize slots

  homepage-personalizer (Vue island, hugo-apps/src/homepage-personalizer/)
    โ”œโ”€ coordinator.ts       โ† reads auth cookie; skips if anon
    โ”‚                         checks sessionStorage cache
    โ”‚                         GET /homepage/personalized (If-None-Match ETag)
    โ”‚                         applies payload to DOM
    โ”‚                         subscribes to BroadcastChannel
    โ”œโ”€ verb-order.ts       โ† Row 2 reorder
    โ”œโ”€ for-you-row.vue      โ† Row 2b (hidden until โ‰ฅ3 candidates)
    โ”œโ”€ teaser-rerank.vue    โ† Row 5 reorder
    โ”œโ”€ shelf-rerank.vue     โ† verb sub-pages (/learn/, /build/, ...)
    โ”œโ”€ video-filter.ts      โ† imported by video-band island
    โ”œโ”€ rss-filter.ts        โ† imported by community-lane island
    โ”œโ”€ personalized-badge.ts
    โ””โ”€ prefs-broadcast.ts   โ† BroadcastChannel + storage fallback

GET /homepage/personalized
  CAP HomepageService, @requires 'authenticated-user'
  Reads UserLearningPreferences for req.user.id
  Builds envelope (buildEnvelope in srv/lib/homepage/personalized-envelope.js)
  In-memory cache per (userId, preferences hash, content hash), 5 min
  Returns JSON envelope

Endpoint contract โ€‹

GET /homepage/personalized

Auth:         @requires 'authenticated-user' โ€” 401 for anonymous requests
Kill switch:  204 (no body) when HomepageConfig.personalizationEnabled = false
Cache-Control: private, no-store
X-Personalization: 1          โ† CDN marker; approuter must never cache this combination
ETag: "<hash>"                โ† SHA-1 of response body, quoted
If-None-Match: "<hash>"       โ† client sends on repeat requests; server returns 304 on match

Response body on 200:

json
{
  "hash": "a1b2c3d4",
  "profile": { "role": "developer", "deployment": "cloud", "cloud": "aws" },
  "verbOrder":     ["build","learn","integrate","ai","operate","connect"],
  "forYou":        [{ "kind": "tutorial", "slug": "...", "title": "...", "description": "...", "imageUrl": "..." }],
  "teaserOrder":   ["<tutorial-slug>"],
  "shelfOverrides": {
    "learn": { "reorder": ["<id>"], "hidden": ["<id>"] }
  },
  "videoFilterTags": ["aws","btp"],
  "rssFilterTags":   ["btp-development","architecture"]
}

Envelope caps (R7): 12 teaser slugs, 8 for-you entries, โ‰ค30 shelf entries ร— 6 verbs. Integration test asserts < 10 KB.

Invariants โ€‹

Two headers are security- and cache-critical and must never be dropped:

  • Cache-Control: private, no-store โ€” prevents shared-cache storage of per-user data.
  • X-Personalization: 1 โ€” CDN/approuter marker. The approuter is documented to never cache responses carrying this header. Removing it silently enables shared caching of personalized payloads.

The smoke test (test/smoke/homepage-personalized.test.js) asserts both on every deployed environment.


Kill switch โ€‹

HomepageConfig.personalizationEnabled (Boolean, default false) is the single on/off control. When false:

  • The endpoint returns 204 (no body).
  • The client coordinator exits immediately on 204 โ€” no DOM changes, no badge.
  • All signed-in users see the same default homepage as anonymous users.

Toggle location: /admin-ui/#homepage, Config tab. No redeploy needed. Rolling back: flip to false.


Data model โ€‹

Extensions to HomepageShelves โ€‹

Three new columns on the existing entity (db/homepage.cds):

ColumnTypeMeaning
personaTagsarray of String(40)Tags that boost this entry for matching profiles
personaWeightInteger (default 0)Boost magnitude; range โˆ’10..+10
personaHiddenarray of String(40)Tags that suppress this entry entirely

New entity HomepageForYouCandidates โ€‹

Curated "For you" pool. Distinct from HomepageShelves โ€” being featured in "For you" is orthogonal to being in the directory footer.

Key fields: kind (tutorial/mission/video/blog/shelf), targetSlug, title, description, imageUrl, personaTags, personaWeight, personaHidden, sortOrder, active.

Admin surface: /admin-ui/#for-you. Curator runbook: docs/authors/homepage-for-you-runbook.md.


Persona-tag vocabulary โ€‹

Tags follow strict <field>:<value> grammar. The source of truth is PROFILE_VOCAB in srv/lib/branch/profile-fields.js.

Current vocabulary:

role:developer | role:architect | role:sysadmin | role:student
deployment:cloud | deployment:onprem
cloud:btp | cloud:aws | cloud:azure | cloud:gcp | cloud:alibaba | cloud:oracle | cloud:ibm

A save-time validator (srv/lib/homepage/persona-tag-validator.js) rejects unknown tags with a field-level 400. A drift-guard unit test (test/unit/homepage/persona-fields-sync.test.js) asserts the validator's allowlist matches PROFILE_VOCAB. Extending the vocabulary means updating PROFILE_VOCAB; the validator and test follow automatically.


Personalization algorithm โ€‹

Layer 1 โ€” Static persona โ†’ verb order map โ€‹

srv/lib/homepage/persona-map.js maps role to a deterministic verb order:

js
const BASE_ORDER = ['learn', 'build', 'integrate', 'operate', 'ai', 'connect'];

const ROLE_TILT = {
  developer: ['build', 'learn', 'integrate', 'ai', 'operate', 'connect'],
  architect: ['integrate', 'build', 'operate', 'learn', 'ai', 'connect'],
  sysadmin:  ['operate', 'integrate', 'build', 'connect', 'learn', 'ai'],
  student:   ['learn', 'build', 'ai', 'integrate', 'connect', 'operate'],
};

Not admin-editable in v1. A small ยฑ1-slot tilt toward the verb with the most persona-matched shelf entries is applied on top; tilt is capped at 1 slot.

Layer 2 โ€” Persona-weight scoring โ€‹

For shelf entries and "For you" candidates:

if any(tag in entry.personaHidden matches user profile):
    excluded (terminal)

matched = any(tag in entry.personaTags matches user profile)
score = entry.personaWeight if matched else 0

Ranking: (-score, sortOrder, title) โ€” stable, deterministic.

The "For you" row additionally requires matched == true โ€” an untagged candidate never appears in "For you". Shelves and teaser still show untagged entries at their default sort position.


Client hydration โ€‹

Coordinator flow โ€‹

ts
async function boot() {
  if (!isSignedIn()) return;                          // anon โ†’ static page
  if (isDefaultViewActive()) return renderBadge();    // ?default=1 or sessionStorage flag
  const cached = readSessionCache();
  const etag = cached?.hash ? `"${cached.hash}"` : undefined;
  const resp = await fetch('/homepage/personalized', {
    credentials: 'include',
    headers: etag ? { 'If-None-Match': etag } : {},
  });
  if (resp.status === 204) return;                    // kill switch off
  const payload = resp.status === 304 ? cached.payload : await resp.json();
  writeSessionCache(payload);
  applyToDom(payload);
  renderBadge(payload.profile);
  subscribeBroadcast(payload.hash);
}

Fetch errors (5xx, network, mid-session 401) are logged at console.debug only; static content remains in place.

Session cache โ€‹

sessionStorage['sap-devs-homepage-personalized'] stores the last payload and its hash. On repeat navigations within the same tab the coordinator sends If-None-Match โ€” a 304 uses the cached payload without re-parsing. Clearing sessionStorage forces a full fresh fetch.

BroadcastChannel + storage fallback โ€‹

prefs-broadcast.ts opens BroadcastChannel('sap-devs-prefs'). When the user saves learning preferences on /me/, the preferences page posts { type: 'preferences-changed' } on the channel and also writes localStorage['sap-devs-prefs-touched'] for browsers without BroadcastChannel support. The coordinator re-fetches and re-applies if the new hash differs from the current one.

?default=1 bypass โ€‹

Clicking "See default" in the badge navigates to ?default=1. The coordinator writes sessionStorage['sap-devs-homepage-default']=1 and early-exits โ€” the flag persists for the rest of the session without the query string staying in the URL. Clearing the sessionStorage or closing the tab resets it.

Badge in bypass mode: "Viewing the default homepage ยท Personalize again". Clicking "Personalize again" clears the sessionStorage flag and reloads.


Observability โ€‹

Two new metrics via srv/lib/metrics.js:

  • homepage.personalized.requests{result} โ€” result โˆˆ {200, 304, 401, 204-disabled, 5xx}. Monitors ETag efficiency (target > 60% 304s after warmup) and endpoint health.
  • homepage.personalized.applied{surface} โ€” surface โˆˆ {verb-order, for-you, teaser, shelf, video-filter, rss-filter}. Emitted from the coordinator via navigator.sendBeacon on first successful hydration per session. No PII โ€” profile values never enter analytics.

Rollout โ€‹

Ships DEV-first with HomepageConfig.personalizationEnabled = false. Admin flips the flag on after DEV smoke. Promotes to STAGE then PROD (aligned with end-July 2026 cutover). Full on/off via the config flag; roll back by flipping to false. No percentage rollout โ€” audience size doesn't warrant it.


Failure modes added by personalization โ€‹

FailureBehaviour
Endpoint 5xx or network errorCoordinator swallows to console.debug; static page renders unchanged. No badge.
Kill switch off (204)Coordinator early-exits; signed-in users see the same page as anonymous users.
UserLearningPreferences missingEnvelope builder returns BASE_ORDER and an empty "For you" row.
HomepageForYouCandidates all-empty or < 3 matched"For you" row stays hidden; no visible gap.
Broken HomepageForYouCandidates link (link-health job)Candidate hidden from API response; red dot in admin /admin-ui/#for-you.