SRD-069 — Rules/Script invocation facts and the registrar audit¶
| Field | Value |
|---|---|
| Status | Accepted |
| Version | v.1 |
| Date | 2026-07-26 |
| Owner | Ruslan Gabitov |
| Implements | ADR-013 v.2 §2.6 (the open phase taxonomy — two new phases, one reused; the masking and volume rules govern every addition) |
| Upstream | ADR-027 v.1 (the rules seam whose calls and registrars this audits), ADR-031 v.1 (the script seam), ADR-029 v.1 §2.6 (the deploy surface) |
| Refines | SRD-060 v.1 (the KindRules outcome pair), SRD-064 v.1 (the KindScript outcome pair) — sideways |
Closes two observability gaps found reviewing the Business Rule / Script
task facts (the post-ADR-032 review the owner requested): (1) both kinds
are outcome-only — no invocation-phase fact, so engine-call latency
and a hung engine are invisible; (2) the commit-stage blind spot — a
successful engine call whose result commit fails faults the task with
no closing KindRules/KindScript fact at all. It also extends the
audit to the registrar surfaces (register/deploy) the engines expose
outside instance execution.
§1 Background¶
- The outcome pairs today.
KindRules:Evaluated(brule_task.go:136— decision_ref, implementation, row_count, result_variable) /Failedon an evaluation error (brule_task.go:118; Warn echo,echolevel.go:57).KindScript:Executed(script_task.go:149) /Failedon an execution error (script_task.go:132; Warn echo). Both emit only after the engine call returns. - The commit blind spot.
commitResult(brule_task.go:168) andcommitOutput(script_task.go:173) failures return straight to the fault machinery — noKindRules/KindScriptfact marks them; the decision/script audit stream goes silent mid-story. KindNodeProgress(Entered/Executing, Debug) brackets every node but cannot distinguish "inside the engine call" from anything else the node does.- The registrar surfaces are invisible.
gorules.Registry.Register(gorules.go:46),dtable.Engine.Register(engine.go:53) anddtable.Engine.Deploy(deploy.go:44— decode, then replacee.tables[t.name]under the mutex) run outside instance execution, called by user code; the adapters hold no Reporter. A runtimeDeploythat replaces a live decision emits nothing today. No remove/unregister surface exists in-repo (grep) — noRemovedphase is invented. - The binding precedent.
tasks.ReporterBinder(workerdispatcher.go:204) is the optional capability the engine binds its sink into at startup:thresher.Newdoesob.BindReporter(t.producer)(thresher.go:257-258); a dispatcher that doesn't implement it simply doesn't emit.enginert.Runtimecarries its own reporter (enginert.go:50). - The e2e hook:
Thresher.Observe(o) *Subscription(producer.go:153) subscribes an engine-wide observer;dtable(its own module,replace ../..) can importthresherin its tests, while core tests cannot import adapters — the e2e split follows the module boundary. AttrDecisionRef/AttrImplementation/AttrErrorexist (fact.go:162-167); nostage/rule_countattrs yet;PhaseRegisteredexists (fact.go:56),Invoked/Deployeddo not.
§2 Requirements¶
§2.1 Functional¶
- FR-1 — the invocation phase. New reused
PhaseInvoked(// Rules / Script): emitted immediately before the engine call —BusinessRuleTask.Execbeforeeng.Evaluate(details: decision_ref, implementation) andScriptTask.Execbeforeeng.Execute(details: script_format, implementation viaroutedKind). One extra fact per task execution — bounded and per-task, not the per-expression flood ADR-032 §2.4 forbids. Paired with the closing fact it makes engine-call latency derivable and a hung engine attributable. - FR-2 — close the pair. Every
Invokedcloses withEvaluated/ExecutedorFailed. Commit-stage failures (commitResult/commitOutput) now also emitFailed, distinguished by the newAttrStage = "stage"detail:engineon an engine failure,commiton a commit failure. Success facts are unchanged (no stage attr). - FR-3 — the engine Reporter seam. New optional capability
rules.ReporterBinder(thetasks.ReporterBindershape:BindReporter(sink observability.Reporter)).thresher.Newbindst.producerintocfg.RuleEngine()when implemented (beside the dispatcher binds,thresher.go:257);enginertbinds its own reporter likewise. An engine that doesn't implement it doesn't emit — and an implementing engine emits nothing while unbound (the pre-Newconstruction wiring is code, not runtime governance; the audit targets what changes after the engine is live). - FR-4 — gorules registration facts.
gorules.Registryimplementsrules.ReporterBinder(sink guarded by the existing mutex);RegisteremitsKindRules/PhaseRegisteredafter success — details: decision_ref, implementation (##GoRules). Failed registrations return their error to the caller and emit nothing (the audit records the catalog, not caller mistakes).MustRegisterridesRegister. - FR-5 — dtable registration/deploy facts.
dtable.Engineimplements the binder;RegisteremitsRegistered(decision_ref, implementation##DTable,AttrRuleCount = "rule_count");Deployemits newPhaseDeployed— details: decision_ref, implementation, rule_count,replaced("true"when the name overwrote a live table, else"false"). Emission after the swap, outside the lock's hot section where practical. - FR-6 — echo posture.
{KindRules, PhaseDeployed}overrides to Info (a runtime governance milestone — theProcessLifecycleanalog);RegisteredandInvokedride the kind default (Debug).
§2.2 Non-functional¶
- NFR-1 — masking: every new detail is a name, kind, count or flag — never a rule body, script source, or payload value.
- NFR-2 — volume: no per-evaluation/per-rule/per-row facts; engine internals stay out of the observer stream (their future home is the Tracer seam, not facts) — this SRD adds at most one fact per task execution and one per registrar call.
- NFR-3 — no
Must*in library paths; validate-all-params (a nil sink inBindReporteris ignored, keeping the current no-op). - NFR-4 — coverage:
make cigreen; diff-coverage ≥95% (aim 100%); touched functions ≥80%; the dtable module's own suite green.
§3 Models (shapes)¶
// pkg/observability/fact.go
PhaseInvoked Phase = "Invoked" // Rules / Script — the engine call opens
PhaseDeployed Phase = "Deployed" // Rules — a definition landed at runtime
AttrStage = "stage" // engine | commit (Failed only)
AttrRuleCount = "rule_count" // registrar facts (dtable)
// pkg/rules (FR-3) — the tasks.ReporterBinder shape
type ReporterBinder interface {
BindReporter(sink observability.Reporter)
}
Worked trace (the audit stream for one BRT execution against a deployed
table): engine.Deploy(tableJSON) at runtime →
Rules/Deployed{decision_ref: discount, implementation: ##DTable,
rule_count: 3, replaced: true} at Info; the instance reaches the
task → `Rules/Invoked{decision_ref: discount, implementation:
DTable}` → the call returns rows and the fold commits →¶
Rules/Evaluated{…, row_count: 1, result_variable: discount}. Had the
commit failed instead: Rules/Failed{…, stage: commit, error: …} —
the pair still closes.
§4 Analysis & decisions¶
- §4.1 One reused
Invoked, not "Called"/"Loaded". The taxonomy reuses phases across kinds (Completed,Failed,Registered); two kind-specific synonyms would say nothing extra. - §4.2 The binder over a constructor option. The engines are built
by the user before
thresher.New, so only the engine (not the core) can emit registrar facts — but the sink is the core's. The established answer is the optional-capability bind atNew(tasks.ReporterBinderprecedent); aWithReporterconstructor option would demand the user hold a reporter before the engine exists. - §4.3 Unbound = silent, deliberately. Pre-
Newregistrations are construction-time wiring — auditable as code; the governance-relevant events are the ones that change a live engine (runtimeDeploy), and those happen bound. No buffering/replay machinery for pre-bind facts. - §4.4 Registrar failures emit nothing. The error returns to the
caller synchronously — the caller is present and told; the audit
records the resulting catalog. (Task-side
Faileddiffers: the "caller" is a process instance and the stream is its audit.) - §4.5 Internals stay out. Per-rule condition hits, hit-policy row
matching, script VM activity are per-evaluation × per-rule — the
flood ADR-013's volume rules exist to prevent; their diagnostic
content already rides the
Failederror details (dtable errors name decision/rule/datum). Deep latency insight belongs to the Tracer.
§6 Test scenarios¶
| # | Test | Verifies |
|---|---|---|
| T-1 | BRT facts (pkg/model/activities) |
FR-1/FR-2: the Invoked→Evaluated pair with details; engine failure → Invoked + Failed{stage: engine}; commit failure (Put rejected) → Invoked + Failed{stage: commit} |
| T-2 | ScriptTask facts (pkg/model/activities) |
FR-1/FR-2: the same trio over the script pair (Executed/Failed) |
| T-3 | gorules registrar (pkg/rules/gorules) |
FR-3/FR-4: bound → Registered with details; unbound → no fact, no panic; a rejected duplicate emits nothing; nil sink ignored |
| T-4 | dtable registrar (adapters/dtable) |
FR-5: Register → Registered with rule_count; Deploy fresh → Deployed{replaced: false}, redeploy → {replaced: true}; unbound silent; plus the module-side e2e — deploy against a live thresher and see Deployed at the engine observer |
| T-5 | wiring + e2e (pkg/thresher) |
FR-3/FR-6: New binds the producer into a fake ReporterBinder rules engine; a BRT process run shows Invoked→Evaluated on the Observe stream; the {KindRules, Deployed} Info override in the echo table |
§7 Milestones¶
- M1 — task invocation facts. FR-1/FR-2 (+ the two attrs and
PhaseInvoked); T-1/T-2.feat(observability): Rules/Script invocation facts close the pair (SRD-069 M1). - M2 — the registrar audit. FR-3…FR-6 (+
PhaseDeployed); T-3…T-5; CHANGELOG.feat(rules): the engine Reporter seam and registrar facts (SRD-069 M2).
§8 Cross-doc¶
- Implements ADR-013 v.2 §2.6 (open phases; masking/volume rules).
- Upstream: ADR-027 v.1, ADR-029 v.1 §2.6, ADR-031 v.1.
- Sideways: SRD-060 v.1, SRD-064 v.1 (the outcome pairs this completes).
§9 Definition of Done¶
- [x] FR-1…FR-6 implemented; every §6 test exists and passes.
- [x]
make cigreen (core) + the dtable module suite green; diff-coverage ≥95% (aim 100%); touched functions ≥80%. - [x] Masking/volume audit: no new detail carries a body/source/value; no per-evaluation fact added.
- [x] §10 filled; CHANGELOG synced.
§10 Implementation summary¶
Landed on feat/rules-script-observability in two milestones, as
planned:
| Milestone | Commit | Scope |
|---|---|---|
| M1 | a1f9336 |
FR-1/FR-2: PhaseInvoked + AttrStage, both task Exec paths open and close the pair (commit failures included); T-1/T-2 |
| M2 | 93f99ef |
FR-3…FR-6: rules.ReporterBinder + the thresher.New/enginert binds, gorules/dtable Registered, dtable Deployed{replaced} at Info, AttrRuleCount; T-3…T-5; CHANGELOG |
- Verification: post-M2
make ciexit 0; diff-coverage 100.0% of 55 changed lines; the dtable module suite green with every touched function at 100%; the decision-table example smokes exit 0 (it deploys pre-New— unbound, silent, the §4.3 posture demonstrated). - Deltas vs the draft: none — the plan landed as written. One
emphasis note: gorules emits under its registration write-lock
(Report is contractually non-blocking,
fact.goReporter doc); dtable emits inside the deploy critical section for the same reason — thereplacedprobe and the emission stay atomic with the swap.
Open questions¶
None — §4 resolves the design points inline.
Document History¶
| Version | Date | Author | Change |
|---|---|---|---|
| v.1 | 2026-07-26 | Ruslan Gabitov | Initial draft — the post-ADR-032 observability review's remediation: the reused Invoked phase opens the Rules/Script audit pair (engine-call latency derivable, hung engines attributable), commit-stage failures close it (stage: engine|commit — the blind spot where a successful call's failed commit left the stream silent), and the registrar surfaces gain their audit through the rules.ReporterBinder capability (the dispatcher-binder precedent): Registered for gorules/dtable registration, Deployed at Info for runtime dtable deploys (replaced flagged). Unbound engines stay silent by design; engine internals stay out of the stream (Tracer territory). |