Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 0 additions & 1 deletion personas/qa-lead/persona.yml
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,6 @@ owner: petry-projects/org-leads
# notification_setting: notifications_disabled (routes a webhook, not a page)
address:
handle: petry-projects/qa-lead
Comment thread
don-petry marked this conversation as resolved.
Resolved
aliases: []

definition:
layers:
Expand Down
67 changes: 14 additions & 53 deletions personas/validate-personas.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,20 @@
schema cannot express on its own:

* `id` == the persona directory name == `canary.agent`
* `address.handle`'s team slug == `id` (so a mention routes by prefix-strip)
* `address` handles/aliases are unique across ALL personas (no two roles
answer to the same mention)
* `address.handle`'s team slug == `id` (so a mention routes by prefix-strip).
This makes handle uniqueness a THEOREM rather than a check: the schema pins
the org (^petry-projects/...), slug == id, id == the persona directory name,
and directory names are unique — so two personas cannot claim the same
handle. There is deliberately no cross-file uniqueness check.

Scope of that claim: it covers `address.handle`, which is the only thing the
mention router resolves. `address.aliases` shared the handle namespace and
was NOT covered by it — which is why a uniqueness check existed while
aliases did. Aliases are being removed from the addressing contract
(petry-projects/.github#755 finding 1); this repo has already dropped every
use, and the companion PR drops the field from the schema. Until that lands
the schema still PERMITS an alias, so re-adding one here would reopen the
namespace this theorem does not cover. Do not.
* every `definition.layers[].path` exists on disk
* a `framework-agent` layer's `framework.vendor_pin` actually appears in the
referenced `frameworks/<name>/VENDOR.md` (pin ↔ vendored version agree)
Expand Down Expand Up @@ -140,14 +151,6 @@ def check_invariants(manifest: dict, manifest_path: Path, repo_root: Path,
if slug != pid:
fail(f"{manifest_path}: address.handle '{address['handle']}' team slug "
f"'{slug}' != id '{pid}' (the handle must address the role by its id)")
# Alias shape is checked here, not in check_handles_unique, so that by the
# time that runs (after EVERY manifest has cleared this function) it can
# treat handles and aliases as well-formed strings.
aliases = address.get("aliases", [])
if not isinstance(aliases, list):
fail(f"{manifest_path}: address.aliases must be a list, got {aliases!r}")
for i, alias in enumerate(aliases):
handle_slug(manifest_path, f"address.aliases[{i}]", alias)

for i, layer in enumerate(manifest["definition"]["layers"]):
lpath = repo_root / layer["path"]
Expand Down Expand Up @@ -176,44 +179,6 @@ def check_invariants(manifest: dict, manifest_path: Path, repo_root: Path,
f"agents.{pid} entry exists in {REGISTRY_PATH_IN_REPO} (register once)")


def check_handles_unique(loaded: list[tuple[Path, dict]]) -> None:
"""No two personas may answer to the same mention.

Cross-file, so JSON Schema cannot see it. A collision here is not cosmetic:
the mention router resolves a handle to exactly one persona, so two claimants
means either a silently-dropped mention or both firing on one comment.
Aliases share the namespace with handles — an alias kept from a rename must
not collide with a live role.

Runs only after every manifest has passed check_invariants, which has already
established that each handle/alias is a well-formed 'org/team-slug' string.
"""
claims: dict[str, tuple[Path, str]] = {}
for manifest_path, manifest in loaded:
address = manifest.get("address")
if address is None:
continue
raw_handle = address.get("handle")
if not isinstance(raw_handle, str):
fail(f"{manifest_path}: address.handle must be a string, got {raw_handle!r}")
aliases = address.get("aliases", [])
if not isinstance(aliases, list):
fail(f"{manifest_path}: address.aliases must be a list, got {aliases!r}")
entries = [("address.handle", raw_handle)]
entries += [(f"address.aliases[{i}]", a)
for i, a in enumerate(aliases)]
for field, handle in entries:
if not isinstance(handle, str):
fail(f"{manifest_path}: {field} must be a string, got {handle!r}")
key = handle.lower()
if key in claims:
prior_path, prior_field = claims[key]
fail(f"{manifest_path}: {field} '{handle}' is already claimed by "
f"{prior_path} ({prior_field}) — a mention must resolve to "
f"exactly one persona")
claims[key] = (manifest_path, field)


def main() -> None:
ap = argparse.ArgumentParser(description="Validate persona manifests.")
ap.add_argument("root", nargs="?", default="personas",
Expand Down Expand Up @@ -246,7 +211,6 @@ def main() -> None:
jsonschema.Draft202012Validator.check_schema(schema)
validator = jsonschema.Draft202012Validator(schema)

loaded: list[tuple[Path, dict]] = []
for manifest_path in manifests:
try:
manifest = yaml.safe_load(manifest_path.read_text(encoding="utf-8"))
Expand All @@ -257,9 +221,6 @@ def main() -> None:
loc = "/".join(str(p) for p in error.absolute_path) or "<root>"
fail(f"{manifest_path}: schema violation at {loc}: {error.message}")
check_invariants(manifest, manifest_path, repo_root, args.registry)
loaded.append((manifest_path, manifest))

check_handles_unique(loaded)

print(f"OK: {len(manifests)} persona manifest(s) valid, invariants hold.")

Expand Down
44 changes: 4 additions & 40 deletions tests/test_validate_personas.bats
Original file line number Diff line number Diff line change
Expand Up @@ -165,15 +165,10 @@ YAML
# are deliberately NOT covered here — they need the network, and this suite is
# hermetic. Those are the `verify-persona-teams` CI job's job.

# add_address <persona-dir> <handle> [alias...] — append an address block.
# add_address <persona-dir> <handle> — append an address block.
add_address() {
local dir="$1" handle="$2"; shift 2
{
printf 'address:\n handle: %s\n aliases: [' "$handle"
local sep="" a
for a in "$@"; do printf '%s%s' "$sep" "$a"; sep=", "; done
printf ']\n'
} >>"$TMP/personas/$dir/persona.yml"
local dir="$1" handle="$2"
printf 'address:\n handle: %s\n' "$handle" >>"$TMP/personas/$dir/persona.yml"
}

# clone_persona <new-id> — a second, self-contained fixture persona.
Expand Down Expand Up @@ -202,24 +197,7 @@ clone_persona() {
[[ "$output" == *"!= id 'demo'"* ]]
}

@test "validate-personas rejects two personas claiming the same handle" {
add_address demo petry-projects/demo
clone_persona rival
# rival's own handle is fine; its ALIAS poaches demo's handle.
add_address rival petry-projects/rival petry-projects/demo
run python3 "$VALIDATOR" "$TMP/personas" --schema "$TMP/schema.json"
[ "$status" -ne 0 ]
[[ "$output" == *"already claimed"* ]]
}

@test "validate-personas treats handle collisions case-insensitively" {
add_address demo petry-projects/demo
clone_persona rival
add_address rival petry-projects/rival PETRY-PROJECTS/Demo
run python3 "$VALIDATOR" "$TMP/personas" --schema "$TMP/schema.json"
[ "$status" -ne 0 ]
[[ "$output" == *"already claimed"* ]]
}

@test "validate-personas accepts distinct handles across personas" {
add_address demo petry-projects/demo
Expand Down Expand Up @@ -249,25 +227,11 @@ clone_persona() {
}

@test "validate-personas reports a non-string handle" {
printf 'address:\n handle: 42\n aliases: []\n' >>"$TMP/personas/demo/persona.yml"
printf 'address:\n handle: 42\n' >>"$TMP/personas/demo/persona.yml"
run python3 "$VALIDATOR" "$TMP/personas" --schema "$TMP/schema.json"
[ "$status" -ne 0 ]
[[ "$output" == *"org/team-slug"* ]]
[[ "$output" != *"Traceback"* ]]
}

@test "validate-personas reports a malformed alias" {
add_address demo petry-projects/demo bogus-no-slash
run python3 "$VALIDATOR" "$TMP/personas" --schema "$TMP/schema.json"
[ "$status" -ne 0 ]
[[ "$output" == *"address.aliases[0]"* ]]
[[ "$output" != *"Traceback"* ]]
}

@test "validate-personas reports a non-list aliases" {
printf 'address:\n handle: petry-projects/demo\n aliases: nope\n' >>"$TMP/personas/demo/persona.yml"
run python3 "$VALIDATOR" "$TMP/personas" --schema "$TMP/schema.json"
[ "$status" -ne 0 ]
[[ "$output" == *"must be a list"* ]]
[[ "$output" != *"Traceback"* ]]
}
2 changes: 1 addition & 1 deletion tests/verify_persona_teams.bats
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@ make_persona() {
{
printf 'id: %s\nname: %s\n' "$id" "$id"
if [ -n "$handle" ]; then
printf 'address:\n handle: %s\n aliases: []\n' "$handle"
printf 'address:\n handle: %s\n' "$handle"
fi
} > "$TMP/personas/$id/persona.yml"
}
Expand Down
Loading