Skip to content

Keep the playground's delete off a component the build ships - #109

Merged
davidmckayv merged 2 commits into
CopilotKit:mainfrom
Hotragn:refuse-a-name-this-surface-does-not-own
Aug 21, 2026
Merged

Keep the playground's delete off a component the build ships#109
davidmckayv merged 2 commits into
CopilotKit:mainfrom
Hotragn:refuse-a-name-this-surface-does-not-own

Conversation

@Hotragn

@Hotragn Hotragn commented Aug 21, 2026

Copy link
Copy Markdown
Contributor

What this changes

DELETE /api/sandboxed/:name handed the raw path parameter to store.remove, which deleted from
components — the table shared with the compiled catalogue — with nothing checking which kind of
component the name belonged to.

The three mutating routes on this surface disagreed about what a name may be, and that asymmetry is
the bug rather than the delete itself: save refuses a name that is not a slug, publish refuses a
name with no draft behind it, remove did neither.

For a compiled component's name the sandboxed_components delete matched nothing and the
components delete succeeded, taking that component's per-Bot withholdings and its function grants
with it through onDelete: "cascade".

The withholdings are the half that fails open, and the schema says why itself — a published component
is available to every Bot unless a component_exclusions row says otherwise. Losing that row does not
hide the component, it releases it. Then syncCatalogue sees the component as missing on the next
announcement from a browser and writes it back with published: true, because that is how one the
build ships arrives. A deliberate "not this Bot" comes back as "every Bot", under an audit row saying
kind: "sandboxed" — the one thing the deleted component was not.

The function grants fail closed, so that half is a capability silently lost rather than one gained.

remove now refuses a name this surface does not own, and the route answers 404 the way publish
already did.

Gated on the governance row's kind, not on the presence of a sandboxed_components row.
Ownership is the actual question, and the two answers differ in one case worth keeping: the two
deletes are not in a transaction, so a failure between them leaves a governance row with no source.
That orphan is the "catalogue disagrees with the build" state the existing comment names, it is this
surface's to clear, and requiring the source row would have made it undeletable through the only
endpoint that could. There is a test for it.

Admin-only either way, so this is a footgun rather than an escalation, and I would not put it higher
than that.

Closes #108

Where it runs

  • New state that outlives a request? None. One select added inside an existing store method.
  • What happens on the second replica? Identical. The check reads the same components row any
    replica would; nothing is cached, held, or remembered between requests.
  • Anything serialised? No, and nothing new needs to be. This narrows what a delete may touch, so
    two administrators racing to delete the same name end where they did before: one deletes, the other
    finds nothing of this kind by that name and gets a 404. The pre-existing non-transactional pair of
    deletes is unchanged, and is the reason the kind gate is written the way it is rather than as
    requireRow.
  • Anything fanned out to a browser? No.
  • New listener, port, or schedule? No.

Boundary and audit

  • No change to resolve → decide → audit → act. requireAdmin still gates the route, ahead of this.
  • A refusal now writes nothing, and that is deliberate. The audit row was written after both
    deletes, so a refused delete never reached it. There is no component.* event for "refused a
    delete", and the existing component.refused means a Bot reaching for a component it does not
    hold, which this is not. Inventing a row for it looked like the larger change and the wrong call
    for a bug fix; happy to add one if you would rather it were recorded.
  • The row a successful delete writes is unchanged. It is now only written for a component this
    surface actually owns, which is the fix — previously it claimed kind: "sandboxed" about a
    compiled component.
  • Nothing new is trusted from the client. Strictly less is: the :name parameter used to select the
    row to delete on its own.

Changelog

Added under UnreleasedFixed: "Deleting a component in the playground could release one the
build ships."

Proof

Five tests added to server/tests/sandboxed-components.integration.test.ts, in a new describe block,
covering: a compiled component's name is refused and its governance row survives; the withholding
that would have been released survives; the function grants survive; a name nothing was authored
under is refused; and an orphaned governance row of this surface's own kind is still deleted.

Honest limit on what I ran. These are integration tests and this machine has no PostgreSQL —
Docker Desktop is not running, and the embedded PostgreSQL this repo ships is an apt install inside
the container image rather than something reusable on a host. So I have not executed them, and CI is
the first place they will run. I would rather say that than imply a green suite I did not see.

What I did run:

server $ bunx tsc --noEmit                         clean
biome format (3 changed files)                     clean
biome lint   (3 changed files)                     clean
bun test server/tests/component-decision.test.ts   5 pass, 0 fail

The typecheck is worth more here than it usually is: Drizzle types .values({...}) against the table
schema, so a missing notNull column, a wrong column name, or a bad agentType in the fixtures would
have failed it rather than failing at runtime. It passes. The agents fixture is copied from
component-store.integration.test.ts, which is an existing passing test against the same tables.

Note for anyone reading the diff locally on Windows: this checkout has core.autocrlf=true, so
biome format reports CRLF against the working tree. .gitattributes is * text=auto and the
committed blobs are LF; the numbers above are from the LF content as committed.

What is not covered

  • The two deletes are still not in one transaction. Unchanged by this, and now with a test that
    documents the orphan it produces and asserts the orphan stays clearable. Wrapping them is a
    separate change and I did not want to fold it in.
  • Nothing stops an administrator deleting a sandboxed component that Bots are using. That is the
    feature working, not a gap; this PR is only about names this surface does not own.
  • No new audit event for a refused delete, per the note above.

`DELETE /api/sandboxed/:name` handed the raw name to `store.remove`, which
deleted from the shared `components` table with nothing checking which kind of
component the name belonged to. `save` refuses a name that is not a slug and
`publish` refuses a name with no draft behind it; `remove` did neither, and that
asymmetry was the bug rather than the delete itself.

Naming a compiled component removed its governance row, and the foreign keys
took its per-Bot withholdings and its function grants with it. The withholdings
are the half that fails open: a published component is available to every Bot
unless a `component_exclusions` row says otherwise, so losing that row releases
the component rather than hiding it, and the next catalogue announcement writes
it back with `published: true`. A deliberate "not this Bot" returns as "every
Bot", under an audit row saying `kind: "sandboxed"`.

Gated on the governance row's `kind` rather than on the presence of a
`sandboxed_components` row, because ownership is the actual question and the two
differ in one case worth keeping. The two deletes are not in a transaction, so a
failure between them leaves a governance row with no source; that orphan is this
surface's to clear, and requiring the source row would have made it undeletable
here. The route answers 404 for a name it does not own, the way publish does.

Admin-only either way, so this is a footgun rather than an escalation.
@Hotragn
Hotragn force-pushed the refuse-a-name-this-surface-does-not-own branch from 00ffaa1 to 0f57875 Compare August 21, 2026 20:55
@Hotragn

Hotragn commented Aug 21, 2026

Copy link
Copy Markdown
Contributor Author

Closing the loop on the verification caveat above: the five integration tests have now run, in CI, and passed — tests green alongside format, lint, types, build and migrations. That was the part I said I could not execute locally for want of a PostgreSQL, so it seemed worth saying out loud rather than leaving the caveat standing.

Rebased onto 37b576c while I was here. The only conflict was CHANGELOG.md, which every branch touches; the source files this changes have not moved since the first commit.

@davidmckayv
davidmckayv merged commit f14d5c7 into CopilotKit:main Aug 21, 2026
6 checks passed
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.

The playground's delete accepts any component name, so it can release a compiled component to every Bot

2 participants