Skip to content
3 changes: 1 addition & 2 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -70,7 +70,6 @@ instance/

# Sphinx documentation
docs/_build/
docs/

# PyBuilder
.pybuilder/
Expand Down Expand Up @@ -223,7 +222,7 @@ __marimo__/
**/.DS_Store

# Local design notes and plans
/docs/
# /docs/

# impeccable design skill (local design context and live-mode config)
/PRODUCT.md
Expand Down
317 changes: 317 additions & 0 deletions docs/STIT-766-design.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,317 @@
# STIT-766 — Current-state read model: design proposal

**Status:** draft
**Author:** Michael Barlow
**Reviewer:** John McGrath, Alex Axthelm
**Jira:** [STIT-766](https://rmi1.atlassian.net/browse/STIT-766) (epic [STIT-689](https://rmi1.atlassian.net/browse/STIT-689))
**Created**: 2026-09-17

## 1. Objective

Design a database architecture and application logic to serve the key Stitch endpoints with minimal latency.

## 2. Background

### Problem

The `list`, `filter-options`, and `detail` endpoints all rebuild the same thing on every request: one coalesced, flat row per resource that represents the current top-level "state". `queries.py` does this by joining membership → resource → default priority → source → value rows, left-joining per-field overrides, ranking candidates with a four-key `ROW_NUMBER()` window, and then pivoting the winners into columns with `max(case(...))` per field.

Combined with serializing to/from Pydantic models, this has led to poor response times that
negatively impact user experience. Furthermore, given that our entire db is less than 100MB
in size, this raises questions about our overall db design and access patterns.
Comment on lines +19 to +21

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
Combined with serializing to/from Pydantic models, this has led to poor response times that
negatively impact user experience. Furthermore, given that our entire db is less than 100MB
in size, this raises questions about our overall db design and access patterns.

Perf testing I ran in support of testing #291 (an alternate version of #290) indicated that we're actually doing pretty well on serialization, since most of the stuff is paginated, and we're only serializing values when we're going to display them (i.e. the detail view which is 1 at a time, or list view hydration)

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can you share some of the perf testing details and implementation?


### Observations

With the newly added attribute-level priority feature ([STIT-494](https://rmi1.atlassian.net/browse/STIT-494)), we now have a more deterministic and bounded way to establish the different variations we display for a flat resource. Our permissions model only has 2 restricted options: `wm` and `cc`. All the remaining sources are public, and ALL users have read permissions for them.

> [!NOTE]
> It was more straightforward to treat these individually when implemented, but reviewing our permissions model may help simplify in the future. However, it's not strictly necessary for this work.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Up to this point, we've treated this as an invariant that stitch needs to build at its core: "If you don't explicitly have permission to see X, then you don't see X". When working on a draft implementation in #290, I've got it still maintaining that behavior, while still hitting the big points of the design below. We treat the "public" as a group of permissions, and basically don't do any of the pre-compute/caching for permissions combos that don't include all of the public sources, then if a token ever comes in that doesn't have the full public set, it falls back to the slow (current main coalescing code path)

All to say that if we want to keep that invariant we can (I have like that stitch doesn't play favorites by design), but if we are actually willing to introduce the concept of a public source (that can be read without explicitly granting permissions) then that opens up some different possibilities for solutions.


If we think of the set of `llm`, `rmi`, `bc`, `alb`, and `gem` as a single `public` visibility.
Then there are some fairly significant implications in how our coalescing logic actually
plays out. For any given attribute on a coalesced resource, users will see at most 3
possible options: `wm`, `cc`, and the highest priority `public` source. To illustrate,
see the following scenarios for a resource's `latitude` data:

- priorities: `wm`, `cc`, `gem`, `bc`
- user with `wm + cc` sees `wm`
- user with `wm` sees `wm`
- user with `cc` sees `cc`
- remaining users see `gem`

@john john Sep 23, 2026 •

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should this be gem, or "all combined public sources?" Like why couldnt' they see GEM+BC? Same question repeats below. You say it above, just confirming in the bullets too.

Re-read, think I get it--the presumption is if both BC and GEM have coords, we'll defaul to GEM?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Right. This was one spot where I suspected a diagram/table would be more illustrative.

- priorities: `wm`, `cc`, `gem`, `rmi`, `llm`, `alb`
- user with `wm + cc` sees `wm`
- user with `wm` sees `wm`
- user with `cc` sees `cc`
- remaining users see `gem`
- priorities: `cc`, `gem`, `wm`, `bc`
- user with `wm + cc` sees `cc`
- user with `cc` sees `cc`
- all remaining users (including `wm` viewers) see `gem`
- priorities: `bc`, `cc`, `wm`, `gem`
- all users see `bc`
- priorities: `bc`, `gem`, `cc`, `wm`
- all users see `bc`

For any view or operation that interacts only with the flattened representation of a resource:

- we only need to store/cache up to the first public source in the priority list
- a resource attribute has at most 3 variations: `wm`, `cc`, and `public`
- an entire coalesced resource has **at most 4 possible variations** depending on user permissions and attribute priorities:
- `public`: all attributes have a public source as the highest priority
- `public + wm`: 1 or more attributes has `wm` as highest priority and all next-highest priorities are public
- said another way, all top 2 priorities are either `wm` or `public` and at least 1 is `wm`
- `public + cc`: 1 or more attributes has `cc` as highest priority and all next-highest priorities are public
- all top 2 priorities are either `cc` or `public` and at least 1 is `cc`
- `public + wm + cc`: at least 1 attribute where `wm` is highest **AND** 1 attribute where `cc` is highest
- Note: `wm + cc` must see different data from both `wm` and `cc` individually

Thus, whatever structure we use here is bounded by at most 4 x the number of top-level (unmerged) resources: 1 variant for each possible licensed representation.

> [!NOTE]
> One caveat here is that the number of possible variants grows exponentially with the number of distinct licenses:
>
> - 2 licensed sources = 4 variants
> - 3 licensed sources = 8 variants
> - 4 licensed sources = 16 variants
>
> so even if we add 2 proprietary sources, this structure grows to a max of 16 x the number of top-level (unmerged) resources. I don't see this as a real issue even in the long term. How many licensed sources are even realistic? 6? 10? Even at 10 proprietary sources, the view/table would be 2^10 = 1024 x resources. The upper bound of **all** named fields is probably somewhere in the ~65k range, so ~65M rows is still manageable...if we'd ever even get close to that.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Practically, I would say we can make this scale nicely up to 4 or 5, but if we go past 32 rows per resource, that would be a good time to rethink the design, not for perf reasons, but it suggests that we need to think a bit more about how we're caching, since most of those rows would end up being the same (there would be very few resources with all source types attached)


## 3. Goals and non-goals

**Goals**

1. Serve `list` and `filter-options` with <100ms latency

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
1. Serve `list` and `filter-options` with <100ms latency
1. Serve `list` and `filter-options` with <100ms latency.
`detail` and `GET {id}` can stay on the live path (`source_data` shouldn't be included in the MV, and these are single-resource endpoints, so shouldn't be taxing queries).

We can sketch out feeding them from the MV if we want that consistency, but I didn't see any great perf changes on them when I included them in #291. No strong opinion on this in practice

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

My thought here was more about consistency and maintainability. The state table becomes the only way users read resources, which makes all the queries we've written thus far implementation details. So long as we preserve invariants on the state table, the details of how we maintain the table are less consequential, giving us more freedom & flexibility to make changes, address tech debt, and/or experiment with alternatives.

2. Give the database one unambiguous stored answer to "what is the value for each attribute of a resource for a user with X permissions"

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

See comment above (in the note) about the "permissions are explcit" invariant, but database already is the source of that answer and it's unambiguous currently. With that in mind, we only would need to store the common values of X (i.e, Public6, P6+wm, p6+cc, all8)

Suggested change
2. Give the database one unambiguous stored answer to "what is the value for each attribute of a resource for a user with X permissions"
2. Let the database store precalculated answers to "what is the value for each attribute of a resource for a user with X permissions"

3. As a follow-up to 2, provide one unambiguous stored representation for each resource for each effective user permission set.
4. Express the invariants declaratively in schema, so the sync/refresh mechanism has lower/zero risk regarding correctness
5. Preserve the public REST surface and today's behavior

**Non-goals**

- A general permission-based solution that accounts for all future/unknown licensed and public permissions
- The details of the sync mechanism. See §6.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Including sync mechanism is essential. If nothing else, there needs to be a way to bulk-populate/rebuild the materialization. It's not far out of the way to have something keep this view in sync.

in #290 I went with app code that could get called as part of wrapping up write operations.

Suggested change
- The details of the sync mechanism. See §6.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The intent was that syncing the state table becomes an implementation detail. This design depends on the structure, constraints, and invariants the state table exhibits but not on how our system realizes and enforces them.

I actually think that separation is really important in providing flexibility in our codebase. If they're decoupled, we can build whatever we need to maintain the state table, and as long as it abides by the necessary contract nothing that reads from it needs to change.

- Redesigning the permission model.

## 4. Schema Changes

Part of the goal is to trade application complexity for schema complexity. Making schemas more complex but in a way that enables tighter constraints and simpler queries (i.e. through mostly joins) frees our application code from being the enforcer of invariants.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm not sure what this is trying to say? Don't all the invariants start their definitions in the application code (through Pydantic model definitions and validation)?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pydantic models provide only structural validation of object shapes, so it's most useful when handling unknown/unreliable data like request objects or unstructured content. They're admittedly somewhat redundant with Postgres/SQL.

But things like the proposed additional tables or updating the state table through TRIGGER objects add DDL complexity (more tables, indirection, relationships, and objects) and remove those responsibilities from the business logic. For example,

  • we could hide memberships entirely from the domain layer, the db maintains a table with no ORM class, it makes updates in response to events. Complex to set up, but the application can drop all references to MembershipModel and only deals in Resources and Sources
  • adding permissions data in the db would allow us to filter the state table effectively by user id: since a view is just a stored query, you'd have a view for
    〰️ 🫱 〰️  HANDWAVING IMMINENT!!! 〰️ 👋 〰️
    select *
    from state s
    join visibility v on s.vis_id = v.id
    join user_visibility uv on uv.vis_id = v.id
    where uv.user_id = $user_id
    That'd still reap the benefits of the state table, but basically remove almost all application handling of permissions. So the application functions in terms of "users" (e.g. "what can this user see") instead of "what permissions does this user have" and "what can this set of permissions see".
  • the attributes table does this too, if we added that + db triggers to create new attributes, then our EAV could handle completely new source model shapes without migrations. Say Brazil needs a new source model for some reason. Application code has to change to accommodate the new model, but it serializes in the exact same way that the current model does when we persist it. The db sees the new colname(s), inserts them into the attributes table, and uses the new ids in the source values table for the Brazil source(s).

There are tradeoffs too (like adding new, unvetted columns), but I'd estimate that for now, they're worth the reduced application responsibilities and coupling between persistence & domain layers.


### Resource State View/Table

The purpose is to provide a durable store that houses the flattened/coalesced resources. We're effectively precomputing the coalescing logic and saving it to a single table. The actual schema is less important than its function and the constraints we place on it.

It must:

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We talked about this and it's inherent, but just to be clear/pedantic, it must also be re-creatable. Like if this table is inadvertently dropped, it would straightforward to recreate it by marching through the resources and re-precomputing. We should be careful to maintain that, ie not add to the table in the future in a way that it would hold data that could be recontructed--so we will be adding complexity, but it's low-risk complexity.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@john Is that sufficiently captured in:

  • be able to be rebuilt from scratch at any time, we should be able to derive the data for the table easily & quickly

Or is there some rewording you think it needs?


- only store unmerged resources (i.e. where `repointed_id == NULL`), merging triggers deletion from the table
- provide highly performant, permission-scoped querying of resource data
- not expose proprietary information to unlicensed users
- provide resource representations that are consistent with existing coalescing logic
- be able to be rebuilt from scratch at any time, we should be able to derive the data for the table easily & quickly

The top contender for a schema is:

```mermaid
erDiagram
og_field_resource_view {
bignt resource_id
smallint permission_mask
jsonb record
}
```

**permission mask**
The `permission_mask` is a bitmask where `public` = 0, `cc` = 1, `wm` = 2, and `wm + cc` = 3.

| wm | cc | perm |
| --- | --- | ---- |
| 0 | 0 | 0 |
| 0 | 1 | 1 |
| 1 | 0 | 2 |
| 1 | 1 | 3 |

This allows for 2 filtering options:

- strict `permission_mask = <user permission>` (incurs minor cost of possibly duplicating data across rows)
- bit comparison to filter where `(<user permission> | permission_mask) = <user permission>`
- if we sort by `permission_mask` (desc), this would allow us to only store the minimum number of resource variants
- for example, if a resource had ALL public sources as the highest priorities, we'd only need 1 row in the table with `permission_mask = 0` because ALL users would see the same version
- or if a resource had only `wm` and `public` variants, a `wm + cc` permission would get the `wm` version: 3 (`cc + wm`) | 2 (`wm`) = 3
- but a `cc` permission would skip the `wm` row and see the `public` variant:
1 (`cc`) | 2 (`wm`) = 3 => exclude
1 (`cc`) | 0 (`pub`) = 1 => include

> [!NOTE] Permission Alternative
> We can also use permission columns for the minor cost of duplicating data across columns. Benefits from being a simpler more understandable approach.

**record column as jsonb**
We'd effectively house the entire flat Pydantic model in json. The main reasoning is that we can likely expand to near-instant full-text search without much difficulty. It's also partially as an experiment to assess the difficulty of working with Postgres JSON syntax and investigate whether there are performance trade-offs. Should it prove easy to use while still being performant, it opens the door to using it in other places across our application where we might want greater flexibility in our data handling.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Having done this before, JSON search semantics are weird but not hard. But also we can encapsulate them so they can be used or not, and it's hidden, and can be changed without effects on calling code. Also I suspect it's avoidable in most other cases, which we may want to aim for just to reduce the set of things most devs need to understand.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Moving this to a "Considered Alternatives" section. I threw a prototype together and it was much larger, slower, and didn't support full text search any better than typed columns.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

for #290, I found it easy to work with individual typed columns (name, basin, etc.) so that the table is coalesced in its wide form, but we can still deal with each field (column) without any extra steps. It does eliminate the "fulltext" search, but keeps the ability to index and search over the text type columns.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Will move to an "Alternatives Considered" section.


**sync overview**
Very roughly speaking, when a user or process updates relevant data (merge resources, reprioritize, new sources from ETL), we compute the updated view state(s), and write them to the table, deleting where necessary.

```mermaid
flowchart LR
A[db updates] -->|Trigger| B[compute coalesced state]
B --> C[Write coalesced state to table]
D(merge) --> A
E(reprioritize) --> A
F(llm enrich) --> A
G(ETL) --> A
```

### Attribute metadata: `og_field_attributes`

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't fully see the value of this og_field_attributes or the single priority store over the current model.

Worth noting here though that in #290, I did have to nudge the DB so that a source couldn't be attached to a resource multiple times (that is, we can't attach source 5 to resource A multiple times, and have its source list be (5, 5, 5, 6).

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Wow...how did we not put a (resource_id, source_pk) uniqueness constraint on memberships?. That's a major oversight.


```mermaid
erDiagram
og_field_attributes {
serial id
text name
}

```

Priority rows and EAV rows reference `og_field_attribute.id`

### Single priority store: `og_field_resource_attribute_priority`

Replace the two-table priority split with a single table at the
`(resource, attribute, source record)` grain, and use defaults when
writing new data rather than as a SQL fallback rule.

```mermaid
erDiagram
og_field_resource_attribute_priority {
bigint resource_id
bigint attribute_id
bigint source_id
int priority
boolean is_curated
}
```

- Drop `og_field_source_priority` and `og_field_resource_source_priority`.
- On resource or source creation, write explicit priority rows for every attribute
the source has a value for, seeded from the existing `SOURCE_PRIORITY` tuple in
`stitch.ogsi.model`.
- Add constraints:
- unique priority across resource and attribute: no 2 rows for a resource & attribute can have the same priority
Comment thread
mbarlow12 marked this conversation as resolved.
`UNIQUE (resource_id, attribute_id, priority)`
- priority > 0
- fk constraint to memberships on resource_id, source_id: ensure source is attached to resource
- fk constraint to `og_field_source_values` on `source_id, attribute_id`
- `oil_gas_field_source_values` currently has an `id` primary key, so we could replace `(attribute_id, source_id)` with `source_value_id`
- we set `is_curated` to `True` when a user makes an update

### Optional Additional Tables

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Similarly, I don't think these optional tables add a whole lot for us, and we would probably be better off letting them arise naturally out of the implementation if that's what it needs to have. 🤷


These are primarily for some convenience in constructing simpler SQL statements and permissions handling.

```mermaid
erDiagram
og_source_keys {
bigint id
text key_name
}
```

References to `source` or `src_key` become foreign keys with `source_key_id`.

```mermaid
erDiagram
user_source_key_permissions {
uuid user_id
bigint source_key_id
source_key_permission perm
}
```

Where the `source_key_permission` is a new ENUM type with `read`, `edit`.

What this would allow is comparatively smaller and more straightforward SQL statements.

**single resource for a user**

```sql
WITH resolved AS (
SELECT DISTINCT ON (
p.resource_id,
p.attribute_id
)
p.resource_id,
p.attribute_id,
p.source_id,
s.source_key_id,
p.priority,
v.value_text,
v.value_num,
v.value_json
FROM og_field_resource_attribute_priority AS p
JOIN oil_gas_field_source_values AS v
ON v.source_id = p.source_id
AND v.attribute_id = p.attribute_id
JOIN oil_gas_field_sources AS s
ON s.source_id = p.source_id
JOIN user_source_key_permission AS permission
ON permission.source_key_id = s.source_key_id
AND permission.user_id = $1 -- pass in specific user id
AND permission.action = 'read'
WHERE p.resource_id = $2 -- drop this predicate to fetch many resources
ORDER BY
p.resource_id,
p.attribute_id,
p.priority
-- p.source_id <-- unnecessary with the priority uniqueness constraint
)
SELECT
r.resource_id,
jsonb_object_agg(
a.name,
COALESCE(
to_jsonb(r.value_text),
to_jsonb(r.value_num),
r.value_json
)
) AS attributes
FROM resolved AS r
JOIN og_field_attributes AS a
ON a.attribute_id = r.attribute_id
GROUP BY r.resource_id;

```

Note: The above can be paged and totaled as well with minimal alteration.

**single resource detailed provenance**

```sql
SELECT
p.resource_id,
a.name AS attribute,
p.source_id,
sk.key_name AS source_key,
p.priority,
v.value_text,
v.value_num,
v.value_json
FROM og_field_resource_attribute_priority AS p
JOIN oil_gas_field_source_values AS v
ON v.source_id = p.source_id
AND v.attribute_id = p.attribute_id
JOIN og_field_attributes AS a
ON a.attribute_id = p.attribute_id
JOIN og_field_sources AS s
ON s.source_id = p.source_id
JOIN og_source_keys AS sk
ON sk.source_key_id = s.source_key_id
JOIN user_source_key_permission AS permission
ON permission.source_key_id = s.source_key_id
AND permission.user_id = $1
AND permission.action = 'view'
WHERE p.resource_id = $2
ORDER BY p.priority, p.source_id;
```

## 5. Open Issues

1. Priority row count at current and projected resource volume.
2. How to handle priorities upon merge?

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We should handle priorities on merge the way we do now: by dropping all the overrides/reordering and it all falls back to default

Suggested change
2. How to handle priorities upon merge?

Loading
Loading