Signals feedback loop — plain-language explainer

The Signals feedback loop watches how a person actually reacts to content — what they rate, save, dwell on, dismiss, skip — and quietly makes the next thing they see fit them better.

A People Analytics Toolbox cross-cutting primitive (src/lib/signals/, contract 0.1.0). Not a spoke: it lives in src/lib/ because every property in the portfolio is a candidate emitter, and the loop that turns signals into better-ranked content is toolbox-owned. Every claim below is grounded in the primitive's own code and contracts; anything not yet built is marked (TBD).


Lead

One person, many small reactions, a feed that gets sharper for them with every one.

This is the FULLER, multi-movement treatment, because the Signals loop is one of the surfaces a developer (and, downstream, an end reader) actually meets — the rate-to-personalize loop that the whole cross-property content premise rides on.

Manifesto — why it exists

The worldview: content should learn from the person consuming it, and it should do so from more than a single thumbs-up. Most personalization in this space is either invisible (a black-box "recommended for you" with no contract you can inspect) or starved (one explicit rating, ignored the moment the page reloads). Neither composes across more than one product.

The pain it removes, stated as a shift:

  • FROM a feed that shows everyone the same cards in the same order, where the one rating someone gives evaporates on the next visit, and where each property re-invents its own ad-hoc "liked this" table.
  • TO a single content-agnostic signal envelope — twelve signal kinds, explicit and implicit — written to one durable store, read back as a per-person affinity that re-ranks the next queue, and shared by every property that vendors the contract.

The premise in one line, lifted from the contract header itself: a feedback loop fed by MANY signals makes content better and better for each user over time. The 1–5 rating is one signal (kind: "rating"); the envelope captures the other eleven — thumb, save, dismiss, act, reviewed (explicit) and view, dwell, expand, complete, share, skip (implicit).

How it differs from the obvious substitutes:

  • vs. doing it by hand — there is no "by hand" for a per-reader affinity that updates every interaction; the alternative is no personalization, or a hard-coded editorial order.
  • vs. generic BI / a dashboard — BI reports on engagement after the fact; this loop acts on it before the next render, in the request that builds the queue.
  • vs. a black-box recommender — the unit of input is a typed, versioned Signal the consumer owns a copy of; the scoring math is a pure, deterministic, readable function (affinityScore), not an opaque model. You can audit exactly why a card ranked where it did.

Visual — Tier B (FROM→TO typographic block). The shift above is the visual: same-order feed, evaporating single rating, per-property "liked" tablescontent-agnostic 12-kind signal envelope · one durable store · per-person affinity re-ranking the next queue · shared by every property.

Walkthrough — how it actually works

There are two journeys through this primitive: the end reader's (signals captured → feed re-ranked) and the developer's (vendor the contract → POST signals → read affinity). Both run on the same envelope and the same store.

The reader's journey, through the real /feed surface (src/app/(surfaces)/feed/):

  1. Identity. On first touch the loop mints an opaque per-browser actor key — anon_<uuid> in an httpOnly cookie named pa_actor (src/lib/signals/actor.ts). It is deliberately not a toolbox user id, so an external-site reader can later be mapped to the same key without reshaping the store.
  2. Capture. As the reader moves through paced insight cards, the player engine emits signals — a rating when they grade a card, plus implicit view / skip / dwell. The <Feed> component's onSignals(signals[]) callback hands the whole batch to a Server Action (recordPlayerSignalsAction, src/surfaces/analytics-player/lib/signal-callback.ts), which is the trust boundary: it resolves the real actor from the cookie server-side and overrides actorId on every signal before persisting. Each signal carries the card's features (topicTags, cardType, sourceApp) in context so the personalizer never has to re-fetch the content.
  3. Store. Signals land in the durable store (getSignalStore()), which selects Postgres (signals.signal_event) whenever DATABASE_URL is set and an in-memory map otherwise — so serverless cold starts don't lose history.
  4. Personalize. On the next render, the feed page reads the actor's recent history (historyFor(actorId, { limit: 500 })), builds an affinity function (buildAffinity), and passes it into buildPlayerQueue(input, { affinity }). Affinity is feature-based — it learns per-topic, per-source, per-card-type sentiment — so every signal informs the ranking even though each one is on a specific card.
  5. Show the work. When personalization is active, the page renders a small badge: "Personalized from N of your interactions" — N is the literal history count, not a vanity number.

The reincarnation-flavored guardrail: affinity boosts what works and dampens what doesn't (WEIGHTS.affinity = 0.5 in buildPlayerQueue), but the sequencer keeps an exploration budget (Vela's "25% exploratory" pattern) so the feed never collapses onto a narrow high-confidence cluster. Focus and freshness still carry weight; affinity reorders without overpowering.

The developer's journey, through POST /api/signals/ingest:

  1. Vendor src/lib/signals/contract.ts into your repo (exactly like the Insight Card contract).
  2. POST one Signal or a Signal[] batch (capped at 1000) to /api/signals/ingest. The route is service-key-gated (PAT-11) because it is a write.
  3. The caller is trusted to set each signal's actorIdthat is how cross-property identity works. A property with an authenticated user asserts a stable key it controls, so the same human shares affinity across properties.
  4. The loop reads it back wherever the same actorId appears next.

Visual — Tier A (live API capture). A real batch POST to the running ingest route:

POST /api/signals/ingest
x-toolbox-service-key: <key>
[
  { "actorId": "anon_8f3c…", "subjectId": "card_attrition_q2",
    "subjectType": "metric-spotlight", "property": "toolbox",
    "sourceApp": "manager-effectiveness", "kind": "rating", "value": 5,
    "context": { "topicTags": ["retention","engagement"], "cardType": "metric-spotlight" },
    "occurredAt": "2026-05-29T18:02:11.004Z" },
  { "actorId": "anon_8f3c…", "subjectId": "card_comp_band_drift",
    "subjectType": "metric-spotlight", "property": "toolbox",
    "sourceApp": "anycomp", "kind": "skip",
    "context": { "topicTags": ["compensation"] },
    "occurredAt": "2026-05-29T18:02:19.220Z" }
]
→ 202 {
    "ok": true,
    "accepted": 2,
    "contractVersion": "0.1.0",
    "receivedAt": "2026-05-29T18:02:19.640Z"
  }

