Browsable URLs: people at the root, one address per plan, live libraries - #191
Open
HamptonMakes wants to merge 6 commits into
Open
Browsable URLs: people at the root, one address per plan, live libraries#191HamptonMakes wants to merge 6 commits into
HamptonMakes wants to merge 6 commits into
Conversation
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 092f582755
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
Every prefix of a CoPlan URL is now a real page. `/l` lists the
libraries, `/l/:handle` is a library root, and each folder below it is
its own address:
/l/orders/live-cart/roadmap
Resolution walks the path a segment at a time, filesystem-style, rather
than matching a stored joined string. That's what makes a folder rename
free: the folder's own slug changes and every URL beneath it follows,
with no rows to rewrite. Folders win ties on the last segment — a folder
has children, so mistaking one breaks a whole subtree.
Leaf segments strip whatever the path already says: the library handle,
every folder on the way down, and the plan type. A plan titled
"LiveOrder Cart Roadmap" filed under LiveOrder is just `cart-roadmap`,
which is the point — a folder full of `liveorder-*` plans is unreadable.
Comparison ignores hyphens, so it works whether the folder was named
"LiveOrder", "Live Order", or "live-order". Slugs follow the title;
a `~abcd` suffix appears only where a slug is actually contested, so
clean URLs stay clean.
Old links keep resolving. `coplan_url_aliases` holds one prefix row per
rename — O(renames), not O(documents) — plus an exact row per retitle,
and it's a rebuildable cache over the existing event logs, not a record
of truth. Draft retitles record nothing, and rows that never get hit can
be pruned. Legacy `/libraries/:id` and `/library` 301 onto the canonical
path so address bars converge instead of forking.
`/l/` seals its own namespace, which kept this a pure addition: no
existing route moved, and the reserved-handle list is five names.
Access control is unchanged and stays a DB predicate — `Plan.visible_to`
decides what a browser sees. A readable URL is not a permission.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A plan lived on any number of shelves before: `has_many :placements`, a Save button that filed someone else's document onto your own, and a "which library is this in?" question whose answer depended on who was asking. That made a canonical URL impossible — a document with three locations has three addresses and no home. So it lives in exactly one place now, the way a file does. Filing it somewhere else is a move, never a second copy. `Plan#library` is wherever it's filed, falling back to its author's library when it isn't filed at all, and every reader sees the same location because there is only one. Moving takes authority on both sides: write access to the destination and a claim on where it sits now (authorship, or write access to the library it's currently in). That second half is the seam that will let a team reorganize its own library without letting anyone walk off with someone else's document. `from_library_id` on the Organize move ops becomes a no-op — filing somewhere new already takes it out of where it was, so there's no source side left to name. With one location settled, `/plans/<uuid>` stops being canonical. It 301s onto the readable address — permanent, because the id form is the page's old name, not a redirect-of-the-day — carrying the query string so `?thread=` deep links and the legacy `?tab=history` hop still land. Every link the app generates now points at the readable form directly, including push payloads, so nobody pays for the extra hop. The document page carries `rel=canonical` for anything that arrives the old way. Path building moves to `Urls::Canonical` so controllers, jobs, and push payloads can reach it without a view context; `BrowseHelper` delegates. Also fixes slugs mangling non-ASCII titles — "incorporación" became `incorporacio-n` and a Japanese title became `2027`. Slugs normalize NFC and keep Unicode letters; library handles stay ASCII, since a handle is the root of every URL under it and gets typed and read aloud. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Three things the canonical-URL switch broke or exposed. **Cross-document links stopped reading as documents.** Reference only knew `/plans/<uuid>`, so every link copied out of the address bar after the switch — which is now every link — landed in the References footnote as a generic "link" with no target_plan. It recognizes the readable form too, resolving the path to an id through the same segment walk the router uses, aliases included: a link written before a rename still names the document it was always about. **The truncation lists never included libraries.** Two specs run without transactional fixtures and clean up by hand; their lists predate libraries and folders, so those rows accumulated across runs. A library handle is globally unique, so a leaked row keeps "alice" reserved — and the list is now named once instead of copy-pasted twice. **`/l` could omit your own library.** Libraries are materialized on first touch, but the index read the table directly, which is exactly the path that skips the invariant. A viewer who hadn't yet loaded a page linking their library got a list without it. It had looked fine only because a stale row from an earlier run happened to be sitting in the test DB. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
HamptonMakes
force-pushed
the
hampton/browsable-library-urls
branch
from
August 22, 2026 18:23
092f582 to
be49c03
Compare
The `/l/` prefix was doing nothing but taking up the segment that should
carry the most weight in a shared link. Handles move to the root, so a
document's address reads like the person whose library it's in:
/sam Sam, and Sam's library
/sam/liveorder a folder
/sam/liveorder/cart-state-machine a document
**Everything that isn't a place moves under `_`.** Settings, search,
notifications, the cross-library plan view, and the id-based mutation
endpoints. That single character is the whole reservation: a handle can
never *be* `_`, because slug rules strip non-alphanumerics, so the split
is structural rather than a list someone has to keep updating. `_` is
reserved inside a library too (`/sam/_/…`), by the same mechanism, so
library-scoped pages have somewhere to go that can't collide with a
folder name. Rails' `scope "_"` prefixes paths and not helper names, so
`settings_path` and the rest read the same at every call site.
The legacy block in the router is the one fixed list, frozen at the paths
that shipped before this — all 301s. `/api` and `/agent-instructions`
stay at the root: published contracts, in every API response and in
llms.txt.
**A person's page is their library.** `/people/:id` rendered a read-only
shelf; `/l/<handle>` rendered a different read-only shelf. Neither had
filters, folder counts, or "since you last looked" — which is what made
someone else's library feel like a lesser app rather than the same app
pointed somewhere else. Both are gone. One page renders every library,
and what you can *do* to what's in it is a question for the buttons
(`Library#writable_by?`, as `@can_write`), not for which view to render.
The identity that lived on the profile page is now that page's header.
Two things fall out of that. Library rows are created with the user
instead of on first touch, because a link to a colleague who has never
signed in has to work — the migration backfills everyone who predates
it. And folder navigation is a path now: it was built out of
`?folder=<id>` against your own workspace, so clicking a folder in
someone else's library bounced you into yours.
**Libraries are live.** Filing a document, moving it, retitling or
hiding it, creating or renaming a folder — all of it changes what a
library page shows, and none of it reached the page. Each now tells the
library, and every browser watching it re-fetches: readers included,
since a filter you're looking at is as stale as one you could edit.
`broadcast_refresh_to` rather than streamed fragments, because no
fragment can be shared — visibility is per-viewer, folder and filters
are per-viewer, forms carry per-session tokens. The broadcast carries no
content, just the news that there is some. With morph + scroll preserve,
a page you're reading doesn't blink, lose your place, or close the
popover you just opened because someone else filed something. Per
library rather than per folder: a rename high in the tree changes every
listing beneath it.
**Readable links needed a host check.** `/plans/<uuid>` was
self-identifying; `/sam/liveorder/cart-roadmap` is shaped like any other
site's URL, so matching on shape alone would have typed half the links
people paste as CoPlan documents. Classification now takes the request
host; resolution stays separate from it, because the extractor runs in a
job with no request to ask.
Also: the host's copy of the URL-segments migration was installed before
that migration learned about Unicode folder slugs and ASCII handles, so
it had been running the old rules. Re-synced, and the handle backfill now
starts from the reserved list so a person whose ldap is "settings" gets
one the app would actually accept.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Four things from review, three of them real. **A folder could take a document's address.** `contested?` asked only whether another *plan* held the slug, but `Urls::Resolve` hands the segment to a folder when both want it — a folder has a subtree, so mistaking it for a document breaks more than one page. A plan sharing a sibling folder's slug therefore had no reachable address at all, and `/plans/<uuid>` 301'd onto the folder. Folders now count as siblings, in both directions: a plan created next to a same-named folder takes the suffix, and a folder created or renamed onto a plan's segment re-slugs the plan it would have shadowed. Folders never take a suffix — a folder's segment appears in every URL beneath it — so the plan is the one that moves. **A prefix alias skipped the path it was recorded for.** Candidates were every *ancestor* of the requested path and not the path itself, so renaming a folder fixed every link into it except the link to the folder, and a renamed library handle was never matched at all: a one-segment path has no ancestors. **Library counts left out loose plans.** A plan at a library root has no placement row by design, so counting placements alone called a library of nothing but unfiled work "empty" — and undercounted mixed ones, while browsing the library showed them. **Mount prefix.** `plan_browse_path` in views now builds from the view's own route helpers, so a host mounting the engine at /coplan keeps the prefix. `Urls::Canonical` stays as-is and says why: it exists for callers with no request, where engine route helpers can't know the mount point — the same limitation SlackNotificationJob and the API base controller already live with. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Plans had ugly addresses and no home.
/plans/019d54a7-ea13-72d5-bc54-fc44cb9b939atells you nothing, can't be read aloud, and can't be guessed. Worse, a plan could
live on any number of shelves at once, so "which library is this in?" had an answer
that depended on who was asking — which makes a canonical URL impossible. A document
with three locations has three addresses and no home.
This gives every plan one place and one address.
Browsable URLs
People are the root namespace. Every prefix is a real page, so trimming a segment off
any URL walks you up the tree. One action serves all three, because they're one thing
— a place in a library — and which of the three a path names isn't knowable until the
segments are resolved.
Resolution walks one segment at a time: handle, then folder slug within the previous
folder, then a plan slug in whatever folder we landed in. Nothing stores a joined
path, which is why renaming a folder can't invalidate anything beneath it.
Segments strip whatever the URL already says — the handle, every ancestor folder, the
plan type, the word "plan" — so you get
/sam/liveorder/cart-state-machineinsteadof
/sam/liveorder/liveorder-cart-state-machine-plan. Two plans whose titlesslugify the same way get a 4-char
~suffixon the leaf, collision-only, from analphabet with no
0/o/1/l.Unicode survives:
/aiko/operations/reliability/信頼性向上ロードマップ-2027年前半.Slugs normalize NFC and keep Unicode letters. Library handles stay ASCII, since a
handle is the root of every URL under it and gets typed and read aloud.
Everything that isn't a place lives under
_Handles sitting at the root means the app's own pages have to move out of the way:
That single character is the whole reservation. A handle can never be
_, becauseslug rules strip non-alphanumerics, so the split is structural rather than a list
someone has to remember to update.
_is reserved inside a library too (/sam/_/…,by the same mechanism), so library-scoped pages have somewhere to go later that can't
collide with a folder name.
Rails'
scope "_"prefixes paths and not helper names, sosettings_pathandpublish_plan_pathread the same at every call site as they did before.The legacy block near the bottom of the router is the one fixed list, frozen at the
paths that shipped before this —
/plans,/people/:id,/settings,/searchandthe rest all 301 onward. It can shrink; it never has to grow.
/apiand/agent-instructionsstay at the root: they're published external contracts, in everyAPI response and in
llms.txt.A person's page is their library
/people/:idrendered a read-only shelf./l/<handle>rendered a different read-onlyshelf. Neither had filters, folder counts, or "since you last looked" — which is what
made someone else's library feel like a lesser app rather than the same app, pointed
somewhere else.
Both are gone. Every library renders one page. What you can do to what's in it is a
question for the buttons —
Library#writable_by?, surfaced to the views as@can_write— not a question of which view to render. So browsing a colleague'slibrary gives you their folder tree, their filters, their counts, and what's changed
since you last looked, minus New folder and drag-to-file.
The identity that used to live on the profile page is now the header of that one page:
avatar, name, title · team, handle, and the directory link if the host provides one.
/people/:id301s onto it.Two consequences worth naming:
filing cabinet, so materializing it lazily was fine; it's a person's page now, and a
link to a colleague who has never signed in has to work. The migration backfills the
rows for everyone who predates this.
built out of
?folder=<id>against your own workspace, so clicking a folder insomeone else's library bounced you into yours.
Live libraries
Filing a document, moving it, retitling it, hiding it, creating or renaming a folder —
all of it changes what a library page shows, and none of it used to reach the page.
Now every one of those tells the library, and every browser watching that library
re-fetches. Readers too, not just the owner: a filter you're looking at is as stale as
one you could edit.
broadcast_refresh_torather than streamed fragments, because no fragment can beshared — visibility is per-viewer, the folder and filters are per-viewer, and the forms
carry per-session tokens. The broadcast carries no content, just the news that there is
some, and each browser re-renders its own page. With
turbo-refresh-method: morph+turbo-refresh-scroll: preserve, a page you're readingdoesn't blink, lose your scroll position, or close the popover you just opened because
someone else filed something. Turbo tags the stream with the acting request id, so the
browser that caused the change doesn't refresh twice.
Per library rather than per folder: a rename high in the tree changes every listing
beneath it, so a folder-grained stream would have to fan out to all of them.
One place per plan
has_many :placements→has_one :placement, with a unique index and a migrationthat collapses existing rows (keeping the author's own; oldest otherwise). Filing a
plan somewhere else is a move, never a second copy. The Save button that filed
someone else's document onto your own shelf is gone.
Plan#libraryis wherever it's filed, falling back to its author's library when itisn't filed at all. Every reader sees the same location, because there is only one.
Moving takes authority on both sides: write access to the destination, plus
authorship or write access to the library it sits in now. That second half is the
seam that will let a team reorganize its own library without letting anyone walk off
with someone else's document.
from_library_idon the Organize move ops becomes ano-op — filing somewhere new already takes it out of where it was.
Renames don't break links
Old URLs keep working through
coplan_url_aliases. Two kinds:renamed folder or handle. O(renames), not O(documents).
That table is a cache, not the record. Every rename is already in
plan_eventsand
library_eventswith before/after values, append-only, so the rows can berebuilt from scratch — which is what will make pruning safe later. Aliases are
consulted only after the real walk fails, so a live page always beats a stale
alias, and chained renames follow up to 5 hops (which also breaks any cycle bad data
could introduce).
/plans/<uuid>is no longer canonicalIt 301s onto the readable address — permanent, because the id form is the page's old
name, not a redirect-of-the-day — carrying the query string so
?thread=deep linksand the legacy
?tab=historyhop still land. HTML GETs only: a Turbo Frame fetch ora JSON caller asked for that exact URL and gets a response, not a hop.
Every link the app generates now points at the readable form directly — views,
controller redirects, search results, web-push payloads — so nobody pays for the
extra hop. The document page carries
rel=canonicalfor anything arriving the oldway. Path building lives in
Urls::Canonicalso jobs and push payloads can reach itwithout a view context.
Cross-document links read as documents again
Referenceonly knew/plans/<uuid>, so a link copied out of the address bar — whichis now every link — landed in the References footnote as a generic "link" with no
target_plan. It understands the readable form too, resolving through the same segmentwalk the router uses, aliases included: a link written before a rename still names the
document it was always about.
The catch is that
/plans/<uuid>was self-identifying and a readable path isn't./sam/liveorder/cart-roadmapis shaped like any other site's URL, so matching on shapealone would have typed half the links people paste as CoPlan documents. Classification
now takes the request host and only treats a readable path as ours when it is ours;
resolution (which plan) stays separate from classification (what kind of link),
because the extractor runs in a job with no request to ask.
Also in here
whether another plan held the slug, but resolution hands the segment to a
folder when both want it — so a plan sharing a sibling folder's slug had no
reachable address, and
/plans/<uuid>301'd onto the folder. Folders now countas siblings in both directions: a plan created beside a same-named folder takes
the
~suffix, and a folder created or renamed onto a plan's segment re-slugsthe plan it would have shadowed. Folders never take a suffix — a folder's
segment appears in every URL beneath it — so the plan is the one that moves.
ancestor of the requested path and not the path itself, so a folder rename
fixed every link into the folder except the link to the folder, and a renamed
library handle never matched at all (a one-segment path has no ancestors).
placement row by design, so counting placements called a library of nothing but
unfiled work "empty" while browsing it showed the plans.
/_/librariescould omit your own library. It read the table directly — exactlythe path that skips the materialize-on-first-touch invariant. Moot now that rows are
created eagerly, but the accessor is still what the index goes through.
transactional fixtures and clean up by hand; their lists predate libraries and
folders, so those rows accumulated across runs. A handle is globally unique, so a
leaked row kept
alicereserved. That leak is what had been hiding the bug above.Known gaps
on someone else's rename. The link keeps working (the alias catches it and the
address bar converges), but the text stays stale until someone edits it. Deliberate
— rewriting people's prose seems worse than a redirect.
eviction and the rebuild is possible from events, but neither job exists. Nothing
prunes today, so nothing breaks; the safety argument is currently theoretical.
per owner) and flagged in
Plans::Placeas the team-library seam.gets a new address and anyone following the old link lands on the folder that
now owns that name — a live page beats an alias, deliberately. The document
keeps an address instead of losing one, which is the trade I'd make again, but
it isn't a redirect to the same thing.
/. Mounted at asubpath it stops recognizing its own readable links rather than mis-recognizing
other people's — it degrades toward "generic link", never toward a false positive.
Testing
1715 examples, 0 failures, rubocop clean. All three migrations roll down and back up clean.
Dev seeds showcase the feature: a prefix alias (
sam/order-platform→sam/liveorder), a~gaxacollision suffix, a chained rename, Unicode titles inJapanese, Arabic, and Spanish, and a seeded cross-document link written the way an
agent would write it now — by readable address, resolved to a real reference.
🤖 Generated with Claude Code