Summary
§9.1.1 types bindings.pattern as a glob, and §5.12.6 clause 1 makes matching against it a MUST. Only apcore-python implements a glob. apcore-rust and apcore-typescript implement two different suffix matches specialised for the default value, and neither is a subset of the other — so all three agree on "*.binding.yaml" and pairwise disagree on almost everything else.
Nothing is broken today, because the only value in use is the default. That is what makes it worth filing now rather than after a configuration declares something else.
This is the shape of #112 and of aiperceivable/apcore-toolkit#18: every implementation agrees with every other on the one value anyone has tried, and all three agree with a specification that does not describe what they do.
Where the spec says "glob"
| Location |
Text |
§9.1.1 (protocol-spec.md:5771) |
bindings.pattern … glob pattern … Binding file matching pattern |
§9.2.1 (protocol-spec.md:5828) |
"It is a glob matched against filenames within bindings.dir" |
schemas/defaults.schema.json:176 |
"Default glob binding files must match within dir" |
§5.12.6 clause 1 (protocol-spec.md:2998) |
"it MUST match candidate files in that directory against bindings.pattern" |
The three implementations
| SDK |
Site |
Predicate |
| Python |
src/apcore/bindings.py:303 |
sorted(p.glob(resolved_pattern)) — a real glob |
| Rust |
src/bindings.rs:589 |
pattern.strip_prefix('*').unwrap_or(pattern), then name.ends_with(suffix) |
| TypeScript |
src/bindings.ts:225 |
actualPattern.replace('*', ''), then f.endsWith(suffix) |
The TypeScript site carries the comment // Simple glob matching for *.binding.yaml, which states the scope accurately: it was written for the default, not for the type the spec declares.
Measured divergence
Directory contents: a.binding.yaml, api-v1.binding.yaml, data1.yaml, users.binding.yaml, zab.yaml.
Each column was produced by executing that SDK's own predicate in its own runtime — Python by calling pathlib.Path.glob (which is line 303), Rust and TypeScript by transcribing the filter expressions verbatim and running them under rustc and node, so strip_prefix vs replace semantics are exercised rather than assumed.
bindings.pattern |
Python |
Rust |
TypeScript |
Agree? |
*.binding.yaml (default) |
3 files |
3 files |
3 files |
✅ |
data*.yaml |
data1.yaml |
— |
— |
❌ |
api-*.binding.yaml |
api-v1.binding.yaml |
— |
— |
❌ |
?.binding.yaml |
a.binding.yaml |
— |
— |
❌ |
[ab].binding.yaml |
a.binding.yaml |
— |
— |
❌ |
a*b.yaml |
— |
— |
zab.yaml |
❌ |
Two rows are worth reading closely.
data*.yaml is the ordinary case — a glob with the star anywhere but the front. Python matches; the other two cannot, because a pattern that does not begin with * survives both transformations unchanged and is then compared with ends_with, which no filename satisfies.
a*b.yaml → zab.yaml is the one that shows Rust and TypeScript also disagree with each other, and that TypeScript's result is wrong rather than merely narrow. strip_prefix('*') removes a star only at position 0, so Rust keeps a*b.yaml and matches nothing. replace('*', '') removes the first star wherever it sits, so TypeScript reduces the pattern to ab.yaml and matches zab.yaml — a file both other SDKs reject, and one a glob cannot match at all, since a*b.yaml is anchored at the start.
So the divergence is not "one strict implementation and two lenient ones". It is three implementations with no ordering between them.
Why the conformance corpus does not catch it
conformance/fixtures/bindings_dir_resolution.json is not silent on pattern — it has two cases, and one of them (config_file_pattern_is_honoured) deliberately uses a non-default value with a distinct-module-id decoy, precisely so that an implementation ignoring the configured pattern is visible.
The value it uses is *.bind.yaml. Running the fixture's own scenario through all three predicates:
files: greet.bind.yaml, decoy.binding.yaml pattern: *.bind.yaml
Python : ['greet.bind.yaml']
Rust : ["greet.bind.yaml"]
TypeScript: ["greet.bind.yaml"]
*.bind.yaml is leading-star-plus-literal-suffix — the one family on which a glob, a leading-star strip, and a first-star removal all coincide. The case is well constructed for the question it asks (is the configured pattern honoured at all?) and cannot answer the question this issue raises (does honouring it mean the same thing in three SDKs?).
That is the more useful finding here: the gap is not missing coverage, it is coverage whose only non-default value is drawn from the family where the implementations converge.
Relationship to #114
#114 closed the "key reaches no consumer" half and noted in passing that "bindings.pattern is in the same position — the pattern default lives in the loader signature rather than being read from config." Resolving that is what makes this reachable: before it, no configured pattern arrived at the predicate, so the predicate's semantics could not matter. Now load_binding_dir_with_config resolves the key in all three SDKs and hands it to three different matchers.
Proposed resolution
Two decisions, in order.
1. Decide what bindings.pattern actually is. The spec says "glob" in three places, but the only implementation that delivers one is the one whose standard library provides it free. Two options, and the second is the one I would take:
- (a) Full glob everywhere. Matches what the spec already claims. Costs Rust and TypeScript a real matcher, including the parts languages disagree on most — character classes (
[a-z], and the two incompatible negation spellings [!x] / [^x]), and whether ? and * cross a .. That is a lot of surface for a key whose realistic use is picking a filename suffix.
- (b) Specify a named subset and stop calling it a glob.
* and ? only; every other character literal, [ and { included. Roughly twenty lines per SDK, no dependency, and it removes the four rows above at once because there is nothing left to interpret differently.
2. Pin it with fixture cases that can fail. Whichever is chosen, bindings_dir_resolution.json needs at least one pattern from outside the leading-star family — data*.yaml alone separates all three implementations today. A case per divergent row above would be better.
Worth stating explicitly, since it is easy to add a case that looks like coverage and is not: a case is only useful here if it fails against at least one current implementation. *.bind.yaml passes against all three.
Related
apcore-toolkit hit the same gap one layer down (aiperceivable/apcore-toolkit#18: its BindingLoader.load had no pattern parameter at all). It resolved it by specifying option (b) — * and ? only, matched against the filename, with the matching algorithm written into the spec as pseudocode rather than only the syntax — and pinning it with a 43-case fixture. It deliberately did not inherit apcore's behaviour, because there was no single behaviour to inherit. If apcore takes (b), the two layers converge; if it takes (a), the toolkit's narrower subset stays a documented, deliberate difference rather than an accidental one.
Summary
§9.1.1 types
bindings.patternas a glob, and §5.12.6 clause 1 makes matching against it a MUST. Onlyapcore-pythonimplements a glob.apcore-rustandapcore-typescriptimplement two different suffix matches specialised for the default value, and neither is a subset of the other — so all three agree on"*.binding.yaml"and pairwise disagree on almost everything else.Nothing is broken today, because the only value in use is the default. That is what makes it worth filing now rather than after a configuration declares something else.
This is the shape of #112 and of aiperceivable/apcore-toolkit#18: every implementation agrees with every other on the one value anyone has tried, and all three agree with a specification that does not describe what they do.
Where the spec says "glob"
protocol-spec.md:5771)bindings.pattern… glob pattern … Binding file matching patternprotocol-spec.md:5828)bindings.dir"schemas/defaults.schema.json:176dir"protocol-spec.md:2998)bindings.pattern"The three implementations
src/apcore/bindings.py:303sorted(p.glob(resolved_pattern))— a real globsrc/bindings.rs:589pattern.strip_prefix('*').unwrap_or(pattern), thenname.ends_with(suffix)src/bindings.ts:225actualPattern.replace('*', ''), thenf.endsWith(suffix)The TypeScript site carries the comment
// Simple glob matching for *.binding.yaml, which states the scope accurately: it was written for the default, not for the type the spec declares.Measured divergence
Directory contents:
a.binding.yaml,api-v1.binding.yaml,data1.yaml,users.binding.yaml,zab.yaml.Each column was produced by executing that SDK's own predicate in its own runtime — Python by calling
pathlib.Path.glob(which is line 303), Rust and TypeScript by transcribing the filter expressions verbatim and running them underrustcandnode, sostrip_prefixvsreplacesemantics are exercised rather than assumed.bindings.pattern*.binding.yaml(default)data*.yamldata1.yamlapi-*.binding.yamlapi-v1.binding.yaml?.binding.yamla.binding.yaml[ab].binding.yamla.binding.yamla*b.yamlzab.yamlTwo rows are worth reading closely.
data*.yamlis the ordinary case — a glob with the star anywhere but the front. Python matches; the other two cannot, because a pattern that does not begin with*survives both transformations unchanged and is then compared withends_with, which no filename satisfies.a*b.yaml→zab.yamlis the one that shows Rust and TypeScript also disagree with each other, and that TypeScript's result is wrong rather than merely narrow.strip_prefix('*')removes a star only at position 0, so Rust keepsa*b.yamland matches nothing.replace('*', '')removes the first star wherever it sits, so TypeScript reduces the pattern toab.yamland matcheszab.yaml— a file both other SDKs reject, and one a glob cannot match at all, sincea*b.yamlis anchored at the start.So the divergence is not "one strict implementation and two lenient ones". It is three implementations with no ordering between them.
Why the conformance corpus does not catch it
conformance/fixtures/bindings_dir_resolution.jsonis not silent onpattern— it has two cases, and one of them (config_file_pattern_is_honoured) deliberately uses a non-default value with a distinct-module-id decoy, precisely so that an implementation ignoring the configured pattern is visible.The value it uses is
*.bind.yaml. Running the fixture's own scenario through all three predicates:*.bind.yamlis leading-star-plus-literal-suffix — the one family on which a glob, a leading-star strip, and a first-star removal all coincide. The case is well constructed for the question it asks (is the configured pattern honoured at all?) and cannot answer the question this issue raises (does honouring it mean the same thing in three SDKs?).That is the more useful finding here: the gap is not missing coverage, it is coverage whose only non-default value is drawn from the family where the implementations converge.
Relationship to #114
#114 closed the "key reaches no consumer" half and noted in passing that "
bindings.patternis in the same position — thepatterndefault lives in the loader signature rather than being read from config." Resolving that is what makes this reachable: before it, no configured pattern arrived at the predicate, so the predicate's semantics could not matter. Nowload_binding_dir_with_configresolves the key in all three SDKs and hands it to three different matchers.Proposed resolution
Two decisions, in order.
1. Decide what
bindings.patternactually is. The spec says "glob" in three places, but the only implementation that delivers one is the one whose standard library provides it free. Two options, and the second is the one I would take:[a-z], and the two incompatible negation spellings[!x]/[^x]), and whether?and*cross a.. That is a lot of surface for a key whose realistic use is picking a filename suffix.*and?only; every other character literal,[and{included. Roughly twenty lines per SDK, no dependency, and it removes the four rows above at once because there is nothing left to interpret differently.2. Pin it with fixture cases that can fail. Whichever is chosen,
bindings_dir_resolution.jsonneeds at least one pattern from outside the leading-star family —data*.yamlalone separates all three implementations today. A case per divergent row above would be better.Worth stating explicitly, since it is easy to add a case that looks like coverage and is not: a case is only useful here if it fails against at least one current implementation.
*.bind.yamlpasses against all three.Related
apcore-toolkithit the same gap one layer down (aiperceivable/apcore-toolkit#18: itsBindingLoader.loadhad nopatternparameter at all). It resolved it by specifying option (b) —*and?only, matched against the filename, with the matching algorithm written into the spec as pseudocode rather than only the syntax — and pinning it with a 43-case fixture. It deliberately did not inherit apcore's behaviour, because there was no single behaviour to inherit. If apcore takes (b), the two layers converge; if it takes (a), the toolkit's narrower subset stays a documented, deliberate difference rather than an accidental one.