Skip to main content

max / makenotwork

server: ship AI disclosure surface per generative-ai.md Item page now shows a colored AI-tier pill (Handmade=violet, Assisted= warm-tan, Generated=charcoal — brand palette, deliberately not traffic- light coloring). Assisted items also render the ai_disclosure text above the buy box so fans see it before purchase. Discover row gets the tier as plain mono text (no pill — keeps row density). DbDiscoverItemRow + DiscoverItem thread ai_tier through; the 3 SELECTs in db/discover.rs and the follow-feed SELECT in db/follows.rs all pull i.ai_tier now. Sidebar filter rewrites the discrete-bucket shape into the policy's Everything / Human-led (handmade ∪ assisted) / Handmade only. New AiTierFilter enum encodes the filter as distinct-from-AiTier (since "Human-led" isn't a per-item value). SQL uses inline literal fragments for the WHERE clause — enum-derived, no user input — which freed up bind position $6, so LIMIT/OFFSET shift from $7/$8 to $6/$7. Project wizards drop the pre-checked handmade default and add `required` to all three radios so the creator picks deliberately (matches policy's "no unlabeled option" spirit). Policy: community reports route to reports@makenot.work (email path) for now; in-app /report endpoint stays on the Phase 4 list. Three new SQL builder tests lock the filter literals (handmade_only = single tier, human_led = IN handmade,assisted) and the enum round-trip. 1,661 lib tests passing.
Author: Max Johnson <me@maxj.phd> · 2026-06-04 03:21 UTC
Commit: 0416215dc91bfdec4314290936d509e45a898dcb
Parent: 5123cf2
13 files changed, +212 insertions, -39 deletions
@@ -84,7 +84,7 @@ Filters apply to the Discover page, search results, and tag browsing. The tier i
84 84 The tier system is self-reported by creators. We enforce it through:
85 85
86 86 1. **The disclosure statement.** Assisted-tier creators put their AI use in writing. If the product contradicts the disclosure, that is documented misrepresentation.
87 - 2. **Community reports.** Fans and fellow creators can flag items they believe are misclassified.
87 + 2. **Community reports.** Fans and fellow creators can flag items they believe are misclassified by emailing [reports@makenot.work](mailto:reports@makenot.work) with the item URL and what they think the correct tier should be. An in-app report flow is on the roadmap.
88 88 3. **Moderation review.** Misrepresenting your tier is treated as fraud. Consequences follow the [Acceptable Use Policy](../legal/acceptable-use.md), up to and including account termination.
89 89
90 90 If you used AI, say so, explain how, and let your audience decide. That is the point of the Assisted tier.
@@ -4,7 +4,7 @@
4 4
5 5 use sqlx::{FromRow, PgPool};
6 6
7 - use super::enums::{AiTier, DiscoverSort, ItemType};
7 + use super::enums::{AiTierFilter, DiscoverSort, ItemType};
8 8 use super::models::*;
9 9 use crate::error::Result;
10 10
@@ -20,7 +20,7 @@ pub struct DiscoverFilters<'a> {
20 20 pub min_price: Option<i32>,
21 21 pub max_price: Option<i32>,
22 22 pub sort_by: Option<DiscoverSort>,
23 - pub ai_tier: Option<AiTier>,
23 + pub ai_tier: Option<AiTierFilter>,
24 24 }
25 25
26 26 // Shared SQL fragments for fuzzy search (trigram + ILIKE fallback).
@@ -113,12 +113,22 @@ fn append_item_discover_filters(
113 113 )"#,
114 114 );
115 115 }
116 - if filters.ai_tier.is_some() {
117 - query.push_str(" AND i.ai_tier = $6");
116 + // AI disclosure filter: `Handmade only` narrows to handmade; `Human-led`
117 + // accepts handmade ∪ assisted. Values are enum-derived constants (not user
118 + // input), so inlining the literals is safe and keeps the bind-position
119 + // count stable across queries that use this fragment.
120 + match filters.ai_tier {
121 + Some(AiTierFilter::HandmadeOnly) => query.push_str(" AND i.ai_tier = 'handmade'"),
122 + Some(AiTierFilter::HumanLed) => {
123 + query.push_str(" AND i.ai_tier IN ('handmade', 'assisted')")
124 + }
125 + None => {}
118 126 }
119 127 }
120 128
121 - /// Bind the 6 discover-filter parameters ($1-$6) to a sqlx query.
129 + /// Bind the 5 discover-filter parameters ($1-$5) to a sqlx query.
130 + /// The AI-tier filter is appended to the WHERE as a literal SQL fragment,
131 + /// so it occupies no bind position.
122 132 macro_rules! bind_item_discover_filters {
123 133 ($q:expr, $filters:expr, $search_term:expr) => {
124 134 $q.bind($search_term.unwrap_or(""))
@@ -126,7 +136,6 @@ macro_rules! bind_item_discover_filters {
126 136 .bind($filters.min_price.unwrap_or(0))
127 137 .bind($filters.max_price.unwrap_or(i32::MAX))
128 138 .bind($filters.tag.unwrap_or(""))
129 - .bind($filters.ai_tier.map(|t| t.to_string()).unwrap_or_default())
130 139 };
131 140 }
132 141
@@ -173,6 +182,7 @@ pub async fn discover_items(
173 182 pt.name as primary_tag_name,
174 183 i.pwyw_enabled,
175 184 i.pwyw_min_cents,
185 + i.ai_tier,
176 186 GREATEST(
177 187 similarity(i.title, $1),
178 188 similarity(COALESCE(i.description, ''), $1) * 0.5
@@ -202,6 +212,7 @@ pub async fn discover_items(
202 212 pt.name as primary_tag_name,
203 213 i.pwyw_enabled,
204 214 i.pwyw_min_cents,
215 + i.ai_tier,
205 216 1.0::real as match_score
206 217 FROM items i
207 218 JOIN projects p ON i.project_id = p.id
@@ -227,6 +238,7 @@ pub async fn discover_items(
227 238 pt.name as primary_tag_name,
228 239 i.pwyw_enabled,
229 240 i.pwyw_min_cents,
241 + i.ai_tier,
230 242 NULL::real as match_score
231 243 FROM items i
232 244 JOIN projects p ON i.project_id = p.id
@@ -252,7 +264,7 @@ pub async fn discover_items(
252 264 }
253 265 };
254 266
255 - query.push_str(&format!(" ORDER BY {} LIMIT $7 OFFSET $8", order));
267 + query.push_str(&format!(" ORDER BY {} LIMIT $6 OFFSET $7", order));
256 268
257 269 let items = bind_item_discover_filters!(
258 270 sqlx::query_as::<_, DbDiscoverItemRow>(&query),
@@ -834,4 +846,45 @@ mod tests {
834 846 append_item_discover_filters(&mut q, &filters, true, false);
835 847 assert!(q.contains("i.title % $1"));
836 848 }
849 +
850 + #[test]
851 + fn append_filters_handmade_only_narrows_to_one_tier() {
852 + let filters = DiscoverFilters {
853 + search: None, item_type: None, tag: None,
854 + min_price: None, max_price: None, sort_by: None,
855 + ai_tier: Some(AiTierFilter::HandmadeOnly),
856 + };
857 + let mut q = String::new();
858 + append_item_discover_filters(&mut q, &filters, false, false);
859 + assert!(q.contains("i.ai_tier = 'handmade'"));
860 + assert!(!q.contains("assisted"));
861 + }
862 +
863 + #[test]
864 + fn append_filters_human_led_includes_handmade_and_assisted() {
865 + // Locks the policy commitment that Human-led covers BOTH handmade
866 + // and assisted. A future rename of the literals or a swap to a
867 + // single-tier match would silently weaken the filter.
868 + let filters = DiscoverFilters {
869 + search: None, item_type: None, tag: None,
870 + min_price: None, max_price: None, sort_by: None,
871 + ai_tier: Some(AiTierFilter::HumanLed),
872 + };
873 + let mut q = String::new();
874 + append_item_discover_filters(&mut q, &filters, false, false);
875 + assert!(q.contains("i.ai_tier IN ('handmade', 'assisted')"));
876 + assert!(!q.contains("generated"));
877 + }
878 +
879 + #[test]
880 + fn ai_tier_filter_round_trip() {
881 + // Parses the query-string value the route receives back into the
882 + // typed enum the SQL builder expects.
883 + assert_eq!("handmade_only".parse::<AiTierFilter>().unwrap(), AiTierFilter::HandmadeOnly);
884 + assert_eq!("human_led".parse::<AiTierFilter>().unwrap(), AiTierFilter::HumanLed);
885 + assert!("everything".parse::<AiTierFilter>().is_err());
886 + assert!("assisted".parse::<AiTierFilter>().is_err());
887 + assert_eq!(AiTierFilter::HumanLed.to_string(), "human_led");
888 + assert_eq!(AiTierFilter::HandmadeOnly.label(), "Handmade only");
889 + }
837 890 }
@@ -591,6 +591,31 @@ impl AiTier {
591 591 }
592 592 }
593 593
594 + /// Discover-page filter shape per `about/generative-ai.md` § "How Fans
595 + /// Use This". Distinct from `AiTier` because this is a *filter*, not a
596 + /// per-item value: `HumanLed` aggregates the Handmade + Assisted tiers.
597 + /// `None` on `DiscoverFilters.ai_tier` means "Everything" — no
598 + /// restriction.
599 + #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
600 + pub enum AiTierFilter {
601 + HandmadeOnly,
602 + HumanLed,
603 + }
604 +
605 + impl_str_enum!(AiTierFilter {
606 + HandmadeOnly => "handmade_only",
607 + HumanLed => "human_led",
608 + });
609 +
610 + impl AiTierFilter {
611 + pub fn label(&self) -> &'static str {
612 + match self {
613 + Self::HandmadeOnly => "Handmade only",
614 + Self::HumanLed => "Human-led",
615 + }
616 + }
617 + }
618 +
594 619 // ── Project Features ──
595 620
596 621 #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
@@ -142,7 +142,8 @@ pub async fn get_followed_feed_items(
142 142 pt.name as primary_tag_name,
143 143 i.pwyw_enabled,
144 144 i.pwyw_min_cents,
145 - NULL::real as match_score
145 + NULL::real as match_score,
146 + i.ai_tier
146 147 FROM items i
147 148 JOIN projects p ON i.project_id = p.id
148 149 JOIN users u ON p.user_id = u.id
@@ -37,6 +37,8 @@ pub struct DbDiscoverItemRow {
37 37 pub pwyw_min_cents: Option<i32>,
38 38 /// Trigram similarity score (set when searching, `None` otherwise).
39 39 pub match_score: Option<f32>,
40 + /// AI disclosure tier — drives the badge / row label on Discover.
41 + pub ai_tier: super::super::AiTier,
40 42 }
41 43
42 44 /// A flattened project row returned by the discover projects query.
@@ -95,7 +95,7 @@ async fn fetch_discover_data(pool: &PgPool, query: &DiscoverQuery) -> Result<Dis
95 95
96 96 let category_filter = query.category.as_deref().filter(|s| !s.is_empty());
97 97
98 - let ai_tier_filter: Option<db::AiTier> = query.ai_tier.as_deref()
98 + let ai_tier_filter: Option<db::AiTierFilter> = query.ai_tier.as_deref()
99 99 .filter(|s| !s.is_empty())
100 100 .and_then(|s| s.parse().ok());
101 101
@@ -399,31 +399,46 @@ pub(super) async fn discover(
399 399 });
400 400 }
401 401
402 + // Per `about/generative-ai.md` § "How Fans Use This", the three
403 + // filter options are "Everything" / "Human-led" (Handmade ∪
404 + // Assisted) / "Handmade only". Aggregate the per-tier counts
405 + // into option-sized counts before passing to the template.
406 + let mut handmade_count: u32 = 0;
407 + let mut assisted_count: u32 = 0;
408 + for ac in &ai_counts {
409 + match ac.category.as_str() {
410 + "handmade" => handmade_count = ac.count as u32,
411 + "assisted" => assisted_count = ac.count as u32,
412 + _ => {}
413 + }
414 + }
402 415 let ai_tier_filter_str = query.ai_tier.as_deref().filter(|s| !s.is_empty());
403 - let mut ai_tier_filters: Vec<FilterCategory> = vec![FilterCategory {
404 - name: "All".to_string(),
405 - value: String::new(),
406 - count: data.total_count,
407 - active: ai_tier_filter_str.is_none(),
408 - id: String::new(),
409 - following: false,
410 - }];
411 - for ac in ai_counts {
412 - let label = match ac.category.as_str() {
413 - "handmade" => "Handmade",
414 - "assisted" => "Assisted",
415 - "generated" => "Generated",
416 - other => other,
417 - };
418 - ai_tier_filters.push(FilterCategory {
419 - value: ac.category.clone(),
420 - name: label.to_string(),
421 - count: ac.count as u32,
422 - active: ai_tier_filter_str == Some(ac.category.as_str()),
416 + let ai_tier_filters: Vec<FilterCategory> = vec![
417 + FilterCategory {
418 + name: "Everything".to_string(),
419 + value: String::new(),
420 + count: data.total_count,
421 + active: ai_tier_filter_str.is_none(),
423 422 id: String::new(),
424 423 following: false,
425 - });
426 - }
424 + },
425 + FilterCategory {
426 + name: db::AiTierFilter::HumanLed.label().to_string(),
427 + value: db::AiTierFilter::HumanLed.to_string(),
428 + count: handmade_count + assisted_count,
429 + active: ai_tier_filter_str == Some(db::AiTierFilter::HumanLed.to_string().as_str()),
430 + id: String::new(),
431 + following: false,
432 + },
433 + FilterCategory {
434 + name: db::AiTierFilter::HandmadeOnly.label().to_string(),
435 + value: db::AiTierFilter::HandmadeOnly.to_string(),
436 + count: handmade_count,
437 + active: ai_tier_filter_str == Some(db::AiTierFilter::HandmadeOnly.to_string().as_str()),
438 + id: String::new(),
439 + following: false,
440 + },
441 + ];
427 442
428 443 (type_filters, tag_filters, ai_tier_filters, price_counts)
429 444 } else {
@@ -77,6 +77,7 @@ impl From<db::DbDiscoverItemRow> for DiscoverItem {
77 77 is_free: i.price_cents == 0 && !i.pwyw_enabled,
78 78 sales: i.sales_count.clamp(0, u32::MAX as i64) as u32,
79 79 date: i.created_at.format(DATE_FMT_SHORT).to_string(),
80 + ai_tier: i.ai_tier.to_string(),
80 81 }
81 82 }
82 83 }
@@ -15,6 +15,10 @@ pub struct DiscoverItem {
15 15 pub is_free: bool,
16 16 pub sales: u32,
17 17 pub date: String,
18 + /// AI disclosure tier — surfaces as plain text on the discover row.
19 + /// Stored as the snake_case string form so the template can drop it
20 + /// verbatim without needing to know about the daemon's enum type.
21 + pub ai_tier: String,
18 22 }
19 23
20 24 /// Project in the discover list (projects mode)
@@ -1025,6 +1025,58 @@ form button:active {
1025 1025 color: var(--primary-light);
1026 1026 }
1027 1027
1028 + /* AI disclosure tier badges. See site-docs/public/about/generative-ai.md.
1029 + Colors track the brand palette — violet for the cleanest case,
1030 + warm-tan for disclosed-AI-use, charcoal for primarily-generated.
1031 + Deliberately not red/yellow/green; this is disclosure, not alarm. */
1032 + .badge.ai-tier {
1033 + letter-spacing: 0.02em;
1034 + }
1035 + .badge.ai-tier-handmade {
1036 + background: var(--highlight);
1037 + color: var(--primary-light);
1038 + }
1039 + .badge.ai-tier-assisted {
1040 + background: var(--warning);
1041 + color: var(--primary-light);
1042 + }
1043 + .badge.ai-tier-generated {
1044 + background: var(--detail);
1045 + color: var(--primary-light);
1046 + }
1047 +
1048 + .item-ai-tier {
1049 + margin: 0.25rem 0 0.75rem;
1050 + }
1051 +
1052 + .ai-disclosure {
1053 + margin: 0.75rem 0;
1054 + padding: 0.75rem 1rem;
1055 + background: var(--surface-muted);
1056 + border-left: 3px solid var(--warning);
1057 + }
1058 + .ai-disclosure-label {
1059 + font-family: var(--font-mono);
1060 + font-size: 0.75rem;
1061 + text-transform: uppercase;
1062 + letter-spacing: 0.05em;
1063 + color: var(--text-muted);
1064 + margin-bottom: 0.25rem;
1065 + }
1066 + .ai-disclosure-text {
1067 + font-size: 0.9rem;
1068 + color: var(--detail);
1069 + }
1070 +
1071 + /* Discover row: plain text mention per design decision 2026-06-03 —
1072 + no pill in the row, so fans skim the listing without disclosure noise
1073 + and use the explicit filter when they care. */
1074 + .discover-row-ai-tier {
1075 + font-family: var(--font-mono);
1076 + font-size: 0.75rem;
1077 + color: var(--text-muted);
1078 + }
1079 +
1028 1080 /* ===========================================
1029 1081 TAGS
1030 1082 =========================================== */
@@ -95,6 +95,12 @@
95 95
96 96 <div class="item-price">{{ item.price }}</div>
97 97
98 + {# AI disclosure tier — visible before purchase per
99 + site-docs/public/about/generative-ai.md. #}
100 + <div class="item-ai-tier">
101 + <span class="badge ai-tier ai-tier-{{ item.ai_tier }}">{{ item.ai_tier.label() }}</span>
102 + </div>
103 +
98 104 <div class="item-meta">
99 105 <div class="meta-item">
100 106 <div class="meta-label">Type</div>
@@ -128,6 +134,18 @@
128 134 {% endif %}
129 135 </div>
130 136 {% else %}
137 + {# Assisted items disclose AI use above the buy CTA so
138 + fans see it before purchase. Handmade and Generated
139 + carry the badge above; only Assisted owes prose. #}
140 + {% if let crate::db::AiTier::Assisted = item.ai_tier %}
141 + {% if let Some(disclosure) = item.ai_disclosure.as_ref() %}
142 + <div class="ai-disclosure">
143 + <div class="ai-disclosure-label">AI use</div>
144 + <div class="ai-disclosure-text">{{ disclosure }}</div>
145 + </div>
146 + {% endif %}
147 + {% endif %}
148 +
131 149 <div class="purchase-box card-muted">
132 150 <h3>What's included:</h3>
133 151 <ul>
@@ -25,6 +25,7 @@
25 25 <span class="row-category">{{ item.primary_tag }}</span>
26 26 <span class="row-price">{{ item.price }}</span>
27 27 <span class="row-date">{{ item.date }}</span>
28 + <span class="discover-row-ai-tier">{{ item.ai_tier }}</span>
28 29 </a>
29 30 {% if is_authenticated %}
30 31 <button class="row-save collection-picker-anchor" data-collection-trigger data-item-id="{{ item.id }}"
@@ -77,6 +78,7 @@
77 78 <div class="grid-card-footer">
78 79 <span class="grid-card-category">{{ item.primary_tag }}</span>
79 80 <span class="grid-card-price">{{ item.price }}</span>
81 + <span class="discover-row-ai-tier">{{ item.ai_tier }}</span>
80 82 </div>
81 83 </div>
82 84 </a>
@@ -61,17 +61,17 @@
61 61 <div class="hint wizard-hint-gap-sm">How was AI used in creating this project's content?</div>
62 62 <div class="wizard-radio-group">
63 63 <label class="wizard-radio-option">
64 - <input type="radio" name="ai_tier" value="handmade"
65 - {% if ai_tier == "handmade" || ai_tier == "" %}checked{% endif %}>
64 + <input type="radio" name="ai_tier" value="handmade" required
65 + {% if ai_tier == "handmade" %}checked{% endif %}>
66 66 <span><strong>Handmade</strong> — no AI tools used in content creation</span>
67 67 </label>
68 68 <label class="wizard-radio-option">
69 - <input type="radio" name="ai_tier" value="assisted"
69 + <input type="radio" name="ai_tier" value="assisted" required
70 70 {% if ai_tier == "assisted" %}checked{% endif %}>
71 71 <span><strong>AI-Assisted</strong> — AI tools used as part of the creative process</span>
72 72 </label>
73 73 <label class="wizard-radio-option">
74 - <input type="radio" name="ai_tier" value="generated"
74 + <input type="radio" name="ai_tier" value="generated" required
75 75 {% if ai_tier == "generated" %}checked{% endif %}>
76 76 <span><strong>AI-Generated</strong> — content primarily generated by AI</span>
77 77 </label>
@@ -81,15 +81,15 @@
81 81 <div class="hint wizard-hint-gap-sm">How was AI used in creating this project's content?</div>
82 82 <div class="wizard-radio-group">
83 83 <label class="wizard-radio-option">
84 - <input type="radio" name="ai_tier" value="handmade" checked>
84 + <input type="radio" name="ai_tier" value="handmade" required>
85 85 <span><strong>Handmade</strong> — no AI tools used in content creation</span>
86 86 </label>
87 87 <label class="wizard-radio-option">
88 - <input type="radio" name="ai_tier" value="assisted">
88 + <input type="radio" name="ai_tier" value="assisted" required>
89 89 <span><strong>AI-Assisted</strong> — AI tools used as part of the creative process</span>
90 90 </label>
91 91 <label class="wizard-radio-option">
92 - <input type="radio" name="ai_tier" value="generated">
92 + <input type="radio" name="ai_tier" value="generated" required>
93 93 <span><strong>AI-Generated</strong> — content primarily generated by AI</span>
94 94 </label>
95 95 </div>