Skip to content

fix(backend): credential issuance fails against a clean install (Draft & Publish id drift) - #118

Closed
arqueon wants to merge 1 commit into
Schroedinger-Hat:mainfrom
arqueon:fix/credential-issuance-revocation-list-draft-publish
Closed

arqueon wants to merge 1 commit into
Schroedinger-Hat:mainfrom
arqueon:fix/credential-issuance-revocation-list-draft-publish

Conversation

@arqueon

@arqueon arqueon commented Sep 13, 2026

Copy link
Copy Markdown

Summary

POST /api/credentials/issue -- the flow documented in this repo's own README -- fails on a fresh database with either:

"Error creating status list credential: Issuer not found"

or, further along the same request:

"1 relation(s) of type api::revocation-list.revocation-list associated with this entity do not exist"

This isn't a config/environment problem -- it reproduces against a clean install with a correctly created and published issuer profile and achievement.

Root cause

Confirmed empirically with strapi console against a real Postgres instance (not just by reading the code). revocation-list has draftAndPublish: true. In Strapi 5, entityService.update() on an already-published Draft & Publish entity does not update that row in place -- it deletes it and creates a new draft/published pair (same documentId, different numeric id):

const upd = await strapi.entityService.update(
  'api::revocation-list.revocation-list', 6, { data: { nextIndex: 1 } }
)
// upd.id === 7, not 6 -- row 6 no longer exists in the database

assignNextIndex() calls exactly this update() and discards the id it returns, handing back only the plain index. issue() (credential.ts) goes on using the pre-update statusList.id when creating the credential -- which, by the time the credential is actually created, may already be a deleted row.

Fix

  • assignNextIndex() now returns { index, statusListId }, using the id entityService.update() actually produced; issue() uses that for the credential's statusList relation instead of the stale id.
  • createStatusListCredential() re-fetches the published copy by documentId, since entityService.create() with publishedAt set can likewise return the draft row's id.
  • getOrCreateActiveListForIssuer()'s existence check now filters on status: 'published', so it can never reuse a draft-only id.
  • The actual root fix: revocation-list's schema now has draftAndPublish: false. A revocation list is mutable operational state (a counter and a bitstring that change on every issuance and revocation) -- not editorial content someone reviews before publishing. It should never have had Draft & Publish enabled. With it off, update() always mutates the same row, and the id-tracking changes above become defense-in-depth rather than strictly necessary.

Why the existing test suite didn't catch this

revocation-list.test.ts's fake strapi.entityService.update() mutates the existing id in place -- it doesn't match Strapi 5's real behavior for a Draft & Publish content type. I updated the two existing assertions for the new return shape, and added a new test with a mock that faithfully reproduces the real update()-replaces-the-row behavior, asserting assignNextIndex() returns an id that actually still resolves (and that the old one doesn't).

Test plan

  • Reproduced the failure against a real Postgres instance via docker-compose up on a clean database (correctly created and published issuer profile + achievement via the admin content-manager API).
  • Applied the fix, wiped the database again, and verified end-to-end: issued a synthetic credential (signed, Ed25519Signature2020), verified it (verified: true), revoked it, and verified again (verified: false, not_revoked).
  • Updated/added unit tests for assignNextIndex's new return shape and the id-drift scenario specifically.

Scope note: I did not audit whether the same entityService.update()-on-a-published-Draft-&-Publish-entity pattern affects other content types elsewhere in the codebase (several others also have draftAndPublish: true, e.g. credential itself, whose own revoke flow updates the credential row). I scoped this fix to what concretely blocks credential issuance, which is the most severe and immediately reproducible instance of the pattern.

🤖 Generated with Claude Code

…t & Publish id drift)

POST /api/credentials/issue -- the flow documented in this repo's own
README -- fails on a fresh database with either:

  "Error creating status list credential: Issuer not found"
  "1 relation(s) of type api::revocation-list.revocation-list associated
   with this entity do not exist"

Root cause, confirmed empirically with `strapi console` against a real
Postgres instance (not just by reading code): `revocation-list` has
draftAndPublish enabled. In Strapi 5, entityService.update() on an
already-published Draft & Publish entity does not update that row in
place -- it deletes it and creates a new draft/published pair (same
documentId, different numeric id):

    const upd = await strapi.entityService.update(
      'api::revocation-list.revocation-list', 6, { data: { nextIndex: 1 } }
    )
    // upd.id === 7, not 6 -- row 6 no longer exists

assignNextIndex() calls exactly this update() and discards the id it
returns, handing back only the plain index. issue() (credential.ts) goes
on using the pre-update statusList.id when creating the credential --
which, by the time the credential is actually created, may already be a
deleted row.

Fix:
- assignNextIndex() now returns { index, statusListId }, using the id
  entityService.update() actually produced, and issue() uses that for
  the credential's `statusList` relation instead of the stale id.
- createStatusListCredential() re-fetches the published copy by
  documentId, since entityService.create() with publishedAt set can
  likewise return the draft row's id.
- getOrCreateActiveListForIssuer()'s existence check now filters on
  status: 'published', so it can never reuse a draft-only id.
- The real fix: revocation-list's schema now has draftAndPublish: false.
  A revocation list is mutable operational state (a counter and a
  bitstring that change on every issuance and revocation), not editorial
  content someone reviews before publishing -- it should never have had
  Draft & Publish enabled. With it off, update() always mutates the same
  row, and the id-tracking changes above become defense in depth rather
  than strictly necessary.

Why the existing test suite didn't catch this: the unit tests' fake
`strapi.entityService.update()` mutates the existing id in place, which
does not match Strapi 5's real behavior for a Draft & Publish content
type. Updated the two existing assertions for the new return shape, and
added a new test with a mock that faithfully reproduces the real
update()-replaces-the-row behavior, asserting assignNextIndex() returns
an id that actually still resolves.

Verified end-to-end against a real, clean install (build from this
branch, Postgres, no seed data reused): created and published an issuer
profile and an achievement via the admin content-manager API, issued a
synthetic credential (signed, Ed25519Signature2020), verified it
(verified: true), revoked it, and verified again (verified: false,
not_revoked). Did not audit whether the same entityService.update()
pattern affects other Draft & Publish content types elsewhere in the
codebase -- scoped this fix to what blocks credential issuance.
@arqueon
arqueon requested a review from TheJoin95 as a code owner September 13, 2026 01:44
@netlify

netlify Bot commented Sep 13, 2026 •

Copy link
Copy Markdown

✅ Deploy Preview for certo canceled.

Name Link
🔨 Latest commit cb6ae34
🔍 Latest deploy log https://app.netlify.com/projects/certo/deploys/6aa5fff4092f3b00082fe41b

arqueon added a commit to arqueon/certo that referenced this pull request Sep 13, 2026
…tial, evidence, endorsement (fork-only)

Every content type in this repo has draftAndPublish: true -- clearly just
the Strapi content-type generator's default, not a deliberate editorial
workflow decision (nobody would want draft review on a signed credential
or on evidence attached to one).

In practice this caused real, reproducible breakage beyond what Schroedinger-Hat#118
fixes for revocation-list specifically:

- entityService.findOne('api::profile.profile', id) can return the
  DRAFT row for a numeric id that only the PUBLISHED row actually has --
  confirmed empirically: created and published a profile (draft id 4,
  published id 8), and findOne(..., 8) returned the row with id 4,
  publishedAt: null.
- Connecting a relation (achievement.creator) from an entity being
  edited in draft context connects to the draft version of the target
  by default; getting it to point at the published version required an
  explicit `status: "published"` on the `connect` payload when using the
  raw content-manager API (the admin UI itself may handle this
  transparently, but this is unreachable from the public REST API these
  content types are meant to serve).

Disabling draftAndPublish removes the entire class of problem: every one
of these content types now only ever has one row per document, so id
lookups and relation connects behave the way a straightforward
create/read/update data model would.

NOT proposed upstream as-is -- this is a real behavior change (removes
the "Publish" step from the admin UI for these content types) that
Schroedinger Hat may have reasons to want, even if it isn't exercised by
this evaluation. Filed as an informational issue instead so maintainers
can decide how they want to address the underlying id-instability
pattern; kept as a fork-only patch for this deployment, where none of
these content types need editorial review before going live.

Verified end-to-end against a fresh database: created an issuer profile
and an achievement with a plain, direct relation set (no more
connect+status workaround needed), issued and signed a credential on the
first attempt, fetched its BitstringStatusListCredential document,
revoked it, and re-verified -- all without a single Draft & Publish
related error.
@Readpato

Copy link
Copy Markdown
Member

Please read through this: https://meatproxy.me/

Not taking time to review this, please avoid opening vibe slop PRs.

Closing.

@Readpato Readpato closed this Sep 13, 2026
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.

2 participants