Phase 3 of docs/project-feature.md. Three arms in `ChatMessage.applyInnerEvent`,
which is where every nip30303 kind lands whether it arrived over the wire or was
signed locally -- `FrostSigningManager` reuses this dispatch rather than
repeating it. A project can now become a row; nothing proposes one yet.
The project writes a chat line naming the project and not its creator. On a
group-signed row `senderPublicKey` is the session's coordinator, so "X started
the project" would be the transcript guessing at something the event states
exactly. The memberships write no line, the way a version writes none beside its
artifact: they arrive as later items of the batch that founds the project, which
has already said so, and one added later is covered by the "The group signed
..." line `FrostSigningManager.announce` already writes.
`fromProjectEvent` refuses an event with no name, no creator or no owners. The
third refusal is the one worth having a test for and it is not about malformed
events at all: it is what `ContributorTag.ROLE_OWNER` missing from `KNOWN_ROLES`
looks like from here. The role parses as null, `owners()` comes back empty, the
signature still verifies and nothing is logged, so the refusal is the alarm.
`ProjectApplyJvmTest` reproduces it with a role deliberately not in the set.
A membership whose version or project this device does not hold is refused by
the foreign key, and the refusal is caught here rather than left to escape.
`applySignedEvent` swallows throwables because the signature is valid either
way, so an escaping one would report a session that succeeded as a session that
failed and tell the group to abandon a ceremony whose signature is good. No call
path wraps `applyInnerEvent` in a transaction -- checked, all three -- so
catching it does not poison one.
1381 tests, no failures.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>