Organization profile syncing
Organizations often manage profiles across multiple grant systems, like Grants.gov, Candid, Temelio, and Fluxx. But today, when information about an organization changes (e.g. website or address), they need to make the same update to their profile in each system. CommonGrants would like to provide a standardized mechanism synchronizing these changes across systems automatically.
ADR-0023 introduced a pattern for matching organizations across platforms using their identifiers collection, and this ADR builds on this pattern by defining the standard API contract for making and reviewing changes to a given organization’s profile once it’s been matched to a record in another system.
The proposed contract remains intentionally agnostic about the architectural pattern used to synchronize changes across systems (pub-sub, webhooks, batch processing, etc.) with the goal of supporting multiple patterns concurrently. Instead, it focuses on defining things like: How can API clients view and make changes to a given organization’s profile? And how do we confirm they have the necessary permissions to do so?
Decision
Section titled “Decision”Questions
Section titled “Questions”- Authentication Which OAuth grant flows does the API contract support? Client credentials flow for machine-to-machine workflows or authorization code flow with PKCE for delegated access based on the consent of an org admin.
- Token format Should access tokens be stateless JWTs that contain signed authorization claims, or arbitrary strings that need to be validated through a separate API call?
- Permissions How are scopes formatted, and how is a token restricted to the organization it may act on?
- Updating a profile Do API clients update a profile with a direct
PATCH, a submitted change viaPOST /changes, or both? - Partial-update format Does the payload for partial updates use JSON Merge Patch, JSON Patch, field masks, or a combination?
- Viewing historical changes Are historical changes to a profile presented as a change log, list of revisions, or point-in-time query?
- Acceptance Are changes applied immediately, queued for review, or both, depending on the write path?
- Provenance How much actor identity is recorded, and how is PII kept out of shared payloads?
Decision summary
Section titled “Decision summary”- Authentication: Support both OAuth 2.0 flows. Use Client Credentials for service-to-service operations, and Authorization Code with PKCE when an org admin needs to grant access on a user’s behalf.
- Token format: Access tokens are self-contained JWTs (RFC 7519), so a receiver can validate one against the issuer’s JWKS without a separate lookup. The required claims are
iss sub aud iat exp scope, andgrant_typeand a namespacedorg_idare recommended. - Permissions: Scopes identify the operations permitted on a resource (e.g.
org:readandorg:write) and by default apply to all organizations asubcan access. Tokens that include a namespacedorg_idclaim are restricted to that org, and granting different levels of access to multiple organizations requires a separate token per org. - Updating a profile: Support a direct
PATCH /orgs/{orgId}and aPOST /orgs/{orgId}/changessubmission, both of which append to the same change ledger. APATCHrequiresorg:write, is accepted immediately, and is intended for changes from trusted clients. APOST /changesrequiresorg.changes:write, submits a change that can be accepted or rejected, and is intended for changes from external systems. An adopter can support one or both operations and grant access to them independently. - Partial-update format: Use JSON Merge Patch (RFC 7396): include a field to set it, leave it out to keep it unchanged, or send
nullto clear its value. - Viewing historical changes: Offer an optional
GET /orgs/{orgId}/changesthat returns a list of changes, each optionally including both its Merge Patch payload and a full snapshot with the change applied, plus an optional?at=parameter on theGET /orgs/{orgId}for a point-in-time snapshot. - Acceptance: Both operations return a change with a
status. APATCHis alwaysacceptedimmediately, and itssnapshotis the updated record; aPOST /changesmay beaccepted,denied,pending, orsuperseded. Both are listed by theGET /orgs/{orgId}/changesendpoint. - Provenance: The system records who made a change from the token itself, never from a field in the request body, along with the source system, and keeps human-identifying PII out of shared payloads.
Consequences
Section titled “Consequences”- Positive consequences
- Authentication: Follows established standards like OAuth 2.0 and JWT, allowing adopters to reuse their existing AuthN/Z infrastructure in many cases.
- Token format: A receiver can authorize a request directly from the signed token, without calling back to the issuer each time.
- Permissions: Operation-only scopes keep the vocabulary limited and match how Auth0 and GitHub scope a token; a token can name one org or omit
org_idto cover every org the subject can access, so it never has to enumerate a list. - Updating a profile: Adopters can support the operation(s) that fit their trust model, a direct
PATCHfor trusted in-system edits or a queuedPOST /changesfor external proposals, and both changes are written to a single ledger. - Partial-update format: A Merge Patch body closely reflects the shape of the organization record clients are trying to update, which makes it intuitive to use, and it is a widely used update format.
- Viewing historical changes: Full snapshots make it easy to fetch a record at a known version, and they still work if a deployment squashes intermediate versions.
- Provenance: Anchoring provenance to the token’s signed claims means a sender can’t forge who made a change.
- Negative consequences
- Authentication: Two flows require more building, testing, and documentation than one, and delegated flows ask senders to store and rotate refresh tokens server-side.
- Token format: Systems can’t immediately revoke a token, since a JWT stays valid until it expires, though short lifetimes and key rotation can reduce that risk.
- Permissions: A client that wants to sync multiple orgs with distinct permissions needs a separate token for each.
- Updating a profile: Supporting two types of write operations could add complexity for change resolution, especially if the same record receives both
POST /changesandPATCHrequests in close succession. - Partial-update format: Merge Patch can’t target a single array element, and it overloads
nullto mean “clear this field.” which adds complexity if systems implement different subsets of optional and custom fields. - Viewing historical changes: Full snapshots cost more to store (or compute) than a field-level change log, especially for records that change often.
- Provenance: Using JWT claims to determine provenance doesn’t let clients record changes on behalf of other requestors.
Decision drivers
Section titled “Decision drivers”- Follow existing standards and practices where they fit. Grounds the use of OAuth 2.0 and JWTs for auth, JSON Merge Patch (RFC 7396) for updates, and an operation-scope vocabulary modeled on common providers, so adopters reuse infrastructure and conventions they already run.
- Balance flexibility with standardization. Preserve optionality where several approaches coexist without conflict (either OAuth flow, a direct
PATCHor a submittedPOST /changesover one ledger, immediate or queued acceptance), but standardize on one where supporting several would produce conflicting or duplicate results (binding a token to an org through oneorg_idclaim rather than also encoding it in the scope string, JSON Merge Patch rather than several competing patch formats). - Pattern alignment. Follow existing CommonGrants conventions: identifier matching (ADR-0023), pagination (ADR-0011), and route-status tags (ADR-0019).
- Balance usability with security. Favor the option that is easiest to adopt unless it weakens security: self-contained JWTs over an introspection round trip, and a token-anchored provenance trail that records who changed what without leaking PII into shared payloads.
Operations with example payloads
Section titled “Operations with example payloads”Every endpoint sits under /common-grants/orgs, and the path-based {orgId} refers to the organization’s system-specific UUID (Organization.id). A client that only knows an external identifier, like an EIN, UEI, or platform ID, can look up the UUID via a filter query param on GET /orgs (see ADR-0023). The org record follows OrganizationBase. Server-assigned fields like datasetVersion appear in response bodies but are ignored in request bodies, and since field-level schemas still need to be finalized in the follow-up spec, the payloads below are just illustrative.
Successful responses use the standard CommonGrants envelope: Responses.Ok<T> wraps a single resource as { status, message, data } and Responses.Paginated<T> wraps a list as { status, message, items, pagination }, where the envelope’s status is the HTTP status code. The read and list examples below show only the data/items payload; the write examples show the full envelope, since a write returns a change whose own lifecycle status (accepted, pending, and so on) sits inside data.
Required endpoints
| Verb | Path | Purpose | Scope |
|---|---|---|---|
| GET | /orgs | List orgs | org:list |
| GET | /orgs/{orgId} | Read one org by UUID | org:read |
Write endpoints (a deployment SHOULD support at least one)
| Verb | Path | Purpose | Scope |
|---|---|---|---|
| PATCH | /orgs/{orgId} | Direct edit, applied now (JSON Merge Patch) | org:write |
| POST | /orgs/{orgId}/changes | Submit a change (may be queued for review) | org.changes:write |
Optional endpoints
| Verb | Path | Purpose | Scope |
|---|---|---|---|
| GET | /orgs/{orgId}/changes | List changes (patch + snapshot) | org.changes:read |
List orgs: `GET /orgs`
Required scope: org:list. By default this returns every organization the caller can view, which is likely the full set for a public directory. Results are paginated per ADR-0011, and each item is a summary that includes the org’s identifier collection. To look up an org by an external identifier, filter with registry and id, like ?registry=us:ein&id=123456789 (see ADR-0023).
Request:
GET /common-grants/orgs?page=1&pageSize=50Authorization: Bearer <jwt>Response:
{ "items": [ { "id": "01912a8b-7c3d-7890-abcd-ef1234567890", "name": "Example Nonprofit", "datasetVersion": 7, "identifiers": { "us:ein": { "id": "123456789" }, "us:uei": { "id": "AB0123456789" } } } ], "pagination": { "page": 1, "pageSize": 50, "totalItems": 1 }}Read one org: `GET /orgs/{orgId}`
Required scope: org:read, where {orgId} is the organization’s system-specific UUID (Organization.id). The response is the org record at its latest version, or at a specific version if requested; the optional ?at= read pattern is covered under changes.
Request:
GET /common-grants/orgs/01912a8b-7c3d-7890-abcd-ef1234567890Authorization: Bearer <jwt>Response:
{ "id": "01912a8b-7c3d-7890-abcd-ef1234567890", "name": "Example Nonprofit", "identifiers": { "systemId": { "registry": { "code": "grants.gov:org", "url": "/registries/grants-gov-org", "scope": "grants.gov", "kind": "platform" }, "id": "01912a8b-7c3d-7890-abcd-ef1234567890" }, "us:ein": { "registry": { "code": "us:ein", "url": "/registries/us-ein", "scope": "US", "kind": "government" }, "id": "123456789" } }, "orgType": { "term": "Hospital", "class": "Organization types", "code": "EO000000" }, "addresses": { "primary": { "street1": "456 Main St", "city": "Anytown", "stateOrProvince": "CA", "country": "US", "postalCode": "12345" } }, "phones": { "primary": { "countryCode": "+1", "number": "444-456-1230", "isMobile": true } }, "emails": { "primary": "info@example.com" }, "mission": "To provide support and resources to the community.", "yearFounded": "2024", "socials": { "website": "https://www.example.com" }, "datasetVersion": 7}Update an org: `PATCH /orgs/{orgId}`
Required scope: org:write. The body is a JSON Merge Patch (RFC 7396): include a field to set it, leave it out to keep it unchanged, or send null to clear it. The receiver determines who made the change and which system it came from using the token and request context, not fields in the request body (see provenance).
Request:
PATCH /common-grants/orgs/01912a8b-7c3d-7890-abcd-ef1234567890Authorization: Bearer <jwt>Content-Type: application/merge-patch+json{ "name": "Example Nonprofit (Renamed)", "mission": "To expand access to community health resources.", "socials": { "website": null }}Response: 200 OK. Like every write, the result is a change in the standard envelope. A PATCH is applied immediately, so its change is accepted, and snapshot is the full updated record at its new datasetVersion.
{ "status": 200, "message": "Change applied", "data": { "id": "ch_01912a8b", "status": "accepted", "datasetVersion": 9, "patch": { "name": "Example Nonprofit (Renamed)", "mission": "To expand access to community health resources.", "socials": { "website": null } }, "snapshot": { "id": "01912a8b-7c3d-7890-abcd-ef1234567890", "name": "Example Nonprofit (Renamed)", "mission": "To expand access to community health resources.", "socials": {}, "datasetVersion": 9 } }}To submit a change that may be reviewed before it takes effect, use POST /orgs/{orgId}/changes instead, which can return a pending change.
Submit a change: `POST /orgs/{orgId}/changes`
Required scope: org.changes:write. Creates a change from a JSON Merge Patch body. A receiver that applies it right away returns accepted; one that routes it through review returns pending with a Location header for the new change (see acceptance semantics). A direct PATCH /orgs/{orgId} covers the apply-now case, and both operations appear in GET /orgs/{orgId}/changes. The exact request envelope is left to the follow-up spec.
Request:
POST /common-grants/orgs/01912a8b-7c3d-7890-abcd-ef1234567890/changesAuthorization: Bearer <jwt>Content-Type: application/merge-patch+json{ "mission": "To expand access to community health resources.", "socials": { "website": null }}Response: 202 Accepted, with the change in the standard envelope. A receiver that applies it right away returns an accepted change with a snapshot; one that routes it through review returns pending.
202 AcceptedLocation: /common-grants/orgs/01912a8b-7c3d-7890-abcd-ef1234567890/changes/ch_01912a8b{ "status": 202, "message": "Change accepted for review", "data": { "id": "ch_01912a8b", "status": "pending", "patch": { "mission": "To expand access to community health resources.", "socials": { "website": null } } }}View changes: `GET /orgs/{orgId}/changes`
Required scope: org.changes:read. This endpoint is optional, and it returns a list of changes, newest first. Each entry can optionally include both the Merge Patch that was submitted and a full snapshot of the record with that change applied, so a consumer sees the delta and the resulting state without a second request. Each entry notes the source system it came from but not the person behind the change (see provenance). The exact schema is left to the follow-up spec.
Request:
GET /common-grants/orgs/01912a8b-7c3d-7890-abcd-ef1234567890/changesAuthorization: Bearer <jwt>Response:
{ "items": [ { "id": "ch_01912a8b", "status": "accepted", "datasetVersion": 9, "modifiedAt": "2026-06-20T14:30:00Z", "source": "grants.gov", "patch": { "name": "Example Nonprofit (Renamed)", "mission": "To expand access to community health resources." }, "snapshot": { "id": "01912a8b-7c3d-7890-abcd-ef1234567890", "name": "Example Nonprofit (Renamed)", "mission": "To expand access to community health resources." } }, { "id": "ch_00a7f2c1", "status": "accepted", "datasetVersion": 7, "modifiedAt": "2026-03-15T09:00:00Z", "source": "candid", "snapshot": { "id": "01912a8b-7c3d-7890-abcd-ef1234567890", "name": "Example Nonprofit", "mission": "To provide support and resources to the community." } } ], "pagination": { "page": 1, "pageSize": 50, "totalItems": 2 }}Scope vocabulary
Section titled “Scope vocabulary”Scopes only name operations. Which organization a token can act on comes from its org_id claim, not from the scope string. A token that omits org_id can exercise its scopes against every organization the subject can access, so org:read or org:list with no org_id reads or enumerates all of them, as far as the receiver’s own policy allows.
| Scope | Description |
|---|---|
org:list | Enumerate accessible organizations |
org:read | Read organization profiles |
org:write | Apply a direct edit (PATCH /orgs/{orgId}) |
org.changes:read | Read the changes feed (patches and snapshots) |
org.changes:write | Submit a change for review (POST /orgs/{orgId}/changes) |
Required JWT claims
Section titled “Required JWT claims”Example token payload and claim requirements
{ "iss": "https://auth.example.com", "sub": "svc_abc123", "aud": "https://sync.example.com", "iat": 1716000000, "exp": 1716003600, "scope": "org:read org:write", "grant_type": "client_credentials", "https://commongrants.org/org_id": "01912a8b-7c3d-7890-abcd-ef1234567890"}| Claim | Required | Description |
|---|---|---|
iss | MUST | Issuer. Receivers MUST verify it matches a trusted authorization server. |
sub | MUST | Subject. The service account or user ID the token was issued to. |
aud | MUST | Audience. The receiving sync API’s base URL. Receivers MUST reject tokens whose aud does not match theirs. |
iat | MUST | Issued-at timestamp (Unix epoch). |
exp | MUST | Expiration timestamp. Receivers MUST reject expired tokens. |
scope | MUST | Space-separated list of granted operation scopes. |
grant_type | SHOULD | The grant flow used (client_credentials or authorization_code), so receivers can vary trust rules by flow. |
org_id | SHOULD | The organization this token is limited to, as its Organization.id UUID, namespaced as a private claim (https://commongrants.org/org_id). Omit it for a token that should act on every org the subject can access (for example a listing or cross-org read token); the receiver then resolves the accessible orgs from its own policy. |
Inbound trust decision
Section titled “Inbound trust decision”Before it accepts a change request, a receiving system runs these checks in order, and a failure at any step rejects the request. Steps 1-4 confirm the token is authentic and unexpired, steps 5-6 confirm it’s allowed to perform this operation on this organization, and step 7 keeps a valid token from overriding the receiver’s own access rules.
- Validate the JWT signature against the issuer’s JWKS; reject if invalid or unresolvable.
- Verify
issis a known, trusted authorization server. - Verify
audmatches the receiver’s own base URL (stops a token for one system being replayed against another). - Check
exp; reject expired tokens, allowing at most 60 seconds of clock skew. - Verify
scopecovers the operation: a directPATCHneedsorg:write, aPOST /changessubmission needsorg.changes:write, and a read needsorg:read. - Check
org_id. When the token includes anorg_id, verify it matches the target org: a token bound to oneOrganization.idis rejected for a request against any other, even if itsscopeis write-capable. When the token omitsorg_id, it isn’t limited to one org, so the receiver relies on the scope plus its local access policy (step 7) to decide which orgs the subject may touch. - Apply local access policy. A valid token does not override the receiver’s rules; if
subcannot modify the target org locally, reject with403 Forbidden.
Next steps
Section titled “Next steps”This ADR outlines the basic API contract, but the following details will need to be determined when this contract is added to the next version of the CommonGrants API spec:
- The exact request and response bodies for each of the proposed routes and operations.
- How HTTP statuses map to the change request status for
POST /changesandPATCHoperations. - The content and shape of new schemas like a change record.
- Which filters can be supported as query params, and which should be reserved for a
POST /searchendpoint. - How a client obtains a token a given receiver will accept, where each system issues its own tokens to registered clients.
- Whether to adopt a hardening profile like FAPI on top of OAuth 2.0 and JWT.
A few things are also out of scope for this decision, mostly centered around the transport mechanisms for synchronizing changes across systems, as mentioned above:
- Outlining push and webhook transport options.
- Defining the pattern for registering or publishing to subscribers.
- Describing the full sync protocol two systems run when they first connect.
Evaluation
Section titled “Evaluation”Authentication
Section titled “Authentication”Which OAuth grant flows should the contract support?
Side-by-side comparison
Section titled “Side-by-side comparison”- ✅ Criterion met
- ❌ Criterion not met
- 🟡 Partially met or unsure
| Criteria | Both (rec.) | Client credentials only | Auth code + PKCE only |
|---|---|---|---|
| Supports unattended machine-to-machine sync | ✅ | ✅ | ❌ |
| Supports human-consented delegated access | ✅ | ❌ | ✅ |
| Reuses standard OAuth 2.0 infrastructure | ✅ | ✅ | ✅ |
| Minimal number of flows to implement | ❌ | ✅ | ✅ |
| Fits a platform syncing on behalf of many orgs | ✅ | ❌ | ✅ |
Option 1: Support both flows (recommended)
Section titled “Option 1: Support both flows (recommended)”Use Client Credentials when a backend service syncs on its own. The service authenticates as itself and reruns the flow when its token expires, with no refresh token:
POST /oauth/tokengrant_type=client_credentials&client_id=svc_abc123&client_secret=...&scope=org:read org:writeUse Authorization Code with PKCE when an org admin has to consent, or when a platform syncs on an org’s behalf. PKCE ties the authorization code to the client that requested it, so a stolen code is useless to anyone else, and it’s required for public clients like a browser, CLI, or mobile app:
GET /authorize?response_type=code&client_id=platform_xyz&scope=org:read org:write&code_challenge=<derived-from-verifier>&code_challenge_method=S256In this flow the token represents a one-time human consent, so the refresh token becomes the lasting record of that consent and lets a platform keep syncing without re-prompting. Senders MUST keep refresh tokens server-side and MUST NOT let them reach a browser or other client-side environment.
- Pros
- Covers both the machine-to-machine and human-in-the-loop cases without a custom scheme.
- Lets a platform sync many orgs under delegated consent while a service syncs its own data directly.
- Both flows are standard OAuth 2.0, so existing identity providers and SDKs work out of the box.
- Cons
- Two flows are more to build, test, and document than one.
- Delegated flows put refresh-token storage and rotation on senders.
Option 2: Client credentials only
Section titled “Option 2: Client credentials only”- Pros
- A single, simple flow with no consent screens or refresh tokens.
- Cons
- No way to capture an org admin’s consent.
- A platform syncing for many orgs would have to model each as its own service credential, losing the delegated-consent trail.
Option 3: Authorization code + PKCE only
Section titled “Option 3: Authorization code + PKCE only”- Pros
- Strong protection for human-initiated flows.
- Cons
- Awkward for unattended sync, where there’s no human to consent and no browser to redirect.
Token format
Section titled “Token format”Are access tokens self-contained JWTs, or opaque strings a receiver has to look up?
Side-by-side comparison
Section titled “Side-by-side comparison”| Criteria | JWT (rec.) | Opaque + introspection |
|---|---|---|
| Validates without a per-request callback | ✅ | ❌ |
| Reuses the standard SDK and library ecosystem | ✅ | 🟡 |
| Immediate revocation | ❌ | ✅ |
| Small, opaque token string | ❌ | ✅ |
Claims (scope, org_id) readable without a lookup | ✅ | ❌ |
Option 1: Self-contained JWT (recommended)
Section titled “Option 1: Self-contained JWT (recommended)”Access tokens are JWTs (RFC 7519). A receiver checks the signature against the issuer’s JWKS and reads scope and org_id straight from the token (the payload shown under Required JWT claims) to make its decision, with no extra round trip. Short lifetimes keep the revocation window small:
-
access tokens live 15 to 60 minutes,
-
refresh tokens live up to a year and rotate on each use,
-
authorization codes are good for 60 seconds and a single use.
-
Pros
- A receiver authorizes from the token alone.
- Broad library support across languages and frameworks.
- The scopes and org binding are right there in the token for the trust checks.
-
Cons
- A token stays valid until it expires, so revocation isn’t immediate; short lifetimes keep the window small.
- Receivers have to fetch and cache the issuer’s keys and handle rotation.
Option 2: Opaque token plus introspection
Section titled “Option 2: Opaque token plus introspection”The token is a random string with no readable claims, so a receiver validates it by calling the issuer’s introspection endpoint (RFC 7662) on each request:
POST /introspecttoken=a1b2c3d4e5f6g7h8
→ { "active": true, "sub": "svc_abc123", "scope": "org:read org:write", "exp": 1716003600 }- Pros
- Revocation is immediate: the introspection endpoint can refuse a revoked token right away.
- The token string contains no readable claims.
- Cons
- Every request needs an introspection call (RFC 7662) unless it’s cached, which adds latency and a dependency on the issuer being up.
- Less well supported by the JWT-centric SDK ecosystem adopters already use.
Permissions
Section titled “Permissions”Is the organization a token can act on named per token, given as a list, or set per-org with its own access level?
All three options keep the organization ID out of the scope string, which is what GitHub and Google both do: GitHub makes the org a property of the token rather than part of a scope, and Google’s scopes are capability URLs with the resource resolved separately. Encoding the org in the scope (org:write:{orgId}) is the alternative we reject, since a token that touches many orgs would need a scope string per org, and the suffix just duplicates the org binding the token already provides through its org_id claim. The open question is how the token names the org (or orgs) it can act on.
Side-by-side comparison
Section titled “Side-by-side comparison”- ✅ Criterion met
- ❌ Criterion not met
- 🟡 Partially met or unsure
| Criteria | One token per org (rec.) | Multi-org, uniform | Multi-org, per-org levels |
|---|---|---|---|
| Uses a standard or common claim | ✅ | ❌ | ✅ |
| Token size independent of org count | ✅ | ❌ | ❌ |
| One token can act on many orgs | ❌ | ✅ | ✅ |
| Supports different access levels per org | ✅ | ❌ | ✅ |
| Simple for a receiver to enforce | ✅ | ✅ | 🟡 |
Option 1: One token per organization (recommended)
Section titled “Option 1: One token per organization (recommended)”A token is bound to one organization through a namespaced org_id claim set to that org’s Organization.id UUID:
{ "scope": "org:read org:write", "https://commongrants.org/org_id": "01912a8b-7c3d-7890-abcd-ef1234567890"}This mirrors how established providers scope a token to a tenant:
- Auth0’s Organizations feature issues a token with a single
org_id(plusorg_name) - Microsoft Entra names a single tenant in
tid - GitHub follows the same model, where a fine-grained token is limited to a single organization and the org is a property of the token rather than a scope string.
We decided to namespace the claim with https://commongrants.org following the collision-safe convention that Auth0 requires for custom claims.
If a token omits the org_id claim, then its scopes apply to every organization the subject can access, based on the permissions that subject already has in the receiving API. This allows clients to do things like list and read multiple orgs without having to create several tokens, each with its own org_id claim. Google follows a similar pattern, in which the token names only the supported operations and the resource server resolves the records against which a client can perform those operations.
- Pros
- Matches a widely understood model (Auth0
org_id, Microsofttid, GitHub per-org tokens). - The token stays small no matter how many orgs a client manages.
- Keeps token scopes conceptually simple by applying a single set of permissions to either a single org or all orgs that a client can access.
- Matches a widely understood model (Auth0
- Cons
- A client that syncs many orgs with different permissions needs a separate token for each.
Option 2: One multi-org token with uniform access
Section titled “Option 2: One multi-org token with uniform access”A single token names several org IDs, with its scopes applying uniformly to all of them:
{ "scope": "org:read org:write", "https://commongrants.org/org_ids": [ "01912a8b-7c3d-7890-abcd-ef1234567890", "02a33c9d-1e2f-4a5b-8c7d-9e0f1a2b3c4d" ]}- Pros
- One token can act on many orgs, so a platform needs only one credential.
- Cons
- The scopes apply uniformly to every org in the list, so “read here, write there” isn’t expressible.
org_idsis a custom array claim with no standard behind it, and the token grows with the number of orgs.
Option 3: One multi-org token with per-org levels (RFC 9396)
Section titled “Option 3: One multi-org token with per-org levels (RFC 9396)”RFC 9396 defines an authorization_details claim: a JSON array where each entry binds an action set to a specific resource, using the standard type, identifier, and actions fields. It’s built for exactly the mixed case (read one org, write another) and coexists with scopes rather than replacing them:
{ "authorization_details": [ { "type": "org_profile", "identifier": "01912a8b-7c3d-7890-abcd-ef1234567890", "actions": ["read"] }, { "type": "org_profile", "identifier": "02a33c9d-1e2f-4a5b-8c7d-9e0f1a2b3c4d", "actions": ["read", "write"] } ]}- Pros
- The one real standard for expressing different access levels per org in a single token.
- Coexists with the operation-scope vocabulary rather than replacing it.
- Cons
- Heavier for issuers to mint and receivers to parse than a single
org_id. - Not universally supported across identity providers yet.
- Heavier for issuers to mint and receivers to parse than a single
Updating a profile
Section titled “Updating a profile”A direct PATCH, a submitted POST /changes, or both over one change ledger?
Both write paths append to the same change ledger (GET /orgs/{orgId}/changes); the difference is whether the edit applies immediately or can be queued for review. A PATCH creates and applies a change in one step (recorded as accepted), which suits trusted in-system edits. A POST /orgs/{orgId}/changes submits a change a receiver can return as pending, which suits proposals from external systems. Both use the same JSON Merge Patch body. A full PUT replace isn’t offered, since a sender that doesn’t model every field would clear the ones it omits (see partial-update format).
Side-by-side comparison
Section titled “Side-by-side comparison”- ✅ Criterion met
- ❌ Criterion not met
- 🟡 Partially met or unsure
| Criteria | Both (rec.) | PATCH only | POST /changes only | PUT (full replace) |
|---|---|---|---|---|
| Simple, familiar direct edits | ✅ | ✅ | 🟡 | ✅ |
| Safe when systems model different fields | ✅ | ✅ | ✅ | ❌ |
| Proposals can be queued for review | ✅ | ❌ | ✅ | ❌ |
| One change ledger behind every write | ✅ | ✅ | ✅ | 🟡 |
| Lets adopters match their own trust model | ✅ | ❌ | ❌ | ❌ |
Option 1: Support both, over one ledger (recommended)
Section titled “Option 1: Support both, over one ledger (recommended)”A PATCH /orgs/{orgId} is the direct path: the receiver applies the edit and records it as an accepted change behind the scenes. A POST /orgs/{orgId}/changes is the reviewable path: the change is created with its own ID and status and can sit as pending until a receiver approves it.
POST /orgs/{orgId}/changes→ 202 Accepted Location: /orgs/{orgId}/changes/ch_01912a8b
{ "id": "ch_01912a8b", "status": "pending", "patch": { "mission": "..." } }Both take the same JSON Merge Patch body and both show up in GET /orgs/{orgId}/changes, so history is uniform no matter how a write arrived. The two paths use different scopes (org:write for PATCH, org.changes:write for POST /changes, mirroring org.changes:read), so a deployment can grant a partner the ability to propose changes without granting direct-write access. A deployment can expose whichever entry points fit its trust model:
- just
PATCH, if it only makes trusted in-system edits, - just
POST /changes, if it only accepts changesets from outside, - or both.
This mirrors the two OAuth flows: one contract, and adopters use the entry points that fit.
This is a common pattern in existing APIs, where you POST to create an object and get back an id and a status to check later:
- Stripe models a refund or a payment intent this way,
- Gerrit uses a literal
/changes/collection whose entries each have a status, - GitHub does the same with pull requests,
The broader async convention returns 202 Accepted with a status monitor to poll, as in Google’s long-running operations and the Microsoft REST guidelines.
- Pros
- Gives a trusted writer a familiar
PATCHand an external proposer a reviewable submission, without forcing either into the other’s shape. - Every write, whichever path, appends to one ledger, so provenance and history stay uniform.
- Adopters implement only the entry points their trust model needs.
- Gives a trusted writer a familiar
- Cons
- Two write entry points are more to build, test, and document than one.
- A deployment that exposes both must keep their behavior (validation, provenance, status) consistent.
Option 2: Direct PATCH only
Section titled “Option 2: Direct PATCH only”- Pros
- The smallest, most familiar write surface: one verb, applied immediately.
- Still records each edit in the change ledger.
- Cons
- No reviewable path: a receiver can only accept or reject a direct write, not queue an untrusted sender’s change as a proposal.
Option 3: POST /changes only
Section titled “Option 3: POST /changes only”- Pros
- A single write path to build and secure, with no direct-mutation verb to reconcile.
- Every write is a first-class change with its own ID and status to inspect.
- Cons
- Heavier for the common trusted edit: create a change, then check its status, where a
PATCHwould be a single call.
- Heavier for the common trusted edit: create a change, then check its status, where a
Option 4: PUT full replace (not offered)
Section titled “Option 4: PUT full replace (not offered)”Different systems won’t support the same set of optional fields. If System A knows only name and mission, a full PUT wipes whatever System B populated but System A doesn’t model; a PATCH (or a POST /changes with a merge patch) touches only the fields it sends:
# PUT: fields System A omits (socials, addresses, ...) get wipedPUT /orgs/{orgId}{ "name": "Example Nonprofit", "mission": "..." }
# PATCH: unmentioned fields survivePATCH /orgs/{orgId}{ "mission": "..." }- Pros
- One write verb, and replace semantics are simple to reason about.
- Cons
- A sender that doesn’t model every field can silently wipe another system’s data.
- Changing one field means transmitting the whole record.
Partial-update format
Section titled “Partial-update format”JSON Merge Patch, JSON Patch, field masks, or a combination?
Side-by-side comparison
Section titled “Side-by-side comparison”| Criteria | Merge Patch (rec.) | JSON Patch (op) | Field masks |
|---|---|---|---|
| Patch body has the same shape as the org record | ✅ | ❌ | 🟡 |
| No separate operations format to learn | ✅ | ❌ | 🟡 |
| Tells “clear a field” apart from “leave unchanged” | 🟡 | ✅ | ✅ |
| Element-level array operations | ❌ | ✅ | ❌ |
| Widely supported as an RFC standard | ✅ | ✅ | 🟡 |
Option 1: JSON Merge Patch, RFC 7396 (recommended)
Section titled “Option 1: JSON Merge Patch, RFC 7396 (recommended)”The patch body looks just like the org record: include a field to set it, leave it out to keep it unchanged, or send null to clear it. Sent as application/merge-patch+json (RFC 7396):
{ "mission": "To expand access to community health resources.", "socials": { "website": null }}This sets mission, clears socials.website, and leaves every other field untouched.
- Pros
- Reuses the org record shape, so there’s nothing new to learn.
- Compact for small diffs.
- A standard (RFC 7396), sent as
application/merge-patch+json.
- Cons
- Can’t target a single array element; an array is replaced whole.
nullis overloaded to mean “clear,” so “set this field to literal null” isn’t separately expressible.
Option 2: JSON Patch, RFC 6902
Section titled “Option 2: JSON Patch, RFC 6902”The same change is a list of operations keyed off JSON Pointer paths (RFC 6902), sent as application/json-patch+json:
[ { "op": "replace", "path": "/mission", "value": "To expand access to community health resources." }, { "op": "remove", "path": "/socials/website" }]- Pros
- Explicit operations (
add,remove,replace,move), including on array elements. - Unambiguous about clearing versus setting null.
- Explicit operations (
- Cons
- A separate operations format with pointer paths, unlike the record-shaped body adopters already use.
- More than org sync needs.
Option 3: Field masks (Google style)
Section titled “Option 3: Field masks (Google style)”The changed fields are named in a mask alongside the values, following Google’s field mask convention:
{ "updateMask": "mission,socials.website", "org": { "mission": "To expand access to community health resources.", "socials": { "website": null } }}- Pros
- An explicit
updateMaskcleanly separates “fields I’m setting” from “fields I’m leaving alone,” including clearing.
- An explicit
- Cons
- Adds a parallel mask field to keep in step with the body.
- Less idiomatic for JSON REST APIs than Merge Patch.
Viewing historical changes
Section titled “Viewing historical changes”A change log, a list of revisions, or a point-in-time query?
The changes endpoint is optional, and a deployment MAY squash intermediate snapshots, so the contract doesn’t require every version to be retrievable. That squash allowance is a key driver below.
Side-by-side comparison
Section titled “Side-by-side comparison”| Criteria | Version list (rec.) | Change log | Point-in-time ?at= |
|---|---|---|---|
| Direct lookup of a record at a known version | ✅ | ❌ | ✅ |
| Tolerates snapshot squashing | ✅ | ❌ | 🟡 |
| Composes with the existing dataset-version concept | ✅ | 🟡 | ✅ |
| Fine-grained “who changed which field” | 🟡 | ✅ | ❌ |
| Cheapest server-side storage | 🟡 | ✅ | ❌ |
| Browsable without knowing a version up front | ✅ | ✅ | ❌ |
Option 1: Version list of full snapshots (recommended)
Section titled “Option 1: Version list of full snapshots (recommended)”GET /orgs/{orgId}/changes returns a list of changes, each with its version metadata and, optionally, both the Merge Patch that was submitted and a full snapshot with it applied (the full payload is shown under View changes):
{ "datasetVersion": 9, "modifiedAt": "2026-06-20T14:30:00Z", "patch": { "mission": "..." }, "snapshot": { "name": "...", "mission": "..." }}With full snapshots:
- fetching a record at a known version is a direct lookup, with no event replay,
- diffing two versions is “fetch both and compare,”
- dropping intermediate versions (the squash the contract allows) still leaves a navigable timeline.
This composes with the dataset-version number reads and writes already return. An optional ?at={timestamp} parameter on the read is a complementary pattern: it lets a consumer ask for “the record as of then” without learning a separate history shape. The version-list shape is common in the wild, like Google Drive revisions, Confluence versions, and MediaWiki history.
- Pros
- Direct version lookup, no replay.
- Straightforward diffing.
- Tolerates squashing, which the contract explicitly permits.
- Self-contained snapshots remain usable as the schema changes over time.
- Cons
- More storage than a change log, especially for large records that change often.
- Per-field “who changed what” means comparing adjacent snapshots.
Option 2: Change log of field-level deltas
Section titled “Option 2: Change log of field-level deltas”History is a stream of field-level deltas, sized by activity rather than record size:
{ "field": "mission", "from": "To provide...", "to": "To expand...", "at": "2026-06-20T14:30:00Z"}- Pros
- Cheapest to store: append-only events sized by activity, not record size.
- Easy to filter by field, actor, or time, and a natural fit for activity feeds.
- Cons
- Rebuilding state at a point in time means replaying events from the start.
- Diffing two arbitrary points means folding events together.
- Tightly coupled to the schema shape, and squashing loses individual events.
Option 3: Point-in-time query only
Section titled “Option 3: Point-in-time query only”There’s no history list; a consumer reads the main endpoint with an ?at= timestamp and gets the full record as of that moment:
GET /orgs/{orgId}?at=2026-03-15T00:00:00Z→ full org record as it stood on that date- Pros
- No new endpoint; reuses the main read with an added parameter.
- One request returns the full state at the requested moment.
- Cons
- No way to list versions or actors, so history isn’t discoverable in a UI.
- Highest server cost, since arbitrary point-in-time answers need fine-grained snapshots or on-demand replay.
Acceptance
Section titled “Acceptance”Does a change resolve immediately, or can its status also represent a queued review?
Every successful write returns a change record with a status attribute. A 2xx PATCH response always has status: accepted, since these updates are made directly by trusted clients, following PATCH semantics. The main question is whether a POST /changes submission can also come back pending for later review, or whether every change should be resolved immediately.
Side-by-side comparison
Section titled “Side-by-side comparison”| Criteria | Status supports both (rec.) | Always immediate | Always queued |
|---|---|---|---|
| Supports immediate-apply deployments | ✅ | ✅ | ❌ |
| Supports approval-gated deployments | ✅ | ❌ | ✅ |
| One status model regardless of workflow | ✅ | ❌ | ❌ |
| Simple for the always-apply case | 🟡 | ✅ | ❌ |
| Submitter can tell whether a change took effect | ✅ | ✅ | ✅ |
Option 1: Status supports both outcomes (recommended)
Section titled “Option 1: Status supports both outcomes (recommended)”A change comes back with a status in data, so the same shape works whether the receiver applies it or queues it for review.
Applied immediately:
{ "id": "ch_01912a8b", "status": "accepted", "datasetVersion": 9 }Queued for review:
{ "id": "ch_01912a8b", "status": "pending" }The full set is accepted (applied), denied (rejected, with a reason), pending (queued for review), or superseded (a newer change won), so a submitter doesn’t have to know the receiver’s workflow ahead of time. A PATCH is always accepted immediately, so this really governs the POST /changes path. How each status maps to an HTTP code is left to the follow-up spec.
- Pros
- One contract spans both immediate and queued deployments.
- The submitter always learns what happened to its change.
- Adding an approval workflow later doesn’t change the contract.
- Cons
- A receiver that always applies immediately still returns a
statusit may not need, though it can point trusted clients toPATCH. - The status values and their fields need careful definition so receivers report them consistently.
- A receiver that always applies immediately still returns a
Option 2: Always immediate
Section titled “Option 2: Always immediate”- Pros
- Simplest model: a change is
accepted(ordenied) the moment it’s submitted.
- Simplest model: a change is
- Cons
- No way to represent a queued change, so approval-gated receivers can’t take part.
Option 3: Always queued
Section titled “Option 3: Always queued”- Pros
- Natural for review-heavy workflows.
- Cons
- Overhead for receivers that apply immediately, which still expose a
pendingstate and a way to learn the final outcome.
- Overhead for receivers that apply immediately, which still expose a
Provenance
Section titled “Provenance”Is provenance derived from the validated JWT, or taken from the request body?
Provenance here means who made a change and which system it came from. What a receiver then stores to represent it (the history shape, retention, how an actor is referenced) is deferred to the follow-up spec; the decision here is only where that information comes from.
Side-by-side comparison
Section titled “Side-by-side comparison”- ✅ Criterion met
- ❌ Criterion not met
- 🟡 Partially met or unsure
| Criteria | From the JWT (rec.) | From the request body |
|---|---|---|
| Can’t be forged or tampered by the sender | ✅ | ❌ |
| Anchored to an authenticated identity | ✅ | ❌ |
| Nothing extra for the client to send or validate | ✅ | 🟡 |
| Can attribute a change to a non-token actor | 🟡 | ✅ |
Option 1: Derived from the JWT claims (recommended)
Section titled “Option 1: Derived from the JWT claims (recommended)”The receiver takes the acting identity from the validated JWT (sub, plus the consenting user it represents in a delegated flow) and the source system from request context. The PATCH body contains only profile fields, never a self-declared actor, so a sender can’t claim to be someone else. If a change needs to be attributed to a specific end user rather than a service account, that comes from a delegated token whose sub is that user (or a dedicated actor claim), still signed by the authorization server.
- Pros
- A sender can’t forge the actor; provenance is only as trustworthy as the signed token.
- Nothing extra for the client to send, and no body field for the server to second-guess.
- Consistent with the token that already authorizes the request.
- Cons
- Attributing a change to a specific end user means issuing a token for that user (or including an actor claim), not just setting a body field.
Option 2: Included in the request body (not recommended)
Section titled “Option 2: Included in the request body (not recommended)”The sender declares who made the change alongside the profile fields:
{ "modifiedBy": "jane.doe@example.com", "mission": "To expand access to community health resources."}It’s flexible, since a platform can attribute a change to any of its internal users without minting a token per user, but the value is unverified: a receiver can’t tell a truthful modifiedBy from a forged one, so it can’t be trusted for audit.
- Pros
- Simple, and lets a sender attribute a change to any actor without a per-actor token.
- Cons
- Unverified and easily tampered with: anyone who can write can set it to anything, so it’s worthless as an audit signal.
- Puts whatever the sender chooses, often PII, into the shared payload.