Skip to content

refactor(plugins): delete the never-provided ctx.devJobs surface (epic #470 C2a) - #555

Merged
Weegy merged 8 commits into
mainfrom
epic/470-c2-devjobs-service
Aug 12, 2026
Merged

refactor(plugins): delete the never-provided ctx.devJobs surface (epic #470 C2a)#555
Weegy merged 8 commits into
mainfrom
epic/470-c2-devjobs-service

Conversation

@Weegy

@Weegy Weegy commented Jul 30, 2026

Copy link
Copy Markdown
Contributor

Deletes a published plugin surface that no code has ever provided. Ratchet 3,303 → 3,220 (−83).

What was dead

ctx.devJobs resolves its host service lazily, per call — and provide('devJobs', …) exists nowhere in middleware/src. So every invocation throws dev-platform host service unavailable. Verified: zero permissions.devJobs declarations in this repo, in omadia-byte5-plugins (six production private plugins), or in any sibling repo. No consumer has ever existed.

Five layers were independently missing: no provider, no grant writer (DevRepoPluginGrantStore was never constructed, so dev_repo_plugin_grants had no writer at all), no grant API, no consent surface, no consumer.

Deleted

PluginContext.devJobs + the six DevJob* types from @omadia/plugin-api · the permissions.devJobs gate, DevJobsHostService and createPluginDevJobsAccessor from platform/pluginContext.ts · the manifest parse and its two summary fields · the dev_jobs / dev_jobs_repos_hint admin DTO fields · devRepoPluginGrantStore.ts (whole file) · pluginDevJobsAccessor.test.ts (whole file).

Types moved to a local src/devplatform/devJobTypes.ts — not a new published package — so they travel with the tree at P4. src/devplatform/types.ts already owned DevJobKind/DevJobStatus as as const unions backing the runtime validators, so the new file re-exports rather than redefines. DevJobCreateRequest had no surviving consumer and was dropped outright.

devJobsHostService.ts survives and shrinks — 163 → 113 lines, deps six → one. chatDevJobService calls exactly getJob, listJobs, listJobEvents; everything else was plugin-only and the chat surface was already faking it (it passed async () => [] for grants and a finalize that unconditionally threw — which is itself the proof those paths were never live).

Migrations 0024/0025 kept and annotated as knowingly orphaned. Forward-only repo.

Scope

Pure deletion. No package manifest, no serviceRegistry.ts, no builder codegen, no permission gate added to ctx.services.

That is deliberate. A previous attempt bundled this with a capability gate on services.get and was rejected: three private byte5 integration plugins call ctx.services.provide() while declaring provides: [], so they would have failed to activate outright — and channel-teams resolves graphTenantId with a ?? 'default' fallback, so a silently-undefined get would have written graph data under the wrong tenant id. Data corruption, not an outage.

The gate is still needed, and it remains a real hole — but it is not a prerequisite for this deletion. Both ctx.devJobs and ctx.services.get('devJobs') already yield nothing today, so removing the accessor opens no path that was not already open. The gate becomes a prerequisite before the extracted plugin registers devJobs (P4), and it needs a compat path for out-of-repo plugins.

Backward compatibility

A manifest still declaring permissions.devJobs installs and activates cleanly with ctx.devJobs === undefined. Unknown manifest keys being ignored was implicit — nothing stated it — so manifestDevJobsLegacyKey.test.ts now asserts it explicitly, in both the true and { repos_hint: [...] } forms, so a future strict-validation change cannot silently start rejecting stale manifests.

Test plan

  • 5,290 pass / 0 fail; npx tsc --noEmit clean; workspace typecheck (31 packages) exit 0; build exit 0
  • Ratchet 3,303 → 3,220; four zones fell, none rose
  • Chat path proven green: devJobOrchestratorTool.test.ts (19 tests incl. the authorization suite), chatDevJobToolWiring.test.ts, chatDevJobService.pg.test.ts
  • Verified no manifest, no serviceRegistry.ts, no codegen touched — every installed plugin resolves exactly what it resolved before
  • web-ui/app/_lib/storeTypes.ts needed no change: its PluginPermissionsSummary never mirrored dev_jobs (nor mcp), which independently corroborates the "no consent surface" finding

Two intermittent full-suite failures were investigated rather than assumed: different tests each run, both green in isolation, and a stash-rebuild-baseline run was clean — the documented load-sensitivity, not this change.


View with [code]smith Autofix with [code]smith
Need help on this PR? Tag @codesmith-bot with what you need. Autofix is disabled.

Weegy added 8 commits July 30, 2026 16:57
`ctx.devJobs` was a published plugin accessor that nothing ever provided.
It resolved its host service lazily per call, so every invocation threw
"dev-platform host service unavailable". No manifest in this repo, in the
private byte5 plugin set, or in any sibling repo ever declared
`permissions.devJobs`, so no consumer has ever existed.

Per specs/470-dev-platform-plugin/dormant-capabilities.md section 2 this is
a pure deletion of an unreachable surface. No permission gate is added to
ctx.services: `provide("devJobs", ...)` exists nowhere, so both `ctx.devJobs`
and `ctx.services.get("devJobs")` already yield nothing. Removing the
accessor opens no hole. No package manifest, serviceRegistry or builder
codegen is touched, and no installed plugin changes behaviour.

Deleted:
- plugin-api: the `devJobs?` field on PluginContext and the six types
  DevJobKind, DevJobStatus, DevJobDescriptor, DevJobCreateRequest,
  DevJobEventRecord, DevJobsAccessor
- host pluginContext: the permissions gate, the context spread,
  DevJobsHostService and createPluginDevJobsAccessor
- manifestLoader: the `permissions.devJobs` parse and its two summary fields
- admin-v1: the dev_jobs / dev_jobs_repos_hint DTO fields
- devRepoPluginGrantStore.ts and pluginDevJobsAccessor.test.ts (whole files)

The descriptor/event view types survive core-locally in
src/devplatform/devJobTypes.ts, which travels with the dev-platform tree when
it moves. DevJobKind/DevJobStatus are re-exported from src/devplatform/types.ts
rather than redefined.

devJobsHostService survives for the chat surface but sheds everything that
existed only for the plugin path: listGrantedRepoIds and the grants dep,
the plugin-shaped createJob (and with it repoStore, resolveJobPlacement and
mintRunnerToken), and cancelJob's requestedByPluginId creator check with its
finalize dep. chatDevJobService only ever called getJob, listJobs and
listJobEvents; it passed inert stubs for the rest.

Migrations 0024 (dev_repo_plugin_grants) and 0025 (source='plugin') are kept
- the migration set is forward-only - and are annotated as knowingly orphaned
so a future reader does not read the schema as evidence of a live feature.

Back-compat: a stale manifest still declaring `permissions.devJobs` installs
and activates unchanged. Unknown permission keys are ignored today; that was
implicit, and test/manifestDevJobsLegacyKey.test.ts now asserts it explicitly
so a future strict-validation change cannot break stale manifests silently.
…ervice

# Conflicts:
#	specs/470-dev-platform-plugin/decoupling-baseline.json
…ervice

# Conflicts:
#	specs/470-dev-platform-plugin/decoupling-baseline.json
The test/ typecheck ratchet (#573, landed after this branch was last
touched) flags 4 errors in test/manifestDevJobsLegacyKey.test.ts:

- `createPluginContext` gained two required options (notificationRouter,
  uiRouteCatalog). They are only dereferenced inside lazy accessors, so
  the test passed at runtime while being type-unsound. Stubbed both.
- Three `as Record<string, unknown>` casts were unsound (TS2352). Two are
  unnecessary — `mcp` is a declared field on PluginPermissionsSummary.
  The key-absence checks now use `Object.hasOwn`, which asserts the key
  is absent rather than merely undefined.

Ratchet baseline unchanged at 406: no new debt. Decoupling baseline
resynced against current main, 3,448 -> 3,362.
…ervice

# Conflicts:
#	specs/470-dev-platform-plugin/README.md
#	specs/470-dev-platform-plugin/acceptance.md
#	specs/470-dev-platform-plugin/decoupling-baseline.json
#554 (C5) landed, so the shared counter moved under this branch. Both
PRs reduce disjoint sets of references: C2a still removes exactly 86,
the same delta it removed against the pre-#554 main.

Also fixes doc drift the merge exposed: README and acceptance.md both
still quoted 3,167 on main while the committed baseline was already
3,312 — the number is in three places and #554 updated only the JSON.
All three now read 3,226.
@Weegy
Weegy merged commit 7e00ce9 into main Aug 12, 2026
9 checks passed
Weegy added a commit that referenced this pull request Aug 12, 2026
…,288

C5 (#554) and C2a (#555) both landed, taking main's baseline to 3,226.
C3's own delta is unchanged: +62 (middleware/src +29, middleware/test
+33), so the hand-raised baseline is 3,288.

That +62 has now measured identically against three successive mains —
before #554, after #554, after #555. It is C3's own concentration
effect, not drift picked up from whatever else landed, and the README
now says so instead of quoting a single merge-time measurement.

Also fixes the baseline number in acceptance.md, which still read 3,365.
The number lives in three places — the JSON, the README, and the
acceptance ratchet row — and nothing checks that they agree.
Weegy added a commit that referenced this pull request Aug 12, 2026
… C3) (#557)

* refactor(dev-platform): one-way layering + namespaced config (C3)

* chore(470): hand-raise the ratchet baseline to 3,365 for C3

FIRST TIME the baseline rises for OUR OWN change rather than for
something main added. That deserves the explanation to be in the diff,
which is why the script refuses `--update` here and forces a hand-edit.

Why it rose (+59): the refactor collapses 41 flat DEV_*/FLY_* config
keys into one `config.devPlatform` object. The zod schema is unchanged —
proven byte-identical, 562 lines, empty diff — so every key name still
appears there, and the new mapping layer that builds the namespace names
each one a SECOND time. Net: config.ts +48, the new devplatform/config.ts
+36, against index.ts −33 and wireDevPlatform.ts −8. The eight moved
routers are a wash: same zone, different path.

Why it is worth taking: index.ts drops from ~20 threaded config values
to one argument, and the layering arrow now points one way (zero
src/devplatform → src/routes edges, zero src/routes → devplatform).
Both make the P4 file-move mechanical instead of a rewrite. And every
one of those +59 references is deleted at extraction — the schema keys,
the mapping, and the type file all go with the tree.

The honest tension: the ratchet counts identifiers, and this refactor
concentrates identifiers rather than removing them. Letting that block a
correct refactor would be the measurement driving the work. Documented
in README so the next raise is judged against the same two criteria and
nothing wider.

* chore(470): re-justify the C3 baseline raise against current main — 3,510

Merging main moved the numbers: the hand-raised 3,365 was measured against
a main that has since grown. Re-measured, C3's own delta is +62 over
main's 3,448 (middleware/src +29, middleware/test +33), not the +59
recorded when the branch was last touched.

The src half is unchanged in kind: 41 flat DEV_*/FLY_* keys collapse into
one `config.devPlatform` object, and the mapping layer names each key a
second time.

The test half was never written down. It is the same effect — a shared
`devPlatformConfig.harness.ts` plus the moved routers' new
`src/devplatform/routes/…` import paths. Checked rather than asserted:
all 212 added matching lines in the test zone are inside
`middleware/test/devplatform/`, none outside. No core test acquired a
dev-platform dependency, which is the only thing a rise is allowed to
mean here.

README updated to 3,510 with the test-zone reasoning, so the next raise
is judged against the same two criteria.

* chore(470): re-justify the C3 baseline raise onto main after #555 — 3,288

C5 (#554) and C2a (#555) both landed, taking main's baseline to 3,226.
C3's own delta is unchanged: +62 (middleware/src +29, middleware/test
+33), so the hand-raised baseline is 3,288.

That +62 has now measured identically against three successive mains —
before #554, after #554, after #555. It is C3's own concentration
effect, not drift picked up from whatever else landed, and the README
now says so instead of quoting a single merge-time measurement.

Also fixes the baseline number in acceptance.md, which still read 3,365.
The number lives in three places — the JSON, the README, and the
acceptance ratchet row — and nothing checks that they agree.
@Weegy
Weegy deleted the epic/470-c2-devjobs-service branch August 14, 2026 06:52
Weegy added a commit that referenced this pull request Aug 20, 2026
#783)

* feat(#470): grant-gate ctx.services.get and cut plugin-api 1.0.0 (C2b)

Closes the second half of C2 (epic #470 Phase A): the G8 contract break and
bug B1.

G8 — the contract break, taken once, deliberately.
C2a (#555) already deleted `ctx.devJobs` and moved the `DevJob*` view types
out of `@omadia/plugin-api` into `middleware/src/devplatform/devJobTypes.ts`,
where they travel with the extraction. This records that break and cuts the
package at 1.0.0: there is no installed base, nothing is published to npm, and
every consumer is a repository we control, so the break is cheap now and
expensive later (`implementation.md` §1 row 4). Adds a CHANGELOG naming the
removed types, so a consumer grepping its own source for `DevJobDescriptor`
lands on the migration note. `harness-channel-api` pinned `^0.1.0` and would
have failed to resolve against the bump; repinned to `*` like its siblings.

B1 — `ctx.services.get` was a bare pass-through.
Any installed plugin could resolve any registered service, `graphPool`
included, with no manifest declaration and nothing in the install dialog.
`serviceRegistry.ts`'s own header conceded it: "enforcement lives at the
consumer seam". The seam now exists. `get` resolves only capability names the
plugin declares in `requires:` (or `provides:`, to read back its own
registration) and throws the new typed `ServiceNotDeclaredError` otherwise,
naming the capability and the manifest field that would grant it. `has` stays
ungated — existence is not a capability.

Removing `ctx.devJobs` without this would have converted a permission-gated,
kernel-attributed accessor into an ungated, self-attributed one (§2.2). So
`provide` also accepts `perCallerService(factory)`: the kernel invokes the
factory with the id it activated the consumer under, never with an argument
the consumer supplies. The factory is a symbol-branded object, so a service
that is itself a function cannot be mistaken for one; value providers are
untouched.

A call-site audit across this repo's built-in plugin packages and all ten
standalone plugin repos found 27 (plugin, capability) pairs consumed without
being declared — a fail-closed gate in one step would have broken every
shipped plugin. Those exact pairs sit behind a dated, frozen, per-plugin
allowlist: they warn once and resolve, everything else fails closed. The
allowlist is closed in both directions — a different plugin asking for the
same name still throws, and an allowlisted plugin asking for a new name still
throws.

Counter-proof: reverting the gate to `return serviceRegistry.get<T>(name)`
fails 9 of the 25 new tests; restoring it passes all 25.

Ratchet 3296 → 3300, hand-raised for `middleware/packages` only. All five
lines are the new CHANGELOG documenting a removal; three of them are literal
strings that cannot be reworded (a spec path, a test filename, the future
package name). The first measurement was +31 — the avoidable 26 were reworded
away rather than excused, leaving `middleware/test` at its 1,030 baseline.
Justification recorded in the baseline JSON, README and acceptance.md.

Also corrects plan.md §4.2, which still claimed the `DevJob*` types stay in
core — `implementation.md` §2.5 had already flagged it as contradicting §4.1,
and shipped code now settles it.

* fix(#470 C2b): close three gaps in the ctx.services.get grant gate

Cross-family review of PR #783 found the gate correct in shape but
incomplete in three ways that a passing test suite did not surface.

1. The dated legacy allowlist was missing 21 (plugin, capability) pairs
   that are resolved today, 9 of them on @omadia/orchestrator itself.
   `harness-orchestrator/src/plugin.ts:452` reads 'llmProviderCatalog'
   unconditionally near the top of activate(), so the gate as merged
   would have thrown ServiceNotDeclaredError and killed the chat
   orchestrator at boot. The first audit missed these because the names
   sit behind exported constants (PROCESS_MEMORY_SERVICE_NAME,
   PLUGIN_CAPABILITIES_SERVICE, CHANNEL_RESOLVER_SERVICE, ...) rather
   than string literals, and because some channel plugins resolve
   capabilities through shared @omadia/channel-sdk helpers instead of a
   literal call site in their own source.

2. Nothing derived the allowlist from the repository, so the miss above
   was invisible to CI. test/pluginServiceGrantCoverage.test.ts now
   walks every middleware/packages/*/manifest.yaml, resolves each
   `services.get` argument through the TypeScript checker (literals and
   const identifiers alike, comments excluded), and fails when a name is
   neither declared nor allowlisted. It found three further gaps on
   @omadia/ui-orchestrator that hand analysis had also missed. A second
   case fails on stale built-in rows so the ramp cannot rot.

3. Only ctx.services.get passed the caller. Every other plugin-facing
   surface — ctx.memory, ctx.entities, ctx.mcp, ctx.subAgents, ctx.llm,
   ctx.events — still called serviceRegistry.get(name) and therefore
   silently received KERNEL_SERVICE_CALLER, so a perCallerService
   provider would have handed the plugin the kernel-scoped instance.
   That is the exact self-attribution failure the feature exists to
   prevent. The frozen ServiceCaller built in createPluginContext is now
   threaded through all six.

Also: perCallerService documented "one implementation per consuming
plugin" while invoking the factory on every .get(). Resolution is now
memoized by factory object then by caller.pluginId, which makes the
documented contract true and self-invalidates on provider replacement
because a replaced provider is a different object.

The gate test fixture stored `manifest: {}`, but memoryDeclared reads
permissions off the catalog entry's unparsed manifest — so ctx.memory
was undefined regardless of what the fixture declared and the new
attribution test asserted on nothing. The fixture now carries the raw
manifest document.

Ratchet unchanged at 3300; no banned strings added.

* chore(#470 C2b): regenerate the plugin-api golden snapshot after C1 landed

PR #780 (C1) merged to main while this branch was in review, so the
snapshot the gate compares against now exists. Merged origin/main and
regenerated it with `npm run api:update -w packages/plugin-api`.

The diff is exactly this PR's intended surface change and nothing else:
ServiceCaller, PerCallerFactory, perCallerService, isPerCallerService,
resolvePerCallerService and ServiceNotDeclaredError added, and
ServicesAccessor.provide/replace widened to accept a PerCallerFactory.
No removals — the DevJob* types were already gone from `src/` before
either branch, leaving only a tombstone comment that the snapshot
strips. So this PR's own surface change is additive; the 0.1.0 -> 1.0.0
bump is the deliberate departure from 0.x, not a break in this diff.

npm run api:check -w packages/plugin-api  ✓ up to date
npm test -w packages/plugin-api           1/1
Weegy added a commit that referenced this pull request Aug 20, 2026
…locked plugin migrations (epic #470 C7/G4) (#787)

* feat(#470): grant-gate ctx.services.get and cut plugin-api 1.0.0 (C2b)

Closes the second half of C2 (epic #470 Phase A): the G8 contract break and
bug B1.

G8 — the contract break, taken once, deliberately.
C2a (#555) already deleted `ctx.devJobs` and moved the `DevJob*` view types
out of `@omadia/plugin-api` into `middleware/src/devplatform/devJobTypes.ts`,
where they travel with the extraction. This records that break and cuts the
package at 1.0.0: there is no installed base, nothing is published to npm, and
every consumer is a repository we control, so the break is cheap now and
expensive later (`implementation.md` §1 row 4). Adds a CHANGELOG naming the
removed types, so a consumer grepping its own source for `DevJobDescriptor`
lands on the migration note. `harness-channel-api` pinned `^0.1.0` and would
have failed to resolve against the bump; repinned to `*` like its siblings.

B1 — `ctx.services.get` was a bare pass-through.
Any installed plugin could resolve any registered service, `graphPool`
included, with no manifest declaration and nothing in the install dialog.
`serviceRegistry.ts`'s own header conceded it: "enforcement lives at the
consumer seam". The seam now exists. `get` resolves only capability names the
plugin declares in `requires:` (or `provides:`, to read back its own
registration) and throws the new typed `ServiceNotDeclaredError` otherwise,
naming the capability and the manifest field that would grant it. `has` stays
ungated — existence is not a capability.

Removing `ctx.devJobs` without this would have converted a permission-gated,
kernel-attributed accessor into an ungated, self-attributed one (§2.2). So
`provide` also accepts `perCallerService(factory)`: the kernel invokes the
factory with the id it activated the consumer under, never with an argument
the consumer supplies. The factory is a symbol-branded object, so a service
that is itself a function cannot be mistaken for one; value providers are
untouched.

A call-site audit across this repo's built-in plugin packages and all ten
standalone plugin repos found 27 (plugin, capability) pairs consumed without
being declared — a fail-closed gate in one step would have broken every
shipped plugin. Those exact pairs sit behind a dated, frozen, per-plugin
allowlist: they warn once and resolve, everything else fails closed. The
allowlist is closed in both directions — a different plugin asking for the
same name still throws, and an allowlisted plugin asking for a new name still
throws.

Counter-proof: reverting the gate to `return serviceRegistry.get<T>(name)`
fails 9 of the 25 new tests; restoring it passes all 25.

Ratchet 3296 → 3300, hand-raised for `middleware/packages` only. All five
lines are the new CHANGELOG documenting a removal; three of them are literal
strings that cannot be reworded (a spec path, a test filename, the future
package name). The first measurement was +31 — the avoidable 26 were reworded
away rather than excused, leaving `middleware/test` at its 1,030 baseline.
Justification recorded in the baseline JSON, README and acceptance.md.

Also corrects plan.md §4.2, which still claimed the `DevJob*` types stay in
core — `implementation.md` §2.5 had already flagged it as contradicting §4.1,
and shipped code now settles it.

* feat(#470): permissions.sql + shared plugin migration runner (C7 / G4)

Plugins can own tables. Three things had to become true for that to be safe
rather than merely possible, and this commit is all three.

1. Reaching a Postgres pool is now a DECLARED, GRANTED permission.

   C2b made `ctx.services.get(name)` resolve only capabilities the manifest
   declares in `requires:` (bug B1). That is the right rule for an ordinary
   capability, but not for `graphPool`: the pool is not a service another
   plugin provides, it is the operator's own database — the same one core
   writes user data through. A `requires:` line is the plugin author's own
   say-so, and that is the wrong bar for handing it over.

   So pool-shaped capabilities need both halves: `permissions.sql` in the
   manifest (visible in the install dialog) AND an operator grant row. The two
   denial reasons stay distinct types because they have different fixers —
   `undeclared` is the author's, `ungranted` is the operator's, and collapsing
   them would send every author chasing a manifest bug that is not theirs.

   The existing `graphPool` capability name is reused; no second name is
   invented for the same pool. `POOL_SHAPED_CAPABILITIES` is a closed set
   rather than a heuristic, so a new pool capability is ungated until someone
   adds it — a reviewable omission instead of an invisible one.

2. A ledger is unambiguously ITS plugin's ledger.

   A plugin able to name any table could forge another plugin's migration
   history and thereby suppress that plugin's schema changes at its next boot.
   The name is charset-validated (`^[a-z][a-z0-9_]{2,62}$`) BEFORE it is quoted
   into DDL — an allowlist that rejects `"` cannot be defeated by a cleverer
   `"`, whereas an escaping approach has to be right about every case.

   Exclusive ownership is enforced by `UNIQUE (ledger)` in `plugin_sql_grants`,
   not by the prefix rule. The prefix rule cannot separate `acme_tool` from
   `acme_tool_extra`: a name carrying both prefixes would pass for either
   plugin. The constraint has no such edge, so the prefix check is documented
   as the cheap offline half, not as the boundary.

3. Applying migrations is serialised, once, for everyone.

   `implementation.md` B3 recorded core migrators racing on multi-replica boot:
   two replicas read an empty ledger and both execute. Handing plugin authors a
   documented pattern to copy would have recreated that bug once per plugin, in
   code the operator cannot patch. So there is one `runPluginMigrations` and a
   plugin cannot opt out of any of it.

   `pg_advisory_xact_lock` rather than the session-scoped variant: it is
   released by COMMIT or ROLLBACK, including the server's rollback on a dead
   connection, so it needs none of the release-or-destroy care a leaked session
   lock demands. `SET LOCAL lock_timeout` rather than a try-lock poll loop:
   "wait, but not forever" in one statement. A plugin-only advisory namespace
   (4420) rather than core's 4410, so a plugin-supplied ledger name that
   happens to hash onto a core ledger is not an availability lever.

   One transaction for the whole batch, not one per file: a half-applied plugin
   schema is a state no file in the directory describes.

   Empty directory throws. A plugin declaring `permissions.sql.migrations` is
   asserting it ships schema; a directory with nothing in it means the build
   dropped the files. "0 applied, all good" would let it activate and fail
   later against tables that were never created, several layers from the cause.

   Checksum drift on an applied file throws unless explicitly opted into. An
   edited migration means the database and the package disagree about what ran.

   `.js`/`.mjs` migrations receive the same transaction-bound client as `.sql`
   ones, so the codegen path in implementation.md D6 is not a weaker path.

Unknown `permissions.*` keys now warn instead of vanishing silently — same
implementation and same `KNOWN_PERMISSION_KEYS` shape C4 (#782) introduced, so
the two branches merge as a one-line union of the key set.

Migration 0045 (0044 is taken by sandbox_registry on main).

* feat(#470): wire the SQL gate and auto-migrate at activate (C7 / G4)

The grant is read ONCE, at activate, before the context is built.

`ctx.services.get` is synchronous and cannot await a database read. Doing the
lookup inside the accessor would force either an async accessor (a breaking
change to every plugin) or a cached best-effort answer that is permissive while
the cache is cold — and a permission that is permissive while cold is not a
permission. So the async read happens where awaiting is free and the gate is a
pure function of its result.

The grant must also still MATCH the manifest. An operator granted a specific
ledger; a package that later ships a manifest naming a different one has not
been granted that one. Carrying the stale row forward would let a plugin update
silently move its schema somewhere the operator never approved, so a mismatch
is treated as ungranted and logged.

`ctx.sql` exists only for a plugin that declared, was granted, and has a package
root to resolve paths against. The root is not optional bookkeeping: the
migrations directory comes from a plugin-supplied manifest, so `../../../etc` is
a string a package can ship. Every other core migrator resolves its directory
from its own `import.meta.url` and inherits containment for free; this one takes
a path from a manifest and therefore has to re-establish it. The `+ path.sep` on
the containment comparison is load-bearing — without it a sibling directory
named `<root>-evil` passes a bare `startsWith`.

Migrations run BEFORE the plugin's `activate()`, and only when the manifest
declares `permissions.sql.migrations`. The ordering is not a convenience: it is
what makes "the tables exist" an invariant `activate()` can rely on rather than
a race each plugin re-loses in its own way. A failure fails the activation,
because the alternative is a plugin running against a schema it was not built
for — which fails later, further away, and with the database in a state nobody
chose.

The grant store resolves `graphPool` per call rather than capturing it.
`ToolPluginRuntime` is constructed ~600 lines before the pool exists, because
the pool is published by a plugin this very runtime activates. A store bound at
construction would therefore always be the null one. A plugin that activates
before the provider (or before migration 0045 has been applied) reads no grant
and is treated as ungranted — the fail-closed direction, self-correcting on the
next boot once ordering has settled.

* test(#470): C7 gate + migration suites, with the lock counter-proof (G4)

42 tests across two suites, split by what they can honestly prove.

`pluginSqlPermission.test.ts` (22, no database) covers the decision table and
the name validation — both pure, so the outcome is asserted directly rather
than inferred from which exception escaped.

`pluginMigrations.pg.test.ts` (20, real Postgres) covers everything whose
property belongs to the SERVER rather than to this code: that
`pg_advisory_xact_lock` actually serialises, that `CREATE TABLE IF NOT EXISTS`
under it cannot double-create, that `UNIQUE (ledger)` actually refuses a second
owner. A hand-rolled fake would have all three by construction and would prove
only that the fake has them.

THE COUNTER-PROOF

The concurrency cases are only worth something if they fail without the lock.
Removing the `pg_advisory_xact_lock` call and re-running:

  without lock:  RUN 1..5 → pass=0 fail=2   (10/10 failures)
  with lock:     RUN 1..5 → pass=2 fail=0   (10/10 passes)

Failure mode is `23505` on `pg_type_typname_nsp_index` — both transactions pass
the `IF NOT EXISTS` existence check and both reach the catalog insert. That is
the exact race B3 documented, reproduced deterministically, which is the only
way to know the lock is load-bearing rather than decorative.

Cases chosen so the assertion is the mechanism, not the report:

- Ordering is proven by the second migration's column existing, not by the
  report's own array; the files are created in an order that disagrees with
  lexical order, so a runner using readdir() order would pass on some
  filesystems and fail on others.
- `allowChecksumDrift` asserts the file is SKIPPED, not re-applied — re-running
  the CREATE would have thrown 42P07, so the assertion also proves the opt-in
  does not silently re-execute.
- Batch rollback checks the ledger table is gone too, including the row for the
  migration that individually succeeded.
- The injection case re-queries `plugin_sql_grants` afterwards: the table still
  being there is the proof the name never reached SQL.
- A case asserts two DIFFERENT plugins do not serialise, or every boot would
  funnel every plugin's schema through one lock.
- The store's read path is exercised against an unreachable database to show it
  degrades to "ungranted" rather than to "granted".

The `PG_URL` narrowing is an `assert.ok`, not a non-null assertion — the
suite-level skip is not a type narrowing, and a bad value should not reach `pg`.
Suite uses `PLUGIN_SQL_PG_TEST_URL` first and skips cleanly with a reason when
no test database is configured (issue #572: no hardcoded default port).

* fix(#470): close six defects found by cross-family review of C7 / G4

Adversarial review of the permissions.sql gate and the plugin migration
runner, from a different model family than the one that wrote them. Six
findings; each fix carries a test that fails without it (verified by
mutation, not assumed).

1. Ledger names now live in a reserved `plg_<sanitized-id>_` namespace.
   The old rule only required a ledger to START WITH the plugin's folded id,
   and some plugin ids fold onto real core table names (`@tasks` -> `tasks`,
   `@agents` -> `agents`, `@plugin/sql` -> `plugin_sql_grants`). For those,
   `CREATE TABLE IF NOT EXISTS` adopted core's table as the plugin's ledger.
   Nothing downstream re-checked the name; the only thing that stopped a
   write was the adopted table happening to lack the ledger's columns, which
   is a coincidence of column shape rather than a rule. Core migration
   ledgers were safe only because they all start with `_`, which the charset
   regex rejects — also not an enforced property. No core table can be inside
   `plg_`, so the syntactic check now carries the guarantee its own doc
   comment claimed. Free to tighten today: nothing calls `grant()` yet, so no
   deployment holds a row under the looser rule.

2. Migrations are time-bounded, and the runner is bounded too.
   `runMigrations()` was awaited BEFORE `withTimeout(activateFn, 10_000)`, so
   the activate cap never covered it, and `lock_timeout` bounds only lock
   ACQUISITION. A migration that took its locks and then ran forever held the
   advisory lock and blocked boot for every replica. Adds a server-side
   `statement_timeout` and wraps the call in its own `MIGRATION_TIMEOUT_MS`.

   The test that claimed "the lock wait budget stays inside the 10s activate()
   cap" asserted a relationship between two numbers that were never related —
   it compared one constant to a literal and passed regardless. Replaced with
   the ordering that does matter (lock wait << statement budget) plus a case
   that reads both budgets back out of the live transaction.

3. `graphPool` is handed to plugins BORROWED, not owned.
   `services.get` returned the raw `pg.Pool` — the same one core writes user
   data through — so `ctx.services.get<Pool>('graphPool')?.end()` in any
   plugin tore down the whole middleware's connection pool. This is the #665
   class of bug and `adminEmbeddingProvider.ts` records it having already
   happened once. `query`/`connect`/`release` pass through (arbitrary SQL is
   the granted, intended behaviour); `end` and the listener removers throw,
   and the `connect().pool` escape is closed. Applied at the plugin-facing
   seam, so core keeps the real object.

4. Read-once grant semantics are documented and pinned.
   Revoking a grant does not disarm a plugin that is already running — the
   gate closes over a boolean taken at activate. That is defensible (see the
   sync `services.get` argument) but was nowhere stated. Documented on
   `revoke()` and on `sqlGranted`, and pinned by a test so drift in either
   direction has to be deliberate.

5. `pluginSqlPermission.test.ts` is text again.
   It contained a literal NUL byte, so git classified the file as binary:
   the diff read `Bin 0 -> 11636 bytes` and the entire 11.6 KB of the test
   proving the injection defence was invisible in the PR, unreviewable and
   un-blameable. Now escaped.

6. Timeout conditions surface as typed errors.
   `55P03` / `57014` reached the caller as bare pg errors, so an activation
   log blamed the package for what is usually another replica holding the
   lock. Both are re-typed as SqlMigrationError; every other pg error still
   propagates raw, because a syntax error in a plugin's own SQL is most
   useful unwrapped.

Verified: build, typecheck, typecheck:test (406 baseline, no regressions),
lint all pass. Full suite 7298 tests / 7286 pass / 0 fail. pg suite 22/22,
re-run 3x. check-core-decoupling held at 3300 (the +3 my new tests cost was
reworded away, not raised).
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant