Skip to content

spp_api_v2 / spp_api_v2_programs: REST API v2 defects found while building the OpenFn OpenSPP2 adaptor (ProgramMembership identity, group filter, If-Match, references, remove-member) #554

Description

@gonzalesedwin1123

Purpose

We are updating the OpenFn adaptor @openfn/language-openspp (OpenFn/adaptors, packages/openspp) to support OpenSPP2. Version 3.x of the adaptor talks to OpenSPP v1 over Odoo JSON-RPC, which Odoo 19 deprecates (removal is scheduled for Odoo 20) and which bypasses the consent, scope and audit controls of spp_api_v2. Version 4 is rebuilt on the OpenSPP2 REST API v2 (/api/v2/spp, OAuth2 client credentials) so that OpenFn workflows can read and update registrants, groups, program memberships and service points through the supported, consent-aware integration surface.

While studying the API and running the new adaptor against a local OpenSPP2 instance, we found the defects below. Each one either blocks a reliable integration or can cause silent wrong results for API clients, not only for OpenFn. This issue collects them in one place so they can be triaged together. Split out sub-issues or PRs as convenient.

Environment: OpenSPP/OpenSPP2 branch 19.0 @ 1a3c5919 (2026-09-16), started with ./spp start --wipe --demo mis --generate, plus spp_api_v2_programs and spp_api_v2_service_points. API client with public_task legal basis (no consent requirement). Every item was reproduced against this instance through the REST API; file and line references are at that commit.

How the adaptor copes for now: it refuses to update a program membership when the beneficiary has more than one (A) and checks the program in the PUT response. It verifies a group exists before searching by it (H). It never sends If-Match by default (B). It reads members through GET /Individual?group= rather than Group.member[] references (F). It documents the stale status after removal (G). It parses both {detail} and Problem Details bodies (C). These workarounds cost extra requests and refuse legitimate operations, so upstream fixes would let us remove them.

Summary

# Area Problem Impact
A spp_api_v2_programs GET/PUT /ProgramMembership/{identifier} resolve to the beneficiary's first membership in any program High: a PUT can silently update the wrong program's membership (43% of demo beneficiaries have more than one)
H spp_api_v2 GET /Individual?group= returns all individuals when the group id is malformed or doesn't exist High: returns the whole registry for a bad filter
B spp_api_v2_programs If-Match on PUT /ProgramMembership always returns 409 (microseconds vs float seconds) Medium: optimistic locking unusable on this endpoint
F spp_api_v2 References built from namespace_uri (group members, membership responses) return 404 when followed Medium: returned references can't be used
G spp_api_v2 / spp_registry $remove-member leaves is_ended/status unset until the daily repair cron Medium: removed members still active and listed for up to a day
C spp_api_v2 Error bodies are FastAPI {detail}, not RFC 9457 as docs/principles/api-error-responses.md says Low: contract mismatch

A. spp_api_v2_programs: GET/PUT /ProgramMembership/{identifier} resolve to the beneficiary's first membership in any program

Describe the bug

ProgramMembershipService.find_by_identifier()
(spp_api_v2_programs/services/program_membership_service.py:103) resolves the
path identifier through the beneficiary's registry id, then returns
spp.program.membership.search([("partner_id", "=", …)], limit=1), which is the
beneficiary's first membership in whichever program. The code comments say so:
"We might have multiple memberships, so we need program reference too. For now,
return the first membership found."

Membership identifier[] values are also the beneficiary's registry ids
(to_api_schema, same file around line 180), so a membership has no identifier
of its own.

Consequences for any beneficiary enrolled in more than one program:

  • GET /ProgramMembership/{id} returns an arbitrary membership.
  • PUT /ProgramMembership/{id} (routers/program_membership.py:284) updates
    the wrong program's membership
    , eg a client exiting a beneficiary from
    program X can exit them from program Y instead. This is silent data corruption.

Scale: in the MIS demo data, 356 of 824 beneficiaries (43%) have more than one
membership.

To reproduce

  1. Pick a group enrolled in several programs:
    GET /api/v2/spp/ProgramMembership?beneficiary=Group/<system>|<value>
    returns, eg, 5 memberships (programs P1…P5).
  2. GET /api/v2/spp/ProgramMembership/<system>|<value> (URL-encoded) returns
    only the P1 membership. There is no way to address P2…P5.
  3. PUT /api/v2/spp/ProgramMembership/<system>|<value> with a body whose
    program.reference is P3 and status: "exited" updates the P1 membership
    (the body's program is only used for validation on create, not lookup).

Expected behavior

A membership can be read and updated unambiguously. Any of these would work:

  • give memberships their own identifier (eg a system for membership ids, or
    <program-id>|<beneficiary-id>) and use it in identifier[] and the path; or
  • require a program query parameter on GET/PUT /ProgramMembership/{id} and
    resolve with the existing find_by_partner_and_program() (same file, just
    below find_by_identifier); or at least
  • have PUT return 409/422 when the body's program.reference doesn't match
    the resolved membership
    , instead of updating another program's record.

Additional context

  • Found while building the OpenFn language-openspp v4 adaptor. The adaptor
    currently refuses to update memberships for beneficiaries with more than one
    membership, and checks the program reference in the PUT response.
  • find_by_partner_and_program() already exists, which suggests the second
    option is a small change.

H. spp_api_v2: GET /Individual?group= returns all individuals when the group identifier is malformed or doesn't exist

Describe the bug

SearchService._parse_group_param() (spp_api_v2/services/search_service.py:265)
returns an empty domain [] in two cases:

  • the value has no | (logs "Invalid group identifier format", line ~284);
  • no group matches the identifier (logs "Group not found", line ~304).

An empty domain means "no filter", so the search returns every individual
instead of none (or an error). A typo in a group id, or a deleted group, gives
a client the whole registry, paged. getGroupMembers-style integrations
then treat every individual as a member of that group.

To reproduce

  1. GET /api/v2/spp/Individual?group=urn:openspp:vocab:id-type%23national_id%7CDOES-NOT-EXIST
  2. The response is 200 with meta.total = the total number of individuals,
    identical to an unfiltered GET /Individual (2,177 on the test instance).
  3. The same happens with ?group=NO-SEPARATOR.

Expected behavior

A group filter that can't be resolved returns either:

  • 400 (malformed) / 404 (group not found), or
  • an empty result (domain = [("id", "=", 0)]).

It should never return an unfiltered result.

Additional context

  • The other _parse_*_param helpers in the same file are worth checking for the
    same "return [] on invalid input" pattern (eg _parse_membership_role_param
    when the role code isn't found).
  • The OpenFn adaptor works around this by reading GET /Group/{id} before
    searching (a 404 aborts), which costs an extra request per call.

B. spp_api_v2_programs: If-Match on PUT /ProgramMembership always returns 409 (version format mismatch)

Describe the bug

ProgramMembership returns meta.versionId (and so the value a client echoes in
If-Match) as integer microseconds
(spp_api_v2_programs/services/program_membership_service.py:232, "Use integer
microseconds for versionId"), but PUT compares If-Match against float
seconds
:

# spp_api_v2_programs/routers/program_membership.py:328
current_version = str(membership.write_date.timestamp() if membership.write_date else 1)

"1790222337475890" != "1790222337.47589", so any client that uses optimistic
locking correctly gets a 409 every time. All other resources use the
microsecond format consistently:
individual.py:396,471, group.py:369,445, change_request.py:370 all use
str(int(write_date.timestamp() * 1000000)).

To reproduce

  1. GET /api/v2/spp/ProgramMembership?beneficiary=Individual/<id> returns a
    membership with meta.versionId = "1790222337475890".
  2. PUT /api/v2/spp/ProgramMembership/<id> with the same body and
    If-Match: "1790222337475890" returns
    409 {"detail": "Version conflict. Resource was modified by another request."},
    although nothing changed.

Expected behavior

PUT /ProgramMembership accepts its own versionId, ie line 328 uses
str(int(membership.write_date.timestamp() * 1000000)) like the other routers.
A shared helper for "current version of a record" would stop this drifting again.


F. spp_api_v2: references built from namespace_uri can't be resolved (group members, membership responses)

Describe the bug

Several places build Individual/… and Group/… references with the id type's
vocabulary namespace (namespace_uri, eg urn:openspp:vocab:id-type)
instead of the full code URI (uri, eg
urn:openspp:vocab:id-type#birth_certificate). Lookups
(find_by_identifier, _find_individual) match on id_type_id.uri, so
following these references returns 404.

Places found:

  • spp_api_v2/services/group_service.py:209: Group.member[].entity.reference
  • spp_api_v2/services/group_service.py:392: identifier string (same pattern)
  • spp_api_v2/services/membership_utils.py:32 and :43: group and entity
    references in MembershipResponse ($add-member, $remove-member,
    PATCH …/member/…)

group_service.py:115 already has a comment saying to use uri and "NOT
namespace_uri which only returns vocabulary namespace", so this looks like
places that were missed.

To reproduce

  1. GET /api/v2/spp/Group/<system>|<value> returns members such as
    {"entity": {"reference": "Individual/urn:openspp:vocab:id-type|BC-0001"}.
  2. The individual's own identifier is
    urn:openspp:vocab:id-type#birth_certificate|BC-0001.
  3. GET /api/v2/spp/Individual/urn:openspp:vocab:id-type%7CBC-0001 returns 404;
    GET /api/v2/spp/Individual/urn:openspp:vocab:id-type%23birth_certificate%7CBC-0001
    returns 200.

Expected behavior

Every reference the API returns can be passed back to the API. Use
id_type_id.uri (the same value used in identifier[].system) in these
places.

Additional context

Workaround for clients: GET /Individual?group=<group id> returns members with
correct identifiers (but see H).


G. spp_api_v2: $remove-member leaves is_ended/status unset until the daily repair cron runs

Describe the bug

GroupService.remove_member() (spp_api_v2/services/group_service.py:748)
defaults the end date to datetime.now(), with microseconds, and writes it
(line 750). The stored computes then compare it with fields.Datetime.now(),
which Odoo truncates to whole seconds:

# spp_registry/models/group_membership.py
return bool(ended_date and ended_date <= now)   # _is_ended_as_of

03:56:14.096894 <= 03:56:14 is false, so the membership is stored with
is_ended = False and status = "active", as if the end date were in the
future. It's only corrected by the daily repair cron added in #418
(cron_repair_crossed_membership_ended_status, interval_number=1 days).

Until then:

  • the $remove-member response says "status": "active", ended_date: null;
  • GET /Individual?group= still lists the removed individual (it filters on
    individual_membership_ids.is_ended = False);
  • anything else reading is_ended (group metrics, CEL, GIS reports; see the
    list in _is_ended_as_of's docstring) sees an active member for up to a day.

GET /Group/{id} already hides the member, so the API is inconsistent with
itself.

To reproduce

  1. POST /api/v2/spp/Group/<gid>/$add-member with an individual.
  2. POST /api/v2/spp/Group/<gid>/$remove-member with the same individual (no
    endedDate). The response has "status": "active".
  3. SQL: select ended_date, is_ended, status from spp_group_membership where id = …
    gives 2026-09-24 03:56:14.096894 | f | active.
  4. GET /api/v2/spp/Individual?group=<gid> still returns the individual.

Expected behavior

Removing a member "now" ends the membership immediately. Options:

  • default to fields.Datetime.now() (second precision, the same clock the compute
    uses) in remove_member; and/or
  • truncate microseconds when writing ended_date; and/or
  • make _is_ended_as_of robust (eg compare at second precision).

Additional context

Related: #417 (stale stored computes; fixed by the repair cron in #418), #420,
#421. This case is narrower: the end date is "now", not in the future, but
the microsecond mismatch makes it look like a future date, so it only becomes
correct when the cron runs.


C. (optional, low) spp_api_v2: error responses are FastAPI {detail}, not RFC 9457 Problem Details as the API principles say

Describe the bug

docs/principles/api-error-responses.md specifies RFC 9457 Problem Details,
and spp_api_v2/schemas/problem_detail.py defines ProblemDetail, but routers
raise plain HTTPException and the OCA fastapi handler
(fastapi/error_handlers.py:50) returns {"detail": ...} with
application/json. For example 404 {"detail": "Individual not found"} and
422 {"detail": [{"loc": [...], "msg": ...}]}.

To reproduce

GET /api/v2/spp/Individual/no-separator returns 400 {"detail": "Invalid identifier format. Expected: {system}|{value}"}
(no type, title, status, application/problem+json).

Expected behavior

Either register an exception handler on the spp_api_v2 endpoint that renders
ProblemDetail (application/problem+json), or update the principle doc to
describe the actual format. Clients currently have to handle both.

Additional context

Low priority. Nothing is broken, but it's an API contract mismatch that
client libraries (such as the OpenFn adaptor) have to code around.


Found while building OpenFn language-openspp v4. Full API study and adaptor plan are available on request.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't working

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions