- Go 96.3%
- Shell 3.7%
The palcos node stands at the apelido node's heights, empty where a block had nothing for palcos; the log names the height and the checkpoint. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> |
||
|---|---|---|
| client | ||
| cmd/palcos-node | ||
| e2e | ||
| node | ||
| .gitignore | ||
| actions.go | ||
| actions_test.go | ||
| bench_test.go | ||
| DESIGN.md | ||
| doc.go | ||
| go.mod | ||
| go.sum | ||
| mutations.go | ||
| README.md | ||
| state.go | ||
| state_test.go | ||
| validator.go | ||
Palcos
Stages on apelido, on the brisa network.
A stage (palco) is a place where members publish with permission — and the permission is holding one of the stage's private keys. What is published is opaque bytes. A stage can be open or closed, moderated or not, and its content public or sealed for its members.
Palcos is the stages of aereum (the audiences of its first draft), rebuilt as a protocol on top of apelido. The reasoning, the decisions and what is still open are in DESIGN.md (Portuguese).
Why it is built this way
The state holds keys, not members
The obvious way to run a closed group on a chain is a membership list in the state: is this author a member of this stage? It does not scale. Aereum's own draft did the sums — 200 million people following 500 audiences each is 10¹¹ memberships, 3.2 TB of 32-byte hashes, all of it in every validator's state.
Palcos turns the question around. Being a member is holding the key. The state keeps, per stage, only which keys are current:
| the state holds | 200 M stages | |
|---|---|---|
| a membership list | one hash per (stage, member) | 3.2 TB at 500 members each, growing with every join |
| palcos | three hashes per stage: the stage, its current submission key, its current moderation key | ~19 GB, whether a stage has five members or five million |
Validating a publication is one set lookup — is hash(stage ‖ key) current? —
and one signature check: whoever signed with the stage's submission key holds it,
and holding it is the permission.
The cost moves, on purpose
Taking members out of the state does not make them disappear:
- Membership is public. Who asked to join and who was let in are actions in the palcos sub-chain, readable by anyone. What is sealed is the content and the keys, not who holds them.
- Removing a member is rotating the stage's keys — new keys, sealed again for everyone who stays. Its cost is paid once, by the stage's owner, in the sub-chain, and never by the state. Today that is one box per remaining member; see Scaling for how it becomes logarithmic.
- A member sent away keeps the old keys. It still reads what was published while it was in, and nothing after. No scheme can take back what was already read.
Each layer answers one question
| layer | answers | carries |
|---|---|---|
| brisa | when, and who paid | the epoch; the wallet, its fee and signature |
| apelido | who — a member, or an attorney of the member's | the author; the signer (member or attorney) and its signature |
| palcos | where, and with which permission | the stage; the stage keys' signatures |
| above | what | the content, opaque to palcos |
So a palcos action carries no author, attorney, wallet or fee: the layers below have them. Palcos only sees apelido Voids that apelido accepted — it follows apelido's sub-chain, whose blocks hold nothing else — and does not check membership or powers of attorney again.
The state validates, it does not index
The state answers only what validation needs. Everything else — the stages' descriptions, the requests, the members, the posts, the approvals — is read from the sub-chain by clients and indexers. That is also why an approval names a hash the state never checks: approving nothing does nothing.
An instruction, layer by layer
A palcos instruction is the Data of an apelido Void, after [IVoid][PALC]
(ProtocolID = 0x50414C43, the ASCII bytes "PALC"):
[0][IVoid][Epoch 8][APEL 4] brisa header
[KindVoid][Handle 2+n] apelido: the handle it is from
[IVoid][PALC 4] palcos' id
[kind] fields… palcos action
… stage-key signature(s) signed over everything above
[Signer 32][Signature 64] apelido: the handle's key, or an attorney
[Wallet 32][Fee 8][Signature 64] brisa: who pays
Three independent signatures, three questions: the stage key proves the permission, the apelido signer proves who speaks (and apelido checks it may speak for the handle), the wallet proves who pays.
Actions
CreateStage opens a stage
Stage token the stage's identity; never changes
Submission token current submission key; zero = anyone may publish
Moderation token current moderation key; zero = no moderation
Flag byte for clients (bit 0: content sealed)
Description string
Signature by Stage proves the key is held — nobody registers another's key
valid if: the stage does not exist.
UpdateStage rotates the keys; how a member is sent away
Stage, OldSubmission, OldModeration, Submission, Moderation, Flag, Description
Read, Submit, Moderate TokenCiphers the new private keys, sealed for each who stays
Signature by Stage
valid if: the stage exists and the old keys named are the current ones.
JoinStage asks to join
Stage token
Key token X25519 key of a device or app, to seal the keys for
Presentation string
valid if: the stage exists. Changes nothing.
AcceptJoin hands a member the keys
Stage, Moderation, Member
Read, Submit, Moderate bytes sealed for the member's JoinStage key; empty = not given
Signature by Moderation
valid if: Moderation is the current, non-zero moderation key. Changes nothing.
Void publishes
Stage, Submission
Data bytes opaque; runs to the signature
Signature by Submission not checked when Submission is zero (an open stage)
valid if: Submission is the stage's current submission key. Changes nothing.
Approve a moderator's yes to a Void in a moderated stage
Stage, Moderation
Hash hash of the apelido instruction that carried the Void
Signature by Moderation
valid if: Moderation is the current, non-zero moderation key. Changes nothing.
JoinStage, AcceptJoin, Void and Approve change no state: they are
validated against it and kept in the sub-chain, where clients read them.
Cryptography
Keys
| key | kind | public half is | private half is held by |
|---|---|---|---|
Stage |
ed25519 | the stage's identity | the owner |
Submission |
ed25519 | in the state, rotates | whoever may publish |
Moderation |
ed25519 | in the state, rotates | whoever lets members in and approves |
| reading key | AES-256 | — | every member of a sealed stage |
| join key | X25519 | in the JoinStage |
the member's device or app |
The member's identity key is never used by palcos. The stage's keys are sealed for a join key chosen per device or app, as apelido's powers of attorney keep the identity key stored away: a stolen device opens that device's stages, not every stage the identity was ever in.
What each stage key signs
A stage key signs apelido.ChildPrefix(epoch, author, PALC) — the brisa header,
apelido's kind and author, palcos' [IVoid][id] — followed by the action up to
its signature. So a signature does not move to another epoch, another author,
or another protocol: the same rule apelido follows, sign the whole
instruction. Each action signs its own kind byte.
| action | signed by |
|---|---|
CreateStage, UpdateStage |
Stage |
AcceptJoin, Approve |
the current Moderation |
Void |
the current Submission (none in an open stage) |
JoinStage |
no stage key — only apelido's signer |
Sealing keys for a member
The private keys travel in sealed boxes (brisa/crypto/dh): anyone can seal
for a public key, only its private half opens.
box = [ephemeral X25519 public 32] [AES-256-GCM ciphertext + tag 16]
key = HKDF-SHA256( X25519(ephemeral, to),
info = "brisa/dh v1" ‖ lower(pub₁, pub₂) ‖ higher(pub₁, pub₂) ‖ context )
A fresh ephemeral key per box, so no two boxes share a key; the key is bound to both public keys and to a context that names the use and the stage:
context = "palcos/" {accept|update} "/" {read|submit|moderate} "/" ‖ Stage
A reading key sealed for an AcceptJoin does not open as an UpdateStage's,
nor for another stage. A box proves nothing about who sealed it; the action
that carries it is signed by the stage's moderation or stage key, which does.
Sealed content
In a stage with Flag bit 0 set, a Void's Data is
[nonce 12] [AES-256-GCM(reading key, nonce, content, additional data = Stage)]
with a random nonce per post. Each rotation makes a new generation — named
by its pair of public keys (Submission, Moderation) — with its own reading
key; a client keeps every generation it was given, and opens a post with the
generation whose submission key the post names (in an open stage, which names
none, each reading key in turn: GCM tells which one fits).
The flows
join member ── JoinStage(Key = fresh X25519 of this device) ──────────────▶
moderator reads it; seals the current keys for Key:
── AcceptJoin(Read, Submit?, Moderate?), signed by Moderation ▶
member opens the boxes with the device key
publish member seals the content with the reading key, signs with Submission:
── Void, carried by the member or its attorney ──────────────▶
a moderated stage shows it once a moderator publishes Approve(hash)
remove owner makes new keys and seals them for every member but the one leaving:
── UpdateStage(Old keys, new keys, boxes), signed by Stage ───▶
the state replaces the keys; a Void signed with the old ones is refused,
in the same block already; the one left out reads nothing new
The keyring at rest
A client's keys are kept in one file: the keyring as JSON, encrypted with AES-256-GCM under a key scrypt derives from a password — brisa's keystore scheme, for many keys instead of one — with a fresh salt, so a fresh key, on every save.
What it does not protect
- Who is in a stage — public by design (above).
- What a member already read — kept after it leaves.
- Forward secrecy within a generation: one reading key seals every post until the next rotation; whoever gets it later reads them all.
- The size of the content, and when and by whom it was published.
Scaling
The state is O(stages) and stays so. What grows with a stage is rotation:
today an UpdateStage carries one box per remaining member, ~114 bytes each,
with a two-byte count — at most 65,535 members, and the client refuses beyond
that rather than write a wrong count.
| members | one box per member (today) | key tree, ~2·log₂N boxes (planned) |
|---|---|---|
| 1,000 | 114 KB | ~2.3 KB |
| 100,000 | 11 MB | ~3.8 KB |
| 1,000,000 | 114 MB — not possible | ~4.6 KB |
Planned, not implemented: the reading keys in a binary tree, as in LKH and
MLS's TreeKEM (RFC 9420). Each tree
node is an X25519 pair, members are the leaves, each holds the keys on its path
to the root, and the reading key derives from the root. Removing a member
renews the keys on its path and seals each for the children of its node — the
leaving leaf excluded. The boxes keep their shape — brisa's TokenCipher{Token, Cipher}, Token
being a node's key; the state and validation do not change. Concurrent changes to the
tree are ordered the way rotations already are: each names the keys it replaces,
and the second of two is refused. See DESIGN.md.
State
Three papirus.HashSets, as apelido's:
| set | holds |
|---|---|
stages |
hash(Stage) of every stage opened, so none opens twice |
submissions |
hash(Stage ‖ Submission) — each stage's current submission key |
moderations |
hash(Stage ‖ Moderation) — each stage's current moderation key |
A zero key is a key like any other in the set: an open stage, or one without
moderation. The Flag is not kept.
Nodes
brisa validators ──> relay ──> apelido-node ──> palcos-node (upstream.kind: apelido)
│ SocialSyncGateway
├──> palcos-node (upstream.kind: palcos)
└──> clients (palcos/client)
A palcos node is a brisa node/social.SocialNode following apelido's sub-chain
as a child protocol (UpstreamParent): it selects the apelido Voids carrying
[IVoid][PALC], validates them, persists its own sub-chain, and serves it.
Confirmations and rollbacks come down from brisa through apelido unchanged.
A block from a height the network dropped stays in the sub-chain — the chain
is forward-only — and its actions have no effect. A node announces the drop
before the blocks it covers, to its live subscribers and to anyone syncing from
its store, and every reader skips them: the nodes below it, and client's
SubChain, which ReadSubChain and Follow feed. node/chain_test.go runs the
whole chain below the validators — a stand-in validator (brisa's
node/relay/relaytest), a real relay, apelido, two palcos nodes — with a drop
in the middle.
Code
| path | what |
|---|---|
actions.go |
the six actions, ParseAction, Wrap, InstructionHash |
state.go, mutations.go, validator.go |
the three sets, the per-block diff, the validating view |
client/ |
the member's side: keyring, building every action, reading and opening the sub-chain, ReadSubChain and Follow |
node/ |
the SocialNode instantiation |
cmd/palcos-node/ |
the node: YAML, keystore, upstream.kind: apelido | palcos |
e2e/ |
the end-to-end run over a local brisa testnet |
Building
Palcos does not build on its own: go.mod points at its siblings on disk.
aslivres/
├── brisa/
├── apelido/
└── palcos/
go build ./...
go test ./... # in memory, under a second
go build ./cmd/palcos-node/
./palcos-node -example > palcos-node.yaml
End-to-end
./e2e/run.sh # ~4 min: brisa testnet → relay → apelido → palcos ×2, and a client script
./e2e/stop.sh
A closed, moderated, sealed stage: two members let in, one publishing through an attorney and the other opening it, an approval, a rotation that leaves the first out — its Void signed with the old key refused, and nothing after it readable by it. Every verdict is checked against the main chain and against palcos' sub-chain, live and confirmed, and the second palcos node has to reach the same height. It uses the same ports as apelido's e2e; the two do not run at once.