Skip to content

service-template-896: jobs.fire and job_fired as a core contract with an optional provider - #74

Merged
vladmesh merged 1 commit into
mainfrom
pipeline/service-template-896
Sep 2, 2026
Merged

vladmesh merged 1 commit into
mainfrom
pipeline/service-template-896

Conversation

@vladmesh

@vladmesh vladmesh commented Sep 2, 2026

Copy link
Copy Markdown
Owner

Card: service-template-896 — jobs.fire and job_fired as a core contract with an optional provider
Branch: pipeline/service-template-896 → main

What the card asks for

Goal

The core contract of every generated product carries typed, versioned jobs.fire / job_fired
operations, so someone outside the product — central QA — can invoke a named scheduled behaviour
without knowing which module implements it or over what transport. The scheduler becomes an
optional provider of that capability rather than something the core runs: a product with no
scheduled behaviour pays neither a container nor memory for one.

This is the last product-side link of the sprint's chain. Its consumer half — moving
scheduler.service_template_ref to this card's merged candidate and having central QA fire a named
behaviour through the released capability — is the next card in codegen_orchestrator and is
deliberately not part of this one.

Context

Repository: /home/dev/projects/service-template, main at f0b27617.
/home/dev/projects/codegen_orchestrator (main 3da1f1a4) is read-only context: nothing in
it changes here, and scripts/system_configs.yaml is not touched by this card.

The architecture this implements is state/knowledge/projects/codegen-orchestrator/decisions/2026-08-28-module-composition-protocol.md,
section 6: job_fired / jobs.fire is one of the five core contracts, versioned with the core; the
scheduler is the first optional core-module, declaring provides; and the deciding argument is that
QA must be able to fire any deferred behaviour without knowing the module ("the Friday digest was
never checked" is a class of failure, not an incident).

The pattern to follow is the settings core released in this repository at f0b27617 — read it
first, and mirror it rather than inventing a second shape:

  • template/services/backend/spec/settings.yaml — a domain spec with typed operations; the
    generator turns it into schemas, protocols, a controller protocol and a router.
  • template/services/backend/src/controllers/settings.py — CORE_SETTINGS_CONTRACT_VERSION = 1 on
    every answer; an undeclared key is 404 "Setting key not declared", a value failing its declared
    schema is 422; identity is a tuple, and storage uniqueness on it makes a repeated write
    idempotent.
  • template/services/backend/manifest.yaml + framework/spec/manifests.py::ServiceManifest — the
    fail-closed, extra="forbid" declaration a service makes about itself. The template ships
    properties: {}: the product declares its own keys, and nothing may be written that is not
    declared.
  • template/services/backend/src/app/settings_capability.py — one exact header
    (X-Settings-Capability) checked with compare_digest on exactly the privileged paths, absent
    from the generated schemas and the OpenAPI document; the read path is unprotected.
  • template/services/backend/env.contract.yaml.jinja — SETTINGS_WRITE_CAPABILITY and
    USERS_GRANT_CAPABILITY as source: generated_secret, resolved and persisted only by the
    deployment secret resolver.
  • docs/CONTRACTS.md "Core settings v1" — the shape of the contract documentation expected here.
  • framework/generate.py, framework/generators/, framework/spec/loader.py, and the
    make check-sync rule that keeps framework/ and template/.framework/ identical.

Acceptance criteria

  1. The contract. A core jobs domain declares typed, versioned operations: firing a named
    behaviour, and reading back what a fired command did. Every generated schema carries its
    contract_version the way the settings schemas do. job_fired is what the core emits when a
    fire is accepted, so a provider does the work without the caller naming a module, a queue, a
    container or a transport.
  2. Declared, never inferred. A behaviour is fireable only because the product declared it — in
    the same fail-closed service manifest that declares settings, extended additively so an existing
    version: 1 manifest with no jobs stays valid and extra="forbid" still holds. An undeclared
    name is refused the way an undeclared setting key is, with a me

… (truncated; the full text is on the card)

What the worker reports

service-template-896 — done

Commit 8bef22f on pipeline/service-template-896 (base main f0b2761). Working tree clean;
git status --porcelain prints nothing. TASK.md is excluded via .git/info/exclude and is not
committed.

What was built

The core contract of every generated product now carries typed, versioned jobs.fire /
job_fired, mirroring the settings core released at f0b2761 rather than inventing a second
shape.

1. The contract. New domain spec template/services/backend/spec/jobs.yaml declares two
operations: POST /jobs/fire (JobFire → JobCommand) and POST /jobs/evidence
(JobCommandRef → JobCommand). Four new generated schemas — JobFire, JobCommandRef,
JobCommand and the event message JobFired — each carry contract_version: 1 exactly as the
settings schemas do. job_fired is a new publish: true event in shared/spec/events.yaml; the
core emits it when a fire is accepted, so a provider does the work without the caller naming a
module, a queue, a container or a transport.

2. Declared, never inferred. framework/spec/manifests.py::ServiceManifest gains jobs_schema
and provides, both additive with defaults: a version: 1 manifest with neither stays valid, and
extra="forbid" still holds (a stray jobs: key is refused). jobs_schema reuses the same
fail-closed Draft 2020-12 form as settings_schema (validation extracted into one shared
_validate_declaration_schema), and additionally requires each behaviour's argument schema to be
type: object with additionalProperties: false. The loader rejects a job name declared by two
manifests, in the same shape as duplicate settings keys. A new JobsManifestGenerator emits
services/backend/src/generated/jobs_schemas.py (JOB_SCHEMAS, JOB_SCHEMA_SOURCES,
JOB_CAPABILITY_PROVIDERS). An undeclared name is refused with 404 Job name not declared — the
mirror of 404 Setting key not declared — and arguments failing their schema with 422. The
template ships jobs_schema.properties: {} and provides: [], exactly as it ships
settings_schema.properties: {}.

3. Identity, provenance, bounded replay. JobFire carries a caller-supplied command_id and
fired_by_product / fired_by_run provenance. The new job_commands table (migration
9c2e5a71d4f3) has UniqueConstraint("fired_by_product", "command_id"); identity is that tuple,
the way a setting's identity is (key, scope, subject_id). dispatch_status is terminal at
dispatched once job_fired was emitted (with dispatched_at): any later fire of the same
identity returns the recorded evidence and emits nothing. A fire that loses the insert race catches
the IntegrityError, re-reads and returns the recorded command rather than creating a second one.
A command whose emit failed is recorded as undelivered and a retry re-attempts delivery without
recording a second command, so at most one successful dispatch exists per identity however many
times a caller retries. POST /jobs/evidence reads that evidence back and is scoped to the product
that fired the command.

4. The capability. X-Jobs-Capability on exactly POST /jobs/fire, checked with
compare_digest in src/app/jobs_capability.py (a direct mirror of settings_capability.py:
missing, duplicated, empty, non-ASCII and wrong values all 403 before any mutation). It is backed by
JOBS_FIRE_CAPABILITY as source: generated_secret in env.contract.yaml.jinja, absent from the
generated schemas and from docs/openapi.json, never logged, and absent from the job_fired
payload, error bodies and URLs. POST /jobs/evidence carries no capability.

5. The scheduler provides; the core does not schedule. The core starts no timer and runs no
loop: fire validates, records and emits. provides reuses the existing service/manifest
declaration mechanism — the scheduler declares provides: ["jobs.fire"] and subscribes to
job_fired; validation is against the framework's own `CORE_CAPAB

… (truncated; the full text is on the card)


Opened by the secretary CI gate so that the pull_request workflow runs. Title and body are built from the card and the worker's done report.

Generated backends now carry a typed, versioned `jobs` core contract: a
capability-protected `POST /jobs/fire` records a caller-supplied command
identity for a behaviour the product declared and emits `job_fired`, and an
unprotected `POST /jobs/evidence` reads the recorded evidence back.

The core schedules nothing: it starts no timer and runs no loop, and it never
resolves which module executes a behaviour. A service manifest gains an
additive, fail-closed `jobs_schema` of fireable names with their argument
schemas, plus a `provides` declaration for the optional module that supplies a
core capability. Identity is `(fired_by_product, command_id)` and storage
uniqueness on it makes a replay return the recorded evidence instead of
executing again.
@vladmesh
vladmesh merged commit d4b07a5 into main Sep 2, 2026
3 checks passed
@vladmesh
vladmesh deleted the pipeline/service-template-896 branch September 3, 2026 16:41
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant