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,27 @@
# This migration comes from co_plan (originally 20261029000000)
class BindReviewCyclesToVersions < ActiveRecord::Migration[8.1]
def up
add_column :coplan_plans, :review_sequence, :bigint, null: false, default: 0
add_column :coplan_review_requests, :generation, :bigint, null: false, default: 1
add_column :coplan_review_requests, :current_response_id, :string, limit: 36
add_column :coplan_review_requests, :reset_reason, :string
add_column :coplan_review_responses, :request_generation, :bigint
add_column :coplan_review_responses, :decision_sequence, :bigint
add_index :coplan_review_requests, :current_response_id
add_foreign_key :coplan_review_requests, :coplan_review_responses, column: :current_response_id, on_delete: :nullify
# Old timestamps cannot prove which request cycle a response completed.
# Keep those receipts as history; require a fresh decision for active tasks.
execute "UPDATE coplan_review_requests SET generation = 2"
execute "UPDATE coplan_review_requests SET status = 'pending', reset_reason = 'legacy_history' WHERE status <> 'withdrawn'"
end

def down
remove_foreign_key :coplan_review_requests, column: :current_response_id
remove_column :coplan_review_requests, :current_response_id
remove_column :coplan_review_requests, :generation
remove_column :coplan_review_requests, :reset_reason
remove_column :coplan_review_responses, :request_generation
remove_column :coplan_review_responses, :decision_sequence
remove_column :coplan_plans, :review_sequence
end
end
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
# This migration comes from co_plan (originally 20261029120000)
class AddReviewResetPolicyToCoplanPlans < ActiveRecord::Migration[8.1]
def change
add_column :coplan_plans, :reset_reviews_on_change, :boolean, null: false, default: true
end
end
11 changes: 10 additions & 1 deletion db/schema.rb

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

2 changes: 2 additions & 0 deletions docs/DEMO_LIBRARY.md
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,8 @@ install demo users or documents.

## Named scenarios

The **Workspace invitations: review dismissed after edits** scenario keeps v1 approvals as history while v2 awaits fresh review. Sign in as Alex to approve v2, or Hampton to edit it again. See [Version-bound reviews](REVIEWS.md) for the source scope and lifecycle contract.

The index starts with **Diagrams**, **Presentations**, **Tables and rich text**,
and **References**. Every link labels the document's actual plan type. The
**Every plan type** section links to a real example of each of the twelve
Expand Down
39 changes: 39 additions & 0 deletions docs/REVIEWS.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
# Version-bound reviews

A native review means satisfaction with a specific Markdown source version. New approvals require the latest version. Older versions remain available as review history, not as targets for new approvals. It grants no execution authority or permissions. An agent response remains an agent response attributed to its human principal; it is never a human sign-off.

## Content and decision identity

Responses retain `plan_version_id`, that version’s SHA-256, `request_generation`, and a server-assigned `decision_sequence`. UUIDs identify transport retries; they do not order decisions. A request’s `current_response_id` is the authoritative projection. `review_status` and `recorded_response_id` describe lifecycle completion, including a decision retained across edits when automatic resets are off. A response is effective only when it belongs to that request, generation and current plan version and matches the task status. `effective_human_decision` is null for pending, withdrawn, dismissed or agent decisions. `latest_approval` is positive history for comparison, not qualification.

The approval covers **Markdown source bytes only**. Title, tags, attachments, linked and embedded resources, child plans, and rendered output are excluded. URLs and embedded source syntax are approved as literal text, not as the bytes they might display. Historical pages expose exact source and hash and label the current title as metadata. Full rich-page approval requires a separate immutable asset envelope and renderer contract.

## Edits and cycles

All current-version writers use `Plans::AdvanceVersion`: Create, ReplaceContent (including SaveHumanDraft/web saves), CommitSession, ToggleCheckbox and the direct operations API. They already hold the plan row lock; CommitSession retains session-before-plan lock order. The version pointer, revision and review sequence always advance together. With `reset_reviews_on_change` enabled (the default), active task resets, generation increments and cleared current pointers commit in that same transaction. Withdrawn tasks and all receipts remain intact. Resets broadcast after commit without sending a review-request notification for every edit.

Reset delivery failures do not change the committed save result or prevent other reviewers' events and the controls invalidation. Failed request deliveries and controls broadcasts use the existing job queue with bounded retries; obsolete request generations and missing plans are skipped. Delivery is at least once. If queueing also fails, the error is reported for operational recovery; there is no transactional outbox guarantee.

An identical normalized ReplaceContent save creates no version and preserves approval. An empty session creates no version. A nonempty session or operations commit creates a new version even if its operations net to identical bytes, and resets reviews when the policy is enabled. Returning A→B→A always produces a distinct version; no older decision becomes exact-current-version evidence. Existing edit leases, human-edit fences and merge guards remain in place.

Editors can toggle **Require a new review after changes** in Manage reviewers. The setting applies to future edits for every reviewer. When off, completed approvals and change requests remain completed on their original versions; edits require no fresh review. Pending tasks stay pending, and withdrawn tasks stay withdrawn. Turning resets back on affects the next edit; turning them off never restores an already dismissed decision. Exact-current-version `effective_status`, `effective_response_id` and `effective_human_decision` remain strict regardless of this preference. Policy updates take the same plan row lock and require the displayed `review_sequence`. Every accepted write advances it, including same-value writes that fence older requests with lost results. A stale sequence returns 409; the dialog reads back the setting before retrying the latest choice.

An explicit re-request advances the task generation and clears its effective decision. Creating an already existing request is a retry and leaves its state alone. Withdrawal clears the effective decision and preserves receipts. A fresh response supersedes the previous effective response, including a later agent response. The latest sequenced human response on the current version is exposed separately so consumers can inspect human feedback across request cycles; native CoPlan does not impose an external approval or objection policy.

New responses send the viewed revision, fetched generation and a fresh UUID `idempotency_key`. Stale revisions or generations fail with 409. Repeating a recorded key returns its immutable receipt without touching status, current pointer, sequence, attribution or notifications, even after an edit, withdrawal or re-request. Unkeyed identical legacy retries return history too. First-cycle clients may omit generation; subsequent cycles require it, including first-seen delayed keyed submissions. A fresh intentional identical response needs a new key and the current generation.

## Reads and compatibility

`GET /api/v1/plans/:id/review_evidence` takes one plan-row transaction and returns exact current source/hash/version, scope, review sequence, reset preference, lifecycle and exact-version projections, and complete response history. It creates no PlanRead or agent session. Ordinary authentication may still update token usage. The review sequence covers body and native review changes, not excluded metadata, sharing or external authorization. Existing snapshot reads retain their older behavior; consumers requiring coherent evidence use this endpoint.

`GET /api/v1/plans/:id/versions/:version_id` returns that exact historical Markdown and hash. It never substitutes the current version. Both endpoints use existing Bearer authentication and visibility rules; neither establishes a read-only workload credential or employee identity mapping.

Migration preserves old receipts with unknown generation/sequence. Existing active tasks become pending with `reset_reason=legacy_history`; withdrawn tasks stay withdrawn. Existing tasks enter generation 2 so first-seen old requests without a cycle cannot complete them. No timestamp or UUID ordering is used to invent legacy effective approvals. Hosts must install the engine migration and update clients to send generation. Downgrading drops the new projection but does not restore prior statuses.

Persisted version IDs, plan IDs, revisions, Markdown and hashes reject ordinary Rails mutation, including `update_columns`. Writing provenance backfills remain supported. Hashes are checked at creation and before new responses or source evidence reads. These are application guarantees, not database/WORM protection against administrator SQL, bulk writes or deletion.

## Local tryout

Seed the shared demo library with `bin/rails coplan:demo:seed`. Sign in as `alex@example.com` and open **Workspace invitations: review dismissed after edits** in Hampton’s Demo scenarios. v1 approvals are history, v2 is pending, and the activity link opens the original source. Approve v2, then sign in as `hampton@example.com` and edit its body or toggle its checkbox. Alex’s task returns to pending. Re-seeding preserves edits and decisions.

