Skip to content
Draft
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
3 changes: 2 additions & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,8 @@ jobs:
# test jobs rather than gating them: a bracket-spacing nit should not hide
# whether the suite passes.
lint:
runs-on: ubuntu-latest
# The pinned setup-ruby action provides Ruby 3.4.7 for this image.
runs-on: ubuntu-24.04

steps:
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0
Expand Down
1 change: 1 addition & 0 deletions docs/DEMO_LIBRARY.md
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@ shipped types, including Scratchpad, PRD, Test Plan, and Handoff.
| Content example | What to try |
| --- | --- |
| writing-legacy-unknown | Pre-identity history repaired as Unknown; recorded agent identities are preserved |
| writing-moved | Original authorship survives reordering, including separate cut and paste autosaves |
| diagram-comments | Flowchart node and edge discussions; browse, press C, select an element, expand |
| entity-relationships / class-model | ER and class diagrams with whole-diagram discussions |
| mobile-checkout / long-title | State and sequence diagrams with whole-diagram discussions |
Expand Down
8 changes: 6 additions & 2 deletions engine/app/services/coplan/plans/writing_provenance.rb
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ module Plans
# Actor identity is separate from input evidence on subsequent writes.
class WritingProvenance
FORMAT = 1
HISTORY_POLICY = 2
HISTORY_POLICY = 3
# Bearer-only agent identity landed in #178 on August 17. Before then,
# host-authenticated API agents were also saved with actor_type "human".
RELIABLE_HUMAN_ATTRIBUTION_FROM = Time.utc(2026, 8, 18).freeze
Expand Down Expand Up @@ -98,11 +98,15 @@ def call
[ [ content.length, identity["kind"] == "ai" ? source_key : unknown_key ] ]
end
runs = self.class.remap(old, content, prior_runs, insertions, fallback_source: @historical_human ? source_key : UNKNOWN)
retained = runs.map(&:last).to_set
# Historical human input is an assumption, not fresh typing evidence.
runs, removed_lines = WritingProvenanceMoves.call(before: old, after: content, prior_runs: prior_runs, runs: runs,
removed_lines: valid ? snapshot.fetch("removed_lines", []) : [], fresh_source: identity["kind"] == "human" && !@historical_human ? source_key : nil)
retained = (runs + removed_lines.flat_map { |_, line_runs| line_runs || [] }).map(&:last).to_set
sources.select! { |key, _| retained.include?(key) }
summary = summarize(content, runs, sources)
result = { "format" => FORMAT, "sha256" => Digest::SHA256.hexdigest(content), "write_source" => identity,
"runs" => runs, "sources" => sources, "summary" => summary }
result["removed_lines"] = removed_lines if removed_lines.any?
# A new save cannot certify old attribution that it merely retained.
result["history_policy"] = HISTORY_POLICY if @previous.nil? || snapshot["history_policy"] == HISTORY_POLICY
# Preserve the cutover assumption on replay, including bounded large
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,9 @@ def self.release(plan, token)
def self.pending?(plan)
return false unless plan.current_revision.positive?
state = plan.writing_provenance_backfill
return plan.writing_provenance_summary.nil? unless state
# Plans created after the initial rollout can have a cached summary
# without a checkpoint. Replay them too when a new policy ships.
return true unless state
state["policy_version"] != WritingProvenance::HISTORY_POLICY || state["token"].present? || state.fetch("revision", 0) < plan.current_revision ||
state.fetch("finalized_revision", 0) < plan.current_revision || plan.writing_provenance_summary&.dig("revision") != plan.current_revision
end
Expand Down
131 changes: 131 additions & 0 deletions engine/app/services/coplan/plans/writing_provenance_moves.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,131 @@
module CoPlan
module Plans
# Exact lines carry their original character sources when reordered. Keep
# bounded deleted-line evidence so autosaving a cut does not erase it before
# the paste. Conflicting origins for identical lines are never guessed.
class WritingProvenanceMoves
MAX_CHARACTERS = 100_000
MAX_LINES = 2048
MAX_RUNS = 4096

def self.call(before:, after:, prior_runs:, runs:, removed_lines:, fresh_source: nil)
new.call(before: before, after: after, prior_runs: prior_runs, runs: runs, removed_lines: removed_lines, fresh_source: fresh_source)
end

def call(before:, after:, prior_runs:, runs:, removed_lines:, fresh_source:)
@lines = {}
current_counts, archived_counts = Hash.new(0), Hash.new(0)
@characters = @run_count = 0
each_line(before, prior_runs) do |text, line_runs, _|
current_counts[text] += 1
remember(text, line_runs)
end
removed_lines.each do |text, line_runs, count|
remember(text, line_runs)
archived_counts[text] += count || 1
end

# Reserve occurrences the normal diff already retained before assigning
# any lost moves. A new copy earlier in the document cannot spend the
# original occurrence's attribution before we reach the retained one.
matched, after_counts = Hash.new(0), Hash.new(0)
each_line(after, runs) do |text, line_runs, _|
after_counts[text] += 1
if @lines[text] == line_runs && matched[text] < current_counts[text]
matched[text] += 1
end
end
remaining = current_counts.dup
matched.each { |text, count| remaining[text] -= count }
archived_remaining = archived_counts.dup

result = []
each_line(after, runs) do |text, line_runs, ending_runs|
restored = @lines[text]
extra_typed_copy = fresh_source && after_counts[text] > current_counts[text] + archived_counts[text] &&
line_runs.any? { |_, source| source == fresh_source }
if extra_typed_copy
restored = nil
elsif restored && matched[text].positive? && line_runs == restored
matched[text] -= 1
elsif restored && remaining[text].positive?
remaining[text] -= 1
restored = retain_fresh_input(line_runs, restored, fresh_source) if fresh_source
elsif restored && archived_remaining[text].positive?
archived_remaining[text] -= 1
restored = retain_fresh_input(line_runs, restored, fresh_source) if fresh_source
else
restored = nil
end
(restored || line_runs).each { |length, source| WritingProvenance.append(result, length, source) }
Comment thread
HamptonMakes marked this conversation as resolved.
Resolved
ending_runs.each { |length, source| WritingProvenance.append(result, length, source) }
end
deleted = @lines.filter_map do |text, line_runs|
count = current_counts[text] + archived_counts[text] - after_counts[text]
[ text, line_runs, count ] if count.positive?
end
[ result, deleted ]
end

private

# Recreating an old line can be a new action: typing an old sentence or
# checking a task again. Archived text cannot erase that observed input.
def retain_fresh_input(runs, archived, fresh_source)
result = []
index = offset = 0
runs.each do |length, source|
while length.positive?
old_length, old_source = archived.fetch(index)
count = [ length, old_length - offset ].min
WritingProvenance.append(result, count, source == fresh_source ? source : old_source)
length -= count
offset += count
if offset == old_length
index += 1
offset = 0
end
end
end
result
end

def remember(text, runs)
return unless text.match?(/[^[:space:]]/)
if @lines.key?(text)
@lines[text] = nil unless @lines[text] == runs
elsif @lines.size < MAX_LINES && @characters + text.length <= MAX_CHARACTERS && @run_count + runs.to_a.size <= MAX_RUNS
@lines[text] = runs
@characters += text.length
@run_count += runs.to_a.size
end
end

# Walk the run stream once; slicing from its start for every line would
# multiply work on documents with many lines and many small edits.
def each_line(content, runs)
index = offset = 0
take = lambda do |length|
result = []
while length.positive?
run_length, source = runs.fetch(index)
count = [ length, run_length - offset ].min
WritingProvenance.append(result, count, source)
length -= count
offset += count
if offset == run_length
index += 1
offset = 0
end
end
result
end
content.each_line do |line|
text = line.delete_suffix("\n")
line_runs = take.call(text.length)
yield text, line_runs, take.call(line.length - text.length)
end
end
end
end
end
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,9 @@ The summary has `counts`, `shares`, `total`, and `contributors`. Categories are
The UI groups known human and AI contributors by name. It shows a separate Unknown row only when unknown text is present. The API categories remain `human`, `ai`, and `unknown`. These describe recorded input, not human review or intellectual ownership.
The method is `retained_markdown_v1`. Counts use non-whitespace Markdown characters, including code and diagram source. They are not edit counts or word counts.
Unchanged characters retain their origin. A small edit does not take credit for a whole line. Very large replacement diffs may remain Unknown when exact matching exceeds the processing budget.
Reordered exact lines retain their original authors and introduction revisions. This also works when autosave records a cut before its paste, using a bounded record of deleted lines. Identical lines with conflicting origins are not reassigned by this matching.
Recorded new human input takes priority over deleted-line matches. Checking a task again records the person and version of that new action.
Each saved line occurrence can supply attribution once. Additional copies keep their recorded typing or paste source.
Identified agent writes are AI written. Observed human input is Human written. External paste and unrecorded new input are Unknown.
Deployment automatically rebuilds existing history in resumable background batches. Human-labeled versions before August 18, 2026 are Unknown unless an agent identity was recorded. Later editor changes through the deployment cutoff and uninstrumented saves from old instances during deployment are treated as Human written; this is a historical assumption, not typing/paste evidence. Instrumented changes use recorded input. Unchanged agent text keeps its AI origin.
AI contributions can report `agent_name`, `harness`, `harness_name`, `harness_version`, and `model`. Missing model information stays an AI contribution. Identity is reported by the tool and captured with its write.
Expand Down
11 changes: 11 additions & 0 deletions engine/db/demo/content/writing-moved.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
## Current Architecture

Today, devices connect directly to the cloud for delivery and networking.

The original author wrote this section before moving it below the proposal.

## Proposal

Let nearby devices exchange data securely over the local network.

Inspect writing to see Hampton as the author of both sections. The history records the cut and paste as separate saves, while the moved text keeps its original version.
18 changes: 18 additions & 0 deletions engine/lib/coplan/development_seed.rb
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,9 @@ module DevelopmentSeed
{ key: "writing-human", author: "hampton", type: "General", title: "Writing sources: human written",
tags: %w[demo provenance], visibility: "published", folder: "Demo content/Writing sources", group: "Writing sources", writing_source: "human",
showcase: "Person icon; recorded human input throughout" },
{ key: "writing-moved", author: "hampton", type: "General", title: "Writing sources: moved paragraphs keep their author",
tags: %w[demo provenance], visibility: "published", folder: "Demo content/Writing sources", group: "Writing sources", writing_source: "human",
showcase: "Hampton's architecture section keeps its original author after separate cut and paste autosaves; inspect writing and history" },
{ key: "writing-ai", author: "hampton", type: "General", title: "Writing sources: AI written",
tags: %w[demo provenance], visibility: "published", folder: "Demo content/Writing sources", group: "Writing sources", writing_source: "ai",
showcase: "Robot icon; Claude Code attribution; inspect the source of each word" },
Expand Down Expand Up @@ -283,6 +286,7 @@ def call(base_url: ENV.fetch("COPLAN_DEMO_BASE_URL", "http://localhost:3000"))
seed_small_edits(users, plans)
seed_writing_sources(users, plans)
seed_writing_word_edit(users, plans)
seed_moved_writing(users, plans)
seed_reviews(users, plans)
seed_review_requests(users, plans)
seed_agent_access(users)
Expand Down Expand Up @@ -809,6 +813,20 @@ def seed_writing_word_edit(users, plans)
input_sources: { "runs" => [ [ changed.encode(Encoding::UTF_16LE).bytesize / 2, "human" ] ] })
end

def seed_moved_writing(users, plans)
plan = plans.fetch("writing-moved")
content = document_content(DOCUMENTS.find { |item| item[:key] == "writing-moved" })
return unless plan.current_revision == 1 && plan.current_content == content
title, sections = content.split("## Current Architecture", 2)
moved, kept = ("## Current Architecture" + sections).split("## Proposal", 2)
kept = "## Proposal" + kept
[ [ title + kept, "Cut architecture before reordering" ], [ title + kept + "\n" + moved, "Paste architecture below the proposal" ] ].each do |text, summary|
Plans::ReplaceContent.call(plan: plan, new_content: text, base_revision: plan.current_revision,
actor_type: "human", actor_id: users.fetch("hampton").id, change_summary: summary,
input_sources: { "runs" => [ [ text.encode(Encoding::UTF_16LE).bytesize / 2, "unknown" ] ] })
end
end

# Demo fixture origin is explicit simulation data. Reconstruct only seed-owned
# initial versions; never replace the input evidence from a person's later edit.
def seed_writing_history(users, plans, catalog)
Expand Down
10 changes: 9 additions & 1 deletion spec/jobs/writing_provenance_backfill_job_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@

it "does not rebuild completed documents" do
plan = CoPlan::Plans::Create.call(title: "Ready", content: "Editor", user: create(:coplan_user), actor_type: "human")
plan.update_columns(writing_provenance_backfill: { "legacy_revision" => 1, "revision" => 1, "finalized_revision" => 1, "policy_version" => 2 })
plan.update_columns(writing_provenance_backfill: { "legacy_revision" => 1, "revision" => 1, "finalized_revision" => 1, "policy_version" => CoPlan::Plans::WritingProvenance::HISTORY_POLICY })
expect(CoPlan::WritingProvenanceJob).not_to receive(:request)
expect { described_class.perform_now }.not_to have_enqueued_job(described_class)
end
Expand All @@ -29,6 +29,14 @@
described_class.perform_now
end

it "revisits cached summaries without a backfill checkpoint" do
plan = CoPlan::Plans::Create.call(title: "Created after rollout", content: "Known writing", user: create(:coplan_user), actor_type: "human")
expect(plan.writing_provenance_summary).to be_present
expect(plan.writing_provenance_backfill).to be_nil
expect(CoPlan::WritingProvenanceJob).to receive(:request).with(have_attributes(id: plan.id))
described_class.perform_now
end

it "discovers plans created by old instances after the migration scan" do
plan = CoPlan::Plans::Create.call(title: "During cutover", content: "Editor", user: create(:coplan_user), actor_type: "human")
plan.plan_versions.update_all(writing_provenance: nil)
Expand Down
21 changes: 20 additions & 1 deletion spec/lib/development_seed_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
expect(seeded_plan("writing-human").writing_provenance_summary.dig("shares", "human")).to eq(1.0)
legacy = seeded_plan("writing-legacy")
expect(legacy.writing_provenance_summary.dig("shares", "human")).to eq(1.0)
expect(legacy.writing_provenance_backfill).to eq("legacy_revision" => 1, "revision" => 1, "finalized_revision" => 1, "policy_version" => 2)
expect(legacy.writing_provenance_backfill).to eq("legacy_revision" => 1, "revision" => 1, "finalized_revision" => 1, "policy_version" => CoPlan::Plans::WritingProvenance::HISTORY_POLICY)
early = seeded_plan("writing-legacy-unknown")
expect(early.writing_provenance_summary.dig("shares", "unknown")).to eq(1.0)
expect(early.current_plan_version.created_at).to eq(Time.utc(2026, 7, 9))
Expand Down Expand Up @@ -42,6 +42,25 @@ def seeded_plan(key)
CoPlan::Plan.all.detect { |plan| plan.metadata.to_h["development_seed_key"] == key }
end

it "keeps moved writing attributed to its original author and preserves it on re-seed" do
described_class.call
moved = seeded_plan("writing-moved")
expect(moved.current_revision).to eq(3)
expect(moved.current_content.index("## Proposal")).to be < moved.current_content.index("## Current Architecture")
expect(moved.writing_provenance_summary.dig("shares", "human")).to eq(1.0)
expect(moved.writing_provenance_summary.fetch("contributors")).to contain_exactly(include("user_name" => "Hampton", "kind" => "human"))
expect(moved.current_plan_version.writing_provenance.fetch("sources").values).to include(include("origin_revision" => 1, "user_name" => "Hampton"))
summary = moved.writing_provenance_summary.deep_dup
described_class.call
expect(moved.reload.current_revision).to eq(3)
expect(moved.writing_provenance_summary).to eq(summary)
CoPlan::Plans::ReplaceContent.call(plan: moved, new_content: "My own revised arrangement", base_revision: 3,
actor_type: "human", actor_id: moved.created_by_user_id)
described_class.call
expect(moved.reload.current_content).to eq("My own revised arrangement")
expect(seeded_plan("demo-library").current_content).to include(moved.url_path)
end

it "refuses non-local environments before creating data" do
allow(Rails).to receive(:env).and_return(ActiveSupport::EnvironmentInquirer.new("production"))

Expand Down
10 changes: 5 additions & 5 deletions spec/requests/toggle_checkbox_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,8 @@
it "attributes repeated checkbox selections to the person while retaining the agent's task text" do
agent_plan = CoPlan::Plans::Create.call(title: "Agent tasks", content: "- [ ] Ship 😀\n", user: user, actor_type: "local_agent")
5.times do |index|
actor = index < 2 ? user : other_user
sign_in_as(actor)
checked = index.even?
patch toggle_checkbox_plan_path(agent_plan), params: {
old_text: checked ? "- [ ] Ship 😀" : "- [x] Ship 😀",
Expand All @@ -66,11 +68,9 @@
snapshot = agent_plan.reload.current_plan_version.writing_provenance
expect(snapshot.dig("summary", "counts", "unknown")).to eq(0)
expect(snapshot.dig("summary", "counts", "ai")).to eq(8)
if checked
source = snapshot.fetch("sources").values.find { |entry| entry["kind"] == "human" }
expect(source).to include("user_id" => user.id, "origin_revision" => agent_plan.current_revision)
expect(snapshot.dig("summary", "counts", "human")).to eq(1)
end
marker_key = CoPlan::Plans::WritingProvenance.slice(snapshot.fetch("runs"), 3, 4).first.last
expect(snapshot.fetch("sources").fetch(marker_key)).to include("kind" => "human", "user_id" => actor.id, "origin_revision" => agent_plan.current_revision)
expect(snapshot.dig("summary", "counts", "human")).to eq(checked ? 1 : 0)
end
end

Expand Down
Loading
Loading