Skip to content

Commit c394930

Browse files
authored
feat: Add InternalAccount resource type to manifest pipeline (#1688)
Add InternalAccountDefinition as a distinct resource type in the manifest pipeline, separate from AccountType (which is reference data). Internal accounts represent the chart of accounts -- structural accounts owned by the economy. - Proto: Add InternalAccountDefinition message with code, account_type, instrument, owner_organization, and description fields - Differ: Implement diffInternalAccounts with create/update/delete/no-change detection and change description - Planner: Add PhaseInternalAccounts (phase 12) after organizations, map to InitiateInternalAccount/UpdateInternalAccount/ControlInternalAccount - Validator: Add duplicate code detection and cross-reference validation for account_type, instrument, and owner_organization references Co-authored-by: Ben Coombs <bjcoombs@users.noreply.github.com>
1 parent eb0de06 commit c394930

10 files changed

Lines changed: 543 additions & 4 deletions

File tree

api/jsonschema/manifest.v1.schema.json

Lines changed: 45 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,8 @@
1616
"mappings",
1717
"operationalGateway",
1818
"marketData",
19-
"organizations"
19+
"organizations",
20+
"internalAccounts"
2021
],
2122
"properties": {
2223
"version": {
@@ -106,6 +107,14 @@
106107
"additionalProperties": false,
107108
"type": "array",
108109
"description": "organizations defines the organization entities (parties) for this tenant."
110+
},
111+
"internalAccounts": {
112+
"items": {
113+
"$ref": "#/definitions/meridian.control_plane.v1.InternalAccountDefinition"
114+
},
115+
"additionalProperties": false,
116+
"type": "array",
117+
"description": "internal_accounts defines the chart of accounts — structural accounts owned by the economy. Each internal account references an account type, an instrument, and optionally an owner organization."
109118
}
110119
},
111120
"additionalProperties": false,
@@ -487,6 +496,41 @@
487496
"title": "Instrument Dimensions",
488497
"description": "InstrumentDimensions describes the unit and precision of an instrument's quantities."
489498
},
499+
"meridian.control_plane.v1.InternalAccountDefinition": {
500+
"required": [
501+
"code",
502+
"accountType",
503+
"instrument",
504+
"ownerOrganization",
505+
"description"
506+
],
507+
"properties": {
508+
"code": {
509+
"type": "string",
510+
"description": "code is the unique identifier for this internal account (e.g., \"REVENUE_GBP\", \"SETTLEMENT_KWH\"). Must be uppercase alphanumeric with underscores."
511+
},
512+
"accountType": {
513+
"type": "string",
514+
"description": "account_type references the account type classification for this account. Must match a code from the manifest's account_types list."
515+
},
516+
"instrument": {
517+
"type": "string",
518+
"description": "instrument references the instrument (asset type) this account holds. Must match a code from the manifest's instruments list."
519+
},
520+
"ownerOrganization": {
521+
"type": "string",
522+
"description": "owner_organization optionally references the organization that owns this account. If set, must match a code from the manifest's organizations list."
523+
},
524+
"description": {
525+
"type": "string",
526+
"description": "description provides additional context about this internal account's purpose."
527+
}
528+
},
529+
"additionalProperties": false,
530+
"type": "object",
531+
"title": "========================================\n Internal Account Definitions\n ========================================",
532+
"description": "======================================== Internal Account Definitions ======================================== InternalAccountDefinition declares a structural account in the chart of accounts. Internal accounts are owned by the economy (not by external parties) and are provisioned via the Internal Account Service. The code field is the immutable primary key."
533+
},
490534
"meridian.control_plane.v1.MTLSAuthConfig": {
491535
"required": [
492536
"clientCertSecretRef",

api/proto/meridian/control_plane/v1/manifest.proto

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,11 @@ message Manifest {
7070

7171
// organizations defines the organization entities (parties) for this tenant.
7272
repeated OrganizationDefinition organizations = 13;
73+
74+
// internal_accounts defines the chart of accounts — structural accounts
75+
// owned by the economy. Each internal account references an account type,
76+
// an instrument, and optionally an owner organization.
77+
repeated InternalAccountDefinition internal_accounts = 14;
7378
}
7479

7580
// ========================================
@@ -889,3 +894,46 @@ message OrganizationDefinition {
889894
// Keys and values are validated against the party type's attribute_schema if defined.
890895
map<string, string> attributes = 4;
891896
}
897+
898+
// ========================================
899+
// Internal Account Definitions
900+
// ========================================
901+
902+
// InternalAccountDefinition declares a structural account in the chart of accounts.
903+
// Internal accounts are owned by the economy (not by external parties) and are
904+
// provisioned via the Internal Account Service. The code field is the immutable primary key.
905+
message InternalAccountDefinition {
906+
// code is the unique identifier for this internal account (e.g., "REVENUE_GBP", "SETTLEMENT_KWH").
907+
// Must be uppercase alphanumeric with underscores.
908+
string code = 1 [(buf.validate.field).string = {
909+
min_len: 1
910+
max_len: 64
911+
pattern: "^[A-Z][A-Z0-9_]*$"
912+
}];
913+
914+
// account_type references the account type classification for this account.
915+
// Must match a code from the manifest's account_types list.
916+
string account_type = 2 [(buf.validate.field).string = {
917+
min_len: 1
918+
max_len: 50
919+
pattern: "^[A-Z0-9_]{1,50}$"
920+
}];
921+
922+
// instrument references the instrument (asset type) this account holds.
923+
// Must match a code from the manifest's instruments list.
924+
string instrument = 3 [(buf.validate.field).string = {
925+
min_len: 1
926+
max_len: 50
927+
pattern: "^[A-Z0-9_]{1,50}$"
928+
}];
929+
930+
// owner_organization optionally references the organization that owns this account.
931+
// If set, must match a code from the manifest's organizations list.
932+
string owner_organization = 4 [(buf.validate.field).string = {
933+
max_len: 64
934+
pattern: "^$|^[A-Z][A-Z0-9_]*$"
935+
}];
936+
937+
// description provides additional context about this internal account's purpose.
938+
string description = 5 [(buf.validate.field).string.max_len = 1024];
939+
}

services/control-plane/internal/differ/manifest_differ.go

Lines changed: 82 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,7 @@ func (d *ManifestDiffer) Diff(ctx context.Context, lastApplied, newManifest *con
8181
d.diffMarketDataSources(lastApplied, newManifest, plan)
8282
d.diffMarketDataSets(lastApplied, newManifest, plan)
8383
d.diffOrganizations(lastApplied, newManifest, plan)
84+
d.diffInternalAccounts(lastApplied, newManifest, plan)
8485

8586
// Run safety checks on all DELETE actions (skip when validating for a new tenant)
8687
if !cfg.skipSafetyChecks {
@@ -361,7 +362,8 @@ func (d *ManifestDiffer) runSafetyChecks(ctx context.Context, plan *DiffPlan) er
361362
ResourceInstructionRoute,
362363
ResourceMarketDataSource,
363364
ResourceMarketDataSet,
364-
ResourceOrganization:
365+
ResourceOrganization,
366+
ResourceInternalAccount:
365367
// No downstream dependency checks for these resource types.
366368
}
367369
if err != nil {
@@ -643,6 +645,50 @@ func (d *ManifestDiffer) diffOrganizations(lastApplied, newManifest *controlplan
643645
}
644646
}
645647

648+
func (d *ManifestDiffer) diffInternalAccounts(lastApplied, newManifest *controlplanev1.Manifest, plan *DiffPlan) {
649+
oldMap := internalAccountMap(getInternalAccounts(lastApplied))
650+
newMap := internalAccountMap(newManifest.GetInternalAccounts())
651+
652+
for code, updated := range newMap {
653+
prev, exists := oldMap[code]
654+
if !exists {
655+
plan.Actions = append(plan.Actions, PlannedAction{
656+
ResourceType: ResourceInternalAccount,
657+
ResourceCode: code,
658+
Action: ActionCreate,
659+
Description: fmt.Sprintf("Create internal account %s (type: %s, instrument: %s)", code, updated.GetAccountType(), updated.GetInstrument()),
660+
})
661+
continue
662+
}
663+
if !proto.Equal(prev, updated) {
664+
plan.Actions = append(plan.Actions, PlannedAction{
665+
ResourceType: ResourceInternalAccount,
666+
ResourceCode: code,
667+
Action: ActionUpdate,
668+
Description: describeInternalAccountChanges(code, prev, updated),
669+
})
670+
} else {
671+
plan.Actions = append(plan.Actions, PlannedAction{
672+
ResourceType: ResourceInternalAccount,
673+
ResourceCode: code,
674+
Action: ActionNoChange,
675+
Description: fmt.Sprintf("Internal account %s unchanged", code),
676+
})
677+
}
678+
}
679+
680+
for code := range oldMap {
681+
if _, exists := newMap[code]; !exists {
682+
plan.Actions = append(plan.Actions, PlannedAction{
683+
ResourceType: ResourceInternalAccount,
684+
ResourceCode: code,
685+
Action: ActionDelete,
686+
Description: fmt.Sprintf("Delete internal account %s", code),
687+
})
688+
}
689+
}
690+
}
691+
646692
// Helper functions to safely extract slices from possibly-nil manifests.
647693

648694
func getInstruments(m *controlplanev1.Manifest) []*controlplanev1.InstrumentDefinition {
@@ -823,6 +869,21 @@ func organizationMap(orgs []*controlplanev1.OrganizationDefinition) map[string]*
823869
return m
824870
}
825871

872+
func getInternalAccounts(m *controlplanev1.Manifest) []*controlplanev1.InternalAccountDefinition {
873+
if m == nil {
874+
return nil
875+
}
876+
return m.GetInternalAccounts()
877+
}
878+
879+
func internalAccountMap(accounts []*controlplanev1.InternalAccountDefinition) map[string]*controlplanev1.InternalAccountDefinition {
880+
m := make(map[string]*controlplanev1.InternalAccountDefinition, len(accounts))
881+
for _, a := range accounts {
882+
m[a.GetCode()] = a
883+
}
884+
return m
885+
}
886+
826887
// Change description helpers.
827888

828889
func describeInstrumentChanges(code string, prev, updated *controlplanev1.InstrumentDefinition) string {
@@ -967,6 +1028,26 @@ func describeOrganizationChanges(code string, prev, updated *controlplanev1.Orga
9671028
return fmt.Sprintf("Update organization %s (%s)", code, strings.Join(changes, "; "))
9681029
}
9691030

1031+
func describeInternalAccountChanges(code string, prev, updated *controlplanev1.InternalAccountDefinition) string {
1032+
var changes []string
1033+
if prev.GetAccountType() != updated.GetAccountType() {
1034+
changes = append(changes, fmt.Sprintf("account_type: %q -> %q", prev.GetAccountType(), updated.GetAccountType()))
1035+
}
1036+
if prev.GetInstrument() != updated.GetInstrument() {
1037+
changes = append(changes, fmt.Sprintf("instrument: %q -> %q", prev.GetInstrument(), updated.GetInstrument()))
1038+
}
1039+
if prev.GetOwnerOrganization() != updated.GetOwnerOrganization() {
1040+
changes = append(changes, fmt.Sprintf("owner_organization: %q -> %q", prev.GetOwnerOrganization(), updated.GetOwnerOrganization()))
1041+
}
1042+
if prev.GetDescription() != updated.GetDescription() {
1043+
changes = append(changes, "description changed")
1044+
}
1045+
if len(changes) == 0 {
1046+
return fmt.Sprintf("Update internal account %s", code)
1047+
}
1048+
return fmt.Sprintf("Update internal account %s (%s)", code, strings.Join(changes, "; "))
1049+
}
1050+
9701051
// attributesEqual compares two string-keyed maps for equality.
9711052
func attributesEqual(a, b map[string]string) bool {
9721053
if len(a) != len(b) {

services/control-plane/internal/differ/manifest_differ_test.go

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1222,3 +1222,78 @@ func TestDiff_OrganizationUnchanged_NoChange(t *testing.T) {
12221222
assert.Len(t, noChanges, 1)
12231223
assert.Equal(t, "ACME_ENERGY", noChanges[0].ResourceCode)
12241224
}
1225+
1226+
// --- Internal Account differ tests ---
1227+
1228+
func TestDiff_InternalAccountAdded_Create(t *testing.T) {
1229+
d := New(nil, nil)
1230+
oldManifest := testManifest()
1231+
1232+
newManifest := testManifest()
1233+
newManifest.InternalAccounts = []*controlplanev1.InternalAccountDefinition{
1234+
{Code: "REVENUE_GBP", AccountType: "REVENUE", Instrument: "GBP"},
1235+
}
1236+
1237+
plan, err := d.Diff(context.Background(), oldManifest, newManifest)
1238+
require.NoError(t, err)
1239+
1240+
creates := filterActionsByResource(plan.Actions, ActionCreate, ResourceInternalAccount)
1241+
assert.Len(t, creates, 1)
1242+
assert.Equal(t, "REVENUE_GBP", creates[0].ResourceCode)
1243+
assert.Contains(t, creates[0].Description, "REVENUE")
1244+
assert.Contains(t, creates[0].Description, "GBP")
1245+
}
1246+
1247+
func TestDiff_InternalAccountRemoved_Delete(t *testing.T) {
1248+
d := New(nil, nil)
1249+
oldManifest := testManifest()
1250+
oldManifest.InternalAccounts = []*controlplanev1.InternalAccountDefinition{
1251+
{Code: "SETTLEMENT_KWH", AccountType: "SETTLEMENT", Instrument: "KWH"},
1252+
}
1253+
1254+
newManifest := testManifest()
1255+
1256+
plan, err := d.Diff(context.Background(), oldManifest, newManifest)
1257+
require.NoError(t, err)
1258+
1259+
deletes := filterActionsByResource(plan.Actions, ActionDelete, ResourceInternalAccount)
1260+
assert.Len(t, deletes, 1)
1261+
assert.Equal(t, "SETTLEMENT_KWH", deletes[0].ResourceCode)
1262+
}
1263+
1264+
func TestDiff_InternalAccountModified_Update(t *testing.T) {
1265+
d := New(nil, nil)
1266+
oldManifest := testManifest()
1267+
oldManifest.InternalAccounts = []*controlplanev1.InternalAccountDefinition{
1268+
{Code: "REVENUE_GBP", AccountType: "REVENUE", Instrument: "GBP"},
1269+
}
1270+
1271+
newManifest := testManifest()
1272+
newManifest.InternalAccounts = []*controlplanev1.InternalAccountDefinition{
1273+
{Code: "REVENUE_GBP", AccountType: "CURRENT", Instrument: "GBP", OwnerOrganization: "ACME"},
1274+
}
1275+
1276+
plan, err := d.Diff(context.Background(), oldManifest, newManifest)
1277+
require.NoError(t, err)
1278+
1279+
updates := filterActionsByResource(plan.Actions, ActionUpdate, ResourceInternalAccount)
1280+
assert.Len(t, updates, 1)
1281+
assert.Equal(t, "REVENUE_GBP", updates[0].ResourceCode)
1282+
assert.Contains(t, updates[0].Description, "account_type:")
1283+
assert.Contains(t, updates[0].Description, "owner_organization:")
1284+
}
1285+
1286+
func TestDiff_InternalAccountUnchanged_NoChange(t *testing.T) {
1287+
d := New(nil, nil)
1288+
manifest := testManifest()
1289+
manifest.InternalAccounts = []*controlplanev1.InternalAccountDefinition{
1290+
{Code: "REVENUE_GBP", AccountType: "REVENUE", Instrument: "GBP"},
1291+
}
1292+
1293+
plan, err := d.Diff(context.Background(), manifest, manifest)
1294+
require.NoError(t, err)
1295+
1296+
noChanges := filterActionsByResource(plan.Actions, ActionNoChange, ResourceInternalAccount)
1297+
assert.Len(t, noChanges, 1)
1298+
assert.Equal(t, "REVENUE_GBP", noChanges[0].ResourceCode)
1299+
}

services/control-plane/internal/differ/types.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@ const (
3939
ResourceMarketDataSource ResourceType = "market_data_source"
4040
ResourceMarketDataSet ResourceType = "market_data_set"
4141
ResourceOrganization ResourceType = "organization"
42+
ResourceInternalAccount ResourceType = "internal_account"
4243
)
4344

4445
// PlannedAction represents a single action in the diff plan.

services/control-plane/internal/planner/manifest_planner.go

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -121,6 +121,8 @@ func phaseForResource(rt differ.ResourceType) Phase {
121121
return PhaseMarketDataSets
122122
case differ.ResourceOrganization:
123123
return PhaseOrganizations
124+
case differ.ResourceInternalAccount:
125+
return PhaseInternalAccounts
124126
default:
125127
return PhaseSeedData
126128
}
@@ -208,6 +210,11 @@ var grpcMethodMap = map[methodKey]GRPCMethod{
208210
{differ.ResourceOrganization, differ.ActionCreate}: MethodRegisterOrganization,
209211
{differ.ResourceOrganization, differ.ActionUpdate}: MethodRegisterOrganization,
210212
// No delete method: organizations are deactivated, not deleted.
213+
214+
// Internal Accounts
215+
{differ.ResourceInternalAccount, differ.ActionCreate}: MethodInitiateAccount,
216+
{differ.ResourceInternalAccount, differ.ActionUpdate}: MethodUpdateInternalAccount,
217+
{differ.ResourceInternalAccount, differ.ActionDelete}: MethodControlInternalAccount,
211218
}
212219

213220
// GenerateIdempotencyKey produces a deterministic SHA-256 based idempotency key.

0 commit comments

Comments
 (0)