TL;DR: I've been building the Social Resource Floor, an open blueprint for coordinating one person's access to basic survival resources (food, water, housing, energy, healthcare, and more) across many independent providers, so that reaching those resources is grounded in being human rather than in financial access. The blueprint is complete: language-neutral schemas, prose specifications, two independent implementations, a first adapter, and a conformance suite that has now rehearsed a small pilot end to end. This post is about the engineering choices behind it and the reasons for each: how it stays a contract rather than a product, how it keeps personal data out of the coordination layer, why it binds to existing standards instead of inventing new ones, and how I check that the contracts are implementation-independent rather than just claiming they are.
The problem the Floor is trying to help with
Today, for most people, survival routes through financial access. To reach food, housing, energy, or healthcare you generally need money, and to hold or move money you need banking, employment, or purchasing power. Financial access has become the gate standing in front of the resources a person needs to stay alive.
The goal of the Social Resource Floor is narrow and specific: to help make it so that financial status is not the condition that determines whether a person can reach the basic resources required to survive. It does not try to abolish money, banks, or markets. Money stays a first-class resource and delivery method. It aims at one thing: a floor beneath which no person should fall, defined locally, reachable regardless of financial circumstances.
That's the mission. Everything technical below exists to make that mission buildable by the institutions that would actually run it (governments, municipalities, NGOs, cooperatives, community providers) without asking any of them to give up their own systems or hand over their data.
Where the Floor sits
The delivery systems for social protection already exist and are strong: OpenSPP for orchestration, OpenG2P for delivery rails, OpenCRVS for civil registration. The Floor is not a replacement for any of them. It's a coordination layer above them, whose job is to let independent systems coordinate one person's whole survival floor, across many providers and many resource types at once, through shared, open interfaces, while each provider keeps its own mission and its own data.
The whole design follows from that position. A layer that sits above independently operated systems and coordinates without owning them has a specific set of constraints, and each of the choices below is an answer to one of them.
Choice 1: Contracts first, implementation second
The source of truth is a set of language-neutral JSON Schemas. Prose specifications describe their intent, a conformance suite validates against them, and reference implementations demonstrate them, but nothing in the repository is permitted to depend on a reference implementation. Every dependency arrow points inward, toward the schemas:
schemas/ source of truth, depends on nothing
^ ^ ^
| | |
specification/ conformance/ reference/ + adapters/
(prose) (validates) (demonstrate, non-authoritative)
The guiding rule is interface over implementation: the blueprint says what a component must be able to do, not which product must do it. The reason is adoption. If the reference implementation were the source of truth, then "conformant" would quietly come to mean "matches my code," and every participant would inherit my choices of language, database, and framework. Keeping the schemas authoritative and the reference deliberately non-authoritative means an independent party can build to the same contracts in a different stack and still interoperate. The concrete guardrail I hold myself to: if an adapter ever imports from the reference implementation, the dependency has inverted, and the project has started shipping a product instead of a contract.
Choice 2: The rules live in the contracts, not in the checkers
JSON Schema can say a field is a string. It can't say "this provider_id must name a provider that exists," or "an entitlement must use the unit of the resource it draws from." In the first version those rules lived as lists inside the conformance runners, written once in Python and again in JavaScript, which quietly made the runners part of the contract.
In 0.2.0 the schemas declare those rules themselves, with a small vocabulary of x-srf-* annotations placed beside the field they govern. An entitlement's decision_ref, for example, says the decision must exist, must allow an allocation, and must concern the same person:
"decision_ref": {
"type": "string",
"x-srf-ref": "decision",
"x-srf-match": [
{ "id": "decision-allows-entitlement", "op": "in", "there": "/outcome", "values": ["eligible", "partially_eligible"] },
{ "id": "decision-same-subject", "op": "equal", "here": "/subject_ref", "there": "/subject_ref" }
]
}
Both runners read every area, reference, and invariant from these annotations, and neither keeps a list of its own. Adding an area or a rule is now a schema edit plus fixtures, with no code change in either language. Any validator that doesn't know the annotations simply ignores them, so the schemas stay plain JSON Schema for every other tool. Shapes that several areas share (a delivery method, a time window, the provenance stamp) are defined once and referenced, so a new delivery method reaches every area at the same moment.
Choice 3: A small data spine
The core data model is three nouns:
- A provider publishes resources.
- A resource is something a provider can supply: food, water, energy, housing, or money, which is one resource type among several rather than the organizing principle.
- An entitlement allocates a resource to a subject and names the provider responsible for fulfilling it.
Around that spine sit six more schema areas (decision, provenance, event, consent, capability, and a public transparency report) for nine in total. Keeping the spine this small is deliberate: the fewer required concepts a participant has to adopt to join, the lower the barrier for a small community provider to become a full participant. Everything else is additive.
Drinking water joined the core resource types in 0.2.0. It is as basic to survival as food, and the original list simply didn't name it.
Choice 4: Keep personal data out of the coordination layer
A layer coordinating across many providers is a tempting place for personal data to accumulate, and that would be exactly the wrong outcome for a system meant to serve vulnerable people. So the design is data-minimizing by construction, not by policy.
The mechanism is a single field, subject_ref: an opaque, pseudonymous handle that means "the same subject, consistently, across the documents that need to correlate," and nothing more. It carries no name, no cleartext ID number, no readable date of birth. Resolving it into actual facts about a person is done by systems that already do that (civil registries, national ID systems, credential issuers) entirely outside the Floor. The Floor never makes the resolve() call itself; it only ever holds the opaque result. Identity is treated as an interface with zero provider lock-in, the same way policy is.
This choice shows up throughout. The conformance suite resolves cross-references like a resource's provider_id or an entitlement's resource_id, but it never resolves subject_ref. What it does check is that documents which must describe the same person agree: a decision and the entitlement it authorizes, a consent record and the one it supersedes, an event and the records it references all have to carry the same subject_ref. That makes "stable within a scope" a tested property without anything ever being looked up.
Event envelopes, which are built to be broadcast between systems, now have a declared, closed payload for every event type, so data can only ever carry the coordination facts listed for its type. A failed delivery's reason is a code such as not_collected, never free text, because a note written for a colleague is exactly where a name or an address slips into a broadcast. The suite holds a fixture for precisely that case, a delivery.failed event carrying a name and a home address, and rejects it. Provenance records describe systems and operators, never subjects. And a subject_ref is not globally stable by default: a handle minted for one provider or jurisdiction need not be the same string used elsewhere for the same person, which limits how far any correlation can travel.
Choice 5: Bind to what exists; invent as little as possible
The most consistent decision across the specification is that before defining anything, I checked whether a mature standard or an existing system already solved it, and bound to that rather than inventing a parallel version. Each spec area records what was checked first. In practice that meant:
-
Policy / eligibility. The Floor does not define a rules language. It defines the interface a policy engine must sit behind,
evaluate(policy_id, policy_version, jurisdiction, subject_context, resource?) -> DecisionRecord, and names existing engines as candidate bindings: OpenFisca for legislation-as-code, CEL (which OpenSPP already uses for eligibility), and OPA/Rego for authorization-style rules. The decision record'sengine.bindingfield just names which one produced a given outcome. Any of them can be swapped without changing anything downstream. - Consent. Rather than a boolean flag on a subject's file, consent is modeled as records following the W3C Data Privacy Vocabulary and ISO/IEC TS 27560: a status tied to a specific purpose, optionally narrowed to particular data categories or a particular provider, resource, or decision. OpenSPP's own consent module is already DPV-aligned, so a participating deployment has something concrete to bind to.
- Identity. Bound to DCI's typed-identifier lookups, W3C Verifiable Credentials with OIDC4VCI, and OIDC/eSignet/Keycloak as candidate resolvers. None is mandated, and all are external to the Floor.
-
Authorization. The cross-provider "which system may do what" question binds to OAuth2 client-credential scopes, the machine-to-machine pattern OpenSPP's API and DCI already use. It's kept separate from OpenSPP's
spp_user_rolesRBAC, which solves a different problem: access control within one deployment, not between independent providers. - Interoperability and capability discovery. These follow the pattern G2P Connect already uses in production: named, versioned, independently adoptable interface codes, where a participant implements only the codes relevant to its role and advertises which ones before an exchange begins.
The reason for this discipline is partly humility and partly durability: a coordination layer that reinvents consent, identity, and policy would be both arrogant about work others have done well and brittle against the systems it's meant to sit above. Binding to established interfaces means the Floor inherits their maturity and stays swappable as they evolve.
Choice 6: Append-only records, and reproducibility
Two smaller choices support auditability, which matters more than usual when the records decide what real people receive.
Records that change are never edited in place. A consent withdrawal is a new record with status: withdrawn and a supersedes pointer to the record it revokes, producing an append-only history a subject or auditor can walk. Provenance corrections work the same way: a correction supersedes rather than silently overwrites. The suite now checks that these chains close on a real record of the same subject and purpose. It checks against the complete set of documents, never in arrival order, so a participant that records a withdrawal before the record it revokes has arrived isn't penalized.
Decisions are built to be reproducible. policy_version is required on every decision record, because a decision that can't be re-evaluated against a fixed policy version can't be honestly audited later, whether for an appeal or a review. The same reasoning applies to the contracts themselves: every schema declares its own version, every document states the version it was written for, and the suite rejects a document its schema can't accept, so a version mismatch between two participants shows up before an exchange rather than as a mysterious failure after it.
Choice 7: Federation, and capability-based participation
The Floor is federated in the established sense: independent systems keep control of their own operation and data, coordinating through shared open interfaces, in the pattern of email and the web where no one owns the protocol. It does not use or assume blockchain, ledgers, or tokens. "No single authority" refers to distributed governance, not to removing trusted parties, since providers, auditors, and operators remain trusted actors in accountable roles.
Participation is capability-based: a participant declares and implements only the subset of areas relevant to its role, in a capability descriptor that says, area by area, whether it publishes, consumes, or both. A small community provider might only consume the resources and entitlements aimed at it and publish delivery events; a national ministry might publish resources, decisions, and entitlements while consuming consent records. Both are full, conformant participants. This is what makes the structure scale-independent: one provider on its own is a valid deployment, and it grows into a union of many providers without changing shape.
The first version also let a provider list its roles in a second, older vocabulary on the provider record itself. The two described the same thing and had already drifted apart in my own fixtures, so 0.2.0 removed the older list. The capability descriptor is now the only place a role is declared, and it can name specific event types, so "publishes delivery events but not eligibility events" is expressible without a second vocabulary.
A declared role only means something if it's checked. Every event now names the provider it's published for, in a required publisher_ref, separate from source (the system that emitted it) and from provider_ref (the provider it concerns). A provider that publishes an event type its descriptor doesn't cover fails the pilot's checks, and both implementations refuse it at the door. Consuming an event is different: reading one leaves no record in the Floor's documents, so a consumer role stays a declaration a counterpart can route by, and the specification says so plainly instead of implying a check that can't exist.
Choice 8: Room to grow without forking
Every schema rejects a field it doesn't recognize. That strictness is what makes the contracts trustworthy, but it has a cost: a municipality that needs one field the core never anticipated would otherwise have to fork a schema, and a forked floor is no longer a shared one.
So the contracts open exactly two declared doors. A resource or provider type can be a namespaced term, such as x-nl.amsterdam-noord:bicycle_repair, which a consumer that doesn't recognize it treats as opaque instead of rejecting. And providers, resources, and capability descriptors can carry an extensions object, grouped by the deployment's namespace. Both doors are shut on anything that describes a person (entitlements, decisions, consent, events) and on the public transparency report, because an open bag of fields beside a subject_ref is exactly where personal data leaks in. A term that several independent deployments come to share can be promoted into the core through the ordinary change process, as a minor version, without breaking the documents that used the namespaced form.
Checking that the contracts are implementation-independent
A blueprint that says "anyone can implement these interfaces" is making a claim, and I wanted that claim to be testable rather than taken on trust. That's what the conformance suite is for.
The runner checks three layers, because the contracts are stricter than any single JSON Schema can express:
- Structural. Each document is validated against its area's schema (JSON Schema 2020-12), formats included.
-
Referential. Every reference a schema declares must resolve: a resource's
provider_id, an entitlement'sresource_id,provider_id, anddecision_ref, a consent record'sscope.provider_ref, an event'sconsent_ref, and so on.subject_refis intentionally excluded. - Semantic. Invariants the schema standard can't state: linked records must agree (same subject, same resource, the resource's unit, a delivery method both sides can operate, a decision that allows the allocation), time windows must not end before they start, versions must be compatible, only one public report may exist per provider per quarter, and no figure in it may fall below its declared threshold, because small aggregates re-identify people.
Fixtures are organized by intent: documents under valid/ must pass every layer, and each document under invalid/ must be rejected for one exact reason, recorded as the rule and the path it fires at, and by no other layer. Pinning the reason matters. A fixture that happened to break for a neighboring reason would keep "passing" even if the check it was written for were deleted.
Hand-written fixtures only prove a rule fires once, so a mutation sweep also takes every valid fixture and breaks it every way its declared rules allow, one change at a time (drop a required field, point a reference at nothing, reverse a time window, change a value a match rule compares, file a second copy where only one may exist), and requires each break to be caught by exactly the rule it breaks. A coverage check then fails the suite if any declared rule is never seen firing at all, and on its first run it caught a rule nothing had ever exercised.
To check that "implementable by anyone" holds rather than just "implementable by my checker," there are two independent conformance runners over the same schemas: Python with the jsonschema library, and Node with ajv, a different language and a different validation engine, chosen so that agreement can't come from shared machinery. A small script runs both and compares every verdict:
py="$(python3 conformance/runner/validate.py | verdicts)"
node="$(cd implementations/node && node conformance.js | verdicts)"
if [ "$py" != "$node" ]; then
echo "cross-check: MISMATCH between the two implementations"
diff <(echo "$py") <(echo "$node")
exit 1
fi
echo "cross-check: OK, both implementations agree on all $count verdicts"
That comparison paid for itself as soon as format checking was switched on. The two engines turned out to disagree about timestamps: ajv accepted a space instead of the T, a leap second, the year 0000, and an hour of 24 that a time-zone offset brings back inside the day, and Python's validator rejected all four. Python's \d also matched Arabic-Indic digits in a version number where ajv's did not. No fixture had ever exercised any of it, so the suite had been green on top of five real disagreements. The contracts now define one timestamp profile that rules out every disputed form, patterns spell their character classes out, and differential testing across 39,965 generated timestamps finds the two engines agreeing on every one. Each disputed case has a fixture, so the cross-check keeps watching.
Today the suite gives 338 verdicts, covering 125 fixtures, 412 generated mutations, and two scenarios with 22 deliberate breakages between them, and both implementations agree on every one.
Rehearsing the smallest honest pilot
The first version ended by naming the next step: one municipality, one resource type, one capability path end to end, consent-gated, with a transparency report. That pilot now runs in CI, on synthetic records.
In the rehearsal, a national agency publishes a food resource and a community kitchen discovers and fulfills it. Three households give consent and are assessed. Two are found eligible; one collects its boxes and one doesn't; and the first withdraws its eligibility consent after the decision it allowed, which must not unmake that decision. A public transparency report closes the quarter.
Each success criterion is an executable check over that whole world: every step about a person had a consent in force at the moment it happened; every delivery that started was recorded to an end; each provider declared every role it played, down to the event types it publishes; a resource published by one provider was fulfilled by the other; a public report was filed for every quarter one is owed; and the records reproduce that report exactly. Each check also comes with deliberate breakages it must catch, and only it: an entitlement with no delivery consent behind it, a consent dated after the decision it was meant to allow, a pickup after consent was withdrawn, a delivery completed before it started, a kitchen publishing event types it never declared, a report claiming a count the records don't support.
Both reference implementations replay the rehearsal through their own interfaces, refusing at the door what a live floor must refuse, and produce the quarter's report from their own records. CI then exports what each actually holds and has both runners judge it. Built independently in two languages, the two implementations end with identical records and the same public report.
What the rehearsal caught
The rehearsal's first public report looked fine and passed every check. It said 2 entitlements were created, 1 delivery was completed and 1 failed, and it suppressed each delivery figure's breakdown because the numbers were small. But a suppressed bucket that is the only one in its figure equals the figure, so hiding it hid nothing. Anyone who knew one of the two households could read off what happened to the other.
Flooring the totals as well wouldn't have fixed it. A total beside one hidden bucket, or two totals a small distance apart, give a small count away by subtraction, and counting events rather than people meant one household's five failed pickups could publish as a 5. Every disclosure-control standard I read treats a total as just another cell once the population is small, and requires protection against exactly that subtraction.
So the report now follows one named method, which I called srf-sdc-1, and it works by saying less:
- One figure about people: the distinct households a provider's own resources reached in a calendar quarter. It is published only if it reaches the threshold, rounded to a multiple of 5, and otherwise it reads "suppressed", zero included, so "none" and "a few" look the same.
- A floor of 10, where most public-statistics practice sits, applied to the true count before rounding.
- One breakdown, delivered and not delivered, published whole or not at all, so there's no hidden part left to recover.
- A closed shape: no free-form metrics, rates or notes, and an id derived from the provider and the quarter rather than chosen.
- One report per provider per quarter, never rewritten. A late record counts in the quarter it arrived, and reports are due on a fixed schedule, so a report's existence never signals that something happened.
- Countable groups. If a resource serves a group a reader could count, such as one building's flats, "10 of 10" would name every household in it, so the uncounted side has to reach the threshold too.
A new check then derives every report again from the records and requires an exact match, which turns "the report carries no individual" from a claim into something CI proves. The honest consequence is that the rehearsal's report is now thin: one resource available, fewer than 10 households, synthetic data. A real pilot needs ten households in a quarter before any count appears at all. I'd rather the tool be true than impressive.
I then asked for an independent, adversarial review of that change, and it earned its keep. The rule for countable groups could be skipped when a building's records were synced in the quarter after its resource closed, so "10 of 10" could still slip out. And because the pilot only ever publishes "suppressed", the branches that publish a count and its breakdown were never exercised in CI: deleting them from both runners left everything green. Both are fixed. A second scenario now publishes counts, breakdowns, late records and a declared group, with a deliberate breakage for each branch, and removing any of them turns CI red. A check that can't fail isn't proving anything.
What this version is, and what it isn't
To be precise about scope, since the mission is easy to overstate:
This version is a coherent, language-neutral contract for coordinating survival resources across independent providers; a conformance suite that makes "implementable" testable and proves its own rules fire; a demonstration, checked by two independent implementations, that the contracts rather than one codebase decide conformance; and a rehearsal showing the contracts can carry a small pilot end to end.
It is not a running network, a governance body, or a funded pilot. The rehearsal's records are invented. Two runners I wrote agreeing is a strong signal that the schemas are implementation-independent, but it is not the same as two separate organizations interoperating in production. There are open questions I'm still working through, too. For one, the report's guarantee covers what can be worked out from the Floor's own documents; it can't reach what a reader already knows about their neighbors, or a publisher who misstates its records, and the specification lists those limits rather than hiding them. The next steps follow directly from that gap: an independent party running the same suite against their own output, and the real pilot, to show the small thing works before claiming the large one does.
That sequencing is intentional: build the shared understanding and the open contracts first, ship the smallest useful slice, and earn the standing to propose the larger structure to the institutions that would run it. Shared infrastructure of this kind rarely arrives because one person built all of it; it arrives because the groundwork already existed when a window opened.
Get involved
- Repository: https://gitlab.com/dobybaxter127/social-resource-floor
- Live site: https://social-resource-floor-5a9cc0.gitlab.io/
References
- Social Resource Floor, repository. https://gitlab.com/dobybaxter127/social-resource-floor
- OpenSPP. https://openspp.org/en/
- OpenG2P. https://www.openg2p.org/
- OpenCRVS. https://www.opencrvs.org/
- Digital Convergence Initiative. https://spdci.org/
- G2P Connect. https://g2p-connect.github.io/
- OpenFisca. https://openfisca.org/
- W3C Data Privacy Vocabulary (DPV). https://w3c.github.io/dpv/
- JSON Schema 2020-12. https://json-schema.org/
- RFC 3339, Date and Time on the Internet: Timestamps. https://www.rfc-editor.org/rfc/rfc3339
- Ajv JSON schema validator. https://ajv.js.org/
- jsonschema for Python. https://python-jsonschema.readthedocs.io/
- Government Statistical Service, disclosure control guidance for tables produced from administrative sources. https://gss.civilservice.gov.uk/wp-content/uploads/2018/03/Guidance-for-tables-produced-from-administrative-sources-4.pdf
- Handbook on Statistical Disclosure Control, 2nd edition. https://sdctools.github.io/HandbookSDC/Handbook-on-Statistical-Disclosure-Control.pdf
Top comments (0)