Skip to content
Open
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
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
# This migration comes from co_plan (originally 20260815000000)
class AddSessionMintingToCoplanApiTokens < ActiveRecord::Migration[8.1]
def change
# Session tokens are minted from a long-lived parent token, so a machine
# keeps one secret and each agent run gets its own short-lived identity.
# Revoking the parent has to revoke everything it minted, hence the link.
#
# Guarded: databases set up via db:schema:load while main's schema.rb
# carried these columns without a migration (and dev databases that ran
# the agent-collaboration branch) already have them.
unless column_exists?(:coplan_api_tokens, :parent_id)
add_column :coplan_api_tokens, :parent_id, :string, limit: 36
end
unless index_exists?(:coplan_api_tokens, [:parent_id, :revoked_at])
add_index :coplan_api_tokens, [:parent_id, :revoked_at]
end
unless foreign_key_exists?(:coplan_api_tokens, column: :parent_id)
add_foreign_key :coplan_api_tokens, :coplan_api_tokens, column: :parent_id
end

# Which agent this token speaks for ("Claude", "Amp"). Attribution rows
# written with the token inherit it, the same way comments carry a
# per-comment agent_name.
unless column_exists?(:coplan_api_tokens, :agent_name)
add_column :coplan_api_tokens, :agent_name, :string
end
end
end
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
# This migration comes from co_plan (originally 20260815000001)
class AddAgentNameToPlanHistory < ActiveRecord::Migration[8.1]
def change
# Comments already record which agent acted for a user (author_id is the
# human, agent_name is the agent). Versions and events stored only
# actor_type, so the history tab rendered an agent's edit as the human's.
# Existing rows stay nil — they were written before the distinction was
# captured and cannot be attributed retroactively.
add_column :coplan_plan_versions, :agent_name, :string
add_column :coplan_plan_events, :agent_name, :string
end
end
23 changes: 23 additions & 0 deletions db/migrate/20260815174515_add_agent_provenance.co_plan.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
# This migration comes from co_plan (originally 20260815000002)
class AddAgentProvenance < ActiveRecord::Migration[8.1]
def change
# Identity facts (harness, harness version, model, …) live on the token,
# captured at mint time — schemaless, because the set of facts worth
# recording grows faster than anyone wants to migrate three tables.
add_column :coplan_api_tokens, :metadata, :json

# Attribution rows keep agent_name as the display string, and point at
# the token for everything else. actor/author stays the human.
add_column :coplan_plan_versions, :api_token_id, :string, limit: 36
add_column :coplan_plan_events, :api_token_id, :string, limit: 36
add_column :coplan_comments, :api_token_id, :string, limit: 36

add_index :coplan_plan_versions, :api_token_id
add_index :coplan_plan_events, :api_token_id
add_index :coplan_comments, :api_token_id

add_foreign_key :coplan_plan_versions, :coplan_api_tokens, column: :api_token_id
add_foreign_key :coplan_plan_events, :coplan_api_tokens, column: :api_token_id
add_foreign_key :coplan_comments, :coplan_api_tokens, column: :api_token_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.

Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,9 @@ def create
file: file,
user: current_user,
actor_type: api_author_type,
actor_id: api_actor_id
actor_id: api_user_id,
agent_name: api_agent_name,
api_token_id: api_token_id
)

if result.success?
Expand Down Expand Up @@ -61,7 +63,9 @@ def destroy
before: filename,
metadata: { content_type: content_type },
actor_type: api_author_type,
actor_id: api_actor_id
actor_id: api_user_id,
agent_name: api_agent_name,
api_token_id: api_token_id
)

head :no_content
Expand Down
55 changes: 53 additions & 2 deletions engine/app/controllers/coplan/api/v1/base_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ class BaseController < ActionController::API
wrap_parameters false

before_action :authenticate_api!
before_action :require_api_token!
after_action :set_agent_instructions_header

private
Expand All @@ -18,6 +19,25 @@ def set_agent_instructions_header
response.headers["X-Agent-Instructions"] = CoPlan::Engine.routes.url_helpers.agent_instructions_path
end

# Every API call must carry its own Bearer token. The host's
# request auth (api_authenticate) only proves which human is
# behind the wire — it says nothing about which agent is acting,
# and an agent edit that arrives on human credentials gets
# recorded as a human edit. So hook auth is good for exactly one
# thing: minting the token (TokensController skips this check for
# create).
def require_api_token!
return if performed?
return if @api_token

render json: {
error: "API calls require a Bearer token. Mint one first: " \
"POST #{CoPlan::Engine.routes.url_helpers.api_v1_tokens_path} " \
"with {\"agent_name\": \"<your name>\"}, then send " \
"Authorization: Bearer <token> on every call."
}, status: :forbidden
end

def authenticate_api!
token = request.headers["Authorization"]&.delete_prefix("Bearer ")
if token.present?
Expand Down Expand Up @@ -55,8 +75,11 @@ def current_user
@current_api_user || @api_token&.user
end

# Unique identifier for the API caller — used as actor_id, holder_id, author_id.
# With token auth this is the token's ID; with hook auth it's the user's ID.
# Unique identifier for the API caller — the session key for
# transient ownership (edit sessions, leases, notifications).
# With token auth this is the token's ID; with hook auth it's the
# user's ID. NOT for persisted attribution rows — those store the
# human (api_user_id) so history can name them; see api_agent_name.
def api_actor_id
@api_token&.id || @current_api_user&.id
end
Expand All @@ -67,6 +90,34 @@ def api_author_type
@api_token ? ApiToken::HOLDER_TYPE : "human"
end

# Persisted attribution rows (versions, events, comments) store
# the human behind the token — a token id in actor_id names nobody
# in a history tab — with agent_name recording which agent acted
# for them, the same split comments already use.
def api_user_id
current_user&.id
Comment on lines +97 to +98

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Propagate agent identity into the initial plan version

When a Bearer-authenticated agent calls POST /api/v1/plans, PlansController#create still invokes Plans::Create with only user, and that service hardcodes version 1 as actor_type: "human" without an agent_name. Consequently, every agent-created plan permanently attributes its initial content to the human, contradicting the new identity model and the documented promise that every version is stamped; pass api_author_type, api_user_id, and api_agent_name through the creation path as well.

AGENTS.md reference: AGENTS.md:L5-L5

Useful? React with 👍 / 👎.

end

# Which agent to attribute a write to: the caller can say
# per-request, otherwise the token knows who it was minted for,
# otherwise the token's own name. Nil under hook auth — a human,
# not an agent (only reachable where require_api_token! is
# skipped).
def api_agent_name
return nil unless @api_token

ApiToken.normalized_agent_name(
params[:agent_name].presence || @api_token.agent_name.presence || @api_token.name
)
end

# The provenance join: attribution rows keep the display string in
# agent_name and point here for everything else the token knows
# about its run (harness, versions, model — see ApiToken#metadata).
def api_token_id
@api_token&.id
end

def set_plan
@plan = CoPlan::Plan.find_by(id: params[:plan_id] || params[:id])
unless @plan
Expand Down
6 changes: 4 additions & 2 deletions engine/app/controllers/coplan/api/v1/comments_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,8 @@ def create
author_type: api_author_type,
author_id: current_user&.id,
body_markdown: params[:body_markdown],
agent_name: params[:agent_name]
agent_name: api_agent_name,
api_token_id: api_token_id
)

reason = comment.agent? ? "agent_response" : "new_comment"
Expand Down Expand Up @@ -131,7 +132,8 @@ def reply
author_type: api_author_type,
author_id: current_user&.id,
body_markdown: params[:body_markdown],
agent_name: params[:agent_name]
agent_name: api_agent_name,
api_token_id: api_token_id
)

reason = comment.agent? ? "agent_response" : "reply"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,9 @@ def update
new_content: params[:content].to_s,
base_revision: base_revision,
actor_type: api_author_type,
actor_id: api_actor_id,
actor_id: api_user_id,
agent_name: api_agent_name,
api_token_id: api_token_id,
change_summary: params[:change_summary],
reason: params[:reason]
)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -135,6 +135,8 @@ def organize
operations: params[:operations],
actor_type: api_author_type,
actor_label: @api_token&.name,
agent_name: api_agent_name,
api_token_id: api_token_id,
dry_run: params[:dry_run].to_s == "true"
)
unless result.success?
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -211,7 +211,9 @@ def commit_version(current_content, result)
revision: new_revision,
content_markdown: result[:content],
actor_type: api_author_type,
actor_id: api_actor_id,
actor_id: api_user_id,
agent_name: api_agent_name,
api_token_id: api_token_id,
change_summary: params[:change_summary],
diff_unified: diff.presence,
operations_json: result[:applied],
Expand Down
14 changes: 7 additions & 7 deletions engine/app/controllers/coplan/api/v1/plans_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -106,7 +106,7 @@ def update
if params.key?(:folder_id) || params.key?(:folder_path)
folder = resolve_folder_params
return if performed? # resolve_folder_params rendered an error
result = Plans::Place.call(plan: @plan, folder: folder, actor: current_user, actor_type: api_author_type)
result = Plans::Place.call(plan: @plan, folder: folder, actor: current_user, actor_type: api_author_type, agent_name: api_agent_name, api_token_id: api_token_id)
unless result.success?
render json: { error: result.error }, status: :unprocessable_content
raise ActiveRecord::Rollback
Expand All @@ -126,15 +126,15 @@ def update
Plans::LogEvent.call(
plan: @plan, actor: current_user, event_type: "title_changed",
before: old_title, after: @plan.title,
actor_type: api_author_type, actor_id: api_actor_id
actor_type: api_author_type, actor_id: api_user_id, agent_name: api_agent_name, api_token_id: api_token_id
)
end

if @plan.saved_change_to_visibility? && @plan.published? && old_visibility == "draft"
Plans::LogEvent.call(
plan: @plan, actor: current_user, event_type: "published",
before: "draft", after: "published",
actor_type: api_author_type, actor_id: api_actor_id
actor_type: api_author_type, actor_id: api_user_id, agent_name: api_agent_name, api_token_id: api_token_id
)
CoPlan::Analytics.track(
"plan_published",
Expand All @@ -149,7 +149,7 @@ def update
Plans::LogEvent.call(
plan: @plan, actor: current_user,
event_type: @plan.archived? ? "archived" : "unarchived",
actor_type: api_author_type, actor_id: api_actor_id
actor_type: api_author_type, actor_id: api_user_id, agent_name: api_agent_name, api_token_id: api_token_id
)
end

Expand All @@ -158,13 +158,13 @@ def update
(new_tag_names - old_tag_names).each do |added|
Plans::LogEvent.call(
plan: @plan, actor: current_user, event_type: "tag_added", after: added,
actor_type: api_author_type, actor_id: api_actor_id
actor_type: api_author_type, actor_id: api_user_id, agent_name: api_agent_name, api_token_id: api_token_id
)
end
(old_tag_names - new_tag_names).each do |removed|
Plans::LogEvent.call(
plan: @plan, actor: current_user, event_type: "tag_removed", before: removed,
actor_type: api_author_type, actor_id: api_actor_id
actor_type: api_author_type, actor_id: api_user_id, agent_name: api_agent_name, api_token_id: api_token_id
)
end
end
Expand All @@ -183,7 +183,7 @@ def update
Plans::LogEvent.call(
plan: @plan, actor: current_user, event_type: "reference_added",
after: ref.url, metadata: { title: ref.title, reference_type: ref.reference_type },
actor_type: api_author_type, actor_id: api_actor_id
actor_type: api_author_type, actor_id: api_user_id, agent_name: api_agent_name, api_token_id: api_token_id
)
end
end
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,10 @@ def show
def commit
result = Plans::CommitSession.call(
session: @session,
change_summary: params[:change_summary]
change_summary: params[:change_summary],
actor_id: api_user_id,
agent_name: api_agent_name,
api_token_id: api_token_id
)

response = {
Expand Down
Loading
Loading