Skip to content

Install capabilities and settings defaults without an activation hook - #3048

Merged
chubes4 merged 2 commits into
mainfrom
fix/3047-activation-idempotent
Aug 4, 2026
Merged

Install capabilities and settings defaults without an activation hook#3048
chubes4 merged 2 commits into
mainfrom
fix/3047-activation-idempotent

Conversation

@chubes4

@chubes4 chubes4 commented Aug 3, 2026

Copy link
Copy Markdown
Member

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 on plugins_loaded priority 5 since 0.84.0, and datamachine_ensure_all_tables() covers the network-scoped agent tables too.

Capabilities and settings defaults were not. datamachine_register_capabilities() had exactly one call site — inside datamachine_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:

  • a complete, correctly migrated database
  • no datamachine_* capability on any role, so every capability check fails
  • 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 are silently absent instead of true

Capabilities is the one that bites: the data is right and the admin surface is dead.

Three ways to reach it

  • deploy-in-place, where files update and activation is never toggled
  • a test harness that loads the plugin without activating it
  • a must-use plugin, for which register_activation_hook never fires at all

The last is why this is a prerequisite for Extra-Chill/wp-coding-agents#323 (moving the agent runtime to mu-plugins so 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 has
  • the defaults use add_option(), which will not overwrite an operator's existing settings

Deliberately 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.php extends the existing stubbed option gate with three cases:

  • setup runs when the persisted version lags — capabilities registered, defaults seeded, without any activation hook
  • the cheap path (option matches constant) skips site setup as well as schema
  • a later deploy does not clobber operator settings — the idempotency property, asserted rather than assumed

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, and scaffold-ability-activation-smoke all still pass.

… 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.
@chubes4

chubes4 commented Aug 3, 2026

Copy link
Copy Markdown
Member Author

This PR cannot be verified by CI, through no fault of the change.

data-machine's Homeboy workflow has been startup_failure since 2026-08-02 — zero jobs start, including the standalone prefix-policy job. Last green run was release: v0.171.2 on 2026-07-29. data-machine-code is in the same state going back to 2026-07-30, so it is every consumer of homeboy-action/.github/workflows/ci.yml@v2, not this repo.

Filed as Extra-Chill/homeboy-action#327 with the evidence and a suggested mitigation (pin consumers to ci.yml@v2.9.2).

Worth knowing: gh pr checks reports "no checks reported" rather than a failure, so an affected PR looks like it simply has no CI. v0.171.3 shipped that way.

Verified locally instead:

  • php tests/migration-runtime-smoke.php — 32 assertions, all pass, including the three new deferred-setup cases
  • non-vacuous: removing the datamachine_run_deferred_site_setup() call fails the suite (exit 1)
  • php tests/prefix-policy-audit.php — 11 passed, 0 failed (this is the job CI would run standalone)
  • flow-schedule-reconciliation-deferred-smoke and scaffold-ability-activation-smoke still pass
  • php -l clean on both changed files

Holding this PR rather than merging unverified — happy to merge once #327 is resolved, or sooner if you want it in.

@chubes4

chubes4 commented Aug 4, 2026

Copy link
Copy Markdown
Member Author

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:

job result
prefix-policy pass
Prepare candidate Homeboy binary pass
Plan pass
Candidate Lint pass
Candidate Audit pass
Candidate Test pass
Candidate Refactor pass

The four failing checks are the reconcile stage, which fails with candidate phase provenance is missing, malformed, or does not match this workflow — a cross-job artifact handshake inside homeboy-action, not a finding about this change. It fails identically regardless of the diff, the candidate phases that actually evaluate the code all pass, and Baseline is skipped because this consumer does not enable differential-gating.

Filed as Extra-Chill/homeboy-action#329. It was invisible until today because ci.yml@v2 was startup_failure for every consumer from v2.10.0 through v2.10.7, so nothing reached the reconcile stage.

Merging on the strength of the candidate gates plus local verification (32 assertions, non-vacuous check, prefix-policy audit).

@chubes4
chubes4 merged commit 5493974 into main Aug 4, 2026
9 of 13 checks passed
@chubes4
chubes4 deleted the fix/3047-activation-idempotent branch August 4, 2026 01:59
chubes4 added a commit to Extra-Chill/homeboy-action that referenced this pull request Aug 4, 2026
…) (#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.
chubes4 added a commit to Extra-Chill/homeboy-action that referenced this pull request Aug 4, 2026
…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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Capabilities and settings defaults never install without activation (schema already reconciles on load)

1 participant