(Shape is the route's real 202 response from src/app/api/signals/ingest/route.ts; the contractVersion field is the literal SIGNALS_CONTRACT_VERSION = "0.1.0". actorId truncated for display.)

What it enables

Concrete uses, each grounded in shipped behavior:

  • A feed that learns from one reader — the /feed surface re-ranks per actor from their own signal history (live today).
  • Cross-property affinity — a reader who rates compensation cards in one property sees the loop carry that affinity into another property that asserts the same actorId (the ingest route + caller-asserted identity make this the supported path).
  • Implicit-signal learning, not just stars — a dwell of 40 seconds, an expand, a complete all move affinity even when the reader never explicitly rates (KIND_SENTIMENT baselines in personalize.ts).
  • "Less like this" that sticks — a dismiss (sentiment −1) or repeated skip (−0.5) durably dampens that topic/source for that actor.
  • Post-sequence reflection — at the end of a session the loop can summarize what it learned (POST /api/player/reflection/generate): rated/resonated/passed counts, total dwell, top topics + sources, and a 1–2 sentence LLM-authored read (Claude Haiku, with a deterministic English fallback) — persisted to signals.sequence_reflection.
  • Operator liveness check/operator/signals aggregates the durable store (total signals, unique actors, unique properties, breakdown by kind and by property, recent events + reflections) so an operator can answer "is the loop alive in prod?"

How it fits in the toolbox

This is a cross-cutting src/lib/ primitive, not a spoke — so it is not in src/lib/contracts/registry.ts and exposes no MCP tools (both confirmed against the code). It composes with the other cross-cutting primitives by contract, never by reaching into a spoke's internals:

  • Insight Card primitive (src/lib/insight-player/contract.ts) — affinity is computed against InsightCard features (topic_tags, source_app, card_type). The signal envelope's subjectType is generic, so the loop is content-agnostic, but the Insight Card is its first and primary subject.
  • The player sequencer (src/lib/insight-player/sequence.ts) — buildPlayerQueue takes the affinity function as an option and blends it with focus + freshness + diversity under a fixed exploration budget. The loop supplies the personalization signal; the sequencer owns the ranking.
  • Any emitting spoke, by API only — the cards a reader signals on are emitted by spokes (manager-effectiveness, anycomp, preference-modeler, forecasting, …) through their emit-insight-cards endpoints; the loop records signals about those cards. It never imports a spoke's core/.

Contracts in play:

  • Owned + vendored: src/lib/signals/contract.tsSignal, SignalKind, SignalPolarity, the ingest request/response shapes, SIGNALS_CONTRACT_VERSION = "0.1.0". Consumers vendor a copy; bump-via-toolbox is the only path to evolve it.
  • Consumed: src/lib/insight-player/contract.ts (InsightCard).
  • Derived persistence: SessionReflectionPayload (schemaVersion: "1.0", versioned separately from the signals contract).

Consumers that meet it: the toolbox /feed surface today; Performix, rankandfile, vela, and the peopleanalyst-site magazine are the named candidate emitters in the contract header (they ingest over the HTTP front door as they wire up).

Visual — Tier B (typographic data-flow). spoke emit-insight-cards → /feed cards → reader reactions → Signal[] → recordPlayerSignalsAction / POST /api/signals/ingest → signals.signal_event → buildAffinity(history) → buildPlayerQueue({ affinity }) → re-ranked /feed. The cross-property branch tees in at the ingest route, asserting actorId.

One load-bearing worked example

A single actor's affinity, computed end-to-end through the real personalize.ts math — no invented model, just the published scoring functions applied to the signals shown above.

Input — the actor's signal history (the two signals from the ingest capture, as historyFor would return them):

  • rating value 5 on card_attrition_q2, source manager-effectiveness, topics ["retention","engagement"], type metric-spotlight.
  • skip on card_comp_band_drift, source anycomp, topics ["compensation"].

Step 1 — per-signal sentiment (signalSentiment):

  • The rating of 5: clamp((5 − 3) / 2) = +1.0 (a 5/5 centers at 3, scales to [−1,1]).
  • The skip: no value, no polarity → KIND_SENTIMENT["skip"] = −0.5.

Step 2 — affinity profile (buildAffinityProfile, each signal weight defaulting to 1):

  • topics: retention +1.0, engagement +1.0, compensation −0.5.
  • sources: manager-effectiveness +1.0, anycomp −0.5.
  • types: metric-spotlight +1.0 (only the rated card carried a cardType).
  • n = 2 contributing signals.

Step 3 — score the next two candidate cards (affinityScore, weights topics 0.6 / source 0.25 / type 0.15):

  • A retention card from manager-effectiveness, type metric-spotlight — topics avg +1.0 (w 0.6), source +1.0 (w 0.25), type +1.0 (w 0.15) → weighted mean +1.0. Ranks high.
  • A compensation card from anycomp — topic compensation −0.5 (w 0.6), source anycomp −0.5 (w 0.25); no learned type overlap → weighted mean −0.5. Ranks low.
  • A card on a brand-new topic the actor has never touched — no feature overlap → affinityScore returns 0 (neutral): it rides focus + freshness + the exploration budget, exactly so the feed keeps surfacing the unknown.

That third case is the load-bearing detail: the loop boosts what works and dampens what doesn't, but a zero-overlap card scores neutral, not negative — the exploration budget, not the affinity score, is what keeps the feed from collapsing onto a narrow cluster.

Every number here is the literal output of the functions in src/lib/signals/personalize.ts (signalSentiment, buildAffinityProfile, affinityScore) on the worked input — illustrative card titles, real math.

Commercialization / packaging

The Signals loop is infrastructure, not a sold line item — it is the cross-portfolio premise (every property emits, the toolbox owns the loop), so it sits behind reader-facing surfaces rather than being priced on its own.

  • Data-license posture: the loop stores only behavioral signals keyed by an opaque actor id and the consumer-asserted tenantId; it carries no PII in the envelope. The actor key is deliberately opaque so identity resolution is a downstream concern, not baked into the store.
  • Tenant scoping: tenantId is optional on the envelope (omit for public/external content) and indexed on the table, so org-scoped affinity and public-content affinity coexist in one store.
  • Pricing tiers / packaged offerings are (TBD) — not earned yet, so not stated.

Visual — (TBD — product-placement diagram once the loop is a named line in a packaged offering).

Current status

Grounded in the real code state (src/lib/signals/, contract 0.1.0):

  • Shipped: the signal contract (12 kinds, explicit + implicit, 0.1.0); durable Postgres store on signals.signal_event with the in-memory fallback; the personalization brain (signalSentiment / buildAffinityProfile / affinityScore / buildAffinity); opaque per-browser actor identity; end-to-end capture on /feed via the player engine and the recordPlayerSignalsAction trust boundary; affinity re-ranking wired into buildPlayerQueue; the cross-property POST /api/signals/ingest front door (service-key-gated, caller-asserted identity, batch ≤ 1000); post-sequence reflections (POST /api/player/reflection/generate, persisted to signals.sequence_reflection, LLM copy with deterministic fallback); the /operator/signals liveness aggregate.
  • In flight / planned: routing signals back to the source spoke for the spoke's own learning (noted as a follow-up in signal-callback.ts); cross-property identity resolution — merging a pre-login anonymous key into a post-login identity, and linking one reader across sites (the deferred identity-link refinement called out in actor.ts and the ingest route); a sequenceId predicate on historyFor (today the reflection route filters client-side); (TBD) an MCP surface and registry enrolment (this is a src/lib/ primitive, so neither exists today by design).

Visual — Tier A (live readout). /operator/signals reports the real shipped totals at request time — total signals, unique actors, unique properties, breakdown by kind and property, and the reflection rollup — directly from the durable store (getSignalStats). It is the runtime proof the loop is alive.

The vision

Every property in the portfolio emitting many small signals into one loop, so the content each person meets — anywhere — gets quietly, continuously better for them; the toolbox owns the loop, the properties just vendor the contract.

The arc from here: close the loop back to the spokes (so a spoke learns which of its own cards land), resolve identity across properties (one reader, one affinity, many sites), and broaden the subject types beyond insight cards (learning modules, marketing sections, media units — the envelope is already content-agnostic). The math stays readable and the contract stays vendored: this is a loop you can audit, not a black box you have to trust.