studio-engine 0.66.2 → 0.67.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 575e62e3700969f869d9e6b079c13a142c164cd655bf95d22be6eea95817e2c8
4
- data.tar.gz: c8ea9ca891cf76ebe2a1f4dbdf1e343546cca37917851feb7151fe59517b3a06
3
+ metadata.gz: 69e6eab8f9186157077a967e6fd21a6b075a94245969f26a42b57bf5154e59f0
4
+ data.tar.gz: d88f16a0143527684b091c40c0fd33dfc561661381fdcdc32ac08d09fa27119b
5
5
  SHA512:
6
- metadata.gz: 7387284b53eb7df98f34204d84bff4e10525c6fc1ff81a9e4ef01ae2418d5a689f523984ee94c128746ac260cc3af9c258c5fd15f81486f506d77e182a8dfb7c
7
- data.tar.gz: a1d93bcf649e7494cac13bab83cc76051e1e86c92f942558e2740f97ee98934ac77c2ebae989cee815c61d50f8e72c305f55f0cd714ebe9b8c4cfa8dd2bec7de
6
+ metadata.gz: de70d91aedcb0d23734ddde03e24272df70a982eea531583247608451f5da7bf86f485857cdc6ceeefaedf91319d318e56254d6e90c702be91b4317f0d34af68
7
+ data.tar.gz: 507a127ab69e870f0f82c0cfff93b5ca2d68b5241c4925eb58fae838ff78821a219614c31c2952fd7fe197b2f2c7eb2e906bfd3feff5ff3d9e973e8cdce15e78
data/CHANGELOG.md CHANGED
@@ -6,6 +6,30 @@ The format is [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). This pro
6
6
 
7
7
  ### Added
8
8
 
9
+ - **Knowledge layer primitive** — `Studio::KnowledgeDoc` + `/admin/knowledge`:
10
+ an S3-backed document store for an entity's business knowledge, built for the
11
+ McRitchie Industries acquisition agents (Samson/Dawn) but app-agnostic.
12
+ One row per document: entity, implicit folder `path` (the S3 key mirrors it,
13
+ so the bucket stays human-browsable), category, `document_date` (the as-of
14
+ date, distinct from upload time), `inbox → filed → superseded` lifecycle, and
15
+ a per-agent access map with three levels — `full` (reads the facts), `aware`
16
+ (knows the document exists and gets the safe `summary` + boundary line, not
17
+ the contents), `none` (does not see the row). "Aware" exists because an agent
18
+ with a hole in its context confabulates or stonewalls; one with an awareness
19
+ entry has something true to say and a boundary to hold.
20
+ The browser renders folder and flat ("show all") views with breadcrumbs,
21
+ entity/status filters, an inbox badge, per-agent access chips, and an intake
22
+ form whose uploads land as `inbox` for triage; downloads are 15-minute
23
+ presigned GETs. Storage goes through `Studio::S3` and **fails loudly** on an
24
+ unconfigured app (`NotConfigured` raised, plus a red banner on the browser) —
25
+ never a silent drop. Routes are opt-in (`Studio.draw_knowledge_routes`,
26
+ default off); `Studio.knowledge_agents` names the agent roster the intake UI
27
+ offers selects for. Ships the reference migration
28
+ `create_studio_knowledge_docs` — consumers run
29
+ `bin/rails studio_engine:install:migrations && bin/rails db:migrate`.
30
+ Deferred to a later cut, recorded on the task: coverage view against a
31
+ diligence tracker, recurring-series tracking, per-folder access defaults.
32
+
9
33
  - **A green comment-leak scan used to mean "not looked at".**
10
34
  `test/views/erb_comment_leak_test.rb` guards the ERB comment form in
11
35
  `app/views/**/*.erb`. It never looked inside `<script>`, and that is where this
@@ -0,0 +1,89 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Studio
4
+ # /admin/knowledge — the knowledge layer's browser + intake surface.
5
+ #
6
+ # A plain host-inherited controller whose views are bare content wrappers,
7
+ # like /admin/geo, so pages render inside each host's application layout and
8
+ # pick up that app's navbar and theme. Routes are opt-in
9
+ # (Studio.draw_knowledge_routes) — see Studio.routes.
10
+ #
11
+ # Uploads land as status "inbox"; an agent running the knowledge-intake SOP
12
+ # (or the operator, on the show page) files them — classification is a
13
+ # deliberate second step, not a side effect of upload.
14
+ class KnowledgeDocsController < ApplicationController
15
+ before_action :require_admin
16
+ before_action :set_doc, only: [:show, :update, :download]
17
+
18
+ VIEWS = %w[folders flat].freeze
19
+
20
+ def index
21
+ @view = VIEWS.include?(params[:view]) ? params[:view] : "folders"
22
+ @folder = Studio::KnowledgeDoc.normalize_path(params[:folder])
23
+ @entity = params[:entity].presence
24
+ @status = Studio::KnowledgeDoc::STATUSES.include?(params[:status]) ? params[:status] : nil
25
+
26
+ scope = Studio::KnowledgeDoc.order(created_at: :desc)
27
+ scope = scope.for_entity(@entity) if @entity
28
+ scope = scope.where(status: @status) if @status
29
+
30
+ @entities = Studio::KnowledgeDoc.distinct.pluck(:entity).sort
31
+ @inbox_size = scope.inbox.count
32
+
33
+ if @view == "folders"
34
+ @folders = scope.folders_under(@folder)
35
+ @docs = scope.in_folder(@folder)
36
+ else
37
+ @folders = []
38
+ @docs = scope
39
+ end
40
+ end
41
+
42
+ def show
43
+ end
44
+
45
+ def create
46
+ file = params.dig(:knowledge_doc, :file)
47
+ doc = Studio::KnowledgeDoc.intake!(
48
+ doc_params.merge(uploaded_by: current_user&.email, status: "inbox"),
49
+ file: file
50
+ )
51
+ redirect_to admin_knowledge_doc_path(doc), notice: "#{doc.title} landed in the inbox."
52
+ rescue Studio::S3::NotConfigured, Studio::KnowledgeDoc::MissingTable => e
53
+ redirect_to admin_knowledge_path, alert: e.message
54
+ rescue ActiveRecord::RecordInvalid => e
55
+ redirect_to admin_knowledge_path, alert: e.message
56
+ end
57
+
58
+ def update
59
+ @doc.update!(doc_params)
60
+ redirect_to admin_knowledge_doc_path(@doc), notice: "#{@doc.title} updated."
61
+ rescue ActiveRecord::RecordInvalid => e
62
+ redirect_to admin_knowledge_doc_path(@doc), alert: e.message
63
+ end
64
+
65
+ def download
66
+ return redirect_to admin_knowledge_doc_path(@doc), alert: "No file attached." unless @doc.file?
67
+
68
+ redirect_to @doc.signed_url, allow_other_host: true
69
+ end
70
+
71
+ private
72
+
73
+ def set_doc
74
+ @doc = Studio::KnowledgeDoc.find(params[:id])
75
+ end
76
+
77
+ def doc_params
78
+ permitted = params.require(:knowledge_doc)
79
+ .permit(:title, :entity, :path, :category, :summary,
80
+ :document_date, :source_note, :status, access: {})
81
+ # The access map arrives as {"samson" => "full", ...}; drop blanks so an
82
+ # untouched select doesn't write a "none" the map means by absence anyway.
83
+ if permitted[:access]
84
+ permitted[:access] = permitted[:access].to_h.reject { |_agent, level| level.blank? }
85
+ end
86
+ permitted
87
+ end
88
+ end
89
+ end
@@ -0,0 +1,207 @@
1
+ module Studio
2
+ # One row per document in an entity's knowledge layer: the S3 object pointer,
3
+ # the filing metadata (entity, folder path, category, as-of date), and the
4
+ # per-agent access map that says who may read it and at what depth.
5
+ #
6
+ # The access model has three levels, because "not allowed to read it" and
7
+ # "does not know it exists" are different states:
8
+ #
9
+ # full — the agent reads the document and its facts.
10
+ # aware — the agent knows the document exists and gets `summary` (the safe
11
+ # summary + boundary line), not the contents. An agent with a HOLE
12
+ # in its context confabulates or stonewalls; one with an awareness
13
+ # entry has something true to say and a boundary to hold.
14
+ # none — the agent does not see the row at all (the default).
15
+ #
16
+ # Folders are implicit: a document claims a `path` and the folder exists.
17
+ # The S3 key mirrors entity + path, so the bucket stays human-browsable.
18
+ # Uploads land as status "inbox" for agent triage (the knowledge-intake SOP);
19
+ # filing flips them "filed"; a replacement marks the old row "superseded".
20
+ #
21
+ # Storage goes through Studio::S3 and FAILS LOUDLY on an unconfigured app
22
+ # (Studio::S3::NotConfigured) — never wrap intake in a rescue that returns
23
+ # success; a QA lane once lost weeks of writes to exactly that.
24
+ #
25
+ # Like Studio::Link, the table is installed per consumer app by
26
+ # `bin/rails studio_engine:install:migrations && bin/rails db:migrate`.
27
+ class KnowledgeDoc < ApplicationRecord
28
+ self.table_name = "studio_knowledge_docs"
29
+
30
+ # The app drew knowledge routes but never installed the table. Raised in
31
+ # place of a bare PG::UndefinedTable so the first person to hit it reads
32
+ # the fix instead of an adapter error — see .intake!.
33
+ class MissingTable < StandardError; end
34
+
35
+ ACCESS_LEVELS = %w[full aware none].freeze
36
+ STATUSES = %w[inbox filed superseded].freeze
37
+
38
+ validates :title, presence: true
39
+ validates :entity, presence: true
40
+ validates :status, inclusion: { in: STATUSES }
41
+ validate :access_levels_are_known
42
+
43
+ before_validation :normalize_fields
44
+
45
+ scope :for_entity, ->(entity) { where(entity: entity) }
46
+ scope :inbox, -> { where(status: "inbox") }
47
+ scope :filed, -> { where(status: "filed") }
48
+ scope :active, -> { where.not(status: "superseded") }
49
+ scope :in_folder, ->(path) { where(path: normalize_path(path)) }
50
+ scope :under, lambda { |base|
51
+ base = normalize_path(base)
52
+ base.empty? ? all : where("path = ? OR path LIKE ?", base, "#{base}/%")
53
+ }
54
+
55
+ class << self
56
+ # Create + upload in one call — the write path the intake UI and the
57
+ # knowledge-intake SOP use. `file` responds to #read (an uploaded file);
58
+ # metadata-only records (file: nil) are legal.
59
+ #
60
+ # Table-missing is checked up front so the failure names its fix before
61
+ # any bytes reach S3.
62
+ def intake!(attrs, file: nil)
63
+ unless table_exists?
64
+ raise MissingTable,
65
+ "The knowledge layer needs the studio_knowledge_docs table, and #{Studio.app_name} has " \
66
+ "no such table. Run `bin/rails studio_engine:install:migrations && bin/rails db:migrate` " \
67
+ "(install ALL of them). Do not hand-copy the migration — it collides with the installed " \
68
+ "copy on `class CreateStudioKnowledgeDocs`."
69
+ end
70
+
71
+ doc = new(attrs)
72
+ doc.title = default_title(file) if doc.title.blank? && file
73
+ doc.validate!
74
+ doc.attach!(file) if file
75
+ doc.save!
76
+ doc
77
+ end
78
+
79
+ # Immediate child folder names under `base` for this scope, derived from
80
+ # the paths documents actually claim — there is no folder table.
81
+ #
82
+ # unscope(:order) is load-bearing: callers hand in display-ordered scopes,
83
+ # and Postgres refuses SELECT DISTINCT with an ORDER BY column outside the
84
+ # select list (SQLite tolerates it, so only a real consumer sees the 500).
85
+ def folders_under(base = "")
86
+ base = normalize_path(base)
87
+ prefix = base.empty? ? "" : "#{base}/"
88
+ unscope(:order).distinct.pluck(:path).filter_map { |path|
89
+ next if path == base || !path.start_with?(prefix)
90
+
91
+ path.delete_prefix(prefix).split("/").first
92
+ }.uniq.sort
93
+ end
94
+
95
+ # "/a//b/" -> "a/b". Nil-safe; the empty string is the root folder.
96
+ def normalize_path(value)
97
+ value.to_s.strip.squeeze("/").delete_prefix("/").delete_suffix("/")
98
+ end
99
+
100
+ def default_title(file)
101
+ name = file.respond_to?(:original_filename) ? file.original_filename : File.basename(file.to_s)
102
+ File.basename(name.to_s, ".*").tr("_-", " ").squeeze(" ").strip.presence
103
+ end
104
+ end
105
+
106
+ # --- access ---------------------------------------------------------------
107
+
108
+ def access_for(agent)
109
+ level = access.is_a?(Hash) ? access[agent.to_s] : nil
110
+ ACCESS_LEVELS.include?(level) ? level : "none"
111
+ end
112
+
113
+ # full or aware — the agent may know this document exists.
114
+ def visible_to?(agent)
115
+ access_for(agent) != "none"
116
+ end
117
+
118
+ def full_for?(agent)
119
+ access_for(agent) == "full"
120
+ end
121
+
122
+ # --- storage --------------------------------------------------------------
123
+
124
+ def file?
125
+ s3_key.present?
126
+ end
127
+
128
+ # Upload the file's bytes and point this row at them. The key mirrors
129
+ # entity + path so the bucket reads like the folder tree. Raises
130
+ # Studio::S3::NotConfigured on an app with no bucket — deliberately.
131
+ def attach!(file)
132
+ filename = file.respond_to?(:original_filename) ? file.original_filename : File.basename(file.to_s)
133
+ content_type = file.respond_to?(:content_type) ? file.content_type : nil
134
+ body = file.respond_to?(:read) ? file.read : file.to_s
135
+
136
+ key = [
137
+ "knowledge", entity, path.presence,
138
+ "#{Time.current.strftime('%Y%m%d%H%M%S')}-#{self.class.sanitize_filename(filename)}"
139
+ ].compact.join("/")
140
+
141
+ Studio::S3.upload(key: key, body: body, content_type: content_type)
142
+ self.s3_key = key
143
+ self.mime_type = content_type if content_type
144
+ self.byte_size = body.bytesize if body.respond_to?(:bytesize)
145
+ self
146
+ end
147
+
148
+ # 15-minute presigned GET — the only way a private object leaves the bucket.
149
+ def signed_url(expires_in: 900)
150
+ raise Studio::S3::Error, "no file attached to #{title.inspect}" unless file?
151
+
152
+ Studio::S3.signed_url(key: s3_key, expires_in: expires_in)
153
+ end
154
+
155
+ # --- lifecycle ------------------------------------------------------------
156
+
157
+ def supersede_with!(replacement)
158
+ update!(status: "superseded", superseded_by_id: replacement.id)
159
+ end
160
+
161
+ def superseded?
162
+ status == "superseded"
163
+ end
164
+
165
+ def folder_segments
166
+ path.blank? ? [] : path.split("/")
167
+ end
168
+
169
+ # The date the row sorts and displays by: the document's own as-of date,
170
+ # falling back to upload time for undated material.
171
+ def display_date
172
+ document_date || created_at&.to_date
173
+ end
174
+
175
+ def self.sanitize_filename(name)
176
+ base = name.to_s.strip
177
+ return "document" if base.empty?
178
+
179
+ base.gsub(/[^A-Za-z0-9._-]+/, "-").squeeze("-")
180
+ .gsub(/-(?=\.)|\A-|-\z/, "").downcase
181
+ .presence || "document"
182
+ end
183
+
184
+ private
185
+
186
+ def normalize_fields
187
+ self.path = self.class.normalize_path(path)
188
+ self.entity = entity.to_s.strip.downcase if entity
189
+ if access.is_a?(Hash)
190
+ self.access = access.each_with_object({}) do |(agent, level), map|
191
+ map[agent.to_s.strip.downcase] = level.to_s.strip.downcase if agent.present?
192
+ end
193
+ end
194
+ end
195
+
196
+ def access_levels_are_known
197
+ return if access.blank?
198
+ return errors.add(:access, "must be a map of agent => level") unless access.is_a?(Hash)
199
+
200
+ access.each do |agent, level|
201
+ unless ACCESS_LEVELS.include?(level)
202
+ errors.add(:access, "level for #{agent.inspect} must be one of #{ACCESS_LEVELS.join(', ')}")
203
+ end
204
+ end
205
+ end
206
+ end
207
+ end
@@ -0,0 +1,10 @@
1
+ <%# Per-agent access chips — who may know this document, at which depth.
2
+ Renders the map the document actually carries (any agent slug), so a chip
3
+ never lies about roster membership. Locals: doc:. %>
4
+ <% if doc.access.blank? %>
5
+ <span class="knowledge-chip knowledge-chip-none">no agents</span>
6
+ <% else %>
7
+ <% doc.access.sort.each do |agent, level| %>
8
+ <span class="knowledge-chip knowledge-chip-<%= level %>"><%= agent %> · <%= level %></span>
9
+ <% end %>
10
+ <% end %>
@@ -0,0 +1,115 @@
1
+ <%# The knowledge-layer browser. Locals:
2
+ view: "folders" | "flat"
3
+ folder: current folder path ("" = root; folder view only)
4
+ entity: entity filter or nil
5
+ status: status filter or nil
6
+ entities: all entity slugs (for the filter row)
7
+ folders: immediate child folder names (folder view)
8
+ docs: the documents to list
9
+ inbox_size: count of inbox docs in the current scope
10
+
11
+ Styling note: gem-specific looks live in the scoped <style> below because a
12
+ host's Tailwind build may not scan this gem's views (same reasoning as the
13
+ geo grid). Layout leans on utilities every host already ships. %>
14
+ <style>
15
+ .knowledge-chip { display: inline-block; padding: 0 .5rem; border-radius: 9999px;
16
+ font-size: .7rem; line-height: 1.4rem; border: 1px solid transparent; white-space: nowrap; }
17
+ .knowledge-chip-full { background: rgb(16 185 129 / .12); border-color: rgb(16 185 129 / .4); color: rgb(5 150 105); }
18
+ .knowledge-chip-aware { background: rgb(245 158 11 / .12); border-color: rgb(245 158 11 / .4); color: rgb(180 83 9); }
19
+ .knowledge-chip-none { background: rgb(148 163 184 / .12); border-color: rgb(148 163 184 / .4); color: rgb(100 116 139); }
20
+ .knowledge-status { font-size: .7rem; padding: 0 .45rem; border-radius: .25rem; border: 1px solid rgb(148 163 184 / .5); }
21
+ .knowledge-status-inbox { border-color: rgb(245 158 11 / .6); color: rgb(180 83 9); }
22
+ .knowledge-status-superseded { text-decoration: line-through; opacity: .6; }
23
+ .knowledge-folder { display: inline-flex; align-items: center; gap: .4rem; padding: .35rem .75rem;
24
+ border: 1px solid rgb(148 163 184 / .4); border-radius: .5rem; }
25
+ </style>
26
+
27
+ <% unless Studio::S3.configured? %>
28
+ <div id="knowledge-storage-warning" class="mb-4 p-3 border rounded" style="border-color: rgb(239 68 68 / .5); color: rgb(220 38 38);">
29
+ Object storage is not configured — set <code>Studio.s3_bucket_prefix</code> in
30
+ <code>config/initializers/studio.rb</code>. Uploads will refuse rather than
31
+ silently drop files.
32
+ </div>
33
+ <% end %>
34
+
35
+ <div class="flex flex-wrap items-center justify-between gap-3 mb-4">
36
+ <h1 class="text-xl font-semibold">
37
+ Knowledge
38
+ <% if inbox_size.to_i.positive? %>
39
+ <%= link_to "#{inbox_size} in inbox",
40
+ admin_knowledge_path(view: "flat", entity: entity, status: "inbox"),
41
+ id: "knowledge-inbox-badge", class: "knowledge-status knowledge-status-inbox ml-2 align-middle" %>
42
+ <% end %>
43
+ </h1>
44
+ <div id="knowledge-view-toggle" class="flex items-center gap-2 text-sm">
45
+ <%= link_to "Folders", admin_knowledge_path(view: "folders", entity: entity, status: status),
46
+ class: view == "folders" ? "font-semibold underline" : "" %>
47
+ <span aria-hidden="true">·</span>
48
+ <%= link_to "Show all", admin_knowledge_path(view: "flat", entity: entity, status: status),
49
+ class: view == "flat" ? "font-semibold underline" : "" %>
50
+ </div>
51
+ </div>
52
+
53
+ <% if entities.length > 1 || entity %>
54
+ <div id="knowledge-entity-filter" class="flex flex-wrap items-center gap-2 mb-3 text-sm">
55
+ <%= link_to "All entities", admin_knowledge_path(view: view),
56
+ class: entity ? "" : "font-semibold underline" %>
57
+ <% entities.each do |candidate| %>
58
+ <%= link_to candidate, admin_knowledge_path(view: view, entity: candidate),
59
+ class: candidate == entity ? "font-semibold underline" : "" %>
60
+ <% end %>
61
+ </div>
62
+ <% end %>
63
+
64
+ <% if view == "folders" %>
65
+ <nav id="knowledge-breadcrumbs" class="text-sm mb-3">
66
+ <%= link_to "root", admin_knowledge_path(view: "folders", entity: entity, status: status) %>
67
+ <% segments = folder.blank? ? [] : folder.split("/") %>
68
+ <% segments.each_with_index do |segment, index| %>
69
+ <span aria-hidden="true">/</span>
70
+ <%= link_to segment,
71
+ admin_knowledge_path(view: "folders", entity: entity, status: status,
72
+ folder: segments[0..index].join("/")) %>
73
+ <% end %>
74
+ </nav>
75
+
76
+ <% if folders.any? %>
77
+ <div class="flex flex-wrap gap-2 mb-4">
78
+ <% folders.each do |name| %>
79
+ <%= link_to admin_knowledge_path(view: "folders", entity: entity, status: status,
80
+ folder: [folder.presence, name].compact.join("/")),
81
+ id: "knowledge-folder-#{name.parameterize}", class: "knowledge-folder" do %>
82
+ <span aria-hidden="true">📁</span> <%= name %>
83
+ <% end %>
84
+ <% end %>
85
+ </div>
86
+ <% end %>
87
+ <% end %>
88
+
89
+ <% if docs.any? %>
90
+ <div class="overflow-x-auto">
91
+ <table id="knowledge-docs" class="w-full text-sm">
92
+ <thead>
93
+ <tr class="text-left border-b">
94
+ <th class="py-2 pr-3">Document</th>
95
+ <% if view == "flat" %><th class="py-2 pr-3">Folder</th><% end %>
96
+ <th class="py-2 pr-3">Category</th>
97
+ <th class="py-2 pr-3">As of</th>
98
+ <th class="py-2 pr-3">Status</th>
99
+ <th class="py-2 pr-3">Access</th>
100
+ </tr>
101
+ </thead>
102
+ <tbody>
103
+ <% docs.each do |doc| %>
104
+ <%= render "studio/knowledge_docs/doc_row", doc: doc, view: view %>
105
+ <% end %>
106
+ </tbody>
107
+ </table>
108
+ </div>
109
+ <% else %>
110
+ <p id="knowledge-empty" class="text-sm opacity-70 mb-4">
111
+ <%= view == "folders" && folder.present? ? "This folder holds no documents." : "No documents yet — upload the first one below." %>
112
+ </p>
113
+ <% end %>
114
+
115
+ <%= render "studio/knowledge_docs/upload_form", entity: entity, folder: folder %>
@@ -0,0 +1,18 @@
1
+ <%# One document row. Locals: doc:, view: ("folders" | "flat"). %>
2
+ <tr id="knowledge-doc-<%= doc.id %>" class="border-b align-top <%= "opacity-60" if doc.superseded? %>">
3
+ <td class="py-2 pr-3">
4
+ <%= link_to doc.title, admin_knowledge_doc_path(doc), class: "font-medium" %>
5
+ <% if doc.summary.present? %>
6
+ <div class="text-xs opacity-70 max-w-md truncate"><%= doc.summary %></div>
7
+ <% end %>
8
+ </td>
9
+ <% if view == "flat" %>
10
+ <td class="py-2 pr-3 text-xs opacity-80"><%= doc.path.presence || "root" %></td>
11
+ <% end %>
12
+ <td class="py-2 pr-3"><%= doc.category %></td>
13
+ <td class="py-2 pr-3 whitespace-nowrap"><%= doc.display_date&.iso8601 %></td>
14
+ <td class="py-2 pr-3">
15
+ <span class="knowledge-status knowledge-status-<%= doc.status %>"><%= doc.status %></span>
16
+ </td>
17
+ <td class="py-2 pr-3"><%= render "studio/knowledge_docs/access_chips", doc: doc %></td>
18
+ </tr>
@@ -0,0 +1,48 @@
1
+ <%# Intake form — uploads land as status "inbox" for triage; classification is
2
+ the second step (the show page / the knowledge-intake SOP), so this form
3
+ asks only for what the uploader knows at drop time. Locals: entity:, folder:
4
+ (both may be blank; they pre-fill from where the browser stands). %>
5
+ <div id="knowledge-upload" class="mt-6 p-4 border rounded">
6
+ <h2 class="font-semibold mb-3">Add a document</h2>
7
+ <%= form_with url: admin_knowledge_path, method: :post, multipart: true, local: true do |form| %>
8
+ <div class="grid gap-3 md:grid-cols-2">
9
+ <label class="block text-sm">
10
+ <span class="block mb-1">File</span>
11
+ <%= form.file_field "knowledge_doc[file]", required: true, class: "block w-full text-sm" %>
12
+ </label>
13
+ <label class="block text-sm">
14
+ <span class="block mb-1">Title <span class="opacity-60">(blank = from filename)</span></span>
15
+ <%= form.text_field "knowledge_doc[title]", class: "block w-full border rounded px-2 py-1" %>
16
+ </label>
17
+ <label class="block text-sm">
18
+ <span class="block mb-1">Entity</span>
19
+ <%= form.text_field "knowledge_doc[entity]", value: entity, required: true,
20
+ placeholder: "commercial-welding-llc", class: "block w-full border rounded px-2 py-1" %>
21
+ </label>
22
+ <label class="block text-sm">
23
+ <span class="block mb-1">Folder</span>
24
+ <%= form.text_field "knowledge_doc[path]", value: folder,
25
+ placeholder: "financials/aging-inventory/2026-08", class: "block w-full border rounded px-2 py-1" %>
26
+ </label>
27
+ <label class="block text-sm">
28
+ <span class="block mb-1">Document date <span class="opacity-60">(the as-of date, not today)</span></span>
29
+ <%= form.date_field "knowledge_doc[document_date]", class: "block w-full border rounded px-2 py-1" %>
30
+ </label>
31
+ <label class="block text-sm">
32
+ <span class="block mb-1">Source <span class="opacity-60">(who/where it came from)</span></span>
33
+ <%= form.text_field "knowledge_doc[source_note]", class: "block w-full border rounded px-2 py-1" %>
34
+ </label>
35
+ <% Array(Studio.knowledge_agents).each do |agent| %>
36
+ <label class="block text-sm" id="knowledge-access-<%= agent %>">
37
+ <span class="block mb-1">Access — <%= agent %></span>
38
+ <%= form.select "knowledge_doc[access][#{agent}]",
39
+ [["none (default)", ""], ["aware — knows it exists", "aware"], ["full — reads the facts", "full"]],
40
+ {}, class: "block w-full border rounded px-2 py-1" %>
41
+ </label>
42
+ <% end %>
43
+ </div>
44
+ <div class="mt-3">
45
+ <%= form.submit "Upload to inbox", class: "px-3 py-1.5 border rounded font-medium" %>
46
+ </div>
47
+ <% end %>
48
+ </div>
@@ -0,0 +1,6 @@
1
+ <%# /admin/knowledge — bare content wrapper; the browser partial is the
2
+ primitive (renderable + testable on its own, like studio/board/_board). %>
3
+ <%= render "studio/knowledge_docs/browser",
4
+ view: @view, folder: @folder, entity: @entity, status: @status,
5
+ entities: @entities, folders: @folders, docs: @docs,
6
+ inbox_size: @inbox_size %>
@@ -0,0 +1,87 @@
1
+ <%# /admin/knowledge/:id — one document: metadata, access, download, and the
2
+ triage form that files it out of the inbox. %>
3
+ <style>
4
+ .knowledge-chip { display: inline-block; padding: 0 .5rem; border-radius: 9999px;
5
+ font-size: .7rem; line-height: 1.4rem; border: 1px solid transparent; white-space: nowrap; }
6
+ .knowledge-chip-full { background: rgb(16 185 129 / .12); border-color: rgb(16 185 129 / .4); color: rgb(5 150 105); }
7
+ .knowledge-chip-aware { background: rgb(245 158 11 / .12); border-color: rgb(245 158 11 / .4); color: rgb(180 83 9); }
8
+ .knowledge-chip-none { background: rgb(148 163 184 / .12); border-color: rgb(148 163 184 / .4); color: rgb(100 116 139); }
9
+ </style>
10
+
11
+ <div class="mb-4 text-sm">
12
+ <%= link_to "← Knowledge", admin_knowledge_path(folder: @doc.path.presence) %>
13
+ </div>
14
+
15
+ <div class="flex flex-wrap items-start justify-between gap-3 mb-4">
16
+ <div>
17
+ <h1 class="text-xl font-semibold"><%= @doc.title %></h1>
18
+ <div class="text-sm opacity-70"><%= @doc.entity %> · <%= @doc.path.presence || "root" %></div>
19
+ </div>
20
+ <% if @doc.file? %>
21
+ <%= link_to "Download (15-min link)", admin_knowledge_doc_download_path(@doc),
22
+ id: "knowledge-download", class: "px-3 py-1.5 border rounded font-medium" %>
23
+ <% end %>
24
+ </div>
25
+
26
+ <dl id="knowledge-doc-meta" class="grid gap-x-8 gap-y-2 md:grid-cols-2 text-sm mb-6">
27
+ <div><dt class="opacity-60">Status</dt><dd><%= @doc.status %></dd></div>
28
+ <div><dt class="opacity-60">Category</dt><dd><%= @doc.category.presence || "—" %></dd></div>
29
+ <div><dt class="opacity-60">Document date</dt><dd><%= @doc.document_date&.iso8601 || "—" %></dd></div>
30
+ <div><dt class="opacity-60">Uploaded</dt><dd><%= @doc.created_at&.to_date&.iso8601 %> by <%= @doc.uploaded_by.presence || "—" %></dd></div>
31
+ <div><dt class="opacity-60">Source</dt><dd><%= @doc.source_note.presence || "—" %></dd></div>
32
+ <div><dt class="opacity-60">File</dt><dd><%= @doc.file? ? "#{@doc.mime_type.presence || 'file'} · #{number_to_human_size(@doc.byte_size) if @doc.byte_size}" : "metadata only" %></dd></div>
33
+ <div class="md:col-span-2"><dt class="opacity-60">Access</dt><dd><%= render "studio/knowledge_docs/access_chips", doc: @doc %></dd></div>
34
+ </dl>
35
+
36
+ <% if @doc.summary.present? %>
37
+ <div class="mb-6">
38
+ <h2 class="font-semibold mb-1">Summary <span class="text-xs opacity-60">(what an "aware" agent may read)</span></h2>
39
+ <p class="text-sm whitespace-pre-line"><%= @doc.summary %></p>
40
+ </div>
41
+ <% end %>
42
+
43
+ <div id="knowledge-triage" class="p-4 border rounded">
44
+ <h2 class="font-semibold mb-3">File it</h2>
45
+ <%= form_with url: admin_knowledge_doc_path(@doc), method: :patch, local: true do |form| %>
46
+ <div class="grid gap-3 md:grid-cols-2">
47
+ <label class="block text-sm">
48
+ <span class="block mb-1">Title</span>
49
+ <%= form.text_field "knowledge_doc[title]", value: @doc.title, class: "block w-full border rounded px-2 py-1" %>
50
+ </label>
51
+ <label class="block text-sm">
52
+ <span class="block mb-1">Folder</span>
53
+ <%= form.text_field "knowledge_doc[path]", value: @doc.path, class: "block w-full border rounded px-2 py-1" %>
54
+ </label>
55
+ <label class="block text-sm">
56
+ <span class="block mb-1">Category</span>
57
+ <%= form.text_field "knowledge_doc[category]", value: @doc.category, class: "block w-full border rounded px-2 py-1" %>
58
+ </label>
59
+ <label class="block text-sm">
60
+ <span class="block mb-1">Document date</span>
61
+ <%= form.date_field "knowledge_doc[document_date]", value: @doc.document_date, class: "block w-full border rounded px-2 py-1" %>
62
+ </label>
63
+ <label class="block text-sm">
64
+ <span class="block mb-1">Status</span>
65
+ <%= form.select "knowledge_doc[status]",
66
+ Studio::KnowledgeDoc::STATUSES.map { |s| [s, s] },
67
+ { selected: @doc.status }, class: "block w-full border rounded px-2 py-1" %>
68
+ </label>
69
+ <% (Array(Studio.knowledge_agents) | @doc.access.keys).each do |agent| %>
70
+ <label class="block text-sm">
71
+ <span class="block mb-1">Access — <%= agent %></span>
72
+ <%= form.select "knowledge_doc[access][#{agent}]",
73
+ [["none", ""], ["aware", "aware"], ["full", "full"]],
74
+ { selected: @doc.access_for(agent) == "none" ? "" : @doc.access_for(agent) },
75
+ class: "block w-full border rounded px-2 py-1" %>
76
+ </label>
77
+ <% end %>
78
+ <label class="block text-sm md:col-span-2">
79
+ <span class="block mb-1">Summary (safe for "aware" agents)</span>
80
+ <%= form.text_area "knowledge_doc[summary]", value: @doc.summary, rows: 3, class: "block w-full border rounded px-2 py-1" %>
81
+ </label>
82
+ </div>
83
+ <div class="mt-3">
84
+ <%= form.submit "Save", class: "px-3 py-1.5 border rounded font-medium" %>
85
+ </div>
86
+ <% end %>
87
+ </div>
@@ -21,7 +21,8 @@
21
21
  through studio/cropper_assets), Web3 (wallet-connect and web3-step-up now
22
22
  rendered from the solana-studio gem / on-chain-tx / deposit / the generic
23
23
  entry-confirmed celebration, gated by Studio.feature?(:web3) —
24
- disabled-but-present-yet-openable when off),
24
+ disabled-but-present-yet-openable when off, except the two gem-backed ids,
25
+ which an app without solana-studio lists but cannot open),
25
26
  System and status (the reusable card blocks), Templates (the copy-from
26
27
  archetypes), and Rewards (level-up + the entry-confirmed seeds bar + the
27
28
  Free Entry Earned reward, gated by :leveling).
@@ -53,12 +54,57 @@
53
54
  # (studio/modals/_host.html.erb): name, prefixes, partial.
54
55
  web3_gem = lookup_context.exists?("wallet_connect", ["solana_studio/modals"], true)
55
56
 
57
+ # web3_gem gates the REGISTRATION below. It must also gate the TRIGGER on the
58
+ # three gem-backed cards (Connect wallet + the two Sign Wallet cards), or they
59
+ # stay role=button over a modal id nothing registered: a card that opens an
60
+ # EMPTY panel. The registration gate cannot prevent that on its own — it is
61
+ # what stops the missing-template 500, one layer down.
62
+ #
63
+ # Both terms are load-bearing, because the two gates answer different
64
+ # questions and a BASE app can fail either:
65
+ # openable: web3_gem -> with the capability OFF, the section's
66
+ # disabled-but-openable preview contract would otherwise keep the card
67
+ # clickable (clickable is !(disabled AND NOT openable)).
68
+ # disabled: web3_card_off -> with the capability ON but no gem, `disabled`
69
+ # alone is false, so the card renders as a NORMAL live card: no badge, no
70
+ # aria-disabled, and clickable whatever `openable` says. That is the worse
71
+ # of the two, and it is the exact scenario the BASE-app test runs in.
72
+ # The other six web3 specimens are engine-owned and registered
73
+ # unconditionally, so they keep the plain capability gate.
74
+ web3_card_off = !web3_on || !web3_gem
75
+
56
76
  # Auth method-toggle defaults — mirror the app's Studio.auth_method? config;
57
77
  # Solana Wallet also needs the web3 capability, so it defaults OFF on an app
58
78
  # (like McRitchie Studio) that ships web3 off.
79
+ #
80
+ # web3_gem is the THIRD term, and it answers a question neither of the other
81
+ # two asks. auth_methods declares which CREDENTIALS an app accepts; features
82
+ # gates PRODUCT SURFACES — lib/studio.rb spells that split out where it
83
+ # deliberately does NOT gate the /auth/solana routes on :web3. Both are
84
+ # POLICY. Whether the wallet picker can RENDER AT ALL is template resolution,
85
+ # and the Solana button does nothing except swap to that picker. A trigger
86
+ # must carry at least the gate its target's REGISTRATION carries (see
87
+ # web3_gem above, which wraps the wallet-connect registration) or it opens an
88
+ # empty panel — the same failure the three specimen cards had, through a
89
+ # different door.
59
90
  ml_default = Studio.auth_method?(:magic_link)
60
91
  g_default = Studio.auth_method?(:google)
61
- w_default = Studio.auth_method?(:wallet) && web3_on
92
+ w_default = Studio.auth_method?(:wallet) && web3_on && web3_gem
93
+
94
+ # The Sign in card's toggles. Solana Wallet is DROPPED where the gem is
95
+ # absent, because that checkbox is the door this page actually opens:
96
+ # methodOn('wallet') reads props.methods.wallet — the toggle's own boolean —
97
+ # BEFORE it ever falls back to the defaults above, so a ticked box overrides
98
+ # w_default outright. Leaving it offered would keep the empty panel one click
99
+ # away in EVERY base-app configuration, including the stock auth_methods
100
+ # default that declares no :wallet at all. A control that cannot do its one
101
+ # job is worse than an absent one; the section prose says why it is gone.
102
+ auth_toggles = [
103
+ { model: "opts.magicLink", label: "Magic Link" },
104
+ { model: "opts.google", label: "Google" }
105
+ ]
106
+ auth_toggles << { model: "opts.wallet", label: "Solana Wallet" } if web3_gem
107
+ auth_toggles << { model: "opts.terms", label: "Terms" }
62
108
 
63
109
  # Active-card glow-match expression builder: yields an Alpine boolean that is
64
110
  # true when $store.dsModals.current() is THIS specimen's modal. It discriminates
@@ -345,8 +391,12 @@
345
391
  :class="$store.dsModals.cardClasses()">
346
392
 
347
393
  <%# --- Auth suite --- %>
394
+ <%# web3_gem is passed, not recomputed: this file owns the lookup (see
395
+ above) and the auth modal's Solana button needs the SAME answer,
396
+ because that button's only job is to swap to the wallet-connect
397
+ registration gated on it a few lines down. One lookup, two readers. %>
348
398
  <template x-if="$store.dsModals.current().id === 'auth'">
349
- <div><%= render "style/modals/auth" %></div>
399
+ <div><%= render "style/modals/auth", web3_gem: web3_gem %></div>
350
400
  </template>
351
401
 
352
402
  <%# --- Onboarding step (its card now lives in Profile) --- %>
@@ -609,6 +659,11 @@
609
659
  magic-link-sent &rarr; magic-link-resent</code>. Toggle the methods below a
610
660
  card, then open it to watch them gate the modal. The live card
611
661
  <strong>glows</strong>, and the glow follows the step machine as you advance.
662
+ <% unless web3_gem %>
663
+ <strong>Solana sign-in needs solana-studio, which this app does not
664
+ bundle</strong>, so that button and its toggle are absent here &mdash;
665
+ nothing registers the wallet picker the button opens.
666
+ <% end %>
612
667
  </p>
