Sloth-ninja makes practice-management links tell the truth

This is a trust-and-controls pass on the links between matters, workspaces and an external practice-management system.

workflowsecurity

Sloth-ninja has cleaned up the awkward moments that make an otherwise useful integration feel unreliable. When the connection is not ready, people now see an honest state rather than links that lead to predictable errors. The experience also handles deleted records, uncertain currencies and the opening view more carefully.

  • Matter links stay hidden until the system can support them.
  • Only workspace owners can remove a workspace link.
  • Matter-to-project links can now be looked up reliably.
  • Rate limits are applied to the individual user where intended, with clearer feedback when a limit is reached.

The team also removed a real client name from public project material and added test coverage around the revised behaviour.

So what Legal-ops teams evaluating practice-management integrations should care because small permission and status details determine whether staff trust the workspace in daily use.

View this fork on GitHub →

Spotted something wrong? Or know the PR text has fresher detail than the writeup above?

Commits in this thread

12 commits from Sloth-ninja/JessicaOSS, oldest first. Source extracted verbatim from the harvested git log.

SHA Subject Author Date
6b52469b fix(I1): stop offering workspace links that cannot work sloth-ninja 2026-08-07 ↗ GitHub
commit body
getLinkForMatter collapsed the "unsupported" sentinel to null, so "this
matter has no workspace yet" and "workspace linking does not exist on this
deployment yet" were indistinguishable by the time they reached the UI. The
detail page therefore drew a "Start workspace" button, and the Matters page a
"Link to a Clio matter" row action, that could only ever answer 409 - the
`matter_workspace_links` migration (20260807_01) is not in the repo at all, so
that is the CURRENT state everywhere, not a hypothetical.

- getLinkForMatter now returns the sentinel; the detail route turns it into an
  explicit `linksUnavailable` flag (and still nulls `link`).
- New `areLinksAvailable(db)` probe, fails OPEN on a transient error (the
  27/07 rule: availability gate, not a destructive op) - the write path's own
  fixed refusal stays the honest backstop.
- The flag rides the LIST payload too: both remaining link affordances live on
  surfaces that never load a matter detail.
