Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
204 changes: 149 additions & 55 deletions CONTEXT.md

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
Expand Up @@ -2,14 +2,14 @@

Status: Accepted. Renumbered and rewritten from the duplicate ADR 0043 after [ADR 0047](./0047-access-link-signed-url-with-fragment-encoded-payload.md) replaced code-bearing Access Links with fragment-encoded signed URLs. Amended 2026-06-11: Access Link Signed URLs remain the unauthenticated recipient discovery model, but publish surfaces no longer create or return Share Links by default.

The **Access Link Signed URL** minted from a **Share Link** is the unauthenticated human handoff URL for live-updating Artifact Viewers when a caller explicitly creates a public/shareable link. A receiving agent discovers the **Agent View** from the same **Access Link Signed URL** that a human opens: parse `https://app.agent-paste.sh/al/{publicId}#{blob}`, preserve the fragment, and call `POST /v1/access-links/resolve` with `{ public_id, blob }`. The response is the **Agent View** plus short-lived content-gateway URLs from [ADR 0028](./0028-signed-url-tokens-for-content-gateway-authorization.md). There are no code-scoped bearer routes and no `Link: rel="agent-view"` header on `content` responses, because `content` sees only the derived content token and cannot reconstruct the fragment credential.
The **Access Link Signed URL** minted from a **Share Link** is the unauthenticated human handoff URL for live-updating Artifact Viewers when a caller explicitly creates an unlisted/shareable link. A receiving agent discovers the **Agent View** from the same **Access Link Signed URL** that a human opens: parse `https://app.agent-paste.sh/al/{publicId}#{blob}`, preserve the fragment, and call `POST /v1/access-links/resolve` with `{ public_id, blob }`. The response is the **Agent View** plus short-lived content-gateway URLs from [ADR 0028](./0028-signed-url-tokens-for-content-gateway-authorization.md). There are no code-scoped bearer routes and no `Link: rel="agent-view"` header on `content` responses, because `content` sees only the derived content token and cannot reconstruct the fragment credential.

## Consequences

- The distributed handoff string is the full **Access Link Signed URL** from ADR 0047, including its fragment. Agents must not drop the fragment when normalizing or logging URLs.
- `POST /v1/access-links/resolve` is the unauthenticated Agent View discovery endpoint. It accepts the `public_id` path segment and fragment `blob`; every invalid, expired, revoked, locked, retained, or deleted case returns the generic `not_found` envelope from [ADR 0036](./0036-error-envelope-and-generic-404-boundary.md).
- `GET /v1/artifacts/{id}/agent-view` remains the authenticated member-or-key-scoped surface and is the publisher's follow-up handle. It is never returned through an unauthenticated surface, so the artifact id stays out of distributed link strings.
- User-facing **Publish Result** surfaces should return the authenticated **Artifact URL** by default. They return `access_link_url`, the **Access Link Signed URL** minted from a **Share Link**, only after explicit public/shareable link creation. `agent_view_url` is for the publishing actor, not for unauthenticated recipients. Recipients derive their **Agent View** through the resolve endpoint above when they receive an **Access Link Signed URL**.
- User-facing **Publish Result** surfaces should return the authenticated **Artifact URL** by default. They return `access_link_url`, the **Access Link Signed URL** minted from a **Share Link**, only after explicit unlisted/shareable link creation. `agent_view_url` is for the publishing actor, not for unauthenticated recipients. Recipients derive their **Agent View** through the resolve endpoint above when they receive an **Access Link Signed URL**.
- The CLI and internal `api-client` accept an Access Link Signed URL wherever an artifact read URL is expected. They parse `{ publicId, blob }`, call resolve, and then follow the returned content-gateway URLs.
- The **Content Origin** does not emit `Link: rel="agent-view"` for Access Link responses. A `Link` header would either omit the fragment credential and fail, or reintroduce a server-visible bearer URL, which ADR 0047 explicitly removed.
- No `GET /v1/r/{code}/agent-view` or `GET /v1/s/{code}/agent-view` endpoints are created. The `publicId` alone is log-safe but not a credential.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

Status: Accepted. Supersedes the scope-granting mechanism of [ADR 0061](./0061-mcp-worker-with-oauth-only-via-auth0-dcr.md). The MCP transport, OAuth-via-AuthKit, CIMD/DCR registration, resource-indicator audience binding, the twelve-tool surface, and the `write`/`read`/`share` scope vocabulary are all retained from ADR 0061 unchanged. Only the **source** of a caller's granted scopes changes: from a WorkOS-issued `scope` token claim (which AuthKit does not and cannot issue) to a set derived from the caller's **Workspace Member** role inside `api`.

