feat!: 🎸 rework the identity/key and multisig model (redesign 6/10) - #353
Open
prashantasdeveloper wants to merge 12 commits into
Open
prashantasdeveloper wants to merge 12 commits into
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.
prashantasdeveloper
force-pushed
the
redesign/06-identity-keys
branch
from
September 10, 2026 14:02
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.
|
prashantasdeveloper
marked this pull request as ready for review
September 11, 2026 12:30
prashantasdeveloper
added this pull request to stack #358
September 15, 2026 08:23
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.



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.