613
668
  </div>
614
669
  <div class="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
@@ -619,12 +674,7 @@
619
674
  card_data: "opts: { magicLink: #{ml_default}, google: #{g_default}, wallet: #{w_default}, terms: true }",
620
675
  open_expr: "$store.dsModals.open('auth', { step: 'credentials', picksRequired: 6, methods: { magicLink: opts.magicLink, google: opts.google, wallet: opts.wallet }, terms: opts.terms })",
621
676
  glow_when: ds_glow.call("auth", step: "credentials"),
622
- toggles: [
623
- { model: "opts.magicLink", label: "Magic Link" },
624
- { model: "opts.google", label: "Google" },
625
- { model: "opts.wallet", label: "Solana Wallet" },
626
- { model: "opts.terms", label: "Terms" }
627
- ] } do %>
677
+ toggles: auth_toggles } do %>
628
678
  <div class="pointer-events-none w-40 rounded-lg bg-surface border border-subtle shadow p-4 space-y-2 text-center">
629
679
  <span class="block h-2 w-16 mx-auto rounded" style="background: var(--color-text); opacity: .18"></span>
630
680
  <span class="block h-6 w-full rounded border" style="border-color: var(--color-border-strong)"></span>
@@ -1077,7 +1127,9 @@
1077
1127
 
1078
1128
  <%# ===================================================================== %>
1079
1129
  <%# 2. WEB3 — the wallet + on-chain modals, in the order a player meets them. %>
1080
- <%# Gated by :web3; off = disabled-but-present-yet-openable. %>
1130
+ <%# Gated by :web3; off = disabled-but-present-yet-openable. The three
1131
+ gem-backed cards carry a second gate, web3_card_off, because a preview
1132
+ you can open needs something registered to open. %>
1081
1133
  <%# ===================================================================== %>
1082
1134
  <section id="modals-web3" class="space-y-5">
1083
1135
  <div class="space-y-1">
@@ -1094,6 +1146,12 @@
1094
1146
  <code class="font-mono text-2xs">Studio.feature?(:web3)</code>. Web3 is
1095
1147
  <strong><%= web3_on ? "on" : "off" %></strong> here, so these render
1096
1148
  <%= web3_on ? "live." : "greyed and badged — but STILL openable as a preview." %>
1149
+ <% unless web3_gem %>
1150
+ <strong>This app bundles no solana-studio</strong>, so the three cards it
1151
+ backs &mdash; Connect wallet and the two Sign Wallet states &mdash; are
1152
+ listed for reference but <strong>cannot be opened here</strong>. Nothing
1153
+ registers them, so a trigger would open an empty panel.
1154
+ <% end %>
1097
1155
  The cards run in the order a player meets them &mdash; <strong>get a wallet,
1098
1156
  prove it, spend from it</strong>: Connect wallet &rarr; Setup Wallet &rarr;
1099
1157
  the two Sign Wallet states &rarr; Processing &rarr; success or error &rarr;
@@ -1143,7 +1201,7 @@
1143
1201
  reference: %(the Web3 "Connect wallet" picker (solana-studio solana_studio/modals/_wallet_connect, configured by style/modals/_wallet_connect) — the walk's entry: picking a wallet runs a brief connecting state then swaps to the on-chain Processing modal. Open with $store.dsModals.open('wallet-connect')),
1144
1202
  open_expr: "$store.dsModals.open('wallet-connect')",
1145
1203
  glow_when: ds_glow.call("wallet-connect"),
1146
- disabled: !web3_on, openable: true } do %>
1204
+ disabled: web3_card_off, openable: web3_gem } do %>
1147
1205
  <%# A PICKER: brand tiles with names beside them, which is what this modal
1148
1206
  actually is. Two empty outlines said "form with two inputs" and were
1149
1207
  the least informative sketch in the section — the one card whose
@@ -1209,7 +1267,7 @@
1209
1267
  reference: %(the Web3 "Sign Wallet" card (solana-studio solana_studio/modals/_web3_step_up — the REAL shared partial, not a specimen copy) — shown to an account that holds a self-custody wallet but authenticated this session with a web2 credential. One wallet row, glowing, because it is the only thing to press. Open with $store.dsModals.open('web3-step-up', { provider: 'phantom', providerLabel: 'Phantom', walletHint: '7xKp…JZ2Q' })),
1210
1268
  open_expr: "$store.dsModals.open('web3-step-up', { provider: 'phantom', providerLabel: 'Phantom', walletHint: '7xKp…JZ2Q' })",
1211
1269
  glow_when: ds_glow.call("web3-step-up", provider: true),
1212
- disabled: !web3_on, openable: true } do %>
1270
+ disabled: web3_card_off, openable: web3_gem } do %>
1213
1271
  <%# A FILLED row: brand tile, the wallet's name, an Installed badge. The
