The Hard Parts of Moving YDB into a Ptah Plugin
How we extracted YDB into a Ptah plugin, from semantic ownership and release decisions to lock failures, performance, model identity, and parity.
Ptah helps you plan, review, and apply database migrations. Try in your browser
The 500-table test exposed a failure that a small schema could not. Ptah sent one execution request to the new YDB plugin. The plugin created hundreds of tables, while Ptah continued checking whether it held the migration lock. Then Ptah reported that the lock outcome was unknown. The database had accepted many of the changes. Retrying the whole operation would have been unsafe.
At first glance, this looked like a YDB lease problem. The lock check itself took less than 25 milliseconds. The request to start that check waited behind a mutex held by the ongoing 500-table execution. After its wait threshold, Ptah stopped because it could not establish lock ownership. A separate, fixed SDK deadline later stopped another run after 212 tables. These were two failures with different owners, both revealed by the same large operation. The lock investigation and SDK deadline fix record the distinction.
This is the kind of problem we had to solve to move YDB out of the Ptah binary. The result is an independently installed YDB plugin, alongside the official Spanner and DuckDB plugins. Ptah keeps the common schema and migration workflow. The plugin owns YDB’s connection, catalog, objects, planning rules and execution. A team can develop its own database integration through the same provider API.
The move took more than replacing direct function calls with RPC. We had to decide what the host was allowed to know about a database, what a saved plan means after a plugin release changes, and how to prove that a separate process preserves behavior we used to test inside the host. This article follows the decisions and the failures that changed them. The public research ledger has dated links, measurement conditions and the evidence behind the claims below.
The process boundary is useful only when the ownership boundary follows it.
The original boundary was in the wrong place
Section titled “The original boundary was in the wrong place”Before the extraction, YDB support lived in Ptah. The CLI could use a common schema workflow, but common packages also contained YDB decisions. The October audit identified 35 YDB-specific concerns, roughly 880 lines, in shared code. That number described one snapshot and one definition of YDB-specific logic. It was not a count of all code later removed. The audit mattered because it challenged an attractive first plan: put a database driver in a new binary while leaving the host to interpret the database’s objects.
YDB has behavior that is not reducible to opening a connection and emitting SQL. A table can carry settings that matter to comparison and planning. Topics and changefeeds have relationships to other objects. Catalog reads can be incomplete, and incomplete knowledge must not become a proposed deletion. Dialect rules affect parsing, lint, rendering and import. If Ptah itself continued making those calls for YDB, the new process would be a transport wrapper around an old ownership model.
We first moved semantic ownership into typed features. The #4140 work and its merged runtime slice carried a selected engine runtime through comparison, planning, rendering, conversion, validation and import. Changefeeds were a useful test of the idea: the type, its covered state and its planning rules needed one consistent owner through a complete workflow, including rollback. The host could still coordinate a migration without pretending that a YDB changefeed was an ordinary table detail.
The first lesson was about where the public interface belongs. We did not want a growing list of host switches such as “if engine is YDB.” We wanted the host to ask a provider for an explicitly declared capability and then handle the answer under a common contract. This also gave the host a reason to refuse a command when the provider did not implement the required service. A refusal is better than silently applying a generic rule to an object the generic model does not understand.
The semantic move was incremental. Earlier PRs established typed ownership before the built-in implementation was deleted. That ordering let existing YDB behavior serve as a comparison target while the plugin was being built. On 5 November, #5298 removed the built-in YDB packages from Ptah. The deletion was a milestone, not the end of replacement work: integration tests, model compatibility and live database behavior still had to be checked against the independent provider.
A process boundary makes hidden services visible
Section titled “A process boundary makes hidden services visible”Moving a provider into another process changed the meaning of a function call. An in-process function could share a registry, a Go callback, a struct pointer and a cancellation context. An external plugin receives a serialized request and has to return a complete, validated result. The experimental SDK introduced authenticated local RPC and validation at that boundary, while the first YDB plugin PR proved the separate implementation could start using it.
Some dependencies were easy to miss because they had no visible YDB label. A global migration planner registry selected a factory as a side effect of package initialization. That works when there is one process with a known set of compiled packages. It fails as a provider boundary: one runtime can change what another runtime will use. #4750 replaced the global selection with a runtime-selected factory. The host kept orchestration, and the runtime supplied the engine-specific planner.
Lint rules supplied another example. An early idea in the work was to pass Go callbacks for provider rules. A callback cannot cross an RPC boundary as a durable provider contract. We changed the design to let the provider describe the rules and claim the syntax it handles. The host routes Go annotation directives and batches work at the run level; the provider owns the meaning of its claims. The decisions appear in the lint discussion, provider claim contract, host routing and YDB checks. This was a design correction during development, not a reported outage.
The SDK also had to state what happens when a call fails partway through a write. If a process disappears after committing a statement but before acknowledging it, the host cannot infer that the database is unchanged. The SDK test fixture deliberately injected crashes and cancellation after committed writes, partial execution and incomplete inspection. Those are synthetic tests of the boundary. They do not describe customer incidents. They establish why Ptah preserves an unknown execution outcome and does not automatically retry a write merely because the reply was lost.
This changed the contract’s level of detail. A service response needs enough information to distinguish “nothing ran,” “some operations may have run” and “the requested work completed.” A catalog response needs coverage: what the provider could observe, not only what objects it returned. Without those distinctions, the host would convert communication failures into destructive schema decisions. The interface became larger, but each added field gave the host a specific safety rule it could enforce.
Offline work belongs on the same boundary
Section titled “Offline work belongs on the same boundary”A database plugin still has work to do when there is no database connection. Ptah can read a desired schema file, check its declarations, plan against a captured schema, render an export, or help an editor explain an engine-specific field. The delivery contract required offline decoding, normalization, comparison and rendering without credentials. A target-version profile can supply known capabilities, but unknown runtime facts must stay unknown. Otherwise an offline preview would appear more certain than a live inspection.
That requirement prevented a tempting shortcut: call the database to answer every question the host cannot answer itself. It also prevented a second shortcut: teach Ptah’s common parser every YDB production so it can validate a file without starting a plugin. The shared frontend owns its source format, source locations and diagnostics. The provider supplies its engine-specific interpretation through a neutral request. Source names, ranges, exact numbers and absent-versus-null values must survive that crossing. If a provider reports a field error, the user should still see the right location in the file. If the provider is missing, Ptah should name the plugin needed for the selected engine and point to setup instructions, while unrelated engines keep working.
The size of an RPC matters here. An in-process model might expose tiny methods such as clone, equal or “what is this field?” Translating each method into a network call would make a 500-table schema pay for thousands of round trips and would make failures hard to report. We used batched, error-returning services for the semantic operations. A transport failure cannot be translated to “equal,” “no diff” or “capability unavailable.” Each of those answers has a meaning in planning; a failed request has a different meaning.
The Go source frontend shows why offline work needs the same provider boundary. This declaration comes from a host/provider integration fixture:
package entities
//ptah:schema:table name="host_source_items"//ptah:schema:columnfamily name="default" compression="lz4"type Item struct { //ptah:schema:field name="id" type="Uint64" primary="true" ID uint64}Ptah’s common Go frontend identifies the table, struct and source positions. The plugin’s GoDirectives claim says which table attributes and column-family directive it owns. The host forwards those declarations; the provider validates their YDB meaning. An executor that can run YQL would not be enough to interpret this desired schema.
This also clarified what the host can do with the serialized model. Protobuf gives the two processes a transport format. Its deterministic encoding does not define semantic equality or the fingerprint of an approved plan. Providers must normalize and describe their own models, and Ptah must validate the result under the declared contract. This decision later became important when model definition identity and provider release identity were found to be entangled in HCL coverage headers. The failure was not a reason to compare arbitrary bytes; it was evidence that the meaning of a model needed its own identity.
A Go executable owns the YDB services
Section titled “A Go executable owns the YDB services”The extracted provider is its own Go module. The entry point constructs a backend.Provider and explicitly registers its services with the SDK. This selected fragment of main.go shows the composition point; the full registration also includes comparison, planning, rendering and other services:
err := runtime.Serve(runtime.Server{DatabaseContexts: owner, DevProvisioner: dev, Description: backend.Description(), Sessions: owner, Inspector: owner,The SDK checks that registered services match the advertised features before serving requests. Inside each process, Go interfaces connect the code. Across the process boundary, the SDK sends Protobuf messages. Its provider package defines operation-level interfaces, its generated Protobuf package defines transport records, and runtime starts and serves the plugin. The startup code checks the executable path, checksum and provider identity before a database call. The protocol can also be implemented in another language; Go currently has the ready-made SDK.
We implemented one distribution plan, then reversed it
Section titled “We implemented one distribution plan, then reversed it”The initial official-provider plan treated YDB differently from other plugins. ADR 0026 specified an explicit install step but a YDB version pinned by the Ptah release and bundled in distribution channels. #4876 implemented the release hooks. Spanner and DuckDB were to be installed separately. It was a concrete design and implementation, not a proposal that we abandoned before writing code.
The implemented bundle exposed a coordination failure. Its YDB plugin v0.1.1 spoke provider contract revision 24 while the host had moved to revision 25, so Ptah correctly refused it. CI could have tested the exact bundled pair before publication; that was a viable way to keep bundling. The mismatch showed why both approaches need contract negotiation and pairwise checks. It did not, by itself, require separate installation.
We accepted ADR 0031 and removed the pin and bundle hooks. Official providers became separately installed releases. Ptah resolves a compatible provider version through its manifest rather than embedding a provider release or digest in the host distribution. Installation remains explicit. The host still checks the installed executable and negotiates the contract before it asks the plugin to operate.
The deciding tradeoff was release cadence. A tested bundle would simplify the first install, but a plugin fix under the pinned model would reach users only with another Ptah release or a hand-written manifest override. ADR 0031 chose independent provider releases so an integration can ship a compatible fix on its own cycle. Separate delivery still needs compatibility checks: the host negotiates the contract, and the provider tests its executable against a host it can consume. Distribution and compatibility solve different problems.
It did not mean every new provider release can reinterpret old work. A saved plan may contain native operations described by a specific provider version. #4935 bound those operations to provider ID, release version and contract major, while startup still checks the executable’s SHA-256 for integrity. Later #5493 kept a host-resolved plan identity as well. The release binding, the plan fingerprint, and the executable checksum answer different questions; none can stand in for the others.
The 500-table test found two independent clocks
Section titled “The 500-table test found two independent clocks”Once the built-in engine had been removed, we tested the provider against live YDB 25.1.4.7 and 26.2.1.14. A 500-table schema apply produced the opening failure. On one line the provider had created 347 tables; on the other, 242, before the host could no longer establish the lock outcome. The plugin investigation traced this to an execution request holding the session mutex while Ptah sent periodic lock probes through the same session. The probes waited for the mutex, not for YDB. Ptah’s unanswered-probe threshold expired.
The fix separated lock state from the long-running execution session. Lock probes could make progress while the provider created tables. The provider also checked the cached lock state before each statement. The responsibility was narrow: the plugin needed to report whether it could still establish ownership, and the host needed to stop when ownership was unknown. We did not extend a database lease by pretending that a blocked RPC was a successful check.
The two mutexes are visible in these source excerpts. An execution holds the session mutex while it processes its units:
s.mu.Lock() defer s.mu.Unlock()CheckLock calls lockState, which takes the separate mutex for lock records and fencing:
func (p *Provider) lockState(request *protocol.LockRequest) (*session, func(), error) { if err := provider.ValidateLockRequest(request); err != nil { return nil, nil, authored.Status(codes.InvalidArgument, err) } s, err := p.lookup(request.Scope.SessionId) if err != nil { return nil, nil, err } s.lockMu.Lock() if s.locksClosed { s.lockMu.Unlock() return nil, nil, runtime.ErrorWithReason(codes.FailedPrecondition, "YDB session is closed") } if err := s.namesItsLock(request); err != nil { s.lockMu.Unlock() return nil, nil, err } return s, s.lockMu.Unlock, nil}The check no longer waits for s.mu to become free. In the full CheckLock method, a failed YDB check records either proven lease loss (LOST) or an unknown result (UNKNOWN) and fences further execution. The execution path checks that state before each unit. An unanswered probe alone had not proved that YDB had revoked the lease.
There is a separate retry boundary for SQL outside an explicit transaction. The provider’s runnerFor uses a dedicated database/sql.Conn, bypassing the retry loop around DB.ExecContext. That does not disable retries a driver might implement internally. The Go driver contract also forbids returning driver.ErrBadConn when the server might already have performed the operation, so ordinary pooled calls are not defined to replay uncertain writes. Independently, the provider refuses to automatically replay a write whose outcome it cannot establish. This is a narrower guarantee than saying sql.Conn switches off all retries; the tests did not uncover a double write.
Another test then stopped after 212 tables with an unknown outcome. This time the lock probes were working. The SDK had a fixed 30-second call timeout covering the entire large execution request. #5460 made large calls obey the caller’s context instead of the default short call deadline. A schema-sized operation and a brief health check have different lifetimes. Sharing one timeout made the larger operation fail even when each database statement was healthy.
The blocked probe and the SDK deadline were separate causes. Both could leave a partial write.
The number of tables created at failure is important. A timeout after 212 statements is not an error before execution. It is an ambiguous partial result. The host cannot simply rerun the request: some tables now exist, and the provider may have applied operations after the last reply the host saw. The safe workflow is to inspect the current database, establish coverage, and plan from the observed state. That principle was already present in the SDK’s fault-injection cases; the large live test showed why it mattered in an ordinary migration workload.
We also found a transaction assumption in the host. The default schema-apply path opened a transaction, but the YDB provider refused transactional DDL unless the user explicitly selected no transaction. #5165 changed Ptah to honor the provider session’s capability when choosing the default. This was another example of a seemingly generic convenience that had actually encoded an engine assumption. The provider needed a way to say what it could do, and the host needed to use that answer before starting work.
Correctness was necessary; performance made the boundary usable
Section titled “Correctness was necessary; performance made the boundary usable”The early provider path was slower than built-in YDB on large schemas. Profiles showed repeated work on both sides of the boundary. The SDK cached already-checked model definitions by the address of their byte buffer. The provider constructed fresh definitions on each call, so the 4,096-entry cache filled with new addresses. Later validations hashed and checked the same content again. The host expanded embedded fields and discovered sensitive inputs per unit. The plugin created a table session for each of 500 creations and checked overlapping indexes with a quadratic scan. Those costs had different owners and therefore needed separate fixes.
SDK #5460 keyed the checked-definition cache by digest and content. Equivalent fresh buffers can now reuse a checked result, while content changed at the same address is checked again. A separate change validated the digest in place instead of decoding and normalizing it on every visit. Host #5463 indexed field constraints, reduced repeated embedded-field expansion and batched sensitive-input discovery where the same answer served multiple units. The investigation found roughly 4,000 discovery calls and 22 seconds before the first statement of a 500-table apply, and about 1,500 calls and 12 seconds in generation. Plugin #293 reused a table session, taking create/delete-session counts from 501 to two; the 500 table operations themselves still had to run. It also built its description once and indexed overlap checks.
The description fix is small Go code with a useful correctness property. Rebuilding the Protobuf description for repeated validation cost about 1.5 seconds of CPU across a 500-table migration. The provider constructs it once with sync.OnceValue(buildDescription). Its public accessor returns a clone, so callers cannot mutate the cached value:
func Description() *protocol.Description { return proto.CloneOf(described())}The cache saves repeated construction without sharing a mutable descriptor with callers. The profile located that cost in the plugin’s Go code.
The next profile showed that transport and validation still copied more than the planners consumed. Four of six planning calls for 500 new tables carried every full table, around 2.3 MB per call, even though the relevant services only needed the table’s subject, action and state names. Every table state repeated the same 16-kind coverage list; those lists were about 90 percent of a planning request in that case. Reply validators checked requests a second time. Contract 25.27 introduced explicit table outlines, shared coverage and reply-only checks. Each optimization retained validation: the SDK refuses an outline for a service that claims to read a full table, and it rejects unresolved coverage references.
The controlled results are more useful than an isolated “faster” claim. In the #5477 measurements, built-in YDB and the provider path alternated over three rounds on remote-dev. Each round began below 35 percent host CPU. Figures are median wall time for a 500-table workload; “before” already includes the earlier host, SDK and plugin fixes. “After” adds contract 25.27 and its host and plugin adoption.
| YDB 25.1.4.7 operation | Built-in | Provider before | Provider after |
|---|---|---|---|
| Generate migration for 500 new tables | 1.9 s | 7.3 s | 4.5 s |
| Apply schema, creating 500 tables | 11.9 s | 16.8 s | 18.3 s |
| Export Go models | 2.2 s | 3.6 s | 3.3 s |
| Build synchronized 500-table plan | 9.8 s | 8.4 s | 5.9 s |
The 26.2.1.14 line told a similar story: generation fell from 7.6 to 4.3 seconds, while apply moved from 16.3 to 18.1 seconds. Apply CPU on 25.1 fell from 9.3 to 7.1 seconds, but wall time increased. The 500 creations still required three YDB calls each, and their execution time varied across runs. The optimization improved work the host and SDK controlled; it did not make database calls free or establish a universal apply speedup. An earlier 112-second provider generation result came from a different code and measurement window and should not be placed in this table as its “before.”
The measured cost remained material: after the transport changes, generation took 4.5 rather than 1.9 seconds, and apply took 18.3 rather than 11.9 seconds on this 25.1 workload. The synchronized plan was faster through the provider, at 5.9 rather than 9.8 seconds. The extraction closed with that tradeoff recorded; these figures alone do not establish a universal plugin cost or a team-approved performance threshold. Further optimization needs profiles of the remaining execution path and repeatable wall-time measurements, especially for apply. We now measure host work, SDK transport and plugin work separately, and ask each planning service to declare the data it actually needs.
A transfer needed facts outside its own model
Section titled “A transfer needed facts outside its own model”YDB’s data model explains the transfer case. A topic stores a stream of messages. A changefeed publishes changes from a table through a topic. A transfer reads from a topic and writes to a table. A transfer can therefore read a table’s changefeed, and its source may be described by a different model from the transfer itself.
Ptah calls a standalone entity such as a topic an object. A changefeed attached to a table is a facet of that table. Desired context says what the schema declares; current context says what the provider read from the database. Coverage records whether that read could describe the relevant set. An unobserved changefeed set is not an observed empty set. Without coverage, an incomplete catalog read could turn “we do not know” into a proposed deletion.
A live comparison on YDB 26.2.1.14 put all three ideas in one case:
- Desired state: the schema declares table
orderswithout a changefeed and declares a transfer whose source isorders/feed. It does not declareorders/feedas an independent topic. - Current state: YDB already has the table, its
feedchangefeed and the transfer. - Observed state: the reader can retain the fact that the source exists, but a consumer codec numbered
10042prevents it from describing the changefeed set. Coverage therefore marks that set as unreadable; it does not supply an invented empty list. - Original contract gap: the transfer comparison saw its own model and the desired cross-model declarations, but it could not see the currently held source or its incomplete coverage. It refused the transfer because the desired schema declared neither a topic nor a changefeed at
orders/feed. - Result after the extension: current objects, facets and coverage let the comparison recognize the held source and leave the existing transfer unchanged with a warning, matching the built-in path. It does not infer that the unreadable changefeed was removed.
The contract grew in the order this case required. Declared objects let a transfer comparison recognize a separately declared topic. Desired facets let it recognize a changefeed declared on a table. Neither described the held source in the example. Current context supplied observed objects, facets and coverage from other model families; the YDB plugin adopted it. A related live case could then create a new transfer on 26.2. YDB 25.1 still refused that create because it lacked the transfer capability.
Declared objects, desired facets and observed current state answer different questions.
We considered a plugin-only check at planning time. It would miss a comparison that proposes no change, including the held-transfer case. It would also encourage passing whole-table payloads again to make the planner self-sufficient. Cross-model context belongs in the comparison contract, where both changed and unchanged states can be evaluated.
The ADR correction and plugin coverage fix made the unreadable set explicit through coverage without invented values. The distinction matters beyond transfers: a planner must not delete an object merely because a reader could not describe it.
Old state survived the cutover, so identity had to survive too
Section titled “Old state survived the cutover, so identity had to survive too”A new provider needs to understand durable files produced before and after its release. We found two model-identity problems that could look similar in an error message but had different causes. The built-in implementation had written HCL coverage headers with its old owner and model names. The plugin initially rejected those headers. We added predecessor identities so the new owner could recognize the old representation without rewriting history. The migration history work and host predecessor changes are part of that cutover.
This did not create a general legacy decoder for every pre-refactor Ptah artifact. The implementation policy called for a clean break in application APIs and serialized formats while preserving database correctness. The predecessor mechanism is narrower: the owner of a current model explicitly names an earlier identity that means the same thing. A coverage document that claims both identities is refused. New exports write the current identity. In the live test, a built-in HCL export containing a coordination node became readable through the plugin; removing that node’s block still planned the intended drop. Without the named predecessor, the file was refused rather than silently treated as an empty claim. The host adoption PR records those cases on both YDB release lines.
The second failure came from a provider release bump. The same provider source, labeled 0.4.0 and then 0.4.1, could produce a host-resolved model digest that changed with the release and negotiated feature description. An export of captured state then failed with an incompatible feature codec, even though the model meaning had not changed. Plugin #299 exposed the problem, but the plugin alone could not repair a digest the host generated. SDK #5491 and host #5493 gave the provider a way to declare its model identity and accepted predecessors. Plugin #303 added regression coverage.
We kept these identities separate from plan approval and binary integrity. A model predecessor says that an older captured representation can be decoded under an explicitly recognized lineage. A saved plan binds the provider behavior that produced native operations. A checksum verifies the installed executable. Treating all three as one fingerprint would make normal release changes break historical state, or worse, make an old plan executable under different semantics without review.
The old tests still mattered after the built-in code disappeared. The cutover left integration tests to port, while a host repair found YDB assumptions in lint and provider selection. The parity audit tracked old tests and remaining host cases; its row counts and checklist groups use different denominators, recorded in the research ledger. We used the inventory to choose cases, then tested the behavior in the appropriate suite.
One parity case caught a safety rule that was too broad. We wanted to prevent dropping a table while an active replication still used its changefeed. The first drop refusal treated any table holding a replication-managed changefeed as undroppable. But a valid plan could drop the replication with CASCADE first, release the stream and then drop the table. The refusal blocked a schema that removed both objects. Live tests on YDB 25.1 and 26.2 failed, so the correction removed that blanket refusal and pinned the valid drop in a unit test.
That correction did not solve the narrower case where a plan keeps the replication while dropping its source table. Rejecting only that case would require the table planner to know what the entire plan does with the replication; it remained open at the time of the fix. The same PR scoped another overstrict rule to row tables, since column tables do not hold changefeeds. Parity was more than reproducing old output: a new restriction could break a valid plan, and safe decisions sometimes require the whole plan’s context.
Release gates had to test installable combinations
Section titled “Release gates had to test installable combinations”The provider API evolved while Ptah and the YDB plugin lived in separate repositories. That made branch coordination a real part of correctness. Five SDK changes were reconciled into contract 25.22 after branches had used competing temporary minor versions and the same Protobuf field number. The collision was found and resolved before merge. It was not a production wire incompatibility.
Another sequence showed that a branch can pass while plugin master is red. A premature host reference pointed to an unmerged host change and failed the main-branch host-reference check. We removed the reference and restored the dependent test after the host change merged. A plugin branch has to test against a host commit it can actually consume.
The module has its own boundary check too. GOWORK=off excludes a local Go workspace from the build; it does not disable replace directives in go.mod. The plugin’s check-module-boundaries.sh inspects the resolved module graph, rejects a dependency on Ptah’s application module and rejects replacements. A separate CGO_ENABLED=0 build checks that this executable needs no cgo. These checks prove different things; together they keep the independently released Go module from depending on a local host checkout or native build toolchain.
We added more direct checks of the executable and the protocol. The raw-wire conformance runner sends malformed and boundary requests without relying on the SDK client and server to normalize them first. This catches cases a shared SDK could hide from both sides. The plugin’s CI gate and release gate check the exact built executable, including subprocess startup and offline conformance, and wait for the tagged commit’s successful run before publication. Live YDB tests remain part of the provider’s qualification.
The dates separate completion from a future promise. YDB plugin v0.4.0 and Ptah v0.22.0, the first Ptah release without built-in YDB, were published on 6 November. The parity issue and provider delivery issue closed on 8 November. That day also brought Ptah v0.25.0 and YDB plugin v0.8.0, including the later cross-model context work.
The result is more than a relocated YDB driver. Ptah can load a separately released database plugin, negotiate what it implements, preserve uncertainty when execution or catalog reading is incomplete, and carry engine-specific objects through the shared workflow. The work also gave other provider authors a concrete SDK, a tutorial and conformance checks. If you want to use the existing integration, start with the YDB provider guide. If you want to build an integration for another database, the provider tutorial follows the same boundary we had to make explicit here.
The extraction also changed what we ask of future provider work. A new engine has to declare what it can observe, which semantics it owns, which operations may be partial, and which model identities its releases accept. Ptah then has enough information to make safe decisions without embedding that engine’s implementation. The YDB work tested those rules against real schemas, large migrations, old captured state, and two server lines. That evidence is what made the boundary usable.