Skip to content

feat(ceremony): generate the profiles for every language from one file - #29

Merged
xgreenx merged 4 commits into
mainfrom
feat/generate-ceremony-profiles
Sep 8, 2026
Merged

xgreenx merged 4 commits into
mainfrom
feat/generate-ceremony-profiles

Conversation

@xgreenx

@xgreenx xgreenx commented Sep 8, 2026

Copy link
Copy Markdown
Contributor

The ceremony profile was written out four times — CeremonyProfile.sol and the two Platform Verifiers here, a hand table in libid-rs, and the browser's own copy in libid — with nothing relating them. The browser's copy has already drifted: it labels api.x.com the platform, when the platform is x and api.x.com is the authority. Nothing failed, because a Consumer dispatching on one string and a verifier registered under another simply never meet.

This does for the profiles what handles.json does for normalization: generated data, hand-written logic that reads it.

What changes

solidity/contracts/ceremony/profiles.json is the source of truth. scripts/regen-ceremony-profiles.py writes three files from it:

output consumed by
solidity/contracts/ceremony/CeremonyProfile.sol the Platform Verifiers, which now read it the way HandleNormalizer reads HandleVectors
rust/profiles/src/profiles.rs (new libid-profiles crate) a Rust prover — the deployment's GitHub Token Service
ts/packages/contracts/src/ceremony/profiles.ts (@libid/contracts/ceremony) the browser, which notarizes three of the four sessions

The Solidity constants are unchanged. Every platform id, authority, launch version, attestation count and protocol parameter generates byte for byte what was already there — the diff against the previous file is additions only, and 439 forge tests pass untouched. What's added is the session data the verifiers used to spell inline: request lines, the committed-body-field count, and the two identity field names. _tokenRequestLine() now returns CeremonyProfile.X_TOKEN_REQUEST_LINE.

Generation is not correctness

A wrong edit to profiles.json reaches all three languages at once, and they would agree perfectly on a platform no verifier is registered for. So the vectors stay and gain a second side: CeremonyProfile.t.sol pins the ids to cast keccak values, and the new crate's tests hash the generated strings and assert the same numbers.

The version guard

--check refuses a shipped profile whose values changed while its ceremonyVersion stayed put, reading the last release tag to know which is which. Exercised across five cases:

nothing changed passes
prose reworded passes — note is exempt
value changed, version held fails: x/v1 changed since v0.7.0 but kept its ceremonyVersion
value changed, version bumped passes
new platform added passes — not a change to a shipped one

The check job now uses fetch-depth: 0: a shallow clone carries no tags, would find nothing to compare, and would pass in silence.

Release surface

libid-profiles has no runtime dependencies — the libid-identity shape, so a prover reads a table without inheriting anything to do it. It joins the single-version invariant (verify-tag.sh), bump-version.sh, the publish dry-run and the release publish. TypeScript gets a ./ceremony subpath export.

Verification

  • python3 scripts/regen-ceremony-profiles.py --check and regen-identity-handles.py --check
  • forge test — 439 passed; forge fmt --check
  • cargo +nightly fmt --all --check, cargo clippy --workspace --all-targets -- -D warnings, cargo test -p libid-profiles — 6 vectors
  • pnpm build, typecheck, test (24), lint, fmt:check

