You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
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:
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.
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 + 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
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.
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.
ScopeResolver wired into the endpoints (replaces the // TODO: subscription filteringfindAll(...) data-leakage stubs in UsagePointController).
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 (ScopeResolver → ResourceScope): valid token, but is this resource granted?
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
ScopeResolver unit tests — each attribute combo → expected ResourceScope; tampered-URI case asserts granted set comes from SubscriptionRepository, not the token.
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).
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.
E1 — Resource engine + Atom entry-assembly (GET-first), first slice: UsagePoint triad
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) ANDcustomer.xsd(customer domain). The resource engine and entry-assembler are schema-neutral — the same engine drivesUsageExportService(→espi.xsd) for usage resources andCustomerExportService(→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: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
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.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 inespi.xsd; Customer→LocalTimeParameters incustomer.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:
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.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
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>(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>.<<link>>graph) — emit on both endpoint entries.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).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:
EspiScopeOpaqueTokenIntrospector): missing / inactive / expired / malformed → 401 before any controller runs.ScopeResolver→ResourceScope): valid token, but is this resource granted?SCOPE_DataCustodian_Admin_Access)customer.xsd-valid entry; related links pruned to in-scope FBsdenied()(no grantable scope at all)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.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).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.