<% record = Struct.new(:first_duration, :second_duration, :third_duration).new(*(f.object.value || {}).values_at('first_duration', 'second_duration', 'third_duration')) %>
- <% durations = (Wabosign.multitenant? ? AccountConfigs::REMINDER_DURATIONS.except('one_hour', 'two_hours') : AccountConfigs::REMINDER_DURATIONS).keys.map { |v| [t(v.underscore), v] } %>
+ <% durations = AccountConfigs::REMINDER_DURATIONS.keys.map { |v| [t(v.underscore), v] } %>
<%= f.fields_for :value, record do |ff| %>
diff --git a/bin/fork-check b/bin/fork-check
new file mode 100755
index 00000000..8eeb478d
--- /dev/null
+++ b/bin/fork-check
@@ -0,0 +1,229 @@
+#!/usr/bin/env ruby
+# frozen_string_literal: true
+#
+# bin/fork-check — assert the WaboSign fork invariants declared in
+# config/fork_invariants.yml. Exits 1 (printing every violation) if any
+# invariant is broken; prints "fork-check: ok" and exits 0 otherwise.
+#
+# This is the executable form of the REBRANDING.md post-merge checklist. It
+# catches an upstream merge that re-introduces a Pro gate, deletes fork code,
+# overwrites AGPL attribution, leaves a dangling partial render, or drifts the
+# rename PRESERVE/ALLOW lists apart. To add or change an invariant, edit
+# config/fork_invariants.yml (not this file). Stdlib-only, so the CI job needs
+# no gem install. Wired into CI via .github/workflows/ci.yml.
+
+require 'find'
+require 'set'
+require 'yaml'
+
+ROOT = File.expand_path('..', __dir__)
+MANIFEST = File.join(ROOT, 'config/fork_invariants.yml')
+
+# Files whose job is to *name* the forbidden markers — never scan them in the
+# tree-wide passes, or they would flag themselves.
+SELF_REFERENTIAL = Set.new([
+ 'config/fork_invariants.yml',
+ 'bin/fork-check',
+ 'bin/rebrand-sync',
+ 'bin/rebrand-check',
+ 'bin/sync-upstream',
+ 'REBRANDING.md'
+]).freeze
+
+DENY_PREFIXES = [
+ '.git/', '.github/', 'node_modules/', 'vendor/bundle/', 'vendor/cache/',
+ 'tmp/', 'log/', 'pg_data/', 'public/packs/', 'public/packs-test/',
+ 'wabosign/', '.claude/', 'coverage/'
+].freeze
+
+BINARY_EXT = Set.new(%w[
+ .png .jpg .jpeg .gif .ico .svg .pdf .zip .tgz .gz .bz2 .xz
+ .woff .woff2 .ttf .eot .otf .pkcs12 .p12 .pem .crt .cer .key
+ .db .sqlite .sqlite3 .so .dylib .dll .exe .onnx
+]).freeze
+
+def abs(rel)
+ File.join(ROOT, rel)
+end
+
+def likely_binary?(path)
+ return true if BINARY_EXT.include?(File.extname(path).downcase)
+
+ File.binread(path, 1024).include?("\x00")
+rescue StandardError
+ false
+end
+
+def marker_list(entry)
+ return [entry['marker']] if entry['marker']
+ return entry['marker_any'] if entry['marker_any']
+ return entry['marker_all'] if entry['marker_all']
+
+ []
+end
+
+def hit?(content, marker)
+ if marker.start_with?('regex:')
+ content.match?(Regexp.new(marker.sub(/\Aregex:/, '')))
+ else
+ content.include?(marker)
+ end
+end
+
+def first_lineno(content, marker)
+ rx = marker.start_with?('regex:') ? Regexp.new(marker.sub(/\Aregex:/, '')) : nil
+ content.each_line.with_index(1) do |line, n|
+ return n if rx ? line.match?(rx) : line.include?(marker)
+ end
+ nil
+end
+
+def read_lines(path)
+ File.readlines(path, encoding: 'UTF-8')
+rescue StandardError
+ []
+end
+
+manifest = YAML.safe_load(File.read(MANIFEST)) || {}
+violations = []
+
+# 1. Files that must exist (fork code + brand assets upstream tends to delete).
+Array(manifest['must_exist']).each do |path|
+ violations << "must_exist: missing required file: #{path}" unless File.exist?(abs(path))
+end
+
+# 2. Files that must not exist (placeholders / console controller / lib/docuseal.rb).
+Array(manifest['must_not_exist']).each do |path|
+ violations << "must_not_exist: forbidden file present: #{path}" if File.exist?(abs(path))
+end
+
+# 3. Markers that must be present in a named file (attribution / identifiers / SDK).
+Array(manifest['must_contain']).each do |entry|
+ path = entry['path']
+ unless File.exist?(abs(path))
+ violations << "must_contain: target file missing: #{path}"
+ next
+ end
+ content = File.read(abs(path))
+ markers = marker_list(entry)
+ if entry['marker_any']
+ unless markers.any? { |m| hit?(content, m) }
+ violations << "must_contain: #{path} contains none of #{markers.inspect} (#{entry['why']})"
+ end
+ else
+ markers.each do |m|
+ violations << "must_contain: #{path} is missing #{m.inspect} (#{entry['why']})" unless hit?(content, m)
+ end
+ end
+end
+
+# 4. Markers that must NOT appear in a named file (re-added Pro gates). Path-scoped.
+Array(manifest['must_not_contain']).each do |entry|
+ path = entry['path']
+ next unless File.exist?(abs(path))
+
+ content = File.read(abs(path))
+ marker_list(entry).each do |m|
+ next unless hit?(content, m)
+
+ violations << "must_not_contain: #{path}:#{first_lineno(content, m)} has forbidden marker #{m.inspect} (#{entry['why']})"
+ end
+end
+
+# 5. Markers banned across the whole tree (kept tiny — prefer path-scoped above).
+global = Array(manifest['forbidden_globally'])
+unless global.empty?
+ Find.find(ROOT) do |p|
+ rel = p.sub(%r{\A#{Regexp.escape(ROOT)}/}, '')
+ if File.directory?(p)
+ Find.prune if rel != '' && DENY_PREFIXES.any? { |d| "#{rel}/".start_with?(d) }
+ next
+ end
+ next if SELF_REFERENTIAL.include?(rel)
+ next if DENY_PREFIXES.any? { |d| rel.start_with?(d) }
+ next if likely_binary?(p)
+
+ read_lines(p).each_with_index do |line, i|
+ global.each do |entry|
+ m = entry['marker']
+ violations << "forbidden_globally: #{rel}:#{i + 1} has #{m.inspect} (#{entry['why']})" if line.include?(m)
+ end
+ end
+ end
+end
+
+# 6. i18n keys that must never reappear (dead paywall/feature strings).
+i18n_keys = Array(manifest['forbidden_i18n_keys'])
+i18n_path = 'config/locales/i18n.yml'
+if !i18n_keys.empty? && File.exist?(abs(i18n_path))
+ read_lines(abs(i18n_path)).each_with_index do |line, i|
+ i18n_keys.each do |k|
+ violations << "forbidden_i18n_keys: #{i18n_path}:#{i + 1} defines #{k}" if line.match?(/^\s*#{Regexp.escape(k)}:/)
+ end
+ end
+end
+
+# 7. Dangling partial renders: every render 'dir/name' must resolve to a file.
+views_root = abs('app/views')
+if File.directory?(views_root)
+ render_rx = %r{\brender\b(?:\s+partial:)?\s+['"]([a-z0-9_]+(?:/[a-z0-9_]+)+)['"]}
+ Find.find(views_root) do |p|
+ next if File.directory?(p)
+ next unless p.end_with?('.erb')
+
+ rel = p.sub(%r{\A#{Regexp.escape(ROOT)}/}, '')
+ read_lines(p).each_with_index do |line, i|
+ line.scan(render_rx).each do |(ref)|
+ dir, base = File.split(ref)
+ next unless Dir.glob(abs(File.join('app/views', dir, "_#{base}.*"))).empty?
+
+ violations << "dangling_partial: #{rel}:#{i + 1} renders '#{ref}' but app/views/#{dir}/_#{base}.* is missing"
+ end
+ end
+ end
+end
+
+# 8. PRESERVE (rebrand-sync) <-> ALLOW_PATTERNS (rebrand-check) consistency.
+# Every preserved token containing "docuseal" must be tolerated by at least
+# one allow-pattern, or the rename sweep and the survivor-check have drifted.
+def extract_block(path, regex)
+ File.read(path).match(regex)&.captures&.first
+end
+
+preserve_src = extract_block(abs('bin/rebrand-sync'), /^PRESERVE = \{\n(.*?)^\}\.freeze/m)
+allow_src = extract_block(abs('bin/rebrand-check'), /^ALLOW_PATTERNS = \[\n(.*?)^\]\.freeze/m)
+
+if preserve_src.nil? || allow_src.nil?
+ violations << 'consistency: could not locate PRESERVE and/or ALLOW_PATTERNS blocks ' \
+ '(bin/fork-check parser needs updating)'
+else
+ preserve_tokens = preserve_src.scan(/^\s*'([^']+)'\s*=>/).flatten
+ allow_patterns = []
+ allow_src.each_line do |l|
+ l.scan(/%r\{(.*?)\}/) { |c| allow_patterns << Regexp.new(c.first) }
+ end
+
+ if preserve_tokens.empty? || allow_patterns.empty?
+ violations << 'consistency: PRESERVE/ALLOW_PATTERNS extraction yielded zero entries (parser drift)'
+ else
+ preserve_tokens.select { |t| t.match?(/docuseal/i) }.each do |tok|
+ # SDK element tokens only ever appear wrapped (, "quoted", <encoded),
+ # and the allow-patterns require that context — so test the token in the
+ # contexts the sweep actually protects, not just bare.
+ candidates = [tok, "<#{tok} ", "<#{tok}>", "#{tok}>", "</#{tok}", "'#{tok}'", "\"#{tok}\""]
+ next if candidates.any? { |c| allow_patterns.any? { |re| c.match?(re) } }
+
+ violations << "consistency: PRESERVE token #{tok.inspect} (rebrand-sync) has no matching " \
+ 'ALLOW_PATTERN (rebrand-check); the lists have drifted'
+ end
+ end
+end
+
+if violations.empty?
+ puts 'fork-check: ok'
+ exit 0
+else
+ warn "fork-check: #{violations.size} invariant violation(s):"
+ violations.each { |v| warn " #{v}" }
+ exit 1
+end
diff --git a/bin/rebrand-check b/bin/rebrand-check
index 27c52bdb..2376d97a 100755
--- a/bin/rebrand-check
+++ b/bin/rebrand-check
@@ -53,6 +53,10 @@ ALLOW_FILES = Set.new([
'bin/rebrand-sync',
'bin/rebrand-check',
'bin/sync-upstream',
+ # Fork-invariant guard + its manifest deliberately name "docuseal" tokens
+ # (e.g. lib/docuseal.rb, docuseal-form) as markers to assert on.
+ 'bin/fork-check',
+ 'config/fork_invariants.yml',
'lib/wabosign.rb',
'lib/docuseal.rb',
# Migration that finds rows by the legacy docuseal_aatl name.
diff --git a/bin/rebrand-sync b/bin/rebrand-sync
index 07058192..5aca8331 100755
--- a/bin/rebrand-sync
+++ b/bin/rebrand-sync
@@ -50,6 +50,11 @@ DENY_PATHS = Set.new([
'bin/rebrand-sync',
'bin/rebrand-check',
'bin/sync-upstream',
+ # Fork-invariant guard + manifest: the sweep must not rewrite the "docuseal"
+ # tokens they intentionally assert on (e.g. the lib/docuseal.rb must_not_exist
+ # marker), or the guard would silently stop catching that regression.
+ 'bin/fork-check',
+ 'config/fork_invariants.yml',
# WaboSign brand logo files — must never be touched by the sweep;
# restored from ORIG_HEAD by bin/sync-upstream after an upstream merge.
'public/favicon.svg',
diff --git a/config/fork_invariants.yml b/config/fork_invariants.yml
new file mode 100644
index 00000000..2fa7f6cc
--- /dev/null
+++ b/config/fork_invariants.yml
@@ -0,0 +1,126 @@
+# WaboSign fork invariants — the executable form of the REBRANDING.md
+# "Post-Merge Verification Checklist".
+#
+# bin/fork-check reads this file and fails (exit 1) if any invariant is
+# violated. It runs in CI on every push, so an upstream merge that re-introduces
+# a Pro gate, deletes fork code, overwrites attribution, or leaves a dangling
+# partial fails the build instead of shipping silently.
+#
+# HOW TO EXTEND (for the next human or AI agent doing an upstream sync):
+# - Upstream re-added a paywall/gate? Add a `must_not_contain` entry SCOPED to
+# the exact file (see the multitenant? note below — never ban a token tree-
+# wide unless it is genuinely unique to the gate).
+# - Upstream deleted fork code / a brand asset? Add a `must_exist` entry.
+# - Upstream re-added a placeholder / console controller? Add `must_not_exist`.
+# - Always include a `why:` — it is the institutional memory the last sync
+# wished it had.
+#
+# Marker semantics (must_contain / must_not_contain / forbidden_globally):
+# marker: "" single literal substring
+# marker_any: ["a", "b"] at least one present (contain) / none present (not_contain)
+# marker_all: ["a", "b"] all present (must_contain only)
+# Prefix any marker value with "regex:" to match as a Ruby regular expression.
+
+# ---------------------------------------------------------------------------
+# Files that MUST exist. Upstream merges have deleted these fork-specific files
+# (damage class #3) and overwritten brand assets (damage class #1).
+# ---------------------------------------------------------------------------
+must_exist:
+ # Independently-developed SMS stack (not in upstream OSS edition)
+ - lib/sms.rb
+ - lib/sms/providers/bulkvs.rb
+ - lib/sms/providers/twilio.rb
+ - lib/sms/providers/voipms.rb
+ - lib/sms/providers/signalwire.rb
+ - app/controllers/sms_settings_controller.rb
+ # Role-based authorization (fork feature; deleted by the 3.0.2 sync)
+ - lib/ability.rb
+ # Brand assets (binary; bypass the text sweep, so easy to lose silently)
+ - public/favicon.svg
+ - public/favicon.ico
+ - public/favicon-16x16.png
+ - public/favicon-32x32.png
+ - public/favicon-96x96.png
+ - public/logo.svg
+
+# ---------------------------------------------------------------------------
+# Files that MUST NOT exist. Upstream re-adds these on a sync.
+# ---------------------------------------------------------------------------
+must_not_exist:
+ # Re-adding this defines a `Docuseal` module that collides with lib/wabosign.rb
+ # under Zeitwerk → boot failure (damage class #4).
+ - lib/docuseal.rb
+ # Console/Upgrade redirect controller — there is no Console in WaboSign.
+ - app/controllers/console_redirect_controller.rb
+ # Pro-gated placeholder views that replace the real free forms.
+ - app/views/esign_settings/_default_signature_row.html.erb
+ - app/views/sso_settings/_placeholder.html.erb
+
+# ---------------------------------------------------------------------------
+# Markers that MUST be present in a specific file (damage classes #5 attribution
+# and #3 renamed identifiers / SDK contract). Reading is scoped to the one file.
+# ---------------------------------------------------------------------------
+must_contain:
+ # --- AGPL §7(b) upstream attribution (must stay visible in interactive UIs) ---
+ - path: app/views/shared/_powered_by.html.erb
+ marker: "UPSTREAM_NAME"
+ why: "Footer credit links DocuSeal via Wabosign::UPSTREAM_NAME/URL — AGPL §7(b)."
+ - path: app/views/shared/_email_attribution.html.erb
+ marker: "product_name_is_a_fork_of_upstream_html"
+ why: "Email footer must state WaboSign is a fork of DocuSeal — AGPL §7(b)."
+ - path: app/javascript/submission_form/completed.vue
+ marker_any: ["fork_of", "DocuSeal"]
+ why: "Post-signing completion screen carries the DocuSeal fork credit."
+ - path: app/javascript/submission_form/calculator.js
+ marker: "DocuSeal"
+ why: "Upstream copyright header on this JS port must be retained."
+
+ # --- Renamed identifiers (sweep must have run; module must be Wabosign) ---
+ - path: lib/wabosign.rb
+ marker_all: ["module Wabosign", "PRODUCT_NAME", "AATL_CERT_NAME = 'wabosign_aatl'"]
+ why: "Core fork module + product/cert identity. Upstream resets these to DocuSeal."
+ - path: config/application.rb
+ marker: "module WaboSign"
+ why: "Rails app module must be the rebranded WaboSign, not DocuSeal."
+
+ # --- SDK embedding contract (intentionally keeps the docuseal-* names) ---
+ - path: app/views/templates/_embedding.html.erb
+ marker: "docuseal-form"
+ why: "Embedding docs must keep — SDK contract with downstream."
+ - path: app/controllers/embed_scripts_controller.rb
+ marker_any: ["docuseal-form", "docuseal-builder"]
+ why: "Custom-element registration keeps docuseal-* names for SDK compatibility."
+
+# ---------------------------------------------------------------------------
+# Markers that MUST NOT appear in a specific file (damage class #2 — re-added
+# Pro/freemium gates). ALWAYS path-scoped: e.g. `Wabosign.multitenant?` is
+# legitimate in ~19 other views, so banning it tree-wide would be wrong.
+# ---------------------------------------------------------------------------
+must_not_contain:
+ - path: app/controllers/errors_controller.rb
+ marker_any: ["ENTERPRISE_PATHS", "ENTERPRISE_FEATURE_MESSAGE", "Pro Edition"]
+ why: "3.0.2 re-added a 404-with-Pro-upsell paywall on HTML/PDF/DOCX export.
+ WaboSign ships every format free."
+ - path: app/views/notifications_settings/_reminder_form.html.erb
+ marker: "Wabosign.multitenant?"
+ why: "Re-added reminder-duration gate (hid one_hour/two_hours unless multitenant).
+ multitenant? is legitimate in ~19 OTHER views, so this ban is scoped here."
+
+# ---------------------------------------------------------------------------
+# Markers safe to ban across the whole tree (genuinely unique to a removed
+# feature). Keep this list tiny — prefer path-scoped must_not_contain.
+# ---------------------------------------------------------------------------
+forbidden_globally:
+ - marker: "console_redirect_index_path"
+ why: "There is no Console in WaboSign; upstream re-adds these call sites."
+
+# ---------------------------------------------------------------------------
+# i18n keys that must never reappear in config/locales/i18n.yml. These are dead
+# paywall/feature strings from upstream that get rebranded by the sweep (so they
+# read 'WaboSign Pro') but reference features WaboSign does not ship (damage #6).
+# ---------------------------------------------------------------------------
+forbidden_i18n_keys:
+ - unlock_with_docuseal_pro
+ - discord_community
+ - ai_assistant
+ - wabosign_trusted_signature