Repository navigation
Sign action records with owner Turnkey keys through one shared path - #277
brianorwhatever wants to merge 4 commits into
Conversation
Defines the version-1 action record payload, its RFC 8785 canonical bytes, the 64-byte domain-separated signing input, and verifyActionRecord: a pure check (no Convex, no network) that only accepts keys the caller already trusts for the payload's owner DID. A key carried by the record selects among those and is never trusted by itself. Part of #237. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Every recorded transition now goes through recordActions: item create (single, template, recurring), complete and reopen (single and batch, which previously recorded nothing), and list create, copy and rename. Batch and individual calls write records of the same shape, bound to the authenticated session or API-key row separately from the authorizing account. Signing custody is author-held at Turnkey and asynchronous. The mutation stores the exact canonical payload as pending and schedules a Node action; it never contacts Turnkey, so signing cannot fail a write. The signer re-checks the persisted bytes and owner binding, signs with the owner's sub-org Ed25519 key, verifies the result, and stores it. Failures retry with backoff up to four attempts, then settle as failed with a reason code. Accounts without a sub-org get an explicit unsigned record. An hourly sweep re-queues runs that never reported back. New unsigned vcProof/vcProofs placeholders are no longer written; existing ones stay readable and untouched. The list placeholder constructor moves into the celAssetDids migration, its only remaining user. Records are deleted with their item, list or account, and are kept out of the DID re-mint rewrite. Adds GET /api/v1/action-records and scripts/verify-action-records.mjs so records can be fetched and checked offline against pinned keys. Part of #237. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Item and list provenance list the new action records with a truthful status: Signed (Turnkey-held owner key), Pending signature, Signing failed or Unsigned, plus the authorizing account and whether a session or an API key was used. Historical placeholders keep their unsigned/unverified labels under a separate heading. The browser re-checks each signed record. It says "verified" only when the owner DID is itself a did:key for the signing key; otherwise it says the signature matches the key boop recorded and that the key is not independently verified there. A record failing that check is shown as failed, not signed. Part of #237. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Covers Turnkey custody, the payload contract, the status lifecycle and failure policy, what independent verification does and does not prove, key rotation, and the remaining gaps, including that the Turnkey key is not published in the owner's DID document and live Turnkey has not been exercised. Part of #237. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
|
🚅 Deployed to the boop-pr-277 environment in Friends
|
There was a problem hiding this comment.
ℹ️ No critical issues — one scale concern inline and two rollout notes below.
Reviewed changes
I reviewed the full PR: the signing path, its retry and sweep lifecycle, the payload and verifier, the deletion cascades, the UI and the tests. I ran scripts/signed-action-records.test.mjs and scripts/provenance-presentation.test.mjs locally and all 21 tests pass.
- One recording path:
recordActionsinconvex/lib/actionRecords.tsreplaces thevcProof/vcProofsplaceholder writers inaddItem,checkItem/uncheckItem, both batch variants,createList,copyList,renameListandcreateListFromTemplate. - Canonical payload and verifier:
shared/actionRecord.tshas the RFC 8785 canonicalizer, the domain-separated 64-byte signing input, base58/Multikey codecs andverifyActionRecord, which trusts only keys the caller supplies. - Async Turnkey signer:
actionRecordSigning.sign→signActionRecords→turnkeySigningKey. Before signing it checks the stored bytes and the owner binding. It verifies each signature before storing it and settles each signed record as it goes. - Lifecycle bookkeeping:
settleuses 1/5/25 minute backoff with 4 attempts. There is also an hourlysweepStalePendingcron and an operator-runrequeueFailed. - Reads and deletion: actor queries plus
GET /api/v1/action-records. Records are deleted with their item, list or account. - UI:
ActionRecordListinProvenanceInfo.tsxshows the signing status honestly. The old item VCs are relabelled "Historical action records".
ℹ️ The frontend queries functions that only exist after the Convex deploy
ItemProvenanceInfo and ListProvenanceInfo now call api.actionRecords.getItemActionRecords and getListActionRecords without any condition. If Railway ships the frontend before deploy-convex.yaml finishes, or the Convex deploy fails (this PR adds a table and a cron), useQuery throws "Could not find public function" during render. That breaks the item details panel and the share modal until the backend catches up.
Technical details
# Deploy ordering for new actionRecords queries
## Affected sites
- src/components/ProvenanceInfo.tsx:745 — `useQuery(api.actionRecords.getListActionRecords, …)` (ShareModal)
- src/components/ProvenanceInfo.tsx:907 — `useQuery(api.actionRecords.getItemActionRecords, …)` (ItemDetailsModal)
## Required outcome
- No window where the deployed frontend calls `actionRecords:*` against a backend that lacks them.
## Suggested approach (optional)
- Run `deploy-convex.yaml` via `workflow_dispatch` (or let it finish) before the Railway frontend deploy goes live, and confirm the schema push and cron registration succeed.
## Open questions for the human (optional)
- Is a short crash window on these two panels acceptable, or should the rollout be explicitly ordered?ℹ️ Every checkbox toggle now makes three Turnkey API calls
Each mutation schedules its own signer run. Each run calls getWallets and getWalletAccounts, then signRawPayload once per record, because the key cache in signActionRecords only lasts one run. So checking or unchecking one item costs a Node action plus three Turnkey requests, one of which is a signing activity. This is the custody model #237 chose, but the volume is worth confirming against Turnkey's rate limits and per-signature pricing before it ships to everyone.
Technical details
# Turnkey request volume per action
## Affected sites
- convex/lib/actionRecords.ts:63 — one `actionRecordSigning.sign` run scheduled per mutation
- convex/lib/actionRecordSigner.ts:66 — key cache scoped to a single run
- convex/turnkeyHelpers.ts:77 — `getEd25519Account` (2 reads) on every key resolution
## Required outcome
- Expected Turnkey call and signing volume is known and acceptable for current traffic (rate limits and billing).
## Open questions for the human (optional)
- Should the sub-org's Ed25519 address and signing org be cached (e.g. on `users`) so a run issues only `signRawPayload`?
- Should rapid toggles be coalesced, e.g. by delaying the signer run a few seconds and batching by owner?ℹ️ Nitpicks
shared/actionRecord.tsandconvex/turnkeyHelpers.tsimport@noble/ed25519and@noble/hashes, but neither is declared inpackage.json. They resolve only because@originals/sdkpulls them in transitively.src/lib/webvh.tsalready relies on the same thing, but the verifier now depends on it in both the browser and Convex bundles, so declaring them directly would be safer.
claude-opus-5-5 | 𝕏
| const records = "itemId" in target | ||
| ? await ctx.db.query("actionRecords").withIndex("by_item", q => q.eq("itemId", target.itemId)).collect() | ||
| : await ctx.db.query("actionRecords").withIndex("by_list", q => q.eq("listId", target.listId)).collect(); | ||
| for (const record of records) await ctx.db.delete(record._id); |
There was a problem hiding this comment.
Records accumulate with every create, complete and reopen, and deleteList now removes all of a list's records in one mutation with an unbounded .collect(). Before this PR, completions were embedded in the item doc. A long-lived shared list, e.g. 50 items toggled daily for a year (~18k rows), will exceed Convex's per-mutation write and read limits, and deleteList will then fail with a generic Server Error.
Technical details
# Unbounded action-record deletion in deleteList
## Affected sites
- convex/lib/actionRecords.ts:71-74 — `deleteActionRecords` collects and deletes every row for the list in one transaction
- convex/lists.ts:518 — called from `deleteList` (single mutation)
## Required outcome
- Deleting a list with many action records cannot exceed per-mutation document/byte limits.
## Suggested approach (optional)
- Delete in bounded batches (e.g. `take(N)` and reschedule the remainder via `ctx.scheduler.runAfter`), the same way `users.deleteUserStep` already drains `actionRecords` with `DELETE_BATCH_SIZE`.
- The per-item path (`removeItem`/`batchDeleteItems`) is far less exposed and can stay as is.
Closes #237.
Problem
Action "credentials" were unsigned JSON placeholders. Batch completion skipped the record that single completion wrote, so equivalent actions left different histories. #273 fixed attribution and made the UI label these records honestly, but nothing produced real signatures.
Change
Custody (product decision on #237): new action records are signed by the authorizing owner's Ed25519 key held at Turnkey (their sub-org). Turnkey can only be called from Node, so signing is asynchronous and a mutation never fails because signing failed.
One recording path (
convex/lib/actionRecords.ts) covers:Equivalent single and batch actions produce records of the same shape.
Payload v1 (
shared/actionRecord.ts) is RFC 8785 canonical JSON. It binds the action, subject, before/after state, server time, the owner (did,userId), the authenticated credential (a session row or a specific API-key row), and the expected signer. The signed message isSHA-256(domain) || SHA-256(payload).Status lifecycle:
pending: written by the mutation, which schedules a Node signer.signed: the signer re-checks the stored bytes, digest and owner binding before signing, then verifies the result before storing it. It is idempotent.failed: after bounded retries (1/5/25 min), or straight away on an integrity failure. Only a reason code is stored.unsigned: the account has no Turnkey sub-org. No signature is ever fabricated.Independent verifier (
verifyActionRecord, plusscripts/verify-action-records.mjs) runs without Convex or network access. It rejects altered payloads, altered signatures, untrusted keys, mismatched owner/credential bindings, and any record that is notsigned. It only trusts keys the caller supplies, never a key carried by the record.UI: ProvenanceInfo shows Signed / Pending signature / Signing failed / Unsigned. Historical placeholders keep their Clarify unsigned provenance and preserve authenticated credential identity #273 labels (unsigned or unverified). It says "signature verified" only when the owner DID is itself a
did:keyfor the signing key.Compatibility:
vcProof/vcProofskeep their schema and wire shape and remain readable. New placeholder writes stop. CEL/WebVH evidence and migrations are unchanged (the legacy list placeholder builder moved into the migration that still uses it).GET /api/v1/action-recordsis added for read-only access.Limitations
did:webvhdocuments publish a browser-held key, not the Turnkey key. A verifier therefore has to pin owner DID → Turnkey key from a trusted source. If boop's word is their only source, a valid signature proves integrity but not that the key belongs to the owner. Publishing the Turnkey key in the DID document is a follow-up.docs/action-records.mddocuments this.vcProofstoday. They are not a permanent audit log.Verification
bun test: 698 pass, 0 fail. This includes 16 new tests covering:There are also 3 rendered UI status tests. Existing envelope-tampering and migration tests pass.
tsc -b,tsc -p convex/tsconfig.json, eslint on changed files andvite build: clean.🤖 Generated with Claude Code