Skip to content

feat(objects): edit a database object's definition and apply the DDL (#789) - #831

Merged
cevheri merged 125 commits into
mainfrom
feat/object-ddl-apply-phase-3
Sep 15, 2026
Merged

cevheri merged 125 commits into
mainfrom
feat/object-ddl-apply-phase-3

Conversation

@cevheri

@cevheri cevheri commented Sep 14, 2026 •

Copy link
Copy Markdown
Member

Phase 3 of #789, from discussion #778. Phases 1 and 2 gave the object tree and the read; this makes the definition editable.

A reader can now open an object's source, edit it and apply it, on four kinds across three engines: PostgreSQL functions and procedures, Trino functions, Redis functions. Every apply goes through a mandatory preview, and the bytes drawn there are the bytes the engine receives: the build mints a sealed plan and the apply takes only that plan.

The other fourteen type-ids declare no editable kind, and each provider doc says which absence it is: no replace in place, a catalog we cannot address, or a strategy that would destroy something a success never showed. Per engine, never a blanket "not supported".

New surface: two routes under /api/db/objects, an object_edit audit arm, security control 3.6, nineteen additive exported types and an optional host editor. No version bump, since the change is additive and 0.16.0 is unreleased.

Residual, filed as D76: on PostgreSQL the reader's text travels as a multi-statement round trip, so a semicolon can carry a rider the plan never named. Refused on Trino and Redis, sealed and shown on PostgreSQL.

Do not merge yet.

…futes

The provider doc (section 3.11) and the `queryReadOnly` docblock both say a
multi-statement string runs its first statement only and that the tail is never
executed. Measured on DuckDB v1.5.5 through @duckdb/node-api 1.5.5-r.4 while
grounding #778 Phase 3: the error raised comes FROM the second statement and the
first statement's effect persists, so the tail runs and nothing rolls back.

The measurement quoted beside the claim is real and is about the RESULT, which is
that runAndReadAll returns the first statement's rows. The sentence built on it is
about EXECUTION, which nobody ran. Recording the distinction rather than the
correction, because the fix touches four sites and belongs to whoever owns that
path, not to this branch.

The security consequence is bounded: the guard beside the docblock reads the whole
string before the call and access_mode is fixed read-only on that path. What is
wrong is the stated reason, which is the load-bearing half of a security docblock.

Refs #789
…tend D57

Found while grounding #778 Phase 3 against live engines and a real browser. None
is Phase 3's to fix and none grows that branch beyond this entry.

Three are cross-user and two of them lose data silently:

D71, an unfinished BEGIN from /api/db/multi-query outlives the request on the
provider cached per connection.id. On SQLite and DuckDB the next writer's row is
accepted with HTTP 200 and then destroyed by a later rollback with no error at any
point. On PostgreSQL the poisoned pooled client is never retired: it answered
"current transaction is aborted" for 60s of retries, survived 40s of idleness, and
reached a different user's /api/db/maintenance eight minutes later.

D72, /api/db/transaction shares txActive per connection.id, so a second Studio
user is refused "Transaction already active" with a 400 and is then allowed to
roll back the first user's uncommitted write with a 200.

D73, session state written by one request is read by every later one across users:
a SET from a user-role session was read back by an admin session on the same
backend pid.

X17, the Source pane captions a truncated part "Complete as shown.", because the
caption reads form and form stays "complete" when the text was bounded. Shipped in
Phase 2 and invisible until this grounding built an object over the bound.

X18, the add-connection button has no accessible name and the a11y gate did not
catch the shape.

D57 gains its end-to-end measurement: the connected MariaDB provider serves the
package kind through the source route while provider-meta answers the MySQL six,
so the client's declaration is the wrong server's.

Refs #789
…ixes left

Found while fixing D71 and D72 in their own PRs (#823, #822).

D74: src/app/api/db/query/route.ts has D71's shape. It takes a provider from
the same process-wide cache, has no finally and nothing that asks whether a
transaction is open, so a lone BEGIN needs ONE request rather than a script.
Filed as READ, not measured: the route's text was verified and the mechanism is
identical, but nobody has sent a lone BEGIN through it. D71's reproduction drove
its SECOND request through this route and watched it fail on a client another
route had poisoned, which shows it shares the handle, not that it can poison one.

D75: #823 implements the is-a-transaction-open surface on postgres, sqlite and
duckdb, the three engines D71 was measured on. The method is optional with no
default, so the other fourteen type-ids answer nothing and a script's unfinished
BEGIN is still left open there. A declared boundary, recorded because a boundary
nobody writes down becomes a fallback the next reader trusts.

Refs #789
…us expectation (#789)

Phase 3 of #789, from discussion #778, lands its contract before any provider
declares anything.

src/lib/db/types.ts gains nineteen exported types describing what a build
answers, what a plan carries and what an apply did, plus two optional methods on
DatabaseProvider, one optional field on ObjectKindSpec and one on the text arm of
ObjectSourcePart. All nineteen are published from src/exports/types.ts, so an
embedded host can name ObjectEditOutcome.

src/lib/db/object-kinds.ts gains the two derivations beside kindHasSource and
requireSourceKind: kindAcceptsSourceEdits, which is not conjoined with anything
so that a half declaration stays visible to the census, and requireEditableKind,
the one entry guard every buildObjectEdit opens with, hoisted before the first
provider rather than after nine copies of it exist.

tests/helpers/object-surface-conformance.ts gains the edit pairing, unconditional
and outside every loop so it certifies the fourteen providers that declare no
editable kind, a refusal for an editable kind the expectation counts at zero, and
design 7.2's second assertion inside the walk, which drives buildObjectEdit for
every editable kind and refuses a declaration with a stub behind it.

tests/helpers/object-edit-expectation.ts is the census expectation, transcribed
from the design and never derived from a build: four editable pairs on three
engines and fourteen abstainers, with a guard asserting the two populations
partition the seventeen type-ids exactly once.
…en path (#789)

Review of the object edit contract found the conformance helper certifying a provider
that declares an editable kind, implements both edit methods and reads no source at all.
The edit pairing agrees with it, the zero-count refusal agrees with it, and the
`read === undefined` early return then skips the walk, so the `undriven` refusal that
exists for exactly that sentence never executes. Measured against the previous commit:
that double resolved, 1 pass 0 fail.

- the early return now refuses an editable kind when nothing can read a definition for it,
  which is the same reasoning that put the pairing above the return;
- two tests for 9b's green path, which no committed test reached: an editable kind the
  walk skips while another kind is read, which makes the `undriven` block killable for the
  first time, and a conforming provider whose `buildObjectEdit` is driven and whose
  submitted request is pinned;
- the helper reads `kindAcceptsSourceEdits()` instead of the field. Measured: mutating the
  derivation kills 54 tests now and killed none before;
- the dead `built === null ||` disjunct is gone, and the census fleet drops its
  `as DatabaseType`, which suppressed the only compile error a renamed id raises.
The pane needs one place that answers whether a part of an object's
definition may be submitted back, and, when it may not, the one sentence
the reader can act on. A discriminated union rather than a boolean: a
disabled control with no sentence is silence with a tooltip on it.

The ORDER is the contract. `truncated` first, then `form`, then the
provider's `edit` affordance. Both core facts travel on the document, so
both arms are reachable on the standalone shell and on the embedded one,
where a host supplies the document and no route of ours runs. That second
half is what the route's affordance stripping cannot cover, because a host
never reaches `boundSourceDocument`. Testing `edit` first would leave the
`bounded` arm with no producer anywhere on the standalone path.

The two core facts stay independent and are never conjoined. Measured on
PostgreSQL 18.4, `form` stays "complete" on a truncated part, so a part
that is both truncated and partial answers `bounded`.

From discussion #778.
…_edit audit arm (#789)

Core's pure edit module for Phase 3 of the object DDL apply (discussion #778).

Three things here are load-bearing and each is measured rather than reasoned.

EDIT_PLAN_EXECUTABLE_LIMIT is a DIFFERENT bound from the read's. The inbound
text bound equals SOURCE_CHARACTER_LIMIT by construction, because a readable
part must be a submittable one. The plan bound covers what this product will
SEND, which is the caller's text plus whatever the provider wrapped around it,
and collapsing the two refuses the route's own plan for any part within the
guard block's length of the read bound. The docblock carries the arithmetic
and the reason, so a later reader does not collapse them again.

The coordinate map exists because a refusal's position is an offset into the
statement the PROVIDER built. Measured in a real browser, an uncorrected
coordinate handed to setModelMarkers did not throw and did not warn: Monaco
CLAMPED it, so nothing in the platform catches the error. userPositionOf
answers `outside` rather than a number for anything it cannot place inside the
reader's own text.

The audit outcome is not a boolean. Seven measured paths across five engines
succeed while destroying something the user was not shown, so
applied-with-collateral is a SUCCESS carrying a reason. The map is keyed on a
type derived from the outcome union, including conflict's second discriminant,
so a new arm with no audit reading fails to compile.

Also adds the object_edit event type and the eight object_edit_* reasons, and
corrects the correlationId docblock: it is no longer only agent_operation that
sets one.
…nse claims (#789)

The `provider-refused` arm's reason guard was a mutation survivor: replacing
`edit?.offered === false && typeof edit.reason === "string" && edit.reason.trim() !== ""`
with `edit?.offered === false` left the suite at 8 pass, 0 fail. Its population is
live and is not a double: `isPartShape` in `source-reader.ts` validates every other
field of the text arm and never examines `edit`, so a host on the embedded seam
reaches this predicate with any `edit` it likes, including `offered: false` with no
reason or a blank one. Under the mutation that draws a refusal headline over
`undefined` or over three spaces.

Two tests, one per half of the guard. Written against the mutated module first:
8 pass, 2 fail. Dropping only the trim kills the blank-reason test, dropping only
the type check kills the no-reason test, 9 pass 1 fail each way. Restored: 10 pass.

Also corrected two claims written in the present tense about code that does not
exist yet. Measured at this commit: `boundSourceDocument` strips no `edit`, and
`acceptsSourceEdits` has no reader outside `kindAcceptsSourceEdits`. The docblock
now says the route is specified to strip and has not been written, and the test
comment says what `isPartShape` actually checks. A docblock paragraph records the
arm boundary: a withheld affordance with no usable reason is answered
`not-offered`, which understates what the provider did and is the lesser of the
two wrongs.
…n three folded guards (#789)

Fix round 1 of task 2. Review found that nothing tied a consequence CLASS to
its SENTENCE: swapping two arrows in CONSEQUENCE_SENTENCE, or replacing the
replaces-whole-container sentence with one saying the apply is safe, killed 0
of 32 tests while format, lint, typecheck and the line gate stayed green. The
one live day-one producer of that class is Redis 8.10.0 FUNCTION LOAD REPLACE,
which drops every other function in the library, so the mutated dialog would
have told the reader the apply is safe on the one path where it is not.

Adds a total Record<ObjectEditConsequenceClass, string> of one distinguishing
substring per class and asserts each sentence carries its own and no other's.
Being total, it also pins the hand-written CLASSES list to the union: a ninth
class now fails to compile here, measured, and a ninth entry with CLASSES left
at eight fails the keys assertion.

Adds three tests for guards raw line coverage cannot see, because the if line
itself executes: an inverted user segment, a non-integer sent offset, and an
offset at the end of a step whose segments over-cover its text. Each kills its
mutation and only its own, 35 pass 1 fail.

No source change.
An unsaved edit of one object part now lives under its own storage key,
libredb_source_drafts_v1, with its own budget, its own try/catch and a
visible failure reason, which is decision H2 of #789 Phase 3 (from
discussion #778). The workspace blob is not touched, so shipped defect
X14 is neither fixed nor made worse here.

Pure functions over an injected Storage, no hook and no DOM, so the whole
eviction arithmetic is unit-testable without a browser. The budget is
1,048,576 characters counted as DRAFT_KEY.length plus the serialized
record, which is 20 percent of the measured 5,242,880-character origin
ceiling and is how that ceiling itself is counted. Eviction counts its
own keys and never asks the browser: navigator.storage.estimate()
reported usage 0 on Chrome 152 while localStorage held 5,242,880
characters.

Four failure reasons and not three. The fourth, budget, is a draft that
is under the read bound and still larger than the whole budget once
serialized, which a text of newlines reaches at about 520,000
characters. Folding it into too-long would print a false sentence about
a text that was never too long.
The embedded seam has no route in front of it: a host's build response and a host's outcome are
ordinary JavaScript values whose declared return type is not a runtime guarantee, and there is no
second line of defence behind these predicates.

MEASURED against tsc 6.0.3: a literal carrying the properties of two arms of a union compiles with
no cast, because the excess-property check on a union admits any property declared on any member
of it. So a command unit that also carries steps, an outcome that both applied and conflicted, and
an unavailable revision carrying a token are all representable. Every predicate here therefore
asserts EXACTLY the key set of the arm its discriminant names, walks every level rather than
trusting Array.isArray, and refuses a consequence whose catalog read came back empty.

No server import in this file, so the browser can name the same predicates the route uses without
dragging jose and node:crypto into the client bundle.
…for (#789)

Mutation: dropping Array.isArray from the draft store's shape walk killed
nothing, 15 pass 0 fail. The brief's own case asks an array store for key
"a", which answers undefined with or without the guard, because an array
has no "a" index, so the assertion could not see the guard it was for.

The guard's live consequence is on the WRITE: without it the array's
elements are spread into the record as numbered keys and are written back
to storage. That case is now asserted, and the mutation kills it,
14 pass 1 fail.
Found by mutation (c). With the second discriminant's VALUE check removed but its key still
required by hasExactKeys, every case in this file still passed, because each conflict arm has its
own required key set and that alone refuses a conflict carrying neither `current` nor `sentence`.

The population the value check actually protects is a conflict whose discriminant is unknown AND
which carries a well formed `current`: without the value check that narrows to object-changed and
the dialog renders a diff for a conflict class nothing in this product produces. Nothing else in
the file built that population. With this case present the narrow mutation kills one test.
… the store on the write path (#789)

Review round 1 of task 6. Six repairs, each with the test that would have caught it.

isDraftShape's savedAt and base arms were 100 percent line-covered and certified nothing:
deleting either left the suite at 15 pass 0 fail, because their false branch was never taken.
Each arm now has its own entry in the readDraft case. base is additionally required to be an
object, so a scalar cannot reach the restore banner's revision comparison as a revision.

readStore now filters every entry through the same shape walk. The read side already declared
the malformed-record population real and the write path then trusted the same bytes: an entry
of null raised "TypeError: null is not an object (evaluating 'entry.savedAt')" out of
oldestDroppable, and an entry with no savedAt was never selectable as a victim, since
undefined < Infinity is false, so every later write was refused budget permanently with a
droppable record sitting there. Both are gone and both have a test.

DRAFT_KEY is pinned by value: changing the string discards every stored draft, and nothing in
the suite read the literal.

Both boundary comparisons are pinned from the inside. A text of exactly SOURCE_CHARACTER_LIMIT
is a maximal READ and is not too long; a store cost of exactly DRAFT_BUDGET_CHARACTERS is
within the budget and evicts nothing. Under >= each prints a false sentence about a text that
was never over.

The test file no longer cites a path that does not exist in a clone.
…erty (#789)

Fix round 1 of task 3. Three defects a reviewer measured against the committed module, each
with the test that would have caught it.

The segment map is now checked against the step's own text, which is ruling 1a at the only
boundary the embedded seam has. `text` is what the engine receives and `segments` is what the
dialog renders, and the module accepted a step whose map rendered "SELECT 1" while the engine
would have received "DROP DATABASE prod". Two invariants, both computable from the step alone:
the segments laid end to end span the text exactly, and every provider segment's bytes are the
text at that segment's offset.

`hasExactKeys` read `Object.keys` for its exclusion arm, which returns only ENUMERABLE own keys.
A command unit hiding `steps`, an applied outcome hiding a `conflict` getter and a built response
hiding a `refusal` all passed. The live population is the embedded seam, where the host's answer
is a live JS object; `JSON.parse` cannot produce a non-enumerable own property, so the route path
never had it.

The three accepted-value lists move from `[...] satisfies readonly Union[]`, which compiles
silently when the union grows a member the list does not carry, to `Object.keys(... satisfies
Record<Union, true>)`, which fails the build both ways.

Plus the refusals that had no test population at all: a plan with a fourteenth key, each required
plan field emptied on its own, a build response carrying a malformed plan, and a collateral
outcome whose `lost` names a malformed consequence.
…only signal (#789)

Two consequences of the previous commit's own repair, both found by mutating it rather than by
reading it.

Filtering every store entry through the shape walk made the existing array-store test vacuous:
removing readStore's Array.isArray guard left 20 pass 0 fail, because an array of junk is now
dropped element by element anyway. The population that can still see the guard is an array whose
ELEMENTS are well-formed drafts, which survive as the numbered keys "0" and "1" of a record,
each an undeletable draft nothing can address. The test now builds that. Mutation: 20 pass 1 fail.

The Array.isArray arm INSIDE isDraftShape went the other way: deleting it leaves 21 pass 0 fail
and no population can change that, since the predicate only ever sees JSON.parse output and a
JSON array carries no named member, so the text arm has already refused it. A probe over six
parsed bodies and every member of each found 0 disagreements between the two predicates. The arm
is removed and the reasoning recorded, because a guard over an empty population is what this
epic keeps shipping.

dropDraft stays void per its brief signature, and the docblock now names the signal that does
exist: readDraft still answers the draft after a refused drop. Asserted, so the pane can tell
"dropped" from "the browser refused" and give the restore banner a reason. Mutating the catch to
clear the store instead: 20 pass 1 fail.
…ved key (#789)

The plan the preview shows and the plan the apply sends are bound by a
SHA-256 over a length-framed walk of every leaf, carried inside a 15-minute
JWS signed with HMAC(JWT_SECRET, "libredb.object-edit.plan.v1") so a session
cookie is not a plan token. The walk drives the top level from PLAN_FIELDS,
sorts every level below it, and emits two synthetic leaves, an object's key
list and an array's length, so a deletion or an explicitly undefined slot is
visible. The test enumerates its tamper population FROM the walk.

Refs discussion #778.
Six states and no other way in or out, per discussion #778's Phase 3 design.

The diff's left side is the BUILD's pre-image and never the tab's text, because a left side taken
from the tab hides a change somebody else made while the tab sat open, and the right side is the
whole unit: the exact bytes that will execute. The shaded regions come from the plan's SEGMENTS
through providerRanges and never from a search of the sent text, which would shade a reader's own
line that happened to match the provider's literal.

The acknowledgement is held with the plan id it was given for, so a rebuild resets it; the pinned
session value is a line the reader sees before confirming (ruling 2e), and an asserted setting
draws nothing; a truncated pre-image disables Apply; and `applying` blocks Escape, the overlay and
the close button.

Group 30 is registered in tests/run-components.sh first, because a component test file nobody
registers there never runs at all. TOTAL_GROUPS is what `grep -c '^run_group '` prints, 45.
…ation (#789)

Round 1 review of the seal, five findings, each repaired and each mutation re-run.

The frame() docblock claimed, in a measurement's voice, that no pair of ObjectEditPlan values
collides unframed. Refuted by running: kind and partId are both plain engine-derived strings, so a
value absorbs the next leaf's path literal. The pair is now a test with a control over the unframed
walks, and the docblock says what was measured.

Four of verifyPlanToken's discriminants were individually deletable with the suite green. One test
per clause, holding the other constant, forging the claim side with the module's own key.

The per-leaf tamper could not discriminate the 14 synthetic leaves, because deleting a key or
popping an element also removes ordinary value leaves. It now adds an undefined slot instead, which
moves the synthetic leaf alone, and under the filter mutation the loop names all fourteen.

The digest was not injective over the strings a plan can carry: TextEncoder maps every unpaired
surrogate to U+FFFD, and String(value) makes 0 and "0" one leaf. The digest is taken over UTF-16
code units and a value leaf carries its typeof. Measured cost over a 975,134-character definition:
3.60 ms.

The file header and the TTL rationale attached to the wrong declarations, and PLAN_KEY_LABEL was
undocumented.
… draw (#789)

Six repairs to the preview dialog, from review round 1 of Task 13.

The `failed` state's outcome type now EXCLUDES `conflict: "object-changed"`.
That arm carries the server's current text and decision H3 asks for it as a
diff with a rebuild; the dialog was collapsing it onto the concurrency
sentence, throwing `current` away and leaving Close as the only control, with
nothing but a code comment separating the two discriminants. The mount that
maps outcomes to states is a later task, so the refusal is the compiler's: a
mount branching on `outcome.outcome === "conflict"` alone no longer compiles,
and the conflict arm in the failure region is narrowed by the type rather than
by a check whose other branch nothing builds.

`applied-with-collateral` gets its own arm. It was falling into the `applied`
else-arm and printing "Nothing here needs fixing." over an outcome whose
non-empty `lost` tuple exists precisely to name what was destroyed. It now
reads the catalog facts back, one line each.

The diff is bounded by `EDIT_PLAN_EXECUTABLE_LIMIT`, the number both apply
routes already enforce, and not by a third number invented here. The
standalone path is bounded at both routes; an embedded host's
`objectEditor.build` passes through no route, and `isObjectEditPlanShape`
bounds no string, so a 50 MB step text was a well-formed plan that went
straight into a Monaco model. Above the bound the dialog draws a sentence
instead of a diff and disables Apply. The remaining host strings the dialog
renders unbounded are named in the task report.

The multi-step identity line no longer contradicts itself: with two steps it
said "the whole of the right side is what will be sent, 1,243 characters" over
a right side of 1,234. It now reads "The diff shows statement 1 of 2, and
1,243 characters will be sent in total."

Tests: the overlay block while `applying` is measured rather than asserted by
comment, with the control that proves a backdrop press DOES close the dialog in
`preview`; the pin dedupe and the acknowledgement's `applying` arm each get the
population that can fail them; and the conflict screen's empty decoration set
is paired with the paint count, which separates "painted nothing" from "never
painted".
One code, and one only. The client's response to an invalid or expired object edit plan is
specific rather than a rendered sentence: it rebuilds the preview from a fresh read and asks the
user to confirm again. A bare `{ error }` would force the browser to match on message text to tell
that apart from every other 400 the apply route answers.

Two other candidates are recorded as dropped in the docblock rather than left to be rediscovered.
"Not editable" is the same 400 `{ error }` `requireSourceReader` already answers for an unreadable
kind, and "too large" is carried by the 413 status itself.

Filed under the database group in the test, which is the friction the group biconditional exists
for: it fails by name until a new code is named in both files.
…on the route (#789)

Three changes to the shared object-route plumbing, plus the census that moves with them.

`readBoundedJson` reads the request body as a STREAM, counting bytes, and answers three separate
sentences where the shipped arm answers one wrong one. The measured fact behind it: there is a
hard inbound bound at exactly 10,485,760 bytes, `DEFAULT_BODY_CLONE_SIZE_LIMIT` in Next 16.3.4's
`getCloneableBody`, which exists because this repository has middleware and the body is therefore
cloned for it. It TRUNCATES rather than refusing, so above it `POST /api/db/query` answers HTTP 500
with a JSON parser's message and `POST /api/db/objects/source` answers HTTP 400 "Empty request
body". One condition, two wrong sentences, and neither is true. The count is over the stream and
never over `Content-Length`, because a check on the header is satisfied by omitting the header and
a chunked body carries none.

`handleObjectRequest` gains a third `context` argument on its `run` callback, carrying the resolved
connection, the guard's session and the route, and a fourth `options` argument whose `readBody`
substitutes the body read. The seven existing call sites are unchanged: a callback declared with
two parameters satisfies a three-parameter signature. The default read is deliberately NOT
repaired, so five shipped routes keep their answer and this pull request does not grow a fifth
subject. `ObjectRouteError` is exported and carries an optional `ApiErrorCode`.

`boundSourceDocument` takes the CONNECTED provider's capabilities and DELETES the `edit`
affordance from any part that is a refusal, that carries `truncated` whether the provider marked it
or this route did, or whose kind the connected provider never declared `acceptsSourceEdits`. The
stripping runs AFTER the character bound, and that order is the guard: `boundText` marks truncation
itself, so stripping first would carry the affordance through on exactly the parts this route cut.

The rate-limit census at the `query` bucket asked by name to be re-counted. Sixteen direct call
sites, verified by grep, and the object routes move from seven to nine. `handleObjectRequest`'s own
numeral moves in the same commit with its basis, which is the route directories and not a digit.
)

The docblock stated the framework bound in one voice. Two of its claims are re-checkable here and
two are not, so it now says which is which.

Re-checkable by grep against the installed dependency: `DEFAULT_BODY_CLONE_SIZE_LIMIT` is
`10 * 1024 * 1024` in `node_modules/next/dist/server/body-streams.js`, and over it
`cloneBodyStream` sets `limitExceeded`, pushes `null` into both streams and logs a warning, which
ends the body early rather than failing the request. `next-server.js:1289` installs that clone on
every non-upgrade request, with `experimental.proxyClientMaxBodySize` as the only override, so the
clone is not conditional on a middleware file being present.

Not re-checkable here: the HTTP 500 and the HTTP 400 the two routes answer above the bound. Those
are a live measurement made earlier in this phase against a running server, and the docblock now
says so instead of claiming them in this file's own voice.
…is unconditional (#789)

The paragraph added in the previous commit stopped one sentence short. `next-server.js:1289`
installs the cloneable body on every non-upgrade request, so the 10,485,760-byte bound is not
conditional on a middleware file being present in this repository, even though middleware is what
the clone exists for. Written down because the opposite reading is the natural one and would send
a later reader looking for a middleware condition that is not there.
… procedures (#789)

Phase 3 of #789, from discussion #778: the write half of the object Source tab,
on the first of the three day-one engines.

`function` and `procedure` declare `acceptsSourceEdits`, the routine read grows
the five columns the edit needs, and every routine part now carries the edit
affordance the CONNECTED provider answered, which is where it has to be resolved
rather than on the client's copy of the declaration (D57).

`buildObjectEdit` re-reads the object through the identity expression the listing
wrote, answers five refusals before anything is sent, and issues a sealed plan
whose unit is ONE step. `applyObjectEdit` sends `plan.unit.steps[0].text` in a
single parameterless `client.query()` and never the text again.

The SET LOCAL and the statement travel in ONE round trip, and that is load
bearing rather than tidy. Measured on PostgreSQL 18.4 (Debian 18.4-1.pgdg13+1)
through pg: sent as its own round trip, `SET LOCAL search_path` answers WARNING
SET LOCAL can only be used in transaction blocks, the value does not take effect,
and pg does not surface that warning as a rejection, so a split pin would leave
the apply running under whatever the previous borrower of the pooled connection
left with nothing downstream able to see it. The control is the identical CREATE
without the pin, which answers ERROR relation "t" does not exist.

Three private SQLSTATEs and not one, because the three raises mean three
different outcomes, a conflict, a refused and an applied-elsewhere, and one
shared code would force the classifier back onto reading messages. The classifier
maps SQLSTATE as data; the single message it reads is `tuple concurrently
updated`, which arrives under XX000 where the code distinguishes nothing.

The fixture gains the object the read bound actually bites, measured at 1,215,122
characters, its 945,116-character control, and the two grants that make the
ownership refusal non-vacuous. D63 is NOT fixed here and stays filed.
…ed about (#789)

Found by running the mutation the task's own brief demands rather than by reading
the test. Mutating the `42P13` arm from `definition` to `privilege` did go red,
which is what the brief predicted, but the failure read `Expected "definition" /
Received "privilege"` and named none of the nine codes: the loop stops at the
first bad case, so a reader could not tell which arm had moved.

The code is now paired into the class assertion the way it was already paired
into the outcome assertion above it, and the same mutation prints "42P13" in the
diff.
PARTIAL fix round for Task 10, committed by the orchestrator after a session
limit killed the fix agent mid-round. It is not reviewed and it does not close
every finding: it is committed so the work is not lost and so a later mutation
window can restore with git checkout -- without destroying it.

What is in it: the readDefaultBody docblock's claim about where the truncation is
reached from, which an earlier revision of it got backwards. next-server.js:1289
calls getCloneableBody on every non-upgrade request, but that call only REGISTERS
the wrapper; the count and the truncation live in cloneBodyStream, which has three
call sites, one of them the middleware adapter. So the INSTALL is unconditional
and the TRUNCATION is conditional on middleware running. Same outcome here because
src/proxy.ts is a middleware, but the stated reason was wrong.

Also the divergence between readDefaultBody, which refuses {} with 400, and
readBoundedJson, which returns it, now said out loud and pinned at both ends.

Verified at this tree: typecheck 0 errors, lint 0 errors, tests/unit 9402 pass 0
fail across 311 files, tests/api 558 pass 0 fail across 33 files.

Refs #789
#789)

Fix round 1 for Task 10, part 2: the audit of the partial commit 18f5150 plus
the one defect that commit introduced.

The audit re-ran all four of the previous round's mutations at this tree rather
than reading its report: m1 (the readBody arm replaced by a copy of the default)
94 pass 3 fail, m2 (fabricated session and route) 95 pass 2 fail, m3 (a
fabricated connection) 95 pass 2 fail, m4 (>= for >) 96 pass 1 fail, against a
97 pass baseline, and every kill is by name. Findings 1, 2 and 4 are closed with
live guards. Finding 3's framework claim was re-measured by grep against the
installed next 16.3.4: getCloneableBody at next-server.js:1289 registers, and
cloneBodyStream at body-streams.js:80-118 counts and truncates, with three call
sites one of which is the middleware adapter, so INSTALL unconditional and
TRUNCATION conditional, as the repaired docblock says.

The defect that commit introduced: it wrote the handler tests in a new file,
tests/api/object-route-handler.test.ts, measured that a second mock.module of
@/lib/db in one process breaks tests/api/db/profile.test.ts, deleted the file and
appended the tests to tests/api/db-objects.test.ts, and left two citations aimed
at the deleted path. So the finding-7 divergence claim named its evidence as a
file a reader cannot open. Both citations now name db-objects.test.ts and the
describe inside it, and the source one records the measured reason the tests live
there, 558 pass 0 fail against 510 pass 5 fail with the file split out.

RED first: a new test extracts every tests/**/*.test.ts path cited in
object-route.ts and asserts each exists. Before the repair 15 pass 1 fail naming
the dead path; after, 16 pass 0 fail. Two mutations on the guard itself: aiming
one citation back at the deleted path kills it, and emptying the population by
rewriting every cited path kills it too, because the count assertion is what
stops a check that runs over nothing from certifying something. Population at
this commit: 2 citations in that one file. Scope is one file on purpose; 48
distinct test paths are cited across src/ and a repo-wide check is a lint-surface
change.

Finding 8 stays open and is NOT this task's file:
src/components/object-source/source-editable.ts:22-26 claims the route-side
stripping is not written yet and that acceptsSourceEdits has no second reader.
Both measured false here. Owner is Task 5; reported for the orchestrator to route.

Gates: format 1103 files no fixes, lint 0 errors, typecheck clean, tests/unit
9403 pass 0 fail across 311 files, tests/api 558 pass 0 fail across 33 files,
and 0 uncovered DA: lines for src/lib/api/object-route.ts in the raw lcov.

Refs #789
…r its rendering moved (#789)

The PostgreSQL apply's post-condition compared md5(pg_get_functiondef(oid))
against the plan's revision and raised LB003 when it had not moved, on the
premise that the build's byte-identical refusal left that comparison no false
positives. It had one. pg_get_functiondef is a canonical rendering, so an edit
that differs from the server's bytes and re-renders to the same bytes passes the
build and then trips the post-condition: measured on PostgreSQL 18.4 through the
shipped provider, app.order_total(integer) re-indented with sql upper-cased
answered applied-elsewhere and was rolled back, a legitimate edit reported as
somebody else's object and taken back.

The pre block now captures the addressed row's pg_proc.xmin into a
transaction-local setting and the post block compares that, so LB003 means the
row that was addressed is not the row that got written. xmin moves on every
rewrite including a byte-identical one, which is the property that makes it a
bad revision token and the right post-condition. Measured after the repair: the
canonicalized edit answers applied, a unit whose text creates a different
routine answers LB003 with count(*) = 0 for the forked name, and the setting
does not survive the round trip.

Also in this commit, all from the same review:

- The identity refusal no longer states a fork it cannot know. A changed
  parameter DEFAULT is rendered inside the header and is an ordinary in-place
  replace on 18.4 (count(*) = 1, oid 16787, xmin 857 to 858), so the sentence
  names that case and its repair instead of claiming the engine would fork.
- The failed pooled client is released before the conflict arm takes a second
  one, which deadlocked nothing at the default pool size and would serialise on
  a pool of one.
- ROUTINE_ROW.revision is the md5 the fixture's definition actually has, with a
  test that keeps the docblock's recomputability claim true.
- Tests for three arms whose mutations survived the review: a NULL may_replace,
  a check_function_bodies this server answered nothing for, and a SQLSTATE in
  the table winning over the message it carries.
- The search_path column nothing reads is recorded as such.
… not (#789)

The docblock on the editable predicate said the route's stripping "IS NOT
WRITTEN YET" and that acceptsSourceEdits had no reader outside
kindAcceptsSourceEdits. Both halves were true when that file landed in this phase
and both are false now: boundSourceDocument reads kindAcceptsSourceEdits and
passes the answer to stripEdit at object-route.ts:549 and :588.

Task 10's reviewer found it and correctly did not edit another task's file. It is
corrected here rather than left because a stale claim about a guard reads as a
statement that the guard is absent, and the next person to touch this predicate
would have built the stripping a second time.

The half that matters to this file is unchanged and is now said as such: a host
never reaches boundSourceDocument, so on the embedded shell edit arrives exactly
as the host wrote it, and the route's stripping covers the standalone path only.

Refs #789
…789)

The collateral arm's docblock said the order of `setPreview`, `endEdit` and `onApplied`
was load-bearing, and gave a reason: a re-read landing first would paint one frame with no
dialog. That was an inference written in a measurement's voice. Mutation window, moving
`setPreview` last: this pane's suite 95 pass 0 fail and `tests/components/studio/
source-tab.test.tsx` 33 pass 0 fail. React batches all three inside one promise
continuation, so no frame can land between them. The comment now says that, with its
numbers, and the code keeps the order it reads best in.
…the first bracket (#789)

routineIdentityHeader took the FIRST ")" as the end of the routine's header, so everything
after it was never compared. Four ordinary shapes carry a ")" before the parameter list
closes, all measured on PostgreSQL 18.4 (Debian 18.4-1.pgdg13+1): a DEFAULT holding a call
(DEFAULT abs('-1'::integer)), a DEFAULT holding one inside a string literal, a quoted
function name (app."we)ird") and a quoted parameter name (app.pn("a)b" integer, c integer)).

Measured through the provider against a live 18.4 before the fix: the build ACCEPTED
b integer DEFAULT 2 -> b bigint DEFAULT 2 on app.dc and sealed a plan, and the apply then
answered applied-elsewhere with undone true, because the post-condition found the addressed
row unrewritten and rolled the unit back. The same statement outside the guarded unit is the
silent fork: two pg_proc rows, 16385 (integer) and 16386 (bigint).

The scan counts nesting and skips single- and double-quoted text, which is the idea the Trino
provider's segment scanner already uses; the docblock says why the two cannot be one scanner.
An engine rendering is always cut correctly, so a mis-cut can only ever come from the submitted
text and can only ever refuse, never accept.

Measured after the fix, live: the same argument-type change is refused with both whole headers
shown, and an ordinary body edit on all five paren-carrying fixtures plus app.np() builds and
applies. The suite went from 268 pass 4 fail to 272 pass 0 fail.
…vacuous (#789)

The scan skips a "'" inside a double-quoted identifier and a '"' inside a string literal, and
nothing built either population, so both guards could be deleted and the suite stayed green.
Both shapes are real and were measured on PostgreSQL 18.4 (Debian 18.4-1.pgdg13+1):
app.mix("a')b" integer, c integer) and app.mix2(a text DEFAULT '")'::text, b integer DEFAULT 1)
are what pg_get_functiondef rendered back for them.

With the fixtures in, dropping "&& !double" fails 1 test and dropping "&& !single" fails 1,
where both survived before.

The no-closing-parenthesis arm also gained the assertion that the refusal sentence carries the
whole submitted text: mutating that arm to an empty string kept every refusal in place and only
emptied the sentence, so the arm was uncovered by anything but a line counter.
… cross-guards are for (#789)

The docblock listed four measured shapes that carry a parenthesis before the parameter list
closes and said nothing about why each quote flag ignores the other. Both mixed shapes are real
on PostgreSQL 18.4 (Debian 18.4-1.pgdg13+1) and are now named with the rendering the engine
answered for them.
#789)

The build refused an edited header by comparing `text.split("\n")[0]` on both
sides, which certifies the identity only while the whole parameter list sits on
line one. Measured on trinodb/trino:476 in container `trino-t32` on host port
18532 on 2026-09-15, that is not always true: the formatter renders an
identifier verbatim, so a ROW field name holding a newline makes the server's
own `SHOW CREATE FUNCTION` text carry the rest of the parameter list on line
two. Driven end to end through this provider against that container, an
argument type edited below line one was ACCEPTED by the build, the apply
SUCCEEDED, the outcome was `applied-elsewhere` with `undone: false`, and
`SHOW FUNCTIONS FROM memory.app` answered two `nlrow` rows where it had
answered one. Trino has no transaction to take that back and this design will
not issue a DROP, so the reader is left with a second function this product
will not remove.

The wrap the old comment named as unmeasured is now measured and it does not
happen: 200 parameters with deliberately long names answer 11,845 characters on
ONE line. It was the identifier and not the width, so the old comment's "fails
safe either way" was true of the population it imagined and false of the one
that exists.

`trinoCreateIdentity` reads the qualified name and the argument type list off
the same parenthesis scan the overload resolution and the post-apply
verification already use, so a build and an apply cannot disagree about which
object a text names. The identity is the name and the argument types and
nothing else, measured on the same container: a changed argument type forks, a
changed name forks, and a changed return type and a renamed parameter are
replaced in place, one row before and one after. So a parameter rename and any
whitespace inside the header now BUILD, where the first-line comparison refused
them as false refusals.

Seven tests, RED at 190 pass 6 fail against the old check and GREEN at 197 pass
0 fail. Five mutations killed and two survived, all recorded in the report:
putting the key back on the first line kills 2, deleting the empty-name guard
kills 1, deleting the no-parenthesis guard kills 1, comparing the signature
alone without the name kills 1; the "head ran out of tokens" guard changed no
answer over 27 enumerated head shapes and is deleted with that measurement in
its place, and the key's length framing survived and is kept as a judgement
that says so.
…nature (#789)

The control that catches a wrong build-time identity rule compared
`trinoCreateSignature(step.text)` against the signature the addressed row was
found by, and a signature is the ARGUMENT TYPES alone. So a statement declaring
`memory.app.plus_two(x bigint)` over a plan addressed to `plus_one(bigint)`
compared EQUAL: the addressed row came back unchanged, the apply answered
`applied`, and the revision token handed back was the digest of a text the
reader's edit never reached. Measured on trinodb/trino:476 in container
`trino-t32` on host port 18532 on 2026-09-15, a changed NAME forks exactly as a
changed argument type does, `rename_probe2` beside `rename_probe`, two rows
where there was one. That is the same class of hole as the first-line rule this
control exists to catch, in the control itself.

It now reads the SENT bytes and the pre-image the digest check just accepted
with the same `trinoCreateIdentity` the build compares with. The keyword prefix
is the caller's, so the build reads `CREATE FUNCTION` and the apply reads
`CREATE OR REPLACE FUNCTION` off `TRINO_REPLACE_CLAUSE`, which is also what
keeps a reader's own `CREATE OR REPLACE` refused at build time.

`wrote` now carries the name the statement DECLARED, qualified, rather than the
addressed bare name: on a name fork the addressed name is precisely the object
that was not written, so printing it named the wrong object in the one sentence
whose job is to name the right one. The three assertions that pinned the bare
form move with it.

Two tests, both RED first: the name fork answered `applied`, and a plan whose
statement carries no readable header now answers `applied-elsewhere` with no
name. 199 pass 0 fail. Mutations: putting the signature comparison back kills
the name-fork test.

The parameter-type reading is one writer again, `trinoArgumentTypesIn`, because
the identity holds the parenthesis offsets already and a second copy of "split,
then drop the names" is a second reading that can drift from the one the whole
overload comparison rests on.
…ll clears the document (#789)

X24 was closed one level too high. Holding the preview session open is not enough: the
same arm calls onApplied, and both shipped shells answer that by running
onSourceChange({ document: undefined, failure: undefined, readAtToken: undefined }).
The dialog's mount sat inside the pane's part !== undefined branch, which the document
chooses, so the arm that created the report told the shell to unmount it. Measured
against the real Studio with a throwaway copy of source-tab.test.tsx: with the re-read
hung, report GONE and the loading region in its place; with the re-read answering 500,
report GONE for good and the loss surviving only in the audit ring, which is X24 word
for word.

The mount moves to the foot of the pane, outside every document branch. Nothing it
renders needed the part, every prop comes off the preview session, and Radix portals it,
so the layout is unchanged. The same probe now prints PRESENT on all three paths.

EditHarness could not model this at all: document={state.document ?? props.document}
fell back to the static prop, so a clear was unrepresentable and the round-1 test was
green over a state neither shell reaches. It now keys on presence, passes failure and
readAtToken through, and takes the shell's applied patch and a reader the test can hang
or fail. Two new tests, RED at 95 pass 2 fail before the move and 97 pass 0 fail after.
Reverting the harness alone also kills both, because each asserts the pane behind the
dialog.

Two docblocks corrected. git show 3a4511a moves one behavioural line, functions.length
< 2 to === 0, inside libraryCollateral, which is the BUILD's warning; the outcome's
producer, disappeared.length > 0, landed in 1d6ac88 and was untouched, so 18a99ce's
claim that D84's fix grew this outcome's population is false. The warning population
grew.

Refs discussion #778.
…t is about to

X24 made the collateral report reachable, and the frame around it still described a
dialog that had not run yet. MEASURED by reading `dialog.textContent` in each of the
twelve states this component can reach: all twelve opened with `Apply this definition`
and `Nothing runs until you press Apply.`, and all eleven that draw a diff labelled the
pre-image column `On the server now`. On the state X24 made reachable, an apply that
SUCCEEDED and destroyed a sibling function, all three of those are false at once.

`applyFrame` answers the three together, because they are one fact: the title, the
disposition sentence, and whether the left column may still claim the server. The right
column and the identity line move to the past tense on any state that carries an
outcome. `interrupted: "unknown"` gets the pre-apply column label rather than a claim
about the server, because nobody knows there and a header claiming it would be a guess
printed as a fact.

Nine tests, one per reachable disposition, asserting the title and the description by
value rather than by presence: every one of these strings already rendered, and every
one of them was wrong about what had happened.

#789 Phase 3, discussion #778.
…in its alert on a success

Three of the region's arms report an apply that SUCCEEDED, and `applied-with-collateral`
is the whole reason X24 keeps it on screen after one, so `FailureRegion` named an
outcome class it no longer only serves. The attention level is unchanged and that is
deliberate: `role="alert"` stays, because an apply that destroyed a sibling function
deserves the alert an engine refusal gets.

The old assertion for that role drives a REFUSAL, which is a population that does not
contain the case, so the success arm is now asserted too. MEASURED: dropping
`role="alert"` fails 2 tests with the new one and 1 without it.

The `data-testid` is deliberately NOT renamed and the docblock says why with the number.
MEASURED: renaming it to `object-source-apply-outcome` fails 10 tests across three
suites that read it, `ObjectSourceView.test.tsx` 6, `embedded-source.test.tsx` 3,
`source-tab.test.tsx` 1, plus one Playwright assertion on the code child, and none of
those four files belongs here. The rename is two string substitutions and it has to
travel with an edit to those four files in one commit.

#789 Phase 3, discussion #778.
…by stripping quotes (#789)

The identity key was built from the form the overload RESOLUTION needs, which lower-cases each
rendered type and strips every space and every double quote. That is the only form the engine's
two renderings of one signature agree on, and it is far more than this engine's identifier rules
allow: two DISTINCT types reduce to one key whenever they differ only inside a quoted identifier.

Measured on trinodb/trino:476 in container trino-t32r1 on host port 18633 on 2026-09-15, three
pairs that reduced to one key and coexist as two rows in SHOW FUNCTIONS FROM memory.app:
row("a b" bigint) beside row("ab" bigint), row("Ab" bigint) beside row("ab" bigint), and
dq(row("a""b" bigint)) beside dq(row(ab bigint)). Driven end to end through this provider against
that coordinator with one rowf overload present, deleting the one space inside the quoted ROW
field name BUILT, the apply answered applied with a revision token, and the catalog then held two
rowf rows. The reader was told the edit landed and never saw the applied-elsewhere sentence.

The key now folds identifiers the way Trino does: an undelimited identifier folds to lower case,
a delimited one is the text between its quotes with "" standing for one quote, and a delimited
one that spells a legal bare lower-case identifier is emitted bare because it names the same
thing. Measured on the same container, each applied over the other with the row count unchanged:
row(ab bigint) over row("ab" bigint), row(Ab bigint) over the same, and memory.app.PLUS_ONE over
memory.app.plus_one. A quoted FUNCTION name folds its case as well, CREATE FUNCTION
memory.app.casefn over an existing memory.app."CaseFn" answering ALREADY_EXISTS errorCode 12,
while a quoted ROW FIELD name does not, so the two halves of the identity fold by different
rules. The overload resolution keeps the stripped form, which is what it needs.
… tense as well

The easy half of this item is a conditional title; the trap is a body that still reads as
a plan under it. INVENTORY, read top to bottom on a completed apply: between the outcome
region and the diff sit the session-pin line and the revision note, and both described
what was about to happen. A reader who has just been told their sibling function is gone
was also told that LibreDB "runs" this apply with search_path pinned and "re-reads the
definition first and refuses if it differs".

All three now key off the same `sent` flag the headers and the identity line use. The
`unavailable` arm gets its own past tense rather than sharing one, because it is the arm
that admits the apply could not tell.

MEASURED, three mutations back to the present tense: the pin line fails 1 test, the
`compared` note 2, the `unavailable` note 1.

#789 Phase 3, discussion #778.
…r cannot answer (#789)

The control compared the sent statement's identity against the addressed one with the same reader
the build refuses with, so it was guaranteed to be blind to exactly the population the build's
rule was blind to. Measured on trinodb/trino:476 in container trino-t32r1 on host port 18633 on
2026-09-15: a live fork through the shipped provider answered applied rather than
applied-elsewhere, because the second, independent chance to tell the reader the truth was spent
on the same reading.

It now also asks the engine. SHOW CREATE FUNCTION answers one row per overload and both reads the
apply already makes carry that count, so comparing the count before and after costs no round trip
and reads no identity at all: if the write ADDED an overload of the addressed name, the statement
created an object instead of replacing one, whatever any reading of its text says. The question is
asked only where the two readings of the sent text disagree, that is where the name and the
argument types as WRITTEN differ from the pre-image's byte for byte while the folded identity
calls them equal. A header the reader never touched cannot reach it, which is what keeps another
session's sibling overload reported as applied, the outcome its own test pins.

Also, from the same review: the reader is renamed trinoCreateFunctionIdentity and its docblock
records that it is FUNCTION shaped, taking the first top-level parenthesis pair as a parameter
list, which a view has never had; the view deferral comment names it as the second thing to fix
before that kind is un-deferred; and the build's docblock no longer claims that the current side
of the comparison is the path segment or that the comparison is the quote-free form, neither of
which was true of the code beside it.
…both halves of its control (#789)

Three mutations survived the fix round's first pass, and each one is a step of the reading that
nothing was measuring. Dropping the whitespace outside a quoted identifier changed no answer:
measured on trinodb/trino:476 in container trino-t32r1 on 2026-09-15, CREATE OR REPLACE FUNCTION
memory.app.dec(x decimal(10,2)) over decimal(10, 2) replaced it in place, one row before and one
after, re-rendered back with the space, so that step is correct and now has the test that says so.
Comparing only the argument types in the post-apply control's byte-exact trigger changed no answer
either, because no apply test moved the NAME in a way the folded reading calls harmless: the fork
arm and its control now drive both halves, a re-cased type and a re-cased qualified name, each one
measured applied in place on that container.

Every mutation re-run against this tree, baseline 207 pass 0 fail: the key back to the stripped
comparison form 201/6; the doubled-quote arm deleted 206/1; the name half no longer folding quoted
case 206/1; the type half folding it 206/1; a delimited identifier never emitted bare 206/1; the
whitespace step dropped 206/1; the engine-count control removed 206/1; the count asked with no
byte-exact trigger 206/1; the count fired on "did not shrink" 206/1; the overload count a constant
206/1; the trigger on the name alone 206/1; on the types alone 206/1; comparing type-list lengths
only 206/1; and the post-apply rule back to trinoCreateSignature 206/1.
…ady ran (#789)

Fix round 1 of task 33, three defects a reviewer measured on the finished screen.

THE CONSEQUENCE ROW. `describeConsequence` opens all eight of its sentences with
`Applying this ...`, and the dialog drew that list on the `failed` state as well. On the
collateral screen, which is the screen this item exists for, the reader was told
"The engine applied this change and it destroyed something else" and
"FUNCTION LIST LIBRARYNAME orders answers: order_count is gone." and then, one row lower and in
the same warning styling, "Applying this replaces the whole container ... the library registers
order_total and order_count." The prediction is now drawn only while there is still an apply for
it to predict, `preview` and `applying`. The population is not a corner case: `libraryCollateral`
attaches `replaces-whole-container` to every Redis function plan whose library registers a
function, and that provider is the only day-one producer of `applied-with-collateral`.

THE REFUSAL TITLE. `The engine refused this change` was printed over a `guard` refusal, where
this product's own code says no engine spoke: a pooled client somebody else left in a transaction
is refused before any bytes leave (D78, day-one PostgreSQL). That arm now reads
`LibreDB refused this change`. Every other class at apply time is a verdict an engine reached.

THE EXPIRED DISPOSITION. `Nothing was applied and the definition on the server is unchanged.` is
a claim about a server this client has not spoken to for up to fifteen minutes, which is the
window the `conflict` state exists for. It now reads `Nothing was applied by this attempt.`

Tests: the whole screen is read on a plan that carries a consequence, a pin and a `compared`
revision, with a preview control, so no future-tense sentence can survive on a completed apply;
the `applying` body is asserted by value, which is where both surviving mutations lived. The
frozen testid's handover count is corrected from 10 tests over five files to 24 over six.
…ly produces it (#789)

The whole-screen future-tense read was taken over a statement plan only. Redis is the only
day-one producer of `applied-with-collateral` and every Redis plan is `medium: "command"`, so
the screen a reader meets first draws one row that read never saw:
`This apply sends a command, not a statement.`

MEASURED as a survivor: rewriting that row to `This apply will send a command` left the suite at
65 pass, 0 fail. With the command plan in the read it is 66 pass, 1 fail.

The new guard is a SHAPE as well as a list. Four patterns (`will`, `Applying this`, `Nothing
runs`, `re-reads`) are matched over `dialog.textContent`, so a future-tense row nobody
inventoried fails them too, and each negative is paired with a control asserting all four DO
match on the matching preview, in both media. The `-payload` assertion is the non-vacuity check:
suppressing that block on a completed apply gives 66 pass, 1 fail rather than a silent pass.

The statement list also gained `I understand what this will replace.`, which the first inventory
missed: it is a future-tense sentence, and the ack row is gated on `preview`/`applying` so it is
correctly absent from a completed screen.
…pin the edit the fix refuses (#789)

PAREN_HEADER_FIXTURES holds seven shapes and its guard asserts seven, while four places in the
same block still said five and the listed population left out app.mix and app.mix2, the two
mixed-quote shapes the scan's two cross-guards exist for. The next reader would have taken those
two for inventions.

Re-measured on 2026-09-15 on a postgres:18 container this task created and removed, answering
PostgreSQL 18.4 (Debian 18.4-1.pgdg13+1): all seven CREATE FUNCTION statements plus app.np() were
applied to a fresh database and md5(pg_get_functiondef(oid)) answered the eight constants in this
file byte for byte, so the population is re-runnable rather than transcribed.

Adds the narrowing c2437ec introduced, which was written down nowhere: changing a LATER
parameter's DEFAULT is an ordinary in-place replace and is now refused as identity. Measured on
the same container, app.dc DEFAULT 2 to DEFAULT 3 left one pg_proc row with oid 16385 unmoved and
xmin moving 754 to 762. The test carries the pre-c2437ec1 reading as its control, so it asserts
the refusal is about where the header ends and not about something else.
… commit made stale (#789)

RE-MEASURED at this commit, rename applied to this file alone and nothing else touched:
`ApplyPreviewDialog.test.tsx` 51 pass / 16 fail, `ObjectSourceView.test.tsx` 91/6,
`embedded-source.test.tsx` 41/3, `source-tab.test.tsx` 32/1. That is 26 tests over six files, not
24, and `grep -o '"-failure[a-z-]*"'` on the owning suite gives 26 call sites, not 24.

The old number was taken before the two tests that landed in the same commit as it, each of which
added a `-failure` call site, so it was shipped as a measurement of a tree it did not describe.
The docblock now says that a count over the suite has to be re-taken whenever the suite changes.
…lsified (#789)

The doc still told a reader the identity comparison cuts at the first `)`, still carried a
FALSE-ACCEPT paragraph that has no case left on the server's side, and still described the
acceptance run's `applied-elsewhere` row as a path a reader can reach.

What replaces them is the measured population and not the story everyone told. The headline
example, a `numeric(10,2)` first parameter, CANNOT reach this comparison from the server's side:
`pg_get_functiondef` renders parameter types through `format_type(t, NULL)` and drops the
modifier. The shapes that do carry a parenthesis before the parameter list closes are seven, and
they are listed: a DEFAULT holding a call, a DEFAULT holding either parenthesis inside a string
literal, a quoted function name, a quoted parameter name, and the two mixed-quote shapes.
Re-measured on 2026-09-15 on a postgres:18 container this task created and removed, answering
PostgreSQL 18.4 (Debian 18.4-1.pgdg13+1): all seven renderings and their md5 came back byte for
byte, and so did the typmod line.

Adds the narrowing the fix accepted, which was written down nowhere: a change to a LATER
parameter's DEFAULT is now refused although PostgreSQL replaces it in place, measured on the same
container as one pg_proc row with oid 16385 unmoved and xmin moving 754 to 762. The two halves of
"it used to apply" are given different evidence classes, because only one of them was run.
…s wrong (#789)

The handover paragraph carried the count and not the recipe. Both facts below were MEASURED in
one mutate-and-restore window at this tree, with the rename applied to this file alone:

- A whole-tree grep for `object-source-apply-failure` finds five files and 21 occurrences and
  misses this region's OWN suite, which composes the prefix and writes `testId("-failure")` at
  26 call sites. A grep-driven rename would leave the 16 failing tests there behind.
- The substitution runs over this paragraph too and turns it into a false one, "still reads
  object-source-apply-outcome ... KNOWN STALE NAME".

The 26-over-six-files count re-measured unchanged after the command-medium tests landed:
16 + 6 + 3 + 1.
… count to the array (#789)

The provider doc said a parameter RENAME and a reader-typed type modifier were "both measured on
18.4 as in-place replaces". The rename half was transcribed, never run, and is false. MEASURED on
2026-09-15 on a postgres:18 container answering PostgreSQL 18.4 (Debian 18.4-1.pgdg13+1):
CREATE OR REPLACE FUNCTION app.tmn(zzz numeric(10,2), b integer) over app.tmn(a numeric, b integer)
answers ERROR 42P13 cannot change name of input parameter "a", HINT Use DROP FUNCTION
app.tmn(numeric,integer) first, and leaves the row untouched. PostgreSQL refuses a rename through
CREATE OR REPLACE, so the build's identity refusal costs the reader nothing there and the SQL editor
the refusal points at cannot perform it either. The doc now says that, and says DROP FUNCTION drops
the routine's grants and dependents. The typmod half reproduced and stays, re-measured.

The two oid/xmin pairs in that paragraph both read 16385/754 and came from two different probe
databases, which one cluster cannot produce: an oid counter is global to the cluster. Both the
narrowing edit and the typmod edit were re-run in ONE database in ONE session, with the seven
fixtures created in the array's order plus app.np(), and the paragraph now quotes that session and
says the numerals are provenance and not the claim.

The count of the header population was stated in nine prose places and asserted in one. The doc now
states it once and a new test in the owner suite reads that sentence out of docs/providers/postgres.md
and asserts the word against PAREN_HEADER_FIXTURES.length, so an eighth shape is a RED and not a
silent drift. The narrowing test selects its fixture by object identity instead of by array index.

Mutations: doc numeral SEVEN to EIGHT, 277 pass 1 fail; the sentence reworded so the regex finds
nothing, 277 pass 1 fail; the array reordered, 278 pass with identity selection and 277 pass 1 fail
with the index selection restored. All restored, suite back to 278 pass 0 fail.

Refs #789, discussion #778.
…x readers (#789)

The region reports three arms that SUCCEEDED and was named object-source-apply-failure.
Task 33 renamed the component to OutcomeRegion and correctly refused the testid,
because its ownership was two files and the readers span six. Done here as one
commit: four literals, six files, 47 call sites.

Worth recording how it went, because the file had already written down exactly
how it would go wrong and I did it anyway, in order.

Trap one: a whole-tree grep for the literal finds five files and 21 occurrences
and MISSES this region's own suite, which composes the id
(const testId = (suffix) => `object-source-apply${suffix}`). The literal-only
substitution took ApplyPreviewDialog.test.tsx to 16 fail, and the composed forms
were 23 "-failure" plus 3 "-failure-code", which is the 26 the docblock had
counted. So it is four literals over six files, not two over five.

Trap two: the substitution rewrites the paragraph describing it into a false one,
because that paragraph quotes the old name. It came out saying the new name was a
KNOWN STALE NAME.

Both are now in the docblock as the record of a rename that happened, rather than
as a forecast of one that has not.

Verified by exit code: format, lint, typecheck, run-components (46 groups) and
tests/unit all 0; the owning suite 67 pass 0 fail; zero occurrences of the old
literal in src, tests or e2e.

Refs #789
…789)

Fix round 1 of task 34 bound one sentence in docs/providers/postgres.md to
PAREN_HEADER_FIXTURES.length and then, in the same commit, wrote two more
sentences stating that count in prose with nothing reading them: the doc's
in-place-replace paragraph for app.dc and the docblock of the DEFAULT-change
test. An eighth fixture would have gone red once, at the bound sentence, and
left both prose counts wrong with the suite green, which is the drift the
round 1 guard existed to remove.

Neither sentence needs the count, so both now say "the shapes above" and
"the fixtures above", and a second test refuses any spelled numeral or digit
written directly in front of `shapes` or `fixtures` in either file. Measured
before the prose was corrected: the doc leg and the test leg each failed on
their own, 278 pass 1 fail, Ran 279, and the same two sentences are what
grep -nEi finds over the two files. Mutations after the fix: reinserting
"seven shapes" in the doc, "seven fixtures" in the test file, and the digit
form "8 fixtures" each give 278 pass 1 fail with the new test dying by name,
and the round 1 mutation SEVEN to EIGHT still kills its own test.

Two prose quotations of the numeral went with them, `toBe(7)` and the doc's
own word, because a quotation of a bound literal goes stale the same way. The
docblock's two line-number citations are replaced by names for the same
reason. Also repairs a duplicated "the" and an over-width line in the
re-measurement paragraph, both found by the round 2 review.

279 pass 0 fail, Ran 279 tests across 1 file. format, lint, typecheck exit 0;
bun test tests/unit 9507 pass 0 fail.
@sonarqubecloud

Copy link
Copy Markdown

@cevheri
cevheri merged commit 249b076 into main Sep 15, 2026
27 checks passed
@cevheri
cevheri deleted the feat/object-ddl-apply-phase-3 branch September 15, 2026 00:16
cevheri added a commit to nycjay/libredb-studio that referenced this pull request Sep 15, 2026
Brings in the object model (libredb#811, libredb#820, libredb#831). Conflicts resolved in
database-compose.yml (both services kept), src/lib/db/factory.ts (main's
comment, db2 kept in the type list), bun.lock (regenerated by bun install)
and the operator bundle CSV (taken from main, regenerated in a later commit).

This commit does not typecheck on its own: Db2Provider still implements the
removed getSchema() instead of the object methods. The next commit converts it.
cevheri added a commit that referenced this pull request Sep 15, 2026
D84, X24 and X25 describe defects that waves 13 to 18 of #789 closed. The
gate task that would have deleted them ran before those waves, so they
shipped with #831 describing code that no longer exists.

Verified on main 249b076 before deleting:

- D84: redis.ts:637 is `functions.length === 0`, not `< 2`
- X24: ObjectSourceView.tsx:403 APPLIED_OUTCOMES is `new Set(["applied"])`
- X25: StudioWorkspace.tsx handleApplied clears `document: undefined`

The two section index counts move with them: drivers D1-D83 at 41, studio
X2-X23 at 16. The docblocks and test comments that cite the three ids are
left alone; they say why the code has its shape, and that history is true.
cevheri added a commit that referenced this pull request Sep 15, 2026
…emicolon rider (#789) (#839)

* docs(backlog): delete three entries whose defects are fixed on main

D84, X24 and X25 describe defects that waves 13 to 18 of #789 closed. The
gate task that would have deleted them ran before those waves, so they
shipped with #831 describing code that no longer exists.

Verified on main 249b076d before deleting:

- D84: redis.ts:637 is `functions.length === 0`, not `< 2`
- X24: ObjectSourceView.tsx:403 APPLIED_OUTCOMES is `new Set(["applied"])`
- X25: StudioWorkspace.tsx handleApplied clears `document: undefined`

The two section index counts move with them: drivers D1-D83 at 41, studio
X2-X23 at 16. The docblocks and test comments that cite the three ids are
left alone; they say why the code has its shape, and that history is true.

* docs(objects): name what enforces the write-path bound and kind

The stripEdit docblock still said nothing on the write path enforced
either fact and closed with an until-it-does sentence. Both halves
landed: the edit-plan route re-resolves the kind on the connected
provider, and the submitted text plus all three day-one buildObjectEdit
reads are held to EDIT_CHARACTER_LIMIT. The paragraph now names the
enforcer of each half and where it sits.

Closes DOC5.

* fix(postgres): re-resolve the editable kind in the object edit apply

Trino and Redis both open applyObjectEdit with requireEditableKind and
PostgreSQL did not, so a plan carrying a kind this provider does not
declare editable reached the round trip and was refused only afterwards
by the re-read's address derivation, with the wrong sentence and the DDL
already sent. The return is discarded, as Redis discards it: the apply
sends the plan's bytes verbatim and needs nothing off the spec.

No shipped path reaches it. The edit-apply route asks the same question
on the connected provider and the statement was minted by a
buildObjectEdit that asked it too; the population is a @libredb/studio
consumer calling applyObjectEdit directly.

Closes D83.

* fix(objects): stop describing an unreadable apply answer as a timeout

The apply preview dialog printed "The engine stopped this statement before it
finished" for every interrupted outcome. On committed: "unknown" nothing
observed the engine at all, and that population includes the outcome the Source
pane synthesises for an apply answer isObjectEditOutcomeShape refuses, where the
engine may have finished perfectly and the answer is what could not be read.

The first line and the title now split on committed. rolled-back keeps the
measured clause, because only a provider that opened and closed the transaction
itself may claim it. unknown says the one thing true of the whole population:
this product never read an answer for the apply.

Closes X20.

* fix(postgres): refuse an editable kind with no routine statement on the read

readObjectSource built the edit affordance whenever kindAcceptsSourceEdits
was true, and that helper reads may_replace and owner, which only the
routine statement selects. A non-routine kind declared editable would
therefore have drawn offered: false with "owned by another role", which
reports a declaration drift in the words of an ownership problem.

The read now raises the build's own sentence through the build's own
address helper, before the round trip, so the two refuse the drift with
one spelling.

Closes D81.

* fix(objects): bound every host-supplied string the object edit wire accepts

The four shape predicates in object-edit-wire.ts checked shape and bounded no
string, so a refusal sentence, a revision reason, a truncation reason and each
consequence fact reached the dialog at whatever length their producer wrote
them. These are not this application's prose: a refusal sentence is the engine's
own message and libraryFact in the Redis provider builds observed from
FUNCTION LIST. The embedded seam was already covered by use-connection-adapter;
the standalone path and the shape layer itself were not.

Every prose and identifier string now takes SOURCE_CHARACTER_LIMIT, which is the
number Phase 2's isSourceDocumentShape already bounds its host-supplied rendered
strings with, and an overrun is a refused shape rather than a silent truncation
for the same reason it is there.

The unit's executable text is measured with planExecutableLength and bounded by
EDIT_BODY_BYTE_LIMIT, deliberately looser than the routes' own
EDIT_PLAN_EXECUTABLE_LIMIT. Both routes run this predicate first and the
executable bound second, and the second check is the one that can name the
number: measured with the tighter bound here, a unit one character over it made
both routes answer a shape complaint about a size problem.

Closes backlog D80.

* fix(objects): release the apply diff models before disposing them

MEASURED in Chromium on 2026-09-14, on every successful apply driven through the
UI: "TextModel got disposed before DiffEditorWidget model got reset", one
console error per apply. @monaco-editor/react disposes the original and the
modified model and only then the widget, so both go while the widget still holds
them and Monaco's own disposal path writes that line.

The diff is now wrapped in a component whose cleanup calls setModel(null) and
then disposes both models itself. A component and not an effect on the dialog,
because React destroys a deleted subtree's effects from the top down: a cleanup
on the parent of DiffEditor runs before the wrapper's in both cases, the dialog
closing and the diff alone going when a preview expires under it. The keep flags
stay false, so the wrapper is still the backstop against the retained text the
browser probe measured.

The suite's Monaco double now carries the wrapper's own unmount, transcribed
from its shipped dist, and raises Monaco's sentence when a model is disposed
while still attached to a live widget. Without the fix both new tests report it.

Closes X21.

* fix(objects): pin every source-line citation the object-route docblock writes

The DOC5 rewrite named where each half of the write-path obligation is
enforced, and one of the six pointers it wrote was invalidated twelve
lines later by a commit on the same branch: the read-definition bound
moved from postgres.ts:3090 to 3102 and 3090 became a comment inside a
helper. That is the defect DOC5 was filed for, re-created at a smaller
size, because nothing in CI reads a code-comment file:line pointer.

The guard holds every number in one place, the cited file itself. The
table in the test names the ANCHOR rather than the line, greps it,
derives the number the docblock must be writing and asserts it writes
it, so a correct renumbering costs nothing and a stale one goes red with
the number to write. Three assertions, each measured non-vacuous: the
stale one listed all five wrong citations, a probe citation added to the
docblock was reported unguarded, and a duplicated anchor was reported
ambiguous.

Five citations were wrong, not one. The four beyond the DOC5 pointer
were already wrong before this branch and sit in the same docblock under
the same "measured by grep" banner, so leaving them would have made the
new guard certify a paragraph it knows is stale: the three producers of
`edit` (postgres 2799 to 2947, trino 1249 to 1257, redis 1657 to 1674)
and the Redis decided-position range (1649-1656 to 1667-1673).

Also pins a documented fact the D81 test left unmeasured: postgres.md
says the declaration-drift refusal is raised before the round trip, so
it costs no query. Measured by moving the guard below the read, where
the thrown sentence stays identical and only the new `expect(sent)`
assertion goes red.

* test(objects): pin the command arm's executable bound and the wire's import closure

Three review findings on the D80 work, all in the two files that work owns.

The command arm of isObjectEditUnitShape survived mutation: replacing its
isWithinTheExecutableBound call with `return true` kept all 48 tests green at
100 percent line coverage, because every command case in the suite tops out at
SOURCE_CHARACTER_LIMIT + 1 and is refused by the name bound three orders before
the executable bound decides anything. The new case sizes a payload against
EDIT_BODY_BYTE_LIMIT minus the name and the argument token, so the at-the-limit
control also proves those two are counted, and the same payload as a statement
step is accepted to show the refusal is the sum. Measured: red on the mutant,
green on the module.

The isWithinTheExecutableBound docblock claimed the bound exists for "the seam
with no route in front of it, where a host's answer is a live JS object and
nothing between it and the dialog counts anything". That is false here. The
embedded shell counts the whole answer at 4,400,000 characters as it snapshots
it, edit-apply reads its body through readBoundedJson at 8,388,608 BYTES before
the parse, edit-plan measures the same unit against EDIT_PLAN_EXECUTABLE_LIMIT
on the next line, and ObjectSourceView narrows what those routes answered. So
the predicate cannot answer false today and the docblock now says so: it is a
ceiling above every bound in front of it, and the SOURCE_CHARACTER_LIMIT bounds
beside it are the ones that bite.

The isStep docblock named EDIT_PLAN_EXECUTABLE_LIMIT while pointing at the
helper that exists to reject that constant. It now names the one the code uses.

The module's "NO SERVER IMPORT" rule stopped holding by construction when this
module gained its first value imports, so a test walks the value-import closure
and fails on the first bare specifier anywhere in it. Measured non-vacuous: a
temporary `import { SignJWT } from "jose"` turns it red, and the same walk from
object-route reaches jose two hops out.

* fix(objects): say the disposition, not the transport, on an unknown apply

X20's first replacement clause, "The apply was sent and LibreDB never read an
answer for it.", is false for the largest part of the population it prints for.
`ObjectSourceView.landApplyError` turns EVERY rejection of `applier.apply`
except `EDIT_PLAN_INVALID` into `interrupted`/`unknown`, and
`source-applier.postJson` rejects on every non-ok status carrying the route's
own sentence, so a proxy's 502 arrives here WITH the answer it produced.
Measured through the suite's own shapes, the shipped render was:

  The apply was sent and LibreDB never read an answer for it. Whether it was
  applied is unknown.The apply failed: HTTP 502.

Trino is the second member: TRINO_APPLY_VERDICT maps timeout and cancelled to
interrupted from the coordinator's own EXCEEDED_TIME_LIMIT and
ADMINISTRATIVELY_KILLED fault names, i.e. from an answer read in full. Pointing
the other way, "the apply was sent" is equally unmeasured: an appFetch rejection
on a dead network reaches the same arm, and `applyFrame` already tells the same
reader "Whether it reached the server is unknown.", which the body line
contradicted.

The clause now states the DISPOSITION, which is the one thing every member
shares: "Whether it was applied is unknown: LibreDB has no answer that says
whether it landed." The split on `committed`, the titles and the frame are
unchanged.

* docs(backlog): file the half of X21 that was deleted without landing

X21's "Done when" had two conjuncts: the widget releases the models before they
are disposed, AND the E2E spec can assert an empty console after an apply. Only
the first landed in ef37e436; the entry was deleted with the second undone and
nothing in the repo recorded that it is owed.

`e2e/object-edit.spec.ts` does install a console listener, but
`watchForCspViolations` keeps only messages containing "Content Security
Policy", so it cannot see the Monaco disposal error or any other class. The
symptom X21 was filed for was therefore never re-read after the fix, which is
pinned only by a component test against a hand-written Monaco double.

X26 states that, and says why widening the CSP filter is not the fix on its own.

* test(objects): drive the release's null arm, and stop repeating a count nobody measured

Two minor findings on ef37e436, both inside the file they were raised against.

`ReleasedDiffEditor`'s `if (mounted === null) return;` was never taken by any
test: the double fires `onMount` synchronously, so the ref was always written.
The guard shares a physical line with its return, so the line gate read 100
percent with the branch dead, and replacing it with a throw kept all 71 tests
green. The double now models Monaco's loader not resolving, which is how the
branch is reached in the product: `@monaco-editor/react` calls `onMount` only
once its loader has, so a dialog closed or a preview expiring mid-load unmounts
a diff whose ref was never written. Verified by removing the guard: the new test
is the only one that fails.

X21's "one console error per apply" was repeated as measured and disagrees with
the mechanism the same docblock cites: `diffEditorWidget.js:233-240` subscribes
`onWillDispose` to BOTH models, so the sentence is raised once per model
disposal. MEASURED with the double by deleting `setModel(null)`: four, two from
this component's disposal and two from the wrapper's, which without the release
still reads both models out of the live widget. The count is dropped rather than
restated, since the Chromium reading was of the sentence's presence.

* docs(objects): correct five sentences that D80's fix made false

c18cc43a bounded every string the four object-edit-wire predicates accept.
Five places in the tree still argued from the opposite, and one of them
handed the reader a grep that now answers against the sentence it supports.

Each is corrected to what is true rather than edited to drop the false
clause, because the surrounding paragraphs reason FROM the old claim:

- use-connection-adapter.ts closed on "the standalone half is filed rather
  than claimed as covered". It now says the per-string half is closed on
  both shells, and why the whole-answer bound is still not redundant: it
  measures the sum, and it measures it before any predicate walks the object.
- source-applier.ts reported the gap as open. It is closed; the paragraph's
  own reasoning about who owns the bound was right and is kept.
- ApplyPreviewDialog.tsx argued from a 50 MB step text being a well-formed
  plan. It no longer is. The band that still reaches this dialog is between
  EDIT_BODY_BYTE_LIMIT and EDIT_PLAN_EXECUTABLE_LIMIT, so the example moves
  to eight million characters and the bound keeps its reason.
- The two test comments follow the same two corrections.

* fix(query): end the transaction one statement left open on a shared handle

POST /api/db/query took a provider from the process-wide cache and executed,
with no finally and nothing that asked whether a transaction was open. That is
the leak #823 closed on /api/db/multi-query, reached by one word instead of by
a script.

Driven live on PostgreSQL 18.4 through this handler, real provider and real
cache, only the session mocked. A lone BEGIN answered 200 and released its
pooled client in status T. The next request on the same cached provider, which
is any other signed-in user of that stored connection, ran CREATE TABLE inside
the stranger's transaction: 200, same client object, and an independent reader
saw no such table. BEGIN followed by a statement naming a missing relation left
the client in E and the next request answered HTTP 500 "current transaction is
aborted". After the fix the same two runs leave the client in I, the next user's
CREATE is durable, and the caller is told openTransaction: "rolled-back".

The finally and the type guard are the ones /api/db/multi-query already spells,
so there is one spelling of this. What the route ends is the transaction its own
statement left open, before the handle is reachable by anyone else, so the only
work it can discard is the caller's own; the interactive session
POST /api/db/transaction drives holds a client of its own and is never touched.

Closes D74.

* fix(db): let an SSH-tunnelled connection build and apply an object edit plan

getOrCreateProvider rewrites host and port to the tunnel's local endpoint
before the provider is constructed, and every sealing provider digests
this.config. The edit routes recompute that digest from the record the
request resolved, which still names the far end, so the two could never be
equal: 100 percent of tunnelled connections were refused EDIT_PLAN_INVALID
with no sentence saying why (X23).

The factory now carries the pre-rewrite endpoint alongside the rewritten
host and port, under a symbol key, and connectionFingerprint frames that
when it is present. The symbol is the access control: JSON.parse can
produce no symbol key, so no stored or posted connection can decide the
digest, which is the footing ProviderExecutionContext already has. It is
not a field on DatabaseConnection, whose keys are an exhaustive write list
for three readers that classify what a user may fill in.

All three rewrite sites carry it: getOrCreateProvider,
acquireExecutionProfileProvider and withOneShotTunnel. The bastion stays
framed separately by tunnelRoute, so two databases behind one bastion and
one database behind two bastions still answer different digests. A
tunnelled provider built without the marker still answers the local
endpoint and is still refused: the compare was not widened.

Driven end to end against a real bastion forwarding to PostgreSQL 18.4:
build, apply and restore of app.order_total(integer) all land, the two
digests agree, and the pre-fix digest does not. That drive is kept as
tests/live/ssh-tunnel-edit-plan.ts, opt-in and not collected by the core
runner. The CI half drives both sides through the real factory, and the
non-tunnelled digest is pinned byte for byte.

* fix(objects): refuse a PostgreSQL edit whose text carries more than one statement

A PostgreSQL object edit spliced the reader's text into one parameterless simple
query, and PostgreSQL runs every statement in one of those. Measured live on 18.4:
the definition of app.order_total(integer) followed by a semicolon and
DROP FUNCTION app.r19f1_victim(); built with consequences: [], applied at HTTP 200
with a plain "applied", and the victim routine's count went 1 to 0, with the dropped
routine named in neither object_edit audit event.

The count is not ours to take. A dollar-quoted body may carry any number of
semicolons and a BEGIN ATOMIC body carries them by construction, so buildObjectEdit
asks the engine: it parses the submitted text alone as a named prepared statement
inside a transaction block it has poisoned with SELECT 1/0, then rolls that block
back. In an aborted block the server performs no parse analysis, no planning and no
execution, and its multi-command check still runs because it precedes the
aborted-block check in exec_parse_message. 25P02 is one statement and the build
proceeds; 42601 refuses, with the engine's own sentence and position for a grammar
error; anything else refuses unsupported, because the count was never established.
A pooled client somebody else left in a transaction is refused rather than probed,
since the rollback would destroy their work.

applyObjectEdit now counts the results the round trip answered. A simple query
answers one result per statement, so the emitted unit answers four, and anything
else is interrupted with committed: "unknown" rather than a plain applied. That is
what makes the audit event's claim as wide as the round trip.

Measured through the provider on 18.4 on 2026-09-15: the rider is refused at build
and the victim's count reads 1 before and after, while the legitimate edit, whose
body carries a semicolon inside $function$, and a BEGIN ATOMIC body of two SELECTs
both still build and apply.

docs/providers/postgres.md and docs/SECURITY.md control 3.6 now describe the guard
and say what it does not cover. The two postgres.ts line numbers object-route.ts
cites moved with this diff and are updated with it.

* fix(query): stop ending a transaction this route cannot name

a5212482 gave POST /api/db/query a finally calling provider.endOpenQueryTransaction().
It closes the D74 leak serially and destroys other people's committed work under
concurrent traffic, so it is removed and D74 stays open.

endOpenQueryTransaction() acts on PostgresProvider.lastQueryClient, one field that
query() overwrites on every call for every concurrent caller of the provider cached
per connection id. The client it rolls back is whichever client anybody recorded
last, not the client this request's statement ran on.

Measured 2026-09-15 on PostgreSQL 18.4 through the shipped handlers, real provider
and real cache, each arm twice with a pre-change control and nothing thrown. A plain
SELECT pg_sleep(3) sent here rolled back a concurrent /api/db/multi-query script
mid-flight: the script answered 200, hasError false, all four statements success
including its COMMIT, and its CREATE TABLE was gone where the control keeps it. The
same plain SELECT discarded an interactive POST /api/db/transaction session's
committed CREATE TABLE while the status route still read inTransaction and commit
answered "Transaction committed". The leak itself also survived the finally: the
opener's client was left idle in transaction because another request had overwritten
the pointer before its finally ran.

The route carries the measurement as a comment and ends nothing. The tests now pin
that a request here does not discard a transaction another caller of the shared
provider opened, with the double's own ender called by hand as the control, and that
the failure path invents no transaction verdict either. D74 needs query() to name the
client its call borrowed, which is postgres.ts and not this route.

* test(query): reset the shared provider double in beforeEach

The cross-request test installs a persistent getOrCreateProvider double. Putting the
default back at the end of that test meant a failing assertion would skip the reset
and leak the double into every test after it. It is restored in beforeEach instead.

* docs(db): correct four sentences the X23 fix made false, and guard two of them

The X23 fix landed with three invented identifiers, four stale line
citations and one claim a thirty-line probe falsifies. The mechanism is
unchanged; only what the tree says about it is.

WithTunnelFarEnd argued that the far end must not be a field on
DatabaseConnection because three maps are exhaustive over keyof
DatabaseConnection, and named none of the three correctly:
CONNECTION_FIELD_RELEVANCE and CONNECTION_FIELD_CLASSES exist nowhere,
and connectionFields is a per-engine UI field list in db-ui-config.ts.
The real maps are FIELD_OWNERSHIP, CONNECTION_RELEVANCE and
CONNECTION_FIELDS. The argument was right and now the pointers are too,
held in a table the tests check from both sides: the docblock must name
exactly the table, and every row must be a real Record<keyof
DatabaseConnection, ...> declaration in the file it names.

The same commit inserted 34 lines above the tunnel branch in factory.ts
and left four factory.ts:485 pointers behind, two here and two in the
test. They are renumbered and guarded the way object-route-edit.test.ts
guards object-route.ts: the table names the anchor, the test derives the
line. That guard caught its own first renumbering being stale.

"The compare was not widened" is false in one case. createSSHTunnel
pools on the connection id alone and TunnelInfo carries no far end, so
the marker is the address the factory ASKED to forward to, never one
read back. MEASURED against the live bastion: a provider built on a live
tunnel's id with the record changed to db-elsewhere.invalid:6543 dialled
the existing forward, answered libredb_dev at 172.23.0.2, and sealed
db-elsewhere.invalid:6543, which is what the routes recompute, so that
plan verifies for a machine the statement never reaches. Before X23 it
was refused by an unequal digest rather than by a check. The three
sentences that stated the far end as fact now state what the code
enforces and name the limit; the fix needs TunnelInfo to carry what it
forwards to, which is src/lib/ssh/tunnel.ts, and is filed.

docs/SECURITY.md still described the pre-X23 frame in the paragraph that
says a change to the frame is a change to this control, so a reader of
the security posture page held exactly the belief X23 was filed about.
security:check cannot see it: it resolves links and pins row ids and
says in its own header that it does not read whether prose matches
behaviour.

* fix(postgres): narrow the non-execution claim, and pin the refusal order it rests on

The review of 54801e35 measured two exemptions to "nothing can run here".
On 18.4, inside a poisoned block, a NAMED Parse of COMMIT, ROLLBACK, END or
ABORT answers no error at all, runs, and ENDS the block the check opened;
and an empty, whitespace-only or comment-only text answers 25P02 from Bind
rather than Parse, so the named statement is created and survives the
ROLLBACK on a client that goes back to the pool.

Neither class reaches the check today. The identity refusal answers two
refusals earlier, because routineIdentityHeader renders the whole text when
it finds no closing parenthesis and neither text renders this routine's
header. So the ORDER of the six refusals is load-bearing and not only the
saving the comment claimed, and moving the count check above the identity
check would put both classes on the wire.

The claim is narrowed to what was measured, the real reason for the order is
written down, and two tests assert that neither class reaches the server,
each with the control that the probe transaction was never opened.

* docs(backlog): close D76 and X23, reopen D74 and D78 on measurement, file three

Wave 2 of the Phase 3 follow-up. The orchestrator applies every backlog
change so no two agents race the same file.

Closed: D76, the PostgreSQL semicolon rider, and X23, the SSH-tunnelled
connection that could never build a plan. Both were driven end to end
against a live engine before being deleted.

Reopened, and this is the honest half of the wave. D74's route-level fix
was implemented, measured to be WORSE than the leak, and removed: copying
the finally from /api/db/multi-query closes the serial case and destroys
other callers' committed work under concurrent traffic, because
endOpenQueryTransaction acts on a single lastQueryClient field that every
concurrent request on a shared cached provider overwrites. D74 now carries
that measurement and says no route can fix it. D78 gains the producer
inventory the entry asked for, measured across three surfaces.

Filed:

- D86, a pooled SSH tunnel serves a far end the record no longer names, so
  after X23 both sides of the seal agree on a server the statement never
  reaches. Found by the review of the X23 fix, measured against the live
  bastion, and reachable from the ordinary UI.
- D87, endOpenQueryTransaction names one shared pointer. This predates
  every entry here: /api/db/multi-query has carried that finally since
  #823. Three arms measured, each against a control, and two docblocks
  that say it cannot happen are refuted.
- D88, a multi-statement text sent to the single-statement query route
  answers HTTP 500 after both statements have already run.

Also corrects two factory.ts line citations the X23 commit's own insertion
moved, derived from the anchors rather than copied.

* docs(mysql,mssql): declare why endOpenQueryTransaction() is absent (D75)

Neither provider can name the session its own query() ran on. mysql2 3.24.4
keeps no transaction flag on Connection or PoolConnection and surfaces
SERVER_STATUS_IN_TRANS only on an OK packet, which a failed statement never
sends; mssql 12.7.2 acquires and releases the tedious Connection inside
Request.query(), so the Connection.inTransaction tedious does maintain never
reaches the provider. Both docs now say which absence it is, and a test per
provider pins the surface as absent and the doc as having declared it.

* feat(redis): end a MULTI a statement left open on the cached connection

The provider holds ONE ioredis connection and `getOrCreateProvider` caches the
provider per connection id for the whole process, so a bare MULTI sent through
query() belonged to whoever borrowed the handle next.

Measured 2026-09-15 on redis 7.4.11 through ioredis 5.11.1: after a bare MULTI
every later command on that connection answers the string "QUEUED" and does
nothing, SET, GET and even CLIENT INFO alike, while a second connection is
untouched. So the next user's command does not fail, it silently does not
happen, and the schema explorer's SCAN is queued with it.

The ask and the act are one call because they cannot be separated here: ioredis
publishes no state for a MULTI sent through call(), and the server cannot be
asked on the same connection because a CLIENT INFO inside the MULTI is itself
queued. The server's own refusal of a DISCARD, "ERR DISCARD without MULTI", is
the reading, and it costs the connection nothing. Any other DISCARD failure is
raised rather than read as an answer.

Part of D75.

* fix(studio): refuse the new-tab shortcut while an object apply is in flight

D82. The new-tab shortcut is registered on `document` by `StudioTabBar` so it
works while Monaco owns focus (#745), and Radix's modal aria-hides the tab strip
rather than removing it, so a keydown from inside the apply dialog reaches that
listener. `addTab` ends with `setActiveTabId`, and the standalone shell renders
the Source pane only for an active Source tab, so the new Query tab unmounted
`ObjectSourceView` and the apply dialog with it after the statement had been
sent. A `conflict`, a `refused` or a `failed` answer then landed on nothing: no
dialog, no banner, no throw. Only `applied` survived, through `onApplied`.

`ObjectSourceView` now publishes the in-flight window through the new optional
`onApplyInFlightChange`, and the shell refuses the shortcut for as long as it is
open, with a toast saying what the reader is waiting for. Refusing rather than
keeping the pane mounted is the smaller change and it matches what the dialog
already does in this window: while applying it withholds its close button and
prevents Escape, a press outside and every other interaction outside. The
reasoning, and the cost of the other shape, is written in the handler.

The false reachability paragraph in `Studio.tsx` is corrected rather than
deleted: it now states what was measured, and the stale-closure argument it
carries is kept, because that is still why the clear reaches the right tab.

Three tests in the standalone shell's source suite, all driving the shortcut
from the dialog's own element: the refusal with the conflict landing afterwards,
the control that the shortcut works again once the answer is on screen, and the
release of the flag when the pane goes away mid apply.

* docs(oracle): declare why endOpenQueryTransaction() is absent (D75)

query() executes every statement with autoCommit: true and closes the pooled
connection in its finally, so no transaction survives a statement for the next
borrower to inherit. oracledb 6.10.0 publishes connection.transactionInProgress
and its Thin close() rolls back an in-progress transaction before the session
returns to the pool, but neither is reachable once the connection is closed.

* docs(clickhouse,libsql,cassandra,druid): declare the endOpenQueryTransaction() absence (D75)

None of the four holds a session a transaction could survive on, and each doc
now says so in the entry's own words. ClickHouse pins no session_id and sends
one HTTP request per statement; the libSQL transport closes its Hrana stream in
the same pipeline and drops the baton; CQL has no statement that opens a
transaction and cassandra-driver 4.9.0 declares no begin, commit or rollback;
Druid SQL cannot write through this endpoint and the transport carries no
session identifier. A test per provider pins the surface as absent and the doc
as having declared which absence it is.

* docs(trino): record the endOpenQueryTransaction() absence as unmeasured (D75)

Trino carries a transaction on X-Trino-Transaction-Id, and the transport writes
and reads neither that header nor the started-transaction one, so the provider
holds nothing between statements to end. What a live coordinator does with a
transaction whose id was dropped has not been run here, so the doc records the
absence as unmeasured rather than arguing it into one of the other two.

* style: wrap the three D75 absence assertions the formatter reflowed

* docs(search): name the endOpenQueryTransaction absence on elasticsearch and opensearch

One provider implementation serves both type-ids and neither grammar has a
transaction verb, so there is nothing for the route to end. Measured 2026-09-15
through the provider: on Elasticsearch 9.1.4 BEGIN, START TRANSACTION, COMMIT
and ROLLBACK each answer "mismatched input '<verb>' expecting {'(', 'DEBUG',
'DESC', 'DESCRIBE', 'EXPLAIN', 'SELECT', 'SHOW', 'SYS', 'WITH'}"; on OpenSearch
3.8.0 each answers "Query must start with SELECT, DELETE, SHOW or DESCRIBE:
<verb>". A SELECT on the same provider straight afterwards still answers. The
transport is one stateless POST per statement, so there is no session for a
transaction to live on either.

Each doc now names WHICH absence it is, and a test pins both halves: the method
is undefined on the provider, and the doc carries exactly one of D75's three
absences.

Part of D75.

* docs(mongodb): name the endOpenQueryTransaction absence, read from the driver

MongoDB does have multi-document transactions on a replica set, so this is not
an argument from the engine's name. A transaction lives on a ClientSession
(startSession, session.startTransaction, session.inTransaction, mongodb 7.6.0)
and an operation joins it only when that session is passed in its options. The
provider holds a MongoClient and a Db and never creates a session, and query()
dispatches over a closed eleven-operation set.

Measured 2026-09-15 against MongoDB 8 in a replica set: startTransaction,
commitTransaction, abortTransaction, runCommand and bulkWrite are each refused
with "Unsupported operation: <name>", and while a separate client held an
uncommitted insert inside an open session transaction, this provider's count
answered 0.

Part of D75.

* docs(couchbase): name the endOpenQueryTransaction absence, measured on 8.0.2 CE

SQL++ transactions do work over REST, so this was measured rather than assumed.
On Couchbase Community Edition 8.0.2, 2026-09-15, against POST /query/service:
BEGIN WORK answers a txid with transactionRemainingTime 15s, so the transaction
is named by that id and not by a connection; a SELECT 1 straight afterwards
without the txid answered success with no transactionRemainingTime at all, so it
was not inside it. The transport never sends a txid, so nothing this provider
sends can join one, and there is nothing for it to end: ROLLBACK WORK with no
txid is refused with code 17002, and reusing the txid after the window answered
code 17010 "Transaction timeout", so the cluster ends it itself.

Part of D75.

* docs(libredb): name the endOpenQueryTransaction absence

The command grammar is a closed set of five verbs and the dispatcher refuses
everything else by name. Measured 2026-09-15 through the provider against
@libredb/libredb: begin, commit, rollback, transact and start transaction are
each refused with "Unknown command <verb>. Supported: get, put, delete, prefix,
range", and a put straight afterwards still writes. The kernel's transact() is a
single atomic call rather than a session that stays open, and no verb reaches
it.

Part of D75.

* fix(objects): build the PostgreSQL fork fixture, and correct six sentences D76 left behind

The fixture half is D77. docker/postgres-init/03-object-fixture.sql held no
routine whose parameter list closes after a closing parenthesis inside a string
literal, so the identity header's parenthesis scan, and the applied-elsewhere
outcome it exists to keep a reader away from, had no live producer here at all:
the seven shapes the suite measures were constants, and the only producer of
applied-elsewhere on this engine was a driver double. The fixture now builds
app.dl(a text DEFAULT ')', b integer DEFAULT 1), and two tests bind the SQL to
those constants so the object cannot be deleted quietly.

Measured against the rebuilt container on 18.4, driving the real provider:
md5(pg_get_functiondef(oid)) answered 0c7937f7060d7bc0c2a583af49227203, which is
the constant the suite carries; the same edit changing the later parameter to
bigint was refused identity with both whole headers; and a plan built elsewhere
carrying that fork applied, raised LB003 from the emitted unit's own
post-condition, answered applied-elsewhere with undone: true, and left one
pg_proc row with oid 16782 and xmin 837 unmoved, against the control of the same
plan without the fork, which answered applied.

The rest is the wave 2 review of D76.

src/lib/audit.ts and tests/security/object-edit-audit.test.ts both gave "this
repository has measured itself unable to count the statements in a routine body"
as the reason the object_edit claim is narrow, and cited a posture-page limit the
posture page no longer states. Both paragraphs are rewritten to reason from what
is true: the count is not taken here, it is asked of the engine on both sides of
the plan, and what survives is that a count says how many statements ran and
never which. A new test reads docs/SECURITY.md and asserts every claim those two
docblocks cite it for, so the citation cannot dangle again.

docs/SECURITY.md and docs/providers/postgres.md carried the unqualified claim that
the server performs no parse analysis, no planning and no execution in an aborted
block. Both are narrowed to the population it was measured over and both now name
the exemption and the refusal order that keeps it away, which is what 31bfa6b2 did
for the provider. The poison constant's own docblock is narrowed with them.

The malformed refusal's no-position arm had no test: every 42601 the suite made
carried a position, bun folded the ternary, and the line gate could not see it. It
is driven now, and the arm was mutated to prove the test discriminates.

Two docblocks in postgres.ts disagreed about whether an apply-time copy of the
single-statement check would guard an empty population, and docs/providers/postgres.md
repeated the empty reading. Resolved the other way, which is the one the apply's
own entry guard already names: the population is a @libredb/studio consumer
building its own plan, applyObjectEdit sends plan.unit.steps[0].text verbatim, and
the result count reports such a round trip rather than preventing it.

The interrupted arm carries a count mismatch outside what src/lib/db/types.ts
documents it as. The deviation, why no other arm fits, and why adding a seventh on
the provider side alone would be worse are written into the provider and the doc.
Neither types.ts nor ApplyPreviewDialog.tsx is this slot's file, so the sentence
that is still wrong there is reported rather than edited.

The D76 test set is thirteen tests of which nine fail pre-fix, and the fourth that
passes is labelled where it sits.

* fix(studio): refuse the palette's table rows in the apply window too, and enumerate the door

Review of D82 found the docblock it added replaced one false reachability claim
with another. "This window already refuses every way out; the shortcut was the
one way out the dialog could not reach" is false: `CommandPalette` registers a
SECOND document-level listener, for Cmd/Ctrl+K, so it fires from inside the
apply dialog, focus moves into its input, and selecting a table calls this
shell's `onTableClick` -> `handleTableClick`, which ends with
`setActiveTabId(newId)` and unmounts the pane and the dialog. Reproduced with
the real palette over a held apply: the dialog went away and the conflict never
rendered, which is the D82 consequence exactly.

The door is closed rather than only recorded. Both gestures that open and
activate a tab now share one refusal, `refuseWhileApplying`, so they cannot
drift apart in wording or in rule. All ten palette entries were then driven from
inside the held window: every one leaves the dialog standing and the conflict
reaches the reader.

The docblock now enumerates what was measured instead of asserting that nothing
else can reach the window, including the three entries that were not measurable
here because they leave the page through a router the harness doubles. The toast
also stops telling the reader to try again "once the answer is on screen", which
is the moment the refusal ends: acting on that wording unmounts the pane and
throws the answer away.

`ObjectSourceView` is mounted by two shells and its docblock told both that D82
was answered. `StudioWorkspace.tsx` passes no `onApplyInFlightChange`, hands the
tab bar the bare `tabMgr.addTab` and mounts the pane only for an active Source
tab, and a probe on that shell with the host's `apply` held loses the conflict.
The pane now names which shell refuses and which does not, and the optional prop
is labelled as a shipped gap rather than a convenience. The dangling D82
citation in the pane's own build-window test is corrected on the same reading.

Two tests, both driving the palette's table row from inside the apply window:
the refusal with the conflict landing afterwards, and the control that the row
opens its tab once the answer is on screen.

* fix(redis): ask before discarding, and stop claiming the editor path is closed

Two defects the D75 review found in a50d10d4.

A blind DISCARD is refused on an ordinary read-only ACL. Measured on redis
7.4.11 with `ACL SETUSER ro on >pw ~* +@read +ping +info`: PING answers PONG and
DISCARD answers "NOPERM User ro has no permissions to run the 'discard'
command". `POST /api/db/multi-query` awaits this call in a finally, so the raise
replaced a response that already carried the statement results it had earned.

The ask and the act are separable after all, which the old comment denied. The
server answers the status QUEUED instead of a command's own reply while a MULTI
is open, so PING reads the state without changing it: PONG means none and no
DISCARD is sent, QUEUED means one is open and a DISCARD follows. Measured live
through this provider: the read-only user now answers none and keeps its
connection, while an ACL that can MULTI but not DISCARD (+@read +ping +multi
+set) still raises, and must, because that transaction is open and unclosable.
The server's own "DISCARD without MULTI" is read as rolled-back only AFTER the
ask saw QUEUED, where it means another caller on this shared connection ended it
in between.

The doc claimed a MULTI typed into the editor is ended here. It is not: the
surface has one caller, POST /api/db/multi-query, and use-query-execution.ts
gates that route on dialectIsSql, which this provider's queryLanguage "json"
makes false. An editor run goes to POST /api/db/query, which ends nothing on
purpose (D74). 5.2a and the supportsTransactions row now say which route ends it
and which does not, and a test pins both the capability and the sentences.

* docs(providers): name redis as an implementer, and file couchbase under the right absence

Five D75 absence sections written in this wave said the surface is implemented
on postgres, sqlite and duckdb, after the redis commit earlier in the same wave
had made that false. They now name redis too, and each type-id's test pins the
enumeration over collapsed whitespace so re-wrapping the paragraph cannot turn
it red.

Couchbase was filed under "the engine has no transaction to leave open" while
its own measurements record the opposite: BEGIN WORK answers a txid and holds a
cluster-side transaction for transactionRemainingTime 15s, so a statement run
through query() does leave one open. The real absence is "the driver cannot be
asked": the HTTP transport sends statement, timeout, metrics, scan_consistency
and at most query_context, args, readonly and profile, never a txid, so nothing
it sends can name or end that transaction. The section now also records that
this is unbuilt work rather than a missing engine feature, since toQueryResult
already hands back the results array the txid arrived in.

* docs(providers): measure the server-side transaction ask on mysql and mssql (D75)

The D75 sections argued the absence from the handle: "a pool cannot be asked
what a session it already handed back is doing". That is false, and the mysql
provider refutes it itself - cancelQuery() addresses a released session by name
with KILL QUERY. So the sections shut a door nobody had tried.

Measured instead, against live servers.

MySQL 8.0.46 with mysql2 3.24.4, asked after release() keyed on the session's
threadId. performance_schema.events_transactions_current joined to
performance_schema.threads on processlist_id reports ACTIVE for BEGIN alone,
BEGIN plus a failing statement, and BEGIN plus INSERT plus a failing statement,
and the terminal state for the control. information_schema.innodb_trx answers
nothing for the first two, because InnoDB registers a transaction only once it
does InnoDB work, so it misses exactly the failed script this surface exists for.

SQL Server 2022 (16.0.4265.3) with mssql 12.7.2, asked from a second pool keyed
on the session's SPID. sys.dm_exec_sessions.open_transaction_count is 1 and
sys.dm_tran_session_transactions has a row with and without a failing statement,
0 and no row for the control. A resolved Request carries parent and no
connection, so the provider never learns the session id to key that on.

Both labels stay "the driver cannot be asked", which is what was measured:
mysql2 publishes no transaction flag on a connection, and mssql.Request never
hands one over. What the sections no longer claim is that nothing else can be
asked. mysql also records why the measured ask is not enough on its own: the
type-id serves MariaDB, where performance_schema is OFF by default and answers
NULL rather than failing.

Also drops the closed implementer list "postgres, sqlite and duckdb" from all
eight sections. redis implements the surface too, so the list was stale the day
it was written; the set is read from the type now. mssql's "the only supported
way to pin one" is corrected as well: ConnectionPool.acquire is public and
documented.

* fix(objects): make the applied-elsewhere double discriminate, and drop a backlog citation that points at nothing

Two findings from the wave 3 review of f54afea8.

The in-suite `applied-elsewhere` case raised LB003 whatever SQL it was
handed, so the fork spliced into the unit changed nothing and the outcome
came from APPLY_VERDICT_BY_SQLSTATE alone. Measured: replacing
`text: forked` with `text: step.text` left the test passing, 2 pass 0 fail.
The double now answers the question the emitted post-condition DO block
asks, whether the routine the plan addresses is the routine the text
rewrote, and the test drives the unforked plan as a control. Re-measured
after the change: the same unfork takes it red with "expected a fork, got
applied".

Both the provider docblock and docs/providers/postgres.md said the
interrupted-arm residual was "in the backlog". docs/BACKLOG.md carries no
entry for it, so a reader following either sentence finds nothing, which is
the class the same commit was sent to repair. Both sentences now say where
the residual really is recorded, and a new guard asserts the general rule:
every backlog mention in those two files must name an entry docs/BACKLOG.md
holds, the way the cancel-mapper sentence already names B4. It listed
exactly those two sentences before the correction.

No executable source line changes; the provider diff is two comment lines
and the cited anchors at 3017 and 3174 do not move.

* fix(objects): bind the whole app.dl statement, and correct three coordinates the review caught

The three minor findings from the wave 3 review of f54afea8, all in this
slot's own files.

The fixture-to-suite binding matched two substrings that both stopped at the
parameter list's closing parenthesis, so `RETURNS bigint` or a body of
`SELECT 2` in the SQL file left every assertion green while `definition` and
`revision` stopped describing what the engine renders. Each piece of the
statement is now asserted on both sides, in the two spellings the two sides
legitimately have. Measured by mutating the fixture to `RETURNS bigint` and
`SELECT 2`: the test goes red naming the return type, and was restored.

Three coordinates: `app.order_total(integer)` and `app.touch_order` are built
by 02-sample-data.sql and not by the fixture file that listed them,
`app.touch_order` is a procedure taking `order_id integer`, and the
"130 lines below" in the multi-statement docblock was a hand-copied number.
It now says where rather than how far, which is the form that cannot go stale.

docs/SECURITY.md's build-side paragraph had swallowed the pre-existing
apply-side sentence. It is its own paragraph again, with its own lead.

The provider diff is comment-only and the two anchors object-route.ts cites
stay at 3017 and 3174.

* fix(db): add redis to the implementer list, and derive it so it cannot go stale

Three repairs the wave left across slot boundaries.

The required tests/unit/lib/api/object-route-edit.test.ts was red: four
citations in object-route.ts named lines that later commits had moved. Each
is re-derived from the anchor the test itself uses, in its own paragraph,
because two cited sites share a path prefix and a blanket substitution
rewrites both to a single value.

D75 gave redis the endOpenQueryTransaction surface, and two prose sites
still named only postgres, sqlite and duckdb. Eight provider docs were
corrected around them; these two had no guard, which is why they survived a
whole wave. The new test derives the set from the provider sources and
asserts each site's enumeration names exactly it.

That test took three narrowings, each forced by a mutation that should have
failed and did not. Reading the whole file was vacuous, because the prose
names redis elsewhere. Taking the first sentence carrying the verb picked
queryReadOnly's docblock, which says the same words about another surface.
Taking the whole sentence was vacuous again, because the types.ts sentence
runs on past a colon and names redis a second time. The claim is the
enumeration before the verb, and dropping a name from either site now fails.

And two docblocks said the interactive session can never be lastQueryClient.
D87 measured the opposite: beginTransaction takes a client from pg's LIFO
idle list and is handed the object a previous query() recorded. Both now say
what was measured and point at D87.

* docs(backlog): close D75, D77 and the standalone half of D82, file three

Wave 3 of the Phase 3 follow-up, applied by the orchestrator so no two
agents race the file.

D75 is closed. All fourteen type-ids that did not implement
endOpenQueryTransaction now answer: redis implements it, and thirteen
declare which of the three absences it is, each in its own provider doc
with what was read or measured to reach it.

D77 is closed: the fixture builds app.dl, whose parameter DEFAULT holds a
closing parenthesis inside a string literal, so applied-elsewhere has a
producer this repository creates rather than only a driver double.

D82 keeps the half that is still open. The standalone shell refuses both
document-level gestures that move the active tab during an apply, the
shortcut and the command palette's table rows; the embedded shell still
loses the answer, measured on that shell rather than inferred.

Filed:

- D89, a count mismatch is reported on the interrupted arm, whose
  documented meaning says the engine never answered. The provider weighed
  a seventh arm and left it, because ApplyPreviewDialog has no
  exhaustiveness check and an arm added on the provider side alone would
  render as a clean success, which is worse. Both ways out are costed.
- D90, three type-ids declare an absence that is not final. mysql and
  mssql were measured live at the SERVER, which can be asked
  (performance_schema ACTIVE against a ROLLED BACK control on MySQL
  8.0.46; open_transaction_count 1 against 0 on SQL Server 2022), and
  neither was implemented on that ask for a reason each doc records.
  trino's code side is settled and its live side is unmeasured.
- X27, the published shortcut description names one of its two exceptions.

Four sentences in provider docs and one docblock promised "this is filed
as its own change" and named nothing. They now name D89 and D90.

* docs(shortcuts): name both windows that refuse the new-tab shortcut

The published description named only the rename input. D82 added a second,
equally transient refusal: the shortcut is declined while an object apply is
in flight, so the shipped shortcut table under-described the shortcut.

docs/FEATURES.md is regenerated by scripts/sync-shortcuts.mjs, not hand-edited.
The unit suite now asserts the exact description string, so the two exceptions
cannot silently diverge from the guard again.

* fix(workspace): refuse the new-tab shortcut while a host apply is in flight (D82)

The embedded shell rendered StudioTabBar with the bare tabMgr.addTab and passed
onApply with no onApplyInFlightChange, so the one document-level listener that
moves the active tab fired from inside the apply dialog. Measured: with the
host's apply held between Confirm and the answer, the strip gained a Query tab,
the dialog was gone, and the held conflict rendered nothing at all.

The shell now mirrors the pane's in-flight window and wraps onAddTab the way
Studio.tsx does. The refusal is rendered by this shell as a live region rather
than toasted: sonner needs a mounted Toaster, this package re-exports none and
this shell mounts none, so a toast here would be a line no adopter can see.

The palette half of D82 does not apply: this shell renders no CommandPalette,
and the only other global listener, in ui/sidebar.tsx, is on window and toggles
a sidebar rather than opening a tab.

* feat(workspace): let a host tell the embedded workspace the catalog moved (D79)

Every statement in the embedded shell leaves through onQueryExecute, which
answers a result set and never says what the statement changed, so a CREATE OR
REPLACE run in the query editor left every open Source tab showing the pre-apply
text with no stale banner. The catalog-change counter saw only an object apply
the workspace itself issued.

StudioWorkspace now publishes StudioWorkspaceHandle through the ref prop, with
one member, catalogChanged(). A handle rather than a field on the onQueryExecute
answer: a field can only describe a statement the workspace issued, and the
party this is about is the host, which also runs migrations and schema editors
this package never sees. A host that wants the per-statement behaviour calls the
handle from inside its own onQueryExecute, so the field is reachable from the
handle and not the other way round.

The announcement marks open Source tabs stale and re-reads nothing: it carries
no address, so clearing a tab would throw away a definition that may still be
current. An apply the workspace issued still clears its own tab, because that
one is addressed. The mount's docblock sentence about what an absent banner
means moves with the change.

* fix(postgres): bind endOpenQueryTransaction to the caller's own call scope (D87, D74)

`PostgresProvider.endOpenQueryTransaction()` acted on `lastQueryClient`, one field that
`query()` overwrote on every call. `getOrCreateProvider` caches one provider per connection
id for the whole process, so that field was shared by every concurrent request on a stored
connection and the ender rolled back whichever client anybody recorded last.

Measured 2026-09-15 on PostgreSQL 18.4 through `pg` 8.23 and the shipped route handlers,
with only the session mocked, each arm against a control:

- A plain read's ender rolled back a concurrent `/api/db/multi-query` script mid-script.
  The script answered 200, hasError false, all four statements "success" including its
  COMMIT, and its table was gone. Control without the ender: the table exists.
- `beginTransaction()` was handed the very client a previous `query()` recorded, pg's idle
  list being LIFO, so the ender rolled an interactive session's committed CREATE TABLE away
  while commit still answered "Transaction committed".
- The leak it exists for survived anyway: request A `BEGIN; SELECT pg_sleep(2)` with request
  B 700 ms in left A's own client idle in transaction, and the next CREATE TABLE answered
  200 and was invisible to an independent reader.

The caller now names its call scope. `query()` takes a fourth argument, the provider records
the client it borrowed under that scope and only when the server's ReadyForQuery byte says
"T" or "E" at the moment the call releases it, and `endOpenQueryTransaction(scope)` ends
exactly those clients and spends the scope. A statement that left nothing open puts nothing
within any ender's reach, which is what takes the interactive session out of range.

With that in place `POST /api/db/query` gets the finally D74 asks for: it mints a scope,
runs its statement under it and ends it, so a lone BEGIN no longer releases a pooled client
in status T for the next signed-in user of that connection to run inside. Re-driven live,
all three arms above are clean and the serial leak closes: the BEGIN is told "rolled-back",
no backend is left idle in transaction, and the next request's write is visible to an
independent reader.

The implementer shape check moves from the multi-query route to `types.ts`, now that two
routes ask it, and the two false docblock sentences about the interactive session go with
the fix. `sqlite`, `duckdb` and `redis` hold one session for the whole provider, take no
argument and are unchanged. Two coordinate guards follow the moved lines.

* fix(objects): give the source pane the retry the tree has, and read the whole console (X22, X26)

Recovered from a wave-4 agent the session limit killed mid-task. Green on
format, lint, typecheck and the pane's own 100-test suite; the E2E run that
would close X22 and X26 has not been driven yet, so this commit is the work
and not the verification.

X22's cause is named rather than waited out. The restored-tab test failed its
first attempt on both CI runs it has had, because every test in the file signs
in as one shared account and that account hits the 120-requests-per-60-seconds
query bucket, whose window is longer than the assertion's 30 seconds. The old
helper reloaded the page to get back, which re-hydrates the whole application
against the very budget it is waiting for.

Behind that sat a real product gap. The object tree has always offered
tree-retry when its read is refused; the source pane drew the engine's sentence
and no control at all, and a tab holding a failure never re-reads, because
needsRead is document === undefined && failure === undefined. So a reader whose
read was refused had no way back except reopening the tab. The pane now offers
Try again, bound to the arm that a second ask can actually answer.

X26 reads the whole console across a real apply, not one known string: a guard
over one message is a guard over a population of one, which is how X21's Monaco
line went unnoticed. The allowlist is keyed on origin as well as text, because
Chromium's resource-load failure names no URL in the message. It carries one
measured entry, the object tree's first read answering 400 on
/api/db/objects/counts before provider-meta lands, which is filed rather than
excused silently. The blocked-resource-load test doubles as the watcher's
control, so the empty array the apply test asserts is a clean page and not a
dead listener.

* fix(workspace): pin the refusal's retirement and put it where it can be announced (D82)

Wave 4 review of 7fe83dcc, findings F1, F2, F3, F5, F6 and F7.

The retirement line in the apply-in-flight mirror was shipped behaviour that no test
pinned: deleting `if (!inFlight) setRefusedWhileApplying(false)` left the file at 51
pass, 0 fail, because the two tests whose comments spoke about it never raised the
refusal at all. One new test raises it and asserts it is gone once the answer lands,
and the unmount test now raises it too. With the line deleted both fail; with it back
the file is 52 pass, 0 fail.

The refusal is now portaled to `document.body`. Radix aria-hides everything outside
the modal, so the `output` rendered inside the workspace box was on screen and absent
from the accessibility tree: measured, `queryAllByRole("status")` answered the pane's
applying region alone. `aria-hidden` marks once and installs no observer, so a body
child created after the dialog opened is not marked; portaled, the region is returned
by role, which the test now asserts. Being a body child is also what makes the z-order
argument hold: both layers sit in the root stacking context, so a transform, filter or
contain on the host's wrapper can no longer become this element's containing block.
The remaining cost, a fixed box the host cannot place, is written down where it is
paid.

The listener enumeration was false. There are four keydown listeners in `src`, not
two, and `DataProfiler` is mounted by this shell; the conclusion holds and each one
now says why.

The counter docblock opened with its pre-D79 claim in the present tense and corrected
itself fifteen lines later. It opens with what it counts now.

* docs(object-source): correct two paragraphs that still assert the defect D82 closed

Wave 4 review of 7fe83dcc, finding F4. Both paragraphs said the embedded shell passes
no `onApplyInFlightChange` and that an embedded host therefore keeps losing a
conflict, a refused or a failed answer. It passes it as of 7fe83dcc, and the
surrounding reasoning rested on the false clause, so the paragraphs are rewritten
rather than trimmed: the `previewHere` binding is defence in depth against a shell
that does not pass the callback, which is no longer either shipped shell, and the
optional prop's undefined arm now has no shipped caller at all. Making the prop
required is named as the option it is, with what it would cost.

* docs(workspace): replace three unmeasured comment claims with what a probe answers

Wave 5 review of the D82 repair found the prose wrong in the class the commit
was written to repair. Three fixes, each backed by a run rather than an argument.

The portal docblock claimed "everything else that floats in this shell reaches
the body the same way, through a Radix portal". QuerySafetyDialog and
DataProfiler, both mounted by this shell, are in-place fixed inset-0 z-50 divs,
and DataProfiler records staying in the subtree as a deliberate choice. The
paragraph now says that, and says the portal here is what the live region needs
rather than house style.

The same docblock claimed "nothing inside the workspace box can paint above it".
Measured in Chromium with the two layers reproduced: with the shell root as the
tree has it, elementFromPoint at an in-box fixed z-[60] child answers the child,
not the body-level z-50 overlay. The control arm, the same probe with a
transform on that root, answers the overlay. So the in-box placement 7fe83dcc
shipped was readable and what it cost was the live region; the containing-block
risk the portal removes is the host's wrapper, which is what the paragraph now
states.

While rewriting the cost paragraph, the one cost it omitted is now in it:
STUDIO_SCOPED_CSS scopes the font stack and letter-spacing to
[data-studio-workspace], so the body-level node renders in the host page's face
at letter-spacing normal, measured both ways. The colour tokens are on :root and
survive.

The control test's comment printed "of the D82 tests only the refusal test above
fails", a count this describe's fourth test invalidated. Re-measured by replaying
the current file against 7fe83dcc^ source: 3 fail, 1 pass, the pass being the
control.

Comments only. No executable line added or altered, so coverage cannot move.

* fix(query): render the rolled-back notice a lone statement now earns

0448fe0b gave /api/db/query the openTransaction field when the ender learned
to name the caller's own call scope, and the route's comment claimed the
client renders the notice "from the field's presence alone". Measured false
by the review of that commit: the notice sat inside use-query-execution's
multiStatement branch, and a single statement never sets that flag.

So the field was answered and never rendered for its whole first commit. A
reader who typed a lone BEGIN had it rolled back in silence and their next
statement autocommitted instead of joining the transaction they had asked
for, which is the exact harm the notice beside it says it exists to prevent.

The notice is now computed once and raised on both paths, with its own toast
on the lone-statement path because that path has no summary toast to append
to. Two tests: the notice on a lone statement, and the control that the same
endpoint and the same lone statement say nothing without the field. The first
fails when the new branch is removed.

Also corrects the standalone shell's listener enumeration. Studio.tsx said
there are TWO document keydown listeners; there are four registrations and
three on document, because DataProfiler registers one and this shell mounts
it. The conclusion is unchanged, since that handler is bound only while the
profiler is open and only closes the profiler, …
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