}>>('finance.journal.list', {}, { actor: asUser(admin), orgId });
- expect(journal.find((j) => j.id === journalEntryId)?.lines.map((l) => l.accountCode)).toContain(account);
- expect((await approvalAudit(id)).at(-1)).toMatchObject({ actor_id: admin, outcome: 'ok', detail: { revision: 2, self_approved: false } });
- });
});
diff --git a/docs/plan/crove-os-kernel.md b/docs/plan/crove-os-kernel.md
index f4d3edd0..7b549e0a 100644
--- a/docs/plan/crove-os-kernel.md
+++ b/docs/plan/crove-os-kernel.md
@@ -125,7 +125,7 @@ Crove Cloud accepts its first external tenant only when every row below is green
| T12 | Sandboxed file parsing: PDF, XML and MIME parsing run in `crove-extract` (no secrets, no egress, CPU, memory and time limits) | Tests: the sandbox cannot open a network connection and has no secrets in its environment; a dependency rule stops the API and worker from importing parsers | Parsing runs in-process (XML with a non-expanding parser); MIME parsing runs in the worker since #78 | #28 |
| T13 | Excel export keeps money exact and escapes formulas | Unit test: amounts with more than 15 significant digits and 4 decimals round-trip exactly from the exported file (never through JavaScript `Number`); text cells starting with `=`, `+`, `-` or `@` cannot run as formulas | Done in #93, with unit tests (`modules/finance/test/export.test.ts`): an amount is a numeric cell only when it has at most 15 significant digits and the double converts back to the identical decimal (number format per its decimal places), otherwise a text cell holding the exact decimal; amounts of 16 to 20 significant digits and 4 decimals round-trip exactly through exceljs. Text is written as string cells with the Text format, so a value such as `=HYPERLINK(...)` stays inert; no apostrophe is added, since in xlsx it would become part of the data (`-ABC`, `+84...`). Negative amounts stay numbers. There is no CSV export; a future one must use `csvSafeText`, which prefixes `=`, `+`, `-`, `@`, tab, CR and LF (OWASP) | #29 |
| T14 | External security review (Decision 7) and `SECURITY.md` | A written report from an independent reviewer covering auth, context and RLS, the sandbox, inbound email and webhooks; every high or critical finding fixed or accepted in writing by the owner; `SECURITY.md` with a private reporting channel | Not started; no `SECURITY.md` | #31, #33 |
-| T15 | Decision 7 review flow in CI | A required workflow on `main` that runs, for PRs touching protected paths (§10, G6), the invariant suite taken from the base branch and a security reviewer agent; a test PR shows it blocking | Only the `ci` and `gitleaks` workflows | #30 |
+| T15 | Decision 7 review flow in CI | A required workflow on `main` that runs, for PRs touching protected paths (§10, G6), the invariant suite taken from the base branch and a security reviewer agent; a test PR shows it blocking | Workflow `protected` built (#30): the invariant suite (`test/invariant/`) runs from the base branch on protected-path PRs, and the security reviewer agent job waits for the `ANTHROPIC_API_KEY` secret (#95). Not yet a required check on `main` | #30, #95 |
| T16 | PDPL package (§7.9) | DPIA, cross-border transfer dossier, DPA template, 72 h breach runbook and sub-processor list, reviewed by a lawyer | Not started | #62 |
| T17 | Definer hardening: every `crove_*` function pins `search_path = pg_catalog, pg_temp`, and login roles have no TEMP privilege | A CI catalog test over `pg_proc` fails on any `crove_*` function without the pinned path, and checks that login roles cannot create temporary objects | Done in #97. The guard is `hardeningViolations` in `packages/db/src/migrate.ts`. The migration runner runs it after every run and fails the run if any of these hold:- A function or procedure in a `crove_*` schema does not have exactly `search_path=pg_catalog, pg_temp` as its settings. SECURITY INVOKER functions count too, because a definer calling them lends its rights. Extension members and pg-boss's invoker functions in `crove_jobs` are skipped; a definer in `crove_jobs` still fails.
- A `crove_*` role has TEMPORARY.
`20261015120000_crove_kernel_revoke_temp` revokes TEMPORARY from PUBLIC and from the Crove roles. Before that, it grants TEMPORARY directly to every other role that held it, so Supabase Auth keeps it. Database tests: planted unpinned functions are reported in a rolled-back transaction, and `migrate()` fails on a committed one. Login roles get `permission denied to create temporary tables`. The pg_temp hijack test still fails with TEMP granted back. A `docker compose` run (sign-up, org, e-invoice upload, proposal) shows that Supabase Auth works without TEMP for Crove roles | #74 |
| T18 | Agents cannot raise their own power or launder approvals (ADR-0029) | Negative database tests: an agent actor cannot run any position, holder, template, `max_risk`, budget or kill-switch command; an agent cannot approve R3, a proposal flagged P03-P07, or work from its own causal chain; P10 counts only humans; a run that lost its checkout lease cannot write; removing a member vacates the position; a `reports_to` cycle is rejected under concurrency | Not started: positions and position agents do not exist yet; agent approvals must not ship before #75 | ADR-0029 |
@@ -1492,7 +1492,7 @@ Updated 2026-10-14 for the Vite + TanStack Router SPA (Decision 6).
| G3 `db-replay` | PRs touching `supabase/**`, `packages/db/**`, `modules/**` | - Clean replay on plain PG17 with the built-in runner (on `main`: the `db` test project in `ci`); re-apply new migrations and compare fingerprints; pgTAP.
- Conformance, RLS matrix, **invariant suite from the base branch**, EXECUTE matrix.
- Manifest matches catalog; types diff (prints the fix command).
- splinter, `pg-boss doctor`, `vitest --project db`.
|
| G4 `supabase-preview` | Crove Cloud, once `crove-cloud` and the beta project exist: PRs replayed on a preview branch of `crove-db-beta` | - Required on `main`.
- On `dev` it is only a warning, with automatic infra-failure triage; agents use `/reset-preview`.
- PRs from forks of the repo get no preview; G3 is the gate.
|
| G5 `ci` | Every PR | - Format, lint, typecheck, `--affected` tests; dependency-cruiser; knip.
- Derived risk <= declared risk.
- PII scan.
- <=800 non-generated lines (unless labelled `split-exempt` with a reason).
|
-| G6 `protected` | Protected paths: context/policy/authz, `packages/db/src/context/**`, identity adapters and `/auth`, migrations with GRANT/POLICY/DEFINER/ROLE, `_*.test.sql` tests, `.github/workflows/**`, `.claude/settings*`, hooks, `AGENTS.md`, `LICENSE`, `CLA.md`, `agent-autonomy.md` | Generates a plain-language before/after summary; the invariant suite runs from base and a security reviewer agent reviews the PR; an external security review comes before the first external tenant (Decision 7). No owner approval label. Every PR to `crove-cloud` counts as protected |
+| G6 `protected` | Protected paths (`tools/ci/protected-paths.mjs`, matched on paths only): context/policy/authz (`packages/kernel-runtime/src/**`, `packages/db/src/context/**`, approval policy), identity adapters, `/auth` and the HTTP edge, every migration (G6 named GRANT/POLICY/DEFINER/ROLE; any SQL can drop a trigger or replace a function, so all count), the migration runner, `_*.test.sql` and `test/invariant/` tests, `.github/workflows/**`, `CODEOWNERS`, `.gitattributes`, `.claude/settings*`, `.claude/agents/**`, `.mcp.json`, `CLAUDE.md`, hooks, dependencies and test runner configs (every `package.json`, lockfile, workspace, `.npmrc`, `vitest*.config.ts`, `vitest*.workspace.ts`), `AGENTS.md`, `LICENSE`, `CLA.md`, `agent-autonomy.md` | Generates a plain-language before/after summary; the invariant suite runs from base and a security reviewer agent reviews the PR; an external security review comes before the first external tenant (Decision 7). No owner approval label. Every PR to `crove-cloud` counts as protected |
| `cla` | Every PR | CLA Assistant: every commit author has signed the CLA (individual or corporate); the project's bot and agent accounts are allowlisted |
| `license` (optional) | New source files | `SPDX-License-Identifier` header matches ADR-0022 |
| `kernel-migration-lock` | PRs with a `_crove_kernel_` file | Fails if another open PR also has a `_crove_kernel_` file |
@@ -1546,7 +1546,7 @@ Only for **Crove Cloud**; self-host depends on none of these. Each item is a sep
Day 0 is behind us: the repo exists, is public and has the MVP. This list keeps what is still open; issue numbers are the tracking issues in this repo.
**A. Defaults already decided** (recorded in ADRs or Decisions; no question blocks code)
-- **Protected paths (G6):** PRs touching context, policy, authorization, GRANT/POLICY in migrations, workflows, hooks or infrastructure run the invariant suite from base and get a security reviewer agent review; an external security review comes before the first external tenant (Decision 7). No owner approval label. Recorded in `AGENTS.md`.
+- **Protected paths (G6):** PRs touching context, policy, authorization, migrations, dependencies, workflows, hooks or infrastructure run the invariant suite from base and get a security reviewer agent review; an external security review comes before the first external tenant (Decision 7). No owner approval label. Recorded in `AGENTS.md`.
- **License:** `AGPL-3.0-only` (declared in `README.md` and `package.json`); license-grant CLA based on the Apache ICLA/CCLA, reviewed by a lawyer before accepting outside PRs (ADR-0022, to write). The repo is public (Decision 9).
- **Release:** SemVer tags from `main`. Crove Cloud promotes through a workflow in `crove-cloud`, with a 1-click approval step.
- **Crove Cloud operations** (Supabase Pro + 7-day PITR on prod, budget, alert channel, domain `app.crove.com`): belong to `crove-cloud`, set up with MVP step 2; they do not block the core repo.
diff --git a/docs/plan/crove-os-kernel.vi.html b/docs/plan/crove-os-kernel.vi.html
index 6e10f5a0..5de40633 100644
--- a/docs/plan/crove-os-kernel.vi.html
+++ b/docs/plan/crove-os-kernel.vi.html
@@ -286,7 +286,7 @@ 4. Cổng trước khách ngoài
| T12 | Parse file (PDF, XML, MIME) trong sandbox crove-extract | Chưa làm Parse trong process; MIME parse trong API. | #28 |
| T13 | Xuất Excel giữ tiền chính xác và escape công thức | Đã xong PR #93: số tiền tối đa 15 chữ số có nghĩa ghi dạng số, dài hơn thì ghi dạng text giữ đúng từng chữ số; chữ được ghi thành ô text có định dạng Text nên chuỗi như =HYPERLINK(...) không chạy thành công thức; không thêm dấu nháy, vì trong xlsx dấu nháy sẽ thành một phần dữ liệu (-ABC, +84...). Chưa có export CSV; khi có thì phải dùng csvSafeText để thêm dấu nháy (theo OWASP). | #29 |
| T14 | Review bảo mật độc lập (Decision 7) và SECURITY.md | Chưa làm Chưa bắt đầu, chưa có SECURITY.md. | #31, #33 |
-| T15 | Workflow CI review vùng bảo vệ (bộ invariant từ nhánh gốc + security reviewer agent) | Chưa làm Mới có workflow ci và gitleaks. | #30 |
+| T15 | Workflow CI review vùng bảo vệ (bộ invariant từ nhánh gốc + security reviewer agent) | Một phần Đã có workflow protected: PR chạm vùng bảo vệ chạy bộ invariant (test/invariant/) lấy từ nhánh gốc. Job security reviewer agent chờ owner thêm secret ANTHROPIC_API_KEY (#95). Chưa là required check trên main. | #30, #95 |
| T16 | Bộ hồ sơ PDPL, luật sư duyệt | Chưa làm Chưa bắt đầu. | #62 |
| T17 | Mọi hàm crove_* ghim search_path; login role không có quyền TEMP | Đã xong PR #97: mỗi lần migrate đều kiểm tra lại, và chạy migrate thất bại nếu có hàm trong schema crove_* chưa ghim đúng search_path=pg_catalog, pg_temp, hoặc có role crove_* còn quyền TEMP. Migration mới thu hồi TEMP khỏi PUBLIC và các role Crove; Supabase Auth vẫn giữ quyền này. Có test cho cả hàm cài cắm lẫn chạy thử bằng docker compose. | #74 |
| T18 | Agent không tự nâng quyền hay rửa duyệt (ADR-0029) | Chưa làm Vị trí và agent chưa tồn tại; không phát hành duyệt bởi agent trước #75. | #79 |
diff --git a/packages/db/test/migrate.db.test.ts b/packages/db/test/invariant/migrate.db.test.ts
similarity index 98%
rename from packages/db/test/migrate.db.test.ts
rename to packages/db/test/invariant/migrate.db.test.ts
index fe0a5622..bd9c894d 100644
--- a/packages/db/test/migrate.db.test.ts
+++ b/packages/db/test/invariant/migrate.db.test.ts
@@ -1,14 +1,15 @@
// Hosted PostgreSQL runs migrations as a role that is not a superuser (CREATEROLE, owns the database).
// The built-in runner must apply every migration as such a role, run again cleanly, and every file must be
// re-applicable. Set CROVE_TEST_ADMIN_URL to a superuser URL; the test creates and drops its own database.
+// Part of the invariant suite: CI runs this file from the base branch on protected-path PRs (AGENTS.md).
import { randomUUID } from 'node:crypto';
import { fileURLToPath } from 'node:url';
import pg from 'pg';
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
-import { hardeningViolations, migrate, readMigrations } from '../src/migrate.ts';
+import { hardeningViolations, migrate, readMigrations } from '../../src/migrate.ts';
const ADMIN = process.env.CROVE_TEST_ADMIN_URL;
-const MIGRATIONS = fileURLToPath(new URL('../../../supabase/migrations', import.meta.url));
+const MIGRATIONS = fileURLToPath(new URL('../../../../supabase/migrations', import.meta.url));
const RUNNER = 'crove_test_runner';
// Roles are cluster-wide. When an earlier run already created them, the runner gets the ADMIN OPTION it
// would hold as their creator.
diff --git a/tools/ci/protected-paths.mjs b/tools/ci/protected-paths.mjs
new file mode 100644
index 00000000..7f4460b8
--- /dev/null
+++ b/tools/ci/protected-paths.mjs
@@ -0,0 +1,118 @@
+// Protected-path detection for the Decision 7 review flow (plan §10, gate G6; External-tenant gate T15).
+// No dependencies, one file: CI copies this script from the base branch before running it, so a PR cannot
+// change what counts as protected for its own run.
+//
+// Usage: node tools/ci/protected-paths.mjs
+// Prints the protected files changed between the merge base and head, and writes `protected=true|false`
+// to $GITHUB_OUTPUT and a table to $GITHUB_STEP_SUMMARY when those are set. Exits 0 either way; a git
+// failure throws, so the CI step fails closed.
+//
+// Detection uses paths only, never file contents: contents can be hidden from `git diff` (for example by a
+// `-diff` or `binary` attribute in .gitattributes), and keyword scans are easy to evade.
+import { execFileSync } from 'node:child_process';
+import { appendFileSync } from 'node:fs';
+import { pathToFileURL } from 'node:url';
+
+// Path rules. Each entry is [regex on the repo-relative path, reason].
+export const PROTECTED_PATHS = [
+ // CI, repository config and the agent harness.
+ [/^\.github\/workflows\//, 'CI workflow'],
+ [/^\.github\/actions\//, 'CI action'],
+ [/^\.github\/CODEOWNERS$/, 'code owners'],
+ [/^\.githooks\//, 'git hook'],
+ [/^tools\/install-git-hooks\.mjs$/, 'git hook installer'],
+ [/(^|\/)\.gitattributes$/, 'git attributes'],
+ [/^\.gitmodules$/, 'git submodules'],
+ [/^\.claude\/settings[^/]*$/, 'agent harness settings'],
+ [/^\.claude\/hooks\//, 'agent harness hook'],
+ [/^\.claude\/agents\//, 'agent definition'],
+ [/^\.mcp\.json$/, 'agent tool config'],
+ [/^CLAUDE\.md$/, 'agent policy'],
+ [/^AGENTS\.md$/, 'agent policy'],
+ // Dependencies and test runners: they decide what code and which tests run in CI.
+ // Every package.json: `exports`/`main`/`imports` can repoint a package the invariant suite imports,
+ // and `scripts`/`pnpm` fields run code, all without touching the lockfile.
+ [/(^|\/)package\.json$/, 'package manifest'],
+ [/^pnpm-lock\.yaml$/, 'lockfile'],
+ [/^pnpm-workspace\.yaml$/, 'workspace and dependency catalog'],
+ [/(^|\/)\.npmrc$/, 'package manager config'],
+ [/^vitest(\.[a-z]+)?\.(config|workspace)\.[cm]?[jt]s$/, 'test runner config'],
+ // This gate itself: the detection script and the invariant suite.
+ [/^tools\/ci\//, 'protected-path gate'],
+ [/(^|\/)test\/invariant\//, 'invariant suite'],
+ // Context, policy and authorization: the one write path, context entry, approval policy, identity, HTTP edge.
+ [/^packages\/kernel-runtime\/src\//, 'command runtime (context and authorization)'],
+ [/^packages\/db\/src\/context\//, 'database context'],
+ [/^packages\/db\/src\/index\.ts$/, 'database context guards'],
+ [/^packages\/db\/src\/migrate\.ts$/, 'migration runner and hardening guard'],
+ [/^packages\/kernel\/src\/(approval|identity)\.ts$/, 'approval policy or identity'],
+ [/(^|\/)auth(\/|\.ts$|-[^/]*\.ts$)/, 'authentication'],
+ [/^apps\/kernel\/src\/(auth|sessions)[^/]*\.ts$/, 'identity adapter'],
+ [/^apps\/kernel\/src\/http\.ts$/, 'HTTP edge (authentication, inbound secret)'],
+ // Schema: every migration change (G6 lists GRANT/POLICY/DEFINER/ROLE; any SQL can drop a trigger or
+ // replace a function, so all of them count).
+ [/^supabase\/migrations\//, 'migration'],
+ // SQL tests and legal documents.
+ [/(^|\/)_[^/]*\.test\.sql$/, 'SQL invariant test'],
+ [/^LICENSE$/, 'license'],
+ [/^CLA\.md$/, 'contributor agreement'],
+ [/(^|\/)agent-autonomy\.md$/, 'agent autonomy table'],
+];
+
+/**
+ * Classify changed paths.
+ * @param {string[]} paths changed paths (renames give both the old and the new path).
+ * @returns {Array<{ path: string, reason: string }>} the protected paths, in input order.
+ */
+export function classify(paths) {
+ const hits = [];
+ for (const path of paths) {
+ const rule = PROTECTED_PATHS.find(([re]) => re.test(path));
+ if (rule) hits.push({ path, reason: rule[1] });
+ }
+ return hits;
+}
+
+/** Parse `git diff --name-status -z` output: NUL-separated status, then one path (two for renames and copies). */
+export function parseNameStatusZ(out) {
+ const fields = out.split('\0');
+ const paths = [];
+ for (let i = 0; i < fields.length; ) {
+ const status = fields[i++];
+ if (!status) continue;
+ // Renames and copies list old and new path; both count, so a rename out of a protected path is caught.
+ const count = /^[RC]/.test(status) ? 2 : 1;
+ for (let n = 0; n < count && i < fields.length; n++) paths.push(fields[i++]);
+ }
+ return paths;
+}
+
+function main(argv) {
+ const [base, head] = argv;
+ if (!base || !head) {
+ console.error('usage: node tools/ci/protected-paths.mjs ');
+ process.exit(2);
+ }
+ const out = execFileSync('git', ['-c', 'core.quotePath=false', 'diff', '--no-ext-diff', '--name-status', '-z', '-M', `${base}...${head}`], {
+ encoding: 'utf8',
+ maxBuffer: 64 * 1024 * 1024,
+ });
+ const hits = classify(parseNameStatusZ(out));
+ const isProtected = hits.length > 0;
+
+ console.log(isProtected ? `Protected paths changed (${hits.length}):` : 'No protected paths changed.');
+ for (const h of hits) console.log(` ${h.path} (${h.reason})`);
+
+ if (process.env.GITHUB_OUTPUT) appendFileSync(process.env.GITHUB_OUTPUT, `protected=${isProtected}\n`);
+ if (process.env.GITHUB_STEP_SUMMARY) {
+ const rows = hits.map((h) => `| \`${h.path.replace(/[`|]/g, '?')}\` | ${h.reason} |`).join('\n');
+ appendFileSync(
+ process.env.GITHUB_STEP_SUMMARY,
+ isProtected
+ ? `### Protected paths changed\n\n| File | Why |\n| --- | --- |\n${rows}\n\n`
+ : '### No protected paths changed\n\nThe invariant suite and the security review are not required for this PR.\n\n',
+ );
+ }
+}
+
+if (import.meta.url === pathToFileURL(process.argv[1] ?? '').href) main(process.argv.slice(2));
diff --git a/tools/test/protected-paths.test.ts b/tools/test/protected-paths.test.ts
new file mode 100644
index 00000000..eda193dd
--- /dev/null
+++ b/tools/test/protected-paths.test.ts
@@ -0,0 +1,102 @@
+import { describe, expect, it } from 'vitest';
+import { classify, parseNameStatusZ } from '../ci/protected-paths.mjs';
+
+type Hit = { path: string; reason: string };
+const paths = (hits: Hit[]) => hits.map((h) => h.path);
+
+describe('protected-path detection (G6)', () => {
+ it('flags the G6 paths, the gate itself, dependencies and test runners', () => {
+ const protectedFiles = [
+ '.github/workflows/ci.yml',
+ '.github/CODEOWNERS',
+ '.githooks/pre-commit',
+ 'tools/install-git-hooks.mjs',
+ '.gitattributes',
+ 'supabase/.gitattributes',
+ '.gitmodules',
+ '.claude/settings.json',
+ '.claude/settings.local.json',
+ '.claude/hooks/guard.sh',
+ '.claude/agents/reviewer.md',
+ '.mcp.json',
+ 'CLAUDE.md',
+ 'AGENTS.md',
+ 'package.json',
+ 'apps/app/package.json',
+ 'packages/db/package.json',
+ 'vitest.workspace.ts',
+ 'pnpm-lock.yaml',
+ 'pnpm-workspace.yaml',
+ '.npmrc',
+ 'vitest.config.ts',
+ 'vitest.invariant.config.ts',
+ 'tools/ci/protected-paths.mjs',
+ 'apps/kernel/test/invariant/kernel-invariants.db.test.ts',
+ 'apps/kernel/test/invariant/fixtures/einvoice-basic.xml',
+ 'packages/db/test/invariant/migrate.db.test.ts',
+ 'packages/kernel-runtime/src/runtime.ts',
+ 'packages/db/src/context/enter.ts',
+ 'packages/db/src/index.ts',
+ 'packages/db/src/migrate.ts',
+ 'packages/kernel/src/approval.ts',
+ 'packages/kernel/src/identity.ts',
+ 'apps/kernel/src/auth.ts',
+ 'apps/kernel/src/sessions.ts',
+ 'apps/kernel/src/http.ts',
+ 'apps/web/src/auth/login.tsx',
+ 'supabase/migrations/20261016000000_crove_finance_add_column.sql',
+ 'supabase/tests/_rls_matrix.test.sql',
+ 'LICENSE',
+ 'CLA.md',
+ 'docs/generated/agent-autonomy.md',
+ ];
+ expect(paths(classify(protectedFiles))).toEqual(protectedFiles);
+ });
+
+ it('leaves ordinary code, docs and tests alone', () => {
+ const ordinary = [
+ 'README.md',
+ 'docs/plan/crove-os-kernel.md',
+ 'docs/plan/crove-os-kernel.vi.html',
+ 'apps/kernel/test/kernel.db.test.ts',
+ 'apps/kernel/src/rpc.ts',
+ 'apps/app/src/routes/inbox.tsx',
+ 'modules/finance/src/record.ts',
+ 'packages/kernel/src/commands.ts',
+ 'packages/l10n-vn/src/money.ts',
+ 'packages/l10n-vn/test/fixtures/einvoice-basic.xml',
+ 'tools/docs-check.mjs',
+ '.claude/worktrees/x/AGENTS.md',
+ 'apps/kernel/src/authority-notes.md',
+ ];
+ expect(classify(ordinary)).toEqual([]);
+ });
+
+ it('parses NUL-separated name-status, keeps non-ASCII paths, and counts both sides of a rename', () => {
+ const out = [
+ 'R100',
+ 'apps/kernel/test/invariant/kernel-invariants.db.test.ts',
+ 'apps/kernel/test/moved.db.test.ts',
+ 'M',
+ 'README.md',
+ 'A',
+ 'apps/kernel/test/invariant/hóa đơn "x".db.test.ts',
+ 'D',
+ '.github/workflows/protected.yml',
+ '',
+ ].join('\0');
+ const changed = parseNameStatusZ(out);
+ expect(changed).toEqual([
+ 'apps/kernel/test/invariant/kernel-invariants.db.test.ts',
+ 'apps/kernel/test/moved.db.test.ts',
+ 'README.md',
+ 'apps/kernel/test/invariant/hóa đơn "x".db.test.ts',
+ '.github/workflows/protected.yml',
+ ]);
+ expect(paths(classify(changed))).toEqual([
+ 'apps/kernel/test/invariant/kernel-invariants.db.test.ts',
+ 'apps/kernel/test/invariant/hóa đơn "x".db.test.ts',
+ '.github/workflows/protected.yml',
+ ]);
+ });
+});
diff --git a/vitest.config.ts b/vitest.config.ts
index fa17d4b1..12535e96 100644
--- a/vitest.config.ts
+++ b/vitest.config.ts
@@ -6,7 +6,7 @@ export default defineConfig({
{
test: {
name: 'unit',
- include: ['packages/*/test/**/*.test.ts', 'modules/*/test/**/*.test.ts', 'apps/kernel/test/**/*.test.ts', 'apps/app/test/**/*.test.ts'],
+ include: ['packages/*/test/**/*.test.ts', 'modules/*/test/**/*.test.ts', 'apps/kernel/test/**/*.test.ts', 'apps/app/test/**/*.test.ts', 'tools/test/**/*.test.ts'],
exclude: ['**/*.db.test.ts', '**/node_modules/**'],
},
},
diff --git a/vitest.invariant.config.ts b/vitest.invariant.config.ts
new file mode 100644
index 00000000..6cd4d4ea
--- /dev/null
+++ b/vitest.invariant.config.ts
@@ -0,0 +1,17 @@
+// Runner config for the invariant suite (Decision 7, External-tenant gate T15). On protected-path PRs,
+// .github/workflows/protected.yml takes this file and every test/invariant/ directory from the base branch
+// and runs them against the PR's code, so the PR's own vitest.config.ts cannot exclude or weaken them.
+import { fileURLToPath } from 'node:url';
+import { defineConfig } from 'vitest/config';
+
+export default defineConfig({
+ test: {
+ root: fileURLToPath(new URL('.', import.meta.url)),
+ include: ['apps/*/test/invariant/**/*.test.ts', 'packages/*/test/invariant/**/*.test.ts', 'modules/*/test/invariant/**/*.test.ts'],
+ exclude: ['**/node_modules/**'],
+ fileParallelism: false,
+ testTimeout: 30_000,
+ // An empty suite must fail the gate; the workflow also fails when any invariant test is skipped.
+ passWithNoTests: false,
+ },
+});