Skip to content

Latest commit

 

History

History
1358 lines (936 loc) · 162 KB

File metadata and controls

1358 lines (936 loc) · 162 KB

Changelog

All notable changes to this project will be documented in this file.

The format is based on Keep a Changelog, and this project adheres to Semantic Versioning.

0.23.0 - 2026-09-07

Added

  • A record in the atmosphere says who wrote it. These live in their authors' own repositories, which is the whole point of the tab, and a card that never named one read as though Chive had written it. Each now carries the account's avatar, display name and handle on its own line. The handle, not the DID: the DID identifies an account without naming it.

  • Semble collections and their membership are indexed. Semble draws a card inside a collection, which is where a reader actually sees it, and network.cosmik.collectionLink is the record that puts it there. Chive had never read that record: it indexed collectionLinkRemoval, the tombstone an owner writes to remove a collaborator's link, and so watched cards being taken out of collections it had never seen them put into. Both the collection and the link are now indexed, and a Semble card on an eprint names the collection holding it and links there.

  • Semble cards link to Semble. A card's address on Semble is /profile/{did}/cards/{rkey}, which today renders "Card page -- coming soon!"; it is the canonical address and will start working when Semble builds the page. A connection has no address at all -- /connections/{rkey} is a genuine "Page not found" -- so it is offered none. Both were established in a browser rather than by reading server HTML: Semble renders on the client, so its server response is the same shell whatever the route, and an earlier reading of that shell had concluded there was no card page.

  • A connection names the paper at its other end. A Cosmik connection is an edge between two entities; Chive stored the note and the relation and dropped both endpoints, so a card said what its author thought about a relationship without ever naming the other half of it. Where that half is an eprint Chive holds, it is shown by title and linked; where it is a DOI or a catalogue page, by its address. The title is resolved at read time rather than stored, so a retitled paper is named correctly everywhere without reindexing.

  • Co-citation and bibliographic coupling reach related work. Both were implemented and neither was switched on.

  • Margin annotations link back to Margin. Its permalink addresses a note by its author's handle and answers "Not found" to the DID form, which is all an AT-URI carries, so the link could not be built. Backlink listings now resolve source handles through the existing batched, cached profile hydrator.

Changed

  • A repository on the Code tab is described as fully as Chive can describe it. A declared code link and a fetched integration for the same repository were two separate renderings of one thing; they are joined on the normalised URL and drawn as the richer card.

  • The atmosphere counts what it draws. The panel's corner badge came from the counts endpoint while the list came from the rows, and the two are maintained separately, so a panel could show one number directly above a list of a different length. The tab keeps the total; the panel's own filter row is the count. Its loading skeleton also said "Backlinks", so the section renamed itself as it finished loading.

  • Each service is drawn with its own mark. Leaflet, Semble, Margin, standard.site and Chive publish vectors; where a service publishes none, the card keeps a generic glyph and the service's name, because a generic glyph identifies nothing on its own.

Fixed

  • A typed field was rendered as the opening words of a title. A Margin motivation and a Cosmik relation were prefixed onto the text a reader sees, so a card read "commenting: The gradable adjective case is..." -- structured data presented as the start of a sentence. They are drawn as chips, and in prose casing, since the lexicons disagree about how to spell an enum: Margin writes commenting, Cosmik writes RELATED, a Chive relation slug is builds-on, and rendered as written three chips in one list were in three different cases.

  • A title and a description were joined with a colon. A Leaflet document and a standard.site document each carry both, and run together they read as one run-on sentence.

  • A connection naming a paper by its Chive web address was indexed under a key nothing could look up. That is how Semble writes an endpoint -- the address its author was looking at -- so the row existed, the paper never showed it, and backlink.list would not accept the URL as a target either. Endpoints are resolved to eprint AT-URIs before being recorded.

  • Chive's own standard.site document is no longer listed as a reference to the paper. That document is the eprint, published outward; it is not something on the network pointing at it.

  • A collection owner's link removal is applied, not merely announced. The plugin emitted an event with nothing subscribed to it, so a card an owner removed stayed indexed as a member of the collection, and the tombstone table the migration created for it stayed empty. The tombstone is kept too, because deleting it is how an owner undoes a removal and a deletion event carries only the tombstone's own URI.

  • The record URI is gone from atmosphere cards, where a "View record" link already goes to the same place.

0.22.2 - 2026-09-06

Fixed

  • Citation arrows ran through the papers and overshot them. Every edge was routed between React Flow handles, and a handle is fixed to one side of its node -- with the target handle at the top, an edge whose cited paper sat above its citing one had to travel up through that paper's body to reach its top edge, crossing the pill and leaving the arrowhead inside it. Edges now compute their own endpoints from the two node outlines, so each meets whichever point faces the other. Nothing overshoots, because the endpoint is the boundary, and nothing enters a node, because the segment stops there. The zIndex in 0.22.1 had treated a symptom of this rather than its cause.

  • The line showed through the arrowhead. The fade on an edge was element opacity, which applies to the path and the marker alike, so where the line ran under the arrowhead the two composited and the line read as a darker streak through it. The fade is carried in the stroke colour now, and the arrowhead takes exactly that colour.

Changed

  • A paper more than one citation away is drawn as a dot, with no label. At fifty papers the names were the clutter, and at five hundred they would be unreadable; the ones worth reading are those beside the paper in hand or beside whatever the reader has clicked. The rest are the shape of the field, which a dot conveys better than a name nobody is reading. Hovering still gives any paper its full bibliography entry, and a dot grows into a labelled pill the moment it attaches to the focus or the selection.

0.22.1 - 2026-09-06

Fixed

  • Citation arrows were drawn on top of the papers. Lit edges carried an explicit zIndex, which lifts an edge out of the layer React Flow draws beneath the nodes, so every highlighted arrow crossed the pills it ran between and cut through their labels. They keep their place above the faded background edges by ordering within that layer instead, which is where the distinction belonged.

0.22.0 - 2026-09-05

Added

  • A citation network that is a network. The graph on an eprint's network page fetched one paper's citing and cited lists, stacked them in two columns, and had no way to draw anything else — so it could only ever show a star. It could not show that two of a paper's citers also cite each other, which is the one thing a network picture exists for.

    pub.chive.discovery.getCitationNetwork reads the citation graph as a graph. The whole Chive-to-Chive network arrives in one request, laid out by simulation so that proximity means something, and a reader can zoom out from the paper they came for to see where it sits among the rest. The focused paper's own edges are read before anyone else's, so a network cut short at the limit still contains the paper it was asked about — the one guarantee a client cannot recover for itself.

    Colour carries two things at once. What a paper is to the paper being read against: mustard for that paper, red for the papers it cites, blue for the papers citing it. And which paper that was: the paper a reader arrived on stays solid however far they wander, while a paper they click lights its own neighbourhood in the same three hues a shade paler. Clicking around the graph never repaints where they started, which is what makes it explorable rather than disorienting.

    Hovering a node gives the bibliography entry — every author named, year, venue, DOI — because the nodes themselves carry only a surname and a year. The old ones carried a truncated title and two badges, which is why four fitted on a screen and four hundred could not.

Fixed

  • The citation graph had no arrows, and no lines either. Every edge was styled stroke: var(--green-500), a variable this application has never defined, so the browser drew nothing between the boxes at all: no line, no arrowhead, no direction. A citation without direction is not a citation — "A cites B" and "B cites A" were the same absent line. The network now draws from a fixed palette a stylesheet cannot silently withdraw, with an arrowhead on every edge in the edge's own colour, and the styling has moved out of the component into pure functions that a test can hold.

  • Two of the links out of the atmosphere panel went nowhere. Carried over from the same pass: a network.cosmik.card was linked as though it were a Cosmik collection, which 404s, and every Leaflet reference was linked as a document because the indexing plugin files comments under the same source type.

  • An eprint reference could not be named in a bibliography. eprintRef carried title, authors, year and venue but not the DOI, so a hover card built from it was a citation with the identifier missing.

0.21.0 - 2026-09-05

Added

  • An Atmosphere tab, holding everything the network has said about a paper. References from elsewhere on the network used to sit at the bottom of the Citations tab, under a heading called "Backlinks", as a stack of collapsed accordions that fetched nothing until a reader thought to open one. The section names came from the counts endpoint, which buckets five ways — so a talk, a standard.site document, a Margin annotation and a Cosmik connection all hid behind a heading reading "Other Sources" that named none of them. They now have a tab of their own, loaded at once, filtered by the application that published each record, and the tab says how many there are before it is opened. Where a paper has none, it says so rather than leaving a blank tab, because an empty panel cannot be told from one that failed to load.

Changed

  • Every link on an eprint page renders through one card. Code, data, materials, external identifiers, linked resources and atmosphere references each had their own layout: a tinted icon and a platform badge on one tab, a flat row with a monospaced value on another, a small grey icon and a line of context on a third. None of the differences carried meaning — a reader learns nothing from a DOI being styled unlike a repository — and the richest of them, the GitHub card, was the one a reader saw least often. That card is now the shape all of them take, and each fills it with whatever detail its own source can give.

    The detail itself is the point. A card headed "GitHub" now reads chive-pub/chive beneath it, and one headed "arXiv" leads with the identifier: the address was in the record all along and simply was not shown. Zenodo reports its deposit type, access, version, views and downloads; Software Heritage says what is archived and when it was last seen; an atmosphere reference says which application published it, what kind of record it is, when it appeared, and what it said.

Fixed

  • Two link destinations on the atmosphere panel were wrong, and most were missing. A network.cosmik.card was linked to cosmik.network/collection/{did}/{rkey}, which 404s — a card is not a collection. Every Leaflet reference was linked as a document, comments included, because the plugin that indexes them files both under one source type. And for a talk, a standard.site document or a Margin annotation, no link was offered at all.

    Links are now built from the collection NSID carried in the record's own URI, which cannot disagree with the record, rather than from Chive's coarser classification. An application's own address is offered only where the route was checked against a live record — Smoke Signal and Bluesky, verified; Cosmik's dropped rather than shipped wrong. Alongside it every reference now carries a link to the record itself in a public record browser, which resolves any AT-URI by reading it from the repository that holds it. That one works for every source type, including the ones no application on the network renders yet.

  • A repository the author named but gave no address for vanished, while the tab still counted it. A Code tab reading "2" above one card reads as a fault in Chive rather than as an incomplete record. The card renders and says the record carries no address.

  • The supplementary materials count ignored the datasets it was displaying. It counted uploaded files only, so a paper whose auxiliary material is a dataset linked on Layers showed a badge reading "0" above the card for it.

0.20.2 - 2026-09-05

Fixed

  • A paper shared from Chive carried the generic Chive card. The share dialog uploads the OpenGraph image as a post thumbnail rather than letting Bluesky refetch the page, and the url it fetched was hardcoded to ?type=default — so the same link pasted straight into Bluesky showed the paper's own card while sharing it from the paper's page did not. Both now build the url through one helper, which is also what stops them drifting apart again.

  • A record referencing several papers backlinked only one of them. backlinks was unique on source_uri alone, while the plugin that writes them emits one row per referenced eprint — so each write overwrote the last and every paper but the final one silently lost its backlink. A Cosmik connection names two eprints by definition and showed on only one; an essay citing three showed on one. The identity of a backlink is the pair, and it is now keyed that way.

0.20.1 - 2026-09-04

Fixed

  • A repository referenced by AT-URI was labelled "Layers" whatever it was. Any at:// reference on the Code tab took the Layers icon and name, so a Tangled repository — the one case the feature exists for — displayed as a Layers dataset. Layers datasets are now identified by the kind of record their URI names rather than by being an AT-URI at all, which also keeps the entries written before a platform was recorded rendering correctly: they carry platform: "other" and were relying on that same forcing.

  • The governance admin dashboard was unreachable for every administrator. getEditorStatus returned a 500, and the frontend reads the caller's role from it — a failed request leaves the role undefined, which renders as "Access Restricted". The cause was one column: governance_roles is LEFT JOINed, so granted_by comes back as null for anyone never granted a role, and the lexicon declares roleGrantedBy an optional string. Passing null through failed output validation. displayName beside it already had ?? undefined and roleGrantedAt was reached through optional chaining; this one had neither. Since holding no stored role is the default for every user, platform administrators included, it failed for everyone.

  • New methods were callable by nobody. The feed, the two notification queries, and starting a claim from an external source were absent from every OAuth permission set, so a PDS refused to mint a service-auth token for them and the browser saw a 401. Readers must re-authorise to pick the grants up. The external-claim method had been missing since before this release.

  • The feed filters were named after the query rather than the reader. Seven checkboxes distinguishing reviews of an author's papers from reviews they write, where five suffice: Papers, Reviews, Endorsements, Annotations, Citations. Each group covers the event types it implies.

  • A two-line entry in the Discover menu pushed its row out of line with the rest.

Changed

  • An eprint page names the publication its document belongs to, and an author page names the publication it hosts. Groundwork rather than a working feature: an enhanced Bluesky link card needs both of those links and a /.well-known/site.standard.publication endpoint on the publication's domain, which Chive does not serve. Until it does, no subscribe control appears on a link card. The endpoint returns one AT-URI per domain, so a publication per author under a single chive.pub cannot be verified as it stands — that is a design decision still to be made, not an oversight in these tags.

0.20.0 - 2026-09-04

Added

  • One feed for everything you follow. Following an author creates a collection, so "everything I follow" and "every collection I hold" are the same question asked at different widths. /feed asks it, with a scope control for the width: only the collections created by following someone, every collection you own, collections other people own that you follow, or all of it. The activity checkboxes from a subscription apply here too, so a reader who wants new papers and nothing else can say so once.

    Deduplication happens in the database rather than the browser. A reader who follows the same author from three collections gets three person nodes and so three copies of every event; the feed collapses them into one row that names all three collections. Merging pages in the client could not do that — it cannot collapse two rows that are the same event, and a cursor over merged pages is not a cursor over any one of them. The engine that does this is the collection feed itself, now given a set of collections instead of one, so there is no second implementation to drift.

  • An author is told when someone follows them or collects one of their papers. Two queries computed from the index rather than a stored notifications table, following the pattern the existing review and endorsement notifications already use, and shown on the notifications dashboard beside them. Neither takes a DID from its parameters: the answer is about whoever is signed in, so reading someone else's is not expressible.

Fixed

  • The standard.site social graph indexed nothing, ever. Subscriptions, publications and recommendations were all read off the firehose and all silently discarded: standard_site_subscriptions, standard_site_publications and standard_site_recommendations were empty in production, and a publication written while the indexer was live and fifteen seconds behind the firehose never appeared.

    loadBuiltinPlugin(plugin, services) hands those services to the context factory as config, so a plugin finds them at context.config.x. This one read context.x, got undefined, and returned at the guard meant for a genuinely absent service — before subscribing to a single event. It loaded, reported healthy, and did nothing. The mistake typechecked because the old code asserted the property onto the context with a cast rather than reading where it lives, which is the reason the fix comes with a test that loads the plugin the way the loader really loads it and asserts it subscribes.

    Until now this made subscriberCount permanently zero and meant the site.standard.graph.subscription record was never written when following an author, since that write is gated on the author holding a publication the index could see.

  • Importing papers failed with a 500. getSuggestions died on s.toLowerCase is not a function while scoring external results. The OpenReview plugin declared content.authors.value as string[]; the API does not honour that, and a search for a common name returns collaboration papers whose author list is thousands of {fullname, username} objects. Author entries are now normalised where they enter, the type no longer claims something untrue, and the scoring path guards the other external sources — one malformed author costs that paper its score rather than blanking the list. The ten seconds before the failure was the plugin search budget, not the cause.

  • An author could not see they had followers. The count was computed and rendered nowhere, so the follow control said nothing about who was on the other side.

  • An eprint's link card carried no subscribe control. A standard.site consumer finds the document by fetching the page and reading at:canonical from its head; Chive emitted only a link rel="alternate" naming the eprint record, which is not a document and resolves to nothing. The page now names its site.standard.document, and since the document names the author's publication, that is all a consumer needs to reach both. Papers submitted without cross-platform discovery have no document, and the remaining head tags are emitted regardless.

  • Three event types were invisible in the feed UI. Reviews, endorsements, and annotations on a tracked author's papers arrived from the server but fell through every icon, label, and attribution switch to the generic "Activity". They now read as what they are, attributed as "On a paper by".

