Pre-release Harn is pre-1.0 — the language, standard library, and CLI may change between releases. See the release notes

Consumer observer admission

The consumer-observer composite action validates an optional observer before a downstream rehearsal starts expensive work. A rehearsal without an observer remains an ordinary run.

Context#

scripts/lib/consumer-observer.mjs owns context validation, the discovery name, and child admission. Consumers use the action at an immutable commit rather than copying its validator.

The closed context contains:

FieldMeaning
schemaharn.consumer_observer.v2
parent_repositoryPublic parent repository
parent_run_idDecimal workflow run identity
parent_run_attemptCurrent workflow attempt
parent_head_shaImmutable parent workflow revision
source_revisionSeparately frozen source being rehearsed
dispatch_nonceUUID identifying this dispatch
started_atOriginal declaration Unix time; unchanged across observation windows

Admission reads authenticated parent and child workflow metadata. It requires the expected parent workflow, revision, current attempt, and live state. The child must be the expected workflow dispatch by the trusted App, with the expected source and the same context in its actual event inputs. The shared owner fixes the dispatch actor to harn-release-bot[bot]; callers cannot change that trust anchor through action inputs or environment variables.

Consumers must register the discovery projection on their workflow:

run-name: >-
  ${{ inputs.observer
    && format('Consumer observer {0}.{1}/{2} @ {3}', fromJSON(inputs.observer).parent_run_id, fromJSON(inputs.observer).parent_run_attempt, fromJSON(inputs.observer).dispatch_nonce, fromJSON(inputs.observer).source_revision)
    || 'Consumer rehearsal' }}

This expression is mechanically checked against observerRunNameExpression. The exact title binds discovery to the admitted dispatch; an ordinary workflow title does not satisfy observer admission. The action cannot set a workflow's run name.

Before dispatch, the parent stores the exact closed context as consumer-observer.json in an immutable artifact named by observerDeclarationName(context). This public declaration contains no child identity. Admission reads that artifact from the authenticated parent run, checks its revision and archive digest, and requires the exact context. Missing, duplicate, expired, or mismatched declarations refuse ownership. A fabricated nonce for a real live parent is insufficient.

The artifact request filters by the exact declaration name. Its matching census must be complete before admission; unrelated build artifacts do not consume the lookup's bounded census. At most 1,000 matching results are read in complete 100-result pages. Partial, duplicate, changing, or over-budget results refuse ownership.

The action requires Node.js, unzip, and Actions read permission for both runs and the parent's declaration. Artifact archives are limited to 64 KiB and must contain only consumer-observer.json.

The action emits owned=false for an ordinary rehearsal. An admitted observer emits owned=true, a receipt path, and the dispatch nonce. The consumer stores that receipt in its own repository. Private child identities and artifacts stay private.

The private v2 ownership receipt includes an admission-minted random receipt_nonce. Terminal v2 receipts retain that same nonce. Only authenticated, normalized custody is hashed into the public custody_commitment; the nonce never leaves the private receipt. This binds recovery to one exact admitted child without exposing guessable private run identities.

The discovery name can locate a candidate; it cannot authorize cancellation. An unreadable run or a missing identity refuses admission. A stale attempt, ended parent, or unmeasured child state also refuses before expensive work. This action alone does not cancel children or replace the observer's continuation windows.

Completion reconciliation#

consumer-observer-cleanup.yml executes trusted default-branch code after one of the three allowed parent workflows completes. It reads the exact historical parent attempt, its complete job and artifact census, and its declarations. Legacy parents with no declaration remain ordinary. The shared owner exports the publication-step identity; workflow projections must use it exactly. Completion reads that step's measured outcome, independently of reusable caller prefixes on job names. A successful publication step whose artifact is missing, or an unmeasured job-step census, refuses reconciliation. The number of authenticated declarations must match the one successful publication step. Multiple publishers, partial publication and artifacts left by failed publication remain unproven. One declaration can still own multiple children. The inert consumer-observer-declare action prepares and uploads that public declaration. Callers must name its outer workflow step exactly Publish consumer observer declaration; the action metadata is mechanically checked against the shared owner. Dispatch consumes the action's closed context only after publication succeeds.

consumer-observer-dispatch.mjs binds that context to its actual invoking run, attempt and workflow revision. The shared dispatch owner authenticates the immutable declaration and reads the latest live parent immediately before the request. Parent reads use the job's public read token; the dispatch uses the existing consumer-scoped App credential. A successful dispatch response exports no private child identity. If the parent ends after the final read, downstream admission still refuses expensive work.

reconcileOwnedChildren discovers candidate children by the shared title, then authenticates each private receipt and freshly reads that child's latest attempt. The receipt must bind the same parent, declaration, source, workflow, App actor, child revision, and child attempt. A newer child attempt refuses the old receipt. Ordinary or reused rehearsals have no observer title and are preserved.

Only a live receipt-owned child of a completed parent receives a cancellation request. Completion observation is bounded to six authenticated reads with five seconds between readings. A request accepted by GitHub is counted separately from measured termination. Pending or refused readings fail the listener; neither missing custody nor an empty discovery census reports successful cleanup. Multiple independently owned children are reconciled separately. A final complete discovery read counts children that appeared during cleanup as pending. This covers children visible at that final census. Discovery is not atomic against a dispatch request still in flight, so a child appearing after the final read remains a blind spot; dead-parent admission still refuses it.

GitHub's cancellation API has no atomic attempt precondition. A final fresh child reading narrows the read/cancel race but cannot eliminate it. Reconciliation prints only declaration and outcome counts, never private repository identities, child run IDs, receipt contents, or arbitrary network errors. The automatic parent dispatch callers remain unchanged until the operational lifecycle is qualified.

Product decision receipt#

The consumer-observer-terminal action records the private consumer's reached canonical decision. It reads the authenticated ownership artifact from its own run, the parent's declaration and exact historical attempt, and a fresh child reading. The producer's current run, attempt and revision must match that reading. An ordinary rehearsal emits recorded=false without requiring a product file.

The canonical decision file contains exactly source_revision, verdict, owed, proven, pending, failed and failed_suites. Recording requires the observer's source, a positive owed count, no pending checks, and owed=proven+failed. A pass has no failures and an empty failed_suites; a failure has positive failures and exactly that many distinct suite identifiers. Missing, skipped, empty or partial decisions refuse recording. Harn validates this shape; the consumer owns which product checks are owed and must emit the file only after its actual canonical judgment reaches every owed check. A reached failure requires a source-qualified suite receipt and actual producer failure. Setup, logging, skipped or missing evidence cannot manufacture a product failure.

The action returns recorded=true, a private receipt path and the canonical artifact name. Store the file in the consumer repository under that name, consumer-observer-product-<nonce>. Its archive contains the single standard filename consumer-observer-<nonce>.json. No child identity is published upstream. Recording while the producer runs does not authorize release: a reader must apply qualifyTerminalReceipt with the same authenticated ownership and parent declaration. A pass requires fresh completed child SUCCESS; a reached product failure requires fresh completed child FAILURE. Execution failure alone leaves the product decision unreported.

consumer-observer-observe.mjs reads that decision through the same custody owner used by cleanup. Its release verdict output exists only for pass or fail. The separate observation_outcome also reports pending, refused or execution_failed. A closed reading contains discovered, owned, pending, refused, custody_commitment, execution and product. Missing or ambiguous children and missing product receipts have product=null; this is an unreported decision, not measured zero checks. A pass requires exactly one discovered and authenticated child, a complete private receipt and fresh completed SUCCESS. The returned product counts come from the reached decision, never a private workflow job table. Final complete discovery and fresh child reads narrow the race; GitHub provides no atomic snapshot against a dispatch or rerun arriving after the final read.

consumer-observer-read-decision.mjs selects the original issued public context without granting new ownership. It accepts a reached pass or failure only after refreshing the parent identity, state and attempt, selected source eligibility, complete child census and child attempt. A completed parent must retain its conclusion; a live parent must remain live. The caller specifies whether the consumer must still be on its current default source. An absent product receipt does not authorize publication or retirement, even when execution failed.

consumer-observer-read-parent.mjs discovers an issued context by its public parent and role. Its default operation requires a reached pass for historical reuse; --decision returns a reached pass or failure through the same reader. Both return owned=false. Reuse requires the current default consumer source and retains the original context, rather than issuing another declaration.

The legacy canary and authenticated selector use consumerPairing for the same trailer grammar. The legacy shell projects that owner through the stdin-only consumer-pairing.mjs command with an environment containing only PATH. The workflow installs the pinned Node runtime before its existing dispatch. An absent trailer selects the default source; malformed, zero or ambiguous pairings refuse the entire input.

The declaration owner records started_at immediately before qualifying the live parent and uploading its immutable declaration. The budget therefore includes declaration qualification and upload, but excludes earlier candidate builds. Callers cannot supply a separate start. Schema v2 replaces v1 directly; no automatic caller had issued a production context before this cutover.

The bounded waiter keeps the original declaration start across credential renewal windows. Window expiry can continue observation; aggregate expiry cannot become a product pass or failure. Execution failure with no reached product decision stops waiting through execution_failed and leaves the release verdict absent. An expired renewal still performs one fresh authenticated observation. Only a singular owned child with no reached product decision and no refused or failed execution can produce a closed deadline receipt after 7,200 seconds. A late product result, missing custody or infrastructure failure cannot produce it. The deadline qualifier binds the issued start and observation time to the canonical declaration and failed observation step intervals, followed by successful publication in that same measured job. The descriptor contains the public context, observation time, counts and opaque custody commitment, never a private child identity or receipt nonce. Deadline authentication and publication wiring remain unactivated. The decision command's inert --deadline operation authenticates this original receipt, its exact failed parent attempt, complete job census and canonical producer steps. It refreshes the parent after reading the digest-bound artifact. This historical evidence grants no product verdict or cleanup ownership.

The inert --recover operation combines that reached deadline with the shared current-default product reader. The normalized private receipt must reproduce the original deadline commitment, including its admission nonce, child run, attempt and source. A different independently admitted child cannot replace the original, even when that original disappears. Recovery requires a completed successful child, a reached pass and a complete exact-attempt job census with at least one actual non-skipped job completed strictly after the original deadline. Every job must belong to the same run, attempt and revision and have an accounted terminal state. Missing timestamps cannot supply the completion witness. The reader refreshes parent/source eligibility, discovery and child state after the timing census. Child job identities and chronology remain private; the result retains owned=false. The publication owner must separately qualify the producer's source, consumer-only failure profile, archives and attestations. The dispatch, decision and waiter commands remain inert until their owning workflow callers are integrated and the operational lifecycle is qualified.