You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
Hooks and ingesters (and soon #215's curate-transforms) are three instances of one thing: named, versioned, containerized, user-supplied code whose executions are provenance-tracked. Today we maintain parallel meta-management implementations for each — #217 exists precisely because the two registries diverged. This issue designs the single abstraction — Function — plus the execution-model redesign that reshapes its core entities: long-lived streaming containers replacing per-batch orchestration, and a candidate-enrichment pipeline with atomic adoption replacing the batch publish machinery.
Greenfield: fresh tables, no data migration — re-deploy conventions and re-ingest. Belongs to the #180 PR 3 epoch (identities and edges are already in motion there).
The model
A Function is named, versioned, containerized, user-supplied code. Its contract says what the name is bound to. Its releases are immutable definitions of exactly what can run. Its invocations record exactly what did run. A session is a long-lived unit of work — a release snapshot plus a watermark — spanning 1..N invocations (restarts resume from the watermark).
One FunctionRegistry[C] port + one PostgresFunctionRegistry adapter + one FunctionRegistryService. Storage: functions / function_releases / function_runs / function_sessions, kind enum column, PK (kind, name) (kinds keep separate namespaces), contract as kind-discriminated JSONB. One router factory instantiated per kind (/hooks, /ingesters, later /transforms) with per-kind auth scopes (hooks:write, new ingesters:write); ingesters gain the full registry surface (releases GET/POST, live PUT, runs) they currently lack.
Idempotency (one rule, both kinds): definition-equality against the live release — byte-identical redeploy is a no-op; any difference (image, digest, config, limits, source_ref; built_by excluded) mints vN+1. No (name, digest) unique constraint (a config-only redeploy legitimately reuses a digest). This is #208's ingester semantics; #217 converges hooks before this abstraction freezes them.
The two (three) instantiations
Kind
Contract type
Contract equality
Conflict
Name policy
hook
FeatureContract(TableFeatureSpec)
model equality
hook_contract_conflict
reserved-name check (URL slot — migrates to feature names under #214)
The contract carries equality, the typed conflict error (message + code), and name policy. Extension rule: a new Function kind = a new contract type (+ execution manifest + provenance-FK target). Never a new registry.
Divergence audit (input evidence — how every current difference resolves)
From a method-by-method diff of the two registries (28 divergences), classified:
Accidental — erased by one implementation (8): assert vs InfrastructureError after inserts (asserts vanish under -O; raise wins), missing error code=s, HookName(root='x') leaking into messages, join-direction, verbatim-copied Outcome type, untyped get_release_by_id.
Parametric — the seams (contract, conflict semantics, name policy, route prefix, auth scope): typed parameters, no booleans.
resolve_live: batch-with-omission (list[name] → dict) + single require_live(name) raising NotFoundError — deletes today's duplicated call-site raises (validation.py:100, process_batch.py:415); PermanentError wrapping stays at the workflow call site.
Invocation ids: caller-supplied; pipeline paths derive deterministic ids (uuid5 over {session}:{chunk}:{name}) so record_invocation's ON CONFLICT DO NOTHING idempotency and crash-recovery guards are real. The deposition path's uuid4 (validation.py:110) is inconsistent with this and gets fixed here.
Exclusion: ingest_runs' batch state machine is orchestration, not a Function concept — and it is deleted outright by the execution redesign below, replaced by the generic Session.
Execution redesign: streaming sessions, no batches
Two execution contracts (the "modes"):
oneshot — mount inputs, run to completion, collect outputs. Deposition path today, unchanged.
streaming — a long-lived container runs until its work domain is exhausted, continuously emitting chunks + a watermark to the output dir (FS contract preserved: chunk files + watermark file; no network). Ingester: streams candidates from upstream until the cursor dries up. Enrichment functions: stream over the candidate flow. Recompute/backfill: stream over published records.
Supervision replaces choreography: liveness = watermark advance (no progress in N min → policy), crash/eviction mints a new Invocation resuming from the session's committed watermark (provenance gets more precise — post-restart rows stamp the restart's invocation), and failure handling is the existing #152 machinery at container granularity (Retry / RetryWithMoreMemory / GiveUp / AbortSession) with restart budgets.
The chunk — demoted to a data-plane detail inside the publisher loop
Progress observability
Watermark + session status (what #204 wants to show anyway)
Container timeout bounds
Progress-based liveness + restart budgets (see risks)
The pipeline: candidates, enrichment, atomic adoption
Records are immutable, and validation decisions may depend on derived features — so mint-then-reject must be unrepresentable, and everything a verdict can read must exist before the mint. (This is also current behavior: hooks gate publication today.) The candidate is the entity that lives through the pipeline, accumulating enrichment; the record is its final frozen form:
candidate → harmonise → derive (features computed ON the candidate)
→ check (findings — may consume computed features, declared via signature DI:
def plausible(s: Submission[Protein], pockets: list[Pocket]) -> Findings)
→ verdict
├─ rejected: candidate + findings + computed features retained in bronze; no record
├─ held: fully-enriched candidate parks in the curation queue
└─ accessioned: ONE transaction mints the record, adopts its features,
updates table_statistics, advances the watermark
The curate/derive distinction is what a function touches, not when it runs: curate fixes/judges asserted content; derive computes attached data and never touches asserted fields. Both run pre-mint; derive additionally re-runs post-mint for recompute under a new release (no record version bump). The same function serves both positions — it is pure over the harmonised shape (Protein) and is not told whether accession happened.
Enrichment is a dependency graph ordered by signatures (harmonisers → derivations → checks that consume them).
In-flight loss on restart = work since last committed chunk — bounded by chunk size (tunable).
Publisher join semantics: per-candidate completeness tracking across N enrichment sessions.
Operator-initiated cancellation (feat: cancel running ingest runs #123): AbortSession exists above only as a failure-policy verb — the design must also expose a user/API-facing cancel on sessions (status transition, watermark semantics for cancelled work, cleanup of in-flight invocations).
Open questions
Re-derivation vs re-judgement: a new release recomputes features on published records; new values might fail a check that passed at admission. Records immutable; retraction exists. Proposal to evaluate: recompute never silently un-publishes — it may attach caveat findings to provenance; judgement is an admission-time event. The one real tension between "checks consume features" and "features are re-derivable".
Held candidates park fully enriched — candidate storage holds feature data pre-adoption (the spool, formalized). Storage shape for bronze candidates + findings + pre-adoption features.
Summary
Hooks and ingesters (and soon #215's curate-transforms) are three instances of one thing: named, versioned, containerized, user-supplied code whose executions are provenance-tracked. Today we maintain parallel meta-management implementations for each — #217 exists precisely because the two registries diverged. This issue designs the single abstraction —
Function— plus the execution-model redesign that reshapes its core entities: long-lived streaming containers replacing per-batch orchestration, and a candidate-enrichment pipeline with atomic adoption replacing the batch publish machinery.Greenfield: fresh tables, no data migration — re-deploy conventions and re-ingest. Belongs to the #180 PR 3 epoch (identities and edges are already in motion there).
The model
One
FunctionRegistry[C]port + onePostgresFunctionRegistryadapter + oneFunctionRegistryService. Storage:functions/function_releases/function_runs/function_sessions,kindenum column, PK(kind, name)(kinds keep separate namespaces), contract as kind-discriminated JSONB. One router factory instantiated per kind (/hooks,/ingesters, later/transforms) with per-kind auth scopes (hooks:write, newingesters:write); ingesters gain the full registry surface (releases GET/POST, live PUT, runs) they currently lack.Idempotency (one rule, both kinds): definition-equality against the live release — byte-identical redeploy is a no-op; any difference (image, digest, config, limits, source_ref;
built_byexcluded) mints vN+1. No(name, digest)unique constraint (a config-only redeploy legitimately reuses a digest). This is #208's ingester semantics; #217 converges hooks before this abstraction freezes them.The two (three) instantiations
FeatureContract(TableFeatureSpec)hook_contract_conflictSchemaBinding(LocalId)ingester_schema_conflictFieldContract(schema field set)The contract carries equality, the typed conflict error (message + code), and name policy. Extension rule: a new Function kind = a new contract type (+ execution manifest + provenance-FK target). Never a new registry.
Divergence audit (input evidence — how every current difference resolves)
From a method-by-method diff of the two registries (28 divergences), classified:
assertvsInfrastructureErrorafter inserts (asserts vanish under-O; raise wins), missing errorcode=s,HookName(root='x')leaking into messages, join-direction, verbatim-copiedOutcometype, untypedget_release_by_id.resolve_live: batch-with-omission (list[name] → dict) + singlerequire_live(name)raisingNotFoundError— deletes today's duplicated call-site raises (validation.py:100,process_batch.py:415);PermanentErrorwrapping stays at the workflow call site.{session}:{chunk}:{name}) sorecord_invocation'sON CONFLICT DO NOTHINGidempotency and crash-recovery guards are real. The deposition path'suuid4(validation.py:110) is inconsistent with this and gets fixed here.HookRelease.with_doubled_memoryrelocates to the runner layer (escalation = a runner deriving a bumpedOciConfig; refactor: untangle hook/ingestion runtime failure handling into facts → policy → action #152's policy already owns the budget).Invocation.retriesgeneric.ingest_runs' batch state machine is orchestration, not a Function concept — and it is deleted outright by the execution redesign below, replaced by the generic Session.Execution redesign: streaming sessions, no batches
Two execution contracts (the "modes"):
oneshot— mount inputs, run to completion, collect outputs. Deposition path today, unchanged.streaming— a long-lived container runs until its work domain is exhausted, continuously emitting chunks + a watermark to the output dir (FS contract preserved: chunk files + watermark file; no network). Ingester: streams candidates from upstream until the cursor dries up. Enrichment functions: stream over the candidate flow. Recompute/backfill: stream over published records.Supervision replaces choreography: liveness = watermark advance (no progress in N min → policy), crash/eviction mints a new Invocation resuming from the session's committed watermark (provenance gets more precise — post-restart rows stamp the restart's invocation), and failure handling is the existing #152 machinery at container granularity (Retry / RetryWithMoreMemory / GiveUp / AbortSession) with restart budgets.
Where each of the batch's jobs goes:
The pipeline: candidates, enrichment, atomic adoption
Records are immutable, and validation decisions may depend on derived features — so mint-then-reject must be unrepresentable, and everything a verdict can read must exist before the mint. (This is also current behavior: hooks gate publication today.) The candidate is the entity that lives through the pipeline, accumulating enrichment; the record is its final frozen form:
Protein) and is not told whether accession happened.Simplification ledger (what gets deleted)
NextBatchRequested→ProcessBatchchoreography (~700 lines: stage guards, redelivery semantics,mark_batch_ingestedGREATEST logic, batch counters,ingestion_finishedlatch)ingest_runsstate machine → genericfunction_sessionsIngestBatchPublishedas a stats carrier (publisher updatestable_statisticslockstep in its own transaction — perf: /data table reads do O(table) work per request — per-request COUNTs, unbounded SQL, unindexable sort #212 D2 simplifies)Risks / new design obligations
safe-to-evict=false) becomes a requirement of this design, absorbed here.AbortSessionexists above only as a failure-policy verb — the design must also expose a user/API-facing cancel on sessions (status transition, watermark semantics for cancelled work, cleanup of in-flight invocations).Open questions
Functionmodule home (shared kernel vs new domain) and whether the deposition oneshot path shares the publisher.Relationship to existing issues
ProcessBatch.Addendum 2026-08-17 — issues this design also resolves or reshapes (from the full-tracker triage):
mark_batch_ingestedGREATEST logic, andingestion_finishedlatch they patch are all deleted; close both when this lands.Invocationrecording removes the two hand-mirrored provenance mappings; the divergence audit above already names itsvalidation.py:110citation.