Skip to content

Sign action records with owner Turnkey keys through one shared path - #277

Open
brianorwhatever wants to merge 4 commits into
mainfrom
fix/237-turnkey-signed-action-records
Open

brianorwhatever wants to merge 4 commits into
mainfrom
fix/237-turnkey-signed-action-records

Conversation

@brianorwhatever

Copy link
Copy Markdown
Contributor

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:

    • item create, including template and recurring items;
    • complete and reopen, for single items and in batch;
    • list create and rename.

    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 is SHA-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.
    • An hourly sweep re-queues signer runs that were lost.
  • Independent verifier (verifyActionRecord, plus scripts/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 not signed. 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:key for the signing key.

  • Compatibility: vcProof/vcProofs keep 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-records is added for read-only access.

Limitations

  • Key binding: users' did:webvh documents 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.md documents this.
  • Not exercised: live Turnkey signing and a Convex deploy. The schema adds a table and a cron, and the API types were registered by hand as in e1d3593. E2E/native runs rely on CI.
  • Lifetime: records are deleted with their item, list or account, the same as vcProofs today. They are not a permanent audit log.

Verification

  • bun test: 698 pass, 0 fail. This includes 16 new tests covering:

    • canonical determinism;
    • single vs batch equivalence;
    • a session vs two different API keys;
    • signing with a stubbed Turnkey client backed by a real Ed25519 key;
    • tampering with the payload, signature, key and binding;
    • retry → failed;
    • no sub-org → unsigned;
    • idempotent re-signing;
    • recurring and template flows.

    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 and vite build: clean.

🤖 Generated with Claude Code

brianorwhatever and others added 4 commits October 5, 2026 02:44
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>
@railway-app

railway-app Bot commented Oct 5, 2026 •

Copy link
Copy Markdown

🚅 Deployed to the boop-pr-277 environment in Friends

Service Status Web Updated
boop ✅ Success (View Logs) Web Oct 5, 2026 at 9:51 am UTC

@pullfrog pullfrog Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ℹ️ 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: recordActions in convex/lib/actionRecords.ts replaces the vcProof/vcProofs placeholder writers in addItem, checkItem/uncheckItem, both batch variants, createList, copyList, renameList and createListFromTemplate.
  • Canonical payload and verifier: shared/actionRecord.ts has the RFC 8785 canonicalizer, the domain-separated 64-byte signing input, base58/Multikey codecs and verifyActionRecord, 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: settle uses 1/5/25 minute backoff with 4 attempts. There is also an hourly sweepStalePending cron and an operator-run requeueFailed.
  • Reads and deletion: actor queries plus GET /api/v1/action-records. Records are deleted with their item, list or account.
  • UI: ActionRecordList in ProvenanceInfo.tsx shows 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.ts and convex/turnkeyHelpers.ts import @noble/ed25519 and @noble/hashes, but neither is declared in package.json. They resolve only because @originals/sdk pulls them in transitively. src/lib/webvh.ts already 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.

Pullfrog  | Fix all ➔ | Fix 👍s ➔ | View workflow run | Using 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);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

This branch was successfully deployed

1 active deployment
Friends / boop-pr-277 — 27083a57 Deployed Oct 5, 2026 by railway-app[bot]
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.

[P1] Replace unsigned credential placeholders with consistent signed action records

1 participant