- Frontend hides both affordances behind it with honest copy ("Workspace
  linking isn't available yet."); the picker also refuses rather than letting
  a solicitor choose a matter and only then be told.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
a46ca14f fix(I2): exempt /clio-matters from the IP-keyed general limiter sloth-ninja 2026-08-07 ↗ GitHub
commit body
The per-user bucket inside the /clio-matters router was decorative: the
app-level generalLimiter still ran in front of it, keyed by IP, at a shared
300 requests per 15 minutes. The pilot firm NATs its whole office through one
address, so that IP bucket - not the per-user one - was the binding constraint
the moment two solicitors browsed matters, and exhausting it would degrade
every other route for their colleagues.

Exemption is a skip on the prefix (kept where generalLimiter is mounted, so the
JSON body parser and everything after it still apply - mount-order would have
skipped those too). Exact-segment match, not a bare startsWith, so a future
`/clio-matters-anything` cannot inherit the exemption.

Corrects the rationale comments, which claimed matters browsing could exhaust
the "shared research allowance" and take /companies and /legislation down:
/clio-matters was never on researchLimiter - it was on generalLimiter, so the
stated mechanism was wrong even before this change. Fixed in
routes/clioMatters.ts, index.ts, .env.example and the CLAUDE.md registry row.

index.ts retains its pre-existing prettier deviation at makeLimiter (the file
was not prettier-clean at baseline); only the hunks added here are normalised.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
a0c89e55 fix(I3): carry isOwner on the workspace link so Unlink is not offered-then-refused sloth-ninja 2026-08-07 ↗ GitHub
commit body
Unlinking is owner-only and the server enforces it with a 403, but the link
payload carried no ownership signal - so a colleague reading a firm-visible
matter saw an Unlink button and only learned it was not theirs by pressing it.

toLink already had the authorising `checkProjectAccess` result in hand, so
isOwner is derived from that same result and cannot disagree with what the
server will enforce. The two write paths (linkWorkspace, createWorkspaceForMatter)
pass true by construction - both refuse or create for the caller as owner.

The frontend withholds the button rather than disabling it; the handler keeps
its 403 branch as the belt for a payload that went stale under an open page.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
d17de50e chore(i/M13): scrub a real client name from the public repo sloth-ninja 2026-08-07 ↗ GitHub
commit body
"Kyckr" is a real client of the pilot firm and this repository is public. It
appeared 11 times in manageTools.test.ts (as search-query fixtures) and once in
BUILD_LOG's 04/08 entry, where it recorded which client a solicitor was
searching for - a confidentiality leak, not a naming nit.

Test fixtures now use the fictitious "Aldergate". The BUILD_LOG line is redacted
IN PLACE as "[client name redacted 07/08]" rather than rewritten, so the entry
stays honest about what it originally said.

Stated plainly: this scrubs the working tree only. The name remains in git
history (commits predating this one) and would need a history rewrite to remove
- out of scope for a fix wave, flagged to the owner.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2bfa0b57 fix(a/M11): route the limiter's IP fallback through ipKeyGenerator sloth-ninja 2026-08-07 ↗ GitHub
commit body
The custom keyGenerator returned req.ip directly, which express-rate-limit
rejects as ERR_ERL_KEY_GEN_IPV6 - confirmed firing in the route suite, not
theoretical. A raw IPv6 address gives every host in a /64 its own bucket, and a
single client can hold trillions of those, so the fallback limit was bypassable.

Kept the fallback rather than deleting it (it is unreachable today - requireAuth
is mounted ahead of the limiter and 401s first - but "unreachable" is a property
of the current mount order, not a guarantee), and recorded that reachability
argument in the comment. Keys are now namespaced user:/ip: so a user id can
never collide with an address literal.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
d7091d34 refactor(c/M12): one UUID_RE, and put the numeric-id docblock on isClioId sloth-ninja 2026-08-07 ↗ GitHub
commit body
Two defects in the same few lines. The docblock describing numeric Clio ids sat
above UUID_RE, which is neither numeric nor Clio's - the function it documents
(isClioId) had none, so the comment described the wrong constant.

UUID_RE also existed twice, once in the seam and once in the routes, guarding
the same values. Duplicated validation regexes drift, and the copy that drifts
is the one that stops guarding. The seam now exports it and the route imports it.

No behaviour change; the two suites over these files stay green (90 tests).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
300ee296 fix(M5): never guess a currency symbol on a money figure sloth-ninja 2026-08-08 ↗ GitHub
commit body
formatMoney defaulted a missing currency code to GBP, so an amount Clio
reported without a currency rendered as "£1,240.00" - a specific claim about a
figure on a legal bill that nothing supported, and simply wrong on a matter
billed in euros.

An absent or unrecognised code now renders the number bare (still 2dp, so
columns line up), never dressed in a symbol. Same honesty rule the redaction
flags already encode: a value we cannot substantiate is not presented as one we
can. Null stays an em dash.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
a86e6867 fix(f/M4,M1,M7): wire the link lookup; stop blaming Clio for our 429s; treat 404 as gone sloth-ninja 2026-08-08 ↗ GitHub
commit body
Three reviewer items that land in the same files.

(f/M4) GET /clio-matters/links/:projectId now exists, using the getLinkForProject
export that was dead code. Registered in the literal-path block ahead of the
/:matterId routes - declared after them, "links" would be swallowed as a matter
id (asserted by a test). Uuid-guarded in the route as well as the seam, and one
uniform 404 for every kind of "no" (absent / not visible / malformed /
unmigrated) so the endpoint cannot be used to probe which workspaces exist. The
Workspaces-tab badge that would consume it stays deferred.

(M1) A 429 can come from our own per-user bucket, but the server called it "Too
many Clio requests" and the client overwrote it with "Clio is rate-limiting
requests" - both blaming Clio for a limit JessicaOS imposed. The server detail
is now neutral, and the surfaces show the server's own message on 429 with a
neutral fallback, so a real Clio 429 and ours stay distinguishable.

(M7) A 404 on save or delete means the entry is already gone from Clio (deleted
in another window), which makes the list on screen stale - so 404 joins 409/412
in offering "Reload time entries", and a 404 delete drops the row rather than
leaving a ghost the solicitor can retry forever.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
57d58714 docs(j/M10) + fix(M8): document the clioState gates; stop the initial-tab flash sloth-ninja 2026-08-08 ↗ GitHub
commit body
(j/M10) The three clioState gates are deliberately asymmetric - fail-closed for
the link WRITE affordance, fail-open for the READ tabs, and narrowest of all for
the banner, which makes a positive claim about the user's account. That was
three scattered half-comments that read like inconsistency. Kept as they are
(the asymmetry is correct per the 27/07 rule) and explained in one block naming
"unknown" as the value that separates them.

(M8) With no stored preference the page resolved to Workspaces while the Clio
status was still loading, painted that list, then jumped to My matters the
moment the status landed. The tab decision now waits for a definite status, the
default matches the tab bar's existing optimism instead of contradicting it, and
the body renders a skeleton until then - so no wrong list is ever painted.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
005ea7e5 docs(M3): commit the Practice Management spec and state per-viewer workspace semantics sloth-ninja 2026-08-08 ↗ GitHub
commit body
Two things here, and the first was not in the brief.

The spec was NEVER COMMITTED. CLAUDE.md, BUILD_LOG and three source files all
cite docs/PRACTICE_MANAGEMENT_SPEC.md, and every sibling spec (FIRM_LIBRARY,
TABULAR_TEMPLATES, DELETION_GOVERNANCE) is tracked - this one existed only as an
untracked file in the owner's checkout, so the whole train's design document was
one `git clean` from gone and invisible to reviewers. Committed as-is apart from
the sentence below.

(M3) Surfaces section 4 now states the per-viewer semantics explicitly: the
workspace a solicitor sees against a Clio matter is one they can access, so a
colleague's appears only when firm-visible. Two people on one matter seeing
different workspaces (or one and none) is correct, not a sync bug - worth
writing down before someone "fixes" it.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
3cdc1eab docs: BUILD_LOG entry for the Practice Management fix wave sloth-ninja 2026-08-08 ↗ GitHub
commit body
Scope, per-item changes, verification evidence, the contention note, and the
items flagged to the owner (missing 20260807_01 migration; "Kyckr" surviving in
git history; the four reviewer-deferred items).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
9c6cd9f0 Address review B1: do not respell the scrubbed client name in the scrub's own record sloth-ninja 2026-08-08 ↗ GitHub
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

Capture this thread into my fork

Download a single Markdown prompt that tells Claude how to port every commit above into your working tree — adapting paths and structure to match your repo. Run it via claude -p < capture-thread-1031.md from inside the repo you want the changes in.

⬇ Download capture-thread-1031.md