Skip to content

Latest commit

 

History

History
637 lines (532 loc) · 41.5 KB

File metadata and controls

637 lines (532 loc) · 41.5 KB

Language — the architecture vocabulary

The canonical structural vocabulary for phoenix. Use these terms exactly when you reason or write about the shape of the code — don't substitute "component," "service," "API," or "boundary." Consistent language is the whole point: an audit, a review, an ADR, and a PR description all mean the same thing by "deep module" or "seam."

This file is two layers:

  1. The general architecture vocabulary — module / interface / implementation / depth / seam / adapter / leverage / locality, the deletion test, and the other principles. Evergreen and project-agnostic; it ports the vocabulary the architecture-audit work is grounded in.
  2. The phoenix structural terms — the project's own named structures (the test tiers, the fate loader/resolver split, the LiveDO roles), each anchored to the ADR that decided it. These are what the general vocabulary names when applied to this codebase.

1. The architecture vocabulary

Terms

Module Anything with an interface and an implementation. Deliberately scale-agnostic — it applies equally to a function, a class, a package, or a tier-spanning slice. Avoid: unit, component, service.

Interface Everything a caller must know to use the module correctly. Includes the type signature, but also invariants, ordering constraints, error modes, required configuration, and performance characteristics. Avoid: API, signature (too narrow — those refer only to the type-level surface).

Implementation What's inside a module — its body of code. Distinct from adapter: a thing can be a small adapter with a large implementation (a real D1-backed repository) or a large adapter with a small implementation (an in-memory fake). Reach for "adapter" when the seam is the topic; "implementation" otherwise.

Depth Leverage at the interface — the amount of behaviour a caller (or test) can exercise per unit of interface they have to learn. A module is deep when a large amount of behaviour sits behind a small interface. A module is shallow when the interface is nearly as complex as the implementation.

Seam (from Michael Feathers) A place where you can alter behaviour without editing in that place. The location at which a module's interface lives. Choosing where to put the seam is its own design decision, distinct from what goes behind it. Avoid: boundary (overloaded with DDD's bounded context).

