You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
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:
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:
Derive from the transaction at decision time (no fork, matches the existing counters rule)
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)
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.
Follow-up work deferred from #250, which added
operated_byon nodes andorg_type/leion 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_registryon chainorg_typecurrently accepts any well-formed string (2-64chars, 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: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_SCHEMESinscripts/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_byfor new node registrationsOptional 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:
optionallist ofbuildSigningPayload. That list rebuilds canonical bytes when replaying committed transactions, so making itrequiredthere would break replay of every node registered before the change, including the three genesis members which will always havenull.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
nullpermanently. That is the grandfather clause and needs no special handling.3. Revocation cascade
The gap
operated_byexists to close:isRevokedis 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_MStreatment 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_byandorg_typeare outside consensus stateNeither field is in the state merkle root.
_canonIdentityand_canonNodeare 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:
operated_byis inNODE_REGISTER_FIELDS(VP-signed, operator-cosigned) andorg_typeis in the REGISTER_IDENTITY signed payload, and those transactions are covered bytxs_merkle_root. Nothing can be forged into existence without the VP key.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:Related: the snapshot transfer had the same root cause.
iterateCanonicalStateshipped the hashing projection, so a resyncing node silently lost both columns. Fixed on the #250 branch by shipping raw rows foridentities,contentandnodeswhile hashing keeps using the whitelist, mirroring the existingcontentRawpattern. 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_byexists 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.org_typeis not written into the credential fileregister-org.jshands to the organization, though it is on chain and returned by the API. The file already carriesregistration_number,registration_schemeandincorporated, so it is inconsistent.