feat!: 🎸 rework the identity/key and multisig model (redesign 6/10) - #353
prashantasdeveloper wants to merge 12 commits into
Conversation
`identity.keyRecords` was cast to a bare `Codec` and read as JSON, which hid that `KeyRecord` has three variants. `MultiSigSignerKey` names the multisig account, not a DID, so the JSON read created an `Identity` keyed by an SS58 address, an `Account` pointing at it, and a portfolio 0 for it. A signer key has no identity and no permissions, so it now resolves to no `Account` at all; `ledgerAccount` still creates a bare one if the address holds POLYX. The same cast is gone from the two `system.account` reads and the reconciliation script, narrowed to the balance fields - the only part that must stay spec-agnostic, since `frozen` is `miscFrozen`/`feeFrozen` on older runtimes. The script loads the chain-type augmentation itself now: only `tsconfig.test.json` includes `scripts/**/*`, so an editor was type-checking it against a different, unaugmented type than CI. The event-data casts are load-bearing and stay: two physical copies of `@polkadot/types-codec`, which `subql build` resolves differently from `tsc`. Separately, the per-block account cache held the resolved `Account`, so a hit skipped the store read as well as the chain read. The chain read cannot change within a block - `api` serves the block's end-of-block state - but the row can, when a handler links or unlinks a key. A cached negative therefore shadowed a row another handler had just written, and `ledgerAccount` went on to overwrite it. The cache now holds only the key record; `Account` is read from the store on every lookup.
Models key membership as an explicit interval record instead of a mutable
pointer plus an untyped log (defect G3).
`IdentityKey` holds one row per `(identity, account, role)` membership
interval: a key joining opens a row (`validFromBlock` set, `validToBlock`
null), a key leaving or being rotated out closes it (`validToBlock`,
`removedReason`), and a permissions change or primary-key rotation closes the
current row and opens a fresh one — so key rotation and permission changes
become a listable, countable history. `Boolean` cannot be indexed on the
Postgres in use, so "currently active" is `validToBlockId: { isNull: true }`;
composite indexes cover `[identity, role]` and `[account, validToBlock]`.
Handlers touched, all through the decode layer already in place:
`handleDidCreated`, `handleSecondaryKeysAdded`,
`handleSecondaryKeysPermissionsUpdated`, `handleSecondaryKeysRemoved`,
`handleSignerLeft`, `handleSecondaryKeyLeftIdentity`,
`handlePrimaryKeyUpdated`. `genesisHandler` and the lazy `getOrCreateAccount`
path open the same rows so a resynced index has a full history regardless of
how a key was first seen.
The pre-5.0 `identity` payload unwrapping (`Vec<Signatory<AccountId>>` →
`Vec<AccountId>` for `SecondaryKeysRemoved`, `SecondaryKey<AccountId>` →
`AccountId` for `SecondaryKeyPermissionsUpdated`, `signer` → `key` in
`SecondaryKeysAdded`) moves out of inline `instanceof Map` / `'key' in rest`
sniffs and into `src/decode/legacy.ts`, with the spec boundary named. Arity is
unchanged across every version, so the shape registry has nothing to say about
it; the logic is unchanged, only its location.
The existing `Permissions` / `AccountHistory` writes are left untouched here
and removed later in this series.
BREAKING CHANGE: `IdentityKey` is added alongside the current
`Identity.primaryAccount` / `Identity.secondaryAccounts` / `AccountHistory`
model. That model is removed in a later commit of this series; consumers
should migrate reads of key membership to `IdentityKey`.
`Identity.secondaryAccounts` derived from `Account.identity`, and
`handleDidCreated` sets `identityId` on the primary key's account too, so the
list returned the primary key alongside the secondaries (defect G1). `Account`
carries no role discriminator, so a consumer could not filter the primary out.
`@derivedFrom` takes no filter, so the field cannot be corrected in place. It
is removed. Current secondary keys are now:
identity.keys(filter: {
role: { equalTo: Secondary },
validToBlockId: { isNull: true }
})
against the `IdentityKey` history added in the previous commit — role is
explicit, so the primary can never appear.
Verified safe to break: the SDK reads secondary keys from chain state
(`polymeshApi.query.identity`) directly, not from this middleware field.
The `tests/entities` query and snapshot are updated to the new form; the
snapshot regenerates on the next full resync.
BREAKING CHANGE: `Identity.secondaryAccounts` is removed. Replace reads with
`Identity.keys(filter: { role: { equalTo: Secondary }, validToBlockId: { isNull: true } })`,
which returns `IdentityKey` rows (each with an `account` relation) rather than
`Account` rows directly.
A multisig is an account — it holds POLYX, holds assets, and signs — but `MultiSig.address` was a bare string with no relation to `Account`, so a multisig's balance and its multisig-ness could not be traversed in one query (defect G5). - `MultiSig.address: String!` → `account: Account!`. `createMultiSig` creates the `Account` row first (via `ledgerAccount`) and links it. The multisig address stays the `MultiSig` id and the `Account` id. - `Account.multiSig: MultiSig` derived relation — reachable from either side. - `MultiSigAdmin.identityId: String!` → `admin: Identity!`, matching `MultiSig.creator` which was already a relation. `ledgerAccount` — get-or-create-a-bare-`Account` — moves from `mapPolyxLedger.ts` to `src/utils/accounts.ts`, next to `getOrCreateAccount`. It is a general account helper, not POLYX-specific, and the multisig handlers and (next commit) the signer work need it too. The multisig event handlers keep their existing version-aware positional decoding for now; moving them behind `decodeEvent` needs a `src/decode/shapes/multiSig.ts` shape table (plan 04 defers this) and is a separate change.
`AccountHistory` had untyped `String` columns and no validity interval; it is superseded entirely by the `IdentityKey` history. `Permissions` (the entity) and `PermissionsJson` (the jsonField) carried the same four fields — the duplicate shape of defect G4 — so `Permissions` collapses into `IdentityKey.permissions: PermissionsJson`, the field already added in the first commit of this series. Removed from `schema.graphql`: `type AccountHistory`, `type Permissions`, `Account.permissions`. `yarn codegen` drops the generated models for both. Handlers rewritten to stop writing either: - `handleDidCreated`, `handleSecondaryKeysAdded`, `getOrCreateAccount`, `genesisHandler` — no longer create a `Permissions` row or set `Account.permissionsId`; the granted permissions ride on the `IdentityKey` row instead (full permission for a primary key, so none is stored). - `handleSecondaryKeysPermissionsUpdated` — no longer mutates a `Permissions` row; `rotateIdentityKey` already records the change as a closed-and-reopened interval. - `handlePrimaryKeyUpdated`, `handleSecondaryKeyLeftIdentity`, `handleSecondaryKeysRemoved`, `handleSignerLeft` — drop the `AccountHistory` entry and the `Permissions.remove`; `closeIdentityKeys` is the record now. BREAKING CHANGE: `AccountHistory` and `Permissions` entities are removed. Read key-membership history and per-interval permissions from `IdentityKey` (`permissions` field) instead. `Account.permissions` is gone; an account's current permissions are the active `IdentityKey` row's `permissions`.
`identity.AuthorizationRetryLimitReached` was registered `[]`. It carries `(Option<IdentityId>, Option<AccountId>, u64 authId)` — the same shape as `AuthorizationRevoked` / `Rejected` / `Consumed` — and fires when the chain gives up re-offering an authorization after too many failed accept attempts, dropping it from storage. It now rides `handleAuthorization` and marks the `Authorization` row `RetryLimitReached`, a new terminal `AuthorizationStatusEnum` value distinct from `Rejected` (which is the target actively declining). The status handler now tolerates a missing row — a terminal event for an authorization created before the index start has nothing to update. CddClaimsInvalidated: left `[]` deliberately. It is a CDD-claims concern — it invalidates the CDD claims issued by a provider whose own CDD was revoked, as of a moment — not a key-management one, and it is absent from the v8 runtime (`EventIdEnum` already marks it deprecated from 8.0.0). Both plan 01 and plan 04 flag it `[I]`; it belongs with plan 01 (claims), which is a separate phase, not forced into identity-and-keys here. `project.ts` carries a comment saying so.
…keyRole
Two gaps beyond G1/G3/G5 (tracked as G16):
- `MultiSigSigner.signerValue: String!` was unindexed and could not be joined
to `Account`, even though a signer key is an account. "Which multisigs does
this account sign for?" was a sequential scan.
- `Account` recorded `keyType` (the cryptographic shape) but nothing about the
key's *role* in the identity system. A bare `Account` with no identity was
ambiguous — `ledgerAccount` builds the identical shape for the treasury pot,
the block-reward pot, and a multisig signer key — even though the chain's
`KeyRecord` distinguishes them and the indexer already reads that via
`resolveKeyIdentity` before discarding it.
Schema:
- `enum KeyRoleEnum { PrimaryKey SecondaryKey MultiSigSigner Unlinked }`,
treated as open (nothing marks it exhaustive — `Unlinked` may be split).
- `Account.keyRole: KeyRoleEnum!` indexed. Derived in one place — `keyRoleFor`
/ `resolveKeyRole` in `src/utils/accounts.ts`, off the same `KeyRecord` read
`resolveKeyIdentity` already does. It is mutable: `PrimaryKeyUpdated` and
`SecondaryKeyLeftIdentity` set it to `Unlinked` on the same write that nulls
`identityId`; identity/multisig handlers and the genesis seed scan set it
authoritatively from `identity.keyRecords` / `multiSig.multiSigSigners`.
- `MultiSigSigner.signerAccount: Account` — NULLABLE. `signerValue` is kept as
the canonical value: `SignerTypeEnum` is `Account | Identity`, pre-7.x
runtimes allowed identity signers, a relation cannot point at two entity
types, and the index replays from genesis. `signerAccount` is populated only
when `signerType` is `Account`, written in the multisig event handlers (not
in `getOrCreateAccount` — the event carries the real block and status).
Once both fields exist, `Account (signer) → MultiSigSigner → MultiSig →
Account (multisig) → Identity` is one joinable path, and the multisig's own
account resolves to `PrimaryKey(did)` / `SecondaryKey(did)` like any other key
— multisig-ness stays the `MultiSig` row keyed by the same address, never a
`keyRole` value.
`ledgerAccount` now derives `keyRole` on its bare-row path too (`Unlinked` for
a pallet/pot, `MultiSigSigner` when the key record names a multisig).
`genesisHandler.ts` (`handleMultiSigs`) fills `MultiSig.creator` from `multiSig.adminDid` storage — the identity currently *administering* the multisig — for every row that existed at genesis. `mapMultiSig.ts` (`handleMultiSigCreated`) fills the same field from the `MultiSigCreated` event's own DID param — the identity that *created* it, at creation time — for everything indexed afterward. These are two different facts, both real, sharing one field, and they can disagree. This does not change either code path — picking a side is a team decision. It adds a docstring on `MultiSig.creator` in `schema.graphql` stating the ambiguity plainly, and records the open question (should `creator` / `admin` / joined-identity become three explicit fields, now that the joined identity is derivable via `Account.identity` on the multisig's own account?) for the PR. Also brings the design docs and the `tests/entities` queries up to what this phase shipped: - `docs/implementation/04-identity-keys.md` — "Target schema" reflects the as-shipped entities (`Account.keyRole`, `MultiSigSigner.signerAccount`, `primaryAccount` kept as a string — G2 not addressed, `secondaryAccounts` removed not corrected); adds the G16 "Problem" entry and the `MultiSig.creator` finding; updates "Handler changes", "project.ts", "Tests" and "Consumer impact". - `docs/reference/identity-asset-model.md` — new `### G16`, cross-referenced from G5. - `docs/reference/consumer-queries.md` — `Permissions` removed from the relations-only group; `secondaryAccounts` line corrected to "removed"; `MultiSigSigner.signerAccount` re-checked as still unqueried. - `tests/entities/identities.test.ts` — drop `permissionsId` / `permissions` (removed) and the `Permissions`-entity query; query `keyRole`, `keyAssignments`, and `identityKeys` instead. Snapshots regenerate on the next full resync.
Resolving the open question from the previous commit by checking the chain: a multisig has four distinct identity relationships, and `MultiSig.creator` silently meant two of them. - **creator** — dispatched `create_multisig`. Only in the `MultiSigCreated` event's `callerDid`; the chain keeps no creator storage. - **admin** — `multiSig.adminDid: Option<IdentityId>`, "the primary key of this identity has admin control". Mutable (`add_admin` / `remove_admin`). - **paying** — `multiSig.payingDid: Option<IdentityId>`, pays proposal fees. Mutable. (Still unindexed — noted as a gap.) - **joined** — the multisig account attached to an identity as a key (`identity.keyRecords` → `SecondaryKey(did)`), reachable via `Account.identity` on the multisig's own account. Pre-7.x `create_multisig` set `MultiSigToIdentity` (renamed `adminDid` at 7.0) to the caller, so creator and admin coincided — the source of the conflation. Changes: - `MultiSig.creator` / `creatorAccount` → **nullable**, populated only from `MultiSigCreated`. Null for genesis-seeded rows (creator is unrecoverable when the creation was not observed). - `genesisHandler.handleMultiSigs` stops writing `creator` from `adminDid`; it now always seeds a `MultiSigAdmin` row from `adminDid` / `multiSigToIdentity` (it previously skipped that on the pre-7 path — a floating-promise bug too). The admin relationship is `MultiSig.admins` (`MultiSigAdmin` rows, status-tracked); the joined identity is `account.identity`.
`Event` carried seven pre-extracted string columns — `claimType`, `claimScope`,
`claimIssuer`, `claimExpiry`, `corporateActionTicker`, `fundraiserOfferingAsset`,
`transferTo` — indexed in `db/compat.sql` (the schema's 10-index cap left no
room for `@index`). They are a harvester-era carry-over ("Kept here, as
`master` had them"): null or wrong on the large majority of events, unqueried
by the SDK or portal per `consumer-queries.md`, and duplicating facts the
`Claim` (`type` / `scope` / `filterExpiry` / `issuerId`), corporate-action and
STO entities already hold.
Removed: the seven fields from `schema.graphql`, the writes in `mapEvent.ts`,
the now-dead `extractCorporateActionTicker` / `extractOfferingAsset` /
`extractTransferTo` helpers and their tests, and the five `compat.sql` indexes.
`extractClaimInfo` stays — `mapClaim.ts` uses it to build `Claim` rows.
Under D5 (fresh resync) `compat.sql` runs against a schema without these
columns, so keeping the `CREATE INDEX` lines would error regardless.
Frees the `Event` index budget and removes a per-event serialise-and-extract
cost that fed nothing.
The three open questions from this series are answered against the chain (`multiSig` pallet storage and `KeyRecord`, chain 8.0.1): - `MultiSigSigner.signerAccount` is nullable because it is forced to be — pre-7.x `Signatory` signers could be an identity, which a relation cannot point at, and the index replays from genesis. - `KeyRoleEnum.Unlinked` stays one value — `KeyRecord` is a closed three-variant enum and everything else is `None`; the chain draws no line between a pallet address, a system pot and a detached key. A future split would be an indexer-side heuristic, not chain data. - `MultiSig.creator` splitting is done in the previous commit — the chain models creator / admin / paying / joined-identity as four separate relationships. `docs/implementation/04-identity-keys.md` gets the resolution table.
e7b30ef to
ecffbba
Compare
`identityKeyId` built the row id with a nested template literal
(`padId(`${eventIdx}`)` inside the outer literal) — typescript:S4624. Lift the
padded value to a local and interpolate that.
|
| "The chain gave up re-offering the authorization after too many failed accept attempts" | ||
| RetryLimitReached |
There was a problem hiding this comment.
the AuthorizationRetryLimitReached event is declared but never emitted. Harmless having it here. We can infer the status if the count on an authorization goes to 0 but the count isn't emitted in an event so I expect they will just sit at Pending.
| updatedBlockId: blockId, | ||
| }).save(); | ||
|
|
||
| export const createAccount = async ( |
There was a problem hiding this comment.
This will override and existing account which will clobber the original createdBlock. Should we consider an updateOrCreate option and should this be in the account util file?
| await Promise.all(removePromises.flat()); | ||
| await Promise.all( | ||
| addresses.flatMap(address => [ | ||
| Account.remove(address), |
There was a problem hiding this comment.
Account.remove(address) deletes the row that this PR's new IdentityKey history points at. closeIdentityKeys sets validToBlock/removedReason on the key's open interval (accountId = address) while Account.remove runs concurrently in the same Promise.all, so the closed interval references a row that no longer exists — and IdentityKey.account is a non-null relation. account { address } on a historical interval resolves to nothing, Account.keyAssignments becomes unreachable, and any PolyxEntry.account! / AccountBalance.account! rows point at the same deleted account.
handleSignerLeft (line 333) does the same thing. handleSecondaryKeyLeftIdentity (line 465) models the identical transition the other way — keep the row, clear identityId, set keyRole = Unlinked — which looks like the behaviour we want in all three now that leaving is recorded on IdentityKey rather than by deleting the account.
Worth adding: this path is hit during every primary-key rotation, not just a plain removal. On 24971813-1 and 21528057-1 the chain emits SecondaryKeysRemoved(did, [new_primary]) immediately before PrimaryKeyUpdated, so Account.remove here deletes the account that is about to become the identity's primary key. It survives only because handlePrimaryKeyUpdated recreates it two events later — and that recreate resets its createdBlock (see line 49). Keeping the row and setting keyRole = Unlinked would make both the removal and the rotation case behave.
| Account.get(identity.primaryAccount), | ||
| Permissions.get(identity.primaryAccount), | ||
| ]); | ||
| const account = await Account.get(identity.primaryAccount); |
There was a problem hiding this comment.
Account.get returns Account | undefined and the result is used unguarded on the lines below. createIdentityIfNotExists writes primaryAccount: '' (line 87), so an identity created down that path that later emits PrimaryKeyUpdated gives Account.get('') → undefined → TypeError, killing the block rather than skipping the update.
Same unguarded read at line 465 in handleSecondaryKeyLeftIdentity. Narrow path in both cases, but a guard plus recordAnomaly beats a crash.
|
|
||
| // ...and open the new primary's. The rotation record G3 asks for: both rows stay queryable, the | ||
| // old row's `validToBlock` equals the new one's `validFromBlock`. | ||
| await openIdentityKey( |
There was a problem hiding this comment.
Withdrawing my earlier concern here — I checked two real rotations on mainnet and the Secondary interval is closed, just not by this handler.
identity.acceptPrimaryKey (24971813-1) and identity.rotatePrimaryKeyToSecondary (21528057-1) both promote an existing secondary key, and the chain emits the transition as a fixed sequence:
SecondaryKeysRemoved(did, [new_primary]) ← closes the incoming key's Secondary interval
PrimaryKeyUpdated(did, old, new) ← this handler
SecondaryKeysAdded(did, [old_primary]) ← rotatePrimaryKeyToSecondary only
Handlers run in event order, so handleSecondaryKeysRemoved closes the Secondary interval before we get here and handleSecondaryKeysAdded re-links the demoted old primary after. No double-open, and the four-step rotation comes out right end to end.
Two things that follow, though:
-
This handler is correct only because of its siblings, and nothing says so. Read on its own it looks like the incoming key's Secondary interval leaks — that's what I assumed. Since this PR introduces the
IdentityKeymodel, the three-event sequence is worth a docstring onhandlePrimaryKeyUpdated, so the next reader doesn't "fix" the non-bug or reorder something that breaks it. -
The coupling is load-bearing in a fragile way. At the first event,
handleSecondaryKeysRemovedcallsAccount.remove(new_primary)— it deletes the account that is about to become the identity's primary key. It survives only becausecreateAccountrecreates it two events later, which also resets itscreatedBlockon every rotation (see line 49). That's a second argument for thekeyRole = Unlinkedtreatment overAccount.remove(line 320).
| const signerParams: MultiSigSignerProps[] = await Promise.all( | ||
| signers.map( | ||
| async ({ signerType, signerValue }) => | ||
| ({ | ||
| id: `${multiSigAddress}/${signerType}/${signerValue}`, | ||
| multisigId: multiSigAddress, | ||
| signerType, | ||
| signerValue, | ||
| signerAccountId: await linkSignerAccount( | ||
| signerType, | ||
| signerValue, | ||
| blockId, | ||
| block.timestamp | ||
| ), | ||
| status: MultiSigSignerStatusEnum.Authorized, | ||
| createdBlockId: blockId, | ||
| updatedBlockId: blockId, | ||
| } satisfies MultiSigSignerProps) | ||
| ) | ||
| ); |
There was a problem hiding this comment.
The signers haven't accepted yet so should'nt be linked fully. Particularly if they can't accept (i.e. joined to another identity)
| Not a closed set — treat it as open. `MultiSigSigner` and detached-key roles are modelled on | ||
| `Account.keyRole` (a separate enum) rather than here, and further membership roles may be added. | ||
| """ | ||
| enum KeyRole { |
There was a problem hiding this comment.
KeyRole and KeyRoleEnum are distinct enough to keep as two types — MultiSigSigner and Unlinked can't be values here (an "Unlinked membership interval" is a contradiction, and a signer key has no membership to hold a role in), so merging them would make invalid states representable. But the pair as named is confusing:
Enumcarries no information. Every legacy enum in this schema has the suffix (EventIdEnum,ModuleIdEnum,SignerTypeEnum), soKeyRolevsKeyRoleEnumis distinguished only by a token that means nothing. Neither name says which is scoped to a membership interval and which is the account's current state.- The value spellings diverge for the same concept —
PrimaryKey/SecondaryKeyvsPrimary/Secondary. GraphQL validation catches a mix-up so it isn't silent, but it's a papercut on every query and part of whykeyRoleForexists as a mapping.
Recommendation: rename both and align the spellings, so the subset relationship is visible on sight.
"The key's current role per the chain's KeyRecord. Superset of IdentityKeyRole."
enum AccountKeyRole {
PrimaryKey
SecondaryKey
MultiSigSigner
Unlinked
}
"The role a key holds within one IdentityKey membership interval."
enum IdentityKeyRole {
PrimaryKey
SecondaryKey
}with Account.keyRole: AccountKeyRole! and IdentityKey.role: IdentityKeyRole!.
Left as prose rather than a suggestion block because the change spans both enum declarations plus both field references — a partial apply would leave the schema invalid.
Cheapest to do now: both enums are new in this PR, so nothing downstream depends on the names yet. I checked redesign/11-holdings-review and both are still exactly as written here.
| "`substrate` for a regular key, `ethereum` for the `0xEE` padded account of an Ethereum key" | ||
| keyType: String! | ||
| "The key's role in the identity system, from the chain's `KeyRecord` — mutable, maintained on every key-link/unlink write" | ||
| keyRole: KeyRoleEnum! @index(unique: false) |
There was a problem hiding this comment.
Account.keyRole is the fourth representation of key membership in the schema, alongside IdentityKey (authoritative), Account.identity, and Identity.primaryAccount. Three of the four are hand-maintained caches of the first, and this review found a defect in each: keyRole goes stale for multisig signers and is never reset, Account.identity is cleared and then restored across two events during a rotation, and primaryAccount holds '' as a sentinel that crashes handlePrimaryKeyUpdated.
G2 defers primaryAccount deliberately (04-identity-keys.md:16), which is fine. The gap is that the schema declares only IdentityKey to be the source of truth and says nothing about the standing of the other three.
Recommendation: a one-line docstring on each cache naming its derivation — here, something like "Derived from the chain's KeyRecord; the authoritative membership history is keyAssignments." That makes the staleness class findable by reading the schema, and sets up the G2 follow-up.
| Replaces `Identity.primaryAccount`/`secondaryAccounts` as the source of truth for key membership, | ||
| and subsumes `AccountHistory`, which had untyped columns and no validity interval. | ||
| """ | ||
| type IdentityKey @entity |
There was a problem hiding this comment.
Two consistency nits on the new entity:
- The id format isn't documented.
PolyxEntrydocuments its own (padId(block)/padId(eventIdx)/side); this one isidentity/address/blockId/paddedEventIdx. Consumers that page or construct ids need it. - No
datetime, where nearly every sibling entity carries one. Reachable viavalidFromBlock, so it's defensible — just inconsistent.
Recommendation: add the id format to the docstring; leave datetime off and rely on the validFromBlock join unless a query in consumer-queries.md actually needs date filtering without a block join.
| @@ -2073,20 +2071,6 @@ type PermissionsJson @jsonField { | |||
| transactionGroups: [String]! | |||
There was a problem hiding this comment.
transactionGroups was a column on the Permissions entity and is now inside a jsonField, which SubQuery doesn't generate filters for. The rest of Permissions was already jsonFields, so this is the only field whose queryability changes.
Recommendation: confirm nothing filtered on it — the collapse is right either way — and if something did, note the replacement in consumer-queries.md alongside the other breaking changes in this PR.



Identity & keys
Implements
docs/implementation/04-identity-keys.md. Models key membership as a time-boundedrecord, links the multisig cluster to
Account, adds a key-role field, and drops dead columns.Based on
redesign/05-holdings-nfts. Parallel track with phases 5 and 8; needs a rebase before merge.Schema
Added
IdentityKey— one row per(identity, account, role)membership interval.validFromBlock/validToBlock(null = active),addedReason/removedReason,permissions: PermissionsJson.Composite indexes
[identity, role]and[account, validToBlock].enum KeyRole { Primary Secondary }.Identity.keys: [IdentityKey!]! @derivedFrom,Account.keyAssignments: [IdentityKey!]! @derivedFrom.Account.keyRole: KeyRoleEnum!(PrimaryKey/SecondaryKey/MultiSigSigner/Unlinked), indexed.MultiSigSigner.signerAccount: Account(nullable), indexed.Account.multiSig: MultiSig @derivedFrom.AuthorizationStatusEnum.RetryLimitReached.Changed
MultiSig.address: String!→account: Account!.MultiSigAdmin.identityId: String!→admin: Identity!.MultiSig.creator/creatorAccount→ nullable; populated only fromMultiSigCreated.Removed
Identity.secondaryAccounts(derived field; wrong semantics,@derivedFromcannot filter).AccountHistoryentity.Permissionsentity andAccount.permissions.Event.claimType/claimScope/claimIssuer/claimExpiry/corporateActionTicker/fundraiserOfferingAsset/transferTo, plus theirdb/compat.sqlindexes.Behaviour
handleDidCreated,handleSecondaryKeysAdded,handleSecondaryKeysPermissionsUpdated,handleSecondaryKeysRemoved,handleSignerLeft,handleSecondaryKeyLeftIdentity,handlePrimaryKeyUpdated),genesisHandler, and the lazygetOrCreateAccountpath open/close/rotateIdentityKeyrows and setkeyRole.identitypayload unwrapping moved from inline handler duck-typing tosrc/decode/legacy.ts.Account, linkMultiSig.account, populateMultiSigSigner.signerAccount(Account signers only), and setkeyRole: MultiSigSigneronsigner accounts.
genesisHandler.handleMultiSigsseedsMultiSigAdminfrommultiSig.adminDidon all chainversions; leaves
MultiSig.creatornull.AuthorizationRetryLimitReachedmarks theAuthorizationrowRetryLimitReachedviahandleAuthorization; the status handler tolerates a missing row.ledgerAccountmoved frommapPolyxLedger.tstosrc/utils/accounts.ts.Decisions
MultiSigSigner.signerAccountis nullable.SignerTypeEnumisAccount | Identityandpre-7.x runtimes allowed identity signers; a relation cannot point at those.
signerValuestays the canonical value.
KeyRoleEnum.Unlinkedis one value. The chain'sKeyRecordis a closed three-variant enumand every other address is
None; pallet addresses, system pots and detached keys areindistinguishable from chain state. The enum is left open for a later indexer-side split.
MultiSig.creatorsplit from admin. The chain models creator (MultiSigCreated.callerDid),admin (
multiSig.adminDid), paying identity (multiSig.payingDid) and joined identity(
identity.keyRecords) as four separate relationships.creatoris now creator-only andnullable; admin is
MultiSig.admins; joined identity isAccount.identity.CddClaimsInvalidatedleft unhandled. It invalidates CDD claims, not keys, and is absentfrom the v8 runtime — belongs with plan 01 (claims).
Eventdenormalised columns dropped, not consolidated (per09-infrastructure.md §9.4).Harvester carry-over, unqueried by either consumer, duplicated by
Claim/ corporate-action /STO entities.
Not in this PR
Identity.primaryAccountstays a string (usekeys(filter: { role: Primary })).decodeEventneedssrc/decode/shapes/multiSig.ts.multiSig.payingDidis unindexed (MultiSigRemovedPayingDid: [], no "set" event).Consumer impact
AccountHistory,Permissions,MultiSig,MultiSigAdmin,MultiSigSigner,ChildIdentityarenot directly queried by the SDK or portal (
consumer-queries.md).Identity.secondaryAccountsisread from chain by the SDK, not middleware. The removed
Eventcolumns are neither selected norfiltered by either consumer.
authorizationsis queried; a new enum value is additive.Migrations
None —
db/migrations/*is not used (D5, full resync from genesis).tests/entities/*snapshotsregenerate on that resync.
Verification
yarn codegen && yarn typecheck && yarn lint && yarn test:unit— pass (42 suites, 482 tests).yarn check-handlers— pass.