Skip to content

E1: DC resource engine + Atom entry-assembly (GET-first) — UsagePoint & Customer verticals, scope-enforced #187

@dfcoffin

Description

@dfcoffin

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 with gh issue edit 187 --body-file .claude/resume/e1-issue-draft.md.
Next: create the feature branch feature/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). So LocalTimeParameters appears iff at least one of UsagePoint-FB / Customer-FB / ServiceLocation-FB is in scope; a token with zero data FBs emits no LocalTimeParameters anywhere.

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. ProgramIdMappings related link is deferred to Add ProgramIdMappings entity + UsagePoint↔ProgramIdMappings relationship (energy) #168 (entity does not exist yet) — note it, don't block on it. LocalTimeParameters related link uses the TimeConfigurationEntity name-map.
  • Customer (customer.xsd): Customer entry resource (existing CustomerRESTController + 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 because CustomerAccountEntity currently maps only customer (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 a customer.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:

  1. Design the schema-neutral seam from both schemas up front — a ResourceDescriptor { resourceName, exportService, governingFbSet, relatedLinks[]:{targetResource} }, where governingFbSet is {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 by governingFbSet ∩ scope ≠ ∅ → assemble entry → emit each related link iff the target's governingFbSet ∩ scope ≠ ∅) is domain-agnostic, with the export service as an injected strategy.
  2. Implement the Usage vertical first (hardest graph), get it green.
  3. Land the Customer vertical on the same base as the falsification test: it should require only a new ResourceDescriptor + CustomerExportService wiring 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

  1. Generic EspiResourceController base (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.
  2. 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", and rel="related" per reachable related resource whose governing FB set ∩ token scope ≠ ∅ (covers FB-exempt LocalTimeParameters via its neighbors' FBs).
    • type attr rule: espi-feed/{Resource} when the href has no index (collection), espi-entry/{Resource} when the href ends in an id.
    • atom:link appears 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.
  3. ScopeResolver wired into the endpoints (replaces the // TODO: subscription filtering findAll(...) data-leakage stubs in UsagePointController).
  4. Strip non-ESPI pagination (limit/offset); bind EspiQueryOptions (all params @RequestParam(required=false); date filters are real predicates, max-results/start-index/start-after/depth accepted 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 (ScopeResolverResourceScope): 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

  1. ScopeResolver unit tests — each attribute combo → expected ResourceScope; tampered-URI case asserts granted set comes from SubscriptionRepository, not the token.
  2. MockMvc web-slice — authorization rows via SecurityMockMvcRequestPostProcessors.opaqueToken() post-processor (assert status and that out-of-scope related atom:links are omitted); authentication-failure rows via a real Bearer badtoken with the introspector bean mocked to throw → 401 (the opaqueToken() PP bypasses introspection, so this needs its own test).
  3. Integration test — mint a real opaque token from the AS for a real subscription → 200; the usage feed validates against espi.xsd and the customer feed validates against customer.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

Metadata

Metadata

Assignees

No one assigned

    Labels

    ESPI 4.0Touches the NAESB ESPI 4.0 implementationNAESB ESPI StandardRequired to comply with the NAESB ESPI standardschema-complianceData elements comply with their appropriate ESPI schema definitionstaskA general task

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions