Repository navigation
Description
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
- Pick a group enrolled in several programs:
GET /api/v2/spp/ProgramMembership?beneficiary=Group/<system>|<value>
returns, eg, 5 memberships (programs P1…P5). GET /api/v2/spp/ProgramMembership/<system>|<value>(URL-encoded) returns
only the P1 membership. There is no way to address P2…P5.PUT /api/v2/spp/ProgramMembership/<system>|<value>with a body whose
program.referenceis P3 andstatus: "exited"updates the P1 membership
(the body'sprogramis 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 inidentifier[]and the path; or - require a
programquery parameter onGET/PUT /ProgramMembership/{id}and
resolve with the existingfind_by_partner_and_program()(same file, just
belowfind_by_identifier); or at least - have
PUTreturn 409/422 when the body'sprogram.referencedoesn't match
the resolved membership, instead of updating another program's record.
Additional context
- Found while building the OpenFn
language-opensppv4 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
GET /api/v2/spp/Individual?group=urn:openspp:vocab:id-type%23national_id%7CDOES-NOT-EXIST- The response is
200withmeta.total= the total number of individuals,
identical to an unfilteredGET /Individual(2,177 on the test instance). - 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_*_paramhelpers 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
GET /api/v2/spp/ProgramMembership?beneficiary=Individual/<id>returns a
membership withmeta.versionId = "1790222337475890".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.referencespp_api_v2/services/group_service.py:392: identifier string (same pattern)spp_api_v2/services/membership_utils.py:32and:43:groupandentity
references inMembershipResponse($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
GET /api/v2/spp/Group/<system>|<value>returns members such as
{"entity": {"reference": "Individual/urn:openspp:vocab:id-type|BC-0001"}.- The individual's own
identifieris
urn:openspp:vocab:id-type#birth_certificate|BC-0001. GET /api/v2/spp/Individual/urn:openspp:vocab:id-type%7CBC-0001returns 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_of03: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-memberresponse 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
POST /api/v2/spp/Group/<gid>/$add-memberwith an individual.POST /api/v2/spp/Group/<gid>/$remove-memberwith the same individual (no
endedDate). The response has"status": "active".- SQL:
select ended_date, is_ended, status from spp_group_membership where id = …
gives2026-09-24 03:56:14.096894 | f | active. 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) inremove_member; and/or - truncate microseconds when writing
ended_date; and/or - make
_is_ended_as_ofrobust (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
gonzalesedwin1123 commentedon Sep 24, 2026
Addendum: I.
POST /Individualaccepts an external identifier that already belongs to another individualFound while running the QA job for the OpenFn adaptor against the same environment (
19.0@1a3c5919). This adds I to the list above.spp_api_v2/spp_registryPOST /Individualcreates a second individual with an identifier (system + value) already used by another individual; identifier lookups then pick one of them arbitrarilyDescribe the bug
IndividualService.create()(spp_api_v2/services/individual_service.py:480) buildsreg_idsfromidentifier[](_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:Nothing makes
(id_type_id, value)unique across registrants. But the whole REST API resolves an identifier to a single record, egfind_by_identifier()searchesspp.registry.idwithlimit=1(individual_service.py:~41), so it treats identifiers as unique. After a duplicate is created,GET/PUT/PATCH /Individual/{identifier},$add-member,$remove-memberand 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
POST /api/v2/spp/Individualwith{"identifier": [{"system": "urn:openspp:vocab:id-type#national_id", "value": "QA-0001"}], "name": {"given": "Original"}}returns201.POST /api/v2/spp/Individualwith the same identifier and"name": {"given": "Copy"}returns201again (expected409/422).PATCH /api/v2/spp/Individual/urn:openspp:vocab:id-type%23national_id%7CQA-0001with{"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:
(id_type_id, value)for live IDs, plus a readable pre-check like_assert_id_type_free) and return 409 fromPOST/PUTon a clash; orPOST /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
POST /Group(GroupService.create), which buildsreg_idsthe same way. Not verified.docs(spp_api_v2, spp_api_v2_programs): regenerate README from CI (#554)
gonzalesedwin1123 commentedon Sep 25, 2026
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 anir.cron.triggerfor the minute after any futureended_date.docker/entrypoint.shdefaults toODOO_CRON_THREADS=1../spp start, which runs withODOO_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:
$remove-memberresponse says"active".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.fix(spp_api_v2): apply _offset in GET /Group search (#554)
docs(spp_api_v2): regenerate README from CI (#554)
test(spp_api_v2_programs): GET /Program without scope must be 403, no…
test(spp_api_v2): PATCH /Individual gender must update and reject unk…
test(spp_api_v2): unknown membership role must be rejected on add/upd…
test(spp_api_v2_programs): membership search fails closed; duplicate …
test(spp_api_v2): group= and membership-role= must hold on the same m…
test(spp_api_v2): consent-filtered paging must not skip rows or stop …
fix(spp_api_v2_programs): GET /Program without scope returns 403, not…
fix(spp_api_v2): PATCH /Individual updates gender and rejects unknown…
fix(spp_api_v2): group= and membership-role= match on the same member…
3 remaining items
docs(spp_api_v2, spp_api_v2_programs): HISTORY for #554 items K-Q
test(spp_api_v2, spp_api_v2_programs): review round for #554 J-Q (S1 …
fix(spp_api_v2, spp_api_v2_programs): never count hidden records in s…
fix(spp_api_v2, spp_api_v2_programs): 409 only for the membership con…
fix(spp_api_v2): gender accepts only ISO 5218 codes on create and PAT…
docs(spp_api_v2, spp_api_v2_programs): HISTORY for the #554 review round
test(spp_api_v2): legal-basis clients keep the exact GET /Group total (…
docs(spp_api_v2, spp_api_v2_programs): HISTORY states only the total …
docs(spp_api_v2, spp_api_v2_programs): regenerate README from CI (#554)
gonzalesedwin1123 commentedon Sep 28, 2026
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:
GET /Groupignored_offset: every page returned page 1_offsetapplied?group=G&membership-role=headalso returned people who head another groupGET /ProgramMembershipwith a malformedbeneficiary=/program=returned every membership_count> 50 or on short pagesnextstarts after the last row examined, and is given while rows remainPATCH /Individualgender always returned 422 (the vocabulary lookup ran as the public user)POST /ProgramMembershipreturned 422 with PostgreSQL text, including internal record ids$add-memberand the member PATCHGET /Programwithout scope returned 500The staff review of J–Q also found a search-total leak.
meta.totalwas hidden only when a page met a hidden record, so?identifier=…&_offset=1returned 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,totalis now always the page size, on/Individual,/Groupand/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:
/_searchhas no scope check), #576 (security: hidden rows countable fromnextoffsets).docs(spp_api_v2): HISTORY example lists only messages the API can ret…
docs(spp_api_v2): regenerate README from CI (#554)
feat(openspp): rebuild adaptor on the OpenSPP2 REST API v2
Merge pull request #555 from OpenSPP/fix-554-api-v2-openfn-defects