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

Closed

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 OpenSPP's API guidelines require 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

OpenSPP's API guidelines specify 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

  1. gonzalesedwin1123 commented on Sep 24, 2026

    @gonzalesedwin1123
    MemberAuthor

    Addendum: I. POST /Individual accepts an external identifier that already belongs to another individual

    Found while running the QA job for the OpenFn adaptor against the same environment (19.0 @ 1a3c5919). This adds I to the list above.

    # Area Problem Impact
    I spp_api_v2 / spp_registry POST /Individual creates a second individual with an identifier (system + value) already used by another individual; identifier lookups then pick one of them arbitrarily High: reads and updates by identifier can hit the wrong registrant

    Describe the bug

    IndividualService.create() (spp_api_v2/services/individual_service.py:480) builds reg_ids from identifier[] (_build_registry_ids, line ~439) without checking whether the same identifier type + value already exists on another registrant. The registry only enforces one live ID of each type per registrant:

    # spp_registry/models/reg_id.py:98
    _unique_active_id_type = models.UniqueIndex("(partner_id, id_type_id) WHERE status IS DISTINCT FROM 'invalid'")

    Nothing makes (id_type_id, value) unique across registrants. But the whole REST API resolves an identifier to a single record, eg find_by_identifier() searches spp.registry.id with limit=1 (individual_service.py:~41), so it treats identifiers as unique. After a duplicate is created, GET/PUT/PATCH /Individual/{identifier}, $add-member, $remove-member and program membership references can all silently act on either registrant.

    Seen in practice: after the duplicate was created, PATCH /Individual/{identifier} with {"active": false} deactivated the new record and left the original active. GET /Individual?identifier= returned only one of the two.

    To reproduce

    1. POST /api/v2/spp/Individual with {"identifier": [{"system": "urn:openspp:vocab:id-type#national_id", "value": "QA-0001"}], "name": {"given": "Original"}} returns 201.
    2. POST /api/v2/spp/Individual with the same identifier and "name": {"given": "Copy"} returns 201 again (expected 409/422).
    3. SQL:
      select p.id, p.name, p.active from spp_registry_id r
      join res_partner p on p.id = r.partner_id where r.value = 'QA-0001';
      returns two partners.
    4. PATCH /api/v2/spp/Individual/urn:openspp:vocab:id-type%23national_id%7CQA-0001 with {"active": false} updates only one of them, and which one isn't defined.

    Expected behavior

    The API never creates a record it can't address unambiguously afterwards. Options, depending on the registry's intended rules:

    • if identifier values are meant to be unique per type, enforce it (a partial unique index on (id_type_id, value) for live IDs, plus a readable pre-check like _assert_id_type_free) and return 409 from POST/PUT on a clash; or
    • if duplicates are legitimate at registry level (eg pending deduplication), have POST /Individual (and /Group) reject identifiers already in use through the API, and have identifier lookups return 409 (not an arbitrary record) when more than one live registrant matches.

    Additional context

    • Same root assumption as A (lookups that silently pick the first match).
    • The same likely applies to POST /Group (GroupService.create), which builds reg_ids the same way. Not verified.
  2. gonzalesedwin1123 commented on Sep 25, 2026

    @gonzalesedwin1123
    MemberAuthor

    Correction to item G: how long a removed member stays active

    G says a removed membership stays is_ended = False / status = "active" "until the daily repair cron" runs, so "for up to a day". That overstates it for normal deployments.

    • spp.group.membership.write() calls _schedule_ended_status_repair() (spp_registry/models/group_membership.py). This files an ir.cron.trigger for the minute after any future ended_date.
    • The microsecond end time looks like a future date, so that trigger does get filed. With cron threads running, the repair cron corrects the row within about 1–2 minutes.
    • docker/entrypoint.sh defaults to ODOO_CRON_THREADS=1.
    • The environment in this report was started with ./spp start, which runs with ODOO_CRON_THREADS: "0" (docker-compose.yml). There the trigger never fires, and only the daily sweep would fix the row. That's where "up to a day" came from.

    The bug itself stands:

    • The $remove-member response says "active".
    • Any read before the trigger fires sees an active member.
    • Deployments without cron threads keep the wrong state until the daily sweep.

    G also affects two paths the report didn't list: the member moves in group merge and split, which end the source membership with the same datetime.now().

    All three sites now use fields.Datetime.now() in #555, so the stored state is correct immediately without waiting for any cron. GET /Group's member filter uses the same clock.

  3. 3 remaining items

  4. gonzalesedwin1123 commented on Sep 28, 2026

    @gonzalesedwin1123
    MemberAuthor

    Addendum: items J–Q and a search-total leak, fixed in #555

    A coverage check of every request the OpenFn adaptor sends, against #555's branch, found more defects on endpoints the adaptor uses. Each was confirmed over HTTP before being fixed. All are now in #555:

    # Problem Fix in #555
    J GET /Group ignored _offset: every page returned page 1 _offset applied
    K ?group=G&membership-role=head also returned people who head another group group and role must hold on the same membership
    L GET /ProgramMembership with a malformed beneficiary=/program= returned every membership 400
    M consent-filtered paging skipped rows, and stopped early on _count > 50 or on short pages next starts after the last row examined, and is given while rows remain
    N PATCH /Individual gender always returned 422 (the vocabulary lookup ran as the public user) works; unknown or non-ISO-5218 codes return 422
    O duplicate POST /ProgramMembership returned 422 with PostgreSQL text, including internal record ids 409 "already a member"; generic error bodies
    P unknown membership role codes were silently dropped on $add-member and the member PATCH 422 naming the code
    Q GET /Program without scope returned 500 403

    The staff review of J–Q also found a search-total leak. meta.total was hidden only when a page met a hidden record, so ?identifier=…&_offset=1 returned the raw count to a client without consent, revealing whether that registrant exists. J made this reachable on /Group. For clients whose legal basis requires consent, total is now always the page size, on /Individual, /Group and /ProgramMembership.

    End to end, the adaptor's QA job against #555 passes 50/50 with no warnings.

    Remaining issues found along the way are filed separately: #570 (re-adding a removed group member fails), #571, #572, #573, #574, #575 (security, latent: /_search has no scope check), #576 (security: hidden rows countable from next offsets).

  5. added a commit that references this issue on Oct 5, 2026
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