Status: Draft for review Storage decision: Versions are stored as an append-only JSONB array on the existing identity row — not in separate version tables. See §4. Relationship to the full epic: This is the minimal, shippable slice of the "versioning of the administrative structure and locations" epic. It lands the full effective-dated data model and as-at resolution, but defers all lineage (split, merge, delegation, predecessor/successor), makes no automatic reassignment of users, and never migrates records — a record keeps its original location references for life. Which users can reach a record is computed from the current structure: reachability follows wherever the record's location currently sits, not where it sat when the record was created (§8).
Assumptions taken to unblock this draft are marked [CONFIRM]. They reflect the defaults discussed in scoping; correct any and the relevant sections update.
Make every location and administrative area an effective-dated entity so that:
- a location can be created, renamed, and inactivated over time without ever changing its UUID or re-seeding;
- a renamed or inactive location still resolves to the name and hierarchy valid at a record's anchor date, so historical records and reprinted certificates stay accurate (the Philippine Alaminos → Alaminos City requirement);
- changes can be future-dated and take effect automatically on their start date;
- no historical record is ever migrated — record→location references are stable by UUID; versioning changes only how they resolve.
This slice is intended to be sufficient for Philippines go-live for the historical accuracy that matters to CRVS. Renames, cityhood conversions, inactivation, and creation are all covered. Re-parenting an existing entity is not supported (a location's parent is fixed for life); transfers, splits, and merges are instead modelled as inactivate-old + create-new (see Appendix A), so historical records still resolve correctly. See §11 for the evidence and the residual gaps.
- Effective-dated model. Every location and administrative area keeps its permanent
identity row (UUID) and gains a
versionsJSONB array — an append-only, ordered list of version objects carryingname,parent,status,external_id, andvalidFrom.location_typestays an identity-level column (§4). Parent is stored in each version element but does not change over an entity's life in 2.1 (§2.4). - Create a location or administrative area (identity row + first version element), with its parent fixed at creation.
- Rename — append a version element with a new
namefrom an effective date; name history is preserved. - Parent is immutable — no re-parenting. A location/area cannot be moved to a
different parent in 2.1.
parentId/administrativeAreaIdis fixed at creation and a PUT that changes it is rejected (§6), exactly likelocationType. Real-world transfers (a barangay moving between LGUs) are modelled as inactivate the old entity + create a new one under the new parent — the same shape as split/merge (§11, Appendix A) — so historical records keep resolving under the old parent and new records land under the new one. Automatic same-entity re-parenting is deferred (§3). - Inactivate — append a version element with
status = 'inactive'. The location disappears from current-date selectors but remains permanently resolvable. This replaces the old bare "deactivate". - Future-dated changes. Any create/rename/inactivate can carry a
validFromin the future; it becomes selectable only from that date. A still-pending future version can be withdrawn before it takes effect. - As-at resolution. Any location reference resolves to the version element valid at a supplied anchor date.
- Context-aware anchors. Selectors and renders resolve against the correct anchor:
- place-of-event / place-of-delivery selector → the event date on the form;
- residential-address and other-address selectors → current date — only admin structures valid now;
- correction flow → the record's anchor date [CONFIRM: in scope for 2.1];
- certificate render → the record's anchor date.
- Inactivated locations stay findable in search — by field type. Advanced Search filters for place of registration (offices) and place of delivery (health institutions) include inactivated locations, so records at now-closed offices and facilities remain searchable. Residential-address and other-address filters list only admin structures valid now.
- Offline. All version elements of all locations/areas (past, current, future, active,
inactive) sync to devices, so historical name/path resolution works offline and a
future version resolves correctly once its date passes — with no redownload. The whole
versionsarray travels with the identity row, so this is automatic. - API-only management. Create/rename/re-parent/inactivate/future-date are expressed via the location and administrative-area APIs. No admin UI in this slice.
- Lineage of any kind — split, merge, delegation, predecessor→successor links, and the lineage query API. These arrive with the lineage increment (§4).
- Re-parenting an existing entity. A location/area's parent is fixed at creation and cannot be changed. Transfers are handled as inactivate-old + create-new (§2.4, §11). Automatic same-entity re-parenting is deferred to the lineage increment.
- Contraction of the legacy columns. This slice is the expand step: it adds the
versionscolumn and backfills it, but leaves the legacyname/parent/status/external_idcolumns in place. Dropping them (and moving every direct read to a resolved read) is a later contract migration, out of scope here. - Automatic movement of records. When an office is inactivated, its records stay referenced to that office. They remain searchable; they are not re-homed to a successor. Re-homing arrives with the delegation increment.
- Automatic movement of users. Users are not reassigned when their office is inactive or restructured. Reassignment uses the existing user-management UI exactly as today; where continued access to an old office's records is needed, create/keep a user there. Jurisdiction resolution is unchanged.
- Changes to statistics and reporting. Vital-statistics attribution across boundary changes, crude-rate denominators, and time-series stitching are untouched. (Inactive locations do appear in search filters per §2.8, but reporting logic is unchanged.)
- Admin UI, retrospective back-fill of pre-go-live history, form-definition versioning, and changes to record statuses / scope grammar / qualifier vocabulary.
Note on re-parenting (deferred): re-parenting an existing entity is out of scope for 2.1. Philippine evidence (Appendix A) shows LGU/barangay transfers are routine, but a same-entity move raises jurisdiction questions (who can act on the moved records) that this minimal slice deliberately avoids. Because 2.1 computes jurisdiction from the current structure (§8), silently moving a node would also move the workqueue reachability of every record under it — a side effect we are not ready to ship without the delegation/lineage work. Instead, a transfer is modelled as inactivate the old entity + create a new one under the new parent (§2.4): historical records keep resolving under the old parent (correct for their anchor date) and new records use the new entity under the new parent. The tree topology of any single entity therefore never changes in 2.1, which keeps reachability stable and predictable.
Same identity/version split as the full design in spirit, but versions live in a JSONB array on the identity row rather than in separate version tables, and minus lineage semantics. This is an expand migration: the mutable fields also still exist as legacy columns until a later contract step removes them (§3, §10).
Both app.locations and app.administrative_areas keep their existing rows and primary
keys. The permanent identity is the row's id (UUID) plus, for locations,
location_type. A single new column carries all history:
ALTER TABLE app.locations ADD COLUMN versions jsonb; -- NOT NULL after backfill
ALTER TABLE app.administrative_areas ADD COLUMN versions jsonb; -- NOT NULL after backfillid— permanent UUID (unchanged; records reference this).location_type— identity-level column onapp.locations(immutable; not versioned).versions— append-only JSONB array of version objects, ordered byvalidFromascending (see below).- Legacy
name/administrative_area_id(parent) /status/external_idcolumns remain in place during the expand phase; they are the fallback until the contract migration drops them.
The fields that live inside the version objects — name, parent, status, external_id
— rather than on identity are name, status, and external_id (which do change over
time) plus parent. Parent is kept in each element for a lossless, forward-compatible
snapshot, but in 2.1 it is constant across an entity's versions: re-parenting is not
supported, so every element of a given entity carries the same parent (§2.4). external_id
is versioned, not identity.
Each element of versions is one version. Keys are camelCase to match the wire/Zod
models, so the array serialises straight onto the API versions[] field with no mapping.
Locations:
Administrative areas are identical except the parent key is parentId and there is no
locationType anywhere (it is not an identity field for areas).
Design decisions:
- Version elements carry only
validFrom— there is novalidUntil. A version's effective end is implicit (the next element'svalidFrom). '0001-01-01T00:00:00Z'is the low sentinel = "has always been valid".- Inactivation is an explicit element with
status = 'inactive', not an ended interval. - Append-only, ordered array. New versions are appended; existing elements are never
mutated in place.
validFromvalues are unique and strictly increasing within an identity's array — enforced at the write boundary (§6), since a JSONB array has no per-element DB constraint. external_idis a version attribute, not identity. It resolves likename— the value valid at the anchor date. A code reassignment (e.g. a PSGC recode) is just a new element with a differentexternalId; the UUID is unchanged. Because it is versioned and lives inside JSONB, there is no DB-level uniqueness on external codes. If a country needs external codes to be unique among currently-active locations, that is enforced at write/import time by the API layer, not the schema. [CONFIRM: is point-in-time uniqueness required, and app-enforced?]- Validation lives at the write boundary, not the DB. Per-element shape (required keys,
statusenum, RFC-3339 formats, ordering, unique/forwardvalidFrom) is validated by Zod / the API layer on every write. The DB carries only a light CHECK thatversionsis a non-empty JSONB array, plus a GIN index for lookups; it does not re-express the element schema.
Lineage (split, merge, predecessor→successor) is deferred to a later increment (§3). The
JSONB model does not preclude it: a dedicated
location_lineage(predecessor_id, successor_id, operation, effective_date) edge table can
be added then by a forward-only migration that changes neither the identity rows nor the
versions arrays above.
As-at T for an identity = the version element with the greatest validFrom <= T.
-- resolve one administrative area as-at :anchor from its JSONB versions array
SELECT elem
FROM app.administrative_areas a
CROSS JOIN LATERAL jsonb_array_elements(a.versions) AS elem
WHERE a.id = :id
AND (elem->>'validFrom')::timestamptz <= :anchor
ORDER BY (elem->>'validFrom')::timestamptz DESC
LIMIT 1;- Recursive parent resolution. The parent key (
parentId/administrativeAreaId) inside each element is an identity reference. A hierarchy path at anchor T is built by resolving each ancestor independently at T — never by freezing a parent element at write time. A 1995 birth's path is the whole chain as it stood in 1995. - Inactive elements resolve normally (name/path always available) but are excluded
from current-date selectors because their status is
inactive. - Anchor precedes all versions. Backfilled rows default to the low sentinel and always
resolve. An identity whose first real
validFromis later than the anchor returns the resolver's defined "precedes all versions" behaviour (nearest / none), not an error.
The wire contract is unchanged by the JSONB decision — the storage layout is an
implementation detail. The versions[] response field is now literally the row's
versions array.
- No date parameter → top-level fields reflect the version valid now.
asAt/validFromparameter → top-level fields reflect the version valid at that date.versions[]always contains every version — past, current, and future-dated — regardless of anchor. Load-bearing for offline: download the identity row once (array included), resolve future versions later with no redownload.- Backwards compatible. Existing Search API consumers that pass no as-at parameter see an unchanged contract and current-structure resolution. Historical versions are strictly opt-in.
- No
validUntilon the wire. Clients derive end-of-validity from the neighbouring element'svalidFrom.
One write verb: a PUT that appends. A PUT submits the complete desired state of a
new version; the server appends it as a new element to the identity's versions array
with a validFrom. Nothing is overwritten. Rename and inactivate are both just PUTs that
differ in which field values you send. (Re-parenting is not offered — parent is fixed for
life; §2.4.)
| Method & path | Purpose |
|---|---|
POST /locations |
Create an identity row + its first version element (parent fixed here). |
PUT /locations/{id} |
Append a new version element carrying the full desired state (rename / inactivate / future-date). Parent must match the entity's fixed parent. |
DELETE /locations/{id}/versions/{versionId} |
Withdraw a pending future element (validFrom > now()) only — removes it from the array. |
GET /locations?asAt=<iso>&administrativeAreaId=<uuid> |
List / hierarchy traversal, resolved at asAt (default now()). |
Administrative areas are symmetric: parent via parentId, no locationType, base path
/administrative-areas.
Write rules:
- Full snapshot, no compose-from-base. Each PUT carries the entire mutable state
(
name, parent,status,externalId). A field omitted is not "unchanged" — send the complete state every time (read-modify-write). - Append-forward only.
validFrommust be strictly greater than the last (max) element'svalidFromin the array. Changes cannot be spliced into the past, so a write can never silently rewrite how already-anchored records resolve. - Future-dating supported. A future
validFromschedules a change; it appears inversions[]immediately but is not offered by selectors until its date. - No mutation of effective elements. The only removal is withdrawing a still-pending
future element. Any element at or before
now()is permanently immutable. Inactivation is a softstatus, never a delete. - Idempotent writes. Callers may supply
id/versionId(and/or anIdempotency-Key) so seeding and offline retries are safe. Send an explicitvalidFromto keep a replayed PUT idempotent. - Concurrency. With no per-element DB unique constraint, optimistic concurrency is
enforced at the write boundary: callers pass the latest observed
versionId(If-Match), and the server appends only if that is still the array's last element — otherwise409. The append itself is a single atomic row update (versions = versions || :newElem) under the row lock.
const CreateLocationRequest = z.object({
id: UUID.optional(),
versionId: UUID.optional(),
externalId: z.string().nullish(), // external key (Pcode / statisticalID); set on first version
locationType: z.string(), // identity-level column
name: z.string(),
administrativeAreaId: UUID.nullable(), // parent identity ref
validFrom: z.iso.datetime().optional(), // defaults to sentinel 0001-01-01T00:00:00Z
status: z.enum(["active", "inactive"]).default("active"),
})
const UpdateLocationRequest = z.object({
versionId: UUID.optional(),
validFrom: z.iso.datetime().optional(), // defaults to now(); send explicitly for idempotent PUT
name: z.string(), // full snapshot — every mutable field required
externalId: z.string().nullish(), // versioned; send the intended value each time
administrativeAreaId: UUID.nullable(),
status: z.enum(["active", "inactive"]),
})The immutable identity field locationType is not accepted on PUT /locations/{id};
if present and different from the stored identity column, the request is rejected.
Parent (administrativeAreaId / parentId) is part of the full snapshot and must be
sent, but it is immutable in 2.1: a PUT whose parent differs from the entity's existing
parent is rejected (400) — there is no re-parenting (§2.4). externalId is accepted
on PUT — it is a versioned attribute, part of the full snapshot, and lives inside the
appended element.
| Status | Condition |
|---|---|
400 |
Body is not a complete version snapshot; or carries locationType differing from the stored identity; or carries a parent (administrativeAreaId / parentId) differing from the entity's fixed parent (no re-parenting). |
404 |
PUT/withdraw against an id/versionId that does not exist. |
409 |
validFrom collides with an existing element of that identity's array; or If-Match versionId is no longer the array's last element; or withdraw target is not pending. |
422 |
validFrom not strictly greater than the array's current max (past-splice attempt). |
Collision, forward-only, and concurrency checks are all performed by the write boundary
against the identity's current versions array (there is no UNIQUE (identity, valid_from)
constraint to lean on any more).
Rename (Alaminos → Alaminos City, effective 2001-03-05).
PUT /locations/{alaminos} with
{ validFrom: "2001-03-05T00:00:00Z", name: "Alaminos City", administrativeAreaId: <same>, externalId: <same>, status: "active" }.
The server appends one element to alaminos.versions. A 1995 birth (anchor = event date)
resolves the prior element and renders "Alaminos"; anything anchored ≥ 2001-03-05
renders "Alaminos City".
Transfer (barangay moved between LGUs, e.g. Makati → Taguig) — modelled as
inactivate-old + create-new, since re-parenting is not supported.
First inactivate the old entity:
PUT /administrative-areas/{barangay_makati} with
{ validFrom: "2023-07-01T00:00:00Z", name: <same>, parentId: "<makati>", externalId: <same>, status: "inactive" }.
Then create the successor under the new parent:
POST /administrative-areas with
{ name: <same>, parentId: "<taguig>", externalId: <code>, validFrom: "2023-07-01T00:00:00Z", status: "active" }
(a new UUID). A birth before 2023-07-01 keeps referencing the old UUID and resolves its
path under Makati (correct for its anchor date); a birth after selects the new entity
and resolves under Taguig. No record is migrated, and no entity changes parent.
- Anchor is a required argument on the shared resolution utilities (including behind
LocationSearch.outputandLocationSearch.toCertificateVariables), enforced at the type level. No call site resolves a location without an explicit anchor. - A surface→anchor inventory documents every place a location is shown and the anchor it resolves against.
- Certificates.
toCertificateVariablesresolves at the record's anchor date, never atnow()— a 1995 birth renders "Alaminos" however late it is reprinted. - Correction to a record declared on 01/01/2020 offers only versions valid on 01/01/2020 in every dropdown. [CONFIRM: in scope for 2.1]
- Presence ≠ selectability. A future-dated element is held in
versions[]but not offered by current/event-date selectors until itsvalidFrom. No sync or serialisation step strips future or inactive elements from the payload.
This is the area deliberately kept minimal in 2.1.
- User jurisdiction is computed from the current structure. The workqueue predicates
(
createdInMyAdminArea,declaredInMyAdminArea,registeredInMyAdminArea) are untouched; they resolverecord-location within user.administrativeAreaIdagainst the current hierarchy. Because 2.1 supports no re-parenting (§2.4), no entity ever moves in the tree: renames and inactivation change a node's name/status but not its position, so a record's workqueue reachability is stable for life. A transfer is done as inactivate-old + create-new, which likewise moves nothing — the old entity stays where it was (records under it keep their reachability) and the new entity is created directly under the new parent. The record itself is never modified. (Computing jurisdiction from the current structure is stated here as the forward-compatible design; in 2.1 it makes no observable difference because topology is fixed.) - No automatic re-homing. A record whose office is inactivated keeps referencing that office. By the existing admin-area logic, such records remain visible to any office in the same administrative area (the Farajaland behaviour: if office A closes, office B in the same area still sees A's records). Notified and Declared records therefore remain processable.
- Records stay searchable by the (now inactive) location that created them (§2.8).
- Operational cover (SOP), not code. For an office closure, the country runs a standard operating procedure — clear the office's Notified/Declared queue by a cut-off date, or stop accepting new declarations there until the structure change takes effect — rather than relying on automatic queue migration.
- No automatic hiding. Inactive-location records are not hidden from workqueues; they stay visible under the current-structure jurisdiction logic above, so Notified and Declared records in an inactive office remain reachable and processable.
- On sync, every identity row travels with its full
versionsarray (past, current, future, active, inactive), so historical name/path resolution works with no network. - A device holding a future version element resolves against it correctly once its
validFrompasses, with no redownload. - A device that has not synced a change already in effect resolves against its last-synced array — defined staleness behaviour, not an error.
Applied to app.locations and app.administrative_areas. This is a forward-only,
expand-only migration: it adds and backfills the versions column and touches no
record row and drops no legacy column. Contraction (dropping legacy name/parent/
status/external_id columns and moving reads fully onto resolved reads) is a separate
later migration (§3).
Order of operations:
- Add the column (nullable) to both tables:
ALTER TABLE app.locations ADD COLUMN versions jsonb; ALTER TABLE app.administrative_areas ADD COLUMN versions jsonb;
- Backfill one sentinel-dated element per existing row — lossless, batched for large
tables:
-- locations (areas symmetric: 'parentId' instead of 'administrativeAreaId', no locationType) UPDATE app.locations l SET versions = jsonb_build_array( jsonb_build_object( 'versionId', gen_random_uuid(), 'validFrom', '0001-01-01T00:00:00Z', 'name', l.name, 'externalId', l.external_id, 'administrativeAreaId', l.administrative_area_id, 'status', COALESCE(l.status, 'active') ) ) WHERE l.versions IS NULL;
- Add a CHECK that
versionsis a non-empty JSONB array, thenSET NOT NULL:ALTER TABLE app.locations ADD CONSTRAINT locations_versions_nonempty CHECK (jsonb_typeof(versions) = 'array' AND jsonb_array_length(versions) >= 1); ALTER TABLE app.locations ALTER COLUMN versions SET NOT NULL; -- (symmetric for app.administrative_areas)
- Create GIN indexes
CONCURRENTLYon theversionscolumns for lookups. - Leave every legacy column in place.
updated_at,name,parent,status,external_idare not dropped in this slice; novalid_until/validUntilcolumn is introduced anywhere. - No record row is read or written, and no location/area
idchanges. - During the expand phase, reads that need as-at resolution read the
versionsarray; legacy columns remain as a compatibility fallback and are retired only by the later contract migration.
Assessed against the actual post-2020 Philippine administrative-change record (Appendix A), which the PSA publishes quarter-by-quarter in the PSGC updates.
Covered directly by 2.1 — and these are the frequent change types:
- Renames / name corrections — several barangays and municipalities almost every quarter.
- Cityhood conversions (Calaca 2021, Baliwag 2022, Carmona 2023) — the direct Alaminos pattern; the PSGC keeps the same code across the conversion, matching the permanent-UUID model.
- Creations — new barangays and the eight BARMM SGA municipalities (2023–2024).
- Inactivation of abolished units, still permanently resolvable at their anchor date.
Handled as inactivate-old + create-new (not as a same-entity change) — assessed as tolerable for go-live:
- Transfers (Makati→Taguig barangays 2023; Guintolan Payao→Imelda 2024; Siquijor→NIR 2024; Sulu→Region IX 2025). Because re-parenting is out of scope (§2.4), a transfer is represented by inactivating the old entity and creating a successor under the new parent (§6 worked example).
- Splits (Maguindanao→2 provinces 2022; Barangay Muzon→4 barangays 2023) and merges (Bacoor 44→18 barangays 2023; San Rafael→Dacanlao 2026). 2.1 records no predecessor→successor link.
- The mitigating fact: for splits and merges the PSGC already mints new codes for successors and inactivates the predecessor's code; transfers fit the same inactivate-old + create-new shape on the CRVS side. A historical record points at the predecessor UUID, and 2.1 keeps inactive identities permanently resolvable — so a pre-transfer/pre-split birth (e.g. old Barangay Muzon, or the barangay while still under Makati) still renders correctly with no lineage, and new records land under the successor. The absence of a same-entity move and of lineage links therefore does not break the core CRVS historical-accuracy requirement.
Residual gaps that remain deferred (and become SOP / a later increment):
- Same-entity re-parenting with a lineage link between the predecessor and successor — deferred to the lineage increment (§3). Until then a transfer fragments a place across two UUIDs (old under old parent, new under new parent), with no automatic link between them.
- Cross-boundary statistics (attributing counts across a transfer/split/merge) — unchanged in 2.1 (§3).
- Moving active records or offices to a successor (re-homing) — deferred to the delegation increment (§3); handled operationally by an SOP for any office closure.
- Showing a citizen the successor name on a reprint of a record whose place was later transferred/split/merged — a certificate-reissue policy question, out of scope here.
Bottom line: rename + inactivate + create is enough to preserve historical accuracy for every PH change type — including the frequent transfers — because a record always resolves the predecessor entity as it stood at the record's anchor date, and the PSGC's own new-code/inactivate convention (mirrored by CRVS for transfers) means new records land under the correct successor. What 2.1 does not do is represent a transfer/split/merge as a single moving entity or link predecessor to successor; that, plus successor-linked statistics, active-record re-homing, and successor-name reprints, is already deferred and non-blocking for go-live.
Model & migration
-
versions jsonbexists on bothapp.locationsandapp.administrative_areas, isNOT NULLafter backfill, and carries theversions[]array of effective-dated elements (each recording name, parent, status,externalId,validFrom). - Each backfilled row has exactly one element with
validFrom = '0001-01-01T00:00:00Z',status = 'active', andname/ parent /externalIdcopied verbatim from the legacy columns. - UUIDs are permanent; no rename or inactivate ever mints a new UUID or requires a
re-seed (a transfer's successor is an explicit
POST, i.e. a deliberate new UUID). - Parent is immutable: every element of a given entity carries the same parent, and a PUT that changes parent is rejected (no re-parenting).
- The migration is expand-only: no legacy column is dropped (
updated_at,name, parent,status,external_idall remain), no record row is read or written, and novalidUntilcolumn is introduced anywhere. - A CHECK enforces
versionsis a non-empty array, and GIN indexes exist for lookups. - Version elements are append-only: never mutated or removed in place (except withdrawal of a pending future element).
Historical accuracy
- A record whose place of event was captured under a location later renamed or made inactive renders the name and hierarchy path valid at its anchor date in both the record view and the certificate.
- When a place is transferred by inactivating the old entity and creating a successor under the new parent on date D, a record anchored before D still resolves under the old parent via its original UUID, and a record created on/after D resolves under the new parent via the successor UUID — with no record migrated.
- A declaration created under "John's CRVS office" still shows "John's CRVS office" on previously issued certificates and previews after the office is renamed.
- Any location reference resolves "as at" an arbitrary date via API, returning the element valid then (or the defined behaviour when the date precedes all versions).
- Inactive locations remain permanently resolvable by UUID and are never hard-deleted.
New registrations & corrections
- Place-of-event and address selectors offer only locations active as at the time of capture; inactive locations are not selectable in current-date contexts.
- A future-dated element appears in
versions[]but is not offered by selectors until itsvalidFrom. - A record declared on 01/01/2020 in correction shows only locations valid on 01/01/2020. [CONFIRM: in scope]
- A record captured against a location on its final day of validity resolves correctly thereafter.
Search
- Advanced Search filters for place of registration (offices) and place of delivery (health institutions) allow filtering by both active and inactivated locations; residential-address and other-address filters list only currently-valid admin structures.
Jurisdiction & routing (minimal)
-
my-administrative-area/locationresolution and workqueue filters resolverecord-location within user.administrativeAreaIdagainst the current structure; a record whose location has since moved out of a user's admin area is no longer in that user's queue, and no record referenced by an inactive location becomes unreachable to every role. - Users are not auto-reassigned; existing user-management reassignment works unchanged.
API
-
POST /locationscreates exactly one identity row + a one-elementversionsarray and anaudit_logcreateentry (actor,validFrom,created_at). -
PUT /locations/{id}appends exactly one new element to theversionsarray carrying the submitted state verbatim, modifies no prior element, and writes oneaudit_logupdateentry with the diff. - A PUT missing a required mutable field →
400; a PUT carrying alocationTypeor a parent (administrativeAreaId/parentId) that differs from the stored identity / fixed parent →400. - Colliding
validFrom→409; non-forwardvalidFrom→422; a staleIf-MatchversionId→409. - A pending future element can be withdrawn via
DELETE .../versions/{versionId}; once itsvalidFrompasses, the same call returns409. - No route deletes an identity or mutates an effective element in place.
- Existing Search API consumers passing no as-at parameter see an unchanged contract;
no
validUntilappears on any response object.
Client & offline
- Every location-displaying surface renders via shared utilities that require an explicit anchor at the type level.
- A synced device holds every identity row's full
versionsarray and resolves offline; a held future element resolves once itsvalidFrompasses, with no redownload.
Documentation
- The roadmap-limitation notes on the data-migration, digitise-paper-records, and location-management pages are updated to describe the shipped 2.1 behaviour; remaining lineage/reporting limitations are re-stated as scoped-out, not silent.
- Office-reference render anchor —
declared_in/registered_inaren't captured in forms, but they render on record views and certificates. Confirm which date they resolve at: the record's anchor (event) date, or the date the action occurred (declaration / registration date) — the latter shows the office as named when it actually declared/registered the record (§7). - Correction flow — confirm in scope for 2.1 (§7).
external_iduniqueness — now thatexternal_idis versioned inside JSONB (§4), there is no DB-level uniqueness. Confirm whether point-in-time uniqueness among currently-active locations is required, and if so that app/import-level enforcement is acceptable.- Offline payload size — validate that syncing every identity row's full
versionsarray is acceptable for the real Philippine hierarchy.
Resolved since the previous draft: storage layout is settled — versions are an append-only JSONB array on the identity row (§4), not separate version tables, and 2.1 is the expand step that keeps every legacy column (§10). Re-parenting existing entities is out of scope (§2.4): a location's parent is fixed for life, and transfers are modelled as inactivate-old + create-new rather than a same-entity move (§11, Appendix A). Workqueue hiding is settled — inactive-location records are not hidden from workqueues (§8), so no config toggle is introduced. Search inclusion by field type is confirmed — place-of-registration (office) and place-of-delivery (health-institution) filters include inactivated locations, while residential-/other-address fields show only currently-valid admin structures (§2.8, §2.9).
Source of record: the PSA's quarterly Philippine Standard Geographic Code (PSGC) updates, plus the enabling Republic Acts / plebiscites. This is the evidence base for the §11 sufficiency call. The right-hand column maps each change type to whether the 2.1 slice can represent it faithfully.
| Change type | Post-2020 examples | 2.1 support |
|---|---|---|
| Rename / name correction | 20 barangays corrected Q1 2024; 12 barangays Q3 2023; ongoing municipal + barangay corrections most quarters | ✅ Rename (§2.3) |
| Cityhood conversion (rename + reclassify, code unchanged) | Calaca (RA 11544, 2021); Baliwag (RA 11929, 2022); Carmona, Cavite (RA 11938, ratified 8 Jul 2023) | ✅ Rename (§2.3); matches Alaminos pattern and permanent-UUID model |
| Creation of new areas | 3 Marawi barangays Q1 2024; Muzon-area barangays; 8 BARMM SGA municipalities (enacted 17 Aug 2023, ratified 13 Apr 2024) | ✅ Create (§2.2) |
| Transfer (same entity moves parent) | 10 barangays Makati → Taguig (Q3 2023, post-Fort Bonifacio ruling); Barangay Guintolan Payao → Imelda (Q1 2024); Siquijor → Negros Island Region (Jun 2024); Sulu → Region IX (30 Jul 2025) | |
| Inactivate / abolish | Barangay San Rafael abolished, code inactivated (Q1 2026) | ✅ Inactivate (§2.5); stays resolvable at anchor date |
| Split (1 → many) | Maguindanao → Maguindanao del Norte + del Sur (RA 11550, 2021/2022); Barangay Muzon → 4 barangays (RA 11896, ratified 25 Mar 2023) | |
| Merge (many → 1) | Bacoor 44 barangays → 18 (Q3 2023); San Rafael consolidated into Dacanlao (Q1 2026) |
Reading of the table. Renames, conversions, creations, and inactivations are covered directly. Transfers, splits, and merges are not expressed as a same-entity move or a lineage link; each is modelled as inactivating the predecessor and creating a successor — and the PSGC's own convention of assigning new codes to split/merge successors and inactivating the predecessor means historical CRVS records remain accurate without lineage (the same holds for transfers on the CRVS side). What genuinely waits for the delegation/lineage increment is same-entity re-parenting with a predecessor→successor link, successor-linked statistics, active-record re-homing, and successor-name reprints — none of which block go-live.
Dates and instruments above are drawn from PSA PSGC quarterly updates and the cited Republic Acts; verify the specific effective dates against the PSGC masterlist when seeding, since plebiscite-ratification and PSGC-adoption dates can differ by a quarter.
{ "versionId": "uuid", "validFrom": "2001-03-05T00:00:00Z", // RFC 3339; sentinel 0001-01-01 = "always" "name": "string", "externalId": "string | null", // Pcode / statisticalID; versioned "administrativeAreaId": "uuid | null", // parent identity ref "status": "active" // "active" | "inactive" }