Follow-ups, not here

  • libid-rs consumes libid-profiles and deletes its hand table (feat(transcript): the launch profiles, pinned to the numbers the chain holds libID-rs#10, closed pending this).
  • libid replaces the browser's copy, which fixes the platform/authority confusion above.
  • The IdShape enum is still hand-written in Solidity (IdShape.JsonString in the verifiers) while the JSON carries the shape. Generating a cast across the enum's ordinal was more coupling than it removed; worth revisiting.

The ceremony profile was written out four times: `CeremonyProfile.sol` and the
two Platform Verifiers here, a hand table in libid-rs, and the browser's own
copy in libid. Nothing related them. The browser's already drifted -- it labels
`api.x.com` the PLATFORM, when the platform is `x` and `api.x.com` is the
authority -- and nothing failed, because a Consumer dispatching on one string
and a verifier registered under another simply never meet.

`solidity/contracts/ceremony/profiles.json` is now the one place those values
live, and `scripts/regen-ceremony-profiles.py` writes all three languages from
it, following `handles.json` exactly: generated data, hand-written logic that
reads it. `CeremonyProfile.sol` becomes generated and the verifiers read it,
the way `HandleNormalizer` reads `HandleVectors`.

The Solidity constants are unchanged. Every platform id, authority, launch
version, attestation count and protocol parameter generates byte for byte what
was there; the diff against the previous file is additions only, and the 439
forge tests pass untouched. What is added is the session data the verifiers
used to spell inline: the request lines, the committed-body-field count, and
the two identity field names.

The new `libid-profiles` crate is the Rust half -- no runtime dependencies, the
`libid-identity` shape, so a prover reads a table without inheriting anything
to do it. It joins the single-version invariant, `bump-version.sh`, the publish
dry-run and the release publish. TypeScript gets `@libid/contracts/ceremony`.

Generation makes the three agree with each other; it does not make them right.
A wrong edit reaches all three at once and they would agree perfectly on a
platform no verifier is registered for. So the vectors stay: `CeremonyProfile.
t.sol` pins the ids to `cast keccak` values, and the new crate's tests hash the
generated strings and assert the same numbers.

`--check` also refuses a SHIPPED profile whose values changed while its
`ceremonyVersion` stayed put, reading the last release tag to know which is
which -- prose is exempt, a new platform is not a change, and a bumped version
passes. The check job now fetches tags, because a shallow clone would find
nothing to compare and pass in silence.

Assisted-by: Claude Opus 5
Signed-off-by: xgreenx <xgreenx9999@gmail.com>
Running the profile generator as a module leaves a `__pycache__` beside it,
and one landed in the previous commit. Removed, and ignored so the next run
cannot repeat it.

Assisted-by: Claude Opus 5
Signed-off-by: xgreenx <xgreenx9999@gmail.com>
`publish-crates` needed verify-tag, versions, solidity, rust and
publish-check, and `publish-npm` needs publish-crates -- so neither waited on
the check that the committed tables still match the file they come from. A
release could publish a stale table, or a shipped ceremony profile whose values
changed while its version stayed put, and every other job would be green while
it happened.

The drift job carries no `if:`, so it already runs on a release event; it was
only missing from the graph. Adding it to `publish-crates` gates npm too,
which waits on crates.

Assisted-by: Claude Opus 5
Signed-off-by: xgreenx <xgreenx9999@gmail.com>
The handles generator escapes every string it emits, because its vectors
deliberately carry bytes a source file cannot -- that is what they test. This
one emitted them raw, so a quote in a path or a field name produced broken
Solidity. It did not reach a file, but only by accident: `forge fmt` rejected
it and the script bailed, reporting a parser error about an unexpected token.
Rust and TypeScript go through no formatter, so a value breaking only those
would have been written.

Escaping is the wrong answer for this data. A path, a method, a host and a JSON
member name are drawn from URL and identifier characters; a quote in one is a
mistake, and escaping it would emit a constant no verifier could ever match. So
the charset is checked before anything is generated, and the error names the
field and the value.

The functions appended for Rust and TypeScript also carried no type
annotations, unlike the rest of this file and unlike its sibling.

Assisted-by: Claude Opus 5
Signed-off-by: xgreenx <xgreenx9999@gmail.com>
@xgreenx
xgreenx marked this pull request as ready for review September 8, 2026 18:49
@xgreenx
xgreenx merged commit 2c84d17 into main Sep 8, 2026
11 checks passed
@xgreenx xgreenx mentioned this pull request Sep 8, 2026
@Wondertan
Wondertan deleted the feat/generate-ceremony-profiles branch October 2, 2026 19:56
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.

1 participant