Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions docs/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,12 @@ entry. See `CONTRIBUTING.md` § Releases & changelog.
host-global single-flight. New env vars documented in `middleware/.env.example`:
`CLI_TOOLS_DIR`, `CODEX_HOME`.

### Fixed — handoff plans now stay inside the package after symlinks and fail closed on declared dry runs (#470 C15)

- `middleware/src/platform/pluginHandoffPlan.ts` now re-checks `permissions.sql.handoff` containment after resolving real paths for BOTH the package root and the target, closing the two escapes PR #815 left open: a file symlink inside the package pointing outside, and a directory symlink inside the package whose child path points outside. Missing targets still refuse as `unreadable`, not as an escape, so the operator still hears "the package does not ship this file" for the case they can actually fix.
- The same loader now refuses `"dryRun": true` in a kernel-run handoff plan. Preview mode belongs to `middleware/scripts/plugin-ledger-handoff.mjs --dry-run`; if core honoured a plan-level dry run it would write nothing, then immediately let its own migration runner apply every file, silently recreating the exact G7 failure C15 exists to remove.
- Regression locks now cover the two fail-closed properties the feature lives or dies on: a witness that fails at the database aborts activation before the migration runner can run, and a manifest whose SQL grant no longer matches its declared ledger is treated as ungranted so neither the handoff nor the runner can reach the database.

### Fixed — core-decoupling zero floor no longer hides same-named files (#470 C13 review)

- `scripts/check-core-decoupling.mjs` now excludes only the exact detector path `scripts/check-core-decoupling.mjs` instead of any basename match, closing the hole where a same-named file dropped under `middleware/src/` could hide Dev Platform identifiers from the permanent zero floor. A colocated regression test proves the detector stays self-excluded while a probe file at `middleware/src/__probe/check-core-decoupling.mjs` is counted.
Expand Down
46 changes: 46 additions & 0 deletions middleware/packages/plugin-api/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,52 @@ Versioning is SemVer over the **exported type surface**. Removing or narrowing
an exported type, or adding a required member to an interface a plugin
implements, is a major.

## 1.6.0 — 2026-08-21

Additive. `permissions.sql` gains an optional `handoff` — a path, inside the
package, to the JSON plan the kernel runs BEFORE it runs the plugin's
migrations directory (epic #470, C15).

### Added

- **`SqlPermission.handoff?: string`** — names a plan of the same shape
{@link SqlAccessor.seedLedger} accepts:
`{ "entries": [{ "filename", "witnessSql" }], "dryRun"?: false }`. The kernel
reads it at activation, validates it against the package root, and performs
the handoff through the same seeder — read-only witness fence, advisory lock
and entry validation included — before its own migration runner. A shared
file MAY carry `"dryRun": false` for the operator CLI's benefit; the kernel
refuses `"dryRun": true`, because a preview that writes nothing would hand
every file straight to the migration runner below. Use the CLI's
`--dry-run` / `--apply` flags to preview or apply.

### Why a declaration and not the call C11 already shipped

`seedLedger` was documented as "call this BEFORE `runMigrations`", and a plugin
cannot honour that. The kernel runs the migrations directory ITSELF, before
`activate()`, so that "the tables exist" is an invariant `activate()` can rely
on rather than a race each plugin re-loses in its own way. The plugin's own
call therefore always arrived second, after every ledger row was already
written.

The 2026-08-21 acceptance run of the first extracted plugin measured the
consequence on the exact upgrade C11 exists for: `0 seeded, 9 already seeded`,
with `skippedNoWitness` — the one alarm the feature was built to raise —
unreachable. Nothing failed, and that is the problem: the line is
indistinguishable from a healthy re-run.

The witnesses are knowledge only the plugin has; the ordering is a decision
only the kernel can make. So the plugin declares and the kernel executes.

### Compatibility

Every existing consumer keeps compiling: `handoff` is optional, and a plugin
that omits it sees the pre-1.6.0 behaviour exactly. `SqlAccessor.seedLedger`
is unchanged and stays the right call for a plugin that manages its own
ordering or must work against a kernel older than this one — against a kernel
that honours `handoff`, that call simply reports `alreadySeeded`, which is what
it should report once the work is done.

## 1.5.0 — 2026-08-21

> **Why 1.4.0 and not 1.3.0.** This change was written against a tree where
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1792,6 +1792,7 @@ constructor(agentId: string, fromVersion: string, toVersion: string, cause: unkn
export interface SqlPermission {
readonly migrations?: string;
readonly ledger: string;
readonly handoff?: string;
}
export interface MigrationReport {
readonly applied: readonly string[];
Expand Down
2 changes: 1 addition & 1 deletion middleware/packages/plugin-api/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@omadia/plugin-api",
"version": "1.5.0",
"version": "1.6.0",
"private": true,
"type": "module",
"main": "dist/index.js",
Expand Down
32 changes: 32 additions & 0 deletions middleware/packages/plugin-api/src/pluginContext.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1927,6 +1927,7 @@ export class MigrationHookError extends Error {
* sql:
* migrations: migrations # optional; directory inside the package
* ledger: omadia_verifier_migrations
* handoff: handoff-plan.json # optional; run before `migrations`
* ```
*/
export interface SqlPermission {
Expand All @@ -1938,6 +1939,37 @@ export interface SqlPermission {
* Must match `^[a-z][a-z0-9_]{2,62}$` AND begin with the plugin's sanitized
* id, so a manifest cannot nominate another plugin's ledger. */
readonly ledger: string;
/**
* Path (relative to the package root) to a JSON ledger-handoff plan the
* kernel runs BEFORE {@link SqlPermission.migrations} — epic #470 C15.
*
* ```json
* {
* "entries": [
* { "filename": "0001_x.js", "witnessSql": "SELECT to_regclass('public.x') IS NOT NULL" }
* ],
* "dryRun": false
* }
* ```
*
* Same shape {@link SqlAccessor.seedLedger} accepts, and the same shape the
* operator CLI (`middleware/scripts/plugin-ledger-handoff.mjs --plan`)
* reads, so one file serves all three readers. A shared file MAY carry
* `"dryRun": false`; `"dryRun": true` is refused on the kernel-run path.
* Preview mode belongs to the CLI flag, not to plugin data: if core read a
* plan that asked it to "write nothing", then core's own migration runner
* would immediately apply every file underneath it, silently recreating the
* exact "0 seeded, 9 already seeded" failure C15 exists to remove.
*
* DECLARE THIS RATHER THAN CALLING `seedLedger` YOURSELF when the manifest
* also declares `migrations`. The kernel runs that directory before your
* `activate()`, so a `seedLedger` call inside `activate()` arrives after
* every ledger row is already written and can only ever report
* `alreadySeeded` — the `skippedNoWitness` alarm never fires. Keeping the
* in-`activate` call as well is safe and is the right fallback for older
* kernels, where it does the work instead.
*/
readonly handoff?: string;
}

/** What one `runMigrations` pass did. Returned rather than logged so a plugin
Expand Down
69 changes: 66 additions & 3 deletions middleware/scripts/plugin-ledger-handoff.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,39 @@
* `migrationsDir` is resolved relative to the plan file, so a plan shipped
* inside a plugin package works from wherever the operator copied it.
*
* ONE PLAN, THREE READERS (epic #470 C15)
* ---------------------------------------
* The same JSON is read by three things:
*
* 1. this CLI, via `--plan`;
* 2. the kernel, when a manifest declares `permissions.sql.handoff`
* (`middleware/src/platform/pluginHandoffPlan.ts`);
* 3. a plugin that manages its own ordering, via `ctx.sql.seedLedger`.
*
* `entries` (and the optional `dryRun`) are what all three consume, and they
* are exactly `SeedLedgerOptions`. `pluginId`, `ledger` and `migrationsDir`
* are for THIS tool alone: it runs with no manifest, so it has to be told
* them. The kernel knows all three authoritatively and deliberately ignores
* the file's copies — a plan that could redirect the write would undo the
* grant matching the manifest — though it does WARN when the plan's `ledger`
* disagrees with the manifest's, because then the table an operator previewed
* here is not the table the kernel is about to write.
*
* So a plan shipped inside a package for the manifest carries only `entries`,
* and this tool reads that same file when the three missing fields are
* supplied as flags:
*
* node middleware/scripts/plugin-ledger-handoff.mjs \
* --plan node_modules/@vendor/thing/handoff-plan.json \
* --plugin-id @vendor/thing \
* --ledger plg_vendor_thing_migrations \
* --migrations-dir migrations
*
* The kernel's reader is STRICTER than this one: it rejects unknown keys
* (notably `dir`, which `SeedLedgerOptions` accepts and the kernel refuses to
* honour) and caps the file size. A plan that the kernel accepts always works
* here; the reverse is not guaranteed.
*
* Exit codes: 0 = plan computed (or applied), 1 = the handoff refused,
* 2 = usage / plan-file error.
*
Expand All @@ -71,6 +104,14 @@ Usage: node middleware/scripts/plugin-ledger-handoff.mjs --plan <file.json> [opt
--apply Actually write the ledger rows. Default is a dry run.
--database-url <url> Overrides $DATABASE_URL.
--json Machine-readable output.

For a plan shipped inside a package for 'permissions.sql.handoff', which
carries only 'entries', supply the three fields the manifest would have
told the kernel:

--plugin-id <id> e.g. @vendor/thing
--ledger <table> e.g. plg_vendor_thing_migrations
--migrations-dir <dir> resolved relative to the plan file
`;

function parseArgs(argv) {
Expand All @@ -82,6 +123,9 @@ function parseArgs(argv) {
else if (arg === '--dry-run') args.apply = false;
else if (arg === '--plan') args.plan = argv[(i += 1)];
else if (arg === '--database-url') args.databaseUrl = argv[(i += 1)];
else if (arg === '--plugin-id') args.pluginId = argv[(i += 1)];
else if (arg === '--ledger') args.ledger = argv[(i += 1)];
else if (arg === '--migrations-dir') args.migrationsDir = argv[(i += 1)];
else if (arg === '--help' || arg === '-h') args.help = true;
else fail(`unknown argument '${arg}'`);
}
Expand All @@ -93,7 +137,7 @@ function fail(msg) {
process.exit(2);
}

function loadPlan(planPath) {
function loadPlan(planPath, args) {
let raw;
try {
raw = readFileSync(planPath, 'utf8');
Expand All @@ -106,9 +150,28 @@ function loadPlan(planPath) {
} catch (err) {
fail(`plan '${planPath}' is not valid JSON: ${err.message}`);
}
// Epic #470 C15 — the same file may also be the one a manifest names in
// `permissions.sql.handoff`, and that reader knows the plugin, the ledger
// and the directory authoritatively, so a package-shipped plan carries only
// `entries` (and optionally `dryRun`). This tool has no manifest, so it
// still needs all three — but they may now come from flags instead of from
// the file. That is what lets ONE plan serve both readers: forcing a plugin
// to ship two files would let the one an operator previews drift from the
// one the kernel runs.
if (typeof args.pluginId === 'string' && args.pluginId.length > 0) {
plan.pluginId = args.pluginId;
}
if (typeof args.ledger === 'string' && args.ledger.length > 0) {
plan.ledger = args.ledger;
}
if (typeof args.migrationsDir === 'string' && args.migrationsDir.length > 0) {
plan.migrationsDir = args.migrationsDir;
}
for (const field of ['pluginId', 'ledger', 'migrationsDir']) {
if (typeof plan[field] !== 'string' || plan[field].length === 0) {
fail(`plan is missing a non-empty '${field}'`);
fail(
`plan is missing a non-empty '${field}' — add it to the plan file, or pass --${field.replace(/[A-Z]/g, (c) => `-${c.toLowerCase()}`)}`,
);
}
}
if (!Array.isArray(plan.entries) || plan.entries.length === 0) {
Expand Down Expand Up @@ -181,7 +244,7 @@ async function main() {
fail('set DATABASE_URL or pass --database-url');
}

const plan = loadPlan(args.plan);
const plan = loadPlan(args.plan, args);
const pool = new pg.Pool({ connectionString, max: 2 });
try {
const result = await seedPluginLedgerFromDonor({
Expand Down
Loading
Loading