Clarify publication replay provenance - #15
Conversation
A singleton response describes the Head that created the Publication, not necessarily the live Project. Document replay against retained provenance while keeping the CLI pinned to its local candidate. Reject recovery-state regressions so uncertainty cannot re-enter a mutating phase. Treat timeout and server-error responses to the PUT as ambiguous because they may follow a committed mutation, and reconcile only through the read-only singleton GET.
Technical reviewCloned and ran it: The state-machine change is the substance here, and it is a genuinely subtle correctness property rather than tidying. Uncertainty is made one-way, and only where it needs to beProgress is ranked in stages: if (status === "compiling") return 0;
if (status === "provisioning_repository" || status === "repository_unknown") return 1;
if (status === "publishing" || status === "publication_unknown") return 2;
return 3;then required to be monotonic. Note that each uncertain state shares a rank with its in-progress twin, so if (
(from === "repository_unknown" && to === "provisioning_repository") ||
(from === "publication_unknown" && to === "publishing")
) {
return false;
}This is the right rule and the scoping is exact. Equally important is what it still allows. An unknown state is not a pending state. Pending can become in-progress again. Unknown cannot, because the thing you do not know about may already have happened.
408 is separated from the rest of 4xxif (response.status === 408 || response.status >= 500) {
throw new PublicationRequestOutcomeUnknownError(response.status, problem);
}Most 4xx codes are a decision the server made and will make again, which is what makes failing fast correct. 408 is the exception: it says the server stopped waiting, which is silent on whether it did any work first. Grouping it with 5xx rather than with the definite rejections is the accurate reading. Worth noting the symmetry with firstdraft#279, where I argued 401 and 403 are the other 4xx codes that are not really decisions about the request. The same distinction is being drawn here for a different code, which suggests it is a considered position rather than a one-off. The default case is unreachable, which I checked rather than assumed
const PUBLICATION_STATUSES = new Set([
"compiling", "provisioning_repository", "repository_unknown",
"publishing", "publication_unknown", "succeeded",
"repository_conflict", "failed", "cancelled",
]);with a rejection at the validation boundary before any transition logic runs. So a novel status from a server is refused rather than silently treated as terminal. Ambiguity still resolves by reading, not repeatingThe reconciliation path continues to perform exactly one GET after an ambiguous PUT and never replays the mutation, which is the property that matters for a singleton whose creation has an external side effect. The added No findings |
Lesson: "unknown" is not "pending"Most state machines people write have states like The if (
(from === "repository_unknown" && to === "provisioning_repository") ||
(from === "publication_unknown" && to === "publishing")
) {
return false;
}You can move from unknown to a definite outcome. You cannot move from unknown back to in-progress. Why the difference matters
Here, So the transition is refused. A mismatch surfaces instead of a duplicate. The Rails shapeYou have written this state machine, probably with an state :pending
state :charging
state :charged
state :failedThen a payment gateway times out, and there is nowhere to put "we sent it and never heard back." The usual outcome is that it goes back to The fix is a state, plus a rule about it: state :charge_unknown
event :reconcile do
transitions from: :charge_unknown, to: :charged
transitions from: :charge_unknown, to: :failed
# deliberately no transition back to :charging
endThe absence of that last transition is the whole design. It forces the recovery path to be "go ask the gateway what happened," which is the only correct move, rather than "try again," which is the tempting one. Getting the ordering right is not enoughA detail worth noticing. This code also ranks states and requires monotonic progress: publicationStage(to) >= publicationStage(from)That alone does not solve it, because A general invariant plus a specific exception is often the honest design. Trying to express "unknown cannot go backward" purely through rank ordering would mean giving the unknown states their own rank, which then wrongly forbids Two rules, each simple, beat one clever rule that gets an edge case wrong. The check to run on your own codeFind every state that means "we do not know." If you do not have one, find the remote call that needs it. Then ask: can this state transition to something that will redo the side effect? If yes, and the side effect is not idempotent, that path will eventually charge someone twice, send two emails, or create two repositories. The answer is usually to delete that transition and replace it with one that reads remote state instead of writing it. |
Summary
Contract boundary
This is a companion contract correction for firstdraft/firstdraft#283. The server route remains provisional and must serialize retained publication provenance, enforce conditional creation and safe replay, and keep uncertainty states one-way. This PR does not publish npm, create a GitHub repository, or mutate a live First Draft service.
Verification
PATH="/Users/sandbox2/.asdf/shims:$PATH" npm run checkgit diff --check