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
1 change: 1 addition & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -123,6 +123,7 @@ it is.
- **Editing model**: humans comment, AI agents apply edits via semantic operations (`replace_exact`, `insert_under_heading`, `delete_paragraph_containing`)
- **Edit leases**: one agent edits at a time, enforced by a lease with TTL
- **Versions are immutable** — every edit creates a new PlanVersion with full provenance
- **A person's hand edit is a fence, not a merge input** — agent-vs-agent staleness gets rebased through intervening versions (OT), but a `human` version blocks every agent write with a 409 (`code: human_edit_pending`) carrying the human's diff, until that credential has actually re-read the plan. Proof of reading is a `PlanRead` receipt, not the caller's `base_revision`. See `Plans::HumanEditGuard`.

## Comment & Review UX

Expand Down
41 changes: 41 additions & 0 deletions app/admin/plan_reads.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
# Read receipts. Mostly here to answer one support question: "why is my
# agent getting human_edit_pending?" — compare last_seen_revision against
# the plan's most recent human version.
ActiveAdmin.register CoPlan::PlanRead, as: "PlanRead" do
actions :index, :show

filter :plan
filter :reader_type, as: :select, collection: CoPlan::PlanRead::READER_TYPES
filter :reader_id
filter :last_seen_revision
filter :last_seen_at

index do
selectable_column
id_column
column :plan
column :reader_type
column :reader_id
column :last_seen_revision
column("Plan revision") { |read| read.plan.current_revision }
column :last_seen_at
actions
end

show do
attributes_table do
row :id
row :plan
row :reader_type
row :reader_id
row :last_seen_revision
row("Plan revision") { |read| read.plan.current_revision }
row("Last human revision") do |read|
read.plan.plan_versions.where(actor_type: "human").maximum(:revision)
end
row :last_seen_at
row :created_at
row :updated_at
end
end
end
22 changes: 22 additions & 0 deletions db/migrate/20260824120000_create_coplan_plan_reads.co_plan.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
# This migration comes from co_plan (originally 20260824000000)
class CreateCoplanPlanReads < ActiveRecord::Migration[8.1]
# Read receipts: the highest revision of a plan a given credential has
# actually fetched content for. This is what makes "the agent hasn't
# seen the human's edit yet" a fact the server can check, rather than a
# number the caller asserts. See Plans::HumanEditGuard.
def change
create_table :coplan_plan_reads, id: { type: :string, limit: 36 } do |t|
t.string :plan_id, limit: 36, null: false
# "api_token" for agents, "user" for hook-authenticated humans.
t.string :reader_type, null: false
t.string :reader_id, limit: 36, null: false
t.integer :last_seen_revision, null: false, default: 0
t.datetime :last_seen_at, null: false
t.timestamps
end

add_index :coplan_plan_reads, [ :plan_id, :reader_type, :reader_id ],
unique: true, name: "index_coplan_plan_reads_on_plan_and_reader"
add_foreign_key :coplan_plan_reads, :coplan_plans, column: :plan_id
end
end
14 changes: 13 additions & 1 deletion db/schema.rb

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

49 changes: 49 additions & 0 deletions engine/app/controllers/coplan/api/v1/base_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -118,6 +118,55 @@ def api_token_id
@api_token&.id
end

# Which credential is reading/writing, for read receipts. Distinct
# from api_actor_id only in being explicit about the namespace: a
# token id and a user id are both UUIDs, and a receipt earned by one
# must never satisfy the other.
def api_reader_type
@api_token ? "api_token" : "user"
end

def api_reader_id
api_actor_id
end

# Call from any endpoint that hands the caller a plan's content.
# This is the only way to earn the right to write over a human's
# edit — see Plans::HumanEditGuard.
def record_plan_read!(plan, revision: nil)
CoPlan::PlanRead.record!(
plan: plan,
reader_type: api_reader_type,
reader_id: api_reader_id,
revision: revision || plan.current_revision
)
end

# A hand-written human edit is a hard stop for agents until they've
# pulled it. Hook-authenticated callers are the human themselves, so
# the fence doesn't apply to them.
def guard_human_edits!
return unless @plan
return if api_author_type == "human"

block = CoPlan::Plans::HumanEditGuard.call(
plan: @plan,
reader_type: api_reader_type,
reader_id: api_reader_id,
base_revision: params[:base_revision].presence&.to_i
Comment thread
HamptonMakes marked this conversation as resolved.
)
render json: block, status: :conflict if block
end

# Reader identity to hand a service that writes under the plan lock,
# where the fence is actually enforced. Empty for a human calling
# the API on hook auth — their write *is* a human edit.
def fence_reader
return {} if api_author_type == "human"

{ reader_type: api_reader_type, reader_id: api_reader_id }
end

# A document's address, absolute, so a caller can hand it straight to
# a human. Built from the request rather than from Urls::Canonical
# alone: only the request knows the host, and a host that mounted the
Expand Down
13 changes: 12 additions & 1 deletion engine/app/controllers/coplan/api/v1/content_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -11,9 +11,15 @@ module V1
#
# Optimistic concurrency: caller MUST supply base_revision matching
# the plan's current_revision, or the request fails with 409.
#
# Human edits go further than that: if a person has edited by hand
# since this credential last read the plan, the write is refused with
# their diff attached and no amount of base_revision bumping gets
# past it — see Plans::HumanEditGuard.
class ContentController < BaseController
before_action :set_plan
before_action :authorize_plan_access!
before_action :guard_human_edits!

def update
if params[:content].nil?
Expand Down Expand Up @@ -44,7 +50,8 @@ def update
agent_name: api_agent_name,
api_token_id: api_token_id,
change_summary: params[:change_summary],
reason: params[:reason]
reason: params[:reason],
**fence_reader
)

if result[:no_op]
Expand All @@ -57,12 +64,16 @@ def update
end

version = result[:version]
# The caller has seen this content — it just wrote it.
record_plan_read!(@plan, revision: version.revision)
render json: {
revision: version.revision,
content_sha256: version.content_sha256,
applied: result[:applied],
version_id: version.id
}, status: :created
rescue Plans::HumanEditGuard::Blocked => e
render json: e.payload, status: :conflict
rescue Plans::ReplaceContent::StaleRevisionError => e
render json: {
error: e.message,
Expand Down
25 changes: 25 additions & 0 deletions engine/app/controllers/coplan/api/v1/operations_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,10 @@ module V1
class OperationsController < BaseController
before_action :set_plan
before_action :authorize_plan_access!
# A stale agent write gets rebased through intervening versions
# below. A stale write over a *human's* hand edit does not — it is
# refused here, before any of that machinery runs.
before_action :guard_human_edits!

def create
operations = params[:operations]
Expand All @@ -28,6 +32,8 @@ def create
end
rescue Plans::OperationError => e
render json: { error: e.message }, status: :unprocessable_content
rescue Plans::HumanEditGuard::Blocked => e
render json: e.payload, status: :conflict
end

private
Expand Down Expand Up @@ -100,6 +106,7 @@ def apply_direct(operations, base_revision)
ActiveRecord::Base.transaction do
@plan.lock!
@plan.reload
enforce_human_edit_fence!(base_revision)

current_content = @plan.current_content || ""

Expand Down Expand Up @@ -187,6 +194,7 @@ def create_version_from_operations(operations, base_revision:)
ActiveRecord::Base.transaction do
@plan.lock!
@plan.reload
enforce_human_edit_fence!(base_revision)

if @plan.current_revision != base_revision
render json: {
Expand Down Expand Up @@ -228,6 +236,9 @@ def commit_version(current_content, result)

@plan.comment_threads.mark_out_of_date_for_new_version!(version)

# The caller has seen this content — it just wrote it.
record_plan_read!(@plan, revision: new_revision)

broadcast_plan_update

render json: {
Expand Down Expand Up @@ -324,6 +335,20 @@ def verify_transformed_ranges!(op, transformed_ranges, content)
end
end

# The before_action gives the caller a fast refusal; this is the one
# that actually holds the line, because it runs under the plan lock
# that the version creation below shares.
def enforce_human_edit_fence!(base_revision)
return if api_author_type == "human"

Plans::HumanEditGuard.enforce!(
plan: @plan,
reader_type: api_reader_type,
reader_id: api_reader_id,
base_revision: base_revision
)
end

def broadcast_plan_update
Broadcaster.replace_to(
@plan,
Expand Down
5 changes: 5 additions & 0 deletions engine/app/controllers/coplan/api/v1/plans_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,11 @@ def index
render json: plans.map { |p| plan_json(p) }
end

# Handing over the content is what earns a read receipt, which is
# what lifts a Plans::HumanEditGuard block. Recorded here and in
# #snapshot — the two endpoints that return current_content.
def show
record_plan_read!(@plan)
render json: plan_json(@plan).merge(
current_content: @plan.current_content,
current_revision: @plan.current_revision,
Expand Down Expand Up @@ -286,6 +290,7 @@ def comments
end

def snapshot
record_plan_read!(@plan)
threads = @plan.comment_threads.includes(:comments, :created_by_user).order(created_at: :desc)
references = @plan.references.order(created_at: :desc)
collaborators = @plan.plan_collaborators.includes(:user)
Expand Down
13 changes: 12 additions & 1 deletion engine/app/controllers/coplan/api/v1/sessions_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,12 @@ class SessionsController < BaseController
before_action :set_plan
before_action :authorize_plan_access!
before_action :set_session, only: [ :show, :commit ]
# Checked twice on purpose: at #create so an agent can't start
# accumulating operations against content it has never seen, and
# again at #commit because a person may have edited by hand while
# the session was open. Neither is a rebase — a human's hand edit
# stops the write until the agent pulls it.
before_action :guard_human_edits!, only: [ :create, :commit ]
Comment thread
HamptonMakes marked this conversation as resolved.

# POST /api/v1/plans/:plan_id/sessions
# Cloud personas create sessions via direct Ruby service calls, not this endpoint.
Expand Down Expand Up @@ -42,7 +48,8 @@ def commit
change_summary: params[:change_summary],
actor_id: api_user_id,
agent_name: api_agent_name,
api_token_id: api_token_id
api_token_id: api_token_id,
**fence_reader
)

response = {
Expand All @@ -55,9 +62,13 @@ def commit
response[:revision] = result[:version].revision
response[:version_id] = result[:version].id
response[:content_sha256] = result[:version].content_sha256
# The caller has seen this content — it just wrote it.
record_plan_read!(@plan, revision: result[:version].revision)
end

render json: response
rescue Plans::HumanEditGuard::Blocked => e
render json: e.payload, status: :conflict
rescue Plans::CommitSession::SessionNotOpenError => e
render json: { error: e.message }, status: :unprocessable_content
rescue Plans::CommitSession::StaleSessionError => e
Expand Down
8 changes: 8 additions & 0 deletions engine/app/jobs/coplan/commit_expired_session_job.rb
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,14 @@ def perform(session_id:)
rescue Plans::CommitSession::SessionNotOpenError
# Session was closed concurrently (manual commit/cancel) — nothing to do
Rails.logger.info("CommitExpiredSessionJob: session #{session_id} already closed, skipping")
rescue Plans::HumanEditGuard::Blocked => e
# A person edited by hand while this session sat open, and the agent
# that owned it never came back to read them. Auto-committing would
# rebase an abandoned draft straight over their words with nobody in
# the loop to notice — so the session dies instead. The operations
# stay on the row if anyone wants to see what was lost.
session.update!(status: "failed", change_summary: "Auto-commit blocked: #{e.message}")
Rails.logger.warn("CommitExpiredSessionJob: session #{session_id} blocked by an unread human edit")
rescue Plans::CommitSession::SessionConflictError, Plans::CommitSession::StaleSessionError, Plans::OperationError => e
# Conflict during auto-commit — mark session as failed
session.update!(status: "failed", change_summary: "Auto-commit failed: #{e.message}")
Expand Down
1 change: 1 addition & 0 deletions engine/app/models/coplan/plan.rb
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ class Plan < ApplicationRecord
has_many :plan_tags, dependent: :destroy
has_many :tags, through: :plan_tags, source: :tag
has_many :plan_viewers, dependent: :destroy
has_many :plan_reads, dependent: :destroy
has_many :notifications, dependent: :destroy
has_many :references, dependent: :destroy
has_many_attached :attachments
Expand Down
Loading
Loading