1214
1272
  two step-up thumbnails used to differ by a single border-dashed class
1215
1273
  on a 20px row, which is not a difference anyone can see at thumbnail
@@ -1232,7 +1290,7 @@
1232
1290
  reference: %(the Web3 "Sign Wallet" card with NO remembered brand (solana-studio solana_studio/modals/_web3_step_up) — every wallet linked before a host recorded brands lands here, so it is a live population and not a defensive branch. The row falls back to the picker rather than dead-ending. Open with $store.dsModals.open('web3-step-up', {})),
1233
1291
  open_expr: "$store.dsModals.open('web3-step-up', {})",
1234
1292
  glow_when: ds_glow.call("web3-step-up", provider: false),
1235
- disabled: !web3_on, openable: true } do %>
1293
+ disabled: web3_card_off, openable: web3_gem } do %>
1236
1294
  <%# An EMPTY slot: dashed outline, no brand tile, a "?" where the wallet's
1237
1295
  name would be. Reads at a glance as "we do not know which wallet",
1238
1296
  which is the whole difference between this card and the one before it. %>
@@ -17,10 +17,27 @@
17
17
  resets; Solana swaps to the wallet-connect picker (a real ported specimen).
18
18
  No real auth happens — the demo shows the real UI.
19
19
 
20
+ LOCALS:
21
+ web3_gem (Boolean, required) — does solana-studio resolve. style/_modals
22
+ owns the lookup and registers wallet-connect behind the same flag; the
23
+ Solana button below is rendered only when it is true, because a button that
24
+ swaps to an unregistered id opens an empty panel. Fetched without a default
25
+ on purpose: a caller that forgets it should fail loudly here rather than
26
+ silently drop Solana sign-in from an app that has the gem.
27
+
28
+ NOT gated on web3_gem, deliberately: _methodDefaults.wallet below. It is the
29
+ fallback methodOn uses when an opener passes no methods hash, and on a base
30
+ app nothing reaches it — the Sign in card always passes explicit booleans, and
31
+ the only opener that omits them is the wallet picker's back button, which
32
+ needs the gem to exist at all. Adding the term there would read like a third
33
+ gate while changing no rendered outcome and no behaviour, and a gate that
34
+ cannot be shown to bite is decoration. The trigger below is the real gate.
35
+
20
36
  CRITICAL: rendered inside <template x-if="id==='auth'"> — Alpine requires a
21
37
  SINGLE root, so everything lives inside the outer <div>. The x-data is a
22
38
  double-quoted attribute: keep it free of double-quotes and backticks.
23
39
  %>
40
+ <% web3_gem = local_assigns.fetch(:web3_gem) %>
24
41
  <div x-data="{
25
42
  email: '',
26
43
  ageAttested: false,
@@ -167,21 +184,36 @@
167
184
  <p role="alert" class="text-red-400 text-xs -mt-2 mb-3" x-text="props.googleError"></p>
168
185
  </template>
169
186
 
170
- <%# 2. Solana — progresses to the Connect Wallet picker modal. Gated on
171
- methodOn('wallet') (default OFF where web3 is off, e.g. McRitchie Studio). %>
172
- <button @click="openWalletHub()" x-show="methodOn('wallet')" :disabled="!!props.submitting"
173
- class="btn btn-neutral btn-lg w-full gap-3 mb-3 disabled:cursor-wait">
174
- <svg width="18" height="14" viewBox="0 0 397 311" fill="none" x-show="props.submitting !== 'wallet'" aria-hidden="true">
175
- <defs><linearGradient id="auth-solana-grad" x1="361" y1="-9" x2="153" y2="389" gradientUnits="userSpaceOnUse">
176
- <stop offset="0" stop-color="#00FFA3"/><stop offset="1" stop-color="#DC1FFF"/>
177
- </linearGradient></defs>
178
- <path d="M65 234c2-2 6-4 9-4h317c6 0 9 7 5 11l-63 63c-2 2-6 4-9 4H6c-6 0-9-7-5-11l64-63z" fill="url(#auth-solana-grad)"/>
179
- <path d="M65 4c2-2 6-4 9-4h317c6 0 9 7 5 11l-63 63c-2 2-6 4-9 4H6c-6 0-9-7-5-11L65 4z" fill="url(#auth-solana-grad)"/>
180
- <path d="M333 119c-2-2-6-4-9-4H7c-6 0-9 7-5 11l63 63c2 2 6 4 9 4h317c6 0 9-7 5-11l-63-63z" fill="url(#auth-solana-grad)"/>
181
- </svg>
182
- <span x-show="props.submitting === 'wallet'" class="spinner" aria-hidden="true"></span>
183
- <span x-text="props.submitting === 'wallet' ? 'Connecting…' : 'Solana'"></span>
184
- </button>
187
+ <%# 2. Solana — progresses to the Connect Wallet picker modal. TWO gates,
188
+ asking two different questions, and only one of them is Alpine's:
189
+
190
+ x-show methodOn('wallet') POLICY does this app offer wallet as a
191
+ credential, and has the specimen card's toggle turned it on. Reads
192
+ props.methods.wallet first, so the card's checkbox overrides the
193
+ server default outright.
194
+ Ruby web3_gem CAPABILITY — does solana-studio resolve, so
195
+ that something actually REGISTERED the wallet-connect id this button
196
+ swaps to. style/_modals wraps that registration in the same gate.
197
+
198
+ The policy gate cannot stand in for the capability one: a base app can
199
+ answer yes to the first and no to the second, and then the button opens
200
+ an empty panel. It has to be the RUBY gate because the toggle can flip
201
+ every Alpine term, and a control the operator can tick is not a gate. %>
202
+ <% if web3_gem %>
203
+ <button @click="openWalletHub()" x-show="methodOn('wallet')" :disabled="!!props.submitting"
204
+ class="btn btn-neutral btn-lg w-full gap-3 mb-3 disabled:cursor-wait">
205
+ <svg width="18" height="14" viewBox="0 0 397 311" fill="none" x-show="props.submitting !== 'wallet'" aria-hidden="true">
206
+ <defs><linearGradient id="auth-solana-grad" x1="361" y1="-9" x2="153" y2="389" gradientUnits="userSpaceOnUse">
207
+ <stop offset="0" stop-color="#00FFA3"/><stop offset="1" stop-color="#DC1FFF"/>
208
+ </linearGradient></defs>
209
+ <path d="M65 234c2-2 6-4 9-4h317c6 0 9 7 5 11l-63 63c-2 2-6 4-9 4H6c-6 0-9-7-5-11l64-63z" fill="url(#auth-solana-grad)"/>
210
+ <path d="M65 4c2-2 6-4 9-4h317c6 0 9 7 5 11l-63 63c-2 2-6 4-9 4H6c-6 0-9-7-5-11L65 4z" fill="url(#auth-solana-grad)"/>
211
+ <path d="M333 119c-2-2-6-4-9-4H7c-6 0-9 7-5 11l63 63c2 2 6 4 9 4h317c6 0 9-7 5-11l-63-63z" fill="url(#auth-solana-grad)"/>
212
+ </svg>
213
+ <span x-show="props.submitting === 'wallet'" class="spinner" aria-hidden="true"></span>
214
+ <span x-text="props.submitting === 'wallet' ? 'Connecting…' : 'Solana'"></span>
215
+ </button>
216
+ <% end %>
185
217
 
186
218
  <%# "or" divider — only when magic-link AND a social method are both on. %>
187
219
  <div class="relative my-4" x-show="methodOn('magicLink') && (methodOn('google') || methodOn('wallet'))">
@@ -0,0 +1,49 @@
1
+ # Reference migration for the Studio::KnowledgeDoc model. Like studio_links,
2
+ # each consumer app installs its own copy into db/migrate
3
+ # (`bin/rails studio_engine:install:migrations && bin/rails db:migrate`) so the
4
+ # table is created in the app's database. Do not hand-copy — a hand copy
5
+ # collides with the installed copy on `class CreateStudioKnowledgeDocs`.
6
+ class CreateStudioKnowledgeDocs < ActiveRecord::Migration[7.2]
7
+ def change
8
+ create_table :studio_knowledge_docs do |t|
9
+ t.string :title, null: false
10
+ # Which business the document belongs to (e.g. "commercial-welding-llc").
11
+ # One app can host several entities' knowledge; every read is entity-scoped.
12
+ t.string :entity, null: false
13
+ # Folder path with no leading/trailing slash ("financials/aging-inventory/2026-08").
14
+ # Folders are implicit — they exist because a document claims the path —
15
+ # and the S3 key mirrors it, so the bucket stays human-browsable.
16
+ t.string :path, null: false, default: ""
17
+ t.string :category
18
+ t.string :mime_type
19
+ # The document's own as-of date (an August aging report dated August),
20
+ # distinct from created_at (when it was uploaded).
21
+ t.date :document_date
22
+ # inbox -> filed -> superseded. Uploads land as inbox for agent triage.
23
+ t.string :status, null: false, default: "inbox"
24
+ # Per-agent access map: {"samson" => "full", "dawn" => "aware"}.
25
+ # "aware" = the agent knows the document exists and gets the safe summary,
26
+ # not the contents; absent agents default to "none".
27
+ t.jsonb :access, null: false, default: {}
28
+ t.jsonb :tags, null: false, default: []
29
+ # The safe summary — what an "aware" agent may read and repeat.
30
+ t.text :summary
31
+ # Provenance: who/where the document came from ("broker email 2026-08-30").
32
+ t.string :source_note
33
+ t.string :uploaded_by
34
+ # Object storage pointer (Studio::S3 logical key); nil for a metadata-only
35
+ # record. byte_size is captured at attach time for the index view.
36
+ t.string :s3_key
37
+ t.bigint :byte_size
38
+ # Set on the OLD row when a newer document replaces it.
39
+ t.bigint :superseded_by_id
40
+
41
+ t.timestamps
42
+ end
43
+
44
+ add_index :studio_knowledge_docs, [:entity, :status]
45
+ add_index :studio_knowledge_docs, [:entity, :path]
46
+ add_index :studio_knowledge_docs, :s3_key, unique: true
47
+ add_index :studio_knowledge_docs, :superseded_by_id
48
+ end
49
+ end
@@ -1,3 +1,3 @@
1
1
  module Studio
2
- VERSION = "0.66.2"
2
+ VERSION = "0.67.1"
3
3
  end
data/lib/studio.rb CHANGED
@@ -554,6 +554,14 @@ module Studio
554
554
  # A trailing slash is added if you leave it off.
555
555
  mattr_accessor :s3_key_prefix, default: nil
556
556
 
557
+ # Knowledge layer — the S3-backed document store + /admin/knowledge browser
558
+ # (Studio::KnowledgeDoc). Routes are opt-in like every route surface.
559
+ # knowledge_agents is the roster of agent slugs the intake UI offers
560
+ # per-agent access selects for (e.g. %w[samson dawn] on Industries); the
561
+ # access map itself accepts any agent slug regardless.
562
+ mattr_accessor :draw_knowledge_routes, default: false
563
+ mattr_accessor :knowledge_agents, default: []
564
+
557
565
  class S3ConfigError < StandardError; end
558
566
 
559
567
  # Whether to validate the host app's User model at boot. See docs/USER_CONTRACT.md.
@@ -846,6 +854,19 @@ module Studio
846
854
  constraints: { token: %r{[^/]+} }
847
855
  end
848
856
 
857
+ # Knowledge layer — /admin/knowledge (Studio::KnowledgeDoc): folder/flat
858
+ # document browser, upload-to-inbox intake, per-agent access map, and
859
+ # 15-minute presigned downloads. Opt-in (default off) like every route
860
+ # surface, so no app grows an admin page it never asked for.
861
+ if Studio.draw_knowledge_routes
862
+ get "admin/knowledge", to: "studio/knowledge_docs#index", as: :admin_knowledge
863
+ post "admin/knowledge", to: "studio/knowledge_docs#create"
864
+ get "admin/knowledge/:id", to: "studio/knowledge_docs#show", as: :admin_knowledge_doc
865
+ patch "admin/knowledge/:id", to: "studio/knowledge_docs#update"
866
+ get "admin/knowledge/:id/download", to: "studio/knowledge_docs#download",
867
+ as: :admin_knowledge_doc_download
868
+ end
869
+
849
870
  # Solana / Phantom wallet sign-in (nonce challenge + signature verify),
850
871
  # plus the MOBILE deep-link callback Phantom redirects back to. The
851
872
  # callback used to be listed here as app-specific and is not any more —
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: studio-engine
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.66.2
4
+ version: 0.67.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Alex McRitchie
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2026-09-01 00:00:00.000000000 Z
11
+ date: 2026-09-02 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: rails
@@ -328,6 +328,7 @@ files:
328
328
  - app/controllers/studio/email_images_controller.rb
329
329
  - app/controllers/studio/emails_controller.rb
330
330
  - app/controllers/studio/geo_settings_controller.rb
331
+ - app/controllers/studio/knowledge_docs_controller.rb
331
332
  - app/controllers/studio/links_controller.rb
332
333
  - app/controllers/studio/local_emails_controller.rb
333
334
  - app/controllers/studio/local_reviews_controller.rb
@@ -361,6 +362,7 @@ files:
361
362
  - app/models/studio/email_setting.rb
362
363
  - app/models/studio/enumeral.rb
363
364
  - app/models/studio/geo_setting.rb
365
+ - app/models/studio/knowledge_doc.rb
364
366
  - app/models/studio/link.rb
365
367
  - app/models/studio/model_page.rb
366
368
  - app/models/theme_setting.rb
@@ -436,6 +438,12 @@ files:
436
438
  - app/views/studio/emails/show.html.erb
437
439
  - app/views/studio/fields/_date_of_birth.html.erb
438
440
  - app/views/studio/geo_settings/edit.html.erb
441
+ - app/views/studio/knowledge_docs/_access_chips.html.erb
442
+ - app/views/studio/knowledge_docs/_browser.html.erb
443
+ - app/views/studio/knowledge_docs/_doc_row.html.erb
444
+ - app/views/studio/knowledge_docs/_upload_form.html.erb
445
+ - app/views/studio/knowledge_docs/index.html.erb
446
+ - app/views/studio/knowledge_docs/show.html.erb
439
447
  - app/views/studio/links/confirm.html.erb
440
448
  - app/views/studio/local_emails/index.html.erb
441
449
  - app/views/studio/mailers/_layered_banner.html.erb
@@ -544,6 +552,7 @@ files:
544
552
  - db/migrate/20260813010000_add_body_cta_footer_to_studio_email_settings.rb
545
553
  - db/migrate/20260813220000_add_standard_user_profile_columns.rb
546
554
  - db/migrate/20260818120000_create_studio_geo_settings.rb
555
+ - db/migrate/20260901000001_create_studio_knowledge_docs.rb
547
556
  - lib/studio-engine.rb
548
557
  - lib/studio.rb
549
558
  - lib/studio/cable.rb