0.19.0 - 2026-09-04

Added

  • Following an author is a collection. Following someone creates a collection in the follower's own repository holding exactly one item — that person — and the activity feed collections already have does the rest. Nothing about it is a separate mechanism: the collection appears in their library under the author's name, and it can be opened, renamed, extended with a second author, or deleted like any other. The control says so before it writes anything, because a collection that appeared in someone's library unannounced would read as a bug rather than as the feature.

    This also lifts a limit the earlier design carried. Following used to require the author to hold a standard.site publication, which meant the control was hidden for the great majority of authors. A collection needs nothing from the person being followed, so anyone can be followed now.

  • A follower chooses which activity reaches them. Seven kinds, each a checkbox: their new papers, reviews and endorsements and annotations of their papers, reviews they write, endorsements they give, and papers that cite them. New papers and the responses to them are on by default; a feed that turns everything on by default is a feed people mute. The choice is stored on the collection record in the follower's own repository, not in Chive — a preference the AppView owned would be a preference that could not be rebuilt from the firehose.

  • A publication, which is what a reader subscribes to across the ecosystem. It carries the name shown on link cards and lives in the author's own repository, so Chive can neither create nor rename one on their behalf — hence a settings panel rather than a stored setting. Its url is not editable: it is what a publication is matched on and what existing subscriptions were written against, so changing it would orphan every subscriber. When an author holds one, following them writes a site.standard.graph.subscription record alongside the collection, so the follow is legible to the wider ecosystem. That write is best effort; the collection is the subscription, and a standard.site that cannot be reached must not read as a failed follow.

  • A way to publish an eprint's standard.site document after the fact. The submission wizard offers to write one and an author may decline; every paper submitted before that offer existed has none. Both leave a paper readers outside Chive cannot find, with resubmission the only way back. Both eprint editors now carry the control, which also offers to re-point a document that names a bare url — the form the schema reserves for loose documents, and one no reader can subscribe to the author from.

Changed

  • A collection feed can be asked for only certain kinds of event. pub.chive.collection.getFeed takes a types parameter. Branches that were not asked for are not emitted at all rather than filtered after the fact, so each branch a caller did ask for keeps its full row budget instead of spending it on rows that would be discarded. This is what a subscription's activity choice is built on, and it is available to every collection feed.

  • A collection holding a person now surfaces what other people do with that person's work. The feed's review, endorsement, and annotation branches fired only for eprints held in the collection directly, so a collection tracking an author showed their output and nothing anyone said about it. Three branches now reach through authorship. The author's own activity is excluded from them, since it already arrives as their review or their endorsement, and the same record emitted under two type names is a record the feed's deduplication does not collapse.

  • An eprint's standard.site document names its author's publication. It named the site root, which the schema keeps for loose documents. An eprint is not loose: naming the publication is what puts a subscribe affordance on a link card anywhere in the ecosystem. The publication is keyed on the author's DID rather than on a handle or a display name, either of which they can change — and either of which would mint a second publication and split its subscribers.

Fixed

  • "Open the citation network" led to an error page. There was no citation route. The link resolved against the eprint page's catch-all, which read the trailing citations segment as part of the AT-URI and asked the API to load an eprint whose uri ended in /citations. The view now has a route of its own, and the graph it renders had until now been built but never reachable from anywhere.

  • Citations named one author and cut the rest. A citation now names all of its authors when there are one or two and the first followed by "et al." when there are more, rather than only ever the first. Surnames survive inverted forms, particles, and suffixes, and a name that is a single word is printed whole instead of being presented as a surname it is not. The em-dash between the byline and the title is gone and the title is set in italics.

  • The site did not fit a phone. The published banner squeezed its label and its link into one row until neither was readable; it stacks now. Chips broke inside their own pill rather than wrapping between pills, and long labels truncate instead of forcing the row wider. The eprint and author page toolbars wrap rather than crushing the name beside them, dialogs and wide popovers are capped to the viewport, and the search, field, collection and dashboard pages no longer outgrow a narrow screen. Row controls that only appeared on hover are now visible on touch screens, where nothing ever hovers.

0.18.3 - 2026-09-04

Fixed

  • The citation network listed papers by AT-URI. Two surfaces draw citations — the network graph and the summary list on an eprint's Network tab — and each had its own rendering. The graph was taught to name papers; the list kept printing at://did:plc:.../pub.chive.eprint.submission/... at every row. The labeller is now one module both import, since a copy in each is how one comes to read correctly while the other does not.

  • A paper announced two different reference counts. The section headers counted the rows fetched under a page limit while the panel header counted the rows that exist, so the same paper read "11 references" and "References (5)" one line apart.

  • Only the first page of citations was shown. A network listing five of eleven references omits exactly the ones a reader went looking for. Every page is now collected — the API caps a page at 100, so the cursor is followed until it runs out, with a guard against a cursor that never advances — and all of them are rendered.

0.18.2 - 2026-09-03

Fixed

  • Citation nodes showed 1572 where they meant 2016. published_version.publishedAt is stored as epoch milliseconds in a text field — every record that has it, and none as an ISO date — and the year was read as the first four characters of that string, giving the leading digits of the timestamp. Close enough to a year to pass review and render on a node. The unit test supplied a row with the year already extracted, so the query itself was never exercised; it now asserts the SQL.

  • The share card for the site itself had no image. The metadata named /og; the generator lives at /api/og. Nothing fails visibly when an unfurl asks for an image that is not there — the card simply renders without one — and eprint pages looked correct throughout, because they build their path from the generator rather than from that literal.

Fixed

  • A backlink from a pasted link pointed at nothing. A reference to a paper arrives written the way its source writes it: someone citing a paper in an essay pastes https://chive.pub/eprints/..., a Cosmik card is a link card, a Margin annotation targets a page. Only a machine writes the AT-URI. The test for "is this an eprint" was a substring check that a percent-encoded address also passes, so the address was accepted and then recorded verbatim — filing the backlink under a string no eprint can be looked up by. The row was written, nothing raised an error, and it rendered nowhere.

    Every reference is now resolved to the eprint's AT-URI before it is recorded, in the Leaflet, Cosmik, Margin and calendar plugins. The resolver is one implementation shared by all of them rather than a copy each, since a copy per plugin is how one comes to accept a form the others reject. Two tests asserted the old behaviour outright and now assert the AT-URI.

Added

  • A dataset can be referenced by its record, not by a URL it does not have. A dataset published on Layers has no web address — its pub.layers.catalog.collection record is the durable identifier — so its at-uri was going into repositories.data[].url, a field declared format: uri and described "Repository URL". It rendered correctly, but the field means something else, and the code-repository shape has had a recordUri for exactly this since it was written. The data shape now has one too. Both are read, the dedicated field first, so records written under the old placement keep working.

Fixed

  • Linking a Layers dataset never worked, in either direction. The write asked the author's PDS to validate pub.layers.eprint.dataLink, and _lexicon.layers.pub publishes no TXT record at all, so the NSID cannot be resolved and the PDS rejected the record outright — the link was silently never made, which is why no repository holds one. The read asked a Layers AppView that is still in development and answers nothing, so the panel was empty regardless.

    Neither was necessary. These records are written by the submitting author into that author's own repository: the eprint's AT-URI names the author, the DID document names their PDS, and the records are one listRecords away. Chive reads them from there, and asks no AppView. The cost is stated rather than hidden — only links the eprint's own author wrote are found, and a third party linking their dataset to someone else's paper stays invisible until Layers publishes an index that can be asked the reverse question.

0.18.1 - 2026-09-03

Fixed

  • A citation network of anonymous boxes. The graph stores only URIs on its nodes, so an edge read back from it carried nothing a reader could recognise, and every node rendered as "Citing paper 1", "Citing paper 2" — a network that is technically correct and tells a reader nothing. pub.chive.discovery.getCitations now returns the papers at both ends of the edges it reports, named the way a paper is cited: first author, year, title, and the venue when known. They come back as a lookup rather than as metadata repeated on each edge, since one paper commonly sits at the end of many of them, and they are resolved in a single query rather than one per edge.

Fixed

  • A quarter of the corpus had a PDF and no references at all. Citation extraction runs once, when an eprint is indexed, and degrades gracefully when GROBID cannot be reached — correctly, since one unreachable service must not fail an indexing run. But the degradation left no trace, and nothing retried: an eprint whose extraction failed was indistinguishable from one whose references had been read and found to be none, because both have no rows. On production this was 18 of 66 eprints, including papers with 141 references apiece, and the only symptom was a citation network smaller than it should be.

    Every attempt is now recorded, so a failure is visible as a failure. The deploy retries the eprints never successfully processed, selecting on that record rather than on the absence of citations — a paper genuinely without matchable references is not put through GROBID again on every deploy. Backfilling production took extracted citations from 2,468 to 3,816 and cross-citation matches from 131 to 173.

Fixed

  • A matched citation whose edge was never written stayed invisible forever. The re-match considers only citations with no match yet, which is right for matching and wrong for edges: every citation matched while the graph had no eprint nodes to attach to was left settled, matched, and unconnected, and nothing revisited it. On production that was all 131 of them. The deploy now writes the edges implied by matched citations before re-matching the rest; both the node and edge writes are merges, so it costs a no-op once the graph is current.

  • Staging never ran the citation steps. They existed only in the production deploy, so staging's graph stayed empty whatever the code did and the first place to find that out was production. Staging now runs the same labelling and re-match.

0.18.0 - 2026-09-03

Added

  • A citation graph with nodes in it. Every eprint reached PostgreSQL and Elasticsearch, while the graph gained a node for one only if a reader happened to interact with it — so there were no eprint nodes, and no CITES edges could exist. Citation edges match their endpoints rather than creating them, deliberately: an edge must never assert a paper Chive does not hold. But nothing was supplying the nodes that guard depends on, and a MATCH that matches nothing writes nothing and raises nothing, so every edge was dropped in silence while the matches accumulated in PostgreSQL. Callers holding the eprint index now supply the nodes, re-checking each URI against that index rather than trusting it, and a script labels the label-less nodes two other writers had already left in the graph.

Fixed

  • Citation matching used two of the fields GROBID gives it and ignored the rest. A reference was resolved by DOI and then by exact title, and nothing else — while the authors, year, venue, volume and pages extracted alongside them sat unread in the table, and the arXiv id was never extracted at all, so its column had been null for every citation ever stored. The matcher now works strongest identifier first: DOI, arXiv id, exact title, then a near title accepted only when an author surname or the year corroborates it. A near match with nothing to corroborate it is left unmatched, because a wrong edge in a citation graph is worse than a missing one.

    Three things were quietly defeating the exact comparison. Titles arrive with the citation's own furniture attached — 2023a., press. — which is now stripped. DOIs arrive as URLs, with a sentence's punctuation attached, or as the tail .org/10.… of a URL whose front was lost, which is now normalised. And the SQL normalisation collapsed no whitespace where the TypeScript collapsed it, so the two disagreed about what "exact" meant; they are now written once and shared. On the production corpus the stripping alone takes exact title matches from 26 to 28 before the near-title pass runs at all.

    The re-match carried its own copy of the old DOI-then-title logic, so none of this would have reached the pass that actually runs on deploy. It delegates now, and a test holds it to that.

  • No eprint could publish a standard.site document. The write asked the author's PDS to validate a foreign lexicon, and a PDS that cannot resolve one rejects the record outright: Unknown lexicon type: site.standard.document. Every attempt failed, which is why no eprint had one. The record was never at fault -- site.standard.document is published, and the shape Chive writes satisfies it -- so validation is now left to this codebase and its tests, here and for the two sifa writers, which had the same latent fault.

  • A preview of an eprint looked like a placeholder. The card was given a title and one author name and drew them on an otherwise empty 1200x630 field, while the record's abstract, full author list, venue and keywords went unused. A preview reads as a paper when it carries the paper's own substance, so all of it is passed and laid out: the abstract fills the card the way a document's text fills Leaflet's, unreadable at thumbnail size and not meant to be read there. A record with none of it centres its title rather than stranding it against the top edge.

0.17.1 - 2026-09-03

Fixed

  • The deploy's citation re-matching never ran. rematch-citations.ts is executed by the deploy as its own node dist/... process, outside the server entry points that install the reflect polyfill, and tsyringe throws at module load without it. So the step failed on every deploy — after the reindex, which meant the reindex's work stood but the citation graph was never refreshed. The polyfill is now installed by every script the deploy runs directly, and a test reads the workflow itself so the list cannot drift from what is actually executed.

0.17.0 - 2026-09-02

Added

  • A linked dataset can now be the dataset, not just one corpus inside it. Layers 0.9.0 added catalogRef to pub.layers.eprint.dataLink, pointing at the pub.layers.catalog.collection that is a dataset's citable artifact. Chive read the earlier schema and passed through only corpusRef, which names a corpus — one record type within a dataset, and one that many datasets do not have at all. MegaAcceptability, for instance, is expressions and judgment sets with no corpus account, so no link to it could name anything Chive would render. Chive now carries catalogRef and experimentRefs alongside corpusRef, and the eprint page prefers the collection, because that is what a reader means by "the dataset".

    The Python snippet follows the same distinction: a collection is loaded with lairs.data.collection.load_collection and a corpus with load_corpus. Emitting the corpus loader for a dataset that has no corpus would hand the reader a call with nothing to call it on. The three data kinds 0.9.0 added — experiment, judgments, dataset — are labelled rather than shown as raw slugs.

Fixed

  • Half the backlink integrations received no records at all. The relay decides what a consumer is sent; the local event filter can only narrow that stream, never widen it. The indexer admitted every observed foreign collection locally but passed no filter to the relay, so the consumer fell back on a hardcoded namespace list of its own that named only Chive, cosmik and margin. Leaflet, standard.site, Bluesky and calendar records were dropped upstream and never entered the process — the plugins loaded, subscribed and were never called, and cosmik worked only because it happened to appear in that list. The subscription is now always built from the observed set, the fallback names no foreign namespace so it cannot diverge from that set again, and a test asserts every observed collection is requested from the relay.

  • A backlink write abandoned the rest of the record. BacklinkTrackingPlugin emits backlink.created and backlink.deleted after each write, and the plugin bus enforces emit permission against the plugin's own manifest. No backlink plugin declared those two hooks, so every write threw immediately after succeeding: the first reference on a record was stored, its remaining references were skipped, and the failure surfaced only as a warning. All seven plugins now declare what they emit, and a test holds each manifest to the hooks its base class raises.

  • Every endpoint that returned a row with a bigint id answered 500. PostgreSQL bigint arrives from node-postgres as a string, because a value past 2^53 cannot survive a JavaScript number. Passed through unconverted it failed output validation against lexicons that declare an integer, so pub.chive.backlink.list, pub.chive.import.search and the claiming queries failed for exactly the rows they existed to return, and looked healthy whenever the result was empty. Every fixture in their tests carried a JavaScript number, so nothing exercised the conversion.

    The ids are converted where each row is mapped, and the tests now use the string the driver really returns. This is why no backlink had ever been observed through the API even where the pipeline had written one: a backlink appeared 28 milliseconds after its record reached the firehose, and asking for it returned an error.

  • A dataset linked by AT-URI rendered as a link that did nothing. A dataset published on Layers has no web address to give: its web routing is not settled, and the record is the durable identifier. So the AT-URI is stored in repositories.data[].url, a field declared as a URI and rendered as an anchor — which produced a link a browser cannot follow, wearing an icon that promised it opened somewhere, badged "Other". Chive now tells an ATProto record reference from a web URL and renders it as what it is: the dataset's name, its URI, an attribution to Layers, and the lairs call that loads it. Ordinary web repositories are unchanged.

  • An eprint page could not say why it showed no datasets. listDataLinks reports whether an answer came from Layers or could not be obtained, so that "this paper has no linked data" and "we could not ask" are distinguishable. That signal reached the page and was discarded. The Data tab now says when the lookup failed. Tab visibility is unchanged: Layers being unreachable must not give every eprint a Data tab.

0.16.0 - 2026-09-02

Removed

  • WhiteWind support. The service is dead and nothing ever wrote a backlink through it. The plugin, its collection subscription, its source type, its counts column and its rendering are gone.

