Skip to content

docs(proposals): revise recipe model — adopter-owned, not framework-managed#507

Closed
bokelley wants to merge 4 commits into
mainfrom
bokelley/recipe-adopter-owned
Closed

docs(proposals): revise recipe model — adopter-owned, not framework-managed#507
bokelley wants to merge 4 commits into
mainfrom
bokelley/recipe-adopter-owned

Conversation

@bokelley
Copy link
Copy Markdown
Contributor

@bokelley bokelley commented May 4, 2026

Summary

Phase 1A of the salesagent side-car experiment (#506) falsified the framework-managed-recipe-state model in the original draft of #502.

Reading `dynamic_products.py` end-to-end (no harness needed) showed that salesagent's recipe is Product-scoped and adopter-owned, not proposal-scoped and framework-managed. Two pre-registered Q1.5 falsifiers fired:

  • ✅ "Variant Products require new schema rows" — confirmed (variants are full `Product` rows with TTLs)
  • ✅ "Hash-dedup state crosses sessions" — confirmed (`generate_variant_id` is a deterministic md5 hash; dedup is global)

What changes

The original draft claimed the framework manages recipe lifecycle: session cache against `proposal_id` during refine, persistence on finalize, hydration at `create_media_buy`, "SDK is system of record." That's wrong against salesagent's actual code.

Revised model: the framework types the recipe contract but does not manage recipe storage. Storage is adopter-owned (Product rows or equivalent in adopter persistence). The seam the framework owns is the typed contract: `recipe_type: ClassVar[type[Recipe]]` on `DecisioningPlatform`, validated at dispatch.

Three sections rewritten:

  • Layer 2 — Product internal-config (the "recipe") — framework types the contract, doesn't manage storage
  • What `DecisioningPlatform` keeps — adopter populates `ctx.recipes` from its own storage; framework receives + types + validates
  • The proposal workflow — framework provides wire-level lifecycle types and finalize transition routing; storage/persistence is adopter-owned

What didn't change

  • Four-layer model (Wire / Recipe / Capability overlap / Supporting tables)
  • Two-platform composition (`ProposalManager` + `DecisioningPlatform`)
  • Path A / Path B recipe type sharing (those discuss TYPE discrimination, not storage ownership)
  • Three concrete shapes (LinkedIn, Prebid, mock-backed)

Net effect

The framework's job is smaller and more focused: type the contract, route transitions, dispatch typed recipes. Storage of recipes and proposal state lives in the adopter's existing data model — where it always lived in salesagent's case.

Refs

  • #506 — the experiment that falsified the original model (Phase 1A)
  • #502 — the original draft this revises (already merged)

Test plan

  • Reviewers confirm the revised seam matches what salesagent actually does (Product-scoped recipes, no framework-managed cache)
  • Reviewers confirm the framework's revised job description (type the contract, route transitions, dispatch) is enough to be useful
  • Reviewers flag any other sections that still claim framework-managed state and need revision
  • Decide `expires_at` enforcement: adopter-checked or framework helper (`assert_proposal_not_expired(ctx)`)

🤖 Generated with Claude Code

bokelley and others added 4 commits May 3, 2026 20:53
…anaged

Phase 1A of the salesagent side-car experiment (#506) falsified the
framework-managed-recipe-state model in the original draft of #502.
Reading dynamic_products.py end-to-end (no harness needed):

- Salesagent recipe lives on the Product row
  (Product.implementation_config: JSONType)
- Variants generated at brief time share the template's recipe
  verbatim and are themselves persistent Product rows
- Variants have global hash-dedup, TTLs, archival lifecycle —
  independent of any proposal lifecycle
- Recipe state was Product-scoped and adopter-owned; not
  framework-managed

Pre-registered Q1.5 falsifiers fired:
- "Variant Products require new schema rows" — confirmed
- "Hash-dedup state crosses sessions" — confirmed

Revisions in this PR:

1. Top-level revision note added so reviewers see the change
   immediately.

2. Layer 2 — Product internal-config (the "recipe") rewritten.
   - Old: framework manages lifecycle (session cache, persist on
     finalize, hydrate at create_media_buy, "SDK is system of
     record")
   - New: framework TYPES the recipe contract via recipe_type;
     framework does NOT manage storage; recipe is adopter-owned

3. What DecisioningPlatform keeps (the seam) revised.
   - Old: framework hydrates proposal/product, looks up recipe by
     id, populates ctx.recipes
   - New: adopter populates ctx.recipes from its own storage;
     framework receives, types, validates against recipe_type +
     capability_overlap

4. The proposal workflow (finalize lifecycle) revised.
   - Old: framework provides session cache, recipe persistence on
     finalize, recipe hydration through buy lifecycle
   - New: framework provides wire-level lifecycle types, finalize
     transition routing, typed dispatch; storage and persistence
     are adopter-owned. expires_at enforcement: open question
     between adopter-checked and framework-helper.

What didn't change:
- The four-layer model (Wire, Recipe, Capability overlap, Supporting tables)
- The two-platform composition (ProposalManager + DecisioningPlatform)
- Path A / Path B recipe type sharing (those are about TYPE
  discrimination, not storage ownership)
- Three concrete shapes (LinkedIn, Prebid, mock-backed)
- Proposal-side capabilities being sales-axis-scoped

The framework's job is smaller and more focused after this revision:
type the contract; route transitions; dispatch typed recipes. Storage
of recipes, proposals, and proposal state lives in the adopter's
existing data model — which is where it always lived in salesagent's
case.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds examples/recipe_falsification/ — the pytest harness for Q2 of
the salesagent side-car experiment (PR #506):

> Does the recipe shape carry GAM's implementation_config without
> escape hatches?

Files:
- gam_recipe.py — typed Pydantic GAMRecipe model with sub-models
  (CreativePlaceholder, FrequencyCap), Literal-typed enums for
  every documented GAM API value, extra="forbid" on every model
- fixtures/gam_impl_config_examples.json — five fixture shapes
  derived from salesagent's GAMProductConfigService:
    guaranteed_default, non_guaranteed_default,
    video_with_targeting, native_with_discount, minimal
- test_recipe_round_trip.py — runs the four pre-registered Q2
  falsifiers from PR #506:
    (a) any extra: dict[str, Any] field
    (b) any # type: ignore needed to construct
    (c) lossy round-trip dict → recipe → dict
    extra-forbid: smuggled fields rejected
- README.md — what's here, how to run, results, caveats

Result: 8 tests pass. All Q2 falsifiers refuse to fire.

- Round-trip is lossless across all 5 fixture shapes
- Zero Any-typed fields; only dict-typed field is custom_targeting_keys
  typed strictly as dict[str, str | list[str]] per GAM's API contract,
  NOT an escape hatch
- Direct construction with sub-models needs no # type: ignore
- Unknown fields are rejected by extra="forbid"

Q2 prior holds: a typed Pydantic recipe carries the full GAM
implementation_config shape without escape hatches.

Combined with Q1.5 (Phase 1A — recipe is adopter-owned, not
framework-managed; corrected in this PR's revision of #502), the
architecture story is now:
- Recipe is typed at framework boundary (Q2 confirmed)
- Recipe storage is adopter-owned (Q1.5 confirmed)
- Framework's job: type the contract, route transitions, dispatch

Caveats documented in README:
- Fixtures derived from service code paths, not production DB dumps;
  dev-DB validation pass would tighten the result
- custom_targeting_keys typing follows GAM's documented API; deeper-
  nested salesagent data would reject (correct against GAM, may
  surface migration edges)
- Literal[...] enums need versioning when GAM adds enum values

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The architectural claim (recipe is adopter-owned; framework types,
doesn't cache) is general. The empirical evidence in
examples/recipe_falsification/ is GAM-specific by construction —
validates GAMRecipe against GAM's implementation_config, not other
adopter shapes.

Added explicit scope note to the top-level revision callout so
reviewers don't read the change as GAM-only thinking. Multi-adopter
validation (LinkedIn, Meta, TikTok, Prebid multi-decisioning) is
future work that would tighten Q2 across more shapes; the general
architecture claim doesn't wait on it.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Reference implementation for Phase 2 of the salesagent side-car
experiment (PR #506). Lives in adcp-client-python as a doc-and-
review-friendly skeleton; deploys into a salesagent fork worktree
under src/sdk_runtime/ where it imports salesagent's _impl
functions and models.

Files:
- account_store.py — SalesagentBuyerAgentRegistry +
  SalesagentAccountStore. Bearer-token (Principal.access_token)
  lookup; AgentAccountAccess scoping; sandbox→mode projection.
  fetch_gam_manual_approval_required helper for the HITL gate.
- gam_platform.py — GAMPlatform wraps salesagent's _impl
  functions: _get_products_impl, _create_media_buy_impl,
  _update_media_buy_impl, _get_media_buy_delivery_impl,
  _sync_creatives_impl. Builds ResolvedIdentity from SDK ctx.
- hitl_gate.py — compose_method before-hook. Wire→GAM-internal
  operation name mapping (create_media_buy, update_media_buy,
  sync_creatives→add_creative_assets). Writes WorkflowStep +
  MediaBuy(raw_request=...) rows via salesagent's existing
  WorkflowManager; short-circuits with status='pending_approval'.
- serve_sidecar.py — adcp.serve(...) entrypoint. Wires platform +
  HITL gates + auth shim + WebhookSender (SDK→SDK signing per
  Step 0.6, since salesagent's scheme is incompatible).
- README.md — deployment recipe (docker-compose addition, nginx
  routing config, scheduler patches, tenant configuration,
  storyboard run commands), exit criteria, open work in scaffold.
- __init__.py

The salesagent imports are wrapped in try/except so the files
lint and import in adcp-client-python's tree without salesagent
installed; SALESAGENT_AVAILABLE flag gates runtime behavior.

Status: structurally complete reference. Actual storyboard run
requires deploying into a salesagent fork worktree with sandbox
GAM credentials, configuring the experiment tenant, and running
nginx + docker-compose (recipe in README). Local-fork only — no
upstream PR to salesagent per the experiment scope constraint.

Open gaps documented in README:
- _build_resolved_identity may need more fields than the minimal
  projection here
- WorkflowManager constructor signature varies across salesagent
  versions; pin to a commit
- Webhook signing parity is SDK→SDK only (Step 0.6 finding)

Recipe shape already validated end-to-end in Phase 1B
(examples/recipe_falsification/, 8 tests pass).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@bokelley bokelley closed this May 4, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant