↑ ↓ to move · Enter to open · Esc to closeTap a result

Architecture
skill · class design · opus

jobs-architect

Designs the scheduled and background jobs: technology, coordination across instances and execution guarantees.

skill · designopuswrites 35-jobs.mdonly inside docs/use-cases/UC-*/**
§ the file it writes

35-jobs.md, block by block

A real partial: UC-002-prune-expired-idempotency-keys, in bank-app, excerpted. Hover or click a block to see what the skill fixes there. The sections of the file are also chips: click one to jump to it.

bank-app/docs/use-cases/UC-002-prune-expired-idempotency-keys/35-jobs.md

UC-002 · Jobs

Partial of docs/use-cases/UC-002-prune-expired-idempotency-keys/. Owner: jobs-architect. Contains no code. Form references in .claude/skills/jobs-architect/templates/. Rules applied, never reproduced: @.claude/rules/scheduling.md.
FieldValue
Mother spec00-caso-de-uso.md
Why this partial exists00-caso-de-uso.md § Trigger is a schedule — every 1 hour
Scheduling packageinfrastructure.scheduling (blueprint packages.map) — subpackage idempotency, as 00-caso-de-uso.md § Components named it
Divergences from other partialsnone

Survey: pom.xml (Boot parent 4.1.1) carries no scheduler and no lock library; src/main has no @Scheduled, no @EnableScheduling; no earlier 35-jobs.md; UC-001 20-persistencia.md records no Claim strategy. First job of the project — technology and wiring are NEW here and reused by every later job. No Form B outbox in the project, so no relay and no outbox prune.

No question asked. Each interview axis was settled by the specs: replicas — more than one (00-caso-de-uso.md § Concurrency, the user's requirement); kind — polling pass (hourly, not calendar-anchored); missed run — the next pass is the catch-up; volume — bounded batches, a rerun from zero costs nothing; retry — the next pass; operator control — not requested.

## 1 · Jobs

JobKindCallsTriggerCadence propertyOn/off propertyCoordinationMissed runPer pass
PruneExpiredIdempotencyKeysJobpolling passPruneExpiredIdempotencyKeysUseCase.prune(PruneExpiredIdempotencyKeysCommand)fixedDelay, with initialDelayapp.jobs.prune-idempotency-keys.interval: PT1H, app.jobs.prune-idempotency-keys.initial-delay: PT5M — read only by the @Scheduled placeholdersapp.jobs.prune-idempotency-keys.enabled — default true, false in the test profileevery instance runs the pass; the delete tolerates the overlap (§ 3)skipped — the next pass deletes every row expired by thenbatches of batch-size rows, up to max-batches batches; a failing batch ends the pass, earlier batches stay committed

fixedDelay, not fixedRate and not cron: the work is not calendar-anchored ("every 1 hour", not "at minute 0"), and fixed delay never overlaps itself on one instance (@.claude/rules/scheduling.md § Triggers). Replicas started at different moments stagger their passes naturally; a cron would fire every replica at the same instant against the same rows. No zone — no cron.

initialDelay PT5M: without it every replica prunes at boot, so a rolling deploy runs the pass once per pod within seconds. Harmless (§ 3), but pointless; five minutes keeps it out of startup.

The trigger builds PruneExpiredIdempotencyKeysCommand(batchSize, maxBatches) from its properties record (§ 8), calls the use case through JobRunRecorder, and logs the returned count — INFO when greater than zero, DEBUG when zero (@.claude/rules/scheduling.md § Observability). No other dependency on business code. Shape: templates/ScheduledJob.java.example.

## 2 · Technology

DecisionValueWhy
Scheduler@Scheduled (Spring Framework)One polling pass; no runtime-created schedule, no catch-up need, no per-execution retry, no operator UI. references/tool-decision-matrix.md § 2 row "many instances, work partitioned by row claim": @Scheduled ✅, every other tool overkill or redundant, and it carries zero infrastructure
Coordination toolnone — the delete partitions the work (§ 3)No lock mechanism exists in the project; references/tool-decision-matrix.md § 3 locks an idempotent prune only "when a lock mechanism already exists". The user asked for "safe on more than one replica", not "runs once"
Inherited?no — first job of this projectno scheduler in pom.xml, no earlier 35-jobs.md

Rejected, one line each: ShedLock — one table and two dependencies to stop an idempotent hourly delete from running on a second replica, when the delete itself can skip what another replica holds; adopt it when a job that must run once arrives. Quartz — eleven tables for one static job. db-scheduler — a persistent scheduler for a pass with no catch-up need. Spring Batch — one bounded loop, nothing to restart from. JobRunr — no operator-UI requirement; LGPL not asked. Kubernetes CronJob — no deployment platform recorded, and the job would need a CLI entry the use case does not have.

## 3 · Coordination

FactValueSource
Replicas in productionmore than one — exact count not needed: the mechanism below is correct for any N00-caso-de-uso.md § Concurrency (user's requirement); no earlier 35-jobs.md or Claim strategy
JobMechanismBounds
PruneExpiredIdempotencyKeysJobOverlap-tolerant delete, no lock around the trigger. Every replica runs the pass; each batch deletes only rows no concurrent transaction holds, so two replicas at the same instant split the rows instead of waiting on or failing each other. Required from persistence in § 6none — no lock to bound

Why this meets 10-dominio.md § 3's port contract: two callers never fail each other (no row lock wait, no deadlock — a held row is skipped, not waited on) and never delete a row with expires_at at or after the cutoff (the condition is in every statement). A row one replica skips is either deleted by the replica holding it, or by the next pass.

Cost of N replicas: N short passes per hour, each finding little after the first. Accepted.

## 4 · Execution guarantees

GuaranteeValue
Body idempotentYes — a deleted row cannot be deleted again; a rerun deletes only what expired since
Per-pass boundbatch-size rows per statement (value: 20-persistencia.md), at most max-batches statements per pass. The use case stops early when a statement deletes fewer than batch-size
One failing itemNo per-item processing — the unit is a batch. A failing statement ends the pass; its exception escapes to the scheduler's error handler through JobRunRecorder (recorded as outcome=failure); committed batches stay deleted; the next pass retries
Missed runSkipped. The window is expires_at, persisted state — never "now minus the interval" — so the next pass catches up by construction
Pass longer than the intervalImpossible to overlap on one instance (fixedDelay); bounded by max-batches × statement time — at the § 8 values, far under one hour

## 5 · Observability

MetricJobAlarm it exists for
jobs.execution (timer, tags job=prune-idempotency-keys, outcome)PruneExpiredIdempotencyKeysJoboutcome=failure count above zero for 3 consecutive hours
jobs.last_success.seconds (gauge, tag job=prune-idempotency-keys)PruneExpiredIdempotencyKeysJobolder than 3 h — three missed or failed passes; the table is growing unpruned

The deleted count is in the INFO log line, not a metric: nothing alarms on it, and a counter per pass adds a series nobody reads (@.claude/rules/observability.md § Metrics). Scheduled runs are observed through SchedulingConfig's registrar hook, so each pass carries a trace.

The timer and the gauge come from JobRunRecorder, NEW in this case and shared by every later job (templates/ScheduledJob.java.example).

## 6 · Schema requirements

Read by persistence-architect in its first pass. Requirements, never DDL.

RequirementWhyAddressee
IdempotencyKeyPort.deleteExpired(cutoff, limit) — bounded delete of idempotency_keys rows with expires_at strictly before cutoff, at most limit rows per call, own transaction, skipping rows a concurrent transaction holds instead of waiting on them§ 3: more than one replica runs the pass with no lock; the statement is the coordinationpersistence-architect — the statement, the batch bound's value, and whether ix_idempotency_keys_expires_at serves it are its own
No scheduling tool table§ 2 — @Scheduled alone—

## 7 · Declared dependencies

none — @Scheduled is in spring-context, already on the classpath through spring-boot-starter. Micrometer (MeterRegistry, ObservationRegistry) is already present through the tracing bridge UC-001 declared.

## 8 · Configuration

Properties bound in PruneIdempotencyKeysJobProperties (record, @ConfigurationProperties app.jobs.prune-idempotency-keys, validated at startup: both counts ≥ 1) — except interval and initial-delay, read only by the @Scheduled placeholders and never also bound (@.claude/rules/scheduling.md § Triggers).

PropertyValueOwner of the value
app.jobs.prune-idempotency-keys.enabledtrue; false in src/test/resources/application-test.ymlthis partial
app.jobs.prune-idempotency-keys.intervalPT1Hthis partial — 00-caso-de-uso.md § Trigger
app.jobs.prune-idempotency-keys.initial-delayPT5Mthis partial
app.jobs.prune-idempotency-keys.batch-size—20-persistencia.md — what one statement can hold
app.jobs.prune-idempotency-keys.max-batches100this partial — one pass stays well inside the hour; a backlog larger than batch-size × 100 drains over successive passes
spring.task.scheduling.pool.size1 — written explicitlythis partial — one job in the project; the next job raises it (@.claude/rules/scheduling.md § Triggers: pool size is a decision)
spring.task.scheduling.thread-name-prefixjob-this partial
spring.task.scheduling.shutdown.await-termination / await-termination-periodtrue / PT30Sthis partial — a pass in flight at shutdown finishes its batch

src/test/resources/application-test.yml does not exist yet (the test profile is active in BankAppApplicationTests, ForwardedHeadersIT, CreateCustomerIdempotencyIT): NEW, holding the one enabled: false line.

Full shape in templates/application-jobs.yml.example.

## 9 · Deferred

none

## Design patterns

none — one job, one trigger calling one inbound port; no shared claim-process-mark skeleton across jobs, no symptom on disk (no job exists yet). JobRunRecorder is the exemplar's shared helper, not a catalog pattern.

## Impact on approved use cases

none — UC-001 has no job; the port change is 10-dominio.md's row.

## Implementation order

  1. SchedulingConfig (@EnableScheduling + observation registry) and JobRunRecorder in
  2. PruneIdempotencyKeysJobProperties and PruneExpiredIdempotencyKeysJob in
  3. Properties in application.yml; src/test/resources/application-test.yml with
  4. Tests — 40-testes.md
Open the full file on GitHub →
§ behavior

What it reads, writes and refuses

The skill's own contract, from SKILL.md, in the original wording.

Class

design — the territory is skill_classes.design in @.claude/schemas/extensions.json, and ArchHook.java guard enforces it. Writes inside the use case folder and nothing else.

Reads

docs/use-cases/UC-NNN-<slug>/00-use-case.md (mandatory), 10-domain.md (when the trigger is a schedule), 25-messaging.md (when present), every partial's Deferred block, earlier cases' 35-jobs.md and 20-persistence.md § 1, pom.xml, the active blueprint's packages.map, @.claude/rules/scheduling.md, @.claude/rules/architecture-ddd.md, @.claude/rules/observability.md, and references/tool-decision-matrix.md.

Writes

docs/use-cases/UC-NNN-<slug>/35-jobs.md. Nothing else.

Owns

the scheduling technology of the project, each job's trigger and cadence — the relay's poll interval included — coordination across instances and the replica count it rests on, overlap and missed-run policy, on/off properties, job metrics, and the outbox prune job.

Does not decide

the relay's form or its broker side (25-messaging.md), any table, column, claim query, retention window or statement (20-persistence.md), the use case boundary (00-use-case.md), or the tests (40-tests.md). Does not write Java, pom.xml, or application.yml — the executor does, from this partial. Doesn't touch .claude/rules/**.

Source: SKILL.md § Contract, at nerviz e4cae27.

§ guard and hook

What the guard does with it

design is the skill's class. ArchHook opens a phase when the skill is called and checks every write against the class's territory. The text on the right is the hook's own.

write_allow of class design

  • ✓docs/use-cases/UC-*/**
  • ✓docs/use-cases/BACKLOG.md
  • src/**: migrations and classes are the executor's
  • docker-compose.yml: another class's file
  • a use case already approved: its specs are frozen

A refusal exits with code 2 and the message goes back to the model.

PreToolUse  Skill(jobs-architect)
  audit call                       exit 0
  guard call                       exit 0
# a skill of another class replaces the phase
# write_allow: docs/use-cases/UC-*/**, docs/use-cases/BACKLOG.md
§ orchestration

Where it sits on the rail

Each card is a step of /new-feature; the arrow carries the partial one step hands to the next. Dashed steps run only when the use case needs them.

/new-feature-implement Who reads the partial next

After approval, /new-feature-implement has the executor build the code. The spec is consolidated by reference and wins where it and a partial disagree; the partial is read, by section, for detail.

35-jobs.mddocs/use-cases/UC-NNN-…/
read by section, for detail
java-spring-boot-developeragent · executor
writes the code
src/**designs one use case into a spec, writes no code
← back to the chains