Fixed

  • Every OpenGraph image on the site returned an error. The main domain routes /api to the API container, which strips the prefix before handling, so /api/og — a Next.js route, and the only one under that prefix — arrived at the API as /og and answered 404. The preview card for a Chive link shared anywhere had a title and description but no image. A more specific Traefik router at higher priority keeps that one path with the frontend, the same carve-out the metrics endpoint already uses.

  • Backlinks from every integration were rejected on insert. The backlinks.source_type constraint listed names from before the plugins were rewritten against the lexicons their services publish, and the types added since were never added to it. A plugin writing a value the constraint does not list has its row rejected by PostgreSQL, which is indistinguishable from finding no reference at all. The constraint is now derived from BacklinkSourceType, with a test tying the two together, and the backlink list renders every source type rather than the three it used to know.

  • One unreachable PDS failed the whole deploy, and the records were never retried. The reindex exits non-zero if any record fails, and a user's PDS being down, rate-limiting or slow is an ordinary condition for an AppView — three records on a single host were enough. Because the reindex is not the last deploy step, everything after it was skipped, including the citation re-matching meant to keep the graph current.

    A record that cannot be fetched is now handed to the index retry worker, which resolves the DID, re-fetches and indexes, backing off exponentially across ten attempts. The reindex finishes, the deploy proceeds, and the records are retried in the background. Should the queue exhaust its attempts, the periodic freshness scan selects records by how long ago they were synced, so one that was never fetched sorts to the front of the next scan — there is no state in which a record is stale and nothing will try it again. Failures that would leave the index wrong rather than stale, including unresolved field labels, still fail the deploy.

0.15.0 - 2026-09-02

Added

  • Code, data and materials have their own tabs on an eprint. They were rendered at the bottom of the Metadata tab, behind tags, enrichment, conference and funding — the last place on the page anyone would look for a repository, so eprints that recorded one were effectively hiding it. The Data tab gathers everything that counts as data for the paper: declared repositories, datasets linked on Layers, and supplementary files, which had been split across the metadata tab and each other. Each tab appears only when the eprint has that kind of resource, so nothing gains an empty tab.
  • A linked dataset comes with the code that loads it. A Layers dataset is addressed by an AT-URI, which tells a reader the data exists but not how to open it. Each linked corpus now carries a copyable lairs snippet with its own URI already in place, so the path from "this paper has a corpus" to "I have the corpus" is four lines.
  • A researcher's graph proposals appear on their profile. The section existed and rendered "Graph proposals are not yet available on profile pages" — a placeholder that shipped. pub.chive.governance.listProposals already accepted proposedBy, so the data was there; the section now lists what was proposed, how the community voted, and where each proposal ended up.

Fixed

  • Leaflet documents were being read by nothing. The plugin was rewritten against Leaflet's published lexicons, its schemas vendored and its collections added to the observed set — and it was never registered with the indexer. The records arrived, the event processor forwarded them to the plugin bus, and nothing was subscribed. The same was true of the WhiteWind plugin. Both are registered now, and a test fails if a collection is observed with no plugin behind it.

  • Eprints linked from other publishers' standard.site documents produced no backlink. The plugin read only the two fields Chive's own documents use, path and the legacy content.uri. But content is an open union — the format does not enumerate block types and each publisher brings its own, so pckt posts, which are site.standard.document records holding blog.pckt.block.* items, could link an eprint in their prose and go unnoticed. Any eprint reference in a document's body is now found, whether written as an AT-URI or as a link to the eprint's page, which is normalised back to the AT-URI so one work does not accumulate references under two identities.

  • A shared eprint was never recorded on its standard.site document. bskyPostRef exists so a paper's Bluesky thread is discoverable as its off-platform discussion, and the helper that writes it was reachable only through a hook no component used. Sharing an eprint now attaches it.

  • The citation network was far emptier than the data supported. Citations are matched to Chive eprints once, while a document is processed, against whatever was indexed at that moment — and nothing re-ran it, so a reference to a paper that arrived later stayed unresolved forever. The graph only ever held edges that were discoverable in extraction order, and grew more incomplete as the corpus filled in behind it. Every deploy now re-resolves citations that have no match yet and creates the missing edges. It reads Postgres and writes the graph — no PDF fetch, no GROBID — and skips anything already matched.

  • Repositories recorded before the platform field was renamed showed a generic icon. The lexicon calls it platformSlug and that is what the submission wizard writes, but older records carry platform and Chive does not rewrite user records. Both are read now, so an older eprint shows its GitHub or OSF icon.

0.14.1 - 2026-09-02

Fixed

  • Affiliations were listed in no particular order. Merging the Chive profile with the sifa.id one walked the Chive record first and appended whatever sifa alone knew about — an order nobody chose, which on a list showing years read as unsorted. Previous affiliations now order by when the role ended and current ones by when it began, most recent first, with the primary affiliation leading whatever its date. A Chive affiliation carries no dates at all, so an institution both sources name takes its dates from the sifa role, undated entries sort last, and a profile with no dates anywhere is left exactly as its owner arranged it.
  • A paginated eprint list could repeat, skip or misorder entries. The queries ordered by date alone, which is a partial order: PostgreSQL gives no guarantee about how it arranges tied rows and may arrange them differently for each query, and every page is a separate query. Publication dates are routinely recorded as just a month or a year, so ties are the norm — 23 of one author's 58 eprints share a timestamp with another. Ordering by the record's URI after the date makes the order total, and identical across pages.

Added

  • A profile links to the researcher's sifa.id page when they have one. The link appears only for a researcher whose repository actually holds sifa records, so it never points at an empty page, and it addresses the profile by DID rather than handle, which does not change when someone moves domain.

Changed

  • A long bio is clamped to a few lines, with a control to read the rest. A long one used to push the affiliations, identifiers and eprint list off the screen. The control appears only when the text is genuinely clipped, measured rather than guessed from a character count, and re-measured when the element resizes: a bio that fits on a wide window clips on a narrow one.
  • The bio field is a rich text editor, the same one the abstract uses, so @ mentions and # tags autocomplete and LaTeX and a preview are available. It was a plain textarea, which accepted the syntax the save path already detected but gave no way to discover it.

Fixed

  • A cache hiccup could strand a deployment. The staging deploy for 0.14.0 built the web image and pushed it to the registry, then failed on error writing layer blob: failed to reserve cache while exporting to the GitHub Actions cache. The image existed and the job was marked failed, so staging never pulled it and sat two releases behind with nothing in the failure pointing at the cause. The cache export is now marked ignore-error=true in both workflows that use it: a cache is an optimisation, and losing it should cost build time rather than a deployment.

0.14.0 - 2026-09-01

Added

  • The Chive bio is rich text, using the same item union reviews and abstracts already use — links, mentions, tags, LaTeX and the rest — rendered by the same renderer. bioRich carries the marked-up form and bio keeps its plain text, so meta descriptions, OG images and search still get a string.

Changed

  • A profile now states each institution once. A researcher can record their affiliations twice, in their Chive profile and in their sifa.id profile, and the page rendered both — the same university under "Affiliations" and again in a separate professional-profile card. The two are merged on the institution, so each appears once carrying what each source knows: the departments and ROR identifier from Chive, the role and years from sifa. Matching is deliberately conservative — case, punctuation and a leading "the" are ignored, but "Univ. of Rochester" will not be merged into "University of Rochester", because one extra row is a cheaper mistake than attributing a department to the wrong employer.
  • The bio falls back through sources rather than always using Bluesky's. A Bluesky description is a personal one and often not professional. A bio written on Chive wins; failing that the sifa.id summary is used; the Bluesky description is the last resort. The settings field says so.
  • Departments sit on their own line beneath the institution in previous affiliations, rather than after a dash that read as though the department were part of the university's name, and the institutions are spaced apart so a list of several is scannable.

Fixed

  • Four admin pages answered with a 500. The XRPC router validates every response against its lexicon, so a handler returning a field the lexicon does not name — or omitting one it requires — fails the request rather than merely mismatching. getGraphStats returned totalNodes/totalEdges where the lexicon required nodeCount/edgeCount, getSearchAnalytics returned totalQueries where it required totalSearches, and listWarnings and listViolations were required to return a total that nothing computed and no page read. In each case the handler and the page agreed with each other and the lexicon was the outlier, so the lexicons now describe what is actually returned. All 41 admin methods were mounted and authorizing correctly throughout; only the response shapes were wrong.

0.13.1 - 2026-09-01

Fixed

  • Author search reported the wrong number of eprints beside each author. The count was built by tallying how often an author appeared in the page of search hits, which is bounded by the page size and by the loop's own early break — so an author with 58 eprints was shown as having 1. It now reports how many eprints the author actually has, fetched for the whole page in one query, because author autocomplete runs this on every keystroke and a query per suggestion would be a round trip per suggestion per character.

0.13.0 - 2026-08-31

Added

  • A researcher's sifa.id profile appears on their Chive profile. sifa.id is an ATProto professional profile service whose records live in each user's own repository, so Chive reads their positions and education from the same place it already reads their eprints — neither service needs an account with the other. Employment with no end date shows as a current role and employment with one as a previous role, which is the split Chive's own profile already makes; education is read too, since for academics a PhD institution is an affiliation that older papers carry. A researcher with no sifa profile sees nothing, and an unreachable PDS costs the rest of the profile nothing. The schemas were read from the lexicons sifa.id publishes, resolved through its _lexicon.sifa.id DNS record.
  • An eprint can be recorded on sifa.id as it is submitted. Writing id.sifa.profile.publication and id.sifa.profile.presentationDelivery records saves entering the same publication twice. The record is written by the researcher's own agent to their own repository, as their pub.chive.* records are, and its sameAs points at the eprint's AT-URI so a sifa consumer can resolve back to the work — without a CID, so the reference follows the eprint's edits rather than pinning one version.

Changed

  • A mapping change now actually reaches production. Editing the index template did nothing to a running deployment: mappings are fixed once an index exists, index bootstrap returns early when the alias is already there, and the deploy's reindex copies documents into whatever mapping the index already has. So the author-name analyzer fix would have shipped and changed nothing. The template now carries a version, the deploy migrates when the live index reports a different one, and running it when they match does nothing — deciding by diffing the mapping instead would rebuild the index on every deploy, because Elasticsearch echoes back a normalised mapping that never equals what was sent.

  • Applying an Elasticsearch mapping change no longer means deleting the index. Mappings are fixed once an index exists, so an edited template reached a deployment only through a script that deleted eprints-v1 outright — search down for the length of a full rebuild. pnpm db:migrate:elasticsearch builds the next index version from the current template, copies the documents, and moves the alias atomically; search serves the old index throughout. The author-name fix below is a mapping change and needs this run against any existing deployment.

  • Every autocomplete can now be operated from a keyboard and announced by a screen reader. Chive has around twenty of them and two implemented the WAI-ARIA combobox pattern; the rest offered their suggestions to a mouse only. Most handled Escape and no other key, so a keyboard user could open a list of suggestions and had no way to reach one. Ten delegate to AutocompleteInput, which implements the pattern once; the nine that render their own grouped list — Chive events beside DBLP venues, personal nodes beside global ones, Chive institutions beside ROR — adopt a new useCombobox hook that owns the keyboard and the ARIA wiring without touching their markup. All of them gain arrow keys, Home, End, Enter to commit, Escape to dismiss, and an aria-activedescendant naming the current suggestion. A test over the whole directory now fails if a new autocomplete does none of the three.

  • Two Enter behaviours changed with it. fast-autocomplete committed the first suggestion regardless of what the user had moved to, which was the only thing it could do; it now commits what is highlighted, and nothing when nothing is. affiliation-input keeps its free-text fallback — an affiliation in neither Chive nor ROR is still a real affiliation — so Enter with nothing highlighted still adds the typed value.

Fixed

  • A profile with several affiliations was a wall of repeated text. Each affiliation is a tree, and the header rendered one fully-qualified path per leaf — so three departments at one university produced three long lines that each began with the university's name, and on a phone each of those wrapped to two or three rows. The institution is now named once with its units listed beneath it, which is also how affiliations are conventionally written. Previous affiliations were badges holding a full three-level path, which wrapped into paragraph-shaped pills; they are now a grouped list.
  • Long identifiers pushed the eprint page sideways. A DOI link was an inline-flex element, which cannot wrap, so a full DOI URL ran past the edge of a phone-width column; the same held for the source record link and RRIDs. The article carried overflow-x-hidden, which hid the symptom rather than the cause. Those identifiers now wrap.
  • Keyword pills had the same problem as field chips, and the "Fields:"/"Keywords:" labels sat inline with them, so on a narrow screen the chips were laid out in whatever width was left after the label. The labels now sit above the chips on a phone and beside them from sm up.
  • Field chips broke up on narrow screens. The badge they are built on never set whitespace-nowrap, so a long discipline label wrapped inside the pill while the pill kept the vertical padding of a single line. Chips are now one line each, truncating at the container with the full label still available to assistive technology, and the gap between them is wide enough that neighbouring links are not one tap apart.
  • No document would open. The pdf.js worker was loaded from cdn.jsdelivr.net, and the Content-Security-Policy added in 0.10.0 restricts script-src to 'self' — so the worker's dynamic import was blocked and every eprint failed with "Setting up fake worker failed". The worker is now copied out of the installed pdfjs-dist into public/ by the build and served from our own origin, which also means it can never drift from the pdfjs-dist the viewers load, and the viewer no longer depends on a third party being reachable.
  • Only PDFs were actually viewable. The submission wizard accepts ten manuscript formats and the eprint page sent all of them to the PDF viewer, so a Markdown, LaTeX, HTML, Jupyter or plain-text submission rendered as a corrupt-PDF error. DocumentViewer, which renders five of those formats, existed and was mounted nowhere. The page now dispatches on the blob's MIME type: PDFs keep the annotated viewer with highlighting and inline review; HTML, Markdown, LaTeX, Jupyter and plain text are fetched from the author's PDS and rendered, with text selection still offering the review action; and DOCX, ODT, RTF and EPUB — which have no in-browser renderer here — say so by name and offer a download instead of failing. Downloads also carry the right extension, having previously been named .pdf whatever the document was.
  • Author pagination never advanced. total was reported as the length of the page just fetched rather than the author's eprint count, and the cursor is emitted only when offset + page.length < total — a comparison that could never be true. So no cursor was ever sent, the profile's infinite scroll saw no next page however many eprints an author had, and "My Eprints" showed the first twenty with no way to reach the rest. That page now uses the same paginated query as the public profile.
  • Searching for an author's name returned nothing. Two independent faults sat on top of each other. authors is mapped as nested, so its subfields are indexed as separate documents and cannot be reached from an ordinary multi_match — yet authors.name was listed among the flat fields, where it matched nothing. Underneath that, the analyzer applied to authors.name used a keyword tokenizer, so "Aaron Steven White" was indexed as the single token aaron steven white and no query for a surname could match it even through a nested query. Author names are now tokenized, and searched through a nested query scored by the best-matching author. Author autocomplete was the visible casualty: it searches eprints and reads the authors off the hits, so it found none and fell through to Bluesky for every query.
  • The dashboard reported at most twenty eprints. It counted the length of the page it had fetched rather than reading the total the server sends, so anyone with more than twenty saw the wrong number beside two other counts that were right — and it disagreed with the profile.
  • The eprint page sorted its reviews, endorsements and annotations on every request. All three are read as WHERE eprint_uri = $1 AND deleted_at IS NULL ORDER BY created_at DESC, and the indexes covered eprint_uri alone — so PostgreSQL matched the URI, filtered the soft-deleted rows, then sorted, on every view. Partial indexes on (eprint_uri, created_at DESC) WHERE deleted_at IS NULL answer the query from the index, already ordered. The busiest papers are the ones most often opened, so they were paying the most for it.
  • Search and endorsement pages issued one query per result. Both looped a single-record fetch over their results — a page of 25 search hits cost 25 round-trips to Postgres, a page of 50 endorsements cost 50 to read 50 titles. A batch getter already existed and issued a single uri = ANY($1); both endpoints now use it. Search did this in two separate code paths, so fixing one would have left the endpoint unbatched for queries without text.
  • A large batch staleness check could not run at all. checkBatch built one query parameter per URI, and the PostgreSQL wire protocol caps a statement at 65535 of them — so a batch past that failed inside the driver before the query was sent, and every distinct batch size produced a differently-shaped statement for the planner to parse afresh. It now passes the list as one array parameter.

0.12.0 - 2026-08-31

Chive now reads and writes other applications' lexicons against what those applications actually publish. Several of the records it was already emitting turned out to be invalid, and several it was already subscribed to were never reaching it.

Added

  • Leaflet documents and comments are read for references to eprints. Leaflet's schemas are vendored under lexicons/vendor/leaflet/, taken from its own lexicon repository. A document reaches an eprint by four routes and all four are followed: a comment's subject, a website block's src, an inline richtext link, and a standardSitePost block naming an eprint directly.
  • standard.site documents that describe an eprint are recorded as backlinks, whoever wrote them. This is also the mapping that lets a reference addressed to a document — a standard.site recommend, an embedded document block — resolve to the work it ultimately means.
  • Talks and presentations appear as backlinks. A community.lexicon.calendar.event naming an eprint among its URIs is evidence the work was presented, and where. Cancelled and postponed events are skipped.
  • pub.chive.site.citationLink, a typed document-to-work link for site.standard.document's reserved links union, with a CiTO-style vocabulary. A consumer that knows CiTO can read a Chive citation without knowing anything about Chive. Opt-in.
  • The Bluesky post announcing an eprint is recorded on its standard.site document as bskyPostRef, which makes the post's reply thread findable as the paper's off-platform discussion.
  • An MCP server over the public read API (pnpm mcp): search, resolve-by-identifier, citations and reviews. It calls the same unauthenticated XRPC a browser does, so it holds no credentials and can be pointed at any deployment. Every tool is read-only.
  • Tangled repositories are a first-class code artifact. A repository hosted there is an ATProto record, so codeRepository gains recordUri alongside url — an address on the network rather than at one web host.
  • The external identifier resolver is documented for other applications at docs/api-reference/resolving-identifiers.md.

Fixed

  • The firehose filter was dropping seven of the twenty collections Chive indexes. Its NSID validator required every segment to be lowercase. An NSID is a domain authority followed by a name, and only the authority is a domain label; the name segment is where camelCase lives. With strict validation on — which is what the indexing service sets — pub.chive.eprint.userTag, eprint.relatedWork, annotation.entityLink, collaboration.inviteAcceptance, actor.profileConfig and both graph proposal types were rejected before the processor saw them. Each is in the indexed set, each has a handler, and the PDS scanner backfills each; only the live firehose path refused them, silently. The name rule now matches the grammar @atproto/syntax implements.
  • Five registered backlink plugins could never be called. The event processor already forwarded foreign records to the plugin bus, and the plugins already subscribed; the filter rejected every collection outside pub.chive.* upstream. Cosmik backlinks, connections, follows and link removals, and Margin annotations were constructed, subscribed, and unreachable.
  • Every site.standard.document Chive had written was invalid. The lexicon requires site and publishedAt; neither was written. content was an object where the schema has an open union, and visibility and createdAt are not properties at all. No standard.site consumer could accept such a record, which is why the cross-platform discovery this feature promised never appeared. Documents now carry the required fields and a path, so site + path is the canonical URL of the eprint page — which is how a reader verifies that document and page describe the same work.
  • Deleting an eprint still finds its documents. The lookup matched the invented content.uri; it now matches path and the legacy field both, because documents already in users' repositories carry the old shape. Updating one repairs it in place.
  • /.well-known/site.standard.publication advertised a record that cannot exist. The collection is absent from the service repository, and self is not a legal record key for a lexicon whose key is tid. It is now CHIVE_PUBLICATION_URI, and unset the endpoint answers 404 — an unconfigured publication rather than a broken one.
  • The Leaflet plugin matched no record that any repository holds. It tracked xyz.leaflet.list, an NSID Leaflet does not publish, and parsed an invented shape; its shouldProcess filtered on a visibility field that does not exist, so even repointed it would have skipped everything.
  • The sign-in field had no accessible name. FormControl passes id and aria-* to its child; HandleInput accepted none of them and forwarded none, so the label never associated and a screen reader announced an unlabelled text box on the first field of the login page.
  • The autocompletes could not be operated from a keyboard. Around twenty of them, of which two implemented the combobox pattern; the shared base implemented none of it and node-autocomplete — behind every knowledge-graph field — handled Escape and no other key. A keyboard user could open a list of suggestions and had no way to reach one. Both now implement the pattern: arrow keys, Home, End, Enter, Escape, with aria-activedescendant and marked-up options.
  • Cancelling an admin operation did not stop it. startOperation returns an AbortSignal that the governance sync and DID sync handlers dropped, so the work ran to completion and reported success against a cancelled operation.
  • triggerBackfill recorded operations nothing ran. It called startOperation and returned, leaving an operation pending forever. It now names the endpoint that does the work for the requested type.
  • The Helm chart still set the governance PDS DID to did:plc:chive-governance, which is not a PLC identifier. Every other configuration was corrected in 0.10.0; a Helm deployment would have carried it. A test now sweeps every tracked config rather than the ones someone remembers.
  • Integration tests ran under Vitest's 5-second default while talking to four datastores, so a slow runner failed the build with no defect behind it. The datastore-backed suite gets 30 seconds; the unit suite keeps the 5-second default.
  • A type error in a test file removed the generated code reference from the documentation site, and the only symptom was a link check naming every page that pointed at the missing section.

Changed

  • Dependency updates target staging. Work reaches production through staging here, so nine dependency PRs had accumulated against a branch the normal flow cannot merge them into.
  • traefik and the PDS image are pinned in production compose, to the versions production already runs. latest meant a compose pull could replace the ingress every request passes through without anyone choosing to.
  • The README names the plugins that actually register. It advertised GitHub, ORCID, DOI registration and Wikidata — the four that are written and not constructed by any service.
  • A clean clone can typecheck: pnpm lexicons:generate is documented as the step that must precede it.
  • E2E runs in CI again, for the unauthenticated project. Seventeen assertions of the form expect(true).toBe(true) now assert something.

0.11.1 - 2026-08-30

Fixed

  • Sign-in failed with Failed to resolve identity, and record writes would have failed the same way. The Content-Security-Policy introduced in 0.10.0 set connect-src to the app origin and the Chive API, which is too narrow to run an ATProto client: the browser connects to hosts it discovers at runtime, and the policy blocked all of them. Handle resolution reads DNS over https://dns.google and falls back to the public AppView; plc.directory supplies the DID document naming the PDS; the OAuth authorization server is that PDS; and every record the app writes — eprints, reviews, mutes, Layers data links — is a direct browser-to-PDS com.atproto.repo.* call. External autocompletes against Crossref, DBLP, arXiv, ORCID and ROR were blocked for the same reason. connect-src now allows https: and wss:, which is the narrowest form that works: the set of PDS hosts is open by design and CSP cannot express "any host this document later learns about". Plaintext HTTP and non-HTTP schemes remain blocked, and script-src, object-src, base-uri, form-action and frame-ancestors are unchanged.

    The 0.10.0 entry describing that policy said everything other than script-src was enforced. That was wrong of connect-src, which was not enforcing a boundary but breaking the application.

0.11.0 - 2026-08-29

Added

  • Layers dataset links. Chive now reads and writes pub.layers.eprint.dataLink, the record Layers defines to associate a dataset with an eprint. The submission wizard attaches a corpus, annotation layer or model output alongside supplementary files and code repositories, writing the records into the author's own PDS. Because a dataLink requires the eprint's AT-URI, the records are written after the eprint exists, one at a time, and a failure to write one neither rolls back the eprint nor stops the rest of the batch; the submitter is told which links did not land.
  • The eprint page shows those datasets, grouped by kind, with the section of the paper each belongs to. The list is read from the Layers AppView rather than indexed: Layers is authoritative for its own lexicons. Answers are cached in Redis for five minutes, the request times out after two seconds, and failures are not cached. source distinguishes an empty list from an unreachable Layers, so a paper with no datasets is not presented the same way as a paper whose datasets could not be fetched.
  • Dataset cards do not link out. Chive holds only the AT-URI of the record in its author's repository, and Layers' web routing is not settled.
  • Server-side mutes. pub.chive.actor.mute records are indexed, and pub.chive.actor.listMutes serves them. The frontend continues to read mutes directly from the user's PDS, which is immediately consistent where the index lags the firehose.
  • LAYERS_APPVIEW_URL configures the Layers AppView, defaulting to https://api.layers.pub.

Fixed

  • Governance PDS writing did not work in any configuration. GovernancePDSWriter was constructed only when GRAPH_PDS_SIGNING_KEY was set — a variable no configuration sets, and not a credential this system uses; the scripts that write to the governance PDS authenticate with GRAPH_PDS_PASSWORD. Independently, the writer discarded the key it was given and built an unauthenticated agent, so writes would have been rejected even with the variable set. It now logs in with the governance account, deferring authentication to the first write so an unreachable PDS at boot does not take the process down. Set GRAPH_PDS_PASSWORD to enable it; GRAPH_PDS_SIGNING_KEY and GOVERNANCE_SIGNING_KEY are removed.
  • grantDelegation and revokeDelegation returned 503 on every call. Only the indexer constructed a governance PDS writer, so the API's was always undefined. Both processes now use the same configuration gate, and startup logs when governance writing is disabled and which endpoints will refuse.
  • pub.chive.graph.getCommunities returned an empty list on every request. GraphAlgorithmJob, the only producer for the cache the handler reads, was never scheduled. It now runs every 24 hours by default (GRAPH_ALGORITHM_INTERVAL_MS) and once on start.
  • Nine hooks truncated at 100 results without indicating it. Collections, mutes, personal graph nodes, node and field edges, endorsement kinds and citations each requested a single page from a cursor-paginated endpoint. A user with more than a hundred of any of them saw only the first hundred; an author muted past the hundredth record reappeared in the feed. All nine follow the cursor, and a walk stopped by the safety ceiling logs which caller it truncated.
  • Every Chive author in the DID autocomplete was described as "Has Chive profile". The component read author.hasEprints, which pub.chive.author.searchAuthors does not return, so the condition was always false. It reports eprintCount instead.
  • Facet suggestions used node UUIDs in place of slugs, because node.slug is not a field pub.chive.graph.searchNodes returns.
  • The Layers data link fetch did not clear its abort timer, leaving a live two-second timer per eprint view.
  • A type error in a test file removed the generated code reference from the documentation site. TypeDoc typechecks the whole program its tsconfig describes, and both tsconfigs included tests, so an unrelated test error made TypeDoc emit nothing and the Docusaurus build failed its link check instead of reporting the type error. The reference is generated from a tsconfig that excludes tests, and a typecheck over exactly the programs TypeDoc compiles runs before the build.

Changed

  • The admin surface uses the instrumented API client. All of use-admin.ts called fetch directly, so roughly forty admin endpoints sent no request ID or traceparent and did not appear in tracing, and their failures were Error rather than APIError with a status. Admin calls now share the handler behind the generated client while still supplying the per-NSID service-auth token their endpoints require. Three form components calling Chive's own XRPC moved to the typed client; calls to Crossref, DBLP, arXiv and the Bluesky AppView are unchanged.
  • scripts/publish-lexicons.ts refuses to run from a dirty or untagged checkout, which would publish schemas belonging to no release. --dry-run is always permitted and --allow-dirty overrides the check.
  • Removed 11,161 lines of unreachable code: the blob proxy with its cache, CID verifier, request coalescer and R2 adapter, injected into every request context but reachable from no route; the notification service and its two push transports, with no importer; the enrichment worker, never constructed; the email and Zulip services, imported only by a script for testing them; and seven unreferenced Neo4j and Elasticsearch modules. Four compliance tests that scanned those files for repository writes now assert the files are absent. Blobs are read from the origin PDS on every request.
  • Line coverage measures 49.8%, up from 47.5%, because the deleted code was untested and left the denominator. The ratchet is raised to match.

0.10.0 - 2026-08-27

Third tranche of the 0.8.0 backlog remediation. The theme of this one is signals that were reporting something other than what was happening: tests that asserted nothing, a coverage number measured against a subset of itself, manifests nobody had ever built, and an audit log that had never returned a row.

Security

  • Anonymous rate limits cannot be bypassed by setting a header. getClientIP read the first X-Forwarded-For entry. That header is append-only — entries the client sent arrive first, and each proxy appends the address it saw — so the first entry is always a value the client wrote, and varying it per request gave every request a fresh window. It now counts back TRUSTED_PROXY_COUNT (default 1, matching Traefik) from the right, and discards the header entirely when it is shorter than the proxy chain rather than guessing. X-Real-IP and CF-Connecting-IP are gone as fallbacks for the same reason: single-value headers with no chain, indistinguishable from a client setting them.
  • The rate-limit window is evaluated atomically. redis.pipeline() batches commands over the connection without making them atomic, so two concurrent requests could each read a count below the limit and each be admitted. zadd also ran unconditionally before the check, so a client already over its limit kept writing entries: its window never drained, its Retry-After kept moving out, and the sorted set grew for as long as it kept knocking. Both are now one Lua script, with the write inside the admit branch. Verified against a real Redis: fifty concurrent requests against a limit of ten admit exactly ten.
  • Only the account whose repository holds a record may alter or delete it. deleteSubmission and updateSubmission authorised anyone listed in the record's authors[] — an array written by whoever submitted the eprint, so a submitter could hand delete rights to an arbitrary DID by typing it in. The narrower rule is also the only coherent one for an AppView: ATProto forbids cross-repository writes, so a co-author cannot change the record itself, and letting them change Chive's copy would leave the index disagreeing with the PDS that owns it.
  • A Content-Security-Policy is served on both sides. Neither had one, which is what let the stored-XSS hole fixed in 0.8.0 reach as far as it did. The API answers JSON only, so its policy is absolute. The frontend policy still carries 'unsafe-inline' for scripts, because Next.js inlines its own bootstrap and removing it requires per-request nonces threaded through the app — that work is not done here. Everything else is enforced, closing the injection paths that do not require running script.
  • /ready no longer publishes the internals of every datastore. Each failing check returned the driver's message verbatim — connection strings, hosts, ports, index names — from an endpoint that is unauthenticated, exempt from rate limiting, and reachable from the internet. The real error still reaches the log with its stack.
  • The external-PDF proxy re-checks its allowlist at every redirect and bounds what it reads. The allowlist was checked once, on the URL the caller named, while fetch followed redirects itself, so an allowlisted host could redirect the request to an address inside the cluster and have Chive fetch it and return the body. Bodies are now read through a counting reader that abandons the stream past 50MB, so a server that lies about its Content-Length cannot exhaust memory.
  • DISABLE_RATE_LIMITING=true is ignored in production. One stray environment variable removed every limit from a live deployment — the same shape as the end-to-end auth bypass fixed in 0.8.0, with a comment as its only guard.
  • Administrative actions reach the audit log. Role grants wrote a Redis key; content deletions published to a channel with no subscriber. Neither reached governance_audit_log, so the two actions an admin audit log exists for were the two it could not show.

Fixed

  • pub.chive.admin.getAuditLog failed on every call. The query selected g.target_did and g.ip_address; neither column existed. A catch swallowed the error, returned an empty result, and logged that the table was unavailable — so the endpoint reported an empty audit log forever and sent anyone investigating to look for a table that was there all along. A second, independent fault in the same method: the count query had no g alias while sharing a WHERE clause written against one, so any call filtering by actor failed as well. Both paths to a row were broken.
  • All three Kubernetes overlays failed kubectl kustomize outright and had never been applicable to a cluster. Each declared a configMapGenerator with behavior: merge naming a ConfigMap present in no base, and the development overlay additionally used deprecated multi-document strategic-merge deletes. Nothing in CI built them, so nothing said so.
  • Even rendering, the overlays pointed every pod at an address that did not resolve: service names were fully qualified into the chive namespace while each overlay deploys into its own, and namePrefix renamed every Service while the addresses in the ConfigMap stayed unprefixed. Addresses are now bare names, which resolve through the pod's search path; namePrefix is removed, since each overlay's namespace already isolates it and the prefix was what broke the names.
  • Citation extraction was silently disabled on Kubernetes. There was no GROBID manifest anywhere, though docker-compose has always run one and the indexer skips extraction when GROBID_URL is unset — no failure, no log an operator would notice.
  • Three lexicon schema errors that @ts-nocheck had been hiding. The generator prepended it to all 436 generated files, exempting the entire lexicon type surface from tsc. Removing it surfaced: pub.chive.actor.profile constraining orcid with a pattern, which the Lexicon spec does not define, so the field accepted any string; pub.chive.eprint.submission putting default on a ref, which the spec allows only on primitives, so a record omitting publicationStatusSlug got nothing rather than a preprint; and ten defs across pub.chive.richtext.* declaring $type themselves, producing a duplicate-identifier error in every file they touched.
  • pub.chive.graph.getCommunities answers with an empty list on every request. 0.9.0 constructed the graphAlgorithmCache the handler reads, which was previously undefined; GraphAlgorithmJob, the only producer that writes into it, is still never constructed or scheduled. The endpoint is therefore served, successful, and empty. Not fixed here — scheduling the job or withdrawing the endpoint is a decision about whether community detection is a feature Chive offers.
  • The PDS scanner backfilled a different set of collections than the firehose indexes. Its hard-coded list had drifted both ways: it scanned pub.chive.review.entityLink, which the event processor does not index, and omitted both collaboration collections — so a repository scan could never recover a co-author invitation the firehose had missed, which is the failure a backfill exists to fix.
  • pub.chive.eprint.tag was in the indexed set, the event processor's dispatch and the scanner's, and there is no such lexicon anywhere. The record type is pub.chive.eprint.userTag. Listing the old name let sync.indexRecord accept a manual index request for a collection with no schema to validate against.
  • An eprint's abstract could not be edited from the frontend. useUpdateEprint re-listed the twelve fields it forwarded while the lexicon accepts fourteen; abstract and document were missing, with no error and no type failure to show it. The hook now takes its parameter type from the lexicon and forwards whatever it is given.
  • toAtUri rejected the project's own record types. Its collection pattern was lowercase-only, so every camelCase NSID failed — pub.chive.eprint.userTag, pub.chive.graph.nodeProposal, pub.chive.collaboration.inviteAcceptance among them. The record-key charset also widens to the spec's, which includes : and ~.
  • XRPC schema validation is no longer opt-in. The body parse and validateXrpcInput sat together inside the JSON content-type check, so sending any other content type skipped both: input stayed undefined and validation never ran. A method whose lexicon declares a JSON body is now rejected without one, and validation runs unconditionally.
  • Endorsement pages can no longer be empty while claiming more results. contributionType was applied in the handler after pagination, so a page could return nothing while total, hasMore and cursor described the unfiltered set. It is now applied in SQL, to both the count and the page.
  • Seven collection handlers report an unconfigured feature instead of an empty one. Returning [] is indistinguishable from a genuine empty result: a client rendered "no collections", and a searcher was told the query found nothing when nothing had been searched.
  • Every 500 is logged with the error that caused it. The request middleware passed a hardcoded undefined in the error slot, so server errors were recorded with a status and a duration and nothing else.
  • 400s are logged and 404s are not logged as errors. The XRPC error handler branched on error class: ValidationError matched no branch, so every 400 went unlogged, while NotFoundError matched the generic branch and filled the error dashboards with routine misses. It now branches on the status the client receives, which cannot develop the same gap when a class is added.
  • The metrics endpoint reports a failure instead of an empty registry. An empty catch around the prom-client import returned metrics: [], indistinguishable from a registry with nothing in it.
  • Integration cards show "unavailable" rather than zero during an outage. GitHub, GitLab and Zenodo fetch failures produced zero-valued placeholders rendered exactly like real figures.
  • The submission wizard keeps a draft. Nine steps of input — title, abstract, every author, fields, facets, funding — were discarded by a refresh, a back-navigation or a closed tab, with no way back. Files are deliberately not persisted, and the restore banner names what needs re-attaching.
  • Canonical and Open Graph URLs come from the deployment origin. Every page staging served carried a canonical pointing at production.
  • Four documented health endpoints did not exist, one of them inside a copy-pasteable Kubernetes probe snippet — following the documentation produced pods whose readiness probe could never pass.
  • The test stack no longer reports success after a failed migration. It connected to a database the compose file does not create, discarded the error, and printed "✓ Migrations complete" regardless, leaving a running stack with no schema.
  • pnpm test:performance invoked a script that has never existed in this repository, so the performance workflow failed on its first command every time and the four k6 scenarios that do exist had never run.
  • The Docker deps stage copies pnpm-workspace.yaml, without which pnpm resolves as a single package against a workspace lockfile.
  • vitest.pre-deployment.config.ts used poolOptions.forks.singleFork, which Vitest 4 removed. Nothing typechecked the config files, so the key sat there being ignored and the suite ran in parallel forks against shared state — exactly what the setting existed to prevent.

Changed

  • Coverage is measured over all of src/. The v8 provider counted only files a test imported, so a module with no test simply left the denominator: coverage could rise by deleting a test and fall by writing the first one for a large file. Measured properly, the same suite covers 46% of lines rather than the 70% reported, and the full suite 52% rather than 80%. Nothing got worse; the figure was always this. The thresholds are now a ratchet set just under the real numbers, and the 80% bar in CLAUDE.md remains the target rather than a description.
  • Twenty-four compliance tests whose body was expect(true).toBe(true) now check what their titles claim, mostly by reading the source and asserting the absence of a repository write — a claim about absence, awkward to demonstrate by executing code and exact to check by reading it. Where source is the wrong instrument they read the real artifact: the OTEL collector manifest must hold no ClusterRole, every verb on Promtail's must be a read, alert rules must contain nothing credential-shaped.
  • The rebuildability rule has an executable test for the first time. It replays a fixed firehose log through the real event processor against a real PostgreSQL index, snapshots it, deletes the index, replays the same log and requires the snapshots to match. The log contains an update and a delete, not only creates; the empty case is excluded; and the three columns normalised out are asserted to exist, so a rename cannot widen the exemption.
  • The published OpenAPI specification is generated from the served one, and CI fails when they disagree. It was five months stale, missing fifteen handlers including the whole com.atproto.repo group and documenting six endpoints that no longer exist.
  • REST endpoints honour the auth and rateLimit they declare, and all five HTTP methods the type admits. PUT, DELETE and PATCH were silently dropped — registered nowhere, a 404 at runtime with nothing said at startup.
  • PDS endpoints resolve through the shared, cached DIDResolver rather than two hand-rolled plc.directory fetches with no cache and no timeout.
  • The test suites default to the Docker test stack's database. The documented local flow failed at migration time with role "chive" does not exist, a message that points at authentication rather than at the database name responsible.
  • CI skips build-tool installation when the compilers are already present (30–60s in every job, six times over) and keys the Turbo cache on the lockfile and branch rather than the commit SHA, which could never hit.
  • Unit tests for the three modules that decide whether firehose events survive — the cursor manager, the event queue and the dead letter queue — and for the four on the trust boundary: the service-auth verifier, the DID verifier, and the OAuth state and session stores. None had a test. The verifier is the same file whose missing lexicon-method check was accepted for four months.

Removed

  • The OpenAPI type generation: a 648KB schema.generated.ts that nothing imported, was gitignored, and required a live server on port 3001 to produce — so CI could neither generate it nor notice it was missing, while contributors were told to run it after every API change.
  • The dependencies and type interfaces the 0.9.0 second-factor removal left behind — otplib, @otplib/*, @simplewebauthn/server, and 720 lines of interfaces exported from the barrel and imported by nothing.
  • The vm2 → isolated-vm pnpm override. vm2 is not in the dependency tree, so it was inert — and actively harmful if a transitive dependency ever pulled it in, since the APIs are incompatible and a version conflict would have become a runtime crash.
  • The second Redis client. redis was a production dependency used by one script while thirty modules use ioredis.
  • Two exported builtin-plugin registries, neither referenced, each listing eight of twenty-eight plugins — and a different eight from the five src/index.ts actually instantiates.
  • 1,350 lines of stub-implementation catalogues describing code that no longer exists, replaced by a note recording the one gap that is still real.

Upgrade notes

  1. TRUSTED_PROXY_COUNT defaults to 1. That matches every current deployment. A deployment behind a different number of proxies must set it, or anonymous rate limiting buckets all traffic under one key.
  2. NEXT_PUBLIC_SITE_URL should be set on staging. It defaults to the production origin, which is the previous behaviour.
  3. One migration. 1742000000000_admin-audit-log-columns adds two nullable columns and widens a CHECK constraint. Its down deletes rows carrying the new actions before restoring the narrower constraint, so rolling back loses audit history.
  4. The lexicons need republishing to the governance PDS. scripts/publish-lexicons.ts --dry-run reports seven changes against the live PDS: pub.chive.actor.profile, pub.chive.discovery.settings, pub.chive.eprint.submission, pub.chive.richtext.defs and pub.chive.richtext.facets updated; pub.chive.actor.profileConfig and pub.chive.claiming.dismissSuggestion created. Editing a lexicon file in this repository does not update the records the PDS serves.

0.9.0 - 2026-08-27

Second tranche of the 0.8.0 backlog remediation: fourteen further changes.

Security

  • Plugin loading refuses third-party code rather than importing it into the host process. loadPlugin dynamically imported manifest.entrypoint, where it ran with the service's full privileges — filesystem, network, and the database credentials in the environment — while the plugin interfaces described isolated-vm isolation and permission enforcement. Both executeInSandbox and enforceNetworkAccess are implemented and have zero call sites: no plugin code has ever run inside an isolate. Nothing called loadPlugin, so this was a trap rather than a live vulnerability, and it is now sprung loudly instead of left armed. CHIVE_ALLOW_UNSANDBOXED_PLUGINS=true overrides it, named for what it grants.
  • The frontend no longer offers its end-to-end authentication bypass in a production build. Setting one localStorage key made the client send X-E2E-Auth-Did, which the API turns into an identity — and X-E2E-Auth-Admin into an administrative one — so a console one-liner on the live site was enough. The API stopped honouring those headers in 0.8.0, but a bypass that depends on the other side refusing it is not a control.

Fixed

  • getCommunities has a cache to read from. Two handlers read services.graphAlgorithmCache, which ServerConfig had no field for and nothing constructed, so it was always undefined and the endpoint returned an empty list on every request — while the graph algorithm job wrote precomputed results into the same cache that nothing read. Corrected in 0.10.0: this fixed the plumbing and not the supply. GraphAlgorithmJob is the only thing that writes into that cache and is still never constructed or scheduled, so the endpoint reads a cache nothing fills and returns an empty list — a well-formed, successful, empty answer instead of an undefined-service one. Saying it "returns data" was too strong.
  • Endorsement views carry the record CID. Handlers returned the literal string 'placeholder' for a field the lexicon marks required, with a comment claiming the CID was not stored; it has always been stored and the queries simply did not select it, so optimistic-concurrency writes were comparing against a constant that could never match.
  • Seven fields the search mapper emits are mapped explicitly, as nested where they are arrays of objects. Elasticsearch inferred object for them, which flattens: a query for one funder's grant number matched across the whole array, so an eprint funded by A with grant X and B with grant Y matched a search for "A's grant Y". Wrong answers indistinguishable from right ones.
  • Mapping changes can reach a live index. bootstrapIndex returns early when the alias exists, and the only path that applied a change deleted eprints-v1 outright — full search downtime for the length of a reindex, no prompt, nothing to fall back to. migrateIndexToCurrentMapping builds the next index version, copies the documents, and moves the alias in a single atomic action; the previous index is kept, so the migration is reversible.
  • The trending page is fetched in one query and its authors hydrated once. It issued a getEprint and a separate call to the public Bluesky appview per entry — 40 network round trips for 20 entries — and silently dropped authors past the first 25 of each eprint.
  • KaTeX loads only when LaTeX is rendered. Roughly 280KB was imported at the top of the rich-text renderer, so browse, search, trending and author listings all shipped a typesetting library for content that rarely contains a formula.
  • The Kubernetes manifests reference images that exist. They named bare chive and chive-frontend, which resolve against Docker Hub, while CI publishes to ghcr.io/chive-pub/chive; overlays set only newTag, never newName; and the base pinned latest, which CI does not produce. No environment could pull an image.

Changed

  • The XRPC method verb is resolved once, so the OpenAPI specification and the runtime router cannot disagree. They decided it from different sources, and because the frontend client is generated from the spec, a disagreement became a 404 at runtime rather than a build error.
  • Profile lookups go through one cached hydrator. Handle, display name and avatar were fetched from the public Bluesky appview at fourteen call sites, two of them byte-identical private methods, none of them cached — so a page of reviews re-fetched the same authors on every request.
  • The pre-deployment suite skips when the external services it verifies cannot be reached, rather than failing. That job is a required check, so a third-party outage previously blocked every merge in this repository however unrelated the change. A service that responds incorrectly still fails the build; only unreachability skips, and the skip says loudly that nothing was verified.
  • Every environment variable the code reads is documented — 75 of 103 were not. Three clusters are marked as inert rather than presented as working configuration: the R2/CDN variables select an adapter that is never chosen, the governance PDS writer is never constructed, and the SMTP path is never invoked.

Removed

  • The second-factor authentication layer: WebAuthn, TOTP and JWT session management, 2,334 lines plus a 688-line authentication service, and the two tables built for it. It was unreachable — no route, handler or service imported it, and credentials lived in Redis under TTLs rather than in those tables — so no user could enrol, because no endpoint existed to enrol through. Chive does not offer 2FA.
  • document_base64 from the search document, an implemented and tested path for putting a base64 document body into Elasticsearch, contradicting the rule that only BlobRefs are stored. Nothing populated it.
  • The repo:pub.chive.graph.fieldProposal OAuth scope, for a record type renamed to node/edgeProposal that has no lexicon.

0.8.1 - 2026-08-26

Security

  • The Prometheus scrape endpoint is no longer reachable from the public internet. /metrics shipped in 0.8.0 unauthenticated by default, which is the usual arrangement for a metrics port on a private network — but Chive's API is fronted by two public Traefik routers, so on this deployment it was world-readable: request rates, per-endpoint latencies and error counts, and queue depths. It was serving 200 to anonymous requests after 0.8.0 deployed. Both public routes now deny the path at the edge, which holds whether or not METRICS_TOKEN is configured, and Prometheus scrapes the API directly over the compose network where it never traverses Traefik.

0.8.0 - 2026-08-25

Remediation sweep against the 0.8.0 backlog. Fourteen changes, each with tests pinning the specific failure. A recurring shape runs through them: work that ran, reported success, and had no effect — telemetry recording into an SDK that was never started, metrics nothing could scrape, deletions announced to a subscriber that was never written, precomputed graph results with no reader.

Security

  • Service auth tokens are verified against the method being called. A JWT's lxm claim scopes it to one lexicon method, and the verifier has always accepted a method to check against — the middleware passed none. The claim was decoded, copied into user.scopes, which nothing reads, and never enforced, so a token minted for pub.chive.metrics.recordView was accepted at pub.chive.admin.deleteContent. Any holder of any valid token could call any endpoint their roles allowed.
  • PDS registration requires authentication and is bound to the caller's own identity. The handler was auth: 'optional', and a registered host is later enumerated by the scanner, which indexes whatever repos that host claims to hold. The ownership check resolves the caller's DID document and fails open when resolution is inconclusive, which is recorded rather than hidden.
  • Server-side request forgery through PDS registration and did:web resolution is blocked. Both fetched caller-supplied hosts with no scheme allowlist, no private-address block and no redirect cap, reaching cloud metadata at 169.254.169.254, loopback and RFC 1918 services. Every resolved address is checked, not just the hostname, and redirects are refused.
  • The E2E authentication bypass cannot be enabled in production. X-E2E-Auth-Did supplies an identity and X-E2E-Auth-Admin: true grants administrative access, with both header names in the production CORS allowlist; an unset environment variable was the only thing between a deploy and an open admin door. It is now gated on NODE_ENV as well, and the process refuses to start if the flag is set in production.
  • Stored cross-site scripting through JSON-LD is closed. The Schema.org payload is rendered with dangerouslySetInnerHTML and carries eprint titles, abstracts and author names taken from user-controlled PDS records; JSON.stringify does not escape <, so a title containing </script> closed the element and everything after it parsed as markup.
  • CodeQL, a dependency audit and a Trivy filesystem scan now run on every pull request and weekly. The repository had no scanning of any kind despite the architecture overview describing some. CodeQL gates; the dependency audit reports without failing, because the tree carries 343 known advisories (8 critical, 141 high) and a gate nobody can satisfy is one that gets disabled. Dependabot opens the update pull requests.

Fixed

  • /ready reports the state of its dependencies. Each probe was raced against a timeout with .catch(() => undefined) applied to the racer, so a dependency refusing connections rejected fast, resolved to undefined, and was recorded as passing. The endpoint answered 200 with PostgreSQL, Elasticsearch or Neo4j down, and Kubernetes kept routing to the pod. Only a probe that hung past the timeout could ever trip it.
  • The admin full reindex no longer destroys indexed fields. It rebuilt each search document from a hand-rolled nine-field projection, and Elasticsearch replaces whole documents rather than merging, so DOIs, publication status, external identifiers, funding, repositories, related works, supplementary materials, licence and document metadata were wiped from every eprint the reindex touched. It now uses the same mapper as the reindex script. Facets remain empty, because they live on the PDS record and not in the index.
  • Records deleted from their PDS are removed from the index. The freshness worker emitted record.deletion_detected and returned success; no subscriber to that event was ever written, so the scan reported a deletion and deleted nothing. PDSSyncService.markAsDeleted was already among the worker's own dependencies.
  • Telemetry is started. initTelemetry was referenced only in its own documentation, so the OpenTelemetry SDK never initialised, every withSpan executed its callback recording nothing, and no OTLP export happened. Both the API and the firehose indexer start it, since they are separate processes.
  • Request rate, latency and error metrics exist and can be scraped. The counter and histogram had no emission site — the middleware computed status and duration and only logged them — and the only exposure was an admin-authenticated XRPC method returning JSON, which Prometheus can neither authenticate against nor parse. GET /metrics now serves the exposition format, exempt from rate limiting, with optional bearer-token protection.
  • Cancelling a long-running admin operation cancels it. startOperation returns an AbortSignal that all seven trigger handlers discarded, so cancelBackfill flipped the operation's state in Redis while the loop ran to completion. The four handlers driving a loop locally now honour it, including both loops of the reindex and citation extraction.
  • Soft-deleted eprints no longer appear in author profiles, the counts beside them, field browse, or tag and keyword browse. The partial index the soft-delete migration created for exactly this filter was never used by any read path. The tag lookup needed a join rather than a predicate, since a tag row carries only the eprint URI.
  • Eprint deletion is reversible and reconcilable. It hard-deleted the PostgreSQL row and then removed the Elasticsearch document best-effort; when that failed the row was already gone, so nothing recorded that a document still needed removing and no sweep could find it. Deletion now marks the row, and reconcileDeletedFromSearch re-issues the removal.
  • A verified ORCID iD survives indexing. Verification wrote only to authors_index, which is rebuilt from the firehose, and both profile upserts assigned orcid straight from the incoming record — so a verified value was overwritten, usually with null, on the next profile update. An author who verified before being indexed lost it outright. Verification is now stored in its own table and seeded into new rows.
  • The child-facet hierarchy resolves. getChildFacets asked Cypher for *1..$maxDepth; a parameter is not accepted as a variable-length bound, so the query was a syntax error and the method threw on every call.
  • Recommendation and collaboration queries match the labels the write side creates. Fields are created as (:Node:Field) and were read as (:FieldNode); authors are created as (:Node:Object:Person) keyed on metadata.did and were read as (:Author {did}), so collaboration strength was null for every pair of authors who had in fact collaborated. Cypher returns no rows for a label that matches nothing, so both read as "no data yet". Interest-based paths remain empty: nothing creates INTERESTED_IN at all.
  • Precomputed community and trending results have somewhere to be read from. Two handlers looked for services.graphAlgorithmCache, which ServerConfig had no field for and nothing constructed, so getCommunities returned an empty list on every request while the graph algorithm job wrote results nobody read. Corrected in 0.10.0: the cache was wired up in 0.9.0, not here, and GraphAlgorithmJob still runs nowhere, so getCommunities remains empty. Both this entry and 0.9.0's claimed more than was done.
  • Trending pagination advances. The cursor was parsed only to build the next cursor and never passed to either data source, so every page returned the same entries while the cursor climbed.
  • The ORCID verification flow resolves. The callback is registered at /v1/auth/orcid/callback while the default redirect URI pointed at /api/v1/..., a prefix only one Traefik router strips.
  • Methods declared as procedures are served on POST. The router ignored a handler's type when no lexicon was registered, so a procedure was mounted as GET and its POST callers received 404. pub.chive.claiming.dismissSuggestion, the concrete victim, also gains the lexicon it never had.
  • Author autocomplete issues one query per page instead of one per hit — up to 75 sequential round trips per keystroke — and faceted browse one query per facet instead of one per edge.
  • Search is billed against the relaxed rate-limit tier. The autocomplete list named pub.chive.search.searchSubmissions, a method that does not exist; the real NSID is pub.chive.eprint.searchSubmissions, so search never matched and every anonymous request took the low tier.
  • Endorsement views carry the record CID. Handlers returned the literal string 'placeholder' for a field the lexicon marks required, with a comment claiming the CID was not stored — it has always been stored, and the queries simply did not select it. Optimistic-concurrency writes were comparing against a constant that could never match.
  • The WhiteWind backlink plugin tracks com.whtwnd.blog.entry, the collection that exists. It subscribed to com.whitewind.blog.entry and so could never have matched a post.
  • The governance PDS DID is validated at load. Every environment file set did:plc:chive-governance, which is not a PLC identifier, overriding the correct default — so the governance sync resolved nothing and imported an empty graph. An ill-formed value now fails startup rather than being carried.
  • Thread loads no longer scan the whole review table. parent_comment carries a foreign key with no index, and PostgreSQL does not index foreign keys automatically.
  • A second paper login within five minutes no longer hangs. The popup's poll interval and timeout lived only in the promise closure, so the success path could not clear them and a stale timeout closed the next attempt's popup, leaving its promise unsettled.

Changed

  • Manual reindex accepts every collection the firehose indexes. Three lists described "the collections we index" and disagreed; sync.indexRecord accepted 13 while the event processor handled 20, so seven were unrecoverable by manual reindex — pub.chive.graph.edgeProposal among them. One list now serves both, derived from the event processor's own dispatch and asserted by test.
  • The scheduled health check probes /ready as well as /api/health. The latter is a static 200 that reports nothing about dependencies, so a datastore outage was invisible to monitoring.
  • pnpm test runs the backend suite. The root package is not a workspace member, so turbo test reached only the frontend and the command exited zero having run none of the 4,000-plus backend tests.
  • The developer test stack no longer collides with production containers, and the deploy sweep excludes it. Both used chive- names — chive-grobid identically — and docker ps --filter "name=chive-" matched the test stack, so a deploy could delete it mid-run.
  • Frontend coverage is measured and enforced. The config declared no thresholds and CI ran the suite without --coverage, so the stated 70% bar was unenforced end to end; the real figure is 41%. Thresholds are set just below current levels as a ratchet, and both configs now record the gap to the documented bar rather than a bare TODO.
  • Every environment variable the code reads is documented — 75 of 103 were not. Three clusters are marked as inert rather than presented as working configuration: the R2/CDN variables select an adapter that is never chosen, the governance PDS writer is never constructed, and the SMTP path is never invoked.
  • The XRPC method verb is resolved once, so the OpenAPI specification and the router cannot disagree and generate a client for a verb the server does not serve.

Removed

  • The second-factor authentication layer. WebAuthn, TOTP and JWT session management — 2,334 lines plus a 688-line authentication service — were unreachable: no route, handler or service imported them, and credentials were held in Redis under TTLs rather than in the tables built for them. No user could enrol, because no endpoint existed. Chive does not offer 2FA; the code and its two tables are gone rather than completed.
  • document_base64 from the search document. It carried a base64 document body into Elasticsearch — implemented, typed and tested — which contradicts the rule that only BlobRefs are stored. Nothing populated it.
  • The repo:pub.chive.graph.fieldProposal scope, for a record type renamed to node/edgeProposal that has no lexicon.

0.7.1 - 2026-08-24

Fixed

  • Node subkinds are validated before being interpolated into Cypher. subkindToLabel only capitalised the hyphen-separated parts, so parentheses, whitespace and comment markers passed into MATCH (n:Node:<label>) unaltered, and subkind is caller-supplied on the unauthenticated pub.chive.graph.listNodes and pub.chive.graph.getHierarchy. The helper was also duplicated, unguarded, across two files feeding nine interpolation sites; both now route through src/storage/neo4j/labels.ts, which rejects anything that is not a plain identifier.
  • Proposals resolve by record key, so proposal detail pages and votes load. getProposalById cast whatever identifier it received to an AtUri and matched on uri, while every route and list link carries the record key — a value that never equals a full AT-URI. No proposal page could load and getUserVote failed identically. IGraphDatabase gains getProposalByRkey, which falls back to the URI suffix so proposals indexed before the record key was persisted resolve without a migration. Closes #89.
  • pub.chive.governance.listVotes returns the votes on a proposal. It synthesised at://chive.governance/pub.chive.graph.fieldProposal/<id> — an authority that is not a DID, and a collection that does not exist — so it matched no vote and always returned an empty list.
  • New proposals are indexed immediately through pub.chive.sync.indexRecord, as every other user write already was. pub.chive.graph.nodeProposal and pub.chive.graph.vote were not accepted by that endpoint, so a proposal was only readable once the firehose delivered it.
  • The governance sync no longer clears the knowledge graph before it has the records to replace it with. An undefined GRAPH_PDS_DID repository variable interpolated to an empty string, which overrode the correct built-in default and failed every request with Params must have the property "repo" — after the graph had already been wiped. Deploys therefore left Neo4j empty, and the eprint reindex that followed resolved every field label to a raw UUID.
  • Field labels survive a reindex that cannot reach the knowledge graph. resolveFieldLabels returns the original UUID when Neo4j has no matching node and swallows the error that caused it, so a racing or failed lookup overwrote correct labels. The reindex now waits briefly for the graph to populate and preserves the label already stored in PostgreSQL rather than downgrading it.
  • The field label resolution job mirrors repairs into Elasticsearch. It only ever wrote to PostgreSQL, while browse and search read from the search index, so repaired labels never reached the UI.
  • The PDS scanner can reach relay-connected servers. getPDSesForScan required is_relay_connected = FALSE and every registered PDS is relay-connected, so the scheduler ran every 15 minutes and scanned nothing. Records from those servers normally arrive over the firehose, but a relay outage longer than the relay's backfill window skips events permanently, and this scan is the only mechanism that can find them. They are now ranked last rather than excluded.
  • A PDS wedged in scanning by a crashed scan is reclaimed after an hour. Nothing cleared that status and it was absent from the selection query, so such a server was excluded from every future cycle.
  • Backend services report their real release version. npm_package_version is unset when a container starts Node directly, so /health, structured logs and OpenTelemetry resources reported 0.0.0 in every deployed environment.

Changed

  • The production deploy no longer re-injects database credentials into the reindex and governance sync steps, using the container's own environment instead. Re-interpolating them risked drift from the values the running service uses, and an undefined variable silently overrode a correct default.
  • A failed Elasticsearch reindex or governance sync now fails the deploy instead of emitting a warning and reporting success.

0.7.0 - 2026-08-24

Added

  • Firehose consumer reconnects indefinitely with capped backoff and a WebSocket keepalive heartbeat, so a relay outage or a half-open socket can no longer wedge ingestion permanently. A single relay 503 on 2026-05-18 killed the production consumer for three weeks without surfacing anywhere.
  • Indexer health endpoint on INDEXER_HEALTH_PORT (default 3001) reporting per-relay connection state, with a watchdog that exits the process when the consumer stays unhealthy past its tolerance. The container healthcheck probes it instead of running true.
  • scripts/publish-lexicons.ts publishes the pub.chive.* lexicon schemas idempotently, so permission-set edits reach the PDS that resolves them.

Changed

  • The lexicon publisher targets the dedicated lexicon account on the governance PDS (lexicons.governance.chive.pub) rather than the chive.pub Bluesky bot account, which exists only for posts. LEXICON_PDS_URL and LEXICON_PUBLISH_IDENTIFIER still override both.
  • transition:generic is no longer requested anywhere. Scope resolution fails closed to the ATProto base scope when session scopes are unavailable, leaving authorization entirely on the pub.chive.* permission sets. Existing sessions keep their old scopes, so users must re-authenticate.

Fixed

  • Deleting an eprint removes it from Chive's index immediately rather than waiting on the firehose, and the frontend deletes the dual-written site.standard.document records alongside it.
  • The deploy's Elasticsearch reindex prunes eprints that are gone from their PDS instead of failing on orphaned index rows, which had made the production deploy unrunnable.
  • Backend services report their real release version. npm_package_version is unset when a container starts Node directly, so /health, structured logs, and OpenTelemetry resources had all reported 0.0.0 in every deployed environment.

0.6.3 - 2026-05-12

Fixed

  • OAuth permission sets now grant the rpc lexicons backing ORCID verification, admin endpoints, claiming flows, and profile-config writes. Production had been silently rejecting every getServiceAuthToken call since 0.6.0 dropped transition:generic without enumerating the rpc grants the corresponding lxm need. Closes #85.
  • Resolved the audience-format contradiction between @atproto/oauth-scopes (whose isAtprotoAudience validator requires <did>#fragment) and com.atproto.server.getServiceAuth (whose lexicon rejects an aud containing a fragment) by setting aud: "*" on the rpc permissions inside the four pub.chive.{basicReader,authorAccess,reviewerAccess,fullAccess} permission-set lexicons. Frontend now requests getServiceAuth with the plain DID and matches the wildcard rpc grant at the PDS.
  • Retry com.atproto.server.getServiceAuth once when the user's PDS responds with use_dpop_nonce. The OAuth client's auto-retry occasionally leaks the nonce-mismatch error through to caller code on the first request against a previously-unseen origin; the explicit retry consumes the freshly-issued DPoP-Nonce header on the second attempt.

Changed

  • Bumped @atproto/oauth-client-browser from 0.3.37 to 0.3.42 to pick up DPoP-handling fixes from @atproto/oauth-client 0.5.12–0.6.1.
  • Production now emits include:pub.chive.* permission-set references instead of individual repo:pub.chive.* scopes (NEXT_PUBLIC_USE_PERMISSION_SETS=true). The consent screen shows one named entry per Chive permission set rather than one row per collection.

0.6.2 - 2026-05-07

Added

  • Hybrid OAuth scope layout: client metadata now emits include: scopes for cooperating apps that publish a covering permission-set lexicon (Margin's at.margin.authFull, Standard.site's site.standard.authFull, Semble's network.cosmik.authFull), and falls back to individual repo: scopes only for gaps Semble's authFull omits (network.cosmik.connection, network.cosmik.follow) and for Bluesky (which publishes no covering set). Collapses the consent screen from a wall of opaque collection names to one named entry per cooperating app, each with publisher-authored title and detail copy.

Changed

  • Renamed Chive's permission-set lexicons from pub.chive.auth.{basicReader,authorAccess,reviewerAccess,fullAccess} to pub.chive.{basicReader,authorAccess,reviewerAccess,fullAccess}. ATProto's IncludeScope.isAllowedPermission only honors lxm/collection references that share the permission set's group prefix (everything up to its last dot). The four-segment names had a group prefix of pub.chive.auth. and silently dropped every referenced collection; the three-segment names authorize the full pub.chive.* namespace, matching Bluesky's chat.bsky.authFullChatClient precedent. Permission set records now live in lexicons/permission-sets/ (excluded from @atproto/lex-cli codegen, which can't generate types for permission-set definitions).
  • Conformed Chive's Margin dual-write to Margin's actual published lexicons: a single at.margin.note collection (W3C Web Annotation Data Model) with the motivation field distinguishing comment, highlight, and bookmark. Replaces the Chive-fictional at.margin.annotation and at.margin.bookmark collections that Margin's AppView never indexed. MarginAnnotationsPlugin, MarginHighlightsPlugin, and MarginBookmarksPlugin consolidated into a single MarginNotesPlugin. The record-creator.ts MarginAnnotation* types/functions renamed to MarginNote*; createMarginBookmark / deleteMarginBookmark collapse to thin wrappers that forward to the note machinery with motivation: 'bookmarking'.
  • Lead permission-set detail strings with eprints (the primary Chive use case) instead of knowledge-graph governance.
  • Owner-private collection reads in the dashboard hooks (useMyCollections, useCollection, useCollectionsContaining, useSubcollections, useParentCollection, useCollectionFeed) now use the authenticated client. The owner-side visibility filter on pub.chive.collection.listByOwner and friends gates unlisted collections to the authenticated owner; the unauthenticated client masked the viewer as anonymous and hid their own collections after reload.
  • pub.chive.collaboration.listInvites now requires authentication and rejects queries that aren't scoped to the caller (invitee = me, inviter = me, or subjectUri authored by me). Closes an enumeration gap.

Fixed

  • "Created a new community/collection but it disappears on reload" (#79) for users who picked the unlisted visibility option in the wizard.

0.6.1 - 2026-05-04

Added

  • NEXT_PUBLIC_USE_PERMISSION_SETS build-time env var. When set, both client metadata and the OAuth login request emit include:pub.chive.auth.* permission-set references instead of individual repo:pub.chive.* scopes. Staging ships with the flag on so we can validate the permission-set flow against the now-live _lexicon.<sub>.chive.pub DNS TXT records.

Changed

  • Handle resolution falls back to the public Bluesky AppView's com.atproto.identity.resolveHandle XRPC when DNS-over-HTTPS returns no record. Direct browser fetches of https://<handle>/.well-known/atproto-did are blocked by CORS in nearly every case (most identity-publishing servers don't set Access-Control-Allow-Origin); the AppView runs both ATProto resolution methods server-side and serves a permissive CORS policy. Unblocks Eurosky users (luismmontilla.com and similar) who publish identity only via the HTTPS path.
  • OAuth client metadata logo_uri now points at /chive-logo.svg (was pointing at /logo.png which 404'd), so the consent screen header renders the Chive logo instead of a generic placeholder.

Fixed

  • "Failed to resolve identity: " sign-in error for users whose handle is published only via .well-known/atproto-did rather than a DNS TXT record.

0.6.0 - 2026-04-24

Added

ATProto Granular Scopes

  • Individual repo:pub.chive.* scopes for all 22 Chive collections (eprint, actor, review, annotation, graph, discovery, collaboration namespaces)
  • External cross-post scopes for app.bsky.*, network.cosmik.*, at.margin.*, and site.standard.* collections
  • Permission-set lexicon schemas at pub.chive.auth.{basicReader,authorAccess,reviewerAccess,fullAccess} (served but not yet requested in the OAuth flow)
  • did:web:chive.pub DID document at /.well-known/did.json, host-aware for staging vs production
  • Minimal com.atproto.repo.* XRPC surface (getRecord, listRecords, describeRepo) that serves Chive's 219 lexicon JSON files as com.atproto.lexicon.schema records with real DAG-CBOR CIDs, enabling NSID resolution for any ATProto service

Collaboration

  • pub.chive.collaboration.invite and pub.chive.collaboration.inviteAcceptance lexicons
  • CollaborationService with full invite/acceptance lifecycle
  • Firehose indexing and XRPC endpoints for collaboration records
  • Collaboration invite flow in the submission wizard
  • Invitations inbox page for pending invites
  • DidAutocompleteInput component for collaborator selection (replaces raw DID text input)

Semble / Cosmik Integration

  • Knowledge-graph-based Cosmik connection-type mapping
  • Firehose plugin bridge for cross-AppView sync
  • syncEdgeToCosmik wired through all edge paths
  • Repair-mirror UI for fixing out-of-sync edges
  • Enriched Cosmik card metadata (DOI, author, description, publishedDate, externalIds)
  • Semble badge on relation-type autocomplete suggestions
  • pub.chive.* lexicons and XRPC handlers for follow count, follow status, and Margin annotations

External Identifiers

  • Canonical external-ID routes (/doi/<id>, /arxiv/<id>, /orcid/<id>, /ror/<id>, /isbn/<id>, /pmid/<id>, /wikidata/<id>)
  • Zotero/Citoid-compatible server metadata endpoint

Changed

  • OAuth client metadata no longer declares transition:generic — the legacy scope short-circuited granular permissions and caused consent screens to display "any public record" instead of Chive's specific collections
  • OAuth login requests use individual repo: scopes instead of include:pub.chive.auth.fullAccess references, because bsky.social cannot resolve permission-set lexicons until the DNS TXT records at _lexicon.<sub>.chive.pub are live
  • User profile is now fetched from the public Bluesky AppView (public.api.bsky.app) rather than through the authenticated session, so avatars and handles resolve correctly under granular scopes (session-bound agent.getProfile() returns 403 without an explicit rpc:app.bsky.actor.getProfile grant)
  • Container log rotation enabled across all services (JSON logs capped at 150 MB per container) to prevent unbounded disk growth
  • Cosmik dual-write edges now emit HTTP URLs and resolve connection types through the AppView

Fixed

  • Wikidata URLs for properties use the Property: prefix
  • Collaborators column migration no longer trips on dollar-quoting
  • Plugin DI dependencies registered in the indexer entry point so the plugin manager can resolve ILogger
  • Deploy App workflow no longer wipes chive-docs after a concurrent Deploy Docs (new step restores the container from docker-compose.docs.yml if a build exists)
  • React hook placement and useCurrentUser destructuring errors
  • Compliance test expected index/table counts updated for new collaboration tables

Security

  • Granular OAuth scopes limit Chive to writing only to its declared pub.chive.* collections plus the explicit external cross-post targets, instead of the blanket write access granted by transition:generic

0.5.1 - 2026-03-30

Fixed

  • Docs deploy workflow missing environment: production for secret access
  • Rate limits too low for SPA usage patterns (Traefik raised to 2000 avg / 500 burst, backend authenticated raised to 1200 req/min)

0.5.0 - 2026-03-28

Added

Open Alpha

  • Public landing page with inline ATProto login and open alpha notice
  • Bug report button in site header and mobile nav with pre-filled GitHub issue URL
  • Open alpha banner for authenticated users (dismissable, localStorage-persisted)
  • Onboarding prompt banner for new users to link academic accounts (auto-dismisses when ORCID is linked)
  • Permanent redirects from /apply and /pending to /

Content Reporting

  • pub.chive.moderation.createReport XRPC endpoint for user-submitted content reports
  • content_reports database table with migration
  • ContentReportService with atomic upsert, pagination, and admin review methods
  • Report dialog on eprint detail pages with reason categories and description (2000 char limit)
  • Lexicon schema for moderation createReport

ORCID OAuth Verification

  • ORCID OAuth 2.0 authorization code flow for verifying researcher identity
  • pub.chive.author.initiateOrcidVerification XRPC endpoint generating state and returning ORCID authorize URL
  • /api/v1/auth/orcid/callback REST handler for token exchange
  • orcid_verified_at column on authors_index for tracking verification status
  • orcidVerified boolean field in pub.chive.author.getProfile response
  • "Sign in with ORCID" button in onboarding wizard (replaces "Coming soon" placeholder)
  • "Verify with ORCID" button in profile settings form
  • Verified badge (ShieldCheck icon) on OrcidBadge component for OAuth-verified ORCIDs
  • Popup-based OAuth flow with localStorage event fallback for cross-origin communication
  • Writes verified ORCID to user's PDS profile record after OAuth completion
  • Graceful fallback to "Coming soon" when ORCID OAuth credentials are not configured
  • ORCID credentials wired into staging and production deploy workflows

Changed

  • All user-facing Bluesky references replaced with ATProto in login form, login dialog, and handle input
  • Landing page restored to inline login style with ATProto handle input
  • ConditionalHeader only hides on /login (was also hiding /, /apply, /pending)
  • OAuth callback redirects to /dashboard directly instead of /

Removed

  • Alpha gate (AlphaGate component) removed from all 16 layout/page files
  • Alpha application system: frontend components, hooks, pages, admin pages, scripts, E2E tests
  • Alpha XRPC handlers (pub.chive.alpha.apply, pub.chive.alpha.checkStatus)
  • Alpha admin handlers and lexicon schemas
  • AlphaApplicationService and requireAlphaTester middleware
  • Alpha type re-exports from web/lib/api/schema.ts

Fixed

  • Open redirect vulnerability in login page redirect parameter
  • localStorage SSR safety guards in banner components
  • Stale alpha references in admin nav, coming-soon page, and OAuth callback

Security

  • Login redirect parameter validated to prevent open redirects (blocks //evil.com and https://...)
  • AT-URI and NSID format validation on content report submissions
  • Description length limit (2000 chars) enforced on frontend and backend for content reports
  • ORCID client secret kept server-side only; state parameter is crypto-random, single-use, Redis-backed with TTL

0.4.1 - 2026-03-19

Fixed

  • pub.chive.eprint.listCitations returned 500 when a citation carried structured author objects. Authors are formatted as strings, as the lexicon requires.

0.4.0 - 2026-03-18

Added

Lexicon Versioning

  • ATProto-standard revision field on all lexicon files and schemaRevision on record-type lexicons
  • Central lexicons/manifest.json registry tracking revision, project version, and change date for every lexicon
  • lexicons/VERSIONING.md documenting the versioning strategy, changelog, and migration table

Record Migration Service

  • Backend migration service (src/services/migration/) that transforms old-format PDS records at index time
  • Migration 0001: convert abstract string to rich text array, add titleRich for LaTeX titles, add license URI from slug mapping (submission rev 1 to 2)
  • Migration 0002: replace flat department field on affiliations with recursive children tree (submission rev 2 to 3, profile rev 1 to 2)
  • Migration chaining so records at any prior revision are brought up to current in a single pass

Changed

Affiliations

  • Replace flat department field with recursive tree structure (children array) supporting arbitrary institutional hierarchies (university, school, department, lab, etc.)
  • Define canonical pub.chive.defs#affiliation shared type with name, institutionUri, rorId, and children
  • All lexicons now reference the shared affiliation type via cross-lexicon ref instead of local definitions
  • Each level in the affiliation tree can independently link to a knowledge graph node via institutionUri
  • Eprint cards display only the top-level institution name; full hierarchy shown in author detail views

Fixed

Admin

  • Alpha dashboard returning 500 because affiliations and researchKeywords lexicon schemas defined items as strings but actual data contains objects

Frontend

  • institutionUri dropped from affiliations in eprint card, edit sections, submission wizard Zod schemas, and sub-unit editing
  • Profile record creator missing institutionUri and children fields, silently stripping tree structure on profile save
  • Eprint submission Zod schema missing institutionUri, stripping institution graph links on submit

0.3.1 - 2026-03-11

Fixed

Frontend

  • Eprint card abstracts not rendering when API returns plain text string instead of rich text array (eprints page, field pages)

API

  • Browse page missing author avatars because browseFaceted handler did not fetch profiles from Bluesky API

0.3.0 - 2026-03-10

Added

Authors Page and Mute Feature

  • Personalized authors page showing authors the user follows or has interacted with
  • Mute author feature allowing users to hide papers from specific authors in feeds and discovery
  • pub.chive.actor.mute lexicon record type for storing mute preferences in user PDSes

Discovery

  • Configurable discovery weight settings allowing users to tune recommendation signal strengths (field affinity, citation overlap, recency, collaborative filtering)
  • XRPC array parameter parsing for multi-value query parameters

Deployment

  • GHCR image registry for CI-built Docker images, eliminating on-server builds during staging deploys
  • Staging docs container added to CI/CD pipeline

Changed

Types

  • Consolidated rich text $type references to use pub.chive.richtext.defs namespace across all tests, lexicons, and frontend code
  • Replaced EprintsByAuthorResponse with ListByAuthorResponse from generated types in use-eprint.ts
  • Replaced EprintChangelogsResponse with ListChangelogsOutput from generated types in use-eprint-mutations.ts
  • Replaced manual Backlink, BacklinkCounts, and ListBacklinksResponse with generated types in use-backlinks.ts
  • Added backlink and changelog type re-exports to schema.ts from generated lexicon types
  • Replaced EdgesResponse in use-edges.ts with generated OutputSchema from pub.chive.graph.listEdges
  • Replaced AuthorEprintsResponse in use-author.ts with ListByAuthorResponse from generated types
  • Derived ProposalStatus and ProposalType from generated ProposalView instead of manual string unions
  • Derived VoteAction from generated VoteView['vote'] instead of manual string union
  • Derived AlphaSector and AlphaCareerStage from generated alpha/apply InputSchema instead of manual string unions

Frontend

  • Comprehensive mobile responsiveness overhaul across the entire frontend
  • Dashboard, admin, and governance sidebars collapse into Sheet drawers on mobile instead of stacking above content
  • Mobile hamburger menu now includes dashboard navigation items for authenticated users
  • Mobile search access via dedicated search icon button that opens a top Sheet
  • Admin tables wrapped in horizontal scroll containers to prevent page overflow on narrow screens
  • Grid layouts use progressive responsive breakpoints (sm:grid-cols-2 md:grid-cols-3) instead of jumping directly to multi-column
  • Tab lists hide scrollbars for cleaner horizontal scrolling on mobile
  • Popover widths constrained to viewport with max-w-[calc(100vw-2rem)]
  • PDF viewer minimum height reduced on mobile (min-h-[400px] md:min-h-[600px])
  • New useIsMobile hook and shadcn Sheet component for consistent mobile patterns

Types

  • Replace redundant annotation hook types (AnnotationView, EntityLinkView, ListAnnotationsResponse, AnnotationThread) with generated lexicon types from pub.chive.annotation
  • Replace ListUserEndorsementsResponse with generated OutputSchema from pub.chive.endorsement.listForUser
  • Derive AnnotationMotivation from generated AnnotationView['motivation'] instead of manual string union
  • Derive ContributionType from generated EndorsementView['contributions'][number] instead of manual string union

Discovery

  • Related papers scoring: lowered combined score threshold from 0.2 to 0.05, reduced ES MLT discount from 0.6 to 0.85, and added author overlap to default signals
  • Removed For You feed in favor of configurable discovery weights

API Validation

  • Enabled server-side XRPC output validation (validateOutput: true) to catch schema mismatches at the source before responses reach clients

Fixed

API

  • getHierarchy handler missing default relationSlug value, causing 500 when output validation is enabled
  • Authors page only showing search box instead of author grid due to incorrect conditional rendering

Tests

  • Eprint integration tests failing due to stale PostgreSQL data from prior runs; added beforeEach cleanup

Admin Dashboard

  • Field name mismatches between admin API responses and frontend expectations
  • Role name references corrected from moderator to admin across admin endpoints
  • Silent auth failures in admin routes now surface proper error messages

Authentication

  • Paper PDS auth wired through all edit and delete flows, fixing unauthorized errors when modifying papers
  • Unified auth error messages across all endpoints for consistent error handling

Frontend

  • Removed broken "View all related papers" link pointing to nonexistent page
  • Flaky mention-popover arrow key navigation test stabilized by replacing fireEvent.keyDown with userEvent.keyboard

Deployment

  • Staging deploy workflow now builds web frontend image in CI and pins both API and web images to the exact commit SHA, preventing frontend-backend version skew
  • Production deploy fixed to build web image with docker build instead of silent no-op docker compose build (compose file had no build: section for chive-web)
  • Split Docker build and push steps to fix GHCR authentication failure
  • Test expectations updated to match unified auth error messages
  • Discovery test expectations updated for new default weights and weight normalization

0.2.0 - 2026-03-06

Added

Admin Dashboard

  • Admin dashboard with 15 pages: overview, health, alpha access, users, content, firehose, backfill, PDS, graph, metrics, search analytics, activity, endpoints, runtime, and governance
  • AdminService for aggregating system health, content statistics, and user management operations
  • BackfillManager for triggering and monitoring PDS record backfills from the admin UI
  • XRPC handlers for all admin dashboard endpoints across health, content, users, firehose, PDS, graph, metrics, search, activity, and governance
  • Frontend admin auth guard component that restricts dashboard access to users with admin roles
  • Role-based access hooks (useMyRoles, useIsAdmin) and admin dashboard query hooks
  • Admin role seeding from ADMIN_DIDS environment variable on server startup
  • pub.chive.actor.getMyRoles XRPC endpoint for querying the authenticated user's roles
  • Direct alpha access grant dialog on the admin alpha management page
  • Lexicon schemas for admin and actor role endpoints

Granular ATProto OAuth Scopes

  • Permission set lexicon schemas (basicReader, authorAccess, reviewerAccess, fullAccess) following the ATProto permission-set Lexicon type
  • Hierarchical permission model: basicReader (read-only RPC) < authorAccess (eprint/profile writes + claiming + blobs) < reviewerAccess (reviews + annotations) < fullAccess (graph governance + proposals)
  • Scope constants for all 19 pub.chive.* repo collections, 6 external namespace collections (Bluesky, Standard, Cosmik), and 5 blob MIME type wildcards
  • buildScopeString utility for constructing space-separated OAuth scope strings with automatic atproto prefix and deduplication
  • Intent-based login flow with AuthIntent type (browse, submit, review, full) that requests only the scopes needed for each activity
  • Frontend getScopesForIntent and hasScope utilities with transition:generic backward compatibility
  • CLIENT_METADATA_SCOPE constant combining full access permission set with all external namespace scopes
  • OAuth client metadata updated to declare granular scopes alongside transition:generic for backward compatibility with PDSes that don't support granular scopes

Deployment

  • Docker smoke tests and staging deployment workflow with environment branch strategy
  • NEXT_PUBLIC_CHIVE_SERVICE_DID build arg for per-environment service DID configuration

Observability

  • Prometheus metric groups for jobs, workers, auth, search, blob proxy, dead letter queue, admin, and backfill operations
  • OpenTelemetry span instrumentation for auth verification, background jobs, worker processing, and blob proxy requests
  • Faro error boundary around eprint detail page for rendering crash diagnostics with trace ID references

Indexing

  • Field label resolution job and indexer retry for unresolved UUID field labels
  • makeJobId utility for sanitizing AT URIs into valid BullMQ job identifiers

Documentation

  • Admin dashboard documentation covering API endpoints, backfill operations, observability metrics, architecture, and role management
  • OAuth scopes documentation covering permission set definitions, intent-based login, and backward compatibility
  • Documentation suite overhaul with formatting improvements, accuracy corrections, and staging deploy workflow

Testing

  • Unit tests for AdminService, BackfillManager, admin XRPC handlers, admin seed script, and observability Prometheus instrumentation
  • Frontend unit tests for admin hooks, admin auth guard, and role hooks
  • Backend and frontend unit tests for OAuth scope constants, permission sets, buildScopeString, getScopesForIntent, and hasScope

Changed

  • Collection visibility renamed from public/private to listed/unlisted across lexicon, backend, frontend, and tests to reflect ATProto semantics (visibility controls AppView listing, not data access)
  • PostgreSQL migration to rename existing visibility column values with backward-compatible normalization for old values
  • GitHub Actions CI workflows updated with ADMIN_DIDS environment variable, admin health check endpoints, and expanded Prometheus metrics collection targets
  • Documentation accuracy: removed non-existent content moderation features, corrected role names, fixed contact emails, replaced Semble references with Cosmik
  • OAuth scope requests fall back to transition:generic until PDSes support granular permission sets
  • Grafana Alloy Faro log pipeline switched from JSON stage to regex stages for Faro's key=value log format

Fixed

  • React 19 + Radix UI infinite loop crash (error #185) when selecting endorsement types by replacing Radix Checkbox with native HTML input
  • BullMQ job ID validation errors caused by colons in AT URIs by sanitizing job IDs in enrichment, freshness, and index-retry workers
  • AlphaGate redirecting approved users to login on transient auth refetch failures
  • Null reference crashes on eprint detail page from missing abstract, endorser display names, or contribution arrays
  • ~90 incorrect Wikidata Q-IDs across governance seed data
  • Invalid lexicon schemas for citation record and listCitations query
  • Permission set scopes to cover all frontend writes including external namespaces and blob types
  • Docker smoke test to use /ready endpoint instead of nonexistent /xrpc/_health
  • Backend SERVICE_DID environment variable name to match deploy configuration
  • Reindex script to use MERGE instead of CREATE to handle Neo4j uniqueness constraints
  • Staging deploy to pull from origin/staging instead of origin/main
  • Admin role reference from moderator to admin in getPendingClaims endpoint
  • Alpha applications table sync when granting access via admin role assignment
  • Permission-set lexicons excluded from codegen to prevent build errors
  • Neo4j Cypher syntax error in recommendation queries that prevented related papers from loading (moved UNION ALL inside CALL {} blocks for Neo4j 5.x compatibility)

0.1.0 - 2026-03-03

Initial release of Chive, a decentralized eprint service built on AT Protocol.

Added

Core Architecture

  • ATProto-native AppView that indexes scholarly records from the relay firehose without writing to user PDSes
  • Hono-based API framework serving both XRPC and REST endpoints (~130 XRPC endpoints across 19 service areas)
  • Modular service architecture with all services behind abstract I* interfaces for dependency injection
  • Multi-database storage layer: PostgreSQL (metadata indexes), Elasticsearch (full-text search), Neo4j (knowledge graph), Redis (caching and rate limiting)
  • All database tables use _index suffix and track PDS source URLs for staleness detection and rebuild-from-firehose capability
  • Plugin system with hybrid TSyringe (DI) and EventEmitter2 (hooks) architecture, isolated-vm sandboxing, and declared permissions
  • Background worker system with enrichment, freshness, and index-retry workers
  • Scheduled jobs for citation extraction, field promotion, governance sync, graph algorithms, PDS scanning, and tag sync
  • OAuth-based authentication with DID resolution, session management, and ATProto identity verification
  • Rate limiting with configurable fail-open/fail-closed behavior when Redis is unavailable

Firehose Indexing and PDS Discovery

  • Firehose consumer with WebSocket connection to ATProto relay, filtering for all pub.chive.* collections
  • Support for both full firehose events (CAR/CBOR parsing) and Jetstream events (pre-decoded JSON)
  • Cursor management with batched persistence to PostgreSQL for resumption after restarts
  • Dead letter queue for failed events with error classification (transient, permanent, rate limit) and exponential backoff retry
  • Backpressure handling to prevent memory exhaustion when queue depth exceeds threshold
  • Reconnection manager with exponential backoff and jitter for network failure recovery
  • PDS Discovery system with three discovery sources: PLC directory enumeration, relay listHosts queries, and DID mention extraction
  • PDS Scanner that backfills records from all 19 pub.chive.* collections on discovered PDSes
  • PDS registry with scan priority scheduling (24h for active, 7d for inactive) and consecutive failure tracking
  • User-facing pub.chive.sync.registerPDS endpoint for self-hosted PDS registration
  • Graceful shutdown with queue draining and cursor flushing

Lexicon Schemas

  • 164 lexicon JSON schemas across 21 namespaces under the pub.chive.* namespace
  • Record types for eprints (submission, version, changelog, tag, userTag, citation, relatedWork)
  • Record types for reviews (comment, endorsement, entityLink)
  • Record types for annotations (comment, entityLink)
  • Record types for the knowledge graph (node, edge, nodeProposal, edgeProposal, vote)
  • Record types for actor profiles (profile, profileConfig)
  • Query and procedure schemas for collections, discovery, governance, claiming, metrics, activity, tags, backlinks, sync, notifications, and import
  • Lexicon code generation pipeline producing TypeScript types and runtime isRecord type guards
  • Rich text facet definitions for cross-references

Eprint Management

  • Eprint submission with structured metadata: title, abstract, keywords, authors with affiliations and contribution types, publication tracking
  • Eprint versioning with version number tracking, previous version references, and change descriptions
  • Eprint changelog tracking for edit history
  • LaTeX-to-Unicode abstract migration with needsAbstractMigration schema hint detection
  • License metadata with URI-based license identification
  • PDF blob reference storage (CID pointers only, never blob data) with on-demand PDS fetching
  • Eprint soft deletion support
  • OG image generation for social media sharing

Search and Discovery

  • Full-text search powered by Elasticsearch with KStem stemmer, bool_prefix queries, and field-specific boosting
  • Faceted search filtering by field, author, date range, keywords, and publication status
  • Search autocomplete with search-as-you-type suggestions
  • Personalized discovery dashboard with multi-signal scoring (field affinity, citation overlap, recency, collaborative filtering)
  • "For You" feed with personalized paper recommendations
  • Similar papers with "More Like This" fallback when primary signals are insufficient
  • Field-filtered trending eprints
  • Citation-based paper discovery
  • Dismiss flow for unwanted suggestions with recordInteraction tracking
  • Elasticsearch index lifecycle management policies, ingest pipelines, and index templates

Knowledge Graph

  • Community-governed taxonomy using SKOS/FAST faceted classification stored in Neo4j
  • Node types: fields, methods, datasets, tools, platforms, licenses, and custom personal nodes
  • Edge types with semantic relation labels (broader, narrower, related, applied-to, uses, etc.)
  • Bidirectional edge support with configurable directionality
  • Graph hierarchy browsing with faceted navigation
  • Subgraph expansion for exploring node neighborhoods
  • Community detection, PageRank, and centrality graph algorithms
  • Wikidata integration via SPARQL for external identifier enrichment
  • Node autocomplete with search-as-you-type across all node types
  • Node proposals and community voting for Wikipedia-style moderation
  • Governance PDS (did:plc:chive-governance) for storing community-approved authority records as ATProto-native portable data
  • Governance sync job for periodic authority record synchronization
  • Trusted editor elevation requests with approval/rejection workflow
  • Role delegation system for governance authority distribution

Collections

  • User-owned collections stored as personal graph nodes in user PDSes
  • Collection wizard with multi-step creation flow (basics, items, edges, structure, cosmik integration, review)
  • Subcollection nesting with SUBCOLLECTION_OF edges and automatic parent propagation on add
  • Add-to-collection buttons on eprint, review, endorsement, and graph node cards throughout the UI
  • Inter-item edge editing for creating relationships between items within a collection
  • Collection activity feeds tracking changes to watched items
  • Collection search, public listing, and owner listing
  • Hierarchical collection dashboard with depth-based indentation and expand/collapse
  • Direct-only vs all-items view toggle for subcollection content
  • Delete propagation from subcollections to parent collections
  • Drag-and-drop item reordering
  • Cosmik dual-write integration for cross-platform collection mirroring
  • Nine XRPC endpoints for collection management

Review System

  • Inline review comments with threaded discussion support
  • Formal endorsement records with contribution type classification (methodology, results, novelty, clarity, significance, overall)
  • Endorsement summary aggregation per eprint
  • Entity linking from review text spans to knowledge graph nodes
  • Review listing by eprint and by author
  • Thread context retrieval for navigating discussion hierarchies
  • Notifications for new reviews and endorsements on authored papers

Annotation System

  • Inline text annotations on eprint PDFs using W3C Web Annotation data model
  • Text selection anchoring with highlight persistence
  • Entity link annotations connecting selected text spans to knowledge graph entities
  • Annotation sidebar with navigation and deletion
  • Dedicated annotation lexicon schemas separated from review system
  • Annotation listing by eprint, by page, and by author

Citation and Related Works

  • Citation extraction pipeline with GROBID integration for parsing reference sections from PDFs
  • Extracted citation indexing in PostgreSQL with eprint cross-referencing
  • User-curated related works linking between eprints
  • Related papers panel with unified display of extracted citations and curated links
  • Citation-based discovery signals feeding into recommendation engine

Author Profiles and Claiming

  • Actor profile records with display name, bio, ORCID identifier, and institutional affiliations
  • Profile configuration with customizable display sections
  • Featured collection display on author profile pages
  • Author claiming workflow with coauthor verification for papers imported from external sources
  • Coauthorship request and approval/rejection flow
  • Autocomplete for ORCID, affiliations, keywords, and OpenReview profiles
  • External paper claiming from arXiv, OpenReview, and other integrated sources
  • Claimable paper suggestions with dismiss capability

Tags and Classification

  • Author-assigned tags on eprint submission
  • User-contributed tags (folksonomy) with quality scoring
  • Tag trending with time-decay algorithms
  • Tag search and autocomplete
  • Tag detail pages showing tagged eprints
  • Tag suggestions based on eprint content
  • Tag sync job for periodic data consistency

Backlinks

  • Cross-platform backlink aggregation from Bluesky, Cosmik, Whitewind, and Leaflet
  • Backlink creation and deletion endpoints
  • Backlink count aggregation per eprint
  • Cosmik backlink source type support

Metrics and Activity

  • View, download, and dwell time recording for eprints
  • Search click and search download tracking
  • Trending calculation based on engagement metrics
  • Activity feed logging with correlation metrics
  • Failed activity tracking for monitoring

Built-in Plugins

  • arXiv plugin with paper search and metadata retrieval
  • OpenReview plugin for conference paper search
  • PsyArXiv plugin for psychology preprint search via OSF API
  • LingBuzz plugin for linguistics preprint scraping with respectful rate limiting
  • Semantics Archive plugin for semantic web publication scraping
  • Plugin import scheduling with configurable intervals and run-on-start support
  • Bluesky, Cosmik, Whitewind, and Leaflet backlink plugins
  • Plugin framework with lifecycle management, hook system, and sandboxed execution

Frontend

  • Next.js 15 application with React 19 and App Router
  • 68 page routes covering search, eprints, authors, collections, governance, dashboard, submission, discovery, and authentication
  • 350+ React components organized by domain
  • TanStack Query data fetching with optimistic updates and cache management
  • TipTap rich text editor with cross-reference [[ autocomplete for knowledge graph entities
  • PDF viewer with text selection, highlight anchoring, and annotation overlay
  • Responsive design with mobile-friendly tab scrolling
  • Radix UI component library with Tailwind CSS styling
  • Grafana Faro frontend observability with configurable trace and session sampling

Observability and Monitoring

  • OpenTelemetry instrumentation for distributed tracing across all services
  • Grafana Alloy agent for receiving frontend Faro and backend OTLP telemetry
  • Tempo for distributed trace storage and querying
  • Loki for centralized log aggregation
  • Prometheus metrics collection with custom Chive metrics (indexing events, queue depth, lag, PDS scan durations)
  • Grafana dashboards for visualization
  • Structured JSON logging with configurable log levels
  • Health check endpoints (/health, /readiness) with detailed service status

Infrastructure

  • Multi-stage Docker build with separate production and development targets
  • Docker Compose configurations for production, local development, observability, and documentation
  • Traefik reverse proxy with automatic Let's Encrypt SSL certificate management
  • Governance PDS container running Bluesky PDS image for authority record storage
  • GROBID container for PDF citation extraction with tuned memory limits and health check timing
  • Kubernetes manifests with Helm charts, horizontal pod autoscaling, pod disruption budgets, and RBAC
  • CI pipeline with 7 jobs: unit tests, type checking, lint and format, ATProto compliance, integration tests, build, and deploy
  • Automated deployment via GitHub Actions with environment file generation, Docker image building, and post-deploy verification
  • Build cache cleanup to prevent disk space accumulation from --no-cache Docker builds
  • Separate indexer process for resource isolation from the API server

Documentation

  • Docusaurus documentation site with auto-generated interactive API docs from OpenAPI specification
  • User guide covering searching, submitting eprints, editing, peer review, endorsements, profiles, authorship claiming, tags, collections, discovery, and Bluesky sharing
  • Developer guide covering API layer, authentication, core services, lexicon validation, frontend architecture, rich text, eprint lifecycle, and observability
  • Service documentation for indexing, collections, discovery, claiming, and PDS discovery
  • Plugin documentation with creation guide and built-in plugin reference
  • Storage documentation for PostgreSQL, Elasticsearch, Neo4j, and Redis
  • Architecture overview, concepts (AT Protocol, knowledge graph, data sovereignty), governance, operations, and reference documentation

Testing

  • ATProto compliance test suite with 100% pass rate requirement validating data sovereignty principles
  • End-to-end test suite with 42 Playwright test files covering all major user workflows
  • Integration test suite with 32 test files covering API endpoints, services, and storage layers
  • Unit test suite with 134 test files covering handlers, services, storage adapters, plugins, and utilities
  • Test infrastructure with Docker test stack, seed data scripts, and cleanup utilities