Vocabulary · Concepts · API · Command line · Undecided · Where each rule lives · Job schema · Logging API · Config API · Router API
The logging API reference is generated from its Thrift definition. Integration of that protocol into the installed service is still pending.
Vocabulary and API
The application holds an id; the work belongs to the machine. A lease that stops being renewed lapses, the next claim
raises the epoch and inherits the checkpoint, and the successor resumes from the proven prefix and discards the rest —
RESUME1
records that between two processes, and
XLANG-DOWNLOAD
between two languages. Go, Python and C++ agree on the record and the rules. Each implements them its own way.
Show
The words
Every term names its ancestor or says it has none; where our name or shape differs from the established one, the difference is stated.
| word | means | is, elsewhere |
record | one job: an opaque id that outlives every process, and everything a stranger needs to continue the work | Google longrunning.Operation (AIP-151) |
kind | what the job is, and therefore who may read spec and checkpoint. The job layer never does | — |
spec | the immutable description of the work. Opaque to the job layer | Operation.metadata. Not Kubernetes' spec, which is mutable desired state — here that is intent |
checkpoint | what a successor needs to continue: the proven prefix, proven ranges, and the HTTP validator — ETag or Last-Modified — the bytes came from. The only thing anybody resumes from | Temporal activity heartbeat details; tus Upload-Offset; BitTorrent's piece bitfield |
progress | best-effort, explicitly non-monotonic, decide nothing on it | BytesTransferred in BITS, Windows' Background Intelligent Transfer Service — the platform downloader every Windows machine already runs |
lease | time-bounded ownership, kept by renewing and lost by lapsing, never by being asked | Chubby; Kubernetes Lease; SQS visibility timeout; beanstalkd TTR |
epoch | rises by one on every claim. Every write presents the epoch it holds; a stale one is refused | Chubby's sequencer; the fencing token of Designing Data-Intensive Applications, ch. 8; Kafka's leader epoch; If-Match with a strong ETag |
holder | the party whose lease.owner and lease.epoch the record carries while expires_at is in the future | Kubernetes holderIdentity |
claim | take the lease. Exclusive, compare-and-set under the record's lock | acquire (Azure Blob lease, Kubernetes Lease); beanstalkd reserve |
orphan | a record that is claimable and stranded: not terminal, not held, not transferred, not paused | Sidekiq Pro's super_fetch orphan. Not Kubernetes' orphan, which is un-parented on purpose |
intent | what somebody wants — run, pause, cancel — written by anyone, without a lease; the holder converges on it | Kubernetes deletionTimestamp; Argo spec.suspend; BITS Resume/Suspend/Cancel |
recall | the issuer asking the holder for the lease back by a deadline, for a reason. The lapse at the deadline is the eviction | the video-memory budget signal of WDDM, the Windows display driver model; Android's trim. The half a Chubby lease lacks |
delegation | the work was handed to an external system; {system, external_id} finds it again; progress is then a cache and the external system is the truth | Camunda external task; CSI external provisioner; BITS job GUID |
transferred | finished and proven, but not yet taken delivery of — moved to its final name and acknowledged by whoever asked. Not terminal; claimable so the requester can collect | BITS TRANSFERRED before Complete() |
terminal | complete, failed, cancelled. No claim, no update, no intent. Nothing reopens it | — |
complete | delivery taken. Trap: BITS calls this ACKNOWLEDGED; a reader mapping complete onto "the transfer finished" is one state early | AMQP basic.ack; Kubernetes and Azure Succeeded |
content / critical | the schemas this record carries — each a namespaced, versioned name like abstraction.job/intent@1 — and the subset a reader must understand or refuse the whole record | JOSE crit (RFC 7515); JSON Schema $vocabulary; X.509 critical extensions |
envelope | which schema the opaque halves follow (schema) and what may be asked of a job of this kind (actions); written once, and nothing dereferences either | the name: google.protobuf.Any's type_url (any.proto: do not contact type URLs), CloudEvents dataschema — diverging: ours is a grammar that cannot spell a URL. The list: Kubernetes APIResource.verbs, the Windows Service Control Manager's dwControlsAccepted — diverging from AIP-151, whose Operations service fixes five verbs where ours is open per kind. Primary text read 2026-09-09 |
provider | the thing that actually does the work, chosen by the machine rather than named by the application: Discover() reads the machine and hands back whatever it has — this process, a service, a NAS | the service-provider interface: Java's ServiceLoader, JDBC drivers, Go's database/sql drivers |
unavailable · forbidden · unknown | the three ways a request does not succeed, and they must never collapse: nothing can do this now, something said no, no answer arrived | SMTP 4yz/5yz for the first two. The third has no common ancestor; xa_recover in X/Open XA is the closest |
Concepts
Each: what it is, when it is met, an example that runs, remarks. Examples are lifted from the tests and the demo, not written for this page.
1. The record is the contract
Go, Python and C++ are not ports of each other and are not generated from a schema. They agree on one thing: the JSON on disk — one file per job, under a directory called the store — and the rules for taking it over. Field order, two-space indent, LF, UTC timestamps with exactly six fractional digits — because the bytes are compared across languages.
{
"content": ["abstraction.job/base@1", "abstraction.job/intent@1"],
"critical": ["abstraction.job/base@1", "abstraction.job/intent@1"],
"id": "1757000000000-deadbeef",
"kind": "download",
"state": "running",
"spec": { "artifact": {}, "sources": [], "sink": {} },
"checkpoint": { "verified_prefix": 400 },
"progress": { "done": 460, "updated_at": "…" },
"lease": { "owner": "go-worker", "epoch": 2, "expires_at": "…" },
"intent": { "want": "pause", "by": "comfyui@desktop:9184", "at": "…" },
"created_at": "…", "updated_at": "…"
}
Strings are escaped exactly one way [JOB-E6]: " and \, the five short control escapes, every other character below U+0020 as lower-case \u00xx, and U+2028 and U+2029. Everything else is raw UTF-8 — &, <, >, /, every non-ASCII character, and characters above U+FFFF as four bytes rather than a surrogate pair; an ill-formed byte becomes �, one per byte. That is RFC 8785's string serialisation with two divergences declared on the contract page. The rule exists because Go's encoder escapes for HTML and Python's for transports that cannot carry high bytes, and a record with an ampersand in its URL came out three ways from three implementations that passed every check; the fixture locator-ampersand.json now carries it.
Remarks. A field not in the table on abstraction-job/README.md is refused, not ignored — the opposite of protobuf and of the robustness principle (be liberal in what you accept), and declared as a divergence. Anything else goes in extensions under a namespaced key and is written back untouched. Inside spec the rule flips: unknown keys are ignored, because a spec is payload the layer above extends. spec is the worst name on the page and cannot change: a renamed field stops every old reader on every record already on disk.
2. Ownership is a lease with an epoch
What. To work on a job, a process claims it for a ttl. The claim returns the record carrying the new holder's epoch, one higher than before. Every write the holder makes presents that epoch. When it stops renewing, the lease lapses; nothing tells it, and its next write is refused. Somebody else's claim then gets epoch+1 and inherits the checkpoint.
When. Every time anything moves bytes, and every time a process starts: look for orphans and adopt them. That pass is the sweep; a supervisor is a process that does nothing but sweep on a timer, and jobd is ours. Reclaiming is the mechanism; a polite release is only an optimisation, because a killed process never releases anything.
Example — claim, checkpoint, lapse, adopt
store, _ := job.NewFileStore("/var/lib/jobs")
rec := job.Record{Kind: "download"}
rec.SetSpec(mySpec) // opaque here; the download layer reads it
id, _ := store.Submit(rec)
r, _ := store.Claim(id, "my-worker", 30*time.Second)
store.Update(id, r.Lease.Epoch, func(r *job.Record) error {
r.Progress.Done = 1 << 20
return r.SetCheckpoint(myCheckpoint) // what a successor would need
})
// …thirty seconds pass with no renew…
orphans, _ := store.Orphans() // [the record]
adopted, _ := store.Claim(orphans[0].ID, "successor", time.Minute) // epoch 2; inherits Checkpoint
_, err := store.Update(id, r.Lease.Epoch, func(*job.Record) error { return nil })
errors.Is(err, job.ErrStaleEpoch) // true
From abstraction-job/README.md § Try it, and abstraction-job/go/store_test.go TestOrphanIsAdoptedAfterExpiry, TestZombieOwnerIsRefused.
store = FileStore("/var/lib/jobs")
jid = store.submit(Record(id="", kind="download", spec={"what": "a thing"}))
held = store.claim(jid, "python-worker", ttl_seconds=30)
def progress(r):
r.progress.done = 400
r.checkpoint = {"proven": 400}
store.update(jid, held.lease.epoch, progress)
# …31 seconds pass with no renew…
orphans = store.orphans() # [the record]
adopted = store.claim(jid, "go-worker", 60)
adopted.lease.epoch # 2
adopted.checkpoint["proven"] # 400 — the successor inherits what was proven
store.update(jid, held.lease.epoch, lambda r: None) # raises StaleEpoch
From abstraction-job/python/test_abstraction_job.py, test_orphan_is_adopted_after_expiry and test_zombie_owner_is_refused.
using namespace abstraction::job;
FileStore store(root);
Record r;
r.kind = "download";
r.spec = Json::parse(R"({"sink":{"final":"model.gguf"}})");
const std::string id = store.submit(std::move(r));
const Record claimed = store.claim(id, "cpp-worker", std::chrono::seconds(30)); // epoch 1
store.update(id, claimed.lease.epoch, [](Record& rec) {
rec.progress.done = 460;
rec.checkpoint = Json::parse(R"({"verified_prefix":400})");
});
// …the lease lapses…
store.orphans().size(); // 1
const Record adopted = store.claim(id, "successor", std::chrono::seconds(30)); // epoch 2
(*adopted.checkpoint)["verified_prefix"]; // 400
store.update(id, 1, [](Record&) {}); // throws StaleEpoch
From abstraction-job/cpp/test/test_job_record.cpp, test_lease_rules.
Where the three differ. The ttl is time.Duration in Go, ttl_seconds: float in Python, std::chrono::milliseconds in C++. A refusal is a returned error in Go (errors.Is against job.ErrStaleEpoch), an exception in Python (StaleEpoch) and C++ (StaleEpoch). spec is raw bytes set through SetSpec(any) in Go, a dict in Python, a Json value in C++ — and Python and C++ re-emit what they parsed, so a number spelling or escape can change on the way through one and not another; specread --echo exists to catch it. The contract asks for both opacity and byte identity over spec, and both are required [JOB-E7]: an implementation must reproduce the exact bytes of an opaque value it was given. A payload already in the JOB-E6 form is written identically by all three; one write by a re-spelling implementation brings an older one to that form.
Remarks. The lapse is silent: expires_at passing signals nothing. A holder learns at its next write, and the verdict — the single word an operation answers with, and the only part of a refusal a caller may branch on — tells it whether it has a successor: lease-expired while nobody has claimed since, stale-epoch once somebody has. A live lease held by another owner refuses a claim lease-held. The same owner may claim again while live and the epoch still rises. The lock is one machine's: two hosts writing one record over SMB lost 147–149 of 2150 updates; a record shared across hosts is handed off by delegation, not co-written.
3. A successor inherits what its predecessor proved — not what it did
"progress": { "done": 460 },
"checkpoint": { "verified_prefix": 400, "verified": [[0, 400]] }
The predecessor wrote 460 units and proved 400. The successor resumes from 400 and discards the rest. For a download the resume point is the smaller of the checkpoint's prefix and what the file actually holds; the file is the floor and a partial is never deleted to make a record true. Ranges are half-open, sorted, merged where they touch. HTTP's byte ranges are inclusive at both ends — [0, 8) goes out as bytes=0-7; same word, different arithmetic, and every implementation converts at the wire.
4. Six states, and who writes each
pendingsubmitted; nobody on it. Written by the store on submit and on release
runninga live lease, or a live delegation. Written by the store on claim
transferreddone and proven, not collected. Written by the holder
completedelivery taken. Holder. Terminal
failedthe request as written can never work. Holder. Terminal
cancelledthe holder honoured intent: cancel, or a momentary claimant did. Terminal
| from | to | by |
| — | pending | submit (store) |
pending, running | running | claim (store) |
running | pending | release (store), unless delegated |
| any non-terminal | any state | holder's update — backwards included; the store guards only terminality |
| non-terminal | cancelled | a momentary claimant — a process that claims the unheld record only to write this — when nobody holds the lease |
| terminal | — | nothing. renew is the one write still accepted; it changes nothing a reader branches on |
Remarks. The store cannot decide transferred, complete or failed, because deciding them means knowing whether the work is done, and it does not know what the work is. Terminal is judged on the record as read, so the write that makes a record terminal lands and the next one of that epoch is refused: put everything into the final update — the error, the last checkpoint, the final done. A defer release() after a final write is a call that cannot succeed. The state names are BITS' with one exception, and the exception (complete) is a trap; see the words.
5. Intent — the one write with no epoch
What. The person who wants a job stopped is not the process doing it, and requiring a lease would mean stealing the job in order to stop it — the one thing the lease exists to prevent. So intent is written by anyone, holding nothing, and the holder must read it before starting and at least as often as it checkpoints, and move toward it.
When. A pause button, a cancel button, a supervisor on another machine that a UI here needs to stop.
Example — a person cancels while an owner is dead; the next owner honours it before moving a byte
submit A size=64 digest=good src=file
claim A alpha 400
intent A cancel
sleep 900
orphans -> ok A
run A beta -> state=cancelled done=0 [DL-R26] [JOB-I9]
Scenario cancel-adoption.txt; all three implementations produce this transcript byte for byte.
j := job.Open(store, id, "comfyui@desktop") // a handle: look at it, give up on it
j.Cancel() // records the request; succeeds while somebody holds the lease
if p, ok := j.(job.Pausable); ok { p.Pause() } // pause is an extended capability an application asks for
store.set_intent(jid, CANCEL, by="comfyui@desktop") # no epoch, idempotent
store.set_intent(jid, PAUSE, by="ui")
store.set_intent(jid, RUN, by="ui") # resume records a value; it does not delete the field
store.set_intent(id, want::kCancel, "a-person"); // no epoch
store.set_intent(id, "nonsense", "a-person"); // throws: an unknown want is refused, never treated as run
Where the three differ. Go has a handle, job.Job, whose base is deliberately two things — Record() and Cancel() — with Pausable as an optional interface a provider advertises. Python and C++ have no handle; an application calls set_intent on the store. A double cancel: the store refuses intent on a cancelled record as terminal; Go's handle absorbs it as a double click. Undecided which is the rule.
Remarks. One word, four operations, and a caller has to know which it is holding:
| "cancel" | what it is | who | crosses a process boundary |
intent: cancel | a request written to the record | anyone, no epoch | yes — that is its purpose |
state: cancelled | the outcome | the holder, or a momentary claimant when unheld | it is the record |
| stopping the execution | the holder stopping itself at its next intent check | the holder | never — nothing reaches into a process |
| stopping the wait | a caller's local cancellation (a Go ctx) | the caller | no; writes nothing |
Cancelling never stops an execution. It records a request; the acknowledgement is the holder's own write of cancelled. A caller that must know the work stopped waits for the state. cancel is honoured by everything; an owner that cannot honour pause fails the job with a reason rather than carrying on, because a pause that quietly does nothing is worse than no pause button. A paused job is not an orphan — unless its state is still running, which is an owner that died between the ask and the honouring; the sweep offers that one so the next owner can honour it, and the scenario above is exactly that case.
6. Recall — the issuer asks for the lease back
What. A Chubby lease expires; nothing recalls it. Systems that genuinely arbitrate a resource have the other half: WDDM tells a process its budget changed and evicts it if it does not trim. A recall presents the epoch the issuer read — the fencing token pointed the other way — and moves expires_at to until. The holder may still renew and checkpoint until then, never past it; it cannot shed the recall by re-claiming; the lapse is the eviction and nothing else is. A recall is about the resource; it leaves intent, the user's wish about the job, untouched.
claim A alpha 5000
recall A beta 800 yield -> stale-epoch [JOB-R1] beta never held it
recall A alpha 800 -> invalid [JOB-R2] no reason
recall A alpha 800 yield -> ok recall=yield want=run [JOB-R3] [JOB-R8]
claim A alpha 5000 -> lease-held [JOB-R5] cannot re-claim under recall
Scenario recall.txt. Command line: jobctl recall <id> --epoch N --reason WHY [--grace SECONDS] [--by who] — --epoch is the one the caller last read, not one it holds.
7. Acceptance — the instant ownership of a request transfers
Before it, an uncertain outcome means nothing was taken and retrying is free. After it, an uncertain outcome is unknown, and a retry starts a second job. Where a download runs is chosen from an ordered list — a NAS, a platform service, this process — and that order is the machine's tiers. Acceptance is a barrier in it: before acceptance a failure moves to the next tier; after it nothing may, because a second tier would start a second job.
| operation | accepted when | a lost answer |
| submit | the record exists under its id | only a submitter that named the id can ask: invalid on resubmission then means accepted, load it. One that let the store mint the id cannot, and a retry makes a second job |
| claim | its write lands under the lock | safe by construction: the retry is lease-held, a same-owner re-claim one epoch higher, or the claim |
| update | the write lands | unknown, and the store offers no way to ask. A retried final write is answered terminal, the one case that says it landed. There is no request identity on the write path |
| intent | — | free: idempotent by rule |
| handoff to a delegate | the delegate's own answer | the download layer writes the delegation before handing over and asks the delegate's Locate: a handle, a certain never, or unknown. XA's xa_recover shape. Go only |
8. The three answers
What. A request that did not succeed ended one of three ways, and a provider that collapses them has told the caller nothing:
| answer | means | the caller must | download spells it |
unavailable | nothing that could do this is here now. The request stands | ask again later; create nothing | not now: the record keeps its error, the lease lapses, the next runner resumes from the last proven byte |
forbidden | something answered and said no | end this request; a retry is a new request | no: state: failed, and nothing tries it again |
unknown | no answer arrived. Not a no, not a yes, not a later | reconcile before retrying: load, Locate, or resubmit by id | ErrOutcomeUnknown, Go only |
The verdict classes — the named refusals in the API tables below — map onto them, and what each obliges is the whole reason they are separate:
| verdict | answer | the caller must |
lease-held · lease-expired · stale-epoch | unavailable, to this caller | re-read the record and decide again. lease-held retries after expires_at; the other two retry by re-claiming, never by re-presenting the epoch |
terminal · invalid · not-found | forbidden | end this request |
unknown-model | neither | the record is fine and this reader is too old: leave it for something newer; never discard, never retry here |
| no verdict | unknown | reconcile first |
Example — one application, three providers, nine situations
$ ABSTRACTION_STORE=/tmp/s fetch -provider curl get http://host/f.bin f.bin
provider=curl where=curl answer=delivered id=curl-0b55… detail="6291456 bytes sha256:e56b… at /tmp/f.bin"
| situation | curl | here | service |
| no service installed at all | delivered | delivered | unavailable |
| service installed between runs | delivered | delivered | delivered |
| caller exits mid-transfer, comes back | unknown — left running by a pid nothing can prove stopped | delivered | delivered |
| the provider refuses the request (403) | forbidden | forbidden | forbidden |
| the service is removed while a job exists | unknown | unavailable | unavailable |
| a guarantee the provider cannot give | unavailable | unavailable | unavailable |
| the destination the caller actually named (absolute) | delivered | delivered | unavailable |
Seven of the nine rows; the whole table is on Evidence, 2026-09-08. fetch is written once against download.Client and never names a provider. Exit codes: 0 delivered or working, 3 unavailable, 4 forbidden, 5 unknown.
Where the three differ. The five words — delivered, working, unavailable, forbidden, unknown — live in the demo program, Go, and are not in a published repository. The job layer has no sentinel for unknown in any language and the transcript format cannot express it — a transcript is one process on its own store. Only Go's download layer names it (ErrOutcomeUnknown). And the error class does not survive Go's Client boundary: the runner stores err.Error() and Wait returns that string, so the only surviving signal of no is state: failed. The demo rebuilds the answer from the state and the text, and marks every answer it had to guess.
9. Retries — not now against no
Retry classification, as old as SMTP's 4yz/5yz. A dropped connection, a full disk, a NAS that rebooted: not now. A request that can never work as written: no. Backwards in either direction costs: a 404 classed not now is re-fetched on every sweep forever; a dropped connection classed no throws away the case this project exists for.
- The class is attached where the error is defined, never kept in a list. Go and Python each kept a list; they disagreed by a row for as long as both existed.
- Over HTTP, the source refused is exactly
400 401 402 403 404 405 406 410 414 451. Listed, not ranged: an unrecognised 4xx is not now. That breaks RFC 9110 § 15 on purpose — 499 is nginx closing on its own client and 440 an IIS session timeout, and neither is a statement about the file.
- No is: the source refused, nothing can serve the job's sources, the sink escapes the store root or names the store's own layout, the record is invalid. Everything else is not now — including a sink written in the other platform's path convention, which is unusable here and fine where it was written.
- The attempt count is the lease epoch. A record carrying an error waits
updated_at + min(15 s · 2^(epoch−1), 15 min); a record without one is adopted at once, because a killed owner wrote nothing.
- Taking a lease to publish a refusal is a write: it bumps the epoch and charges the submitter's backoff. So a sweep declines to claim a job whose sink it cannot write, and refuses out loud only when asked by name.
download.Permanent(err) // true: trying again unchanged is pointless
download.RetryAfter(rec) // time.Time; zero when the record carries no error
permanent(exc) # isinstance(exc, (Permanent, Invalid))
retry_after(rec) # POSIX timestamp; 0.0 when the record carries no error
download::Error e(...); e.permanent(); // the class rides on the exception
Where the three differ. C++ carries the class on download::Error and has no retry_after: the backoff formula exists in Go and Python only, tagged on no contract page and cited by no scenario. Whether the job layer owns the formula or only the counter is undecided.
10. Declarations, and what an older reader does
There is no version number and no negotiation. A record says which schemas it carries (content) and which a reader must understand (critical); both are derived from the record on every write, never remembered. An unknown name in critical is refused with the verdict spelled unknown-model in a transcript and unknown_schema in the definition: the record is fine, this reader is too old, leave it alone. abstraction.job/terminal@1 and recall@1 name rules, not fields — a shape can be ignored, a rule about writes cannot, because the reader that does not know it is precisely the reader that breaks it. Measured: a reader published one day earlier walked a complete record back to pending under an unchanged base@1.
Over the socket transport none of this runs: content and critical are stripped in both directions and an unknown field is dropped where the disk would refuse it. The socket ships to nobody today. Four combinations of a client and a service one day apart all connect, none refuses, and two finish the same script with the job in a different state (measured 2026-09-08; transcript not yet public).
11. Delegation — handing the work to something that outlives the caller
Two shapes, and each half is somebody's published interface; the pair is not, and is the one concept on these pages with no ancestor. A Fetcher streams bytes through us and dies with the process (http, a local file, an SMB path). A Delegator does the work under its own account and hands back a handle (BITS, a NAS daemon). The Runner is what drives either one: it claims the record, resumes from what was proven, hashes, verifies and delivers. A caller asks for survives_process_exit; which shape provides it is the machine's business. When delegated, the handle is {system, external_id}, the lease is released — holding it would stop anyone else verifying and delivering the finished file — and progress is a cache. A delegate's "success" is verified by hashing the delivered file: BITS guarantees size and timestamp, not content. A handle that stops resolving returns the job to pending with its sources and checkpoint intact.
runner.Delegators = download.NewDelegators(bits.New())
err := runner.Delegate(ctx, id) // returns as soon as the service has it; this process may now exit
n, _ := runner.ReconcileAll(ctx) // any later process catches up with work handed to a service
n, _ := runner.Adopt(ctx) // claims every orphan and finishes it here
runner.adopt() # claims every orphan and finishes it here
Runner runner(store, "cpp-worker");
runner.run(id); // claim, resume from what was proven, hash, verify, deliver
Where the three differ, and it is the largest gap on this page. Delegator, Delegate, Reconcile, the BITS and NAS delegates and the supervisor jobd exist in Go only. Python has a Runner that fetches and adopts and can be supervised by the Go jobd; C++ has a Runner that fetches and no adopt sweep. All three read and write a delegated record correctly — the field is in the contract — but only Go can create one. Intent on a delegated record (who honours cancel when the holder released to a delegate) has no rule in any language.
API
Signature, then what it refuses. Each language's shape is that language's; the refusal classes are the contract, their wording is not.
Store
| operation | Go job.Store | Python abstraction_job.Store | C++ abstraction::job::Store | refuses |
| submit | Submit(r Record) (string, error) | submit(r) -> str | std::string submit(Record) | invalid — a supplied id that already exists, an empty kind |
| load | Load(id string) (*Record, error) | load(job_id) -> Record | Record load(const std::string&) const | not-found, unknown-model. Never takes the lock; any process may read |
| list | List() ([]*Record, error) | list() -> List[Record] | std::vector<Record> list() const | see below |
| orphans | Orphans() ([]*Record, error) | orphans() -> List[Record] | std::vector<Record> orphans() const | see below |
| claimable | Claimable(r *Record) bool | claimable(r) -> bool | bool claimable(const Record&) const | a predicate over a record and a clock; no round trip |
| claim | Claim(id, owner string, ttl time.Duration) (*Record, error) | claim(job_id, owner, ttl_seconds) -> Record | Record claim(id, owner, std::chrono::milliseconds ttl) | not-found, lease-held, terminal, unknown-model; an empty owner is refused with no fixed class |
| renew | Renew(id string, epoch int64, ttl time.Duration) (*Record, error) | renew(job_id, epoch, ttl_seconds) -> Record | Record renew(id, epoch, ttl) | stale-epoch, lease-expired even when the epoch matches. Accepted on a terminal record |
| release | Release(id string, epoch int64) error | release(job_id, epoch) -> None | void release(id, epoch) | stale-epoch, lease-expired, terminal. An epoch-checked write, not advisory: courtesy means only that nothing depends on it |
| update | Update(id string, epoch int64, mutate func(*Record) error) (*Record, error) | update(job_id, epoch, mutate) -> Record | Record update(id, epoch, const std::function<void(Record&)>&) | stale-epoch, lease-expired, terminal, invalid. The single gate every change passes through |
| set intent | SetIntent(id string, want Want, by string) (*Record, error) | set_intent(job_id, want, by="") -> Record | Record set_intent(id, want, by) | terminal, invalid on an unknown want. No epoch. Idempotent |
| recall | Recall(id string, epoch int64, reason, by string, grace time.Duration) (*Record, error) | recall(job_id, epoch, reason, by="", grace_seconds=30.0) -> Record | Record recall(id, epoch, reason, by, std::chrono::milliseconds grace) | stale-epoch if the record moved, invalid without a reason, lease-expired where nobody holds it, terminal |
Where the three differ. A partial answer — some records read, some unreadable — is a fourth shape. Go's List and Orphans return the records beside an *ErrUnreadable naming the ids. Python and C++ skip an unreadable record and return success, so a sweeper there calls itself healthy while work is stranded. Live divergence; the cross-language rule is undecided. Python and C++ also expose claim_from(seen, owner, ttl): claim the record as the caller last read it, refused lease-held if it moved since. Go has no equivalent on the interface.
Refusals by language: Go returns sentinel errors — job.ErrNotFound, ErrLeaseHeld, ErrStaleEpoch, ErrLeaseExpiry, ErrTerminal, ErrInvalid, ErrUnknownSchema — matched with errors.Is. Python raises NotFound, LeaseHeld, StaleEpoch, LeaseExpired, Terminal, Invalid, UnknownSchema, all under JobError. C++ throws the same names under JobError, with TerminalState for terminal. All three spell one Verdict enumeration declared in abstraction-job/job.thrift, from which the Schema page and the Go record type are generated.
Optional: a local area
A store backed by a filesystem can say so. A store backed by a service answers no, and a caller must have an answer for that.
if sc, ok := store.(job.Scratch); ok { sc.Root(); sc.WorkPath(id) }
store.root(); store.work_path(job_id) # FileStore implements the Scratch protocol
if (auto* l = dynamic_cast<LocalStore*>(&store)) { l->root(); l->work_path(id); }
work/<id> is a name, not a shape: derived from the id so a successor finds what a predecessor left, and used by nothing else. Reserved(owner, path) (reserved in Python, abstraction::job::reserved in C++) says whether a path names the store's own layout — a sink of jobs/<id>.json would overwrite a record.
Watching
A live view of one kind's jobs: a snapshot first, then every visible change, and quiet once nothing has changed for a budget. A change is identity, state, progress.done, progress.total, the lease owner or the error. A lease renewal is not a change — a view that redraws on every heartbeat flickers.
sub := job.Watch(store, "download") // Records(), Next(ctx), Changes() <-chan, Close()
sub = watch(store, "download", budget=0.0) # records(), next(timeout) -> Notice, close()
#include <abstraction/job/watch.h> // the same three operations
KeepAwake(store, claimed) holds the machine out of idle sleep for exactly as long as this process holds that lease and not a moment longer. Windows PowerRequestSystemRequired, macOS caffeinate -i, Linux systemd-inhibit. A queued or delegated job holds nothing. macOS and Linux are written and not run.
Download
The layer that can fetch. It adds to a job the three things a transfer needs and nothing about what the bytes are for:
| field | meaning | absent | is, elsewhere |
artifact.digest | sha256: + 64 lowercase hex | unknown — not "matches anything" | an OCI descriptor |
artifact.size | bytes | 0, unknown | |
sources[] | where the bytes may be had, ordered. scheme, locator, priority (lower first), attrs (to us), headers (to the server) | nothing to try | Metalink, RFC 5854 — including its priority direction |
sink.final, sink.partial | where the bytes end up, and accumulate first. A relative path is under the store root on whichever machine picks the job up; an absolute one is honoured only by the machine whose convention it is written in | the layer invents the partial | BITS LocalName, curl -o |
a, _ := abstraction.Discover() // reads the machine; no path, no flag in the caller
h, _ := a.Download().Get(url, dest) // returns at once; the transfer outlives the caller
rec, err := h.Wait(ctx) // blocks until the bytes are here, then takes delivery
// a caller that knows more
h, _ := c.Submit(download.Spec{
Artifact: download.Artifact{Digest: "sha256:74a4da…", Size: 491400032},
Sources: []download.Source{
{Scheme: "https", Locator: "https://huggingface.co/…/model.gguf"},
{Scheme: "smb", Locator: `\\nas\models\model.gguf`, Priority: 1},
},
Sink: download.Sink{Final: "models/model.gguf"},
}, "survives_process_exit")
h, out, _ := c.ResumeOrGet(url, dest) // out.Decision: submitted · resumed · delivered · busy · paused
sub := c.Jobs() // every download on this machine, live; bind a UI to it
c.Where() // "nas", "bits", "here" — display text, never a branch
download.Client: Get, Submit, ResumeOrSubmit, ResumeOrGet, Open, Jobs, Where, Deliver, TakeDelivery. Handle is a job.Job plus Destination(), Wait(ctx), TakeDelivery().
svc = abstraction_download.discover() # reads where this machine keeps its jobs
svc.deliver(svc.get(url, "models/x.gguf")) # ids, not handles; deliver waits and takes delivery
job_id = svc.submit(Spec(
artifact=Artifact(digest="sha256:74a4da…", size=491400032),
sources=[Source(scheme="https", locator="https://huggingface.co/…/model.gguf")],
sink=Sink(final="models/model.gguf"),
), requires=["survives_process_exit"])
svc.jobs() # a snapshot, not a live collection
svc.where() # "here", or the supervisor's tier
Client: get, submit, open, jobs, where, take_delivery, deliver.
using namespace abstraction::download;
Spec spec;
spec.artifact = {"sha256:74a4da…", 491400032};
spec.sources.push_back({"https", "https://huggingface.co/…/model.gguf"});
spec.sink.final_path = "models/model.gguf";
const std::string id = submit(store, spec); // job::Invalid when the spec is refused
Runner runner(store, "my-app");
runner.run(id); // this process does the transfer
switch (abstraction::discovery::ask(store_root, "abstraction.downloads")) {
case Answer::Present: break; // a supervisor would take it
case Answer::Incompatible: break; // a NEWER supervisor: do not
case Answer::Absent: break; // the ordinary case, not an error
}
Where the three differ. Go has a facade (abstraction.Discover()), a Client, handles and a live collection. Python has a Client with ids in place of handles and a snapshot in place of a collection, because the Python job layer has neither. C++ has no Client: submit and Runner::run, and a discovery ask that answers with three words and hands nothing over. Only Go can delegate. And the two-string convenience differs in meaning: Go's Get absolutises the destination — a shell path on this machine — while Submit carries the sink as written; Python's get also absolutises. The demo measured the same string rel.bin landing in the working directory under one provider and in the store root under another.
Capabilities
requires is a placement constraint (Kubernetes nodeSelector, Nomad constraint). A job that asks for a property is not quietly served by something without it.
| capability | promise | how the claim is established |
verifies_content | the delivered bytes match the digest | checked: the core hashes again, so a false claim costs nothing |
resume | continues from a proven prefix | falsifiable: one observation refutes it, and a refuted claim is disbelieved |
survives_process_exit | the transfer continues when the caller is gone | recovered: a false claim costs work that is taken back and redone |
delegates | hands the work to another system | trusted: nothing here refutes a false claim. Published because a promise nobody checks is one an adopter is entitled to see named |
Known and unfixed: the check runs against the
fetcher, not the arrangement.
-require survives_process_exit is refused by all three demo providers, including the supervised one that does survive the caller exiting — the row
a guarantee the provider cannot give on
Evidence.
Command line
| tool | does | languages |
jobctl submit|claim|progress|finish|show|cancel|intent|recall|orphans | drives a store from a shell. It resolves a store root rather than asking to be told, with JOB_STORE as its own override above ABSTRACTION_STORE. Passes the spec through untouched | Go, Python, C++ — the same verbs, so a script can drive any |
dl <url> · dl list · dl watch | fetches one URL, resumes from the last byte it can prove, verifies. Ctrl-C it and run it again | Go |
jobd once|run|status|install | the supervisor: reconciles delegated jobs, verifies and delivers finished ones, adopts orphans. Moves no bytes. install prints the schtasks lines and does not run them | Go |
wanted/ | a text file in the store is a request; the folder answers by renaming it .accepted, .done, .failed, .refused. Only http and https; no credential names | served by jobd |
specread, replay | the conformance instruments: print the meaning each implementation arrived at, and run a scenario against its own store, so transcripts compare byte for byte | Go, Python, C++ |
Undecided
Behaviour none of the sources settles, with what the three implementations do today. An adopter meets these as surprises; they are listed so the surprise is here instead.
- Backward state moves. All three let a holder write
transferred → pending. No scenario asserts it lands; no refusal class exists for it.
- Same-owner re-claim while live. All three bump the epoch. It let one process race itself and report a delivered download as a failed install.
- Verdict class for an empty owner. Go: a bare error. Python, C++:
JobError. Neither is in the verdict list.
- Whose clock. Each store reads its own. Two hosts on one share may assume nothing.
- Delegated orphans. The store offers them; the download kind declines them.
- Intent on a delegated record. Who honours cancel and pause once the holder released to a delegate.
- Request identity on the write path. An update's lost answer cannot be asked about.
- Possibly delegated.
external_id == id is Go's encoding of "unsettled"; a Python or C++ reader sees a settled delegation it can never settle.
- A repeated recall may leave
until past expires_at.
unknown in the job layer. No sentinel, no verdict class, no transcript shape.
- What
refused names now that unknown-model exists.
- Partial enumeration across languages — Go names the unreadable ids; Python and C++ skip.
- Release after a final write has no distinguished answer.
- Update, intent and release on
transferred. All three answer ok; no page decides.
- Where backoff lives. In the download kind, on the epoch. Whether the job layer owns the formula or only the counter.
- The socket transport's decoder, and whether a connection declares what it enforces.
- Renew on a never-held record (all three:
lease-expired) and on a live lease (all three: ok).
The full list with what would settle each: abstraction-job/SPEC.md § 11.
Where each rule lives
Four places, each rule in exactly one:
| a rule about | is normative in | today |
| fields, types, identifiers, structural constraints | a machine-readable schema | abstraction-job/job.thrift, which the Schema page and the Go record type are generated from; the table on abstraction-job/README.md |
| operations, state transitions, cancellation, ownership, retries | a behavioural specification | abstraction-job/SPEC.md, drawn from the tagged rules on the READMEs |
| framing, discovery, authentication, reconnection | a transport binding | the file layout on abstraction-job/README.md; a socket that ships to nobody |
| concrete examples and regressions | a versioned conformance corpus | abstraction-download/testdata/scenarios/, three transcripts per scenario compared byte for byte |
Every rule on a contract page ends with a tag — [JOB-L1], [DL-E2] — and a scenario cites the tag it tests. A rule nothing cites is printed and counted UNEXERCISED; the tags minted in abstraction-job/SPEC.md § 13 are counted so until the harness reads that page. The count is an instrument's number and moves on every run, so it is not quoted here.