Repository navigation
E1: DC resource engine + Atom entry-assembly (GET-first) — UsagePoint & Customer verticals, scope-enforced #187
Description
E1 — Resource engine + Atom entry-assembly (GET-first), first slice: UsagePoint triad
This is the body for E1, filed as #187 (labels: task, ESPI 4.0, schema-compliance, NAESB ESPI Standard).
This file is the source of truth; re-sync edits withgh issue edit 187 --body-file .claude/resume/e1-issue-draft.md.
Next: create the feature branchfeature/187-e1-resource-engine-usagepoint-triad(see RESUME-STATE.md).
Part of the canonical DC ESPI Resource Surface (GET-first) plan under #119. F1 (#167) delivered the scope core (ScopeResolver, ResourceScope, EspiQueryOptions) but nothing consumes it yet. E1 builds the resource engine + entry-assembler and migrates the first resources onto it, fully scope-enforced and XSD-valid.
Namespace gate CLEARED (2026-06-10, team decision): keep explicit atom:/espi: prefixes — no default/unprefixed namespace. The existing tested marshallers (UsageExportService, CustomerExportService) are NOT changed; E1 only layers link/URI assembly on top of them.
Dual-schema requirement (2026-06-11): E1 must support BOTH espi.xsd (usage domain) AND customer.xsd (customer domain). The resource engine and entry-assembler are schema-neutral — the same engine drives UsageExportService (→ espi.xsd) for usage resources and CustomerExportService (→ customer.xsd) for customer resources. No usage-only assumptions may leak into the base; the assembler keys off the resource descriptor, not a hard-coded namespace. Both schemas are validated in the integration tier.
Unifying rule (one model, both domains)
Usage and Customer are the same access model, not two. Every resource has a governing FB set, and a single rule governs both reachability (GET) and rel="related" link emission:
A resource/API is served, and a
rel="related"link to it is emitted, iff its governing FB set ∩ token scope ≠ ∅.
- Resource with its own FB → governing set =
{its FB}(singleton). This is the normal case.- Resource with no FB (
LocalTimeParameters) → governing set = the union of its associated resources' FBs (it borrows its neighbors' FBs — it is NOT unconditionally emitted). SoLocalTimeParametersappears iff at least one of UsagePoint-FB / Customer-FB / ServiceLocation-FB is in scope; a token with zero data FBs emits noLocalTimeParametersanywhere.
Both domains traverse related resources; both prune links by this intersection test. Example: a Customer-FB-only token makes the Customer API valid and emits the Customer(s), but the related links to CustomerAccount/Statement are pruned because those FBs are absent — identical to how a usage token without the MeterReading FB prunes that link. The engine is therefore fully schema- and domain-neutral; the only per-resource inputs are its export service (espi vs customer) and its governing FB set.
Scope of this slice — two verticals proving the one model
- Usage (
espi.xsd): UsagePoint → MeterReading → ReadingType triad — the richest related-link path that exists today; stresses the assembler + FB-pruning hardest.ProgramIdMappingsrelated link is deferred to Add ProgramIdMappings entity + UsagePoint↔ProgramIdMappings relationship (energy) #168 (entity does not exist yet) — note it, don't block on it.LocalTimeParametersrelated link uses theTimeConfigurationEntityname-map. - Customer (
customer.xsd): Customer entry resource (existingCustomerRESTController+CustomerMapper+CustomerExportService) with its related traversal Customer → {CustomerAccount, Statement, LocalTimeParameters} — the three relationships that are actually JPA-mapped today (CustomerEntity.customerAccounts,.statements,.timeConfiguration; verified 2026-06-11). Chosen over CustomerAccount-as-entry becauseCustomerAccountEntitycurrently maps onlycustomer(a single link), so it would prove no traversal. The deeper PII tree (CustomerAgreement,ServiceLocation,Meter,EndDevice,ServiceSupplier) related links are deferred to Map existing PII-tree FKs into JPA entity relationships (CustomerAgreement/ServiceLocation tree) #169 (JPA mappings for those FKs do not exist yet) — parallel to ProgramIdMappings→Add ProgramIdMappings entity + UsagePoint↔ProgramIdMappings relationship (energy) #168 on the usage side. Proves the same assembler emits acustomer.xsd-valid entry envelope through the same base.
Shared resource — LocalTimeParameters (TimeConfigurationEntity): one DC-global table (Standard + DST offsets from UTC) surfaced in both schemas and reachable from both verticals (UsagePoint→LocalTimeParameters in espi.xsd; Customer→LocalTimeParameters in customer.xsd). It is FB-exempt (governing set = its neighbors' FBs) because a TP requires it to translate the mandatory UTC timestamps to local time. This makes it E1's cleanest schema-neutrality proof: one table + one descriptor, referenced from both domains, exercising the FB-exempt branch of the gate rule.
Build approach (combined, usage-first, customer as the proof)
Develop both verticals in this one slice — do not ship a usage-only foundation and retrofit customer later (that abstracts from a single example and forces a later rework of the energy base). Instead:
- Design the schema-neutral seam from both schemas up front — a
ResourceDescriptor { resourceName, exportService, governingFbSet, relatedLinks[]:{targetResource} }, wheregoverningFbSetis{ownFB}for an FB-bearing resource and the union of neighbor FBs for an FB-exempt one (LocalTimeParameters). The engine (resolve scope → token FB set → gate endpoint bygoverningFbSet ∩ scope ≠ ∅→ assemble entry → emit each related link iff the target'sgoverningFbSet ∩ scope ≠ ∅) is domain-agnostic, with the export service as an injected strategy. - Implement the Usage vertical first (hardest graph), get it green.
- Land the Customer vertical on the same base as the falsification test: it should require only a new
ResourceDescriptor+CustomerExportServicewiring and zero base modification. If the base must change to admit customer, the seam was wrong — caught on day 3, not after usage ships.
Deliverables
- Generic
EspiResourceControllerbase (or equivalent) that all resource endpoints build on, schema-neutral (serves both usage and customer resources via the appropriate export service): ROOT + XPath (/Subscription/{subscriptionId}/…) forms, collection + individual. - Entry-assembler that populates the Atom envelope on each
<entry>(the existing marshalling renders links present in the DTO but does not populate them); identical assembly for usage and customer resources:atom:id=urn:uuid:{mRID}.rel="self",rel="up", andrel="related"per reachable related resource whose governing FB set ∩ token scope ≠ ∅ (covers FB-exempt LocalTimeParameters via its neighbors' FBs).typeattr rule:espi-feed/{Resource}when the href has no index (collection),espi-entry/{Resource}when the href ends in an id.atom:linkappears only on<entry>, never on<feed>.- Related links are bidirectional (per the EA UML
<<link>>graph) — emit on both endpoint entries. - Cross-domain links (energy↔PII:
UsageSummary↔Statement) emit the href only — the cross-domain resource content is NEVER co-marshalled into the same feed/entry (ESPI forbids mixing PII and energy in one document); and the FB gate omits the link unless the other domain's FB is also in scope. LocalTimeParameters is shared reference data, not PII/energy, so this constraint does not apply to it.
ScopeResolverwired into the endpoints (replaces the// TODO: subscription filteringfindAll(...)data-leakage stubs inUsagePointController).- Strip non-ESPI pagination (
limit/offset); bindEspiQueryOptions(all params@RequestParam(required=false); date filters are real predicates,max-results/start-index/start-after/depthaccepted best-effort — no 400s).
Access-control acceptance criteria (valid vs invalid token)
Two enforcement layers, validated independently:
- Authentication (opaque-token introspection via
EspiScopeOpaqueTokenIntrospector): missing / inactive / expired / malformed → 401 before any controller runs. - Authorization (
ScopeResolver→ResourceScope): valid token, but is this resource granted?
| Token presented | Expected |
|---|---|
| none / no Authorization header | 401 |
| garbage / expired / inactive (introspection fails) | 401 |
valid, admin authority (SCOPE_DataCustodian_Admin_Access) |
200, full feed |
| valid, Subscription scope including requested UsagePoint | 200, feed contains only scoped UPs |
| valid, Subscription scope excluding requested UsagePoint | 404 |
| valid customer-FB token hitting a customer resource (Customer) | 200, customer.xsd-valid entry; related links pruned to in-scope FBs |
| valid Customer-FB-only token (no CustomerAccount/Statement FB) | 200 Customer(s) emitted; CustomerAccount/Statement related links omitted; LocalTimeParameters link present (FB-exempt, Customer-FB satisfies it) |
| valid token with a UsagePoint or Customer/ServiceLocation FB | LocalTimeParameters reachable + emitted (FB-exempt, neighbor FB present) |
| valid token with zero data FBs | no LocalTimeParameters emitted anywhere (governing set empty ∩ scope) |
| valid energy (Subscription) token hitting a PII resource | 404 |
| valid PII (RetailCustomer) token hitting an energy resource | 404 |
valid token resolving to denied() (no grantable scope at all) |
403 |
| tampered URI widened to another subscription | scope unchanged (granted set read from DB, not token) → 404 |
Status-code policy (decided 2026-06-10, reversible one-liner): 404 for a valid token requesting a resource outside its granted scope (prevents existence-probing via status code); 403 for a valid token with no grantable scope at all; 401 for the authn layer.
Test tiers
ScopeResolverunit tests — each attribute combo → expectedResourceScope; tampered-URI case asserts granted set comes fromSubscriptionRepository, not the token.- MockMvc web-slice — authorization rows via
SecurityMockMvcRequestPostProcessors.opaqueToken()post-processor (assert status and that out-of-scope relatedatom:links are omitted); authentication-failure rows via a realBearer badtokenwith the introspector bean mocked to throw → 401 (theopaqueToken()PP bypasses introspection, so this needs its own test). - Integration test — mint a real opaque token from the AS for a real subscription → 200; the usage feed validates against
espi.xsdand the customer feed validates againstcustomer.xsd, each containing exactly the granted resources; garbage/expired token → 401; subscription B's token requesting subscription A's UsagePoint → 404. Both schemas MUST be exercised — a usage-only validation does not satisfy E1.
Out of scope / follow-ups
ProgramIdMappingsrelated link → Add ProgramIdMappings entity + UsagePoint↔ProgramIdMappings relationship (energy) #168.- ApplicationInformation and remaining resources fold onto the base in subsequent E-slices.
- CRUD writes remain deferred (chore(#119): defer ESPI resource CRUD writes — delete write/admin controllers #166) — surface is GET-only.
Activity
[-]E1: DC resource engine + Atom entry-assembly (GET-first) — UsagePoint triad, scope-enforced[/-][+]E1: DC resource engine + Atom entry-assembly (GET-first) — UsagePoint & Customer verticals, scope-enforced[/+]