Skip to content

feat(publish): atomic expected workspace incarnation fence on publish_bytes #508

Closed
loopx-project/loopx
#4774

Description

@wchwawa

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_id is the store lineage identity;
  • read(workbench, path) → envelope bytes plus metadata.generation and 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

  1. publish_bytes(..., expected_workspace_incarnation_id: str | None = None) (Python API; the Rust publish_artifact options gain the same optional field). When set, the server evaluates it in the same atomic step as expected_generation against the target workbench's current incarnation.
  2. A typed rejection distinct from the generation conflict, for example WorkspaceIncarnationMismatch { expected, current }, surfaced as its own Python exception class rather than a RuntimeError string, so an SDK caller can classify it without parsing text (LoopX currently has to treat every RuntimeError from publish as ambiguous).
  3. The publish result echoes the incarnation the write landed in (workspace_incarnation_id), matching what read metadata already returns.
  4. No change to callers that omit the parameter.

Acceptance

  • A publish with a stale expected_workspace_incarnation_id after workbench 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_id replay semantics (a replayed request carries the same fence).
  • expected_generation and expected_workspace_incarnation_id are 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

  1. wchwawa commented on Sep 19, 2026

    @wchwawa
    MemberAuthor

    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:

    1. "after workbench remove + recreate" — NoKV has no client-facing verb that removes or retires a visible workbench (the wire protocol has CreateWorkspace only; WorkspaceState::Retired is 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.
    2. "the publish result echoes the incarnation the write landed in" — deferred. PublishResult is decoded with deny_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 from read metadata / 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 (WorkspaceIncarnationMismatch in nokv-meta), so the only open window was Begin against 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 as RequestReplayMismatch.

    Everything else in the acceptance list holds as written (atomic with expected_generation on the same read version, no object or generation left behind, typed on the wire and in Python, documented). Implementation PR: #514.

  2. moved this from Todo to In Progress in NoKV Deliveryon Sep 19, 2026
  3. moved this from In Progress to Done in NoKV Deliveryon Sep 19, 2026
  4. moved this from Done to In Progress in NoKV Deliveryon Sep 20, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions