@voltro/cli 0.17.0 → 0.18.0

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.
Files changed (61) hide show
  1. package/CHANGELOG.md +268 -0
  2. package/bin/voltro.mjs +6 -1
  3. package/dist/{apiBuild-ChdlLqGv.js → apiBuild-CPDTJHkH.js} +2 -2
  4. package/dist/apiBuild-D1UBJ4TM.js +2 -0
  5. package/dist/bin.js +2 -2
  6. package/dist/{commands-DYQbv-DG.js → commands-laJDMj2m.js} +1899 -1700
  7. package/dist/{dev-DB7pbLob.js → dev-BOFxC21E.js} +993 -930
  8. package/dist/{dev-BCmoJfBm.js → dev-DOK0w6ZW.js} +1 -1
  9. package/dist/index.js +1 -1
  10. package/dist/{inspectMetrics-DWh56Qas.js → inspectMetrics-D1DmLeJs.js} +507 -494
  11. package/dist/{serveCommand-DO6qr5Ok.js → serveCommand-CxcxHc9Y.js} +320 -320
  12. package/dist/serveEntry.js +2 -2
  13. package/dist/{start-ksY0wMZG.js → start-Clvz4IJb.js} +1 -1
  14. package/dist/startEntry.js +2 -2
  15. package/package.json +17 -17
  16. package/templates/AGENTS.core.md +38 -0
  17. package/templates/AGENTS.md +39 -1
  18. package/templates/agent-docs/_index.md +1 -1
  19. package/templates/agent-docs/authentication.md +48 -0
  20. package/templates/agent-docs/cli.md +9 -5
  21. package/templates/agent-docs/database/querying.md +6 -2
  22. package/templates/agent-docs/introduction.md +15 -1
  23. package/templates/agent-docs/routing.md +2 -0
  24. package/templates/agent-docs/schema-driven-ui.md +6 -3
  25. package/templates/agent-docs/whats-new.md +199 -30
  26. package/templates/apps/api-ai/package.json +7 -7
  27. package/templates/apps/api-auth/package.json +8 -8
  28. package/templates/apps/api-backend/package.json +7 -7
  29. package/templates/apps/api-backend-deactivation/package.json +7 -7
  30. package/templates/apps/api-backend-mail/package.json +8 -8
  31. package/templates/apps/api-backend-mariadb/package.json +9 -9
  32. package/templates/apps/api-backend-storage/package.json +8 -8
  33. package/templates/apps/api-data-advanced/package.json +8 -8
  34. package/templates/apps/api-durable/package.json +8 -8
  35. package/templates/apps/api-feature-flags/package.json +9 -9
  36. package/templates/apps/api-governance/package.json +8 -8
  37. package/templates/apps/api-kv/package.json +8 -8
  38. package/templates/apps/api-moderation/package.json +8 -8
  39. package/templates/apps/api-observability/package.json +8 -8
  40. package/templates/apps/api-ratelimit/package.json +8 -8
  41. package/templates/apps/api-rbac/package.json +8 -8
  42. package/templates/apps/api-rest/package.json +7 -7
  43. package/templates/apps/api-saas/package.json +11 -11
  44. package/templates/apps/api-search/package.json +8 -8
  45. package/templates/apps/api-versioning/package.json +8 -8
  46. package/templates/apps/api-webhooks/package.json +9 -9
  47. package/templates/apps/changelog/package.json +6 -6
  48. package/templates/apps/edge-functions/package.json +2 -2
  49. package/templates/apps/frontend-admin/package.json +8 -8
  50. package/templates/apps/frontend-app/package.json +8 -8
  51. package/templates/apps/frontend-blank/package.json +7 -7
  52. package/templates/apps/frontend-contact/package.json +7 -7
  53. package/templates/apps/frontend-dashboard/package.json +7 -7
  54. package/templates/apps/frontend-docs/package.json +7 -7
  55. package/templates/apps/frontend-i18n/package.json +6 -6
  56. package/templates/apps/frontend-landing/package.json +7 -7
  57. package/templates/apps/frontend-spa/package.json +7 -7
  58. package/templates/apps/frontend-ssr/package.json +7 -7
  59. package/templates/apps/frontend-ssr-api/package.json +8 -8
  60. package/templates/apps/frontend-static-blog/package.json +6 -6
  61. package/dist/apiBuild-D5WB119b.js +0 -2
@@ -1,5 +1,5 @@
1
- import { Q as e } from "./inspectMetrics-DWh56Qas.js";
1
+ import { Q as e } from "./inspectMetrics-D1DmLeJs.js";
2
2
  import { c as t } from "./seedRunner-D6eu-u5U.js";
3
3
  import { r as n } from "./appModuleLoader-C9r9mxZt.js";
4
- import { t as r } from "./serveCommand-DO6qr5Ok.js";
4
+ import { t as r } from "./serveCommand-CxcxHc9Y.js";
5
5
  export { e as loadDotEnv, n as registerAppModules, t as registerDriver, r as runServe };
@@ -1,4 +1,4 @@
1
- import { A as e, B as t, C as n, E as r, G as i, I as a, K as o, N as s, O as c, U as l, W as ee, X as u, Y as d, _ as f, a as p, b as m, c as h, et as g, f as _, g as te, h as v, i as ne, it as y, j as b, k as x, m as S, nt as C, o as re, p as ie, q as w, r as ae, rt as T, s as E, st as D, t as O, tt as k, ut as A, v as j, w as oe, y as M } from "./inspectMetrics-DWh56Qas.js";
1
+ import { A as e, B as t, C as n, E as r, G as i, I as a, K as o, N as s, O as c, U as l, W as ee, X as u, Y as d, _ as f, a as p, b as m, c as h, et as g, f as _, g as te, h as v, i as ne, it as y, j as b, k as x, m as S, nt as C, o as re, p as ie, q as w, r as ae, rt as T, s as E, st as D, t as O, tt as k, ut as A, v as j, w as oe, y as M } from "./inspectMetrics-D1DmLeJs.js";
2
2
  import { D as se, E as ce, T as le, a as N, p as ue, w as de } from "./inspect-Dwx0_tUj.js";
3
3
  import { t as fe } from "./bootTiming-BdyP9nYw.js";
4
4
  import { dirname as pe, extname as P, join as F, resolve as I } from "node:path";
@@ -1,3 +1,3 @@
1
- import { Q as e } from "./inspectMetrics-DWh56Qas.js";
2
- import { t } from "./start-ksY0wMZG.js";
1
+ import { Q as e } from "./inspectMetrics-D1DmLeJs.js";
2
+ import { t } from "./start-Clvz4IJb.js";
3
3
  export { e as loadDotEnv, t as runStartCommand };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@voltro/cli",
3
- "version": "0.17.0",
3
+ "version": "0.18.0",
4
4
  "description": "The `voltro` CLI — dev server, codegen, migrations, project scaffolding, agent-docs seeding, and production serve.",
5
5
  "keywords": [
6
6
  "voltro",
@@ -62,22 +62,22 @@
62
62
  "@effect/platform-node": "^0.107.0",
63
63
  "@effect/sql": "^0.51.1",
64
64
  "@effect/workflow": "^0.18.2",
65
- "@voltro/ai": "0.17.0",
66
- "@voltro/cache": "0.17.0",
67
- "@voltro/data-transfer": "0.17.0",
68
- "@voltro/database": "0.17.0",
69
- "@voltro/env": "0.17.0",
70
- "@voltro/kv": "0.17.0",
71
- "@voltro/logger": "0.17.0",
72
- "@voltro/plugin-auth": "0.17.0",
73
- "@voltro/plugin-broadcast": "0.17.0",
74
- "@voltro/plugin-mail": "0.17.0",
75
- "@voltro/plugin-storage": "0.17.0",
76
- "@voltro/plugin-webhooks": "0.17.0",
77
- "@voltro/protocol": "0.17.0",
78
- "@voltro/runtime": "0.17.0",
79
- "@voltro/serverless": "0.17.0",
80
- "@voltro/workflow": "0.17.0",
65
+ "@voltro/ai": "0.18.0",
66
+ "@voltro/cache": "0.18.0",
67
+ "@voltro/data-transfer": "0.18.0",
68
+ "@voltro/database": "0.18.0",
69
+ "@voltro/env": "0.18.0",
70
+ "@voltro/kv": "0.18.0",
71
+ "@voltro/logger": "0.18.0",
72
+ "@voltro/plugin-auth": "0.18.0",
73
+ "@voltro/plugin-broadcast": "0.18.0",
74
+ "@voltro/plugin-mail": "0.18.0",
75
+ "@voltro/plugin-storage": "0.18.0",
76
+ "@voltro/plugin-webhooks": "0.18.0",
77
+ "@voltro/protocol": "0.18.0",
78
+ "@voltro/runtime": "0.18.0",
79
+ "@voltro/serverless": "0.18.0",
80
+ "@voltro/workflow": "0.18.0",
81
81
  "chokidar": "^5.0.0",
82
82
  "ioredis": "^5.11.1",
83
83
  "tinyglobby": "^0.2.17",
@@ -449,6 +449,44 @@ aggregations, FTS, vectors/RAG, migrations, dialect parity → the **`database/*
449
449
  docs (see index). Async-vs-Effect handlers, `EffectStore`, typed store errors →
450
450
  **`data.md`**.
451
451
 
452
+ ## What may cross the wire (three ORTHOGONAL markers — do not substitute one for another)
453
+
454
+ A column is not "protected" or "unprotected". Three separate questions get three
455
+ separate markers, and using one to answer another's question is the mistake:
456
+
457
+ | Marker | Answers | Enforced by |
458
+ |---|---|---|
459
+ | `.serverOnly()` | may this value leave the server AT ALL? | **the boot audit — it FAILS the boot**, it does not warn |
460
+ | `.sensitive('class')` / `.safe()` | may it appear in a `voltro data export`? | the export's masking profile, **fail-closed** |
461
+ | `.encrypted()` | is it encrypted AT REST? | the store's codec |
462
+
463
+ ```ts
464
+ export const users = table('users', {
465
+ id: id(),
466
+ email: text().sensitive('pii'), // exportable only through a profile
467
+ pinHash: text().serverOnly(), // never reaches a client, ever
468
+ ssn: text().encrypted().serverOnly(), // both — they are not the same claim
469
+ })
470
+ ```
471
+
472
+ - **`.serverOnly()` is the one that decides "leak / no leak".** A wire-reachable
473
+ query that declares such a column in its OUTPUT does not start: the boot audit
474
+ refuses. That is a feature — the failure happens at boot, not in a bundle.
475
+ - **`.encrypted()` is NOT an exposure marker.** It says the bytes at rest are
476
+ encrypted; the runtime decrypts them for a handler, so an encrypted column
477
+ flows to the client exactly like any other unless it is ALSO `.serverOnly()`.
478
+ Reading `.encrypted()` as "safe to expose" is a category error, and a plausible
479
+ one — say both when you mean both.
480
+ - **Masking is fail-closed**: an unclassified column blocks the export rather
481
+ than passing through, so `.sensitive()` / `.safe()` is a decision you make once
482
+ per column, not a filter you remember to apply.
483
+ - `crud.list` / `getById` / `create` / `update` are **redacted by construction** —
484
+ they strip `serverOnly` columns for you. Hand-rolling the same CRUD is where
485
+ that stripping gets forgotten.
486
+
487
+ Depth (classes, profiles, the export flow) → **`database/sensitivity`**; the
488
+ redacted CRUD surface → **`data/crud`**.
489
+
452
490
  ## Naming / RPC tags
453
491
 
454
492
  - **camelCase** for vars, files, schemas, and rpc tags. The tag IS the wire
@@ -449,6 +449,44 @@ aggregations, FTS, vectors/RAG, migrations, dialect parity → the **`database/*
449
449
  docs (see index). Async-vs-Effect handlers, `EffectStore`, typed store errors →
450
450
  **`data.md`**.
451
451
 
452
+ ## What may cross the wire (three ORTHOGONAL markers — do not substitute one for another)
453
+
454
+ A column is not "protected" or "unprotected". Three separate questions get three
455
+ separate markers, and using one to answer another's question is the mistake:
456
+
457
+ | Marker | Answers | Enforced by |
458
+ |---|---|---|
459
+ | `.serverOnly()` | may this value leave the server AT ALL? | **the boot audit — it FAILS the boot**, it does not warn |
460
+ | `.sensitive('class')` / `.safe()` | may it appear in a `voltro data export`? | the export's masking profile, **fail-closed** |
461
+ | `.encrypted()` | is it encrypted AT REST? | the store's codec |
462
+
463
+ ```ts
464
+ export const users = table('users', {
465
+ id: id(),
466
+ email: text().sensitive('pii'), // exportable only through a profile
467
+ pinHash: text().serverOnly(), // never reaches a client, ever
468
+ ssn: text().encrypted().serverOnly(), // both — they are not the same claim
469
+ })
470
+ ```
471
+
472
+ - **`.serverOnly()` is the one that decides "leak / no leak".** A wire-reachable
473
+ query that declares such a column in its OUTPUT does not start: the boot audit
474
+ refuses. That is a feature — the failure happens at boot, not in a bundle.
475
+ - **`.encrypted()` is NOT an exposure marker.** It says the bytes at rest are
476
+ encrypted; the runtime decrypts them for a handler, so an encrypted column
477
+ flows to the client exactly like any other unless it is ALSO `.serverOnly()`.
478
+ Reading `.encrypted()` as "safe to expose" is a category error, and a plausible
479
+ one — say both when you mean both.
480
+ - **Masking is fail-closed**: an unclassified column blocks the export rather
481
+ than passing through, so `.sensitive()` / `.safe()` is a decision you make once
482
+ per column, not a filter you remember to apply.
483
+ - `crud.list` / `getById` / `create` / `update` are **redacted by construction** —
484
+ they strip `serverOnly` columns for you. Hand-rolling the same CRUD is where
485
+ that stripping gets forgotten.
486
+
487
+ Depth (classes, profiles, the export flow) → **`database/sensitivity`**; the
488
+ redacted CRUD surface → **`data/crud`**.
489
+
452
490
  ## Naming / RPC tags
453
491
 
454
492
  - **camelCase** for vars, files, schemas, and rpc tags. The tag IS the wire
@@ -541,7 +579,7 @@ each plugin's own README.
541
579
 
542
580
  | Topic | Open | Summary |
543
581
  |---|---|---|
544
- | **What's new in 0.16.0** | `node_modules/@voltro/cli/templates/agent-docs/whats-new.md` | Everything that changed in this version. Read it before hand-rolling something the framework may now ship. |
582
+ | **What's new in 0.18.0** | `node_modules/@voltro/cli/templates/agent-docs/whats-new.md` | Everything that changed in this version. Read it before hand-rolling something the framework may now ship. |
545
583
  | AI | `node_modules/@voltro/cli/templates/agent-docs/ai.md` | How Voltro treats AI — agents, tools, streaming, RAG — all primitives over the same WebSocket as the rest of the framework. |
546
584
  | Authentication | `node_modules/@voltro/cli/templates/agent-docs/authentication.md` | How @voltro/plugin-auth wires password + session-cookie auth across api + web, plus the pluggable identity-strategy protocol. |
547
585
  | Caching | `node_modules/@voltro/cli/templates/agent-docs/caching.md` | Voltro's caching layer (@voltro/cache) — an always-on memory default, swappable Redis-compatible backends, a low-level wrap primitive, and automatic query-result invalidation. |
@@ -9,7 +9,7 @@ each plugin's own README.
9
9
 
10
10
  | Topic | Open | Summary |
11
11
  |---|---|---|
12
- | **What's new in 0.16.0** | `node_modules/@voltro/cli/templates/agent-docs/whats-new.md` | Everything that changed in this version. Read it before hand-rolling something the framework may now ship. |
12
+ | **What's new in 0.18.0** | `node_modules/@voltro/cli/templates/agent-docs/whats-new.md` | Everything that changed in this version. Read it before hand-rolling something the framework may now ship. |
13
13
  | AI | `node_modules/@voltro/cli/templates/agent-docs/ai.md` | How Voltro treats AI — agents, tools, streaming, RAG — all primitives over the same WebSocket as the rest of the framework. |
14
14
  | Authentication | `node_modules/@voltro/cli/templates/agent-docs/authentication.md` | How @voltro/plugin-auth wires password + session-cookie auth across api + web, plus the pluggable identity-strategy protocol. |
15
15
  | Caching | `node_modules/@voltro/cli/templates/agent-docs/caching.md` | Voltro's caching layer (@voltro/cache) — an always-on memory default, swappable Redis-compatible backends, a low-level wrap primitive, and automatic query-result invalidation. |
@@ -569,6 +569,54 @@ Read users, sessions, keys. A strategy that runs domain writes while deciding
569
569
  who the caller is has the two jobs the wrong way round; nothing in the type
570
570
  stops you, and it is still wrong.
571
571
 
572
+ #### What the boot store carries, and what it does not
573
+
574
+ The line is **everything that does not need a Subject** — not "less than
575
+ `ctx.store`":
576
+
577
+ | | Boot store (`input.store`, `req.store`) | Request store (`ctx.store`) |
578
+ |---|---|---|
579
+ | `.encrypted()` columns decrypt / encrypt | ✓ | ✓ |
580
+ | Array columns round-trip on non-native dialects | ✓ | ✓ |
581
+ | Tenant scope | — | ✓ |
582
+ | Soft-delete filter | — | ✓ |
583
+ | Audit-column stamping | — | ✓ |
584
+ | Row-level security | — | ✓ |
585
+
586
+ The right-hand four need a resolved Subject, and a strategy runs *before* one
587
+ exists — so a read of tenant-owned rows here must derive and apply that scope
588
+ itself. The first two do not, and getting them wrong is silent: a `.encrypted()`
589
+ column read raw hands back the string `enc:v1:…`, which compares, concatenates,
590
+ renders and logs perfectly well, and simply never matches the token you compare
591
+ it to.
592
+
593
+ This is also what changes when you move a read **off** hand-written SQL and onto
594
+ the store. Raw SQL sees ciphertext and you decrypt it yourself — `decryptField`
595
+ from `@voltro/runtime` is the escape hatch for exactly that. Through either
596
+ store you get plaintext, so a hand-rolled `decryptField` on the way out will now
597
+ be handed a plaintext value; `decryptField` passes a non-ciphertext value
598
+ through unchanged, so the double call is harmless, but the manual step is no
599
+ longer doing anything.
600
+
601
+ #### Reading a plugin's own tables
602
+
603
+ A plugin's tables are declared through `extendSchema` like any others, so they
604
+ are in the same registry and the same store reads them. A public route that
605
+ needs a row a plugin wrote — a storage reference for an avatar proxy, say —
606
+ reads it directly:
607
+
608
+ ```ts
609
+ const [ref] = await req.store.query(
610
+ queryFor(storageObjects).where(eq('id', objectId)).descriptor,
611
+ )
612
+ ```
613
+
614
+ Two things to keep in mind. The table is the plugin's contract with itself, not
615
+ with you, so it can change shape in any release — pin the version if you depend
616
+ on it. And this store applies no tenant scope, so a route reading a
617
+ tenant-owned plugin table must filter by tenant itself, from something the
618
+ request proves rather than something it claims.
619
+
572
620
  ## Composing the chain
573
621
 
574
622
  `composeAuthStrategies` turns an ordered list of strategies into a single resolver. First `matched` wins; first `failed` short-circuits to anonymous.
@@ -600,19 +600,23 @@ export default {
600
600
  }
601
601
  ```
602
602
 
603
- #### Inspect token (token-gated deploys)
603
+ #### Inspect token
604
604
 
605
- The overlay's webhooks / traces / indexes panels poll each api's `/_voltro/inspect/*` endpoints. Under `voltro dev` that surface is open, so no auth is needed. A token-gated deploy (`voltro start` with `VOLTRO_INSPECT_TOKEN` set) requires the same `Authorization: Bearer <token>` the CLI sends otherwise the panels 401 to their empty state.
605
+ The overlay's webhooks / traces / indexes panels poll each api's `/_voltro/inspect/*` endpoints, and that surface is **fail-closed everywhere**: with no `VOLTRO_INSPECT_TOKEN` configured, nobody is authorised`voltro dev` included.
606
606
 
607
- The overlay reads that token from `VITE_VOLTRO_INSPECT_TOKEN` (only `VITE_`-prefixed vars reach the browser bundle). A foreign-host mount can also pass it explicitly:
607
+ **Under `voltro dev` you configure nothing.** The dev server mints a token per project and its proxy attaches the `Authorization: Bearer` header server-side, on the `/_voltro/api/<name>` route the panels fetch through. The token stays in the dev server's process; the browser never holds it.
608
+
609
+ That is deliberate rather than convenient. A token compiled into the client bundle is a live credential published to everyone who loads the page, so there is no env-var channel for it — `voltro dev` and `voltro build` set vite's `envPrefix` to a sentinel precisely so nothing leaks through `import.meta.env`.
610
+
611
+ For an api the dev proxy does not front — a `voltro start` deploy with `VOLTRO_INSPECT_TOKEN` set, say — pass the token explicitly, and note that whatever you pass ships in the bundle:
608
612
 
609
613
  ```tsx
610
614
  import { VoltroDevtools } from '@voltro/devtools'
611
615
 
612
- <VoltroDevtools inspectToken={import.meta.env.VITE_VOLTRO_INSPECT_TOKEN} />
616
+ <VoltroDevtools inspectToken={myToken} />
613
617
  ```
614
618
 
615
- When neither the prop nor the env var is set, no `Authorization` header is sent local dev is unaffected. (The indexes panel's live SSE stream can't carry a header; a token-gated deploy falls back to token-carrying HTTP polling for that panel.)
619
+ Without the prop the overlay sends no `Authorization` header of its own, which is correct: under `voltro dev` the proxy has already added one. (The indexes panel's live SSE stream can't carry a header at all; against an api reached without the proxy it falls back to token-carrying HTTP polling.)
616
620
 
617
621
  #### Overriding the overlay's labels
618
622
 
@@ -105,11 +105,15 @@ ctx.store.select('notes')
105
105
  .where('col', '>', value)
106
106
  .where('col', '>=', value)
107
107
  .where('col', 'in', [a, b, c])
108
- .where('col', 'like', 'abc%')
109
- .where('col', 'contains', 'needle') // case-insensitive substring (ILIKE '%…%')
108
+ .where('col', 'contains', 'needle') // case-INsensitive substring (ILIKE '%…%')
109
+ .where('col', 'startsWith', 'awb_') // case-SENSITIVE prefix (LIKE 'awb\_%')
110
110
  .where('col', 'fts', 'query string') // full-text fallback (LIKE-based here)
111
111
  ```
112
112
 
113
+ `contains` folds case because it is a search primitive — a human typing into a box means `hello` to find `Hello`. `startsWith` does not, because a prefix is a namespace: `awb_` and `AWB_` are two different key spaces, and quietly merging them is a bug. `startsWith` is also the only one of the two a database can answer from an index — `LIKE 'literal%'` is a btree range scan, `%…%` is not. `%` and `_` inside either value are escaped, so they match literally.
114
+
115
+ There is no `'like'`. It used to be here, and it was a lie: it mapped to `contains`, so `.where('path', 'like', '/api/%')` matched only rows literally containing the characters `/api/%` and the wildcard you wrote did nothing. An operator named after SQL's must honour your wildcards or not exist.
116
+
113
117
  These are the only operators the ergonomic `.where(col, op, value)` form accepts. For `IS NULL` / `NOT IN` / `IS NOT NULL`, pass a predicate built with the `@voltro/database` helpers:
114
118
 
115
119
  ```ts
@@ -473,7 +473,7 @@ If yes, the promise belongs in the name — you cannot see a contract before you
473
473
 
474
474
  | Suffix | Promise | Enforced by |
475
475
  |---|---|---|
476
- | `*.component.tsx` | exactly one component (+ types) | `component/one-per-file`, `component/no-hook-export` |
476
+ | `*.component.tsx` | exactly one component | `component/one-per-file`, `component/no-hook-export` |
477
477
  | `*.component.ui.tsx` | one component, **reads only** | `ui/no-write`, `ui/orphaned`, `ui/unlinked` |
478
478
  | `*.hook.ts` | exactly one `use*` hook (+ types) | `hook/one-per-file`, `hook/no-component-export` |
479
479
  | `*.types.ts` | zero runtime exports | `types/runtime-export` |
@@ -483,6 +483,10 @@ If yes, the promise belongs in the name — you cannot see a contract before you
483
483
  | `*.client.ts` | it and its imports are browser-safe | boot-time import walk, `client/not-browser-safe` |
484
484
  | `*.store.ts` | exactly one `defineStore`, no server state | `store/one-per-file`, `store/mirrors-server-state` |
485
485
 
486
+ A `*.component.tsx` promises exactly ONE component. It does not promise to export nothing else: types, and plain module-local values a `const COLUMNS = […]` beside the table that renders them, are fine and always were. What the rule counts is components — a declaration that renders — so an object, an array, a string or a `new` beside your component is not a second one, and neither is `export default Card` next to `export const Card`.
487
+
488
+ The BOUNDARY rules (`internal/foreign-import`, `fixture/production-import`, `ui/unlinked`) are assertions about your import graph, so it is worth knowing which edges they follow: relative specifiers, your tsconfig `paths` aliases (read from the nearest `tsconfig.json`, so a per-app `@/*` works when you run `voltro doctor` at the repo root), `export … from` re-exports, and dynamic `import()`. A package import is a leaf — the walk stops at the edge of your app.
489
+
486
490
  ## `*.component.ui.tsx` — reads, never writes
487
491
 
488
492
  ```tsx
@@ -536,6 +540,16 @@ A component then wires it up with `useTracking(checkoutTracking, props, sink)`
536
540
 
537
541
  The payoff is not tidiness. "What do we send to third parties" becomes a file listing instead of an archaeology project — which is the only form in which that question can be answered on demand when someone asks about personal data.
538
542
 
543
+ ## `convention/missing-test` — why a shallow test is still worth writing
544
+
545
+ Every suffix that declares a runtime contract also expects a test beside it, named mechanically: `Card.component.tsx` → `Card.component.test.tsx`. It is an advisory, not an error.
546
+
547
+ The usual objection is that a per-component test at any real size is low value, and for *assertions* that is often true. That is not what the rule buys. What it buys is that something **mounts** the component — and a render loop, a crashing effect, a missing provider or a broken context is invisible until something does.
548
+
549
+ That is not hypothetical. One app adopting the taxonomy wrote 251 of these, deliberately shallow (it mounts, it performs no domain write, it renders no raw catalogue key). The first run found a page whose breadcrumb effect rebuilt a fresh array literal on every render — effect → context state → re-render → new literal, without end. That one test took 423 seconds and exhausted the heap. Ten sibling pages memoised; exactly one did not, and in a browser the screen had looked usable. After the fix the whole web suite went from 645 s to 57 s.
550
+
551
+ So write them shallow if you like. The mount is the point.
552
+
539
553
  ## What deliberately has NO suffix
540
554
 
541
555
  A generic "one component per file" rule would be worth enforcing everywhere, so tying it to a rename would make it opt-in — less coverage for more cost. The shape rules above fire only on files that *declared* the contract, because declaring it is what makes the promise mean something.
@@ -262,6 +262,8 @@ No naming trick is needed to keep something out of the router: the absence of th
262
262
 
263
263
  The canonical form is **no trailing slash** — always link with `<Link to="/about">`, not `<Link to="/about/">`.
264
264
 
265
+ `Link` forwards every prop it does not consume itself to the underlying `<a>`, `ref` included — so it drops straight into a polymorphic slot (`<Button component={Link} to={url}>`) without a wrapper.
266
+
265
267
  The framework does NOT emit a trailing-slash redirect on its own. If you need `/about/` → `/about` normalisation (for SEO), configure a 301 redirect at your reverse proxy.
266
268
 
267
269
  ## What pages CAN'T do
@@ -903,9 +903,12 @@ that affordance read-only rather than binding to a tag that does not resolve. Th
903
903
  [`useCan`](/docs/ui/client-utilities/use-can); map them to your app's real RBAC
904
904
  scopes.
905
905
 
906
- The inspect surface is open in dev. When a deploy sets an inspect token the
907
- manifest GET is bearer-gated, so an admin UI pointed at a locked-down api has to
908
- supply that token a deployment concern, not something this hook handles.
906
+ The manifest GET is bearer-gated wherever it runs `/_voltro/inspect/*` is
907
+ fail-closed, so no configured `VOLTRO_INSPECT_TOKEN` means `401`, not "everyone".
908
+ Under `voltro dev` that is handled for you (the dev server mints a token and its
909
+ proxy attaches it server-side). An admin UI pointed at a deployed api has to
910
+ supply the token itself — a deployment concern, not something this hook
911
+ handles.
909
912
 
910
913
 
911
914