Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions knowledge/changelogs/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,6 @@
3.26.0 || 27.08.2026
feature(sdk) + docs(kb): `curateAds` — the D51 ad-dissemination helper (the ads lane's second foundation). The per-creator choice of how their ads get mixed into a viewer's feed is a shared, **deterministic** SDK helper, not SQL: the stateful algorithms (round-robin needs "which ad showed last," greedy needs performance numbers) don't belong in a query, and being pure + shared means every app curates a given creator's ads identically from the same inputs. `sdk/src/curate.ts` exports `curateAds(creatorAds, setting, state)` → the ordered **active** subset to show (the caller takes the first for a single post, or the whole list for a curated-subset surface). Four modes: `round_robin` (rotate past `lastShownDocId`, wrap-around), `pinned` (just the creator's pick), `frequency_capped` (drop ads already shown `cap`× this session), and `greedy` (order by performance, highest first). Filters on `body.status` (an ad is showable unless `paused` — status defaults to `active`). The active set is canonically ordered by `doc_id` first, so the output is independent of the order the ads arrive in — pinned by a determinism test. **The v0 `greedy` gap (operator-surfaced):** `greedy` weights by offer performance, but the v0 ad object carries no `stats` (D55 — a counter is a write on a read path; impression/revenue verification is the v4 layer). So with no `performance` supplied, `greedy` **degrades to `round_robin`** (equal exposure — no ad starved) and becomes true greedy the instant the v4 metrics layer feeds per-ad scores into `state.performance`. `round_robin` / `pinned` / `frequency_capped` need no metrics (app-local state + the creator's pick). KB: `ads.md` Dissemination section updated (the `curateAds` signature gains `state`; the v0 `greedy` degradation is documented so it's not a silent lie). 110 SDK tests green (22 new), typecheck + build clean.

3.25.0 || 27.08.2026
test(api): the tagged-post ad conformance floor (D55) — the ads lane's foundation. An ad is a `posts` doc tagged `ad` (a leaf-typed `offer` + a `status`), not a service — so the API has zero ad-specific branches, and the conformance is that the ad post is indistinguishable from a post except its tag + body fields. Pinned by `api/tests/test_ads.py` (5 tests): (1) the ad post is created through the existing `/v3/create` on `posts` (no `ads` service, no new endpoint) with `tags=["ad"]` + the leaf-typed `offer` + `status` in the body, attached to the creator's followers group; (2) the feed read (`read_documents_in_groups` over the followers group) returns it interleaved with normal posts — same shape, same keys, the ad fields are the only delta; (3) I3 — a non-follower is an access failure (D42 403) and the document query never runs, so the ad is never returned; (4) `status` is a plain body field the read does NOT filter — a paused ad comes back exactly like an active one (curation + the renderer filter client-side, D51). Plus a SQL-level pin: the feed read query selects the `tags` column but filters on neither tags nor status (`status` is a body field, not a column — it cannot be filtered in SQL at all). No production code changed — the API already treats ad posts as posts (verified: no `offer` / `ad`-tag branches in `api/app/v3/`). 795 API tests green, ruff clean. Gates the catalog + composer (both read this).

Expand Down
4 changes: 3 additions & 1 deletion knowledge/knowledge-base/web10-v3/social/ads.md
Original file line number Diff line number Diff line change
Expand Up @@ -103,7 +103,7 @@ How a creator's ads get shown is a **per-creator choice**, not a platform decisi
The curation is a **shared, deterministic SDK helper**, not SQL (the stateful algorithms — round-robin needs "which ad showed last," greedy needs the performance numbers — do not belong in a query):

```
curateAds(creatorAds, creatorSetting) → the ordered subset to show
curateAds(creatorAds, creatorSetting, state) → the ordered active subset to show
```

- **round_robin** — rotate the creator's active ads so each gets equal exposure (state in the app: memory/localStorage)
Expand All @@ -113,6 +113,8 @@ curateAds(creatorAds, creatorSetting) → the ordered subset to show

The helper filters on `status === 'active'`. Because it is shared and deterministic, every app curates a given creator's ads identically. It is used where a creator's ads are *selected* — the composer's "Rotate my ads" (which ad a post carries, `ads-catalog.md`) and any app surfacing a curated subset. The feed's ad *posts* need no curation: they are posts, they render, 100% delivery by architecture.

**v0 note — `greedy` has no data yet.** `greedy` weights by offer performance (clicks/conversions), but the v0 ad object carries no `stats` (above — a counter is a write on a read path; revenue settlement + impression verification are the v4 layer). So in v0, `curateAds` degrades `greedy` to `round_robin` (equal exposure) whenever no `performance` is supplied: no ad is starved, and the mode becomes true greedy the instant the v4 metrics layer feeds per-ad scores into the helper's `state`. The other three modes need no metrics at all — `round_robin` and `frequency_capped` run on app-local state (last-shown / session counts), and `pinned` is just the creator's pick.

## The Partner Links UI (the ingest)

The Studio's monetization screen has one card for this: **Partner Links** (it was "Amazon Associates" + "Direct Deals" — collapsed, because they are the same primitive: a link that pays the creator). The card is the ingest:
Expand Down
2 changes: 1 addition & 1 deletion knowledge/strategy/parallel-execution.md
Original file line number Diff line number Diff line change
Expand Up @@ -270,7 +270,7 @@ object + feed read + dissemination) and `social/ads-catalog.md` (the catalog
- [✓ 3.16.1] KB: the standard ad object + the per-user query + the Dissemination section (per-creator setting + feed+ads join + `curateAds` SDK helper) + the two-layer note (`social/ads.md`) + D50 + D51
- [✓ 3.22.0] KB: the Ad Catalog + the composer integration (`social/ads-catalog.md`) + D54 — catalog = the canonical per-viewer read run by the owner (no special-case); post carries ad by `ref_value` (no copy); `body.status` = `active` | `paused`; round-robin = D51 setting at render time; no new tables/endpoints/SDK surface
- [✓ 3.23.0] KB: D55 — an ad is a `posts` doc tagged `ad`, not a service (`social/ads.md` rewritten + `ads-catalog.md` updated) — the feed join + the `ads: [readAll]` contract + the provisioning all disappear; the object is locked (post fields + leaf-typed `offer` + `status`, no `stats`, no `creative.format`); the creative is data + the HTML is the app's renderer (no `html` leaf type); `html_template` is v4; carrying is post → post
- [ ] Dissemination (SDK): the `curateAds(creatorAds, creatorSetting)` helper — `round_robin` / `greedy` / `pinned` / `frequency_capped`, deterministic + per-creator so every app curates identically; the per-creator setting is a field on the `settings` doc; filters on `body.status === 'active'` (D54)
- [✓ 3.26.0] Dissemination (SDK): the `curateAds(creatorAds, creatorSetting, state)` helper — `round_robin` / `greedy` / `pinned` / `frequency_capped`, deterministic + per-creator so every app curates identically; the per-creator setting is a field on the `settings` doc; filters on `body.status === 'active'` (D54). v0: `greedy` degrades to `round_robin` (no `stats` in the ad object — D55) until the v4 metrics layer feeds `performance` in
- [ ] Partner Links card (UI): collapse "Amazon Associates" (`AmazonTagCard.tsx`) + "Direct Deals" (`DirectDealsCard.tsx`) into one "Partner Links" card in the Studio monetization screen — `offer.kind` = `affiliate` | `direct` | `own_store` + the dissemination picker; update `studio-data.ts` + `studio.test.tsx`
- [✓ 3.25.0] The tagged-post ad (API conformance): the ad object (a `posts` doc tagged `ad` with the `offer` + `status`) through the existing posts CRUD + the feed read returns it + I3 (a non-follower can't read the ad post) — no service to provision, no new endpoint; verify + pin with `api/tests/test_ads.py`. Gates the catalog + composer (both read this)
- [ ] Ad Catalog (authenticator): the Studio's inventory screen — the owner's posts filtered to `tags ∋ 'ad'` (creative / offer / status / attached posts via reverse `ref_value`), new-ad ingest flow (offer + content → one `posts` doc tagged `ad` on the followers group), edit / pause / retire, all states designed (empty → CTA, skeleton, error, active/paused rows). `ui/src/components/Studio/`
Expand Down
2 changes: 1 addition & 1 deletion knowledge/strategy/plan.md
Original file line number Diff line number Diff line change
Expand Up @@ -216,7 +216,7 @@ the ad object + feed read + dissemination are in `social/ads.md` (D50 + D51
- [✓ 3.23.0] **Decision: D55** (`knowledge/strategy/decisions.md`) — an ad is a `posts` doc tagged `ad`, not a service (supersedes D50's service framing): the feed join + the `ads: [readAll]` contract + the provisioning all disappear; the object is locked (post fields + leaf-typed `offer` + `status`, no `stats`, no `creative.format`); the creative is data + the HTML is the app's renderer (no `html` leaf type); `html_template` is v4; carrying is post → post.
- [✓ 3.23.0] **KB** (`knowledge-base/web10-v3/social/ads.md` rewritten + `ads-catalog.md` updated) — the tagged-post model: why a post not a service, the locked ad object, the creative-is-data/HTML-is-the-app split + the v4 `html_template` escape hatch, the data-model map, the feed read as the ad read, D51 dissemination re-scoped (curation selects; the feed's ad posts just render), the two-layer note.
- [✓ 3.25.0] **Foundation: the tagged-post ad conformance** — the ad object (a `posts` doc tagged `ad` with the `offer` + `status`) through the existing posts CRUD + the feed read returns it + I3 (a non-follower can't read the ad post), pinned by `api/tests/test_ads.py`. No service to provision. The catalog and the composer both read this, so it gates both surfaces.
- [ ] **Foundation: `curateAds` (SDK)** — the D51 helper (`round_robin` / `greedy` / `pinned` / `frequency_capped`), deterministic + per-creator, filters on `body.status === 'active'`. The composer's rotation + any curated-subset surface calls it.
- [✓ 3.26.0] **Foundation: `curateAds` (SDK)** — the D51 helper (`round_robin` / `greedy` / `pinned` / `frequency_capped`), deterministic + per-creator, filters on `body.status === 'active'`. The composer's rotation + any curated-subset surface calls it.
- [ ] **Ad Catalog (authenticator)** — the Studio's inventory screen: the owner's posts filtered to `tags ∋ 'ad'` (creative / offer / status / attached posts via reverse `ref_value`), new-ad ingest flow (offer + content → one `posts` doc tagged `ad` on the followers group), edit / pause / retire, all states designed (empty → CTA to ingest, skeleton, error, active/paused rows). `ui/src/components/Studio/`.
- [ ] **Composer ad control (web10-social)** — the "Attach ad" control in `PostComposer`: the catalog picker sheet (active first, empty + loading states), attach writes the post's `ref` → `ref_value` (post → post), the ad block renders under the post (creative + offer + disclosure, disclosure never hidden), "Rotate my ads" per-post opt-in to the D51 setting. `marketing/web10-social/src/components/Feed/`.
- [ ] **E2E** — the torture gauntlet: create ad in the catalog → attach to a post → follower sees the post with the ad block + disclosure → pause the ad → it stops rendering → non-follower never sees the ad (I3). `e2e/tests/ads.spec.ts`.
Expand Down
67 changes: 67 additions & 0 deletions sdk/dist/curate.d.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
/**
* curateAds — the shared, deterministic D51 curation helper.
*
* How a creator's ads get mixed into a viewer's feed is a per-creator choice
* (D51), not a platform decision. The curation is a pure function, not SQL:
* the stateful algorithms (round-robin needs "which ad showed last," greedy
* needs performance numbers) do not belong in a query. Because the helper is
* shared and deterministic, every app curates a given creator's ads
* identically from the same inputs.
*
* KB: knowledge/knowledge-base/web10-v3/social/ads.md (the Dissemination
* section) + ads-catalog.md.
*/
/** The D51 dissemination modes — a per-creator choice on the `settings` doc. */
export type DisseminationMode = 'round_robin' | 'greedy' | 'pinned' | 'frequency_capped';
/** The creator's ad-dissemination setting (normalized from the `settings` doc). */
export interface AdDisseminationSetting {
dissemination: DisseminationMode;
/** `frequency_capped`: max times the same ad shows per session. */
cap?: number;
/** `pinned`: the doc_id of the ad that is live. */
pinnedDocId?: string;
}
/**
* App-local curation state (memory / localStorage) — the stateful
* algorithms' memory. Passed in so the helper stays a pure function.
*/
export interface CurationState {
/** `round_robin` (and the `greedy` fallback): the doc_id shown last. */
lastShownDocId?: string;
/** `frequency_capped`: how many times each ad has shown this session. */
shownCounts?: Record<string, number>;
/**
* `greedy`: per-ad performance score (doc_id → number). v0 supplies none —
* the ad object carries no `stats` (D55) — so `greedy` degrades to
* `round_robin` until the v4 metrics layer feeds this in.
*/
performance?: Record<string, number>;
}
/** An ad post as the feed read returns it. Only `doc_id` + `body.status` are needed. */
export interface CuratableAd {
doc_id: string;
body?: Record<string, unknown>;
}
/**
* Given a creator's ad posts + their dissemination setting + app-local state,
* return the ordered subset of ACTIVE ads to show. The caller takes what it
* needs — the first entry for a single post (the composer's "Rotate my ads"),
* or the whole list for a curated-subset surface.
*
* Filters on `body.status`: an ad is showable unless it is `paused` (status
* defaults to `active`, per the locked ad object).
*
* Modes:
* - `round_robin` — rotate the active ads (start after `lastShownDocId`) so
* each gets equal exposure.
* - `greedy` — order by `performance` (highest first). v0 has no performance
* metrics, so with no `performance` supplied it degrades to `round_robin`.
* - `pinned` — return just the pinned ad (if active).
* - `frequency_capped` — drop ads already shown `cap`× this session.
*
* Deterministic: the result depends only on the inputs. The active set is
* canonically ordered by `doc_id` first, so the output is independent of the
* order the ads happened to arrive in.
*/
export declare function curateAds<T extends CuratableAd>(creatorAds: T[], setting: AdDisseminationSetting, state?: CurationState): T[];
//# sourceMappingURL=curate.d.ts.map
1 change: 1 addition & 0 deletions sdk/dist/curate.d.ts.map

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions sdk/dist/index.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
export { createV3Client, type V3Client } from './v3';
export type { V3ClientOptions, V3Document, V3Group, V3GroupMember, V3InviteResponse, V3JoinRequest, V3ServiceContract, V3CR, V3AppCR, V3GroupCR, V3GroupRole, V3GroupMemberCR, V3User, V3LoginResponse, } from './v3';
export { cookieDict, readTokenCookie, setTokenCookie, scrubTokenCookie, decodeJwt, isTokenExpired, } from './token';
export { curateAds, type AdDisseminationSetting, type CurationState, type CuratableAd, type DisseminationMode, } from './curate';
export { Web10Error } from './http';
export type { TokenPayload } from './types';
//# sourceMappingURL=index.d.ts.map
2 changes: 1 addition & 1 deletion sdk/dist/index.d.ts.map

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

51 changes: 50 additions & 1 deletion sdk/dist/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -106,7 +106,9 @@ function createV3Client(options = {}) {
return;
try {
const token = state.token ?? readTokenCookie();
const body = { url: window.location.href.split(/[?#]/)[0] };
const rawUrl = window.location.href.split(/[?#]/)[0];
const url = rawUrl.replace(/\/index\.html$/, "/");
const body = { url };
if (token)
body.token = token;
fetch(`${apiOrigin}/v3/apps/register`, {
Expand Down Expand Up @@ -420,12 +422,59 @@ function createV3Client(options = {}) {
pingAppRegister();
return client;
}
// src/curate.ts
function curateAds(creatorAds, setting, state = {}) {
const active = creatorAds.filter((ad) => (ad.body?.status ?? "active") !== "paused");
if (active.length === 0)
return [];
const canonical = [...active].sort((a, b) => compareDocIds(a.doc_id, b.doc_id));
switch (setting.dissemination) {
case "pinned": {
const pinned = canonical.find((ad) => ad.doc_id === setting.pinnedDocId);
return pinned ? [pinned] : [];
}
case "frequency_capped": {
const cap = setting.cap ?? Number.POSITIVE_INFINITY;
const counts = state.shownCounts ?? {};
return canonical.filter((ad) => (counts[ad.doc_id] ?? 0) < cap);
}
case "greedy": {
const perf = state.performance;
const hasPerf = perf !== undefined && Object.keys(perf).length > 0;
if (!hasPerf) {
return rotate(canonical, state.lastShownDocId);
}
return [...canonical].sort((a, b) => {
const pa = perf[a.doc_id] ?? 0;
const pb = perf[b.doc_id] ?? 0;
if (pb !== pa)
return pb - pa;
return compareDocIds(a.doc_id, b.doc_id);
});
}
case "round_robin":
default:
return rotate(canonical, state.lastShownDocId);
}
}
function rotate(list, lastShownDocId) {
if (!lastShownDocId || list.length === 0)
return list;
const idx = list.findIndex((ad) => ad.doc_id === lastShownDocId);
if (idx === -1)
return list;
return list.slice(idx + 1).concat(list.slice(0, idx + 1));
}
function compareDocIds(a, b) {
return a < b ? -1 : a > b ? 1 : 0;
}
export {
setTokenCookie,
scrubTokenCookie,
readTokenCookie,
isTokenExpired,
decodeJwt,
curateAds,
createV3Client,
cookieDict,
Web10Error
Expand Down
Loading
Loading