Skip to content

Deferred: on-chain org_type registry, required operated_by, revocation cascade #251

Description

@t-bhendarkar

Follow-up work deferred from #250, which added operated_by on nodes and org_type / lei on organization identities. All three items below wait until partner nodes actually exist, since none of them is meaningful with a 3-node genesis committee we operate ourselves.

1. org_type_registry on chain

org_type currently accepts any well-formed string (2-64 chars, lowercase, digits, hyphens) with no per-country allowlist. The VP vouches for the value.

A hardcoded allowlist is not the answer: legal forms differ per jurisdiction and new ones appear, so any list validated at commit time would need a coordinated fleet upgrade plus an activation gate every time it changed, and during each rolling window newer nodes would accept registrations older nodes reject.

The right shape is an on-chain registry mirroring interests_registry, which is already VP-governed chain state:

org_type_registry (code, label, country, registered_by_vp_id, tx_id, registered_at)

Every node then validates against identical shared state, adding a legal form is a transaction rather than a code deploy, and there is no fork risk. Seed it with the forms actually encountered rather than trying to enumerate world company law.

Note the same reasoning applies to ORG_ID_SCHEMES in scripts/register-org.js. Those per-country registration-number rules are script-side today, so they carry no consensus risk. If they ever move into commit-time validation they hit the identical problem and want the identical solution.

2. Require operated_by for new node registrations

Optional today, so the field is populated only when the operator supplies it. Once partner onboarding is real it should be mandatory for non-genesis nodes.

Two constraints:

  • It must stay in the optional list of buildSigningPayload. That list rebuilds canonical bytes when replaying committed transactions, so making it required there would break replay of every node registered before the change, including the three genesis members which will always have null.
  • Requiredness therefore belongs in validateRequest (request-level), not in the payload contract. Request-level checks carry no fork risk: a rejected request never becomes a transaction, so nodes running different API validation cannot diverge.

Genesis members keep null permanently. That is the grandfather clause and needs no special handling.

3. Revocation cascade

The gap operated_by exists to close: isRevoked is checked in 8 places (signers, verifiers, authors, disputers, jurors, appellants) and zero of them cover node operation. Revoke an identity today and it can no longer register content or adjudicate, yet a node it operates keeps signing certificates and sitting in the committee.

The cascade has teeth and must not be built naively. Quorum is 2 of 3, so instant suspension on operator revocation means revoking two operators halts the chain and an administrative action becomes a liveness weapon.

Route it through committee rotation instead: a revoked operator's node stops being eligible at the next rotation, so quorum adjusts in an orderly way. This is consensus-affecting, so it needs the ACTIVATION_MS treatment with the activation set in the future at deploy time.

Open question for VPs specifically: if the operator behind a VP is revoked, every identity that VP attested is now vouched for by a discredited authority. Retroactive re-verification is almost certainly wrong, but new attestations should stop. Decide this before enforcing anything.

4. operated_by and org_type are outside consensus state

Neither field is in the state merkle root. _canonIdentity and _canonNode are explicit whitelists and do not read them, which is deliberate: adding a field to those projections changes the root for every existing row and forks the chain.

What that means in practice:

  • The values are cryptographically attested. operated_by is in NODE_REGISTER_FIELDS (VP-signed, operator-cosigned) and org_type is in the REGISTER_IDENTITY signed payload, and those transactions are covered by txs_merkle_root. Nothing can be forged into existence without the VP key.
  • The materialised columns are not protected. A node could hold a wrong value and the state-root comparison would not catch it. They are a read-model, rebuildable by replaying the transaction, exactly like verify_count / dispute_count.

So the rule already recorded for those counters applies here too: consensus must derive the value from the transaction, never read the column.

This matters most for item 3 above. If the revocation cascade suspends a node by reading nodes.operated_by, then a node with a corrupted column reaches a different decision and the chain forks. Options when that work starts, in order of preference:

  1. Derive from the transaction at decision time (no fork, matches the existing counters rule)
  2. Add the fields to the canonical projections (full protection, but changes the root for every row so it needs a coordinated fork with an activation gate)
  3. Leave unhashed and forbid consensus from reading them

Related: the snapshot transfer had the same root cause. iterateCanonicalState shipped the hashing projection, so a resyncing node silently lost both columns. Fixed on the #250 branch by shipping raw rows for identities, content and nodes while hashing keeps using the whitelist, mirroring the existing contentRaw pattern. Any future non-root column needs the same treatment, in both MemoryStore and SQLiteStore (Postgres delegates to the mirror).

Also deferred from the same work

  • verification_providers.operated_by exists as a column but is never written. The VP registration path is unchanged on purpose: a field accepted into signed bytes with no validation behind it is worse than no field. Wire format and validation land together when VP registration is actually needed.
  • LEI, which was built and then removed from feat(governance): operated_by on nodes, org_type on organizations #250 before merge. It is public, globally unique and ISO 7064 checksum-verifiable (verified against 25 real GLEIF records), but nothing on chain reads it, so it belongs in the off-chain KYB record alongside the registration number and tax ID. When that record exists, add LEI to it and pair it with a GLEIF lookup at registration time so a well-formed but wrong value is caught by eye. The lookup cannot live in consensus validation, since a network call is non-deterministic.
  • org_type is not written into the credential file register-org.js hands to the organization, though it is on chain and returned by the API. The file already carries registration_number, registration_scheme and incorporated, so it is inconsistent.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions