abilities.php
Abilities API integration
Registers three read-only WordPress Abilities that expose this plugin's job data to MCP clients, REST automation and the WordPress AI client. Nothing is registered when the Abilities API is absent, when ACF Pro is not active, or when JPKCOM_ACFJOBS_ABILITIES is false.
Every callback in this file returns a WP_Error rather than throwing. On the declared WordPress 6.9 floor there is no Throwable-to-WP_Error wrapper around ability callbacks, so an escaping exception is an uncaught fatal inside a REST request.
Tags
Table of Contents
Constants
- JPKCOM_ACFJOBS_ABILITY_CATEGORY : mixed = 'jpkcom-content'
- Ability category slug, shared with the other JPKCom content plugins.
- JPKCOM_ACFJOBS_ABILITY_COUNT_LIMIT : mixed = 500
- Number of listed jobs above which list-filters stops tallying.
- JPKCOM_ACFJOBS_ABILITY_INPUT_KEYS : mixed = ['jpkcom-acf-jobs/list-filters' => [], 'jpkcom-...
- Top-level input keys each ability declares.
- JPKCOM_ACFJOBS_ABILITY_JOB_TYPE_FIELD : mixed = 'field_68de7a25cd78d'
- Field key of the job_type checkbox, the source of the filter vocabulary.
- JPKCOM_ACFJOBS_ABILITY_MAX_VALUES : mixed = 20
- Maximum number of values accepted per filter axis.
- JPKCOM_ACFJOBS_ABILITY_PAGE_MAX : mixed = \intdiv(\PHP_INT_MAX, \max(1, \JPKCOM_ACFJOBS_A...
- Highest page number the query ability will ask the database for.
- JPKCOM_ACFJOBS_ABILITY_PER_PAGE_DEFAULT : mixed = 10
- Default page size of the query ability.
- JPKCOM_ACFJOBS_ABILITY_PER_PAGE_MAX : mixed = 50
- Maximum page size of the query ability.
- JPKCOM_ACFJOBS_ABILITY_SEARCH_MAX_BYTES : mixed = 1600
- Longest search term, in bytes, that WordPress will actually apply.
- JPKCOM_ACFJOBS_ABILITY_VOCABULARY_LIMIT : mixed = 500
- Maximum number of company or location records list-filters offers.
Functions
- jpkcom_acf_jobs_abilities_enabled() : bool
- Decide whether the abilities may be registered at all.
- jpkcom_acf_jobs_ability_log() : void
- Record an abilities failure that WordPress itself reports silently.
- jpkcom_acf_jobs_ability_json_object() : array<string|int, mixed>|stdClass
- Force a map to encode as a JSON object rather than as an array.
- jpkcom_acf_jobs_ability_error() : WP_Error
- Build a WP_Error that carries an HTTP status.
- jpkcom_acf_jobs_ability_capability() : string
- Resolve the capability required to run an ability.
- jpkcom_acf_jobs_ability_meta() : array<string|int, mixed>
- Build the meta array of an ability.
- jpkcom_acf_jobs_job_type_choices() : array<string|int, mixed>
- Read the registered job_type vocabulary as value => label.
- jpkcom_acf_jobs_ability_language() : string
- Resolve the language the answer is actually in.
- jpkcom_acf_jobs_ability_related_vocabulary() : array<string|int, mixed>
- List every published company or location this site offers as a filter value.
- jpkcom_acf_jobs_ability_count_query() : int
- Count the posts matching a set of WP_Query arguments without fetching them.
- jpkcom_acf_jobs_ability_visibility_counts() : array<string|int, mixed>
- Count the published jobs the site visibility rule excludes, by cause.
- jpkcom_acf_jobs_ability_validate_input_keys() : true|WP_Error
- Refuse a top-level input key the ability does not declare.
- jpkcom_acf_jobs_ability_normalise_bool() : mixed
- Read a boolean the way a GET query string can actually express one.
- jpkcom_acf_jobs_ability_clamp_per_page() : int
- Clamp a requested page size into the range the query ability allows.
- jpkcom_acf_jobs_ability_normalise_filter() : array<string|int, mixed>|WP_Error
- Validate one filter axis, refusing anything that would normalise away.
- jpkcom_acf_jobs_ability_has_meta_clause() : bool
- Whether a meta query carries any clause on a meta key.
- jpkcom_acf_jobs_ability_unbacked_claim() : string
- Report the first filter the response would claim that the query does not carry.
- jpkcom_acf_jobs_ability_canonical() : string
- Reduce any query fragment to one string that stands for its exact content.
- jpkcom_acf_jobs_ability_group_label() : string
- Name a query fragment for an error message a site owner has to act on.
- jpkcom_acf_jobs_ability_query_commitments() : array<string|int, mixed>
- Record the query the ability is about to hand to the site, clause by clause.
- jpkcom_acf_jobs_ability_query_divergence() : string
- Report the first commitment the executed query does not keep.
- jpkcom_acf_jobs_ability_group_relation() : string
- Read the relation of a meta_query or tax_query group.
- jpkcom_acf_jobs_ability_permission_list_filters() : bool
- Permission callback for jpkcom-acf-jobs/list-filters.
- jpkcom_acf_jobs_ability_permission_query_jobs() : bool
- Permission callback for jpkcom-acf-jobs/query-jobs.
- jpkcom_acf_jobs_ability_permission_get_job() : bool
- Permission callback for jpkcom-acf-jobs/get-job.
- jpkcom_acf_jobs_get_ability_definitions() : array<string|int, mixed>
- Build the registration arguments for every ability this plugin provides.
- jpkcom_acf_jobs_ability_list_filters_inner() : array<string|int, mixed>|WP_Error
- Execute callback for jpkcom-acf-jobs/list-filters.
- jpkcom_acf_jobs_ability_query_jobs_inner() : array<string|int, mixed>|WP_Error
- Execute callback for jpkcom-acf-jobs/query-jobs.
- jpkcom_acf_jobs_ability_job_is_listed() : bool
- Ask the site visibility rule whether it returns this one job.
- jpkcom_acf_jobs_detail_page_renders() : bool
- Decide whether a job's own page would render for an anonymous visitor.
- jpkcom_acf_jobs_ability_get_job_inner() : array<string|int, mixed>|WP_Error
- Execute callback for jpkcom-acf-jobs/get-job.
- jpkcom_acf_jobs_register_ability_category() : void
- Register the shared JPKCom content ability category.
- jpkcom_acf_jobs_register_abilities() : void
- Register every ability this plugin provides.
- jpkcom_acf_jobs_ability_boundary() : array<string, mixed>|WP_Error
- Run an ability body and convert any Throwable into a WP_Error
- jpkcom_acf_jobs_ability_list_filters() : array<string, mixed>|WP_Error
- list-filters, behind the callback boundary
- jpkcom_acf_jobs_ability_query_jobs() : array<string, mixed>|WP_Error
- query-jobs, behind the callback boundary
- jpkcom_acf_jobs_ability_get_job() : array<string, mixed>|WP_Error
- get-job, behind the callback boundary
Constants
JPKCOM_ACFJOBS_ABILITY_CATEGORY
Ability category slug, shared with the other JPKCom content plugins.
public
mixed
JPKCOM_ACFJOBS_ABILITY_CATEGORY
= 'jpkcom-content'
Tags
JPKCOM_ACFJOBS_ABILITY_COUNT_LIMIT
Number of listed jobs above which list-filters stops tallying.
public
mixed
JPKCOM_ACFJOBS_ABILITY_COUNT_LIMIT
= 500
Tags
JPKCOM_ACFJOBS_ABILITY_INPUT_KEYS
Top-level input keys each ability declares.
public
mixed
JPKCOM_ACFJOBS_ABILITY_INPUT_KEYS
= ['jpkcom-acf-jobs/list-filters' => [], 'jpkcom-acf-jobs/query-jobs' => ['job_type', 'company', 'location', 'attribute', 'search', 'include_closed', 'page', 'per_page', 'order'], 'jpkcom-acf-jobs/get-job' => ['id']]
One list, in one place, because the call sites used to carry it inline and one of the three did not carry it at all. get-job accepted any undeclared key with a 200 while query-jobs and list-filters refused the same key with a 400 — measured over the REST route on WordPress 7.0.3. That is the trap the comment at the first call site already named: a caller that learns the refusal on one ability assumes it everywhere, and the one ability that silently accepts is the one it will trust.
On get-job an ignored key cannot widen a result set the way it can on
query-jobs — the answer is determined by id alone — so this is a
consistency defect rather than a wrong answer. It is still the shape that
teaches a caller the wrong rule.
Keep in step with the input schemas: tests/test-abilities.php compares
this map against the properties of every registered schema and fails the
build on either drift direction. Declaring additionalProperties => false
would derive the list automatically, but it was measured to preempt
jpkcom_acf_jobs_ability_validate_input_keys() entirely — validate_input()
runs before the execute callback — and core's replacement message names
neither the accepted keys nor where a nested filter belongs.
Tags
JPKCOM_ACFJOBS_ABILITY_JOB_TYPE_FIELD
Field key of the job_type checkbox, the source of the filter vocabulary.
public
mixed
JPKCOM_ACFJOBS_ABILITY_JOB_TYPE_FIELD
= 'field_68de7a25cd78d'
Tags
JPKCOM_ACFJOBS_ABILITY_MAX_VALUES
Maximum number of values accepted per filter axis.
public
mixed
JPKCOM_ACFJOBS_ABILITY_MAX_VALUES
= 20
Tags
JPKCOM_ACFJOBS_ABILITY_PAGE_MAX
Highest page number the query ability will ask the database for.
public
mixed
JPKCOM_ACFJOBS_ABILITY_PAGE_MAX
= \intdiv(\PHP_INT_MAX, \max(1, \JPKCOM_ACFJOBS_ABILITY_PER_PAGE_MAX))
Derived, not picked. WP_Query::get_posts() computes its LIMIT offset as absint( ( $page - 1 ) * $posts_per_page ), and that product is a plain PHP integer multiplication: past PHP_INT_MAX it becomes a float, and absint() casts rather than throws. Measured on WP 7.0.2 — page 1844674407370955161 at a page size of 10 collapses the offset to 0, so page ONE's records come back labelled as a page far beyond total_pages and a caller paginating on those numbers is handed the same records twice.
per_page is clamped to at most PER_PAGE_MAX before this bound is applied, so bounding the page at intdiv( PHP_INT_MAX, PER_PAGE_MAX ) keeps the product exact for every page size the ability accepts. max() guards the divisor: a site is free to redefine PER_PAGE_MAX, and intdiv() by zero is a fatal at file load.
Tags
JPKCOM_ACFJOBS_ABILITY_PER_PAGE_DEFAULT
Default page size of the query ability.
public
mixed
JPKCOM_ACFJOBS_ABILITY_PER_PAGE_DEFAULT
= 10
Tags
JPKCOM_ACFJOBS_ABILITY_PER_PAGE_MAX
Maximum page size of the query ability.
public
mixed
JPKCOM_ACFJOBS_ABILITY_PER_PAGE_MAX
= 50
Tags
JPKCOM_ACFJOBS_ABILITY_SEARCH_MAX_BYTES
Longest search term, in bytes, that WordPress will actually apply.
public
mixed
JPKCOM_ACFJOBS_ABILITY_SEARCH_MAX_BYTES
= 1600
Read out of wp-includes/class-wp-query.php:866-869 rather than guessed. The
guard there is an anti-DoS measure that silently empties s when it is not
scalar or longer than 1600 bytes, and it runs INSIDE WP_Query — after any
caller has finished inspecting the arguments it passed. The result is not a
crash: the search simply stops narrowing and every post matches.
Verified byte-identical on WordPress 6.9.4 (the declared floor) and 7.0.2, so there is no stricter of the two to take.
strlen(), so the unit is BYTES. Counting characters would hand a 900- character accented term to a guard that counts bytes and reproduce the defect for non-ASCII callers only.
Tags
JPKCOM_ACFJOBS_ABILITY_VOCABULARY_LIMIT
Maximum number of company or location records list-filters offers.
public
mixed
JPKCOM_ACFJOBS_ABILITY_VOCABULARY_LIMIT
= 500
Tags
Functions
jpkcom_acf_jobs_abilities_enabled()
Decide whether the abilities may be registered at all.
jpkcom_acf_jobs_abilities_enabled() : bool
ACF Pro is part of the condition, not an assumption. Requires Plugins
only blocks activation: WordPress does not stop an administrator from
deactivating a dependency that has active dependents, so one click is
enough to remove get_field() from under this file. Without that check the
first field read is Call to undefined function — an uncaught fatal in a
REST request on the 6.9 floor.
Tags
Return values
bool —True when the Abilities API is present, ACF is active and the kill switch is on.
jpkcom_acf_jobs_ability_log()
Record an abilities failure that WordPress itself reports silently.
jpkcom_acf_jobs_ability_log(string $message) : void
wp_register_ability() and wp_register_ability_category() return null on every failure path and report only through _doing_it_wrong(), which is silent in production. Debug-only on purpose: this exists to make a failed registration findable, not to fill a production log.
Parameters
- $message : string
-
Message to record.
Tags
jpkcom_acf_jobs_ability_json_object()
Force a map to encode as a JSON object rather than as an array.
jpkcom_acf_jobs_ability_json_object(array<string|int, mixed> $map) : array<string|int, mixed>|stdClass
PHP serialises an empty array as [], but filters, unknown and every
top-level input default are declared type: object. Core's REST list
controller special-cases exactly that value and rewrites it to }; the
MCP Adapter does not — it hands the schema to clients raw. So this wrapper
is required, not decorative.
Parameters
- $map : array<string|int, mixed>
-
Map that must not degrade to a JSON array.
Tags
Return values
array<string|int, mixed>|stdClass —The map, or an empty object when it is empty.
jpkcom_acf_jobs_ability_error()
Build a WP_Error that carries an HTTP status.
jpkcom_acf_jobs_ability_error(string $code, string $message[, int $status = 400 ]) : WP_Error
The status is never optional in practice. The REST run controller returns the WP_Error verbatim and rest_ensure_response() defaults to 500 when data['status'] is absent — which tells an agent "transient server fault, retry unchanged", the exact opposite of the intended instruction. These messages exist so a caller can correct itself in one turn.
Parameters
- $code : string
-
Machine-readable error code.
- $message : string
-
Human-readable message naming the valid form.
- $status : int = 400
-
HTTP status to report. Default 400.
Tags
Return values
WP_Error —Error carrying the status in its data.
jpkcom_acf_jobs_ability_capability()
Resolve the capability required to run an ability.
jpkcom_acf_jobs_ability_capability(string $ability) : string
Defaults to read. Every query is hard-scoped to published, unprotected
jobs, so this cannot expose drafts or private content — but it is bulk
machine-readable access, which a site may want to restrict further.
Parameters
- $ability : string
-
Fully qualified ability name.
Tags
Return values
string —Capability name.
jpkcom_acf_jobs_ability_meta()
Build the meta array of an ability.
jpkcom_acf_jobs_ability_meta(string $ability) : array<string|int, mixed>
Three independent exposure switches live here. show_in_rest governs core
REST visibility. public seeds it from WordPress 7.1 onwards and is an
inert passthrough on 6.9 and 7.0. mcp is not a core key at all — it is
the MCP Adapter's own gate, and without it an ability is neither
discoverable nor executable over MCP.
All three annotations are set explicitly. They default to null, and the REST run controller derives the HTTP method from them, so an ability without annotations is POST-only.
Parameters
- $ability : string
-
Fully qualified ability name.
Tags
Return values
array<string|int, mixed> —Meta array for wp_register_ability().
jpkcom_acf_jobs_job_type_choices()
Read the registered job_type vocabulary as value => label.
jpkcom_acf_jobs_job_type_choices() : array<string|int, mixed>
Read from the field definition, never from stored values, so the enum is complete even for a type no job currently uses. The eight literals are a fallback rather than a second source of truth: they keep jpkcom_acf_jobs_get_ability_definitions() free of any dependency on a live ACF registry, which is what lets the CI harness assert the registration arrays without a WordPress installation.
The labels are locale-dependent and are never valid filter input. Only the keys are.
Tags
Return values
array<string|int, mixed> —Map of job type value => human-readable label.
jpkcom_acf_jobs_ability_language()
Resolve the language the answer is actually in.
jpkcom_acf_jobs_ability_language() : string
There is deliberately no lang input anywhere in this feature. Nothing
here can switch WPML's language context, and a declared parameter with
nothing behind it is a false statement in the schema: a client sending
lang=fr would receive German and have no way to notice. Reporting what
the site resolved is the honest half of that trade.
Both WPML accessors are guarded. wpml_current_language is the documented
filter and returns the value it was handed when WPML is absent, so no
plugin check is needed around it; ICL_LANGUAGE_CODE is read only after
defined(), because referencing an undefined constant is a fatal on PHP 8.
Tags
Return values
string —Language or locale code, empty only when WordPress itself cannot say.
jpkcom_acf_jobs_ability_related_vocabulary()
List every published company or location this site offers as a filter value.
jpkcom_acf_jobs_ability_related_vocabulary(string $post_type) : array<string|int, mixed>
Deliberately independent of how many jobs exist. Deriving this list from the pass over the visible jobs would make its contents depend on the corpus size, so a caller would be offered a different filter menu on a large site than on a small one with nothing in the response explaining why. Only the counts depend on that pass; the vocabulary does not.
fields => 'ids' so the query itself carries no post rows, no_found_rows because nothing here needs a total, and both cache flags off because the caller primes exactly the meta it goes on to read. The titles and the second status check then come from jpkcom_acf_jobs_normalise_related(), which is the same projection every other reader in this feature uses and which never emits a WP_Post. A direct SELECT of ID and post_title would touch fewer columns, but this plugin issues no SQL of its own anywhere, and it would bypass that status and password recheck.
Parameters
- $post_type : string
-
Either 'job_company' or 'job_location'.
Tags
Return values
array<string|int, mixed> —{ @type array $records List of [ 'id' => int, 'title' => string ]. @type bool $truncated Whether the site holds more records than the cap. }
jpkcom_acf_jobs_ability_count_query()
Count the posts matching a set of WP_Query arguments without fetching them.
jpkcom_acf_jobs_ability_count_query(array<string|int, mixed> $args) : int
fields => 'ids' plus a page size of one keeps the result to a single column of a single row while the total is still computed. no_found_rows is pinned to false because that total is the only thing this call exists to produce.
Parameters
- $args : array<string|int, mixed>
-
WP_Query arguments.
Tags
Return values
int —Number of matching posts.
jpkcom_acf_jobs_ability_visibility_counts()
Count the published jobs the site visibility rule excludes, by cause.
jpkcom_acf_jobs_ability_visibility_counts(int $published_total, int $listed_total) : array<string|int, mixed>
Two independent causes exclude a job that has no job_featured row: the EXISTS clause of the visibility rule, and the meta_key the ordering needs, whose postmeta.meta_key condition lands in the WHERE clause. Removing either one changes nothing, so subtracting a listed total from a published total would attribute the shortfall to whichever cause happened to be named. Each cause therefore gets its own query.
The expired count is conditioned on job_featured existing as well, so the two causes partition the difference rather than overlapping.
Parameters
- $published_total : int
- $listed_total : int
Tags
Return values
array<string|int, mixed> —{ @type int $hidden_missing_featured Published jobs carrying no job_featured row. @type int $hidden_expired Published jobs whose expiry date has passed. }
jpkcom_acf_jobs_ability_validate_input_keys()
Refuse a top-level input key the ability does not declare.
jpkcom_acf_jobs_ability_validate_input_keys(array<string, mixed> $input, array<string|int, string> $allowed) : true|WP_Error
Neither input schema declares additionalProperties, so an unrecognised key
reaches the callback and is simply not read. The observable result was the
complete unfiltered corpus behind an HTTP 200, with filters correctly
omitting what it had not applied and unknown empty - so the caller had to
notice an absence to notice the failure, and one that trusts total reports
the whole corpus as a filtered answer.
Two routes led there and both are common. The output schema of query-jobs instructed the model to send a work_type filter that has never existed as an input, and a single transposed letter in a real axis behaved identically.
Parameters
- $input : array<string, mixed>
-
Raw ability input.
- $allowed : array<string|int, string>
-
Declared input keys.
Tags
Return values
true|WP_Error —True when every key is declared, WP_Error otherwise.
jpkcom_acf_jobs_ability_normalise_bool()
Read a boolean the way a GET query string can actually express one.
jpkcom_acf_jobs_ability_normalise_bool(mixed $value) : mixed
Mirrors core's rest_sanitize_boolean() rather than inventing a set: those are the spellings every other WordPress REST endpoint accepts, so a caller that has learned one surface has learned this one. Anything outside them comes back unchanged, so the caller still gets the 400 - widening what is accepted must not turn into guessing what was meant.
Parameters
- $value : mixed
-
Raw input value.
Tags
Return values
mixed —A bool when the value has a boolean reading, the input otherwise.
jpkcom_acf_jobs_ability_clamp_per_page()
Clamp a requested page size into the range the query ability allows.
jpkcom_acf_jobs_ability_clamp_per_page(mixed $value) : int
Never returns -1 or 0. The shortcode's own default IS -1 and the shared builder refuses to default to it, but this clamp is what keeps an API caller from reaching an unbounded query in the first place.
Clamping rather than refusing is deliberate here, and it is not the same decision as the one the filter axes take. The response echoes the applied page size back, so a caller can see what it got; a silently dropped filter clause has no such tell, which is why that case is an error instead.
Parameters
- $value : mixed
-
Requested page size.
Tags
Return values
int —Page size between 1 and JPKCOM_ACFJOBS_ABILITY_PER_PAGE_MAX.
jpkcom_acf_jobs_ability_normalise_filter()
Validate one filter axis, refusing anything that would normalise away.
jpkcom_acf_jobs_ability_normalise_filter(mixed $raw, string $axis, int $max) : array<string|int, mixed>|WP_Error
This is the load-bearing guard of the whole ability. The shortcode builds each clause as array_filter( array_map( 'absint', … ) ) and skips it when the result is empty, so company=["acme"] adds no clause at all and the response contains EVERY job — the same class that answered with 19 of 19 posts in jpkcom-post-filter. A requested filter that survives normalisation empty is therefore an error naming the valid form, never a dropped clause.
A well-formed value that simply matches nothing is a different statement and
is not handled here: the caller resolves it and reports it in unknown,
because an empty result for company=[999] is honest.
Only the shape is decided here. Whether a well-formed id or slug exists on
this site is resolved at the call site, where get_post_type() and
get_term_by() are available and where the unknown bucket lives.
Parameters
- $raw : mixed
-
Raw value as it arrived in the ability input.
- $axis : string
-
Axis name: 'job_type', 'company', 'location' or 'attribute'.
- $max : int
-
Maximum number of values this axis accepts.
Tags
Return values
array<string|int, mixed>|WP_Error —Normalised values, [] when the axis was not requested, or an error.
jpkcom_acf_jobs_ability_has_meta_clause()
Whether a meta query carries any clause on a meta key.
jpkcom_acf_jobs_ability_has_meta_clause(mixed $meta_query, string $key[, int $depth = 0 ]) : bool
This answers one question only, and it is a question about the ability's OWN construction: did the shared builder turn the request into a clause at all. It is asked before jpkcom_acf_jobs_ability_query_args runs, because after that filter the question is no longer "is something there" but "is what is there what this ability built" — which is decided by identity, in jpkcom_acf_jobs_ability_query_divergence(), and not by inspecting properties.
Deliberately shape-tolerant. The builder is resolved through the plugin's file override chain and the spec's promise is that this ability runs the same query the site itself runs, so how a site spells its own clause is its business; that the clause exists for a filter the response is about to CLAIM is not.
The depth limit is not decoration: unbounded recursion on a deep array is a stack overflow, which no ability callback may risk.
Parameters
- $meta_query : mixed
-
Meta query.
- $key : string
-
Meta key to look for.
- $depth : int = 0
-
Current recursion depth. Internal.
Tags
Return values
bool —True when at least one clause names the key.
jpkcom_acf_jobs_ability_unbacked_claim()
Report the first filter the response would claim that the query does not carry.
jpkcom_acf_jobs_ability_unbacked_claim(array<string|int, mixed> $args, array<string|int, mixed> $claims, bool $attribute, string $search, string $order) : string
A precondition on the ability's own construction, asked before any site
callback can touch the arguments. The shared builder is resolved through the
plugin's file override chain and skips a clause whose value list came out
empty, so a request that never became a clause would be answered with every
job on the site while filters names the axis as applied — the defect this
whole feature exists to prevent.
It asks presence and nothing else, on purpose. What a clause has to survive between here and WP_Query is decided by identity in jpkcom_acf_jobs_ability_query_divergence(); what a site's own builder chooses to build is the site's business, and the spec's promise is that this ability runs the same query the site runs.
Nothing here casts an unvalidated value: a cast of an object without __toString is a Throwable, and a Throwable out of an ability callback is an uncaught fatal on the declared 6.9 floor.
Parameters
- $args : array<string|int, mixed>
-
WP_Query arguments as the ability built them.
- $claims : array<string|int, mixed>
-
Meta keys that must each carry a clause, keyed by the label to report.
- $attribute : bool
-
Whether a job-attribute clause is required.
- $search : string
-
Search term that must have reached the query, '' when none was requested.
- $order : string
-
Direction both sort components must carry.
Tags
Return values
string —Label of the first unbacked claim, or '' when every claim is carried.
jpkcom_acf_jobs_ability_canonical()
Reduce any query fragment to one string that stands for its exact content.
jpkcom_acf_jobs_ability_canonical(mixed $value[, int $depth = 0 ]) : string
Two fragments produce the same string when they carry the same values under the same keys, and a different one as soon as anything about them differs — a value, an operator, a cast, an added key, an added clause, a changed type. That is the whole point: this ability cannot enumerate what a site callback might change about a clause, and it does not have to, because it knows what it built and can recognise it again.
Three properties are load-bearing:
- Key ORDER is not significant. WP_Meta_Query reads a clause by key, so [ 'key' => …, 'compare' => … ] and [ 'compare' => …, 'key' => … ] are the same clause, and a callback that rebuilds a clause it has read has not changed it. The parts are therefore sorted before they are joined.
- LIST order IS significant, because each part carries its own index. Two clauses swapped inside an OR group mean the same thing to core and are reported as a divergence here anyway. That direction is deliberate: this comparison may refuse a query the ability did in fact build, and may never accept one it did not.
- Scalar TYPES are part of the value. '182' and 182 produce different SQL through a LIKE comparison, so they must not compare equal.
Strings are length-prefixed so that no punctuation inside a value can imitate the structure around it.
Nothing here throws for any input. No value is cast to string — a cast of an object without __toString is a Throwable, and a Throwable out of an ability callback is an uncaught fatal on the declared 6.9 floor — and the recursion is depth-limited. The limit is far above anything this ability builds (its deepest fragment is a clause inside a group, at depth two), so a commitment can never itself be truncated, and a filtered fragment that is deeper than the limit differs from the commitment at a shallower level in any case.
Parameters
- $value : mixed
-
Fragment to reduce.
- $depth : int = 0
-
Current recursion depth. Internal.
Tags
Return values
string —Canonical representation of the fragment.
jpkcom_acf_jobs_ability_group_label()
Name a query fragment for an error message a site owner has to act on.
jpkcom_acf_jobs_ability_group_label(mixed $fragment) : string
Descriptive only. Nothing about the guard depends on it: a fragment nobody anticipated is still committed and still compared, it is merely reported under a general name.
Parameters
- $fragment : mixed
-
Query fragment.
Tags
Return values
string —Human-readable name.
jpkcom_acf_jobs_ability_query_commitments()
Record the query the ability is about to hand to the site, clause by clause.
jpkcom_acf_jobs_ability_query_commitments(array<string|int, mixed> $args) : array<string|int, mixed>
Taken immediately before jpkcom_acf_jobs_ability_query_args runs, so that what comes back can be compared against it rather than interrogated. Four review rounds asked a longer list of questions of each clause every time — presence, then compare, then type, then the enclosing relation — and every round found a question the previous one had not thought to ask. A value was still not among them, and neither was a clause added inside an OR group. The list of things that can be altered about a clause is not bounded by what anyone thought of; the set of clauses this ability built is.
Committed: every top-level element of meta_query and of tax_query. Those two structures are the whole of what a callback may contribute, so they are the whole of what can come back changed — every other query var is simply never read from the filtered array, which is a stronger guarantee than any comparison and needs no list of names to hold.
Parameters
- $args : array<string|int, mixed>
-
WP_Query arguments as the ability built them.
Tags
Return values
array<string|int, mixed> —{ @type array $meta List of [ 'label' => string, 'canonical' => string ] for meta_query. @type array $tax The same for tax_query. }
jpkcom_acf_jobs_ability_query_divergence()
Report the first commitment the executed query does not keep.
jpkcom_acf_jobs_ability_query_divergence(array<string|int, mixed> $committed, mixed $args) : string
The rule has two halves and both are established here rather than assumed.
Every clause this ability built has to be present, byte for byte as it was built, as a direct element of the same group. Anything else about it — a value trimmed, a boundary moved, a term list widened, one more value added inside an OR group, an operator flipped, a key nobody has thought of yet — makes it a different clause, and a different clause is not the one the response is about to name as applied.
What a callback may still do is ADD, and that permission rests on the relation of the group it adds to: under AND every further element can only remove rows, which is the one direction that cannot turn the response into a false claim. So the relation is verified on the arguments that actually run, and an addition anywhere else is refused by the paragraph above — an extra value inside an axis OR group widened FULL_TIME from 2 jobs to 4 on /home/jpk/ddev/posts while the response still claimed FULL_TIME.
A commitment is consumed once it is matched, so two committed clauses need two elements to satisfy them.
Nothing here throws for any input: every comparison is between two strings produced by jpkcom_acf_jobs_ability_canonical().
Parameters
- $committed : array<string|int, mixed>
-
Output of jpkcom_acf_jobs_ability_query_commitments().
- $args : mixed
-
WP_Query arguments as they will reach WP_Query.
Tags
Return values
string —Name of the first divergence, or '' when the query is the one that was built.
jpkcom_acf_jobs_ability_group_relation()
Read the relation of a meta_query or tax_query group.
jpkcom_acf_jobs_ability_group_relation(mixed $group) : string
Absent means AND, which is what both WP_Meta_Query and WP_Tax_Query default to, so a missing relation is not a divergence.
Parameters
- $group : mixed
-
Query group.
Tags
Return values
string —'AND' or the uppercased relation as given.
jpkcom_acf_jobs_ability_permission_list_filters()
Permission callback for jpkcom-acf-jobs/list-filters.
jpkcom_acf_jobs_ability_permission_list_filters([mixed $input = null ]) : bool
Parameters
- $input : mixed = null
-
Validated ability input. Unused.
Tags
Return values
bool —True when the current user may run the ability.
jpkcom_acf_jobs_ability_permission_query_jobs()
Permission callback for jpkcom-acf-jobs/query-jobs.
jpkcom_acf_jobs_ability_permission_query_jobs([mixed $input = null ]) : bool
Parameters
- $input : mixed = null
-
Validated ability input. Unused.
Tags
Return values
bool —True when the current user may run the ability.
jpkcom_acf_jobs_ability_permission_get_job()
Permission callback for jpkcom-acf-jobs/get-job.
jpkcom_acf_jobs_ability_permission_get_job([mixed $input = null ]) : bool
Parameters
- $input : mixed = null
-
Validated ability input. Unused.
Tags
Return values
bool —True when the current user may run the ability.
jpkcom_acf_jobs_get_ability_definitions()
Build the registration arguments for every ability this plugin provides.
jpkcom_acf_jobs_get_ability_definitions() : array<string|int, mixed>
Touches no registry and reads no WordPress state beyond __() and the jpkcom_acf_jobs_ability_meta filter, which is what lets the CI harness assert these arrays without a WordPress installation. Not free of side effects, though: __() and the three meta calls each fire apply_filters(), so third-party callbacks run whenever this is called.
Every output property is optional. A theme may replace includes/acf-field_groups.php through the plugin's file override system, after which ACF falls back to the raw meta and several values arrive in a different shape.
Tags
Return values
array<string|int, mixed> —Ability name => wp_register_ability() arguments.
jpkcom_acf_jobs_ability_list_filters_inner()
Execute callback for jpkcom-acf-jobs/list-filters.
jpkcom_acf_jobs_ability_list_filters_inner([mixed $input = null ]) : array<string|int, mixed>|WP_Error
Answers "which values does query-jobs accept, and how much of this site do they actually reach". The job type vocabulary comes from the field definition rather than from stored values, so it is complete even for a type nobody uses today; the attributes come from the term relations, because load_terms makes ACF discard the stored meta and a slug read from that meta may name a term the job no longer carries.
The counts and the visibility summary come from one pass over the listed job IDs rather than from one query per value.
Parameters
- $input : mixed = null
-
Validated ability input. This ability takes none.
Tags
Return values
array<string|int, mixed>|WP_Error —The filter vocabulary, or an error.
jpkcom_acf_jobs_ability_query_jobs_inner()
Execute callback for jpkcom-acf-jobs/query-jobs.
jpkcom_acf_jobs_ability_query_jobs_inner([mixed $input = null ]) : array<string|int, mixed>|WP_Error
Answers "which jobs does this site list, narrowed by these filters" with the same query the site itself runs, plus three things the site never needed:
- A requested filter that normalises to nothing is refused with a 400. The shared builder skips a clause whose value list is empty, so company= ["acme"] would otherwise return every job as a filtered answer. Two further guards stand behind that one, and they answer different questions. Before the argument filter runs, every filter the response is about to CLAIM has to have become a clause — the builder is overridable and a request that never became a clause cannot go missing later. After it runs, the query that will execute has to be the query this ability built: each clause is recorded beforehand and compared by content, so a value, a boundary, a term list, an operator or a key nobody has thought of yet all read the same way — this is not the query that was built, and no answer is given for it. A callback may still add, because the top-level relation of both queries is verified to be AND on the arguments that run and a further conjunct can only narrow.
- A deterministic tiebreaker. ORDER BY meta_value_num DESC, date DESC leaves
ties unresolved and MySQL permutes tied rows per execution — measured,
four fetches of unmodified code produced three orderings, because the
seeded jobs share a post_date. Harmless for the site's own unpaginated
listing; here an unstable sort puts a job on two pages or on none. It is
appended after the builder, never inside it: the builder is shared with
the shortcode and the archive, whose ordering this feature does not
change. The whole sort is then committed across the argument filter —
the featured-first component and the meta_key it needs, the date
direction
filters.orderreports back, and the tiebreaker — so a site may add to the query, but not re-sort what it answers with. - Real totals for a page past the last one. WP_Query::set_found_posts() returns early when posts is empty, so found_posts and max_num_pages stay 0 and a response would claim an empty corpus next to a page number of three.
Per-property defaults are resolved here. Core applies only the top-level
default, and only when the input is exactly null.
Nothing in this function throws for any input, and nothing casts an
unvalidated value: the shared builder casts order to string, which throws
for an object without __toString, and a Throwable out of an ability callback
is an uncaught fatal on the declared 6.9 floor.
Parameters
- $input : mixed = null
-
Validated ability input.
Tags
Return values
array<string|int, mixed>|WP_Error —The result set, or an error.
jpkcom_acf_jobs_ability_job_is_listed()
Ask the site visibility rule whether it returns this one job.
jpkcom_acf_jobs_ability_job_is_listed(int $post_id) : bool
The verdict comes from the rule itself, restricted to a single post ID,
rather than from a second reading of the same meta in PHP. Spec section 5.3
defines listed as whether the job satisfies the visibility rule, and the
visibility rule is jpkcom_acf_jobs_build_job_query_args() — not a paraphrase
of it.
A paraphrase was measured to be wrong, not merely fragile. MariaDB casts a stored job_expiry_date of '2025-11-30 00:00:00' to the DATE 2025-11-30 through the rule's own type => 'DATE' comparison and drops the job, while jpkcom_acf_jobs_normalise_date() refuses that spelling outright and every PHP reading of it concludes "not expired". get-job then reported listed: true for a job query-jobs would not return — two abilities in one feature answering the same question differently. Asking the query removes the entire class rather than that one instance of it: any later change to the rule is reflected here automatically, and no copy of it can drift again.
The jpkcom_acf_jobs_ability_query_args filter is deliberately NOT applied. It exists so a site can shape what query-jobs lists; applying it to a verdict about the site's own rule would let a callback make this answer disagree with the rule it reports on.
Parameters
- $post_id : int
-
Job post ID.
Tags
Return values
bool —True when the site visibility rule returns this job.
jpkcom_acf_jobs_detail_page_renders()
Decide whether a job's own page would render for an anonymous visitor.
jpkcom_acf_jobs_detail_page_renders(int $post_id) : bool
This is the question the detail block depends on, and it is not the same question as "is this job listed". Address, salary, attributes and application data are public only as a side effect of a job's detail page rendering, so for a job that has no such page they were never published to anybody and emitting them to a logged-in subscriber would publish data the site has deliberately never shown. A job that is merely absent from every listing has a page like any other and keeps its detail block.
Three states answer false, and each of them is a redirect in includes/redirects.php:
- job_url is set — :32-86 sends every caller without manage_options to that target with a 307 before single-job.php runs.
- The job has expired — :132-173 sends every caller without edit_post to the job archive with a 307.
- The post carries a password — no public page in any meaningful sense, and the reader's own gate refuses such a job outright.
The job_url test mirrors redirects.php literally: ! empty() on the RAW value, NOT the trimmed value the reader compares. A url of " " is not empty, so the site really does redirect to it, while a reader that trims first concludes the job does not redirect at all and returns everything. That difference is the whole reason this predicate exists as a second, independent decision rather than as a comment on the reader.
The expiry test deliberately does NOT mirror redirects.php literally. redirects.php compares the stored value against a Y-m-d today as strings, and ACF hands out Ymd whenever the field's key reference row is missing — the state includes/wpml-acf-field-keys-fix.php exists to repair — so '20251130' < '2026-01-15' holds only by accident of the same leading digits. The shared normaliser is used instead, which is also what the visibility rule compares.
Nothing here throws for any input, and a false is always the safe answer: the caller may only ever narrow what it emits on the strength of it.
Parameters
- $post_id : int
-
Job post ID.
Tags
Return values
bool —True when a visitor would be served the job's own page.
jpkcom_acf_jobs_ability_get_job_inner()
Execute callback for jpkcom-acf-jobs/get-job.
jpkcom_acf_jobs_ability_get_job_inner([mixed $input = null ]) : array<string|int, mixed>|WP_Error
Answers "everything this site publishes about job N", including the detail
data only its own page carries, and answers it for jobs no listing contains —
that is what separates this ability from a query-jobs of one result. A job
with no job_featured row, or an expired one, is resolvable here with listed
false and the reason; applying the visibility rule as an existence test
instead would leave "why does job 42 appear in no list" unanswerable by any
ability.
Two rules hold this callback together:
- One answer for "absent" and for "not readable". The same code, the same message, the same 404, and the same amount of work, for an ID that names nothing, an ID that names a draft, a page, a revision, a company or a password-protected job, and an ID that is not a positive integer. Anything else — a distinct message, an extra lookup, a log line — turns this ability into a way for every logged-in subscriber to enumerate which post IDs a site holds. Only the shape of the CALL is answered precisely: a request that names no id at all gets a 400 naming the parameter, which says nothing about any ID.
- The detail-page rule is re-decided here, not inherited. The reader is called with $full = true and applies the rule itself, but includes/jobs-data.php is resolved through the plugin's file override chain and a site may replace it outright. jpkcom_acf_jobs_detail_page_renders() is therefore re-run over the result. It can only ever remove the detail block, never add one, so this is a narrowing and never a disclosure.
Parameters
- $input : mixed = null
-
Validated ability input.
Tags
Return values
array<string|int, mixed>|WP_Error —The job record, or an error.
jpkcom_acf_jobs_register_ability_category()
Register the shared JPKCom content ability category.
jpkcom_acf_jobs_register_ability_category() : void
Defensive on purpose. Categories are global and first-wins, the loser of a collision gets a silent null, and an ability registered into an unregistered category is not registered at all. jpkcom-post-filter ships the same slug, so on any site running both plugins one of the two always loses the race.
Tags
jpkcom_acf_jobs_register_abilities()
Register every ability this plugin provides.
jpkcom_acf_jobs_register_abilities() : void
wp_register_ability() returns null on every failure path and reports only through _doing_it_wrong(), which is silent in production, so each result is checked rather than assumed.
Tags
jpkcom_acf_jobs_ability_boundary()
Run an ability body and convert any Throwable into a WP_Error
jpkcom_acf_jobs_ability_boundary(callable $body, string $ability) : array<string, mixed>|WP_Error
The declared floor is WordPress 6.9, which has no Throwable-to-WP_Error wrapper - that landed in 7.0 - so an exception escaping a callback there is an uncaught fatal: a blank 500 with no body, no code and nothing a client can act on, triggerable by any logged-in subscriber. This file's own docblock already promises that every callback returns a WP_Error rather than throwing. Until this function existed, that promise covered the plugin's own arithmetic and not the reads.
It is a boundary rather than a set of shape checks on purpose. The throw happens INSIDE ACF while it reads - acf_maybe_get() for checkbox and select, acf_field_flexible_content->load_value() for layout content - so nothing on this side of the call can inspect the value first, the file's "read unformatted" rule does not reach the load path, and the set of shapes ACF cannot tolerate belongs to ACF and changes with it. An enumeration of fields or of corrupt shapes would be the same mistake the query post-condition made four times before it was replaced by one rule that needs no list.
The two paths that can degrade instead of failing do so before reaching here: query-jobs skips the unreadable job and counts it, and get-job answers as it does for an id that resolves to nothing. This catches what neither foresaw.
The message is deliberately generic. The exception text used to reach every logged-in subscriber over REST and every MCP client as an isError block, and a raw PHP engine string is neither something a caller can act on nor something a site owner wants published; it goes to the log instead.
Parameters
- $body : callable
-
The ability body to run.
- $ability : string
-
Ability name, for the log line.
Tags
Return values
array<string, mixed>|WP_Error —The body's result, or an error.
jpkcom_acf_jobs_ability_list_filters()
list-filters, behind the callback boundary
jpkcom_acf_jobs_ability_list_filters([mixed $input = null ]) : array<string, mixed>|WP_Error
Parameters
- $input : mixed = null
-
Validated ability input.
Tags
Return values
array<string, mixed>|WP_Error —The vocabulary, or an error.
jpkcom_acf_jobs_ability_query_jobs()
query-jobs, behind the callback boundary
jpkcom_acf_jobs_ability_query_jobs([mixed $input = null ]) : array<string, mixed>|WP_Error
Parameters
- $input : mixed = null
-
Validated ability input.
Tags
Return values
array<string, mixed>|WP_Error —The listing, or an error.
jpkcom_acf_jobs_ability_get_job()
get-job, behind the callback boundary
jpkcom_acf_jobs_ability_get_job([mixed $input = null ]) : array<string, mixed>|WP_Error
Parameters
- $input : mixed = null
-
Validated ability input.
Tags
Return values
array<string, mixed>|WP_Error —The record, or an error.