> **Amendment (private-first publish, [ADR 0086](./0086-private-first-publish.md)):** The `write`/`read`/`share` MCP scope vocabulary referenced throughout this ADR has since been **unified with the API scope vocabulary** (`read`/`publish`/`admin`). There is now **one** scope set shared by `api` and MCP: a member's MCP scopes **are** their stored API scopes verbatim. The translation layer described below (`apiScopesToMcpScopes` / `mcpScopesToApiScopes` in `packages/contracts/src/mcp/scopes.ts`) has been **removed** — there is nothing to map. Scope meaning is now canonical: `read` = view your stuff; `publish` = change your stuff, **including managing an Artifact's own public access** (make_public, list and revoke that Artifact's links — these are `publish`-scope actions, **not** `admin`); `admin` = account/workspace management (API keys, settings, audit, billing). The decision below (scopes are derived from the member, not the WorkOS token; the Worker verifies issuer + audience only and pre-flight-gates against `mcp.whoami`) **still holds** — only the vocabulary and the now-deleted translation step changed. Read references to `write`/`share` below as `publish`/`publish` respectively, and `mcp.whoami` now returns the member's API scopes verbatim.
> **Amendment (private-first publish, [ADR 0086](./0086-private-first-publish.md)):** The `write`/`read`/`share` MCP scope vocabulary referenced throughout this ADR has since been **unified with the API scope vocabulary** (`read`/`publish`/`admin`). There is now **one** scope set shared by `api` and MCP: a member's MCP scopes **are** their stored API scopes verbatim. The translation layer described below (`apiScopesToMcpScopes` / `mcpScopesToApiScopes` in `packages/contracts/src/mcp/scopes.ts`) has been **removed** — there is nothing to map. Scope meaning is now canonical: `read` = view your stuff; `publish` = change your stuff, **including managing an Artifact's own unauthenticated access** (make_public, list and revoke that Artifact's links — these are `publish`-scope actions, **not** `admin`); `admin` = account/workspace management (API keys, settings, audit, billing). The decision below (scopes are derived from the member, not the WorkOS token; the Worker verifies issuer + audience only and pre-flight-gates against `mcp.whoami`) **still holds** — only the vocabulary and the now-deleted translation step changed. Read references to `write`/`share` below as `publish`/`publish` respectively, and `mcp.whoami` now returns the member's API scopes verbatim.

ADR 0061 assumed the MCP consent screen would request from `{write, read, share}` and that WorkOS would mint those into the access token's `scope` claim, which both `apps/mcp` and `api` would read. That assumption is wrong for how WorkOS AuthKit actually works, and the entire authenticated MCP surface is non-functional as a result.

Expand Down
24 changes: 15 additions & 9 deletions docs/adr/0086-publish-is-content-only-private-first.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,11 @@
# Publish Is Content-Only and Private-First; Going Public Is a Separate Step

> **Planned terminology amendment:** [ADR 0087](./0087-public-artifacts-and-unlisted-share-links.md)
> reserves **Public Artifact** for a future CDN-backed public distribution model
> and reclassifies the current Share Link handoff as unlisted. Until that model is
> implemented, this ADR still describes shipped CLI/MCP behavior: `make_public`
> and `agent-paste make-public` mint or reuse the Artifact's one Share Link.

agent-paste is private-first: an **Artifact** is for its owner until they decide
otherwise. The publish surfaces must honor that by default and never expose anything
by URL that the caller did not explicitly ask to expose.
Expand Down Expand Up @@ -35,15 +41,15 @@ Link** (when private) and the public **Share Link** (when shared), surfaced thro
only from the Artifact id — no token, signature, or expiry — and `add_revision`
republishes into the same id, so the link never changes across revisions and
live-updates to the latest Published Revision. It is member-only (publish never
grants public access) and stops resolving only when the Artifact itself is deleted
or swept by Auto Deletion. The `expires_at` in the publish response is the
Artifact's content lifetime, not a link expiry. The mental model: a permanent,
private, internal link that is always there; making it public is a separate,
revocable Share Link.
- **Going public is a separate, explicit verb.** `make_public` (MCP) and
`agent-paste make-public` (CLI), replacing `create_share_link`, mint or reuse the
one revocable **Share Link** (`access_links.type='share'`) and return its public,
no-login **Access Link Signed URL**. This is the only way an Artifact becomes
grants unauthenticated access) and stops resolving only when the Artifact itself
is deleted or swept by Auto Deletion. The `expires_at` in the publish response
is the Artifact's content lifetime, not a link expiry. The mental model: a
permanent, private, internal link that is always there; unauthenticated sharing
is a separate, revocable Share Link.
- **Creating unauthenticated access is a separate, explicit verb.** `make_public`
(MCP) and `agent-paste make-public` (CLI), replacing `create_share_link`, mint or
reuse the one revocable **Share Link** (`access_links.type='share'`) and return
its no-login **Access Link Signed URL**. This is the only way an Artifact becomes
reachable without login.
- **Revocation is independent of content.** `revoke_access_link` kills a Share Link
(or Revision Link) without touching the Artifact, its data, its revisions, or its
Expand Down
93 changes: 93 additions & 0 deletions docs/adr/0087-public-artifacts-and-unlisted-share-links.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
# Public Artifacts and Unlisted Share Links

Status: Planned. Current shipped CLI/MCP behavior still treats `make_public` /
`agent-paste make-public` as Share Link minting until the implementation specs
and routes are updated.

## Context

ADR 0086 made publish private-first and moved unauthenticated handoff into a
separate `make_public` step that mints the Artifact's one revocable Share Link.
That fixed accidental public-by-flag publishing, but it reused "public" for two
different jobs:

- unlisted, revocable handoff to a specific audience
- broad public distribution that should survive traffic spikes and benefit from
aggressive edge caching

Those jobs have different control and caching expectations. A user iterating on
an Artifact may need a no-login URL that can be revoked cleanly. A user sharing a
finished Artifact with many people needs a stable permalink and CDN-shaped cost
profile. Treating both as "public" makes the product and implementation lie.

## Decision

- **Share Links are unlisted.** A Share Link remains an Access Link that follows
the latest Published Revision, opens the Artifact Viewer, and can receive Live
Updates. It is the control-oriented unauthenticated path: revocable,
expirable, not a permalink, and not the aggressive edge-cache surface.
- **Public Artifacts are a separate planned distribution model.** A Public
Artifact has a stable ID-only Public URL shaped `/p/{publicId}`. The Public ID
is separate from the Artifact id and has no slug.
- **The first public action is atomic.** It allocates the Public ID, creates the
Public URL, and selects the initial Public Version in one durable action.
There is no reserved Public URL state before the first Public Version is
selected.
- **Public Versions are frozen.** A Public Version points at one Published
Revision. Ordinary Publish Updates do not move it. Moving the public pointer is
an explicit action available to Agent Credentials with publish Scope.
- **Public Offline is soft.** Clearing the selected Public Version keeps the
Public URL and Public ID reserved while stopping the Public Resolver from
serving broad public content. It is for owner/agent control, not hard abuse or
legal takedown.
- **Public Resolver and Public Version Assets are separate cache surfaces.** The
Public Resolver is mutable and must change quickly through short cache lifetime
or explicit purge. Public Version Assets are immutable for one Published
Revision and are the aggressive edge-cache surface for broad traffic.
- **Platform Lockdown is the hard public takedown path.** Operator-only Platform
Lockdown blocks the Public Resolver and Public Version Assets, using cache
purge and deny controls where available. It remains distinct from Access Link
Lockdown and Public Offline.
- **Public pointer changes are audit-worthy.** Public Version changes and Public
Offline changes create Audit Events with a redacted Change Summary containing
the Public ID, old and new Published Revision ids or null, actor, and calling
surface.

## Consequences

- The current `make_public` / `agent-paste make-public` name becomes misleading:
it creates an unlisted Share Link today, not the future Public Artifact model.
A follow-up implementation should choose explicit verbs before shipping true
public distribution, for example `share` / `create_share_link` for unlisted and
`make_public` / `select_public_version` for true public.
- Existing shipped specs and user docs remain current until that implementation
lands: publish is private-first, Share Link creation is explicit, and Access
Link Signed URLs remain the only shipped no-login latest-moving handoff.
- The Public Artifact model needs schema, API, CLI, MCP, cache, audit, and
operator-lockdown work before it can be described as shipped behavior in
`docs/specs/`.
- Public should be chosen for broad distribution and traffic spikes. Unlisted
Share Links should be chosen when revocation and takedown control matter more
than cache-level distribution.

## Considered Options

- **Keep calling Share Links public.** Rejected. It hides the cache and
revocation tradeoff and makes CDN-backed distribution sound safer to revoke
than it is.
- **Use signed URLs for public distribution.** Rejected. Signed URLs are the
right shape for unlisted grants, but broad public distribution needs a stable
permalink and immutable cacheable assets.
- **Let Publish move the public pointer automatically.** Rejected. Public
versions should be frozen so the public page does not live-update while an
agent iterates.
- **Add slugs to Public URLs.** Rejected for the canonical URL. Slugs create
uniqueness and rename concerns without adding enough product value. The Public
ID is the canonical segment.

## What this ADR is not

- Not an implementation of Public Artifacts.
- Not a change to the current Access Link Signed URL model from ADR 0047.
- Not a change to the current shipped `make_public` command until a follow-up
spec and implementation PR changes it.
Loading