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
- 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.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
GET /api/v2/spp/Individual?group=urn:openspp:vocab:id-type%23national_id%7CDOES-NOT-EXIST
- The response is
200 with meta.total = the total number of individuals,
identical to an unfiltered GET /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_*_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
GET /api/v2/spp/ProgramMembership?beneficiary=Individual/<id> returns a
membership with meta.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.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
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
identifier is
urn:openspp:vocab:id-type#birth_certificate|BC-0001.
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
POST /api/v2/spp/Group/<gid>/$add-member with an individual.
POST /api/v2/spp/Group/<gid>/$remove-member with the same individual (no
endedDate). The response has "status": "active".
- SQL:
select ended_date, is_ended, status from spp_group_membership where id = …
gives 2026-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) 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.
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 ofspp_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/OpenSPP2branch19.0@1a3c5919(2026-09-16), started with./spp start --wipe --demo mis --generate, plusspp_api_v2_programsandspp_api_v2_service_points. API client withpublic_tasklegal 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-Matchby default (B). It reads members throughGET /Individual?group=rather thanGroup.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
spp_api_v2_programsGET/PUT /ProgramMembership/{identifier}resolve to the beneficiary's first membership in any programspp_api_v2GET /Individual?group=returns all individuals when the group id is malformed or doesn't existspp_api_v2_programsIf-MatchonPUT /ProgramMembershipalways returns 409 (microseconds vs float seconds)spp_api_v2namespace_uri(group members, membership responses) return 404 when followedspp_api_v2/spp_registry$remove-memberleavesis_ended/statusunset until the daily repair cronspp_api_v2{detail}, not RFC 9457 asdocs/principles/api-error-responses.mdsaysA.
spp_api_v2_programs:GET/PUT /ProgramMembership/{identifier}resolve to the beneficiary's first membership in any programDescribe the bug
ProgramMembershipService.find_by_identifier()(
spp_api_v2_programs/services/program_membership_service.py:103) resolves thepath identifier through the beneficiary's registry id, then returns
spp.program.membership.search([("partner_id", "=", …)], limit=1), which is thebeneficiary'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 identifierof 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) updatesthe 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
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) returnsonly the P1 membership. There is no way to address P2…P5.
PUT /api/v2/spp/ProgramMembership/<system>|<value>with a body whoseprogram.referenceis P3 andstatus: "exited"updates the P1 membership(the body's
programis only used for validation on create, not lookup).Expected behavior
A membership can be read and updated unambiguously. Any of these would work:
<program-id>|<beneficiary-id>) and use it inidentifier[]and the path; orprogramquery parameter onGET/PUT /ProgramMembership/{id}andresolve with the existing
find_by_partner_and_program()(same file, justbelow
find_by_identifier); or at leastPUTreturn 409/422 when the body'sprogram.referencedoesn't matchthe resolved membership, instead of updating another program's record.
Additional context
language-opensppv4 adaptor. The adaptorcurrently 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 secondoption is a small change.
H.
spp_api_v2:GET /Individual?group=returns all individuals when the group identifier is malformed or doesn't existDescribe the bug
SearchService._parse_group_param()(spp_api_v2/services/search_service.py:265)returns an empty domain
[]in two cases:|(logs "Invalid group identifier format", line ~284);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 integrationsthen 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-EXIST200withmeta.total= the total number of individuals,identical to an unfiltered
GET /Individual(2,177 on the test instance).?group=NO-SEPARATOR.Expected behavior
A group filter that can't be resolved returns either:
400(malformed) /404(group not found), ordomain = [("id", "=", 0)]).It should never return an unfiltered result.
Additional context
_parse_*_paramhelpers in the same file are worth checking for thesame "return [] on invalid input" pattern (eg
_parse_membership_role_paramwhen the role code isn't found).
GET /Group/{id}beforesearching (a 404 aborts), which costs an extra request per call.
B.
spp_api_v2_programs:If-MatchonPUT /ProgramMembershipalways returns 409 (version format mismatch)Describe the bug
ProgramMembership returns
meta.versionId(and so the value a client echoes inIf-Match) as integer microseconds(
spp_api_v2_programs/services/program_membership_service.py:232, "Use integermicroseconds for versionId"), but
PUTcomparesIf-Matchagainst floatseconds:
"1790222337475890" != "1790222337.47589", so any client that uses optimisticlocking 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:370all usestr(int(write_date.timestamp() * 1000000)).To reproduce
GET /api/v2/spp/ProgramMembership?beneficiary=Individual/<id>returns amembership with
meta.versionId = "1790222337475890".PUT /api/v2/spp/ProgramMembership/<id>with the same body andIf-Match: "1790222337475890"returns409 {"detail": "Version conflict. Resource was modified by another request."},although nothing changed.
Expected behavior
PUT /ProgramMembershipaccepts its ownversionId, ie line 328 usesstr(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 fromnamespace_urican't be resolved (group members, membership responses)Describe the bug
Several places build
Individual/…andGroup/…references with the id type'svocabulary namespace (
namespace_uri, egurn:openspp:vocab:id-type)instead of the full code URI (
uri, egurn:openspp:vocab:id-type#birth_certificate). Lookups(
find_by_identifier,_find_individual) match onid_type_id.uri, sofollowing 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:groupandentityreferences in
MembershipResponse($add-member,$remove-member,PATCH …/member/…)group_service.py:115already has a comment saying to useuriand "NOTnamespace_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"}.identifierisurn: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-0001returns 200.
Expected behavior
Every reference the API returns can be passed back to the API. Use
id_type_id.uri(the same value used inidentifier[].system) in theseplaces.
Additional context
Workaround for clients:
GET /Individual?group=<group id>returns members withcorrect identifiers (but see H).
G.
spp_api_v2:$remove-memberleavesis_ended/statusunset until the daily repair cron runsDescribe 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:
03:56:14.096894 <= 03:56:14is false, so the membership is stored withis_ended = Falseandstatus = "active", as if the end date were in thefuture. It's only corrected by the daily repair cron added in #418
(
cron_repair_crossed_membership_ended_status,interval_number=1 days).Until then:
$remove-memberresponse says"status": "active",ended_date: null;GET /Individual?group=still lists the removed individual (it filters onindividual_membership_ids.is_ended = False);is_ended(group metrics, CEL, GIS reports; see thelist 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 withitself.
To reproduce
POST /api/v2/spp/Group/<gid>/$add-memberwith an individual.POST /api/v2/spp/Group/<gid>/$remove-memberwith the same individual (noendedDate). The response has"status": "active".select ended_date, is_ended, status from spp_group_membership where id = …gives
2026-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:
fields.Datetime.now()(second precision, the same clock the computeuses) in
remove_member; and/orended_date; and/or_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 sayDescribe the bug
docs/principles/api-error-responses.mdspecifies RFC 9457 Problem Details,and
spp_api_v2/schemas/problem_detail.pydefinesProblemDetail, but routersraise plain
HTTPExceptionand the OCAfastapihandler(
fastapi/error_handlers.py:50) returns{"detail": ...}withapplication/json. For example404 {"detail": "Individual not found"}and422 {"detail": [{"loc": [...], "msg": ...}]}.To reproduce
GET /api/v2/spp/Individual/no-separatorreturns400 {"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_v2endpoint that rendersProblemDetail(application/problem+json), or update the principle doc todescribe 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-opensppv4. Full API study and adaptor plan are available on request.