Skip to content

feat!: 🎸 rework the identity/key and multisig model (redesign 6/10) - #353

Open
prashantasdeveloper wants to merge 12 commits into
redesign/05-holdings-nftsfrom
redesign/06-identity-keys
Open

prashantasdeveloper wants to merge 12 commits into
redesign/05-holdings-nftsfrom
redesign/06-identity-keys

Conversation

@prashantasdeveloper

Copy link
Copy Markdown
Contributor

Identity & keys

Implements docs/implementation/04-identity-keys.md. Models key membership as a time-bounded
record, 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 from MultiSigCreated.

Removed

  • Identity.secondaryAccounts (derived field; wrong semantics, @derivedFrom cannot filter).
  • AccountHistory entity.
  • Permissions entity and Account.permissions.
  • Event.claimType / claimScope / claimIssuer / claimExpiry / corporateActionTicker /
    fundraiserOfferingAsset / transferTo, plus their db/compat.sql indexes.

Behaviour

  • Identity handlers (handleDidCreated, handleSecondaryKeysAdded,
    handleSecondaryKeysPermissionsUpdated, handleSecondaryKeysRemoved, handleSignerLeft,
    handleSecondaryKeyLeftIdentity, handlePrimaryKeyUpdated), genesisHandler, and the lazy
    getOrCreateAccount path open/close/rotate IdentityKey rows and set keyRole.
  • Pre-5.0 identity payload unwrapping moved from inline handler duck-typing to src/decode/legacy.ts.
  • Multisig handlers create the multisig's Account, link MultiSig.account, populate
    MultiSigSigner.signerAccount (Account signers only), and set keyRole: MultiSigSigner on
    signer accounts.
  • genesisHandler.handleMultiSigs seeds MultiSigAdmin from multiSig.adminDid on all chain
    versions; leaves MultiSig.creator null.
  • AuthorizationRetryLimitReached marks the Authorization row RetryLimitReached via
    handleAuthorization; the status handler tolerates a missing row.
  • ledgerAccount moved from mapPolyxLedger.ts to src/utils/accounts.ts.

Decisions

  • MultiSigSigner.signerAccount is nullable. SignerTypeEnum is Account | Identity and
    pre-7.x runtimes allowed identity signers; a relation cannot point at those. signerValue
    stays the canonical value.
  • KeyRoleEnum.Unlinked is one value. The chain's KeyRecord is a closed three-variant enum
    and every other address is None; pallet addresses, system pots and detached keys are
    indistinguishable from chain state. The enum is left open for a later indexer-side split.
  • MultiSig.creator split from admin. The chain models creator (MultiSigCreated.callerDid),
    admin (multiSig.adminDid), paying identity (multiSig.payingDid) and joined identity
    (identity.keyRecords) as four separate relationships. creator is now creator-only and
    nullable; admin is MultiSig.admins; joined identity is Account.identity.
  • CddClaimsInvalidated left unhandled. It invalidates CDD claims, not keys, and is absent
    from the v8 runtime — belongs with plan 01 (claims).
  • Event denormalised columns dropped, not consolidated (per 09-infrastructure.md §9.4).
    Harvester carry-over, unqueried by either consumer, duplicated by Claim / corporate-action /
    STO entities.

Not in this PR

  • G2 — Identity.primaryAccount stays a string (use keys(filter: { role: Primary })).
  • Multisig handlers still use positional decoding — moving to decodeEvent needs
    src/decode/shapes/multiSig.ts.
  • multiSig.payingDid is unindexed (MultiSigRemovedPayingDid: [], no "set" event).

Consumer impact

AccountHistory, Permissions, MultiSig, MultiSigAdmin, MultiSigSigner, ChildIdentity are
not directly queried by the SDK or portal (consumer-queries.md). Identity.secondaryAccounts is
read from chain by the SDK, not middleware. The removed Event columns are neither selected nor
filtered by either consumer. authorizations is queried; a new enum value is additive.

Migrations

None — db/migrations/* is not used (D5, full resync from genesis). tests/entities/* snapshots
regenerate on that resync.

Verification

yarn codegen && yarn typecheck && yarn lint && yarn test:unit — pass (42 suites, 482 tests).
yarn check-handlers — pass.

@prashantasdeveloper prashantasdeveloper changed the title feat!: 🎸 rework the identity/key and multisig model feat!: 🎸 rework the identity/key and multisig model (redesign 6/10) Sep 10, 2026
F-OBrien and others added 11 commits September 10, 2026 19:26
`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.
`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.
@sonarqubecloud

Copy link
Copy Markdown

@prashantasdeveloper
prashantasdeveloper marked this pull request as ready for review September 11, 2026 12:30
@prashantasdeveloper
prashantasdeveloper requested a review from a team as a code owner September 11, 2026 12:30
@prashantasdeveloper
prashantasdeveloper added this pull request to stack #358 September 15, 2026 08:23
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants