Org Web Adapter

hungryroot/contract-testing/backend-plan.org

ID
cdbe3298-4355-4570-898a-c55f5a1853cc

Backend Work: Client-Safe Sodapop UI Contracts

Source RFC: [[https://hungryroot.atlassian.net/wiki/spaces/WD/pages/1584168961/RFC+Client-Safe+Sodapop+UI+Attachments+and+Suggested+Action+Contracts][RFC: Client-Safe Sodapop UI Attachments and Suggested Action Contracts]] (Henry Ng)

Also see: Colin vs Henry comparison — alignment already mapped there.

Open Question (Blocker)

Where does capability resolution live: Hungryroot API layer or Sodapop?

This determines Django vs Sodapop ownership for nearly everything below.

Lean: API layer owns resolution, passes resolved profile to Sodapop in request

context. Keeps Sodapop stateless and prevents mobile clients from needing to

call Sodapop directly to discover their profile.

Work Items

TODO Capability Registry [0/3]

Server-owned mapping: (platform, min_version) → capability profile.

TODO Design storage layer

Options:

- Django model (=CapabilityProfile=, =CapabilityProfileRule=) — queryable, auditable, supports soft-delete

- JSON fixture / dynamic config — simpler, no migrations, harder to audit

- Recommendation: Django model if registry will grow; fixture if ≤10 profiles

Fields needed:

- =platform= (ios/android/web)

- =min_version= (semver string, nullable for web)

- =profile_name= (e.g. =smart-cart-agent.ios.v2=)

- =capabilities= (JSON array of capability strings)

- =status= (active/deprecated)

- =introduced_at=, =deprecated_at=

TODO Version resolution logic

Given =(platform, version_string)= → return matching =CapabilityProfile=.

Resolution order per RFC:

1. Exact or nearest-lower min_version match for platform

2. If version < minimum supported → text-only/legacy profile

3. If platform unknown → text-only profile

4. If version missing/malformed → text-only profile

Web: skip version matching, always resolve to latest web profile.

Write this as a pure function (no I/O) so it's unit-testable without DB.

TODO Client identity header parsing

Parse =X-HR-Client-Platform= and =X-HR-Client-Version= headers.

DRF middleware or custom =BasePermission= subclass.

Attach resolved profile to =request.client_capability_profile=.

Log resolution decisions (see Observability section).

TODO Contract Registry [0/2]

Track contract versions and deprecation state.

TODO Data model

#+begin_src python

class Contract(models.Model):

name = models.CharField(max_length=128, unique=True) # "action.swap-recipe.v1"

status = models.CharField(choices=["active", "deprecated"])

introduced_at = models.DateField()

deprecated_at = models.DateField(null=True)

#+end_src

TODO Validation: contract name in emitted profile capabilities

When a Sodapop response emits a contract type, validate it exists in =Contract=

table and is =active=. Log (don't hard-fail) if unknown.

TODO Conversation History Filtering [0/3]

History replay must be capability-safe across platforms.

TODO Event metadata schema

Persisted conversation events must store:

#+begin_src json

{

"eventType": "suggested_action",

"contract": "action.swap-recipe.v1",

"createdForProfile": "smart-cart-agent.web.v3",

"data": {}

}

#+end_src

Add migration if events are stored in Django (check current schema first).

TODO Restore endpoint: filter/transform by requesting client profile

When =GET /conversation/{id}/history= (or equivalent):

1. Resolve requesting client's capability profile

2. For each event:

a. If contract in client's profile → return as-is

b. If older compatible version exists → transform to older shape

c. If no compatible version → omit UI/action, preserve text

d. If omitting makes history confusing → inject text-only fallback message

TODO Cross-platform resume UX

RFC says acceptable to block with: "This conversation is not supported in this

version of the platform."

Define the exact response shape so clients can display a helpful message.

Open question: does BE return 200 with =conversationUnsupported: true= or a

specific error response?

TODO Observability [0/1]

TODO Structured log events for capability resolution

Log on each of these:

- Unknown platform

- Missing/malformed app version

- Version below minimum supported

- Newer unlisted version → resolved to nearest lower profile

- Unsupported contract attempted for resolved profile

Fields per log line:

- =platform=

- =app_version=

- =resolved_profile=

- =sodapop_surface= (e.g. =smart-cart-agent=, =rooty-search=)

- =unsupported_contract= (if applicable)

Use existing structlog patterns.

TODO Contract Testing (Pact) [0/3]

Per RFC lightweight path — also aligns with Colin's Phase 2 proposal.

See colin-vs-henry-doc for context on merging these.

TODO Shared schema/fixture location

Decide: =contract_testing/fixtures/= in hungryroot repo, or separate repo.

Recommendation: =contract_testing/pact/= inside hungryroot, importable by

Sodapop tests and client test suites.

Structure:

#+begin_src

contract_testing/

pact/

profiles/

smart-cart-agent.web.v3.json

smart-cart-agent.ios.v1.json

smart-cart-agent.ios.v2.json

smart-cart-agent.android.v1.json

schemas/

action.add.v1.json

action.swap-recipe.v1.json

attachment.item-list.v1.json

#+end_src

TODO CI job: provider verification

Sodapop CI (or hungryroot CI) runs against shared fixtures before capability

registry is updated or new profile enabled.

Add branch protection rule requiring this job.

TODO Pact Broker (deferred)

Defer to Phase 2 per RFC and Colin's doc. Revisit when fixture-based path

becomes painful (multiple environments, version/environment tracking).

Decisions Needed

| Question | Owner | Status |

|-------------------------------------------------------+-------------------+---------|

| Capability resolution: API layer vs Sodapop? | Colin + Henry | Open |

| History filtering: 200 with flag vs error response? | Colin + Mobile | Open |

| Contract registry: Django model vs fixture? | Colin | Open |

| Shared Pact fixture location: mono vs separate repo? | Colin + Henry | Open |

| Deprecation window policy for old contract versions? | Henry | Open |

Not Backend's Problem (yet)

- Client-side graceful degradation (web, iOS, Android own this)

- A2UI adoption (deferred, revisit when use cases grow)

- Theming/layout (Sodapop AI response shaping)