A
How to read this
This is a conformance check, not a code review. The question is whether a concept
the reference treats as load-bearing exists here, not whether the code implementing it is good.
9
Present
The concept exists and works the way the reference describes
5
Partial
Something is there, with a narrower scope or a known soft edge
4
Absent
No implementation. Not necessarily wrong — see §L
Absent is not a criticism on its own. This is one product
with one tenant, built by a small team under delivery pressure; several of the reference's
concepts exist to solve problems this deployment does not have yet. The useful distinction is
between what is absent because it is unnecessary and what is absent because nobody has needed it
yet — the second kind is what §L prices.
Checked against a single commit of that codebase. Line references are to that
state and will drift.
B
The scoreboard
| Concept | Ref | Status | Finding |
| Grant-composed registry | I, II §D | Present | Registry built from permissions before any model call. Textbook |
| Tool definitions | VI §C | Present | 25 JSON schemas, described and versioned with the gem |
| Server-derived scope | II §D | Present | One funnel, ContentScope, never read from model output |
| Opaque reference handles | III §H.2 | Present | Arrived at independently, for a different reason — §D |
| Prompt versioning and election | II, IV §G | Present | Versions with a current pointer; rollback is one write |
| Config-not-code vocabulary | — | Present | A pattern the reference does not name and should — §D |
| Ambient surfaces | II §F | Present | 17 named features, with an applied/viewed event split |
| Admin surfaces | II | Present | Models, assistant settings, configs, prompts, functions |
| Artifact generation | I | Present | PDF and PPTX, server-rendered, with provenance rules |
| Tenancy isolation | V §K | Partial | Brand filter with a default fallback — §E |
| Model routing | V §C | Partial | Per assistant, not per task class — §F |
| Cost attribution | V §G | Partial | Token capture exists; inline turns record nothing — §F |
| Provenance | III §H | Partial | Falls back to scraping links out of prose — §G |
| Machine interface | VI §H | Partial | One tool exposed of twenty-five — §K |
| Mutation confirmation | VI §K | Absent | No confirmation concept anywhere — §H |
| Evaluation and labelled sets | IV §C–F | Absent | The largest gap by some distance — §I |
| Run record | V §N.3 | Absent | No reconstruction path for a bad turn — §J |
| Extension stability contract | VI §B | Absent | Vendored gem, no declared surface. Correct for now |
C
Already there
The nine that hold up. Worth stating plainly, because the rest of this document is
about gaps and that gives a misleading impression of the whole.
C.1 The registry is composed from grants, before anything else happens
The reference calls this the defence that holds when the others fail, and it is implemented
here exactly as described. Fifteen tools are unconditional; usage analytics, business
metrics and report building are each added only when the corresponding permission answers true,
read from the user and not from anything in the conversation.
app/services/ai_chat.rb · tool_registry
750 def self.tool_registry(ctx)
770 usage_allowed = usage_user&.can_view_ai_chat_usage_tools? || false
776 reports_allowed = usage_user&.can_build_ai_reports? || false
780 metrics_allowed = usage_user&.can_view_business_metrics? || false
782 registry = { … 15 unconditional tools … }
806 if usage_allowed then registry["ai_usage_query"] = …
821 if metrics_allowed then registry["stat_query"] = …
836 if reports_allowed then registry["report_builder"] = …
The detail that shows this was understood rather than copied: the error-recovery path resolves
through the same registry rather than a separate default list, with a comment saying why — a
failing turn must not be able to reach a tool the principal lacks. That is the invariant in
Volume VI §K, enforced in the place it is easiest to forget.
C.2 Everything visible funnels through one definition
ContentScope is the single answer to "what may this caller see", used by result
lists, single fetches and search alike. Volume II §D asks for exactly this and warns against the
alternative — scope decided at each call site — which is how divergence starts.
C.3 Prompts are versioned with an elected current
ChatAssistantPromptVersion holds monotonic versions scoped per prompt, with the
parent naming a current_version_id. Election is a pointer move and rollback is the
same move backwards — the property Volume IV §G requires of the change gate and Volume V §J
requires of incident response. It is in place without the gate that would use it, which makes it
the cheapest of the remaining gaps to close.
C.4 Ambient assistance is real, and instrumented better than most
Seventeen named features, the majority of them passive suggestion surfaces — headlines, tags,
photos, subject lines, summaries, newsletter ordering, clarity, bias, ledes, pitches. Events are
bucketed into viewed, applied, submitted and interacted, and the registry file explains why the
fourth bucket matters: leaving out interacted undercounts features that are reloaded more
often than first opened. That is an adoption-measurement insight most teams reach after a
year of arguing about a dashboard, written down in a config file comment.
D
Convergent inventions
Two patterns the codebase arrived at independently, for reasons unrelated to the
ones the reference gives. Both are cases where the implementation is ahead of the abstraction.
D.1 Reference handles, invented for a rendering problem
Volume III derives opaque handles from a safety argument: a claim must resolve to a source, and
the resolution must be internal so injected content cannot address anything outside the run. This
codebase has the same structure — ConversationStore keeps the payload and hands the
model a short reference — but reached it from an entirely different direction:
Anything broadcast to the page is gone once the reader navigates away, and
anything a later tool needs — a report builder reading back an approved plan — has to
outlive the turn. Both wants are the same want: keep the payload, hand the model only a
short reference.
It bounds itself at forty entries per bucket, oldest evicted, so a long conversation cannot
grow the row without limit. The safety property Volume III argues for comes along free, which is
usually how the durable patterns arrive.
D.2 A vocabulary the model is never asked to transcribe
config/ai_feature_registry.yml maps what people say onto what is actually logged:
"Bias" resolves to neutral_voice_assessment_suggestion_submitted. Tools take
a feature key and resolve it server-side; the list of valid keys is injected into the system
prompt. The model never types an event name.
The reference does not name this pattern and should. It
generalises well beyond analytics: wherever a tool's parameter is drawn from a closed set
of internal identifiers, the right design is to accept the human word, resolve it server-side
against reviewable configuration, and tell the model only which words exist. It removes a whole
class of silent wrong-parameter failures that no amount of description tuning fixes — and the
configuration stays reviewable by people who cannot read the code.
E
Scope and tenancy
Right in structure, with one soft edge that is defensible today and would not
survive a second tenant.
…/tool/content_scope.rb
58 @brand ||= (Brand.find_by(id: @brand_id) if @brand_id.present?) || Brand.default_brand
Scope resolves from context rather than model output, which is the property that matters. But
an absent or unresolvable brand falls back to a default brand rather than to nothing. Volume V §K
asks for the tenant identifier to be part of the retrieval predicate and for failure to close
rather than open.
Today this is a sensible default in a product with one primary
brand. The moment brands stop being facets of one publication and start being
organisations that must not see each other, the same line is a cross-tenant disclosure with a
comment explaining that it is intentional. The fix is small and the window to make it cheaply is
now: fail closed, and let the caller supply the default explicitly.
F
Routing and cost
Both half-built, and in the same shape: the mechanism exists, the dimension the
reference cares about does not.
F.1 Model selection is per assistant, not per task class
AssistantSetting carries a chat_model and a vision_model,
selected from managed ChatModel records, with an admin screen that lists what the
provider currently offers. That is real routing infrastructure — better than a constant in a
config file, and it makes a provider change a data change.
What it does not do is distinguish task classes. Volume V §C.1 calls routing by task class the
highest-leverage cost decision available, because ambient volume against a conversational-grade
model is how the economics come apart. With seventeen features, most of them passive suggestion
surfaces firing on editing activity, this is the deployment that lever was described
for.
F.2 Token accounting exists but misses the turns that matter
Usage normalisation is careful — both provider response shapes handled, accumulation across
every call in a job, and a stated rule that accounting must never break a run. It accumulates onto
a thread-local armed for the duration of an async job.
app/models/async_queue.rb · record_usage
117 def self.record_usage(tokens)
118 meta = job_metadata
119 return unless meta.is_a?(Hash) ← no job armed: silent no-op
Chat turns that run inline rather than through the queue therefore record nothing. The
instrumentation is sound; the attribution dimension is the job, where Volume V §E needs it per
turn, per principal and per tenant. Cost per active user — the only unit that composes
with a commercial model — is not currently derivable, and reconstructing it after the
fact never comes out clean.
G
Provenance
Sources are carried through the pipeline properly. The failure path is where the
reference's objection lands.
The overview path asks for a structured source list and parses it. When that parse fails, it
falls back to scanning the model's prose for markdown links and treating whatever it finds as
citations:
app/services/ai_chat.rb · split_overview_sources
663 # Fallback: pull [label](url) markdown links out of the body as sources.
664 sources = text.scan(/\[([^\]]+)\]\((https?:\/\/[^\s)]+)\)/)…
A URL the model invented is indistinguishable from one it
retrieved. Volume III §H.2 closes this by issuing handles at retrieval and stripping any
the run did not issue — an unissued citation is a recorded event, not a rendered link. The
fallback here does the opposite: it promotes anything URL-shaped in generated prose to the status
of a source. It fires precisely when the structured path failed, which is when the output is least
trustworthy.
The fix does not require the full handle machinery: validating scraped URLs against what
retrieval actually returned this turn, and dropping the rest, would close most of it.
H
Mutation confirmation
Absent entirely. Low consequence today, and that is exactly why it is worth
fixing now.
Four tools write: conversation_store, define_theme,
feedback_capturer and feedback_updater. There is no confirmation concept
in the chat service, in the tools, or in the client — the word does not appear.
| Observation | Consequence |
| All four write conversation-local or feedback state | Nothing reaches an external system, so the current blast radius is genuinely small |
| No effect flag on tool definitions | Read and write are indistinguishable to the loop. Nothing can enforce a rule that has no field to read |
| Injected content reaching a write is a live path | Retrieved article text enters context, and a write tool sits in the same registry |
The cost of adding this is proportional to the number of write
tools, which is four. It will not stay four. The first tool that files
something, sends something, or publishes something arrives into an architecture with no
confirmation step to inherit — and at that point the change touches the loop, the client, every
definition, and the surfaces. Declaring an effect flag now, even with confirmation defaulted off
for the existing four, is close to free.
I
Evaluation
The largest gap, and the one that compounds. There is no way to know whether any
change to this assistant made it better.
| Volume IV asks for | Present |
| A labelled case set | None |
| Retrieval evaluation — the cheap one, no model call | None |
| Selection probes over tool descriptions | None |
| Answer evaluation | None |
| A change gate | None — though the prompt versioning it would act on exists |
| Human review sampling | None |
| Online quality signals | Partial: AiChatUsage logs event types per user and publisher, which supports adoption analysis but not quality |
There are 15 spec files under spec/, covering models including
content_scope_spec.rb and chat_configs_spec.rb. None of them can
run: rspec is not in the Gemfile. The intent was there; the harness never landed.
This is what makes every other gap harder to close. Adding
task-class routing, rewriting a tool description, changing chunking, switching models — each
is a quality decision, and none of them can currently be made on evidence. The compounding cost is
not the absence of a number; it is that improvements get argued rather than measured, so the
cautious option wins by default and the assistant stops changing.
The cheapest possible start is fifty real questions from existing conversations, and the
retrieval evaluation from Volume IV §D — which needs no model call, no judging, and no labelling
beyond marking which documents should have come back.
J
The run record
Conversation state is persisted. What happened during a turn is not.
Conversation.metadata holds tool payloads under the handle scheme from §D.1,
so the artifacts of a turn survive. What is missing is the turn itself: which instructions
resolved, which registry was composed, which tools were called with what arguments and what
outcome, what retrieval returned, and what was delivered.
Volume V §N.3 exists because every incident starts with "what
actually happened in that conversation". Without the record the answer is reconstructed
from the transcript and a guess about which permissions the user held at the time — and permissions
change. This is also the substrate the evaluation work in §I would otherwise have to build from
scratch, which makes the two gaps cheaper to close together than separately.
K
The machine interface
Built correctly, scoped narrowly. The narrowness looks deliberate.
The MCP server speaks Streamable HTTP with an SSE path for streamed calls and a JSON fallback,
reports itself at version 0.2.0, and exposes exactly one tool — search_overview,
brand-scoped, returning a synthesised overview. Volume VI §H's independence point is handled: the
tool description tells callers each call stands alone and that context must be handed back.
| Against VI §H | State |
| Calls are independent, and callers are told | Present — stated in the description, where callers actually read it |
| Mutations excluded by default | Present — by consequence: the one exposed tool reads |
| Structured results | Partial — a synthesised overview is prose; sources come alongside |
| Per-credential authentication | Partial — a shared token, previously flagged. Fine for a single trusted caller, not for a second one |
| Per-credential budgets | Absent |
Exposing one tool of twenty-five is a defensible choice, not an
oversight. The reference's warning applies at the moment that changes: descriptions
written for in-product use assume a caller who already knows the product, and external agents do not.
Each additional tool exposed needs its description re-read from a stranger's position.
L
What closing costs
Ordered by value per unit of effort, which is not the order of severity.
| Gap | Effort | Why here in the order |
| Effect flag on tool definitions | Hours | A field and a loop check. Buys the confirmation invariant before there is anything consequential to confirm — §H |
| Fail closed on brand resolution | Hours | One line, plus making callers pass the default explicitly. The window to do it cheaply closes when a second organisation appears — §E |
| Validate scraped citation URLs | Hours | Drop anything retrieval did not return this turn. Closes most of the fabrication path without the handle machinery — §G |
| rspec in the Gemfile | Hours | 15 spec files already written and unrunnable. Nothing else on this list is testable until this is done |
| Fifty-case retrieval set | Days | No model calls, no judging. Turns every later decision from an argument into a measurement — §I |
| Per-turn token attribution | Days | The capture already exists; it needs a turn-scoped context rather than a job-scoped one — §F.2 |
| Task-class model routing | Days | Largest cost lever available, and 17 ambient features make it worth more here than in most deployments — §F.1 |
| Run record | Weeks | High value, real work. Cheaper alongside the evaluation set than after it — §J |
| Selection probes | Weeks | Needs the case set first. With 25 tools and overlapping descriptions, this is where measured quality gain will come from — §I |
The first four are a single afternoon between them, and three
of the four are invariants that get structurally more expensive the longer they wait. If nothing
else on this page is acted on, those are the ones with a deadline attached.
M
What the spec got wrong
Running the comparison in the other direction is the part that actually tests the
reference, and it did not come out clean.
| Correction | Detail |
| The resolved-vocabulary pattern is missing | §D.2. A real, generalisable technique for closed-set parameters, present here and absent from six volumes. It belongs in Volume VI §C alongside description authoring |
| Ambient surfaces are under-described | The reference treats ambient assistance as one tool class. This implementation has seventeen distinct features with different trigger models, cache behaviours and adoption profiles. The interacted event bucket in particular has no counterpart in Volume IV §I |
| Handles were justified too narrowly | Volume III argues them from safety. The stronger argument is the one found here: state that must outlive a broadcast, and context that must not be re-sent to the model. Safety is a consequence, and consequences survive refactors better than requirements |
| Artifact generation was underweighted | Volume I treats artifacts as an output mode. Two of the largest files in this codebase exist to produce them, and the layout-feedback loop between the model and the renderer has no counterpart anywhere in the reference |
An abstraction that only ever flatters its source has not been
tested. Four corrections from one comparison suggests the reference is roughly right in
shape and thin in the places where this implementation went furthest — which is the expected
result, and the argument for running the same comparison against a second implementation before
treating any of it as settled.