Skip to content

Browsable URLs: people at the root, one address per plan, live libraries - #191

Merged
HamptonMakes merged 6 commits into
mainfrom
hampton/browsable-library-urls
Aug 23, 2026
Merged

Browsable URLs: people at the root, one address per plan, live libraries#191
HamptonMakes merged 6 commits into
mainfrom
hampton/browsable-library-urls

Conversation

@HamptonMakes

@HamptonMakes HamptonMakes commented Aug 22, 2026

Copy link
Copy Markdown
Collaborator

Plans had ugly addresses and no home. /plans/019d54a7-ea13-72d5-bc54-fc44cb9b939a
tells 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

/sam                                    Sam, and Sam's library
/sam/liveorder                          a folder
/sam/liveorder/cart-state-machine       a document

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-machine instead
of /sam/liveorder/liveorder-cart-state-machine-plan. Two plans whose titles
slugify the same way get a 4-char ~suffix on the leaf, collision-only, from an
alphabet 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:

/_/settings          /_/search          /_/notifications
/_/plans             everything you can see, wherever it lives
/_/libraries         every library you can browse

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 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, so settings_path and
publish_plan_path read 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, /search and
the rest all 301 onward. It can shrink; it never has to grow. /api and
/agent-instructions stay at the root: they're published external 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. 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's
library 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/:id 301s onto it.

Two consequences worth naming:

  • Library rows are created with the user now, not on first touch. A library was a
    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.
  • Folder clicks stay inside the library you're browsing. Folder navigation was
    built out of ?folder=<id> against your own workspace, so clicking a folder in
    someone 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_to rather than streamed fragments, because no fragment can be
shared — 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 reading
doesn'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 :placementshas_one :placement, with a unique index and a migration
that 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#library is wherever it's filed, falling back to its author's library when it
isn'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_id on the Organize move ops becomes a
no-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:

  • exact — one URL to one URL, for a retitled plan.
  • prefix — rewrites the head of a path, so one row covers everything beneath a
    renamed folder or handle. O(renames), not O(documents).

That table is a cache, not the record. Every rename is already in plan_events
and library_events with before/after values, append-only, so the rows can be
rebuilt 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 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. HTML GETs only: a Turbo Frame fetch or
a 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=canonical for anything arriving the old
way. Path building lives in Urls::Canonical so jobs and push payloads can reach it
without a view context.

Cross-document links read as documents again

Reference only knew /plans/<uuid>, so a link copied out of the address bar — which
is 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 segment
walk 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-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 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

  • A folder could take a document's address. Collision checking asked only
    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 count
    as 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-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 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).
  • Library counts left out loose plans. A plan at a library root has no
    placement row by design, so counting placements called a library of nothing but
    unfiled work "empty" while browsing it showed the plans.
  • /_/libraries could omit your own library. It read the table directly — exactly
    the 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.
  • 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 handle is globally unique, so a
    leaked row kept alice reserved. That leak is what had been hiding the bug above.

Known gaps

  • Inline links in document bodies are not rewritten. Nothing edits your markdown
    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.
  • Alias pruning and rebuild aren't built yet. The table has the index for
    eviction and the rebuild is possible from events, but neither job exists. Nothing
    prunes today, so nothing breaks; the safety argument is currently theoretical.
  • Cross-library moves log only the destination. Unreachable today (one library
    per owner) and flagged in Plans::Place as the team-library seam.
  • A segment can change hands. When a folder takes a plan's segment, the plan
    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.
  • Readable-link recognition assumes the engine is mounted at /. Mounted at a
    subpath 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 ~gaxa collision suffix, a chained rename, Unicode titles in
Japanese, 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

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 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".

Comment thread engine/app/services/coplan/plans/assign_slug.rb
Comment thread engine/app/services/coplan/urls/canonical.rb
Comment thread engine/app/models/coplan/url_alias.rb Outdated
Comment thread engine/app/controllers/coplan/libraries_controller.rb Outdated
HamptonMakes and others added 4 commits August 22, 2026 13:17
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
HamptonMakes force-pushed the hampton/browsable-library-urls branch from 092f582 to be49c03 Compare August 22, 2026 18:23
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>
@HamptonMakes HamptonMakes changed the title Browsable library URLs: one place per plan, one address per plan Browsable URLs: people at the root, one address per plan, live libraries Aug 22, 2026
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>
@HamptonMakes
HamptonMakes merged commit 5710139 into main Aug 23, 2026
4 checks passed
@HamptonMakes
HamptonMakes deleted the hampton/browsable-library-urls branch August 23, 2026 14:29
HamptonMakes added a commit that referenced this pull request Aug 23, 2026
Codex caught a real false positive: a renamed heading files an untouched
body under a new slug key, which the key-level diff can't tell from
newly written text. Rename three headings in a four-section plan and both
rewrite thresholds pass, so the reader is told the plan was rewritten
when only three heading lines moved.

Bodies that already existed somewhere in the old document are now
carried over rather than written, and don't count toward "most of this
is new". Matching is by exact body, one old section per new one, so
repeated boilerplate can't discount two sections at once. Renamed
sections still highlight — the heading did change — they just no longer
argue for the notice.

Also: /plans/:id redirects to the canonical browsable URL since #191, so
the two request specs added here follow the rebased ones onto
plan_page_path.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
HamptonMakes added a commit that referenced this pull request Aug 23, 2026
* Say "rewritten" instead of highlighting the whole plan

The "changed since you last looked" highlights were making plan pages
look messy in the two most common cases. Open a plan while an agent is
still drafting it — or come back after a rewrite — and every section
differs from your baseline, so every section lit up. The diff was right
and the page said nothing.

Plans::ChangedSections now reports that case as a rewrite instead of a
list of keys: most of the document is new, measured against both the
section count and the volume of text (either measure alone misreads a
common shape — a swarm of one-line sections changing isn't a rewrite,
and neither is one long section getting edited), and only for documents
of four or more sections, since banding a short plan in full is a few
inches of tint rather than noise. The page drops the highlights and
carries one line above the content: "Rewritten since you last looked",
linking to the history.

Second, unrelated fix in the same feature: the controller tinted each
top-level block separately, so one changed section rendered as a stack
of rounded boxes with untinted gaps and a broken-up left bar. Adjacent
changed blocks are now grouped into runs, and the inter-block margins
inside a run become padding within the tint, so a run reads as one
continuous band — rounded at the ends only, with mid-run headings
keeping their breathing room inside the band instead of punching a gap
through it.

Verified against the real page in both states, before and after.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* Don't count renamed headings as rewriting

Codex caught a real false positive: a renamed heading files an untouched
body under a new slug key, which the key-level diff can't tell from
newly written text. Rename three headings in a four-section plan and both
rewrite thresholds pass, so the reader is told the plan was rewritten
when only three heading lines moved.

Bodies that already existed somewhere in the old document are now
carried over rather than written, and don't count toward "most of this
is new". Matching is by exact body, one old section per new one, so
repeated boilerplate can't discount two sections at once. Renamed
sections still highlight — the heading did change — they just no longer
argue for the notice.

Also: /plans/:id redirects to the canonical browsable URL since #191, so
the two request specs added here follow the rebased ones onto
plan_page_path.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant