- 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)