diff --git a/deployments/api/src/stitch/api/db/og_field_resource_actions.py b/deployments/api/src/stitch/api/db/og_field_resource_actions.py index 94c5d157..d5960d1f 100644 --- a/deployments/api/src/stitch/api/db/og_field_resource_actions.py +++ b/deployments/api/src/stitch/api/db/og_field_resource_actions.py @@ -22,7 +22,11 @@ attach_sources_to_resource, get_or_create_sources, ) -from stitch.ogsi.model import OGFieldListItemView, OGFieldResource +from stitch.ogsi.model import ( + OGFieldListItemView, + OGFieldResource, + OGFieldSourceValueView, +) from stitch.ogsi.model.types import OGSISrcKey from .model import ( @@ -30,7 +34,7 @@ MembershipStatus, ResourceModel, ) -from .model.oil_gas_field_source_value import value_attr_for +from .model.oil_gas_field_source_value import ATTRIBUTE_NAMES, value_attr_for from .queries import base_resource_query, construct_base_query_statement, add_ranking from .utils import ( coalesce_resources, @@ -127,6 +131,45 @@ async def get( ) +async def field_source_values( + session: AsyncSession, + id: int, + field: str, + licensed_sources: Collection[OGSISrcKey] | None = None, +) -> list[OGFieldSourceValueView]: + """Every source's value for one field of a resource, best-priority first. + + Returns only sources that carry a value for ``field`` (empty/null omitted), + each with its effective per-resource priority. The first entry is the + coalesced winner. Licensing is applied. Priority is source-scoped today; the + contract is unchanged when it becomes resource/field-scoped. + """ + if field not in ATTRIBUTE_NAMES: + raise HTTPException( + status_code=422, + detail=f"field={field} is not a known resource field.", + ) + if await session.get(ResourceModel, id) is None: + raise HTTPException( + status_code=HTTP_404_NOT_FOUND, detail=f"No Resource with id `{id}` found." + ) + + by_id = await ResourceModel.source_data_by_resource_id( + session, [id], licensed_sources + ) + values = [ + OGFieldSourceValueView( + source=src.source, id=src.id, value=value, priority=priority + ) + for src, priority in by_id.get(id, []) + if src.id is not None + and (value := getattr(src, field)) is not None + and value != "" + ] + values.sort(key=lambda v: (v.priority, v.id)) + return values + + async def create( session: AsyncSession, user: CurrentUser, resource: OGFieldResource ) -> OGFieldResource: diff --git a/deployments/api/src/stitch/api/routers/oil_gas_fields.py b/deployments/api/src/stitch/api/routers/oil_gas_fields.py index b38c1f00..75c7b620 100644 --- a/deployments/api/src/stitch/api/routers/oil_gas_fields.py +++ b/deployments/api/src/stitch/api/routers/oil_gas_fields.py @@ -41,6 +41,7 @@ OGFieldListItemView, OGFieldResource, OGFieldResourceView, + OGFieldSourceValueView, OGFieldView, ) @@ -284,6 +285,27 @@ async def get_resource_detail( return resource_to_detail_view(resource=res) +@router.get( + "/{id}/fields/{field}/sources", + response_model=list[OGFieldSourceValueView], + dependencies=[Depends(require_permissions(RESOURCE_READ))], +) +async def get_field_source_values( + *, + uow: UnitOfWorkDep, + user: CurrentUser, + claims: Claims, + id: int, + field: str, +) -> list[OGFieldSourceValueView]: + return await resource_actions.field_source_values( + session=uow.session, + id=id, + field=field, + licensed_sources=licensed_sources(claims), + ) + + @router.post( "/", response_model=OGFieldResourceView, diff --git a/deployments/api/tests/db/test_resource_actions.py b/deployments/api/tests/db/test_resource_actions.py index edac7208..e7eea148 100644 --- a/deployments/api/tests/db/test_resource_actions.py +++ b/deployments/api/tests/db/test_resource_actions.py @@ -1092,6 +1092,115 @@ async def test_override_is_scoped_to_its_resource( assert (await resource_actions.get(session, untouched)).view.name == "GEM Name" +class TestFieldSourceValues: + """Per-field source-value listing, best-priority first.""" + + async def _seed(self, session, user) -> int: + # gem(2) outranks wm(3) by default; llm has no state_province. + return await _create_resource_with_sources( + session, + user, + {"source": "gem", "name": "GEM Name", "country": "USA", "basin": "Alpha"}, + {"source": "wm", "name": "WM Name", "country": "CAN", "basin": "Beta"}, + {"source": "llm", "name": "LLM Name", "country": "GBR"}, + ) + + @pytest.mark.anyio + async def test_lists_values_sorted_by_priority_with_winner_first( + self, + seeded_integration_session: AsyncSession, + test_user: User, + ): + session = seeded_integration_session + rid = await self._seed(session, test_user) + + rows = await resource_actions.field_source_values(session, rid, "name") + + # gem(2) < wm(3) < llm(4): winner (gem) first, then in priority order. + assert [(r.source, r.value) for r in rows] == [ + ("gem", "GEM Name"), + ("wm", "WM Name"), + ("llm", "LLM Name"), + ] + assert [r.priority for r in rows] == sorted(r.priority for r in rows) + + @pytest.mark.anyio + async def test_override_reorders_field_values( + self, + seeded_integration_session: AsyncSession, + test_user: User, + ): + session = seeded_integration_session + rid = await self._seed(session, test_user) + session.add( + OGFieldResourceSourcePriority(resource_id=rid, source="wm", priority=1) + ) + await session.flush() + + rows = await resource_actions.field_source_values(session, rid, "basin") + + # wm promoted above gem; llm has no basin so it is omitted. + assert [(r.source, r.value) for r in rows] == [ + ("wm", "Beta"), + ("gem", "Alpha"), + ] + + @pytest.mark.anyio + async def test_omits_sources_without_a_value( + self, + seeded_integration_session: AsyncSession, + test_user: User, + ): + session = seeded_integration_session + rid = await self._seed(session, test_user) + + rows = await resource_actions.field_source_values( + session, rid, "state_province" + ) + + assert rows == [] + + @pytest.mark.anyio + async def test_unlicensed_sources_are_excluded( + self, + seeded_integration_session: AsyncSession, + test_user: User, + ): + session = seeded_integration_session + rid = await self._seed(session, test_user) + + rows = await resource_actions.field_source_values( + session, rid, "name", licensed_sources=["gem"] + ) + + assert [r.source for r in rows] == ["gem"] + + @pytest.mark.anyio + async def test_unknown_field_is_rejected( + self, + seeded_integration_session: AsyncSession, + test_user: User, + ): + session = seeded_integration_session + rid = await self._seed(session, test_user) + + with pytest.raises(HTTPException) as exc: + await resource_actions.field_source_values(session, rid, "not_a_field") + assert exc.value.status_code == 422 + + @pytest.mark.anyio + async def test_missing_resource_is_404( + self, + seeded_integration_session: AsyncSession, + test_user: User, + ): + with pytest.raises(HTTPException) as exc: + await resource_actions.field_source_values( + seeded_integration_session, 9_999_999, "name" + ) + assert exc.value.status_code == 404 + + class TestResourceDetailCoalescing: """Detail-path (``resource_actions.get``) coalescing behavior. diff --git a/deployments/seed/data/006-source-values-demo.json b/deployments/seed/data/006-source-values-demo.json new file mode 100644 index 00000000..8ad7e9cf --- /dev/null +++ b/deployments/seed/data/006-source-values-demo.json @@ -0,0 +1,56 @@ +[ + { + "id": 0, + "source_data": [ + { + "source": "rmi", + "name": "Coalescing Demo Field", + "country": "USA", + "location_type": "Onshore", + "discovery_year": 1969 + }, + { + "source": "gem", + "name": "Coalescing Demo (GEM A)", + "country": "USA", + "basin": "Permian Basin", + "latitude": 31.9, + "longitude": -102.1, + "production_conventionality": "Unconventional", + "discovery_year": 1970, + "production_start_year": 1975 + }, + { + "source": "gem", + "name": "Coalescing Demo (GEM B)", + "country": "CAN", + "basin": "Williston Basin", + "production_conventionality": "Conventional", + "discovery_year": 1971 + }, + { + "source": "wm", + "name": "Coalescing Demo (WM)", + "country": "GBR", + "basin": "North Sea", + "state_province": "Texas", + "latitude": 31.85, + "longitude": -102.2, + "location_type": "Offshore", + "primary_hydrocarbon_group": "Light Oil", + "discovery_year": 1968, + "production_start_year": 1974, + "field_status": "Producing" + }, + { + "source": "llm", + "name": "Coalescing Demo (LLM)", + "country": null, + "basin": "Ghawar (guess)", + "primary_hydrocarbon_group": "Medium Oil", + "field_status": "Non-Producing" + } + ], + "constituents": [] + } +] diff --git a/deployments/stitch-frontend/src/components/FieldCard.jsx b/deployments/stitch-frontend/src/components/FieldCard.jsx index 0b7f023c..de174bed 100644 --- a/deployments/stitch-frontend/src/components/FieldCard.jsx +++ b/deployments/stitch-frontend/src/components/FieldCard.jsx @@ -1,3 +1,4 @@ +import { useId } from "react"; import { SOURCE_COLORS, SOURCE_LABELS, @@ -7,7 +8,19 @@ import { // Used to display a single field value in a card, as seen in the ResourceDetailPage. // Pass `source` (one of "gem" | "wm" | "rmi" | "llm") to tint the left border by data source. -export function FieldCard({ label, value, source }) { +// Pass `expandable` + `isOpen` + `onToggle` to make the value a toggle button; `children` +// (e.g. an "All sources" panel) render below the box while open. The card is presentational: +// the parent owns open state and any data fetching. +export function FieldCard({ + label, + value, + source, + expandable = false, + isOpen = false, + onToggle, + children, +}) { + const panelId = useId(); const display = value === null || value === undefined || value === "" ? null @@ -18,30 +31,61 @@ export function FieldCard({ label, value, source }) { ? (SOURCE_LABELS[source] ?? UNKNOWN_SOURCE_LABEL) : null; + const boxContent = ( + <> +
+
+ {display ?? } +
+ {expandable && ( + + )} +
+ {sourceLabel && ( +
+
+ )} + + ); + + const boxClasses = + "min-h-[2.5rem] w-full rounded-md border border-line bg-panel px-3 py-2 text-left text-sm text-ink"; + return (

{label}

-
-
- {display ?? } + {expandable ? ( + + ) : ( +
+ {boxContent}
- {sourceLabel && ( -
-
- )} -
+ )} + {expandable && isOpen &&
{children}
}
); } diff --git a/deployments/stitch-frontend/src/components/FieldCard.test.jsx b/deployments/stitch-frontend/src/components/FieldCard.test.jsx index 1a825509..d064ea91 100644 --- a/deployments/stitch-frontend/src/components/FieldCard.test.jsx +++ b/deployments/stitch-frontend/src/components/FieldCard.test.jsx @@ -1,5 +1,6 @@ -import { describe, it, expect } from "vitest"; +import { describe, it, expect, vi } from "vitest"; import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; import { FieldCard, FieldGrid } from "./FieldCard"; import { SOURCE_COLORS, @@ -82,6 +83,55 @@ describe("FieldCard", () => { }); }); +describe("FieldCard expandable behavior", () => { + it("renders a plain box (no button) when not expandable", () => { + render(); + expect(screen.queryByRole("button")).not.toBeInTheDocument(); + }); + + it("renders a toggle button reflecting isOpen when expandable", () => { + const { rerender } = render( + , + ); + const toggle = screen.getByRole("button"); + expect(toggle).toHaveAttribute("aria-expanded", "false"); + + rerender(); + expect(screen.getByRole("button")).toHaveAttribute("aria-expanded", "true"); + }); + + it("calls onToggle when the value button is clicked", async () => { + const user = userEvent.setup(); + const onToggle = vi.fn(); + render( + , + ); + await user.click(screen.getByRole("button")); + expect(onToggle).toHaveBeenCalledTimes(1); + }); + + it("renders children only when expandable and open", () => { + const { rerender } = render( + +
panel body
+
, + ); + expect(screen.queryByText("panel body")).not.toBeInTheDocument(); + + rerender( + +
panel body
+
, + ); + expect(screen.getByText("panel body")).toBeInTheDocument(); + }); +}); + describe("FieldGrid", () => { it("renders its children", () => { render( diff --git a/deployments/stitch-frontend/src/components/ResourceFieldCard.jsx b/deployments/stitch-frontend/src/components/ResourceFieldCard.jsx new file mode 100644 index 00000000..4770fa9f --- /dev/null +++ b/deployments/stitch-frontend/src/components/ResourceFieldCard.jsx @@ -0,0 +1,101 @@ +import { useState } from "react"; +import { FieldCard } from "./FieldCard"; +import { useFieldSourceValues } from "../hooks/useResources"; +import { + SOURCE_COLORS, + SOURCE_LABELS, + UNKNOWN_SOURCE_LABEL, + DEFAULT_FIELD_COLOR, +} from "../constants/sourceMeta"; + +function SourceValueRow({ source, value, id, isWinner }) { + const barColor = SOURCE_COLORS[source] ?? DEFAULT_FIELD_COLOR; + const sourceLabel = SOURCE_LABELS[source] ?? UNKNOWN_SOURCE_LABEL; + const meta = + id !== null && id !== undefined ? `${sourceLabel} · #${id}` : sourceLabel; + // Quote strings to set text values apart; render numbers/booleans bare. + const display = typeof value === "string" ? `"${value}"` : String(value); + + return ( +
+
{display}
+
{meta}
+
+ ); +} + +function FieldSourcesPanel({ isLoading, isError, sources }) { + return ( +
+ {/* Reserved header — future controls will live alongside this label. */} +

+ All sources +

+ {isLoading &&

Loading sources…

} + {isError && ( +

Failed to load source values.

+ )} + {!isLoading && !isError && sources.length === 0 && ( +

+ No source values for this field. +

+ )} + {!isLoading && !isError && sources.length > 0 && ( +
+ {/* The endpoint returns best-priority first, so index 0 is the winner. */} + {sources.map((row, idx) => ( + + ))} +
+ )} +
+ ); +} + +// A FieldCard for the resource detail page: clicking a populated value lazily +// fetches every source's value for that field and shows them in priority order. +export default function ResourceFieldCard({ + endpoint, + resourceId, + fieldKey, + label, + value, + source, +}) { + const [isOpen, setIsOpen] = useState(false); + const expandable = value !== null && value !== undefined && value !== ""; + const { data, isLoading, isError } = useFieldSourceValues( + endpoint, + resourceId, + fieldKey, + isOpen && expandable, + ); + + return ( + setIsOpen((current) => !current)} + > + + + ); +} diff --git a/deployments/stitch-frontend/src/components/ResourceFieldCard.test.jsx b/deployments/stitch-frontend/src/components/ResourceFieldCard.test.jsx new file mode 100644 index 00000000..f28b7010 --- /dev/null +++ b/deployments/stitch-frontend/src/components/ResourceFieldCard.test.jsx @@ -0,0 +1,124 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import ResourceFieldCard from "./ResourceFieldCard"; +import { useFieldSourceValues } from "../hooks/useResources"; +import { SOURCE_LABELS } from "../constants/sourceMeta"; + +vi.mock("../hooks/useResources", () => ({ + useFieldSourceValues: vi.fn(), +})); + +function renderCard(props = {}) { + return render( + , + ); +} + +describe("ResourceFieldCard", () => { + beforeEach(() => { + useFieldSourceValues.mockReset(); + useFieldSourceValues.mockReturnValue({ + data: undefined, + isLoading: false, + isError: false, + }); + }); + + it("is not expandable when the value is empty", () => { + renderCard({ value: null }); + expect(screen.queryByRole("button")).not.toBeInTheDocument(); + }); + + it("does not enable the query until the panel is opened", () => { + renderCard(); + // Closed on first render → query disabled. + expect(useFieldSourceValues).toHaveBeenLastCalledWith( + "oil-gas-fields", + 42, + "basin", + false, + ); + }); + + it("enables the query and shows the panel when opened", async () => { + const user = userEvent.setup(); + useFieldSourceValues.mockReturnValue({ + data: [ + { source: "wm", id: 20, value: "Foo Basin", priority: 1 }, + { source: "gem", id: 10, value: "Bar Basin", priority: 2 }, + ], + isLoading: false, + isError: false, + }); + renderCard(); + + await user.click(screen.getByRole("button")); + + expect(useFieldSourceValues).toHaveBeenLastCalledWith( + "oil-gas-fields", + 42, + "basin", + true, + ); + expect(screen.getByText("All sources")).toBeInTheDocument(); + expect(screen.getByText('"Foo Basin"')).toBeInTheDocument(); + expect(screen.getByText('"Bar Basin"')).toBeInTheDocument(); + expect(screen.getByText(`${SOURCE_LABELS.wm} · #20`)).toBeInTheDocument(); + }); + + it("highlights the first (winning) row and diminishes the rest", async () => { + const user = userEvent.setup(); + useFieldSourceValues.mockReturnValue({ + data: [ + { source: "wm", id: 20, value: "Foo Basin", priority: 1 }, + { source: "gem", id: 10, value: "Bar Basin", priority: 2 }, + ], + isLoading: false, + isError: false, + }); + renderCard(); + await user.click(screen.getByRole("button")); + + expect(screen.getByText('"Foo Basin"').closest(".border-l-4")).toHaveClass( + "bg-surface", + ); + expect(screen.getByText('"Bar Basin"').closest(".border-l-4")).toHaveClass( + "bg-panel", + ); + }); + + it("shows a loading state while fetching", async () => { + const user = userEvent.setup(); + useFieldSourceValues.mockReturnValue({ + data: undefined, + isLoading: true, + isError: false, + }); + renderCard(); + await user.click(screen.getByRole("button")); + expect(screen.getByText("Loading sources…")).toBeInTheDocument(); + }); + + it("shows an error state when the fetch fails", async () => { + const user = userEvent.setup(); + useFieldSourceValues.mockReturnValue({ + data: undefined, + isLoading: false, + isError: true, + }); + renderCard(); + await user.click(screen.getByRole("button")); + expect( + screen.getByText("Failed to load source values."), + ).toBeInTheDocument(); + }); +}); diff --git a/deployments/stitch-frontend/src/hooks/useResources.js b/deployments/stitch-frontend/src/hooks/useResources.js index 1b4de26d..a990b884 100644 --- a/deployments/stitch-frontend/src/hooks/useResources.js +++ b/deployments/stitch-frontend/src/hooks/useResources.js @@ -87,6 +87,19 @@ function useSourceDetailReal( }); } +function useFieldSourceValuesReal( + endpoint = "oil-gas-fields", + id, + field, + enabled = false, +) { + const config = useConfig(); + return useAuthenticatedQuery({ + ...resourceQueries.fieldSources(config, endpoint, id, field), + enabled: enabled && Boolean(field) && Number.isFinite(id), + }); +} + function useMergeCandidatesReal(endpoint = "oil-gas-fields", enabled = false) { const config = useConfig(); return useAuthenticatedQuery({ @@ -287,6 +300,22 @@ function useSourceDetailMock( }); } +function useFieldSourceValuesMock( + endpoint = "oil-gas-fields", + id, + field, + enabled = false, +) { + // Mock mode has no backend; the panel degrades to an empty state, matching + // useSourceDetailMock. Real behavior is exercised against the API. Guard `id` + // the same way the real hook does so the query key stays stable. + return useQuery({ + queryKey: resourceKeys.fieldSources(endpoint, id, field), + queryFn: () => Promise.resolve([]), + enabled: enabled && Boolean(field) && Number.isFinite(id), + }); +} + function useMergeCandidatesMock(endpoint = "oil-gas-fields", enabled = false) { return useQuery({ queryKey: resourceKeys.mergeCandidates(endpoint), @@ -343,6 +372,9 @@ export const useResourceDetail = USE_MOCK_DATA export const useSourceDetail = USE_MOCK_DATA ? useSourceDetailMock : useSourceDetailReal; +export const useFieldSourceValues = USE_MOCK_DATA + ? useFieldSourceValuesMock + : useFieldSourceValuesReal; export const useMergeCandidates = USE_MOCK_DATA ? useMergeCandidatesMock : useMergeCandidatesReal; diff --git a/deployments/stitch-frontend/src/pages/ResourceDetailPage.jsx b/deployments/stitch-frontend/src/pages/ResourceDetailPage.jsx index 6354c680..01d67100 100644 --- a/deployments/stitch-frontend/src/pages/ResourceDetailPage.jsx +++ b/deployments/stitch-frontend/src/pages/ResourceDetailPage.jsx @@ -12,6 +12,7 @@ import { import SourceMixBar from "../components/SourceMixBar"; import SectionHeader from "../components/SectionHeader"; import { FieldCard, FieldGrid } from "../components/FieldCard"; +import ResourceFieldCard from "../components/ResourceFieldCard"; import { SOURCE_LABELS } from "../constants/sourceMeta"; import Button from "../components/Button"; import { @@ -578,8 +579,11 @@ export default function ResourceDetailPage() { {IDENTITY_FIELDS.map((key) => ( - {PRODUCTION_FIELDS.map((key) => ( - { refetch: vi.fn(), }); vi.mocked(useSourceDetail).mockReturnValue(defaultSourceDetailHookReturn); + vi.mocked(useFieldSourceValues).mockReturnValue({ + data: [], + isLoading: false, + isError: false, + }); vi.stubGlobal("crypto", { randomUUID: () => "persist-uuid-123", }); diff --git a/deployments/stitch-frontend/src/queries/api.js b/deployments/stitch-frontend/src/queries/api.js index 63bc3d4d..ade766c2 100644 --- a/deployments/stitch-frontend/src/queries/api.js +++ b/deployments/stitch-frontend/src/queries/api.js @@ -65,6 +65,25 @@ export async function getResourceDetail( return data; } +export async function getFieldSourceValues( + config, + id, + field, + fetcher, + endpoint = "oil-gas-fields", +) { + const url = `${config.apiBaseUrl}/${endpoint}/${id}/fields/${encodeURIComponent( + field, + )}/sources`; + const response = await fetcher(url); + if (!response.ok) { + const error = new Error(`HTTP error! status: ${response.status}`); + error.status = response.status; + throw error; + } + return await response.json(); +} + export async function createLLMSuggestion( config, id, diff --git a/deployments/stitch-frontend/src/queries/resources.js b/deployments/stitch-frontend/src/queries/resources.js index 55b4de94..9ebe280b 100644 --- a/deployments/stitch-frontend/src/queries/resources.js +++ b/deployments/stitch-frontend/src/queries/resources.js @@ -3,6 +3,7 @@ import { getResource, getResources, getResourceDetail, + getFieldSourceValues, getMergeCandidates, getMergeCandidate, getMergeCandidatePreview, @@ -33,6 +34,11 @@ export const resourceKeys = { ...resourceKeys.details(endpoint), id, ], + fieldSources: (endpoint = "oil-gas-fields", id, field) => [ + ...resourceKeys.detail(endpoint, id), + "field-sources", + field, + ], views: (endpoint = "resources") => [...resourceKeys.all(endpoint), "view"], view: (endpoint = "resources", id) => [...resourceKeys.views(endpoint), id], @@ -100,6 +106,14 @@ export const resourceQueries = { enabled: false, }), + fieldSources: (config, endpoint = "oil-gas-fields", id, field) => ({ + queryKey: resourceKeys.fieldSources(endpoint, id, field), + queryFn: (fetcher) => + getFieldSourceValues(config, id, field, fetcher, endpoint), + enabled: false, + staleTime: DEFAULT_STALE_TIME, + }), + view: (config, endpoint = "resources", id) => ({ queryKey: resourceKeys.view(endpoint, id), queryFn: (fetcher) => getResource(config, id, fetcher, endpoint), diff --git a/packages/stitch-ogsi/src/stitch/ogsi/model/__init__.py b/packages/stitch-ogsi/src/stitch/ogsi/model/__init__.py index 49fbf477..ad778640 100644 --- a/packages/stitch-ogsi/src/stitch/ogsi/model/__init__.py +++ b/packages/stitch-ogsi/src/stitch/ogsi/model/__init__.py @@ -21,6 +21,7 @@ __all__ = [ "OGFieldSource", "OGFieldSourceView", + "OGFieldSourceValueView", "OGFieldResource", "OGFieldResourceView", "OGFieldView", @@ -115,6 +116,22 @@ def _normalize_source_data(cls, value): return normalized +class OGFieldSourceValueView(BaseModel): + """One source's value for a single field, with its effective priority. + + Returned by the per-field source-values endpoint. ``priority`` is the + effective per-resource ranking (lower ``priority`` value = higher priority); + the winning value is the entry with the lowest ``priority`` value (ties + broken by ``id``). When priority becomes resource/field-scoped, only that + resolution changes -- this shape is stable. + """ + + source: OGSISrcKey + id: int + value: Any + priority: int + + class OGFieldResource(Resource[int, OGFieldSource]): provenance: dict[str, tuple[Any, OGSISrcKey, int] | None] = Field( default_factory=dict