Scheduled publishing is not complete when content reaches a spreadsheet. It is a stateful chain spanning generation, persistence, editorial approval, publication, and public visibility. This guide explains a fail-closed scheduled publishing contract for bilingual articles. The design uses exact states, invariants, readbacks, and safe local tests so that uncertainty stops the workflow instead of silently becoming a release decision.

Why scheduled publishing should fail closed

A fail-closed publishing workflow advances only when status, language, translation grouping, timestamps, source evidence, and site visibility are all proven. Missing or contradictory evidence causes a stop; an earlier successful task never substitutes for the current gate.

The most dangerous automation shortcut is treating a successful scheduler exit as proof that an article is safely public. A publishing chain contains several independent facts: the scheduler process finished, the article package passed validation, rows were persisted, an editor approved the transition, and the public route serves the approved representation. Each fact needs its own observation. A successful outer task says nothing conclusive about a later site request, while a failed site request does not prove that persistence never happened.

Fail-closed behavior differs from ordinary exception handling because it defines the default decision under uncertainty. A fail-open workflow may treat Draft, Drafted, and case variations as equivalent, or reuse yesterday's context when a current read fails. A fail-closed workflow requires an exact Drafted value, an empty publication timestamp, exactly one zh-TW article and one en article, and one shared nonempty translation group. If any condition is absent, no approval or public transition starts.

This approach also controls retries. Schedulers can retry after a network interruption, an interrupted process, or an external delay. If a new run can overwrite an existing slug, a retry may replace reviewed content or separate two translations. The safer policy is append-only creation with a unique slug check. A collision fails visibly. Idempotency comes from explicit identifiers, state checks, and readbacks rather than a fuzzy judgment that two titles look similar.

Search quality is part of the reason for this discipline. A premature transition can expose unreviewed text, publish only one language, omit a canonical reference, or present structured data that disagrees with visible content. Exact gates help search engines and answer systems encounter a coherent, reviewed document. They also preserve provenance: both language versions can share the same source hashes and verified facts without being sentence-by-sentence translations.

A useful mental model is a chain of evidence, not a chain of commands. Commands are implementation details. Evidence defines what the next actor may conclude. The generator may conclude that a validated package exists. The persistence gate may conclude that two draft rows exist. The editor may conclude that an approved transition is allowed. The site gate may conclude that an approved representation is reachable. Keeping those conclusions narrow prevents accidental overstatement.

Model Drafted and Published as explicit invariants

A minimal publishing state machine needs only two persisted states: Drafted means written but not approved, and Published means approved by the editor. Each state must be coupled to the publication timestamp through an enforceable invariant.

Define row rules before adding procedural branches. When a row is Drafted, PublishedOn must be empty. When a row is Published, PublishedOn must contain a complete timestamp with a timezone offset. Unknown states are invalid. The application must not normalize Draft into Drafted or guess that a misspelling expresses the intended state. A spelling drift should become an observable contract failure rather than a silent release path.

The bilingual package adds group invariants. Every scheduled unit contains exactly two articles. Its language set is exactly zh-TW and en; the two slugs differ; and the translation group is identical and nonempty. The group states that the articles share technical conclusions, source evidence, and verification results. It does not require literal translation. The Traditional Chinese version can frame the operational problem for Taiwanese readers, while the English version uses natural release-gate terminology.

Transition authority should also be explicit. The generation job creates Drafted rows and leaves publication time empty. It cannot approve its own output. The editor receives permission to change both fields only after editorial review, source-hash verification, package validation, and safe tests. Separating generation from approval narrows each component's power and creates an auditable answer to who performed each transition.

A spreadsheet is a persistence mechanism, not the sole definition of truth. The official Google Sheets values.append API documents an updates response that can identify the affected range. That response is useful persistence evidence, but it should be followed by a readback of Status, PublishedOn, Language, TranslationGroup, and slug. An accepted API request does not prove another process will not modify a row before the editor starts, so the editor needs a fresh context gate as well.

Represent the invariants as a decision table. Drafted with an empty time may enter review. Drafted with a time is rejected. Published with a complete time may enter public-site verification. Published without a time is rejected. Every unknown state is rejected. A small table is easier to review and test than conventions distributed across prompts, spreadsheet formulas, and scheduled task descriptions.

The invariant should be checked at boundaries, not only when data is first created. Check it after append, at editor startup, immediately after the editor transition, and before interpreting a site response. Boundary checks catch both programming defects and changes made by independent automation. They also produce a precise location for incident analysis: the last passing boundary and first failing boundary identify the layer that needs investigation.

Separate persistence, editorial, and visibility gates

A reliable pipeline treats persistence, editorial approval, and public visibility as three independent gates. Each gate owns a defined input, pass condition, and failure report; success in one layer cannot replace evidence from another layer.

The first gate covers content and persistence. It validates unique slugs, language-specific length, heading structure, source existence, matching SHA-256 evidence, reachable official references, and complete reproduction steps. Once valid, it appends exactly two Drafted rows and leaves PublishedOn empty. The process then reads the affected rows by range and stable identifiers. A row count, language, group, or status mismatch marks the persistence layer as failed.

The second gate belongs to the editor. It should not merely ask whether any draft exists today. It requires exactly two current rows, the exact bilingual language set, one translation group, slugs identical to the local package, and a successful upstream generation result. The editor recomputes source hashes because source notes can change between generation and review. Hash drift requires regeneration or a new review; the editor must not claim that an old article is grounded in a changed source.

The third gate tests the public site. RFC 9110 defines 404 Not Found as a response indicating that the origin server did not find a current representation for the target resource, or is unwilling to disclose one. For a draft route, 404 is an expected visibility result. It demonstrates that this request did not receive a public representation; it does not prove whether a draft row exists. Only after approval should the workflow expect 200 and then inspect canonical markup, BlogPosting, and indexing directives.

Reporting must preserve those distinctions. During the draft stage, report that the sheet contains Drafted rows and the site is not published. Do not say the article is live. If rows become Published while routes remain 404, report that the data layer is published but the site has not synchronized. Do not guess that caching, deployment, or routing is the cause without evidence. Accurate language is part of the control system because it directs the next investigation.

The same rule applies to dashboards and source inventories. A dashboard may read analytics and render charts while its Markdown inventory is empty. If the source root is known to exist, a zero inventory is degraded or failed, even when unrelated panels succeed. The diagnostic output should include the resolved root, enumerated file count, and filter counts without exposing note bodies. A broad ok flag must never hide a failed sub-gate.

Independent gates also improve recovery. If content validation passes but append fails, regenerate nothing; retry persistence after a fresh uniqueness check. If persistence passes but editor context fails, preserve the drafts and investigate state drift. If approval passes but site visibility fails, avoid rewriting rows merely to provoke synchronization. Recovery begins at the first unproven gate, not at the beginning of the entire workflow.

Verification and reproduction with Python 3.11

This contract fixture was actually executed in the Windows 11 scheduler environment with Python 3.11.15 and the standard-library unittest module. It uses no remote system, modifies no external data, and requires no real account.

  1. Create an empty test directory, run python --version, and verify that it prints Python 3.11.15; save the following code as fail_closed_fixture.py.
  2. Run python fail_closed_fixture.py in that directory. All three tests must show ok, followed by Ran 3 tests and OK; the process exit code must be zero.
  3. Change the second row in the negative test from Draft to Drafted while leaving the different group. Run the command again. The fixture should still pass because group drift alone must make readiness false; if readiness becomes true, the negative assertion fails.
from dataclasses import dataclass
from datetime import datetime
from unittest import TestCase, main

@dataclass(frozen=True)
class ArticleRow:
    language: str
    status: str
    translation_group: str
    published_on: str = ""

def package_is_editor_ready(rows):
    return (
        len(rows) == 2
        and {row.language for row in rows} == {"zh-TW", "en"}
        and all(row.status == "Drafted" for row in rows)
        and all(row.published_on == "" for row in rows)
        and len({row.translation_group for row in rows}) == 1
        and bool(rows[0].translation_group)
    )

def is_complete_timestamp(value):
    try:
        parsed = datetime.fromisoformat(value)
    except (TypeError, ValueError):
        return False
    return parsed.tzinfo is not None and parsed.utcoffset() is not None

def row_invariant_holds(row):
    if row.status == "Drafted":
        return row.published_on == ""
    if row.status == "Published":
        return is_complete_timestamp(row.published_on)
    return False

class ContractTests(TestCase):
    def test_pair(self):
        rows = [ArticleRow("zh-TW", "Drafted", "fixture-group"),
                ArticleRow("en", "Drafted", "fixture-group")]
        self.assertTrue(package_is_editor_ready(rows))

    def test_drift(self):
        rows = [ArticleRow("zh-TW", "Drafted", "fixture-group"),
                ArticleRow("en", "Draft", "other-group")]
        self.assertFalse(package_is_editor_ready(rows))

    def test_time_invariant(self):
        self.assertTrue(row_invariant_holds(
            ArticleRow("en", "Published", "fixture-group", "2026-08-31T09:00:00+08:00")))
        self.assertFalse(row_invariant_holds(
            ArticleRow("en", "Published", "fixture-group")))
        self.assertFalse(row_invariant_holds(
            ArticleRow("en", "Published", "fixture-group", "2026-08-31T09:00:00")))

if __name__ == "__main__":
    main(verbosity=2)

The real scheduler run completed all three tests successfully with exit code zero. The observations were the individual ok results, Ran 3 tests, and final OK. No throughput, benchmark, reliability percentage, or production result is inferred from this fixture. Its scope is deliberately narrow: a valid bilingual draft pair passes, state or group drift fails closed, and the timestamp invariant distinguishes Drafted from Published.

Pass and failure conditions are observable. A valid pair being rejected fails the positive assertion. Accepting a nonstandard state or mismatched group fails the negative assertion. Accepting Published without a timestamp fails the invariant assertion. The test process returns a nonzero exit code when an assertion fails, making it suitable for a local project or sandbox gate.

The Python documentation confirms that TestCase provides the assertion framework and that a test module can run through unittest. Fixing the interpreter version matters because reproducibility requires more than a code sample. Record the exact runtime, prerequisites, complete command, and expected observation. If a scheduler cannot execute the fixture, report: Reader-reproducible steps; not executed in the scheduler environment. Never replace missing execution with plausible output.

The fixture does not call the Sheets API or a public route. Those belong to integration gates and require separately controlled test resources. Keeping the local contract test pure makes it deterministic and non-destructive. An integration project can later inject a fake persistence adapter and local HTTP server, but it should not use a live publishing target as a basic unit-test dependency.

Design safe retries, readbacks, and failure reports

A safe retry starts by observing the current state and repeats only computation that cannot create or modify remote records. Any append or transition first checks stable slugs, translation group, and the last proven boundary.

Generation and validation are usually safe to repeat when source files and versions remain fixed. An upsert is different. Before appending, it must reject existing slugs, including nonpublished rows, because daily automation requires new identifiers. After appending, capture the updated range and read it back. An editor must not rewrite the publication time of a row already marked Published; doing so destroys the meaning of the first publication event. Revisions need a separate modified time.

When an external dependency is unavailable, do not use stale context as current online evidence. A previous snapshot may support offline diagnosis or topic comparison, but it cannot prove that rows are still Drafted or authorize a transition. After recovery, obtain new context, recompute source hashes, recheck uniqueness, and resume from the last gate that can be proven with fresh evidence.

Failure messages should include the layer, expected condition, and observed condition without exposing source text or environment-specific values. Editor gate failed: expected two Drafted rows, observed one is actionable. Site gate failed: expected 200 after approval, observed 404 preserves the HTTP fact. A source hash can identify the reviewed revision without placing the note body in a log.

Avoid automatic repair of ambiguous states. Changing Draft to Drafted may look harmless, but the unexpected value could indicate another workflow, an incomplete migration, or a row selected by the wrong query. Stop, identify the writer, and apply a reviewed correction. Fail-closed systems make anomalies visible before they become publication authority.

Retries also need bounded ownership. The generator owns package creation, the persistence step owns append and readback, the editor owns approval, and the site verifier owns visibility observations. A component may retry its own reversible operation, but it should not compensate by performing another component's privileged transition. Clear ownership prevents a recovery script from becoming an undocumented publication path.

Finally, make reports state-aware. A Drafted row with an empty publication time and a 404 route is a normal intermediate result. A Published row with a timestamp and 404 is a site-layer failure. A Drafted row with a timestamp is a data invariant failure. These cases may display the same HTTP response, yet require different actions. The state contract supplies the context needed to interpret the observation correctly.

Prepublication checklist and conclusion

Before approval, verify source, package, persistence, editorial, and site evidence in order. If any layer is missing, stop at the current state, preserve reproducible diagnostics, and never infer that another layer has passed.

  • Source gate: the configured root exists, safe candidates are nonzero, every cited file was read completely, and each SHA-256 value matches.
  • Package gate: there is one Traditional Chinese article and one English article, distinct slugs, one translation group, shared facts, and shared references.
  • Content gate: one H1, useful answer-first sections, safe numbered reproduction steps, and schema matching visible content.
  • Persistence gate: exactly two new Drafted rows, empty PublishedOn values, a captured updated range, and a matching readback.
  • Editorial gate: exact Drafted selection, fresh source verification, successful tests, and one atomic transition to Published with a complete time.
  • Site gate: draft 404 is reported as unpublished; after approval, 200, canonical markup, BlogPosting, and indexing directives pass separately.

Fail-closed scheduled publishing replaces “the job looked successful” with evidence at every boundary. The state machine limits legal transitions, invariants couple status to time, bilingual grouping preserves shared provenance, and independent gates prevent partial success from being reported as publication. Fixed-version local tests verify the decision logic without touching an external system, while official HTTP and API references define what integration observations do and do not prove.

The result is not a promise that failures disappear. It is a workflow in which failures stop safely, identify their layer, and retain enough evidence for a controlled retry. That is the practical standard for automating reviewed technical content: generate freely in a sandbox, persist only validated drafts, grant publication authority only to the editor, and declare a page public only after the public site proves it.