Files
PaperClipAI/ui
Devin Foley 76f7019bdf feat(server): derive hosted-tenant issue prefixes from the company name and follow renames (#12292)
## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work
> - Every company has an issue prefix. It is the visible half of each
task and case identifier, and a self-hosted company derives it from the
name it was created with
> - A hosted or managed instance does not use the create-company flow.
The trusted-header auth path claims the tenant company instead
> - That path minted the prefix from a hash of the stack id, and it
wrote a placeholder description that nobody chose
> - So a hosted company showed opaque task IDs such as `PC7F2A-14`, and
a rename never changed them
> - This pull request derives the prefix from the company name on that
path too. It re-derives the prefix when the name changes on a managed
instance, and it rewrites the stored issue and case identifiers so
existing tasks follow the rename
> - It also repairs each company that an earlier build claimed. The
repair runs once, on the next authenticated request
> - The benefit is that task IDs on a hosted instance read like the ones
on a self-hosted instance, and they stay correct after a rename

## Linked Issues or Issue Description

No public issue exists. The description below follows
`.github/ISSUE_TEMPLATE/enhancement.yml`.

**What existing behavior does this improve?**

The tenant company claim in `resolveCloudTenantActor`
(`server/src/middleware/auth.ts`) and the company update in
`companyService.update` (`server/src/services/companies.ts`). Both
decide the
`issue_prefix` and the `description` of a company on a hosted or managed
instance.

**Subsystem affected**

`server/` — REST API and orchestration services. One small hint was also
added
in `ui/`.

**Current behavior**

A self-hosted company gets its issue prefix from its name. "Acme
Robotics"
becomes `ACM`, and its tasks read `ACM-14`.

A hosted or managed instance claims the company through the
trusted-header auth
path. That path wrote a different prefix: `"PC"` plus the first four hex
characters of the SHA-256 of the stack id. The same path also wrote a
placeholder description, `"Provisioned by ... for stack <stack id>."`.

The result is a task ID such as `PC7F2A-14`. It says nothing about the
company.
A later rename of the company does not change it, because nothing
re-derives
the prefix after creation.

**Proposed behavior**

The claim path derives the prefix from the company name, exactly as the
create-company flow does. It writes no description.

On a managed instance, a rename re-derives the prefix. The stored issue
and
case identifiers move with it, so `ACM-14` becomes `NOR-14` when "Acme
Robotics" becomes "Northwind Traders". A rename that keeps the same
three-letter
base keeps the current prefix, including any disambiguating suffix.

A self-hosted instance is unchanged. A rename there still keeps the
prefix the
company was created with.

Companies that an earlier build already claimed get a one-time repair on
their
next authenticated request. The repair re-derives the prefix from the
current
name, re-keys the identifiers, and clears the placeholder description.

**Reason and benefit**

A task ID is the primary handle for a task. People type it, paste it
into chat,
and read it in a URL. On a hosted instance that handle was an opaque
hash, and
it disagreed with the company name that the same user chose during
signup. The
name is the only prefix source a hosted user ever supplies, so the
prefix now
follows it.

**Breaking changes**

Yes, on hosted and managed instances only. A company rename now rewrites
the
stored issue and case identifiers. Links that carry an old identifier
stop
resolving after the rename. The company settings page states this before
the
user saves. The one-time repair applies the same rewrite once to
companies that
carry the old hash prefix. Self-hosted behavior does not change.

## What Changed

- Added `server/src/services/issue-prefix.ts`. It holds the prefix
helpers that
used to live inside the `companyService` closure:
`ISSUE_PREFIX_FALLBACK`,
  `deriveIssuePrefixBase`, `issuePrefixSuffixForAttempt`, and
  `isIssuePrefixConflict`. The companies service now imports them.
- Added `pickAvailableIssuePrefix` to that module. It reads the prefixes
in one
base family and returns the first free candidate. A standalone `INSERT`
can
retry on a unique violation, because each failed statement is its own
implicit
transaction. A caller that already holds a transaction cannot, because
the
violation aborts the whole transaction. Such a caller picks first, then
writes.
- Added `rekeyCompanyIssueIdentifiers` to that module. It rewrites the
prefix of
the stored `issues.identifier` and `cases.identifier` values of one
company in
  the caller's transaction, and it returns the two row counts.
- `companyService.update` re-derives the prefix when the name changes on
a
managed instance, re-keys both tables in the same transaction, and
writes a
  `company.updated` activity entry after the commit.
- `resolveCloudTenantActor` claims the company with a name-derived
prefix and a
null description. The claim retries with the next suffix when the prefix
is
  taken.
- `resolveCloudTenantActor` also runs a one-time repair for companies
that carry
the old hash prefix. An exact-match fence on the update lets a
concurrent
rename win. The repair is idempotent, because its guards stop matching
after
  it lands.
- The rename takes a row lock on the company before it compares anything
against
it, and it re-keys from the prefix it reads under that lock. Only patch
and
environment facts gate the lock, so no stale read can steer the
decision. Two
overlapping updates would otherwise leave a company whose prefix
disagrees with
its own identifiers, in either direction: two renames, where the second
re-keys
from a prefix the first already moved; or a rename plus a stale form
that
resubmits the original name, where the second sees an unchanged name,
skips
re-derivation, and restores the old name on top of the first rename's
prefix.
Only a managed instance takes the lock, and only for an update that
carries a
  name.
- Both helpers compare an exact identifier head instead of a LIKE
pattern. A
  stored prefix is data, so it must never be read as a pattern.
- The company settings page shows a hint under the name field on a
managed
  instance: renaming can change the task ID prefix.

## Verification

Automated tests:

```
pnpm --filter @paperclipai/server exec vitest run \
  src/services/issue-prefix.test.ts \
  src/__tests__/companies-service.test.ts \
  src/__tests__/cloud-tenant-company-provisioning.test.ts \
  src/middleware/cloud-tenant-actor.test.ts \
  src/__tests__/auth-session-route.test.ts \
  src/__tests__/cloud-routes.test.ts \
  src/__tests__/cloud-instance.test.ts \
  src/__tests__/company-branding-route.test.ts \
  src/__tests__/company-cloud-floor.test.ts \
  src/__tests__/companies-route-cross-company-authz.test.ts \
  src/__tests__/companies-route-path-guard.test.ts \
  src/__tests__/company-portability.test.ts
pnpm --filter @paperclipai/ui exec vitest run
pnpm --filter @paperclipai/ui typecheck
```

New coverage:

- `server/src/services/issue-prefix.test.ts` covers the derivation, the
suffix
  ladder, the cause-chain walk of the unique-violation detector, and
  `pickAvailableIssuePrefix` against a stubbed select.
- `server/src/__tests__/companies-service.test.ts` covers a managed
rename
against a real Postgres database: the prefix moves, both identifier
tables are
re-keyed, and the activity entry is written. It also covers a same-base
rename,
  a collision that takes the suffixed candidate, a non-name patch, and a
self-hosted rename that leaves the prefix alone. Two more tests drive
the
overlap cases: two concurrent renames of the same company, and a rename
racing
a stale form that resubmits the original name. Both assert that the
surviving
name's base matches the company prefix and that the stored identifiers
sit on
  that prefix.
- `server/src/__tests__/cloud-tenant-company-provisioning.test.ts`
covers the
claim path and the repair against a real Postgres database: a
name-derived
prefix, a null description, a suffixed prefix on collision, the full
repair,
  a second pass that changes nothing, a description-only repair, and an
  operator-written description that the repair leaves alone.
- `ui/src/pages/CompanySettingsRenameHint.test.tsx` covers the hint on a
managed
  instance and its absence on a self-hosted instance.

The `substring` cast in `rekeyCompanyIssueIdentifiers` is load-bearing
and the
database tests prove it. The driver binds the offset as text. Without
the
`::int` cast Postgres resolves the SQL-regex overload of `substring`,
and every
identifier becomes NULL.

## Risks

- **Re-keying changes existing identifiers and URLs.** This is
deliberate, and
it happens on hosted and managed instances only. After a rename, a link
that
carries an old task identifier stops resolving. The settings page warns
about
  this before the user saves.
- **Identifiers inside comment text are not rewritten.** Only the
`identifier`
columns of `issues` and `cases` move. A task ID that someone typed into
a
  comment, a description, or a document keeps the old prefix.
- **A lost prefix race inside the rename transaction surfaces as a
conflict.**
The rename picks a free prefix and then writes, because a unique
violation
inside a transaction aborts the whole transaction. Two *different*
companies
renamed onto the same base at the same moment can still collide. The
loser
sees its PATCH fail with the unique violation. The write is retryable by
the
client, and the window is a single statement wide. Two renames of the
*same*
company no longer race: the row lock serializes them, and the second one
  re-keys from what the first committed.
- **The rename holds a row lock.** A managed rename takes `SELECT ...
FOR UPDATE`
on its own company row for the rest of the transaction. It is one row,
and no
other path in the transaction locks a company row, so there is no
lock-order
cycle. A self-hosted instance and every non-rename company update never
reach
  the lock.
- **The one-time repair is best effort.** It runs inside a try/catch and
logs a
warning on failure, so it never blocks authentication. A failed pass is
retried
  on the next request, because its guards still match.
- No schema change and no migration.

## Model Used

- Provider: Anthropic (Claude)
- Model: Claude Opus, model id `claude-opus-5[1m]`
- Context window: 1M
- Reasoning mode: extended thinking
- Capabilities used: agentic tool use through Claude Code (file edits,
shell,
  test runs against an embedded Postgres database)

## Checklist

- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used (with version and capability
details)
- [x] I have checked ROADMAP.md and confirmed this PR does not duplicate
planned core work
- [x] I have searched GitHub for duplicate or related PRs and linked
them above
- [x] I have either (a) linked existing issues with `Fixes: #` / `Closes
#` / `Refs #` OR (b) described the issue in-PR following the relevant
issue template
- [x] I have not referenced internal/instance-local Paperclip issues or
links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip`
URLs)
- [x] My branch name describes the change (e.g. `docs/...`, `fix/...`)
and contains no internal Paperclip ticket id or instance-derived details
- [x] I have run tests locally and they pass
- [x] I have added or updated tests where applicable
- [x] I have updated relevant documentation to reflect my changes
- [x] I have considered and documented any risks above
- [ ] All Paperclip CI gates are green
- [ ] Greptile is 5/5 with no open P2s, recommendations, or follow-ups
- [x] I will address all Greptile and reviewer comments before
requesting merge
2026-08-27 11:34:42 -07:00
..
…

@paperclipai/ui

Published static assets for the Paperclip board UI.

What gets published

The npm package contains the production build under dist/. It does not ship the UI source tree or workspace-only dependencies.

Storybook

Storybook config, stories, and fixtures live under ui/storybook/.

pnpm --filter @paperclipai/ui storybook
pnpm --filter @paperclipai/ui build-storybook

Typical use

Install the package, then serve or copy the built files from node_modules/@paperclipai/ui/dist.