For the off state, open **Workspace invitations: reviews retained after edits**. Automatic resets are off: v1 decisions remain completed after v2, with an explicit changed-content label. Toggle the option in Manage reviewers to try both policies.
5 changes: 5 additions & 0 deletions engine/app/assets/stylesheets/coplan/review.css
Original file line number Diff line number Diff line change
Expand Up @@ -241,6 +241,8 @@ button.visibility-option__button:hover:not(:disabled) { background: var(--color-
.review-assignment-form__footer { display: flex; justify-content: space-between; align-items: center; gap: 12px; margin-top: 12px; }
.review-dialog .review-required { display: flex; gap: 8px; align-items: center; margin: 0; color: var(--color-text); cursor: pointer; }
.review-dialog .review-required input { width: 15px; height: 15px; padding: 0; margin: 0; accent-color: var(--color-primary); }
.review-reset-policy { margin-bottom: 20px; }
.review-reset-policy p { margin: 6px 0 0 23px; }
.review-search-results { position: absolute; z-index: 1; top: calc(100% + 6px); left: 0; right: 0; max-height: 210px; overflow: auto; padding: 6px; border: 1px solid var(--glass-edge); border-radius: 10px; background: var(--color-surface); color: var(--color-text-muted); box-shadow: var(--shadow-lg); font-size: 12px; }
.review-search-results[hidden] { display: none; }
.review-search-result {
Expand Down Expand Up @@ -528,3 +530,6 @@ body:has(.review-tray[data-completed="true"]:not([data-reconsidering="true"]):no

/* Live content and the inline editor share this reader, including empty plans. */
.plan-reader:has(.markdown-rendered > *) > .empty-state { display: none; }

.review-reset-policy__status { display: inline-block; width: 1em; color: var(--color-text-muted); opacity: 0; }
.review-reset-policy__status[data-state="saved"] { opacity: 1; }
Original file line number Diff line number Diff line change
Expand Up @@ -230,10 +230,7 @@ def commit_version(current_content, result)
reason: params[:reason]
)

@plan.update!(
current_plan_version: version,
current_revision: new_revision
)
Plans::AdvanceVersion.call(plan: @plan, version: version)

@plan.comment_threads.mark_out_of_date_for_new_version!(version)

Expand Down
13 changes: 11 additions & 2 deletions engine/app/controllers/coplan/api/v1/plans_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,8 @@ module CoPlan
module Api
module V1
class PlansController < BaseController
before_action :set_plan, only: [ :show, :update, :versions, :comments, :snapshot, :locations, :writing_provenance ]
before_action :authorize_plan_access!, only: [ :show, :update, :versions, :comments, :snapshot, :locations, :writing_provenance ]
before_action :set_plan, only: [ :show, :update, :version, :versions, :comments, :snapshot, :review_evidence, :locations, :writing_provenance ]
before_action :authorize_plan_access!, only: [ :show, :update, :version, :versions, :comments, :snapshot, :review_evidence, :locations, :writing_provenance ]

def index
plans = Plan
Expand Down Expand Up @@ -270,6 +270,11 @@ def update
render json: { error: e.record.errors.full_messages.join(", ") }, status: :unprocessable_content
end

def version
version = @plan.plan_versions.find(params[:version_id])
render json: version_json(version).merge(content_markdown: version.verified_content!, approval_scope: Reviews::Evidence::SCOPE)
end

def versions
versions = @plan.plan_versions.order(revision: :desc)
render json: versions.map { |v| version_json(v) }
Expand Down Expand Up @@ -307,6 +312,10 @@ def comments
render json: threads.map { |t| thread_json(t) }
end

def review_evidence
render json: Reviews::Evidence.call(plan: @plan)
end

def snapshot
record_plan_read!(@plan)
threads = @plan.comment_threads.includes(:comments, :created_by_user).order(created_at: :desc)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,10 @@ def update
Reviews::Request.call(plan: @plan, reviewer: @review_request.reviewer, actor: current_user,
requirement: params[:requirement].presence || @review_request.requirement, api_token: @api_token)
else
@plan.with_lock { @review_request.update!(requirement: params.require(:requirement)) }
@plan.with_lock do
@review_request.update!(requirement: params.require(:requirement))
@plan.update!(review_sequence: @plan.review_sequence + 1)
end
Reviews::Changed.call(request: @review_request, event_type: "review.requested", actor_token_id: api_token_id,
payload: { "action" => "requirement_changed", "requirement" => @review_request.requirement })
end
Expand All @@ -54,10 +57,12 @@ def respond
return render json: { error: "Only the requested reviewer can respond" }, status: :forbidden
end
response = Reviews::Respond.call(request: @review_request, actor: current_user,
revision: params.require(:revision), decision: params.require(:decision), note: params[:note], api_token: @api_token, idempotency_key: params[:idempotency_key])
revision: params.require(:revision), decision: params.require(:decision), note: params[:note], api_token: @api_token, idempotency_key: params[:idempotency_key], generation: params[:generation])
render json: { id: response.id, decision: response.decision, revision: response.plan_version.revision,
actor_type: response.actor_type, agent_name: response.agent_name,
comment_thread_id: response.comment_thread_id }, status: :created
comment_thread_id: response.comment_thread_id, plan_version_id: response.plan_version_id,
content_sha256: response.plan_version.content_sha256, generation: response.request_generation,
decision_sequence: response.decision_sequence, effective: @review_request.reload.effective_response&.id == response.id }, status: :created
end

private
Expand Down
Loading
Loading