Adapter A concrete thing that satisfies an interface at a seam. Describes role (what slot it fills), not substance (what's inside).

Leverage What callers get from depth: more capability per unit of interface they have to learn. One implementation pays back across N call sites and M tests.

Locality What maintainers get from depth: change, bugs, knowledge, and verification concentrate at one place rather than spreading across callers. Fix once, fixed everywhere.

Principles

  • Depth is a property of the interface, not the implementation. A deep module can be internally composed of small, mockable, swappable parts — they just aren't part of the interface. A module can have internal seams (private to its implementation, used by its own tests) as well as the external seam at its interface.
  • The deletion test. Imagine deleting the module. If complexity vanishes, the module wasn't hiding anything (it was a pass-through). If complexity reappears across N callers, the module was earning its keep.
  • The interface is the test surface. Callers and tests cross the same seam. If you want to test past the interface, the module is probably the wrong shape.
  • One adapter means a hypothetical seam. Two adapters means a real one. Don't introduce a seam unless something actually varies across it.

Relationships

  • A module has exactly one interface (the surface it presents to callers and tests).
  • Depth is a property of a module, measured against its interface.
  • A seam is where a module's interface lives.
  • An adapter sits at a seam and satisfies the interface.
  • Depth produces leverage for callers and locality for maintainers.

Rejected framings

  • Depth as ratio of implementation-lines to interface-lines (Ousterhout): rewards padding the implementation. We use depth-as-leverage instead.
  • "Interface" as the TypeScript interface keyword or a class's public methods: too narrow — interface here includes every fact a caller must know.
  • "Boundary": overloaded with DDD's bounded context. Say seam or interface.

2. Phoenix structural terms

The general vocabulary above, applied to this codebase, names a handful of recurring structures. Each is anchored to the ADR that decided it — read the ADR for the why; this section fixes the term so an audit, a review, and a test file all mean the same thing by it.

The two test tiers (unit / integration) and seam-graduation

phoenix runs exactly two test tiers, split by which fidelity a claim needs — not by folder. (ADR 0082Two test tiers: unit (no DB) and integration (real D1 via alchemy Test.make), the source of truth, extended by 0104. 0082 supersedes 0040, whose four-tier T0–T3 taxonomy rested on a faked in-memory node:sqlite D1 stand-in (makeSqliteTestDb) and the now-falsified premise that node:sqlite is the same engine as D1. There is no in-memory SQL tier; the helper is deleted.)

  • unit — pure logic + in-process service contracts, no database. Pure functions / Effect logic and feature-service contracts that are wrong-or-right even if the DB behaves perfectly (normalization, clamping, envelope shaping, pagination math, auth gates, empty/cursor-miss branches, topic-key routing). No deployed worker and no SQL engine — the Drizzle storage seam is substituted (a run/batch that throws or a stubbed return proves the decision never touched the DB). Runs offline in the default node pool; files carry the *.unit.test.ts infix (plus plain *.test.ts service-contract tests). Examples: a keyset codec, the pasaport me auth gate, Bookmark.unit.test.ts.
  • integration — real behavior over real remote Cloudflare D1. Black-box over the deployed phoenix stack via the alchemy Test.make idiom: a file runs against a real worker + D1 (+ DOs) and asserts over HTTP/SSE. This is the only tier that can prove a claim that could only be wrong if the real engine differed — D1's FTS5 build, tokenizer, and collation are not node:sqlite's, so search/ranking fidelity, ON CONFLICT/soft-delete round-trips against real rows, and the DO + SSE + D1 composite all land here. Per ADR 0104 the tier runs in two modes: a run-scoped shared stage for the pure-logic-dominant files and per-file dedicated stages for the few that need isolation (tests/integration/_integration.ts). Examples: search.test.ts, fate-live-posts.test.ts.

integration names two different fidelities, one per app. The paragraph above is apps/web's and stays the definition ADR 0082 fixed: real remote Cloudflare, real D1, real credentials. apps/tuval deploys nothing and has no D1, so its integration project means the other half of what the tier was always for — the fidelity a claim needs that a substituted seam cannot give. There it is a real Pi AgentSession behind a real loopback socket and a real WebSocket codec, on Pi's own faux provider, so the tier is slow but not remote and needs no cloud credentials (apps/tuval/vitest.config.ts; epic #7497). Two rules hold across both: the split is by fidelity and never by folder, and a claim that only a real engine could falsify belongs in integration whichever app it is in. What you may not do is read one app's integration and expect the other's — say which app you mean.

One Database seam. A single Database tag holds the raw D1Database handle; both the Drizzle service and the better-auth adapter derive from it, so they share one underlying handle by construction — the one-handle invariant is type-enforced by the layer graph, not upheld by hand. At unit that seam is substituted; the real handle exists only at integration, where it is real remote D1.

Seam-graduation (organic framework evolution). A test seam is born app-local and graduates only when it has earned it:

  • Gate A — rule-of-three → extract an app-local factory. At ≥ 3 in-app call sites, extract a factory (fresh per call; never a shared mock-layer instance). Its home stays app-local under worker/, not packages/.
  • Gate B — graduate to a package / upstream. Requires proven-in-app and a second consumer or an upstream home. An empty packages/ is load-bearing signal that nothing has earned graduation.

The fate loader/resolver split

In phoenix's fate data layer, sources LOAD; operations RESOLVE. The split is a seam, and the two sides have fixed interfaces. (ADR 0016fate is pure transport, Effect services are the domain; the loader contract is the fate-effect sources pattern.)

  • Loader (a source). Fate.source(View, {id}, handlers) declares the per-entity loader: at least one of byId / byIds, silent reads (a missing id returns null / a short list, not a failure), and a failure channel pinned to never (infrastructure failures die one layer down, inside the domain service). byIds is the workhorse that kills N+1 under the interpreter's RequestResolver batching, and it must be membership-stable — its rows must be a function of the id set (every SQL IN-shaped loader qualifies); a cursor-limited or order-sensitive byIds silently diverges under a merged batch window.
  • Resolver (an operation / custom query). What runs the read-or-write, masks the resolved fields down to what the view permits, and translates failures to wire codes at the boundary. Connections are resolvers, not loader handlers: a source carries no connection handler — keyset ORDER BY lives in the domain service, surfaced by a custom resolver in queries.ts / lists.ts.

The seam matters because the two sides cross different trust boundaries: the loader is pure-membership and batch-mergeable, the resolver is where authorization-masking and error-translation happen. fate itself never queries the database — handlers delegate to the domain services.

Branded ID schema

A branded ID schema is an entity id typed as a nominal brand over a plain string — Schema.String.pipe(Schema.brand("Name")), output type Brand.Branded<string, "Name"> — so two ids that are both string at runtime (a user id, a definition id) become distinct types the checker won't let you pass for one another. The brand is compile-time only: it narrows the output type without validating, so .make/decode return the input unchanged and the wire + D1 bytes stay byte-identical — no runtime allocation, no runtime check. (Epic #2700; idiom grounded in Effect-TS/effect packages/effect/SCHEMA.md §Branding — the top-level Schema.brand form, not a hand-rolled phantom symbol.)

  • The shared home + the mint. All branded ids live in one module, apps/web/worker/lib/ids.ts, and are minted by its brandedId(name) helper (export const UserId = brandedId("UserId")). A write boundary brands a raw session string at the call site with .make — e.g. UserId.make(user.id) in features/sozluk/mutations.ts.
  • Shared cross-feature vs. feature-owned. UserId is the cross-feature id — the authenticated user threaded as every write's actor/author/voter/reactor argument. A feature-owned id belongs to one product surface: sözlük's DefinitionId / TermSlug. Feature-owned ids co-locate in lib/ids.ts beside UserId (one import for every child of #2700), but stay conceptually owned by their feature — a PostId (pano) is declared with its own surface, not reused across features. Distinct brands are what make an argument swap like voteDefinition({definitionId, voterId}) a typecheck error instead of a live bug.

Store of record vs. data view — "view" names the fate read-shape only

Two unrelated module kinds in the same feature folder once both wore the word "view"; the vocabulary now keeps them apart.

  • Store of record (*_record Drizzle table). The authoritative, mutated D1 table add/edit/remove write directly to under the D1-direct model (ADR 0009 — there is no projection layer). definition_record / comment_record (Drizzle definitionRecord / commentRecord) are the canonical stores whose loss is data loss. The _record suffix is load-bearing: it reads as "store of record" and reserves "view" for the data view below. They were renamed off the projection-era *_view name in #853 — that name lied about the module kind (a reader assumes a rebuildable read-projection) and collided one capital apart with the fate data view.
  • Data view (fate FateDataView). A pure read-shape declaration — the fields a fate entity exposes, not a table. DefinitionView / CommentView (PascalCase, in each feature's views.ts) declare what the loader/resolver above project; they hold no rows and are never written. See the "data view" row in TERMS.md.

The test: if you can INSERT/UPDATE it, it's a store of record; if it only declares fields to read, it's a data view (*View). Never name a write target a "view." The *_record suffix is narrower than "any store of record": it marks a store that is shared across packages (the canonical @kampus/db-schema declaration). A worker-private mutated store — user_profile, content_report, the stats singletons — is a store of record too, but lives in the worker schema without the suffix, so lacking *_record is not a convention violation for those.

The LiveDO connection / topic roles

Cross-isolate live (SSE) fan-out is carried by one LiveDO Durable Object class that plays two roles, selected by instance-name prefix. (ADRs 0023 — live views over SSE, the original fan-out; 0025 — the connection/topic split, now superseded; 0028 — the Effect DO model the roles are authored on; 0037 — the current state: one void-aligned class, two roles, KV storage.)

  • Connection role (connection:<id>). Owns one client's held SSE stream, its subscription list, and its persisted generation (the stale-detection counter). It is the per-client endpoint of the fan-out.
  • Topic role (topic:<key>). Owns the durable subscriber registry for one topic, the publish fan-out to that topic's connections, and the reap of dead connections. It is the per-topic hub.

A publish-only LiveEventBus forwards live.* events to the topic role, which delivers to the connection role. The two roles share no runtime state — an instance is always exactly one role. The history is worth knowing: ADR 0023 packaged both roles in one class; 0025 split them into separate ConnectionDO / TopicDO classes to make invalid cross-role calls unrepresentable; 0037 reunified them into a single class (the split's two motivations — a mutual-DO Layer cycle and a SQLite registry — both went away), where a misroute now no-ops at runtime (role-guarded) rather than failing to compile.

The composition shell / recipe

A shell wraps a nav/page primitive and owns a whole zone, exposing it to the page as flat element-props — one ReactNode prop per zone — so an element not assigned to a declared zone is a type error, not a lint finding. The recipe is the composition idiom a shell is built on. (ADR 0182SubnavShell / PageShell composition API — flat element-props, which coins both terms; it builds on ADR 0176's nav element taxonomy. The SubnavShell / PageShell source is defined by that ADR — the term is grounded in the ADR until the shells land in apps/web/src/components/layout/.)

  • recipe (UI sense; the pipeline sense is below) — the composition-primitive idiom: one flat element-prop per zone, orphan-as-type-error. NOT a zones-object, NOT compound components (<Shell.Destinations>…) — a compound API leaves an orphan slot (an element rendered but placed nowhere) that only a lint pass catches; flat element-props make that unrepresentable, because an element with no declared zone prop has nowhere to compile in.
  • shell — the layout-composition wrapper (SubnavShell / PageShell) that names a page's zone-plus-content shape once and hands each zone to the consumer as one prop. SubnavShell composes the per-product Subnav primitive (wrapping it, not replacing it); PageShell composes SubnavShell plus the routed page content below it. A shell sits between a primitive and the page — distinct from a primitive, which owns no zone and exposes raw slots.

The lane, the chore lane, and the pipeline sense of "recipe"

A lane is one unit of pipeline work driven by a machine document plus an append-only events.jsonl, folded fresh on every read — no resident process, no snapshot (#5673). A lane is addressed by a key, and the key decides where its ledger lives:

  • chore lane — a lane keyed by a name rather than an issue number, because a recurring chore has no issue to be keyed by; its state lives at .fabrika/chores/<name>/events.jsonl, against .fabrika/lanes/<n>/ for an issue lane. Same fold, same six-event vocabulary (DONE/PASS/FAIL/BLOCKED/WIP/UNBLOCKED), different key. Source: packages/fabrika-cli/src/lane/key.ts (#5840).
  • recipe (pipeline sense) — one deterministic fabrika verb a chore workflow state applies: a fixed sequence with named exit codes and no judgment in it. A recipe relays a verb's answer, it never derives the decision — ADR 0228. The operator that runs one is a thin executor of the machine it is handed; the known/novel split lives in the verb's exit codes, never in operator prose.

Two senses of recipe. This is the pipeline one. The other is the UI composition idiom above ("The composition shell / recipe", ADR 0182) — one flat element-prop per zone. They share nothing but the word; name which one you mean when the context does not fix it.

Diátaxis-lite README shape

The canonical section order every packages/*/README.md follows — explanation (what it is / why it exists, citing the forcing ADR) → how-to (runnable recipes) → reference (dry, look-it-up, last or linked out) → a short testing tail — with two hard rules: no tutorial at package scale (a walkthrough moves to its own linked surface), and scope/non-goals live in the explanation half. A small package may satisfy it in three short sections; the order is canonical, the length is not. The diataxis skill is the single-mode classifier over any page. Pinned by package-readme-shape.md.

The three senses of "phoenix"

"phoenix" carries three distinct meanings, each with a graduation name — disambiguate which one a doc means, because they're about to fork harder (phoenix-ops leans on sense 3, product milestones lean on sense 2):

  1. phoenix, the monorepo — this repository: the multi-app, multi-worker Cloudflare workspace (apps/*, packages/*, infra/*). Product/company side — the ground the other two stand on.
  2. phoenix, the product codename — kamp.us vNext, the reborn community, under its build-time codename. Product/company side. When it ships it simply becomes kamp.us — the codename is retired, not renamed; the product comes home to its own name.
  3. phoenix, the framework — the batteries-included application framework the other two are quarried from: alchemy + Effect + fate, battle-tested in production, general-purpose, future-facing. Not a kamp.us artifact — a standalone one forged here, built for keeps. It graduates to anka — the Turkish phoenix (Anka kuşu / Zümrüdüanka) — as its permanent name.

Monorepo + product-codename are product/company side; the framework is the durable, reusable side.

The through-line worth preserving: the framework is the true phoenix — the part built to outlive its origin — so it earns the Turkish phoenix name anka and rises to live independently; the product simply comes home to kamp.us. Rebirth named in English (phoenix), completed in Turkish (anka), landing on the repo's Turkish-for-brand / English-for-technical rule (§3). anka is a framework name, not user-facing product copy, so it lives here in sense (3) rather than as a §3 Turkish-surface brand-noun row.

Diátaxis-lite README shape

The canonical section order every packages/*/README.md follows — explanation (What it is / Why it exists) → how-to → reference tail → testing — scaled down to a three-section minimum for small packages. No tutorial at package scale: walkthroughs live on their own linked surface. The pattern doc is .patterns/package-readme-shape.md; the diataxis skill is the single-mode classifier over any docs page, READMEs included.

Milestone

A milestone is an initiative. An initiative has a Definition of Done. A catch-all with no DoD is a label, not a milestone. A fixes-bucket like "Sözlük/Pano fixes" is an oxymoron to this definition — it has no DoD, so it can't be an initiative, so it isn't a milestone; its disposition is to retire (convert to a plain label if the grouping is still wanted), not "close as done." (ADR 0072milestones encode strategic sequencing.)

A standing cross-cutting axis — a perpetual concern with no terminal DoD (token efficiency, test/CI health, pipeline hardening) — is likewise a label, not a milestone, by the same DoD test. Its label is axis:*, distinct from area:*: area:* = product area, axis:* = cross-cutting concern (e.g. axis:token-efficiency, axis:test-ci-health, axis:pipeline-hardening vs area:sozluk-pano-ui). Tag go-forward only — open issues get the axis label; closed issues are not retro-tagged (the area:sozluk-pano-ui precedent).

An axis:* label does not anchor p1 — only a live fire or an active bounded milestone does. A standing axis is p2-by-default; an individual item rises to p1 only when it is a genuine fire (a broken gate, a false-verdict trust bug). This keeps a lane's sustained importance out of the priority spine, so it can't rebuild the p1-inflation backdoor the milestone-relative p1 rule closed (#1936 / #2078). Founder-override-open — the founder may later designate an axis:* (e.g. axis:pipeline-hardening) a p1-anchor; until relayed, this rule stands. (Extends the #2093 → #2095 milestone-governance convention.)

The config surface, a shipped default, and delete authority

Three terms epic #5631 pinned while turning fabrika's repo-specific literals into .fabrika.jsonc keys (ADR 0273). Shaped in .patterns/fabrika-config-key-groups.md; named here so a doc, a skill and a verb use one word for each.

The config surface is the set of keys .fabrika.jsonc may carry, each with its shipped default — packages/fabrika-cli/src/config/registry.ts. It is not the file: the file is one repo's instance of the surface, and a repo that ships no file still stands on the whole surface. fabrika status settings prints the surface, which is why it can answer for a repo that declared nothing.

A shipped default is the value a key resolves to when the file or that key is absent. Two rules ride the term. It is never an empty set where empty would turn a gate off — an empty governed root list reads as "nothing is governed", so the default reproduces today's behaviour and an explicitly-declared empty is Malformed. And it is never what an unreadable file resolves to: a read that failed proves nothing about what the repo declared, so every key is UNKNOWN and the caller refuses. Absent and unreadable are opposite answers all the way down, and collapsing them is how a gate reports "this repo declared nothing" about a repo it never read.

Delete authority is what a triage facet's owns pattern grants: every label the pattern matches and the keep set does not is removed. The term carries the #4285 incident — a declared lane no facet owned was written once and never superseded. This epic makes delete authority a property of loaded data rather than of source: it is composed in packages/fabrika-cli/src/config/resolve-board.ts from triageFacets joined against boardVocabulary, so a repo that renames a status cannot end up with a facet that deletes labels nobody declared.

Tuval: program, process, window

Tuval's three nouns, ruled by #7484 R1.1 ("let's be boring and choose program and process for now") and first used in code by the registry slice of epic #7496 under apps/tuval (ADR 0345). English technical terms, per §3.

  • program — one registry row: a stable id, a private Demlik core machine, public typed port schemas, host handlers, a capability request list, an optional renderer reference, and the #7467 identity / capability / placement records as inert data. A program is exactly one row; there is no second species and no view-only exemption. Source: apps/tuval/src/registry/program.ts.
  • process — one running instance of a program: a stable id, a parent, ports, a lifecycle. Always say "OS process" for the operating-system kind; a bare "process" in Tuval prose is this one.
  • window — a view onto a process, the Vim-buffer model: many windows may show one process at once, all sharing the process state, each window owning its own view state (scroll, selection).

"Widget" and "actor" are retired for Tuval's own surfaces — an older doc that says either means program (definition) or process (running instance). "Grain" (Orleans' virtual actor) is noted as a future-feeling alternative and is not adopted.

Tuval: stack, orientation, size, zoom

The layout tree's four nouns, first used in code by apps/tuval/src/shell/layout/ (#7551).

  • stack — a node holding an ordered list of child windows and stacks. Windows are the leaves; a stack is never empty, and only the root may hold a single child.
  • orientation — how a stack lays its children out. "horizontal" means the children sit side by side in a row; "vertical" means they stack top to bottom. Studio's layout-tree inverts this at its render boundary (its orentationFromDirection maps left/right to "vertical"); phoenix does not, and no orientation flip appears anywhere in the port.
  • size — a child's share of its stack's extent, in percent, never pixels: every tab mirrors one desk at a different width, so a pixel would mean something different in each. Min and max are render-time props of the panel component and live nowhere in the tree.
  • zoom — the one window rendered alone, zoomed on the tree. Setting or clearing it never writes a size, so unzoom restores the layout exactly.

Tuval: workspace, prefix, attach

The shell's three nouns, first used in code by apps/tuval/src/shell/core/ (#7554). English technical terms, per §3, and named in the epic's own vocabulary ruling (#7499).

  • workspace — one named desk: a layout tree and the window focus sits in. The shell holds many and exactly one is active; the last one cannot be removed, because a shell with no desk has nothing to show. Re-derived from the founder's Studio, where workspaces are a keyed map beside an activeWorkspace id (monorepo/packages/studio/studio.ts).
  • prefix — the one key that arms the shell for the sequence after it (<c-b> by default, tmux's shape). With the prefix unarmed every key belongs to the focused window; there is no shell-wide mode, and "mode" is retired with the Runekeeper lineage.
  • attach — a page joining the running kernel over one socket, the tmux client sense. A restart is literal: kill Node, boot Node, re-attach the page, and the desk that comes back is the checkpointed one.

"Pane" is not adopted: the tmux word for what Vim calls a window stays window. "Widget" and "rune" are retired with the same lineage; "spell" is re-coined below as a Tuval command, not the Studio widget verb.

Tuval: spell, registry, palette, scope, the Tuval protocol

Tuval's command-framework nouns, ruled by the thirteen decisions on grilling #7617 and built by epic #7627. English technical terms, per §3. The why is ADR 0348; the shapes are .patterns/tuval-spells.md.

  • spell — one addressable command in Tuval's spell registry: a path, a one-sentence description, an Effect Schema for its parameters and one for its result, an Effect execute, and an inert capability list. Source: apps/tuval/src/commands/spell.ts.
  • registry — ambiguous on its own in Tuval prose, so always qualify it. The spell registry is the one table of every callable spell, keyed by path, built from the core spell list plus each program row's spells and replaced whole on a config reload (apps/tuval/src/commands/registry.ts). The program registry is the separate kernel table of program rows (apps/tuval/src/registry/Registry.ts); it never reads a row's spells.
  • palette (the Tuval palette) — Tuval's desk-level command overlay at the top center of the app, fixed width and never anchored to a window, where a person types a spell and picks from ranked completions. Not apps/web's ⌘K command palette (ADR 0186) and not a reaction palette (ADR 0139); a bare "palette" in Tuval prose is this one.
  • scope — where one spell call came from, as the kernel decides it: the workspace and client always, plus the window and process when the caller was inside one. The page names a window and nothing else; the kernel resolves the rest, so a page cannot address a process by putting its id on the wire. Not Effect's Scope, the resource-lifetime handle Tuval prose also uses (every process runs in its own Effect Scope forked from its parent's); a bare "scope" in Tuval spell prose is this record. Source: apps/tuval/src/commands/scope.ts.
  • the Tuval protocol — the one versioned page-to-kernel wire: four Effect Schema messages (SpellCall, SpellReply, Snapshot, Patch), one union per direction, JSON text only. Source: apps/tuval/src/protocol/messages.ts.

Tuval: partial (a transcript item)

  • partial — a transcript item still being written, marked partial on the item itself. The backend re-upserts the same ItemId as the text grows and leaves the marker off the last upsert, so absent means final and one field carries the whole distinction. It is not an item kind and not an event kind, and it names no backend: the window learns "still growing" once and every agent program streams the same way (#8142's ruling, epic #8160). Two kinds grow one — the assistant reply and the thinking row, since a turn's reasoning streams before its answer does (#8288) — and every predicate that reads the marker reads it through in, so a third costs no arm. Source: apps/tuval/src/ai-agent/ports/transcript-item.ts.

Tuval: thinking row, compaction marker, session row

Three of the six rows a Tuval chat window renders, minted by epic #8142 phase 1. English technical terms, per §3, and model-blind: none of them names a backend, a model or a session id. Source: apps/tuval/src/ai-agent/ports/transcript-item.ts.

  • thinking row — the agent's reasoning for one turn, as content. Not ports/thinking.ts, which is the effort-level control; a bare "thinking" in Tuval transcript prose is this row, and the control is always "thinking level".
  • compaction marker — where a session compacted its context. Its own kind rather than a session row, because it is a boundary the window draws rather than a notice it prints, and it is the one place a reader needs to see why earlier turns are gone.
  • session row — one backend notice, collapsed: a summary line always shown plus optional detail the window folds away. Every notice lands here — status, hooks, local command output, refusals, rate limits — and the row deliberately does not name which it was.

3. Product / brand nouns (Turkish surface)

The naming convention is Turkish for product / brand, English for technical: product and brand names stay Turkish and are never translated; everything technical is English — URL routes/paths, code identifiers, D1 table/column names, file names. The canonical example is that the route is /search?q=, not /ara.

User-facing copy in apps/web is Turkish and English, behind the i18n catalog (ADR 0347). The reader picks a locale; Turkish is the default, so a reader who picks nothing reads what the site always read. Both locales are served from one typed catalog per locale under apps/web/src/i18n/, and the brand nouns in the table below are not translated in either one — the English interface still says sözlük, pano, mecmua, yazar, çaylak. Tuval, Fabrika and Demlik are English-only: only the product name is Turkish, and none of them coins a Turkish term.

Three technical terms come with that rule:

  • locale — the reader's chosen language, tr or en. tr is the default.
  • catalog — the typed per-locale message record (Record<Key, string>) under apps/web/src/i18n/, split one file per surface. There is no runtime i18n dependency; a key present in one locale and missing in the other is a pnpm typecheck failure.
  • catalog key — the identifier a surface passes to read one message out of the catalog. Keys are technical, so they are English, whatever locale they resolve into.

Two axes hide inside this one rule — keep them separate. Canonical glossary-term language (the name a concept carries in TERMS.md) and UI copy language (the strings rendered on a user's/mod's screen) are decided independently:

  • A technical / analytics / infra concept keeps an English canonical term, even when the concept appears on a Turkish-speaking user's or mod's screen. A conversion funnel is recorded as funnel / conversion funnel, never force-translated into a manufactured "dönüşüm hunisi" (an ugly, needless translation of a technical concept). This holds when the ADR 0092 glossary-freshness gate demands a term for a new internal/analytics surface: coin the English term, don't translate it — the concept stays English regardless of what language its page renders in.
  • User-facing UI copy is whatever locale the reader picked — it comes out of the catalog, in tr or en. The mod-facing funnel page renders localized copy while the concept underneath keeps its one English canonical name in TERMS.md. Adding English copy changes nothing on the glossary axis: a term is not re-decided because the page it appears on can now render in English.

The bildir row below already models this split: the brand lexeme surfaces in the user-facing copy (bildir / bildirildi, in both locales — it is a brand noun) while its technical surface (features/report, the Report service, content_report) stays English. The funnel is the mirror case — a technical concept whose canonical term is English (funnel / conversion funnel) whatever locale the surface it powers renders in. Collapsing the two axes ("the concept shows on a Turkish screen, so its glossary term must be Turkish") is the mistake this note exists to stop.

On a showcase / exhibit surface, the chrome is technical, only the sample content is product copy. Any surface that demonstrates components — a design-system storyboard, a component gallery, a pattern showcase — layers a component/primitive that renders some example content. Split them: the names — component names, primitive names, section and exhibit labels, and every code identifier or route path around them — are technical, so they stay English; only the in-exhibit example/sample content the component renders is user-facing product copy, so it reads in the product's voice. The sample never bleeds up into the technical chrome that frames it: a Button exhibit is labeled Button (English name) showing a Gönder sample (product copy), never a Düğme exhibit. This is the general rule for every showcase surface — Turkish-naming the components was the atölye-storyboard inversion the founder ruled a standing don't. Exhibit sample content is not a user surface, so it is exempt from the catalog and stays as authored.

The Turkish product/brand nouns this repo uses:

Noun What it names
sözlük the dictionary product (terms + definitions)
pano the link/discussion board product (posts + comments)
kampus the umbrella product / community
bildir the report / notify surface — the brand lexeme surfaces in the user-facing copy (the ReportButton labels bildir / bildirildi / zaten bildirildi); the technical surface (features/report dir, Report service, content_report table) is English per this convention
künye the per-user identity DO (karma, invite-only access, privileges)
depo the internal asset store/CDN (was imge)
divan the proving-ground reviewer surface — the gated /divan destination where yazar + moderatör review a çaylak's sandboxed work ("work goes before the divan")
mecmua the serious long-form blogging / publishing product (a third surface beside sözlük + pano, epic #2429) — a yazar authors and publishes a long-form post (başlık + markdown body) that anyone may read; a çaylak cannot publish (authorship is earned). v1 is a surface on the existing apps/web worker, not its own app. Turkish for "magazine / journal / anthology"
sustur mute — the one-directional, silent, notification-suppressing member-mute lever (epic #2571; v1 semantics fixed by ADR 0188). Muting a member both read-masks their content and suppresses the bildirim their interactions would generate to the muter; the muted member is never notified. Distinct from engelle (block) — mute is one-directional and lighter. Turkish for "silence / mute"
engelle block — the heavier, mutual interaction-prevention lever (preventing replies/mentions/mutual visibility, symmetry TBD). Deferred from mute v1 and scoped to its own later decision/epic (ADR 0188); named here to keep it distinct from the lighter one-directional sustur (mute). Turkish for "block / obstruct"

This is the brand-noun seed. The full domain-noun glossary (the entities and their precise definitions) lives in its own .glossary/TERMS.md; this table fixes only the product/brand spellings and the Turkish-vs-English rule so they aren't duplicated in CLAUDE.md.


See also

  • .decisions/ — the why and the history behind every term here (an ADR is the source for each phoenix structural term).
  • .patterns/ — how the current code is shaped (the loader contract, the test seams, the DO wiring).
  • CLAUDE.md — points here for the canonical vocabulary.