@shardworks/reckoner-apparatus
v0.1.295
Published
The Reckoner — petitioner registry and petition() helper for Reckoner-gated writs
Readme
@shardworks/reckoner-apparatus
The Reckoner stands up the petition-scheduler apparatus: the
kit-static registry of petitioner sources, the canonical
petition() / withdraw() helpers (Workflow 2 in the contract document),
the inspection helpers downstream consumers use to read the registry
and live config, and a periodic tick relay that drives held petitions
out of new and writes one row per consideration to its
reckoner/reckonings evaluation journal.
What ships here:
- A new
petitionerskit-contribution type. Kits declare petitioners by contributing an array of{ source, description }entries; the Reckoner consumes the array at boot, validates the source-id grammar, and seals the registry atphase:started. - A new
schedulerskit-contribution type. Kits (and the Reckoner's ownsupportKit) declare scheduler instances by contributing an array ofSchedulerobjects under that key. The Reckoner consumes the array at boot, validates the id grammar, seals the registry atphase:started, and resolves a single active scheduler fromguild.json reckoner.scheduler. The default scheduler isreckoner.always-approve(shipped from the Reckoner's ownsupportKit.schedulers); plugins land additional policies by contributing their own scheduler under their own{pluginId}.…id. - The
Prioritytype (five dimensions:visionRelation,severity,scope,time,domain), theComplexityTierenum, and theReckonerExtshape — all faithful to the contract document atdocs/architecture/petitioner-registration.md. - The
petition()helper in two forms:- Create + stamp (
petition(request)) — posts a writ in its initial phase viaclerk.post(), then stampswrit.ext['reckoner']viaclerk.setWritExt(). Two-step and non-atomic by design (see D7). - Stamp-only (
petition(writId, extRequest)) — the draft-then- publish idiom. Stampsext.reckoneronto an already-posted writ that is still in its writ-type's initial phase, making the writ Reckoner-visible. Lets a petitioner create the writ, wire dependencies viaclerk.link(), and then publish it as a single transactional unit when wrapped instacks.transaction(...). Both forms run through the same source / priority validation path.
- Create + stamp (
- The
withdraw()helper — a thin pass-through toclerk.transition(writId, 'cancelled', { resolution: reason }). - Inspection helpers —
isSourceRegistered,isSourceDisabled,listPetitioners— typed underReckonerApiand reachable throughguild().apparatus<ReckonerApi>('reckoner'). - A
reckonerblock inguild.jsonwithenforceRegistrationanddisabledSources; both fields are optional and re-read on every consumer call so operators can hot-edit without restarting the guild. - A periodic tick relay (
reckoner.tick) plus a kit-contributed standing order at@every 60sthat drives the relay. Every fire sweeps the held-petition set in one batch, applies source / disabled-source gates, runs the dependency-aware consideration gate, dedupes against the persisted(writId, writUpdatedAt)lookup, calls the active scheduler'sevaluateonce with the full surviving candidate set, and applies each emitted decision (approve→ transition to active target;decline→ transition tocancelledwith the decision's reason recorded as the resolution string + a Reckonings row carryingdeclineReason: 'other';defer→ no transition + a row carryingdeferReason: 'other'and the decision's reason indeferNote). The other decline path is the source-unregistered +enforceRegistration: truerule (row carriesdeclineReason: 'source_unregistered'); the disabled-source path produces a row withdeclineReason: 'source_banned'. The@every 60scadence is hard-coded — there is no operator knob in this commission, and no way to disable the standing order short of removing the apparatus. The tick is the single evaluation entry — there is no CDC handler, no startup catch-up scan, and no per-writ-update path. Pre-existing held petitions are picked up on the first tick afterphase:started. - A dependency-aware defer rule, slotted into the tick sequence
between source-registration enforcement and the scheduler call. The
rule walks each held petition's outbound
depends-onlinks (filtered bylink.kind === 'depends-on'), resolves each target via the writs book, and classifies the target via its writ-type config attrs: a target is cleared iff its current state is terminal AND its attrs includesuccessorcancelled; a terminal-but-not- cleared target is failed; any non-terminal target is gating. A dangling target is treated as gating. Failed-precedence aggregation produces one of three outcomes —proceed(all targets cleared, or nodepends-onlinks exist),defer-pending(≥1 gating, none failed), ordefer-failed(≥1 failed). Defer outcomes leave the writ innewphase and emit adeferredReckonings row carryingdeferReason: 'dependency_pending'or'dependency_failed'anddeferNotelisting the gating / failed dep writ ids. The dep gate re-evaluates on every Reckoner tick (the polling cadence is the v0 wake-up mechanism); the row write is suppressed when the outcome shape is identical to the writ's most recent deferred row, so a long-deferred dependent does not produce a heartbeat duplicate per tick. - A
ReckonerStatussnapshot atwrit.status['reckoner'], kept in sync by a Phase-2 CDC watcher on the Reckoner's ownreckoningsbook. Everycreateevent runs through the snapshot handler, which derives the writ's current decision shape, running deferral counters (deferCount,firstDeferredAt,lastDeferredAt), and a stalled flag (set in v0 only ondependency_faileddefer rows atdefer_count >= 1) and writes it back viaclerk.setWritStatus(writId, 'reckoner', next). Counters are preserved verbatim across deferred → accepted / declined transitions;'no-op'rows bumplastEvaluatedAtonly. Consumers reading the snapshot should cross-checkwrit.phaseto detect the petitioner-withdrawal lag case (a withdrawn writ still reads as deferred on the snapshot until a future Reckonings row refreshes it). Seedocs/architecture/apparatus/reckoner.md§"Staleness diagnostic" for the full surface, the v0 threshold table, and thelastEvaluatedAt-during-stable-stalled cadence note. - The
reckoner/reckoningsbook — the Reckoner's evaluation journal. One row per substantive consideration, immutable after write. The auto-wiredbook.reckoner.reckonings.{created,updated,deleted}Clockworks events fire normally (no carve-out);createdis the channel petitioners subscribe to. Seedocs/architecture/reckonings-book.mdfor the schema, index set, and CDC contract.
The Reckoner requires clerk. Stacks is reached transitively through
Clerk. Clockworks is a soft recommends because the periodic tick
relay only fires when Clockworks is installed; the Reckoner's
petition() / withdraw() and registry inspection helpers continue
to work without it.
See the contract document at
docs/architecture/petitioner-registration.md for the full data shape
and the apparatus shape doc at docs/architecture/apparatus/reckoner.md.
Installation
{
"dependencies": {
"@shardworks/reckoner-apparatus": "workspace:*"
}
}Add reckoner to guild.json's plugins array.
Posting a petition (Workflow 2)
Create + stamp (one-shot)
import type { ReckonerApi } from '@shardworks/reckoner-apparatus';
import { guild } from '@shardworks/nexus-core';
const reckoner = guild().apparatus<ReckonerApi>('reckoner');
const writ = await reckoner.petition({
source: 'tech-debt.detected',
title: 'Address vision drift detected at 04:00 UTC',
body: '...',
codex: 'nexus',
priority: {
visionRelation: 'vision-violator',
severity: 'serious',
scope: 'major-area',
time: { decay: true, deadline: null },
domain: ['quality'],
},
complexity: 'bounded',
payload: { /* opaque petitioner-defined data */ },
});
// writ is in `new` phase with `writ.ext.reckoner` populated.Omitted priority dimensions fall back to the contract defaults at the helper boundary (see §3 of the contract document).
Draft-then-publish (stamp-only)
When the petitioner needs to wire links or other dependencies onto a
writ before it becomes Reckoner-visible, use the stamp-only form.
Post the writ first, perform any setup, then call
petition(writId, extRequest) to stamp ext.reckoner and publish:
import type { StacksApi } from '@shardworks/stacks-apparatus';
import type { ClerkApi } from '@shardworks/clerk-apparatus';
import type { ReckonerApi } from '@shardworks/reckoner-apparatus';
import { guild } from '@shardworks/nexus-core';
const clerk = guild().apparatus<ClerkApi>('clerk');
const stacks = guild().apparatus<StacksApi>('stacks');
const reckoner = guild().apparatus<ReckonerApi>('reckoner');
// Post the draft writ first — it is in its initial phase but not yet
// Reckoner-visible (no ext.reckoner).
const draft = await clerk.post({
title: 'Address vision drift',
body: '...',
});
// Wire dependencies and stamp ext.reckoner inside a single
// transaction so the writ becomes Reckoner-visible only after the
// outer commit. The Reckoner CDC handler observes the post-commit
// update event and runs its rule sequence.
await stacks.transaction(async () => {
await clerk.link(draft.id, blockerId, 'depends-on');
await reckoner.petition(draft.id, {
source: 'tech-debt.detected',
priority: { visionRelation: 'vision-violator' },
});
});The stamp-only form fails loud (with no writ mutation) when the writ
is past its initial phase, when ext.reckoner is already present, or
when the writ id does not exist. Source registration and priority
defaults / dimension validation are identical to the create+stamp
form — a single canonical validation path runs for both.
Declaring a petitioner
A kit (or apparatus's supportKit) declares its source(s) under the
petitioners key:
export default {
kit: {
requires: ['reckoner'],
petitioners: [
{
source: 'tech-debt.detected',
description:
'Worked-example petitioner emitting tech-debt findings worth surfacing as held writs.',
},
],
},
};The source id must match {contributingPluginId}.{kebab-suffix};
malformed entries hard-fail at startup. Two kits contributing the same
source is also a hard startup error (mirrors Clerk link-kinds, Spider
rigTemplateMappings, and Fabricator engine-design collision rules).
Configuration
The Reckoner reads its configuration from guild.json under the
reckoner key. Every field is optional:
{
"reckoner": {
"enforceRegistration": false,
"disabledSources": [],
"scheduler": "reckoner.always-approve",
"schedulerConfig": {}
}
}enforceRegistration(boolean, defaultfalse) — whentrue,petition()throws fail-loud for an unregistered source and does not post the writ.disabledSources(string array, default[]) — sources operators want to skip. The list is re-read on every call; operators can hot-editguild.jsonwithout restarting the guild.scheduler(string, optional) — selects the active scheduler from the kit-static scheduler registry. Defaults toreckoner.always-approvewhen unset; setting it to an unregistered id throws fail-loud at startup with a diagnostic listing every registered id. Resolution happens once atphase:started.schedulerConfig(any, optional) — opaque config passed to the active scheduler'sevaluatecall. Re-read fromguild.jsonon every consideration so operators can hot-edit; each scheduler narrows the value through its ownvalidateConfig.
Declaring a scheduler
A kit (or apparatus's supportKit) declares one or more scheduler
instances under the schedulers key:
import type { Scheduler } from '@shardworks/reckoner-apparatus';
const myScheduler: Scheduler = {
id: 'my-plugin.priority-walk',
description: 'Selects highest-weight petition first.',
async evaluate(input) {
// input.candidates: the held petitions for this consideration.
// input.config: the validated config from guild.json.
return [{ writId: input.candidates[0]!.id, outcome: 'approve', reason: 'top-of-queue' }];
},
validateConfig(raw) {
// optional; throw on shape mismatch.
return raw;
},
};
export default {
kit: {
requires: ['reckoner'],
schedulers: [myScheduler],
},
};The id grammar matches the petitioner-source grammar:
{contributingPluginId}.{kebab-suffix}. Duplicate ids across two
kits, malformed grammar, missing evaluate, and post-seal
registration all hard-fail at startup. Schedulers reach for shared
guild state (Stacks book handles, Clerk helpers) via guild() rather
than constructor injection.
Withdrawing a petition
await reckoner.withdraw(writId, 'Snapshot superseded by drift detected before this ran.');Equivalent to clerk.transition(writId, 'cancelled', { resolution: reason }).
No source check, no owner check. Reason is passed through verbatim — when
omitted, no resolution is fabricated.
