Install capabilities and settings defaults without an activation hook - #3048
Conversation
… hook Closes #3047. datamachine_register_capabilities() had exactly one call site — inside datamachine_activate_for_site() — and datamachine_activate_defaults_for_site() likewise only ran from register_activation_hook. Tables did not have that problem: datamachine_maybe_run_deferred_migrations() has reconciled them on plugins_loaded since 0.84.0, and datamachine_ensure_all_tables() covers the network-scoped agent tables too. So an install that was deployed in place rather than activated ended up in a confusing half-working state: a complete, correctly migrated database, no datamachine_* capability on any role, and PluginSettings reading get_option('datamachine_settings', array()) — an empty array rather than the seeded defaults, so site_context_enabled and cleanup_job_data_on_failure were silently absent instead of true. Three ways to reach it: deploy-in-place where activation is never toggled, a test harness that loads without activating, and a must-use plugin, for which register_activation_hook never fires at all. The last one is why this is a prerequisite for Extra-Chill/wp-coding-agents#323. Both calls are idempotent by construction, which matters because this runs on every version bump rather than once: add_cap() is a no-op for a capability the role already has, and the defaults use add_option(), which will not overwrite an operator's existing settings. ComposableFileGenerator::regenerate_all() is deliberately excluded. It is expensive and activation-only by design, and the composable files have their own invalidation path; running it here would put a full regeneration on the first request after every deploy. tests/migration-runtime-smoke.php covers the new path on the existing stubbed option gate: setup runs when the version lags, is skipped on the cheap path, and a later deploy does not clobber operator settings. Verified non-vacuous — removing the call fails the suite.
|
This PR cannot be verified by CI, through no fault of the change.
Filed as Extra-Chill/homeboy-action#327 with the evidence and a suggested mitigation (pin consumers to Worth knowing: Verified locally instead:
Holding this PR rather than merging unverified — happy to merge once #327 is resolved, or sooner if you want it in. |
|
CI is running again (homeboy-action v2.10.8 restored it, Extra-Chill/homeboy-action#327). This PR's own quality gates are all green:
The four failing checks are the reconcile stage, which fails with Filed as Extra-Chill/homeboy-action#329. It was invisible until today because Merging on the strength of the candidate gates plus local verification (32 assertions, non-vacuous check, prefix-policy audit). |
…) (#331) Two diagnosability defects in reconcile-differential-phases.sh, found while investigating a reconcile failure on Extra-Chill/data-machine#3048 that could not be diagnosed from its logs. 1. Twelve identity checks were a single jq predicate with 2>/dev/null and one catch-all message: "candidate phase provenance is missing, malformed, or does not match this workflow." An operator could not tell which field mismatched, or even whether the artifact was absent versus present-and-wrong. Each field is now checked individually and the error names the field, the expected value, and what the artifact actually carried. 2. Worse: a wholly missing artifact failed the job with NO output at all. manifest_for() called fail_closed() from inside a command substitution, and fail_closed writes its ::error:: line to stdout — so the message was captured into the caller's variable instead of reaching the log. The job exited 1 and recorded results=fail with not one word explaining why. manifest_for now selects into RESOLVED_MANIFEST in the current shell, and says explicitly that no artifact was found and which phase failed to upload one. The checks themselves are unchanged and still fail closed — artifacts remain untrusted transport. Only the reporting changes. Tests assert the diagnosis rather than just the exit code: a mismatched field is named, expected and actual are both reported, an unusable results object is distinguished from a generic mismatch, an empty component is named, and an absent artifact says so instead of failing silently. Verified non-vacuous by restoring the command substitution, which fails the suite.
…concile (#332) (#333) Every job checked out `inputs.action-ref` independently. That default is a floating tag (v2), and jobs in one run are minutes apart, so a release landing mid-run gave different jobs different action code. Provenance reconciliation compares an action_revision derived from each job's own checkout, so it failed closed — correctly, but for a reason no operator would guess. Proven on Extra-Chill/data-machine#3048, run 30869272476: Candidate Test started 01:38:36Z -> v2 = v2.10.8 v2.10.9 released 01:38:58Z <- 22 seconds later homeboy / Test started 01:53:27Z -> v2 = v2.10.9 All four reconcile jobs failed. A probe with no release in flight passes all four. Ten v2.10.x tags shipped in four days, and the window is the whole duration of a run, so this is systemic rather than a one-off. It is also self-amplifying during incident response: fixing a broken v2 requires releasing, and each release breaks whatever is running. plan now resolves the ref to an immutable SHA once and publishes it as action-sha; every job checks out that SHA. binary and policy gain plan in needs, because the needs context exposes only DIRECT dependencies and a transitive path is not enough to read needs.plan.outputs. Pinning only the recorded action_revision would not have been sufficient: the manifests would agree while the phases still ran different code, trading a loud failure for a silent inconsistency. The checkout itself is what is pinned. The resolver prefers the `^{}` dereferenced entry. An annotated tag resolves to a TAG OBJECT, and `git ls-remote` sorts its output rather than honouring argument order, so taking the first line yields the tag object while `rev-parse HEAD` in the consuming job reports the commit — reintroducing the exact mismatch this removes. Caught while verifying against the live repo, where refs/tags/v2 is 9486dd4 and refs/tags/v2^{} is 96cdaef. Verified against real refs: v2, v2.10.10, v2.9.2, main, and a full SHA all resolve to commits; an unknown ref fails loudly. test-action-ref-pinning.sh asserts every action checkout is pinned, that each consuming job lists plan directly in needs, that the resolver prefers the dereferenced commit, that a full SHA skips the lookup, and that an unresolvable ref errors. Verified non-vacuous against all three regressions.
Closes #3047.
What was actually broken
I filed this issue claiming schema and defaults both only install from
register_activation_hook. That was half wrong, and the correction is on the issue — worth reading before this diff, because it makes the change much smaller than the title suggests.Tables were already fine.
datamachine_maybe_run_deferred_migrations()has reconciled them onplugins_loadedpriority 5 since 0.84.0, anddatamachine_ensure_all_tables()covers the network-scoped agent tables too.Capabilities and settings defaults were not.
datamachine_register_capabilities()had exactly one call site — insidedatamachine_activate_for_site()— and the settings defaults likewise only ran from the activation hook.So an install deployed in place rather than activated reached a confusing half-working state:
datamachine_*capability on any role, so every capability check failsPluginSettingsreadingget_option( 'datamachine_settings', array() )— an empty array rather than the seeded defaults, sosite_context_enabledandcleanup_job_data_on_failureare silently absent instead oftrueCapabilities is the one that bites: the data is right and the admin surface is dead.
Three ways to reach it
register_activation_hooknever fires at allThe last is why this is a prerequisite for Extra-Chill/wp-coding-agents#323 (moving the agent runtime to
mu-pluginsso a site owner cannot deactivate their own assistant). Doing that move without this fix produces a site with tables and no capabilities, and no error to explain it.The change
datamachine_run_deferred_site_setup()on the existing version gate. No new hook, no new option, no new mechanism — it rides the gate that already exists.Both calls are idempotent by construction, which matters because this runs on every version bump rather than once:
add_cap()is a no-op for a capability the role already hasadd_option(), which will not overwrite an operator's existing settingsDeliberately excluded
ComposableFileGenerator::regenerate_all(). It is expensive and activation-only by design, and the composable files have their own invalidation path. Adding it here would put a full regeneration on the first request after every deploy — inheriting the activation-only decision by accident is exactly what this issue is about, so it is called out in the docblock rather than left implicit.Tests
tests/migration-runtime-smoke.phpextends the existing stubbed option gate with three cases:Verified non-vacuous: removing the
datamachine_run_deferred_site_setup()call fails the suite (exit 1).tests/prefix-policy-audit.php,flow-schedule-reconciliation-deferred-smoke, andscaffold-ability-activation-smokeall still pass.