Repository navigation
service-template-896: jobs.fire and job_fired as a core contract with an optional provider - #74
Merged
Merged
Conversation
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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Card:
service-template-896— jobs.fire and job_fired as a core contract with an optional providerBranch:
pipeline/service-template-896→mainWhat the card asks for
Goal
The core contract of every generated product carries typed, versioned
jobs.fire/job_firedoperations, 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_refto this card's merged candidate and having central QA fire a namedbehaviour through the released capability — is the next card in
codegen_orchestratorand isdeliberately not part of this one.
Context
Repository:
/home/dev/projects/service-template,mainatf0b27617./home/dev/projects/codegen_orchestrator(main3da1f1a4) is read-only context: nothing init changes here, and
scripts/system_configs.yamlis 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.fireis one of the five core contracts, versioned with the core; thescheduler is the first optional core-module, declaring
provides; and the deciding argument is thatQA 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 itfirst, and mirror it rather than inventing a second shape:
template/services/backend/spec/settings.yaml— a domain spec with typed operations; thegenerator turns it into schemas, protocols, a controller protocol and a router.
template/services/backend/src/controllers/settings.py—CORE_SETTINGS_CONTRACT_VERSION = 1onevery 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— thefail-closed,
extra="forbid"declaration a service makes about itself. The template shipsproperties: {}: the product declares its own keys, and nothing may be written that is notdeclared.
template/services/backend/src/app/settings_capability.py— one exact header(
X-Settings-Capability) checked withcompare_digeston exactly the privileged paths, absentfrom the generated schemas and the OpenAPI document; the read path is unprotected.
template/services/backend/env.contract.yaml.jinja—SETTINGS_WRITE_CAPABILITYandUSERS_GRANT_CAPABILITYassource: generated_secret, resolved and persisted only by thedeployment 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 themake check-syncrule that keepsframework/andtemplate/.framework/identical.Acceptance criteria
jobsdomain declares typed, versioned operations: firing a namedbehaviour, and reading back what a fired command did. Every generated schema carries its
contract_versionthe way the settings schemas do.job_firedis what the core emits when afire is accepted, so a provider does the work without the caller naming a module, a queue, a
container or a transport.
the same fail-closed service manifest that declares settings, extended additively so an existing
version: 1manifest with no jobs stays valid andextra="forbid"still holds. An undeclaredname 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
8bef22fonpipeline/service-template-896(basemainf0b2761). Working tree clean;git status --porcelainprints nothing. TASK.md is excluded via.git/info/excludeand is notcommitted.
What was built
The core contract of every generated product now carries typed, versioned
jobs.fire/job_fired, mirroring the settings core released atf0b2761rather than inventing a secondshape.
1. The contract. New domain spec
template/services/backend/spec/jobs.yamldeclares twooperations:
POST /jobs/fire(JobFire→JobCommand) andPOST /jobs/evidence(
JobCommandRef→JobCommand). Four new generated schemas —JobFire,JobCommandRef,JobCommandand the event messageJobFired— each carrycontract_version: 1exactly as thesettings schemas do.
job_firedis a newpublish: trueevent inshared/spec/events.yaml; thecore 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::ServiceManifestgainsjobs_schemaand
provides, both additive with defaults: aversion: 1manifest with neither stays valid, andextra="forbid"still holds (a strayjobs:key is refused).jobs_schemareuses the samefail-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 betype: objectwithadditionalProperties: false. The loader rejects a job name declared by twomanifests, in the same shape as duplicate settings keys. A new
JobsManifestGeneratoremitsservices/backend/src/generated/jobs_schemas.py(JOB_SCHEMAS,JOB_SCHEMA_SOURCES,JOB_CAPABILITY_PROVIDERS). An undeclared name is refused with404 Job name not declared— themirror of
404 Setting key not declared— and arguments failing their schema with422. Thetemplate ships
jobs_schema.properties: {}andprovides: [], exactly as it shipssettings_schema.properties: {}.3. Identity, provenance, bounded replay.
JobFirecarries a caller-suppliedcommand_idandfired_by_product/fired_by_runprovenance. The newjob_commandstable (migration9c2e5a71d4f3) hasUniqueConstraint("fired_by_product", "command_id"); identity is that tuple,the way a setting's identity is
(key, scope, subject_id).dispatch_statusis terminal atdispatchedoncejob_firedwas emitted (withdispatched_at): any later fire of the sameidentity 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
undeliveredand a retry re-attempts delivery withoutrecording a second command, so at most one successful dispatch exists per identity however many
times a caller retries.
POST /jobs/evidencereads that evidence back and is scoped to the productthat fired the command.
4. The capability.
X-Jobs-Capabilityon exactlyPOST /jobs/fire, checked withcompare_digestinsrc/app/jobs_capability.py(a direct mirror ofsettings_capability.py:missing, duplicated, empty, non-ASCII and wrong values all 403 before any mutation). It is backed by
JOBS_FIRE_CAPABILITYassource: generated_secretinenv.contract.yaml.jinja, absent from thegenerated schemas and from
docs/openapi.json, never logged, and absent from thejob_firedpayload, error bodies and URLs.
POST /jobs/evidencecarries no capability.5. The scheduler provides; the core does not schedule. The core starts no timer and runs no
loop:
firevalidates, records and emits.providesreuses the existing service/manifestdeclaration mechanism — the scheduler declares
provides: ["jobs.fire"]and subscribes tojob_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_requestworkflow runs. Title and body are built from the card and the worker's done report.