Skip to content

Private-first publish: one permanent private link, explicit make_public, unified scopes#525

Merged
isuttell merged 5 commits into
mainfrom
private-first-publish
Jun 14, 2026
Merged

Private-first publish: one permanent private link, explicit make_public, unified scopes#525
isuttell merged 5 commits into
mainfrom
private-first-publish

Conversation

@isuttell

@isuttell isuttell commented Jun 14, 2026

Copy link
Copy Markdown
Contributor

What & why

Started as "publish an HTML report and give me a link." Publishing with --share made it public without consent on a private-first platform, and the later shared:false on revise was a lie. This rebuilds the publish surface around the right model:

  • Private Link (/v/<artifactId>): permanent, stable, member-only, always private. The one link every publish returns (private_url). Derived only from the Artifact id — no token, signature, or expiry — and add_revision republishes into the same id, so it never changes across revisions and live-updates in place. Stops resolving only when the Artifact is deleted or swept by Auto Deletion.
  • Share Link (/al/<id>#...): the public counterpart, off by default, minted only by an explicit make_public (MCP) / agent-paste make-public (CLI). Revocable any time with revoke_access_link — kills public access without touching the Artifact, its data, its revisions, or its Private Link.
  • Publish/add_revision are content-only: no share input, no shared output, no access_link_url. The /artifacts/<id> console is never returned to an agent or user.

Supersedes ADR 0085, amends ADR 0084 and ADR 0079; adds ADR 0086. Specs are the source of truth and updated to match.

Key decisions

  • Private Link is a plain _authed route, not an access_links row — nothing to sign, leak, or revoke. The public /al/ resolve gate is untouched, so existing Share Links keep resolving.
  • make_public reuses the one active Share Link instead of minting duplicates (an Artifact has at most one live Share Link). Makes the "mints or reuses the one Share Link" contract true in code.
  • Unified scope vocabulary read/publish/admin, shared verbatim by API and MCP. The old MCP-only write/share names and the mcpScopesToApiScopes/apiScopesToMcpScopes translation layer are deleted. The four agent access-link routes require publish (managing your own Artifact's public access is content authority); admin is dashboard-only.

Verified end-to-end on preview

  • publish → one private_url/v/<id>, no share/shared/access_link_url
  • private by default (no access links until make_public)
  • make_public → no-login /al/ URL that resolves 200; second call reuses the same single link
  • revoke → public resolve returns 404; Artifact + Private Link survive
  • post-revoke make_public mints a fresh public link (fixed an idempotency-replay edge where a reused key could point mint at a revoked link)
  • /v viewer no longer links to the banned console

pnpm verify + pnpm test:coverage green (floors held). OpenAPI goldens regenerated.

Follow-up (not in this PR)

The deployed prod MCP server's instructions text still teaches share:true on publish; it updates on the next prod deploy and should land in lockstep with merge.

🤖 Generated with Claude Code


Note

High Risk
Breaking publish and MCP contracts, changes to who can mint public links and what URLs agents hand users, plus new member viewer routing—core access-control and agent-integration surface.

Overview
Private-first publish replaces the old viewer_url / share / shared model: every publish path (REST, CLI, MCP, shared runPublish) returns only private_url — the login-walled clean viewer at /v/<artifactId> — and no longer accepts visibility flags or mints Share Links during publish. The API publish coordinator drops all share-link creation/reuse logic; signPublishResult emits private_url instead of artifact_url.

Going public is explicit: MCP make_public (replacing create_share_link) and CLI agent-paste make-public create or reuse the one Share Link and return its signed public URL, with a salted idempotency retry when a replayed create points at a revoked link.

Web: new authenticated route /v/$artifactId for the handoff viewer; shared ArtifactLiveViewer (live updates) is used by both /v and the dashboard /artifacts console (management only, never returned by agents).

Scopes: one vocabulary read / publish / adminmcp.whoami returns member API scopes verbatim (no MCP translation layer); link tools align with publish for managing your artifact’s public access.

Docs & ADRs: CONTEXT.md, agent/docs surfaces, CLI/MCP READMEs, and ADR 0086 (supersedes 0085); amendments to 0079 and 0084. OpenAPI/contracts use private_url on publish results.

Reviewed by Cursor Bugbot for commit 8335df4. Bugbot is set up for automated code reviews on this repo. Configure here.

isuttell and others added 4 commits June 14, 2026 14:42
…te_url, explicit make_public

Publish and add_revision are now content-only on every surface (CLI, MCP, REST).
They take no visibility input and return exactly one link, `private_url`, the
login-walled clean viewer at `/v/<artifactId>` for a Workspace Member. There is
no `share` input and no `shared` output anywhere; the misleading viewer_url/
shared flip is gone. Going public is a separate, explicit, revocable step:
`make_public` (MCP) / `agent-paste make-public` (CLI), with `revoke_access_link`
as the go-private verb. The `/artifacts/<id>` console is never returned to an
agent or user.

Private Link is a plain `_authed` route, not an access_links row: nothing to
sign, nothing to leak, nothing to revoke. The public `/al/<publicId>` resolve
gate is untouched, so existing Share Links keep resolving. A new shared
ArtifactLiveViewer backs both the console and the clean `/v` viewer.

make_public now reuses an Artifact's one active (non-revoked, unexpired) Share
Link instead of minting a duplicate, so an Artifact has at most one live Share
Link — making the "mints or reuses the one Share Link" contract true in code,
not just in copy. revision links are never deduped.

Unify the scope vocabulary: one set `read`/`publish`/`admin` shared verbatim by
API and MCP. The old MCP-only `write`/`share` names are gone, and the
mcpScopesToApiScopes/apiScopesToMcpScopes translation layer is deleted (MCP
scopes are the member's stored API scopes verbatim, per ADR 0079). The four
agent access-link routes require `publish` (managing your own Artifact's public
access is content authority); `admin` is dashboard-only and no MCP tool needs
it. web.accessLinks.lockdown.* stay `admin`.

Supersedes ADR 0085 (publish-returns-one-viewer-url, which made the link flip
and surfaced the lie); amends ADR 0084 (output shape) and ADR 0079 (scope
vocabulary unified). Adds ADR 0086. Specs are updated as the source of truth
(api/web/cli/ephemeral-publish, CONTEXT vocab, docs/mcp.md, apex agent docs).
OpenAPI goldens regenerated.

Early-alpha break: CLI and MCP ship in lockstep; the deployed MCP server
`instructions` text still teaches `share:true` and updates on deploy.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…tifacts console

The /v clean viewer's own header Wordmark linked to /artifacts/$artifactId — the
dashboard console page that is explicitly never to be handed to a user. Point it
at /dashboard (brand home) instead. The console stays reachable only from the
dashboard's own artifact list.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…t a revoked link

If a client reuses the same idempotency key across a revoke (make_public →
revoke_access_link → make_public with the same key), the create step replays the
now-revoked link from its idempotency record and mint fails on the dead link.
Real MCP clients use monotonic JSON-RPC ids and the CLI uses random UUIDs so
neither collides, but the failure mode is sharp: a revoke must never lock you out
of going public again.

createAndMintAccessLink now retries once on a salted idempotency key when the
first create→mint fails. The salted create runs the command fresh, so for a share
link it reuses the artifact's active link (the DB-layer findActiveShareLink path)
or mints a new one. The happy path never retries.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
We kept tripping over what the link guarantees, so write it down where the specs
are the source of truth. The Private Link (`/v/<artifactId>`) is permanent and
stable: derived only from the Artifact id with no token/signature/expiry, and
add_revision republishes into the same id, so it never changes across revisions
and live-updates to the latest Published Revision. It is always private (member
only; publish never grants public access) and stops resolving only when the
Artifact is deleted or swept by Auto Deletion — the expires_at in the publish
response is the Artifact's content lifetime, not a link expiry. Going public is
the separate, revocable Share Link.

Documented in CONTEXT.md (Private Link + Share Link vocab), docs/specs/api.md
(publish behavior), and ADR 0086 (decision trail).

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

coderabbitai Bot commented Jun 14, 2026

Copy link
Copy Markdown

Warning

Review limit reached

@isuttell, we couldn't start this review because you've reached your PR review rate limit.

More reviews will be available in 25 minutes and 26 seconds. Learn how PR review limits work.

To continue reviewing without waiting, enable usage-based billing in the billing tab.

⌛ How to resolve this issue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

We recommend that you space out your commits to avoid hitting the rate limit.

🚦 How do rate limits work?

CodeRabbit enforces hourly rate limits for each developer per organization.

Our paid plans include higher PR review limits than trial, open-source, and free plans. In all cases, reviews become available again over time. During sustained high-volume PR review activity, CodeRabbit may temporarily slow when the next review becomes available.

Please see our Fair Usage Limits Policy for further information.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: c3d93f49-e5d9-4a21-97c5-77a49f54982f

📥 Commits

Reviewing files that changed from the base of the PR and between 3b3b31b and 7fa03fb.

⛔ Files ignored due to path filters (3)
  • apps/web/src/routeTree.gen.ts is excluded by !**/routeTree.gen.ts
  • packages/contracts/openapi/api.json is excluded by !packages/contracts/openapi/*.json
  • packages/contracts/openapi/upload.json is excluded by !packages/contracts/openapi/*.json
📒 Files selected for processing (86)
  • CONTEXT.md
  • apps/apex/src/agents.ts
  • apps/apex/src/docs/pages/artifact-model.ts
  • apps/apex/src/docs/pages/cli.ts
  • apps/apex/src/docs/pages/ephemeral.ts
  • apps/apex/src/docs/pages/getting-started.ts
  • apps/apex/src/docs/pages/mcp.ts
  • apps/apex/src/docs/pages/sharing.ts
  • apps/apex/src/llms.ts
  • apps/api/src/agent-view.test.ts
  • apps/api/src/agent-view.ts
  • apps/api/src/mcp-route-auth.test.ts
  • apps/api/src/publish-coordinator.test.ts
  • apps/api/src/publish-coordinator.ts
  • apps/api/src/routes/account.ts
  • apps/api/src/routes/revisions.ts
  • apps/api/test/route-account-member.test.ts
  • apps/api/test/route-core.test.ts
  • apps/cli/README.md
  • apps/cli/src/index.ts
  • apps/cli/test/ephemeral.test.ts
  • apps/cli/test/index.test.ts
  • apps/mcp/README.md
  • apps/mcp/src/protocol.ts
  • apps/mcp/src/tools.test.ts
  • apps/mcp/src/tools.ts
  • apps/mcp/src/transport.test.ts
  • apps/mcp/src/workos.test.ts
  • apps/upload/src/index.test.ts
  • apps/upload/src/mcp-route-auth.test.ts
  • apps/web/src/components/artifacts/ArtifactLiveViewer.tsx
  • apps/web/src/routes/_authed.artifacts.$artifactId.tsx
  • apps/web/src/routes/v.$artifactId.tsx
  • apps/web/test/v-route.test.tsx
  • docs/adr/0079-mcp-scopes-derived-from-member-role-not-workos-token.md
  • docs/adr/0084-cli-and-mcp-share-one-publish-path.md
  • docs/adr/0085-publish-returns-one-viewer-url.md
  • docs/adr/0086-publish-is-content-only-private-first.md
  • docs/adr/README.md
  • docs/marketing-brand-guide.md
  • docs/mcp.md
  • docs/ops/agent-experience-todo.md
  • docs/ops/bootstrap-hosting-checklist.md
  • docs/ops/duplication-todo.md
  • docs/ops/live-updates-todo.md
  • docs/ops/project-status.md
  • docs/ops/runbook-ephemeral-publish.md
  • docs/ops/runbook-mcp-hosts.md
  • docs/specs/acceptance.md
  • docs/specs/api.md
  • docs/specs/architecture.md
  • docs/specs/cli.md
  • docs/specs/ephemeral-publish.md
  • docs/specs/local-dev.md
  • docs/specs/mvp.md
  • docs/specs/phases.md
  • docs/specs/use-cases.md
  • docs/specs/web.md
  • packages/api-client/src/index.ts
  • packages/api-client/src/publish.test.ts
  • packages/api-client/src/publish.ts
  • packages/api-client/test/client.test.ts
  • packages/auth/src/mcp-auth.test.ts
  • packages/contracts/src/mcp.test.ts
  • packages/contracts/src/mcp/constants.ts
  • packages/contracts/src/mcp/registry.ts
  • packages/contracts/src/mcp/schemas.ts
  • packages/contracts/src/mcp/scopes.ts
  • packages/contracts/src/mcp/tool-schemas.ts
  • packages/contracts/src/mvp-contracts.test.ts
  • packages/contracts/src/revisions.ts
  • packages/contracts/src/routes/registry.artifacts.ts
  • packages/contracts/src/uploadSessions.ts
  • packages/db/src/agent-view.test.ts
  • packages/db/src/agent-view.ts
  • packages/db/src/member-mcp-operations.test.ts
  • packages/db/src/repository/workflows/access-links-workflow.ts
  • packages/db/src/repository/workflows/ephemeral-claim-workflow.test.ts
  • packages/db/src/repository/workflows/ephemeral-workflow.test.ts
  • packages/db/src/write-allowance-enforcement.test.ts
  • scripts/smoke-ephemeral-harness.mjs
  • scripts/smoke-ephemeral-harness.test.mjs
  • scripts/smoke-hosted-ephemeral.mjs
  • scripts/smoke-hosted.mjs
  • scripts/smoke-local-ephemeral.mjs
  • scripts/smoke-local-mvp.mjs
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch private-first-publish

Comment @coderabbitai help to get the list of available commands and usage tips.

@cursor cursor 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.

First-pass review

Risk: high
Decision: needs human review


Ticket triage

  • Intended change: Rebuild publish around private-first semantics: publish/add_revision are content-only and always return a stable member-only Private Link (/v/<artifactId> as private_url). Public access requires an explicit make_public / make-public step. Removes share/--share/shared/access_link_url from publish surfaces. Unifies API+MCP scopes to read/publish/admin (drops write/share translation layer). Adds ADR 0086; supersedes ADR 0085.
  • Scope match: Aligns with the spirit of AP-304 (explicit sharing) but goes further — visibility is fully decoupled from publish rather than opt-in via --share. No Linear issue is linked in the PR; consider attaching a ticket for traceability.

Review findings

Blocking: None found in critical paths (publish coordinator, MCP tools, access-links workflow, scope checks, /v viewer). The core security fix — publish no longer mints or implies a public link — is implemented correctly.

Non-blocking:

  1. /v/$artifactId first-login edge case — The route authenticates via loadAuthedSessionFn but does not call provisionWebMemberSessionFn (unlike _authed routes). A brand-new user signing in directly via a private link may hit 403 and see the generic empty state instead of the viewer. Not a data leak, but worth fixing or documenting.
  2. MCP createAndMintAccessLink retries on any first failure — The :r salted-key retry runs for all failures, not only revoked-link replay. Happy path is fine; consider narrowing to mint failures.
  3. Server-side idempotency replay for revoked share links — Idempotency replay can return a revoked link ID; MCP compensates client-side but API/CLI callers with stable keys may not. Worth a server-side fix to remove the client hack.
  4. Missing /v/ route testsArtifactLiveViewer is tested on the dashboard route; the new clean /v/ handoff route has no dedicated coverage.
  5. Deploy coordination — PR notes deployed prod MCP instructions still teach share:true until next deploy. Merge should ship MCP + API + docs together.

Merge checklist

Check Status
Ticket linked ⚠️ No Linear issue linked
Scope matches intent ✅ Private-first model is coherent end-to-end
Checks green ✅ Validate, Postgres smoke, CodeQL, Secret scan all pass
Tests/docs appropriate ✅ Broad test + spec/ADR updates; /v/ route tests are a gap
No blocking findings
No high-risk areas ❌ Auth/authorization/permissions + breaking public contracts
Merge-safe for automation

Why human review

This PR touches authentication, authorization, and permissions (scope unification, MCP tool scope gates, login-walled viewer), is security-sensitive (publish visibility model), and makes breaking contract changes across CLI, MCP, REST, and OpenAPI (~88 files). The implementation looks sound and CI is green, but the blast radius warrants a human pass before merge — especially scope migration for existing MCP tokens/API keys and coordinated prod deploy of MCP instructions.

Open in Web View Automation 

Sent by Cursor Automation: First Pass PR Reviewer

@isuttell

Copy link
Copy Markdown
Contributor Author

@cursor review

@cursor cursor 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.

Cursor Bugbot has reviewed your changes using default effort and found 1 potential issue.

Fix All in Cursor

❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.

Reviewed by Cursor Bugbot for commit 8335df4. Configure here.

Comment thread apps/mcp/src/tools.ts
…st-login provisioning, /v tests

Cursor Bugbot (blocking, Medium) + first-pass review findings:

- **Revision-link retry duplicates (Bugbot).** createAndMintAccessLink's salted-key
  retry ran for both make_public and create_revision_link. Share links dedupe on
  create (findActiveShareLink), so the retry is idempotent; revision links do NOT
  dedupe, so a create-ok/mint-fail retry inserted a second revision link for the
  same revision. Scope the retry to `type:'share'` only; revision links return the
  original failure. Test asserts create_revision_link does not retry.

- **/v first-login provisioning (finding #1).** /v is a standalone handoff route,
  not under _authed, so it never provisioned the workspace member. A brand-new user
  signing in directly via a private link had a valid token but no member row, so the
  owner-scoped artifact read missed and showed the empty state. Provision via
  webSessionQuery in the loader before the artifact read.

- **/v route tests (finding #4).** New apps/web/test/v-route.test.tsx pins the gate:
  unauthed → redirect + no artifact read; authed → provision-then-read in order;
  chromeless viewer renders; redirect path works.

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

Copy link
Copy Markdown
Contributor Author

Thanks for the thorough pass. Addressed in 7fa03fb5:

  • Bugbot (blocking) — revision-link retry duplicates: fixed. Scoped the salted-key retry to type:'share' only (share links dedupe on create via findActiveShareLink; revision links return the original failure instead). Test added. Thread resolved.
  • feat(apex): add marketing worker at agent-paste.sh #1 /v first-login provisioning: fixed. The /v loader now provisions the workspace member via webSessionQuery() before the owner-scoped artifact read, so a brand-new user signing in directly via a private link gets provisioned and sees their content instead of the empty state.
  • ci(pr-preview): register dynamic GitHub deployment per PR #2 retry runs for all failures: addressed by the same change — only share links retry now.
  • feat: wire runCommand into mutation routes #4 missing /v route tests: added apps/web/test/v-route.test.tsx — pins the auth gate (unauthed → redirect + no artifact read), provision-before-read ordering, chromeless viewer render, and redirect path.

Acknowledged, not changed in this PR:

  • ci: upload combined code coverage artifact #3 server-side idempotency replay returning a revoked link: real, but it's the broader command-idempotency refactor I deliberately scoped out — the client-side salted retry is a contained, tested mitigation for the MCP path (CLI uses random keys, so it's unaffected). Filing a follow-up to fix it server-side and drop the mitigation.
  • fix: harden interim production security baseline #5 deploy coordination: correct — the deployed prod MCP instructions still teach share:true and update on the next prod deploy. This PR ships MCP + API + docs together; the instructions land on merge-to-main deploy.

All green: pnpm verify + test:coverage, web 326, mcp 97. Re-verified the happy path live on preview (make_public → share link, create_revision_link → revision link, one of each, no dups).

@isuttell

Copy link
Copy Markdown
Contributor Author

Traceability + follow-up tickets (per review):

@isuttell isuttell merged commit b7d0bab into main Jun 14, 2026
10 checks passed
@isuttell isuttell deleted the private-first-publish branch June 14, 2026 22:29
@github-actions

Copy link
Copy Markdown

agent-paste PR preview resources were cleaned up. The shared Preview GitHub Environment is retained for future preview deploys.

isuttell added a commit that referenced this pull request Jun 14, 2026
0.1.5 was published to npm before #525 (private-first publish) merged, so
the released CLI parses the old `artifact_url` publish response while the
live server now returns `private_url`. Every authenticated publish fails
client-side with `cli_error … expected string … artifact_url`, even though
the artifact is created server-side — stranding the link and causing retry
duplicates.

The fix is already on main (#525 swapped artifact_url->private_url in the
shared runPublish/PublishResult contract); 0.1.5 just predates it. Bump the
version so cli-release cuts cli-v0.1.6 and re-publishes from current source.

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
isuttell added a commit that referenced this pull request Jun 15, 2026
…lta, agent read-back (ADR 0088/0089/0090) (#529)

* docs(adr): record Git-like revision model (commit chain + jobs-reconstructed delta)

ADR 0086 retroactively captures the shipped workspace-scoped content-addressed
blob dedup (no prior ADR existed; it was only in data-model.md/api.md + commit
dea091f). ADR 0087 decides the next step: revisions.parent_revision_id + tree
inheritance (partial-manifest publish, unlisted paths inherit the parent tree by
reference) so an agent can express "change this file" instead of the whole tree,
plus server-reconstructed intra-file delta (unified diff for text, whole-blob
fallback for binary).

Reconstruction runs in jobs, not upload: upload is write-only against R2 today
(sole op ARTIFACTS.put), while jobs already does the
read-decrypt-transform-reencrypt-write shape for Bundle generation. This keeps
content and the ADR 0063 encryption boundary untouched. Chunk stores, per-block
AEAD, Range serving, global dedup, and dropping encryption are deferred.

Spec/CONTEXT edits land with the implementation, per the spec-is-source-of-truth
rule. Staged plan in docs/ops/git-like-revisions-todo.md.

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

* feat(db,contracts): add revision commit chain + partial-manifest upload contract (ADR 0087 stages 1-2)

Stage 1 (schema): revisions.parent_revision_id nullable column with a deferrable
composite self-FK on (workspace_id, artifact_id, parent_revision_id) ->
revisions(workspace_id, artifact_id, id), ON DELETE SET NULL (parent_revision_id),
plus revisions_parent_idx. The composite target structurally pins a parent to the
same Workspace and Artifact; the column-list SET NULL nulls only the pointer
(plain SET NULL would violate the NOT NULL workspace_id/artifact_id). Deferrable
because claim-reparent bulk-rewrites workspace_id across all revisions inside
deferred constraints. Threaded through the Revision type, insert mapper, and
mapRevision; draft creation writes NULL (Stage 3 populates from base_revision_id).
Migration 0024 is idempotent (journal-less runner) and verified on PGlite +
snapshot regenerated.

Stage 2 (contract): CreateUploadSessionRequest gains optional base_revision_id,
deleted_paths, and a per-file patch descriptor {base_sha256, format:"unified",
result_sha256}. A superRefine enforces the structural rules (patch/deleted_paths
require base_revision_id; deleted_paths unique; a path cannot be both uploaded and
deleted; format must be unified). Stateful checks and the tree-inheritance merge /
diff reconstruction are deferred to Stages 3-4. OpenAPI golden regenerated; round-
trip tests added.

Also fixes a pre-existing Sha256Hex /u-flag leak that serialized an invalid
"^...$/u" pattern into the published upload OpenAPI (now clean in all 6 spots),
and folds the ADR 0087 spec-source-of-truth updates into data-model.md (column +
index), api.md (request fields + rules), and CONTEXT.md (Revision parent
relationship).

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

* feat(api,db): tree inheritance at finalize for revision commit chain (ADR 0087 stage 3)

Publishing against base_revision_id now inherits the base Revision's unchanged
blob-backed files instead of re-uploading them: a one-file change yields a full
artifact_files tree with one new blob and parent_revision_id set. The merge runs
at finalize (mergeBaseRevisionTree), recomputes file_count/size_bytes from the
merged tree, and re-runs validateUpload (caps + entrypoint) against it.

Stateful validation deferred from the Stage 2 contract now lands server-side
(6 new repo error codes -> invalid_request): published-base-only, same workspace
(base_revision_not_found) and same artifact (base_revision_artifact_mismatch,
fired before the composite parent FK would 500), deleted-path-in-base,
patch base_sha256 match, and blob-backed-only inheritance (a revision-scoped base
path is not refcount-protected, so it is rejected rather than dangled).

Patch descriptors (patch_base_sha256/patch_result_sha256) are recorded and
validated on upload_session_files with the diff uploaded as a revision object
(sha256 omitted from the signed PUT), but finalize fails loud
(patch_reconstruction_unavailable) until jobs reconstruction lands in Stage 4 -
otherwise the raw diff bytes would be served as the file. A file may not declare
both a whole-file sha256 and a patch.

Carriers: upload_sessions.base_revision_id + deleted_paths, the two patch columns
on upload_session_files (migration 0025, idempotent). Specs updated alongside.

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

* feat(upload,db,storage): synchronous intra-file patch reconstruction at finalize (ADR 0087 stage 4)

Apply an agent-uploaded unified diff to the base blob synchronously at finalize,
in the upload worker, before the new Revision commits. A patch that cannot apply
fails the finalize call with patch_conflict (HTTP 422, "patch_conflict: <path>:
<reason>") so the agent re-submits a corrected diff; a broken patch never becomes
a servable Revision. The reconstructed result is an ordinary content-addressed
blob, so content/bundles/GC are unchanged and no migration is needed.

- packages/storage: hand-rolled byte-exact unified-diff applier (no diff dep:
  jsdiff's UTF-16 round-trip breaks the raw-byte result_sha256 check) +
  workspace-blob read/write helpers (blob AAD) and a revision-file read helper.
- packages/db: RevisionReconstructor adapter (RepositoryOptions, wired in
  createPostgresRuntime + local MVP harness), invoked from mergeBaseRevisionTree
  before any DB write; result blob + content_blobs row commit with the draft;
  caps run on the reconstructed result size; removes the Stage 3
  patch_reconstruction_unavailable gate; conflict -> patch_conflict, infra
  failures -> storage_unavailable.
- contracts/worker-runtime: new patch_conflict ErrorCode (422), MCP status map
  + publishChain, route errors, openapi goldens; also maps the previously
  500-falling-back finalize codes (caps, expired, incomplete) for MCP.
- upload: widen R2 binding with get; surface the conflict path+reason as the
  error message.
- scripts: smoke-local-patch.mjs drives the real reconstruction path (local +
  preview). Verified byte-exact serve + patch_conflict on hosted preview.

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

* ci: run the patch reconstruction smoke locally and against PR preview (ADR 0087 stage 4)

Stage 4's unit/integration tests use a fake reconstructor, so the only checks that
exercise the real decrypt -> apply diff -> hash-verify -> re-encrypt -> serve path
are the smoke. Wire it into both gates:

- ci.yml: `pnpm smoke:local:patch` after the existing local smoke (in-memory MVP,
  every PR via Validate).
- pr-preview.yml: `node scripts/smoke-local-patch.mjs pr` against the deployed PR
  preview Workers, reusing the per-PR deploy outputs + harness secret.

smoke-local-patch.mjs now supports local/preview/pr targets with env resolution
mirroring scripts/smoke-hosted.mjs.

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

* docs: renumber Git-revision ADRs to 0087/0088 after merge collision

Merged PR #525 claimed ADR 0086 for "publish is content-only, private-first".
This branch's earlier work also created an 0086 (workspace-scoped blob dedup)
and an 0087 (revision commit chain + reconstructed delta). Renumber this
branch's pair to 0087 (blob dedup) and 0088 (revision delta) so 0086 stays the
merged publish-privacy ADR, and sweep every cross-reference (ADR bodies, specs,
migrations, code comments, CI smoke steps) to the new numbers.

Reference-only: no schema, contract, or logic change. Full suite green
(typecheck 39/39, test 39/39, openapi:check).

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

* feat(api,cli,mcp,contracts): agent file read-back + CLI incremental-revise diff client (ADR 0089)

Stage 5 of the git-like revision model: give an agent without a working copy a
way to read back exactly what's stored so it can produce a correct unified-diff
revise, and make the CLI send only what changed.

- sha256 on Agent View file entries (optional, non-breaking add)
- new member-authed GET /v1/artifacts/{id}/file-content in the api worker: it
  decrypts the owning member's plaintext and returns
  { path, sha256, size_bytes, content_type, is_binary, body? }. Oversize text and
  binary files omit the body; oversize short-circuits before touching R2. Any
  decrypt/storage failure maps to storage_unavailable (503), never a 500.
- MCP read_file tool forwarding to it; api-client artifacts.readFile()
- CLI: per-artifact manifest cache (0600), a byte-exact unified-diff generator
  that self-checks (re-applies its own diff and verifies the digest before
  attaching it; a generator bug degrades to a whole-blob upload, never a finalize
  conflict), incremental revise wiring, a `pull` verb, and single-shot
  full-republish fallback when the cached base is unusable
- finalize now carries the precise base-* repository kind as the error detail so
  the CLI self-heal fires for all base-unusable conditions, not just patch
  conflicts (the 5 base-* kinds collapse to invalid_request on the wire)

ADR 0089 records the api-decrypts-member-plaintext trust-boundary decision.

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

* docs(adr): renumber revision stack to 0088/0089/0090 (0087 taken by public-artifacts)

Main merged ADR 0087 (public-artifacts-and-unlisted-share-links, #528) while
this revision stack was off-branch, so the prior renumber onto 0087/0088
collided. Shift the stack up one and restore main's 0087 index row:

  workspace-scoped blob dedup        0087 -> 0088
  revision commit chain + delta      0088 -> 0089
  agent file read-back               0089 -> 0090

Renames the three ADR files and rewrites every in-tree reference (filename
tokens + bare "ADR 00NN" comments across code, migrations, specs, CI, and
CONTEXT.md). Bare "ADR 0087" references that mean main's public-artifacts ADR
(CONTEXT.md, project-status.md, 0086) are preserved untouched. README index
re-adds the 0087 public-artifacts row dropped during the rebase.

Also drops four dead imports in apps/cli/src/index.ts (PublishResultShape,
formatBytes, hyperlink, paint) left unused by the Stage 5 work.

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

* docs(adr): record ADR 0091 — shared revise engine + literal multi-edit tools

Design for @agent-paste/revise-core: a pure applyEdits core, a RevisionReader
read-side seam (twin of PublishTransport), and a reviseOnePath orchestrator that
both the CLI `edit` verb and an MCP `multi_edit` tool drive, plus a rebuilt MCP
`add_revision` that preserves the artifact title (fixing the "Revision" overwrite
bug) and sends a verified patch. Strict fail-fast; moves diffWithSelfCheck out of
apps/cli so MCP can share it; finalize render_mode inheritance invariant.

Records the planned spec in docs/ops/git-like-revisions-todo.md (live cli/mcp
specs update when the code lands). Builds on ADR 0090; reverses its
"diff half stays CLI-only" deferral.

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

* fix(upload): allow delete-only revise (empty files) against a base revision

A partial-manifest revise where every remaining file is unchanged but some
paths are deleted produced an empty files manifest. Both CreateUploadSessionRequest
(files.min(1)) and validateUpload (files.length === 0 -> file_count_cap_exceeded)
rejected it, so delete-only revises failed instead of inheriting the base tree and
dropping the paths.

Make the min-1 file rule conditional on the publish kind: a whole publish (no
base_revision_id) still requires at least one file; a base delta may send zero
files as long as it deletes at least one path. validateUpload mirrors this for the
partial-manifest path; the merged tree is still re-checked with the whole-tree caps
(entrypoint + total size) at finalize.

Bugbot finding on PR #529.

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

* fix(cli): fall back to full publish on no-op delta; guard claim-token leak + LCS blowup

- runPublish drops the base and sends a whole-blob manifest when the revise
  plan produces no changed files and no deletions, so an unchanged working
  tree revise no longer sends an empty delta the server rejects (bugbot).
- publish-format only treats the claim token in the URL query string as a leak,
  not a coincidental fragment substring (CodeRabbit).
- unified-diff-gen returns null instead of attempting an LCS over more than
  MAX_LCS_CELLS cells, so a pathological diff falls back to a whole blob rather
  than pinning a core (CodeRabbit).
- Corrected the pull/read-back comment that wrongly claimed base64 bytes ride
  in the JSON; oversize text/binary is metadata-only.

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

* fix(api): infer is_binary from content type on the oversize file-content path

An oversize file is returned as metadata without reading R2, so its bytes are
never inspected. Previously is_binary defaulted to false, mislabeling an
oversize binary as text. Now the flag is derived from the stored content type
(non-text/* => binary), so a client keying on the flag does not try to inline
binary bytes. body stays absent on this branch (CodeRabbit).

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

* fix(db): return committed revision counts on finalize fast path; document FK scope

- The finalized-session fast path now reads the committed revision and reports
  its file_count/size_bytes, so a repeat finalize returns the merged-tree
  counts rather than the pre-merge session counts (CodeRabbit).
- Documented that migration 0024's column-scoped ON DELETE SET NULL on
  parent_revision_id is authoritative and that Drizzle cannot express the
  column list, so the snapshot's unscoped form is expected drift, not a bug to
  "fix" toward the snapshot (CodeRabbit).

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

* fix(storage,docs): preserve UTF-8 BOM in decodeUtf8Strict; tighten patch sha256 spec

- decodeUtf8Strict keeps a leading UTF-8 BOM (ignoreBOM) so valid BOM-prefixed
  text round-trips and is not rejected as binary; fatal is passed explicitly
  for the Worker TS lib option type (CodeRabbit).
- api.md: a patched per-file entry's size_bytes is the diff byte length and the
  entry carries no whole-file sha256; the sha256 rule now scopes to whole-file
  entries (CodeRabbit).
- Test-only: cover the BOM round-trip + invalid-UTF-8 reject, read_file omits
  revision_id from the query when absent, and isolate the non-unified-patch
  contract test from sha256.

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

---------

Co-authored-by: Claude Opus 4.8 <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