Repository navigation
feat(publish): atomic expected workspace incarnation fence on publish_bytes #508
Description
Summary
Expose an atomic expected workspace incarnation fence on Client.publish_bytes (and the underlying publish path), so a caller that binds durable state to one workbench incarnation can refuse to write after that workbench was removed and recreated, instead of detecting the mismatch only after the write landed.
Context
LoopX's Stage 2A NoKVAuthorityStore candidate (loopx-project/loopx#3819, merged) stores one authority envelope per goal at metadata/loopx-authority/<digest>.json and uses NoKV as follows:
find_workspaces→workspace_incarnation_idis the store lineage identity;read(workbench, path)→ envelope bytes plusmetadata.generationand the incarnation;publish_bytes(workbench, path, data, content_type="application/json", expected_generation=..., operation_id=..., artifact_revision_id=...)→ path-level generation CAS.
Today the adapter fails closed through post-write readback: it accepts even a successful publish only after a fresh read proves the exact transaction under the current workbench incarnation. That closes false success at the LoopX boundary, but it cannot prevent the write itself when the workbench was recreated between the identity read and the publish (generations restart at 1 on recreation, so a stale expected_generation can match again). LoopX records this as an explicit provider-contract hold ("preventing the stale-incarnation write itself requires a future provider primitive", examples/nokv-authority-store/README.md and RFC Appendix A).
The metadata layer already carries this notion for workspace commits: crates/nokv-meta/src/workspace/commit.rs checks expected_source_workspace_incarnation_id and rejects a commit whose workspace incarnation moved. Publish has no equivalent parameter.
Proposal
publish_bytes(..., expected_workspace_incarnation_id: str | None = None)(Python API; the Rustpublish_artifactoptions gain the same optional field). When set, the server evaluates it in the same atomic step asexpected_generationagainst the target workbench's current incarnation.- A typed rejection distinct from the generation conflict, for example
WorkspaceIncarnationMismatch { expected, current }, surfaced as its own Python exception class rather than aRuntimeErrorstring, so an SDK caller can classify it without parsing text (LoopX currently has to treat everyRuntimeErrorfrom publish as ambiguous). - The publish result echoes the incarnation the write landed in (
workspace_incarnation_id), matching whatreadmetadata already returns. - No change to callers that omit the parameter.
Acceptance
- A publish with a stale
expected_workspace_incarnation_idafterworkbench remove+ recreate is rejected atomically and leaves no object or generation behind; the same call with the current incarnation succeeds. - The rejection is typed on the wire and in the Python SDK, and is idempotent with
operation_idreplay semantics (a replayed request carries the same fence). expected_generationandexpected_workspace_incarnation_idare checked together; no window exists where one passes and the other is evaluated against a different workspace state.- Documented in the Python API reference and the metadata schema notes.
Why it matters for the collaboration
This is the last provider-side hold named by the LoopX Stage 2A qualification. With it, the authority store can move from "detect and fail closed after the fact" to "the stale lineage cannot write at all", which the Shared Goal Authority RFC needs before any shared-mode promotion (Stage 3 onward).
Activity
wchwawa commentedon Sep 19, 2026
Acceptance correction before the implementation lands
While implementing this I checked the publish path end to end and two of the original acceptance statements need correcting:
workbench remove+ recreate" — NoKV has no client-facing verb that removes or retires a visible workbench (the wire protocol hasCreateWorkspaceonly;WorkspaceState::Retiredis written by the restore lifecycle). The scenario is therefore proven at the owner level by rebinding the visible workspace marker to a new incarnation (the existing executor test support), and at the SDK level by: a fenced publish with a stale incarnation is refused with the typed exception and nothing is written; the same call with the current incarnation succeeds; an exact replay with the fence is idempotent. A real remove/recreate lifecycle verb is a separate feature request, not part of this fix.PublishResultis decoded withdeny_unknown_fields, so any new response field breaks every 0.11.0 client. The echo waits for the next wire-schema bump; callers keep reading the incarnation fromreadmetadata /find_workspaces, which already report it.Two facts that make the fix smaller than the proposal: the owner already re-validates the recorded incarnation at completion (
WorkspaceIncarnationMismatchinnokv-meta), so the only open window wasBeginagainst a workbench that had already been recreated; and the durable operation row already binds the incarnation, so the fence is not stored — an exact request replay carries it by construction, and a request id reused with a different fence is refused by the request ledger asRequestReplayMismatch.Everything else in the acceptance list holds as written (atomic with
expected_generationon the same read version, no object or generation left behind, typed on the wire and in Python, documented). Implementation PR: #514.