Skip to content

[oss-candidate] blueprints: re-apply when a file referenced by !File changes - #1

Open
askalf wants to merge 11 commits into
mainfrom
fix/blueprint-file-tag-hash
Open

askalf wants to merge 11 commits into
mainfrom
fix/blueprint-file-tag-hash

Conversation

@askalf

@askalf askalf commented Sep 21, 2026

Copy link
Copy Markdown
Collaborator

Summary

  • A blueprint's change-detection hash covered only the blueprint .yaml file, not the files it references through !File tags. Rotating a Kubernetes Secret mounted into the container therefore left the hash identical, so check_blueprint_v1_file never dispatched apply_blueprint and authentik kept serving the old value.
  • Adds blueprint_hash(content) in authentik/blueprints/v1/tasks.py: the blueprint's own content, then the contents of every file it references through a !File tag, each folded in as a fixed-length sha512 digest.
  • Adds iter_file_tags(value, ancestors), which walks the loaded blueprint and finds !File tags including ones passed as arguments to another tag (!Format ["client-%s", !File /x]) — a flat scan of the top level would miss those. The walk skips a node already on the path from the root, because YAML anchors let a node contain itself; a node merely shared by two routes is not its own ancestor and is still walked from each, so no existing blueprint's digest moves.
  • Skips any !File whose path is not a usable string, reading the attribute with getattr(tag, "path", None). File.__init__ assigns self.path only for scalar and sequence nodes, so a !File built from a mapping node has no path attribute at all, and a sequence-node path built from a nested tag (!File [!Env P, default]) is a tag object. Resolving those needs an entry and a blueprint the hashing path does not have, and their own content is already in the digest.
  • Both places that compute the hash call it (blueprints_find, which produces the value compared against the DB, and apply_blueprint, which stores it). They must agree or every discovery run would re-apply forever; that symmetry is why this is one shared helper.
  • A blueprint with no !File tag hashes exactly as before when it has LF line endings, so the two literal sha512 digests pinned by the pre-existing tests do not move.
  • One deliberate side effect, now pinned by test_valid_crlf: discovery used to hash the raw file bytes while apply_blueprint hashed the text-mode read that retrieve_file() returns, so a blueprint saved with CRLF line endings never matched its own last_applied_hash and was re-applied on every discovery run. Both sides now hash the same text, so they agree.

Head of this branch: 18479bddc34eabab26995a951d5467d749339d9f. The production file authentik/blueprints/v1/tasks.py is byte-identical from 1c67effb1 through 3ad347d1b, e0bdb02a4, c4c91590a and 2453510bc; the tenth commit a3c2c6e81 changes only its comments and docstrings (the module's AST with docstrings blanked is identical before and after, and every statement is unchanged), and the eleventh 18479bddc is a test. Transcripts below that name 3ad347d1b, e0bdb02a4 or c4c91590a were run against that identical source and are labelled with the sha they were captured at. The probe transcripts in ## Repro and ## Test evidence were re-run at this head. The issue's own scenario, both arms, run at this head:

$ python repro.py /agent-workspace/oss/authentik-base-arm   # base 38fca6b34, no fix
# arm: base (/agent-workspace/oss/authentik-base-arm)
FAIL: test_file_tag_content_changed - 0904e2ed0ad1aaad vs 0904e2ed0ad1aaad
FAIL: test_file_tag_content_changed_nested - f76c17ed9decdfa2 vs f76c17ed9decdfa2
FAIL: test_file_tag_created - 84daa043565dac0a vs 84daa043565dac0a
PASS: test_file_tag_content_unchanged (control)
PASS: test_file_tag_missing (control)
PASS: test_file_tag_path_from_tag (control)
PASS: plain blueprint hash unchanged (control)
PASS: invalid yaml hash unchanged (control)

5 passed, 3 failed

$ python repro.py /agent-workspace/oss/authentik-wt-verify3   # e0bdb02a4; same result re-run at 18479bddc
# arm: fixed (/agent-workspace/oss/authentik-wt-verify3)
PASS: test_file_tag_content_changed - 9f47abbce2e09297 vs ed1236ca39d98b53
PASS: test_file_tag_content_changed_nested - 6e48d3a19656a19d vs 3b2a1da981850ae7
PASS: test_file_tag_created - 9fd1cd0d5ee854b5 vs 8c67ee9d122c7e9c
PASS: test_file_tag_content_unchanged (control)
PASS: test_file_tag_missing (control)
PASS: test_file_tag_path_from_tag (control)
PASS: plain blueprint hash unchanged (control)
PASS: invalid yaml hash unchanged (control)

8 passed, 0 failed

On base the three discriminating cases print the same digest twice — that identity is the bug.

Commits three to five on this branch answer two rounds of adversarial verification, the seventh (e0bdb02a4) adds the three tests a third round asked for, the eighth (c4c91590a) folds the accumulated tests into the surrounding file's idiom at a gating review's request, the ninth (2453510bc) adds the two discovery inputs a fourth verification round found unpinned, and the tenth and eleventh (a3c2c6e81, 18479bddc) answer a second-opinion review: comments trimmed to the runtime contract, and a CRLF discovery/apply test (round one, round two); see ## Rework at the end of this body for exactly what changed in each and why one line of the fix was deleted rather than tested.

Upstream

  • Repository: goauthentik/authentik, default branch main
  • Base sha: 38fca6b34951852b53db575394a5fb0c546cdcd3
  • Issue: #26289 — "Blueprints do not re-apply when a referenced Kubernetes Secret changes" (open, labels enhancement/triage, reported against 2026.8.2, Kubernetes, no linked PR)
  • Files: authentik/blueprints/v1/tasks.py (blueprints_find, apply_blueprint, new blueprint_hash and iter_file_tags); authentik/blueprints/tests/test_v1_tasks.py
  • Related, unchanged: authentik/blueprints/v1/common.py (class File, line 278; registered as !File at line 771)
  • Diff against base at this head: 2 files, 248 insertions, 4 deletions (tasks.py +56/-4, test_v1_tasks.py +192/-0) — purely additive apart from the two call sites.
  • Test file: 5 pre-existing tests, 10 added by this series, 15 total.

Bug

A blueprint may pull values out of files at apply time:

attrs:
  client_secret: !File /blueprints/mounted/secret-app-secret/client_secret

blueprints_find() computed file_hash = sha512(path.read_bytes()).hexdigest() — the bytes of the blueprint file only. check_blueprint_v1_file() re-applies a blueprint exactly when instance.last_applied_hash != blueprint.hash. The referenced file's contents are not in that hash, so when a mounted Kubernetes Secret is rotated the blueprint's meaning changes while its hash does not, and the hourly blueprints_v1_discover schedule (authentik/blueprints/apps.py:170) skips it forever. The watchdog path does not cover it either: BlueprintEventHandler.on_modified only matches files whose path equals an instance's own path, so writes to the secret volume are ignored.

Blast radius: any Kubernetes deployment using the Helm chart's blueprints.secrets mechanism, i.e. the documented way to keep credentials out of blueprint files. Secret rotation silently does not take effect — the operator sees a successful rotation at the Kubernetes level while authentik keeps using the previous value, and only a manual re-apply fixes it (as the reporter found). Non-Kubernetes users of !File on any file that changes out of band are affected in the same way.

Repro

Requires only a checkout; no database. harness.py configures a minimal Django settings module, stubs the three authentik.blueprints.v1.common imports that need the app registry (each used only for isinstance checks on paths the hashing code never reaches), imports the real File tag and BlueprintLoader, and executes the hashing code from the real tasks.py source. Against a base checkout there is no blueprint_hash, so it lifts base's own hash expression verbatim via regex — the same expression base compares against last_applied_hash.

$ git worktree add --detach /agent-workspace/oss/authentik-base-arm 38fca6b34951852b53db575394a5fb0c546cdcd3
$ python repro.py /agent-workspace/oss/authentik-base-arm
# arm: base (/agent-workspace/oss/authentik-base-arm)
FAIL: test_file_tag_content_changed — e48c7403f1b1c736 vs e48c7403f1b1c736
FAIL: test_file_tag_content_changed_nested — 557993adc1f25ff6 vs 557993adc1f25ff6
FAIL: test_file_tag_created — 7fbefff1993ecc2e vs 7fbefff1993ecc2e
PASS: test_file_tag_content_unchanged (control)
PASS: test_file_tag_missing (control)
PASS: test_file_tag_path_from_tag (control)
PASS: plain blueprint hash unchanged (control)
PASS: invalid yaml hash unchanged (control)

5 passed, 3 failed

repro.py mirrors the seven non-ORM tests from the first two commits, eight checks in total (the extra two guard the pre-existing literal digests). repro-rework.py mirrors the four added by the third commit and repro-rework2.py the five added by the fourth; both three-arm transcripts are under ## Test evidence. Files: /agent-output/oss/authentik/repro.py, repro-rework.py, repro-rework2.py, harness.py, probe-boundaries.py, probe-blastradius.py, probe-r23.py, probe-r23c.py, probe-mutants-rework.py, probe-recursion.py, probe-anchors.py, probe-alias-routes.py. Transcripts: base-arm.txt, unguarded-arm.txt, head-arm.txt, boundaries.txt, rework-transcript.txt, rw2-repro.txt, rw2-anchors.txt, rw2-alias-routes.txt, rw2-lint.txt.

Fix

blueprint_hash(content) replaces the two bare sha512(...) calls:

def iter_file_tags(value: Any, ancestors: frozenset[int] = frozenset()) -> Generator[File]:
    """Find all `!File` tags in a loaded blueprint, including tags used as arguments
    of other tags. A node is not descended into again below itself; a node reached by
    several routes is visited once per route."""
    if id(value) in ancestors:
        return
    ancestors = ancestors | {id(value)}
    if isinstance(value, File):
        yield value
    if isinstance(value, dict):
        children = value.values()
    elif isinstance(value, list | tuple):
        children = value
    elif isinstance(value, YAMLTag):
        children = vars(value).values()
    else:
        return
    for child in children:
        yield from iter_file_tags(child, ancestors)


def blueprint_hash(content: str) -> str:
    """Hash a blueprint's content and the contents of the files it references with
    `!File` tags"""
    hasher = sha512(content.encode())
    try:
        raw_blueprint = load(content, BlueprintLoader)
    except YAMLError:
        return hasher.hexdigest()
    for tag in iter_file_tags(raw_blueprint):
        # Mapping-node tags have no path; nested tags cannot be resolved here
        path = getattr(tag, "path", None)
        if not isinstance(path, str):
            continue
        try:
            referenced = Path(path).read_bytes()
        except OSError, ValueError:
            # Unreadable references contribute only their blueprint source text
            continue
        hasher.update(sha512(referenced).digest())
    return hasher.hexdigest()

Why this is the minimal correct change:

  • The one wrong thing is the hash's coverage. The trigger (last_applied_hash != blueprint.hash), the schedule, the watchdog and the apply path are all untouched.
  • Hashing must never fail on a blueprint that can be loaded. blueprints_find guards only the load() call, and only for YAMLError; blueprint_hash(content) at tasks.py:203 is unguarded, so any exception it raises aborts the whole rglob loop and every other blueprint stops being discovered. apply_blueprint's except tuple does not contain AttributeError, ValueError or RecursionError either. Every input that parses is therefore either hashed or skipped, never raised on — this is the invariant the third and fourth commits restore, and the the discovery-continues case table tests pin it at the blueprints_find level rather than at the helper.
  • getattr(tag, "path", None) rather than tag.path: a guard cannot protect the expression that evaluates its own operand. path is a class-level annotation on File, which creates no attribute, and __init__ assigns it in two if branches with no else.
  • isinstance(path, str) is a type guard, not a truthiness guard: !File "" has a str path and must still be hashed (R2). A truthiness test would skip it, and on a tag-valued path it raises — mutant M4 below.
  • except OSError, ValueError: Path(...).read_bytes() raises ValueError, not OSError, for a path no syscall can accept. Unreadable file → continue, deliberately not an error: the tag then contributes only its own source text, which is part of content and already hashed. This is a statement about hashing, not about apply: File.resolve() (common.py:293-303) catches only OSError, so for an OSError it falls back to the tag's default, but a ValueError path (null byte, lone surrogate) raises there and is not resolved to the default. Hashing tolerates those inputs; resolution does not, on base or here. The unparenthesised spelling is what black at the version pinned in pyproject.toml:93 produces, and what the repo already carries elsewhere (outposts/controllers/docker.py:76, kubernetes.py:52, enterprise/license.py:111).
  • The walk is bounded by the ancestor chain, not by a global seen-set or a depth cap. A cycle is a node that is its own descendant, so tracking the path from the root is exactly what terminates it. A global seen-set would also suppress a shared but acyclic subtree — an ordinary blueprint reusing one anchor in two places — folding its !File in once where the walk folds it in twice, which silently changes the digest of every such blueprint in the wild and re-applies each one once. A depth cap would reject deeply nested blueprints that are perfectly valid; depth is measured as a control and is unaffected. Both alternatives were built and measured — see ## Rework.
  • Invalid YAML → the content-only digest, preserving base's behaviour for files blueprints_find rejects anyway.
  • blueprints_find now reads the file once into content and reuses it for both the parse and the hash, replacing a second path.read_bytes().
  • No separate digest of the path. The path is a str taken from a ScalarNode.value, so it is determined by the document text that sha512(content.encode()) already covers, although with YAML escapes and quoting it need not appear there literally. Ten input pairs, including escaped and differently quoted paths, gave the same verdict with and without a path digest (mutant M6 killed by nothing) — see ## Rework.
  • Both call sites hash the text-mode read. blueprints_find hashes the content it already read with open(path, encoding="utf-8"), and apply_blueprint hashes instance.retrieve(), which reads the same way (models.py:111). Keeping discovery on path.read_bytes() (the rejected alternative, mutant M7) leaves a CRLF blueprint with a discovery hash that never equals the stored one; test_valid_crlf kills it.

Alternatives rejected — each built as a mutant of the real tasks.py and killed by a named test (probe-mutants-rework.py, probe-recursion-repair.py, transcripts under ## Test evidence):

  • Dereference tag.path directly (M1) — the guard raises on a mapping-node !File. Killed by test_file_tag_unreadable_discovery_continues [path from a mapping].
  • Keep except OSError alone (M2) — a null-byte path escapes as ValueError. Killed by test_file_tag_unreadable_discovery_continues [path no syscall can take].
  • No guard, widen the except to swallow AttributeError/TypeError (M3) — swallows genuine type errors anywhere in the loop, and only after the tag has been part-processed. Killed by test_file_tag_unreadable_discovery_continues [path from a mapping].
  • Truthiness guard instead of isinstance (M4) — skips a valid !File "" and raises TypeError on a tag-valued path. Killed by test_file_tag_unreadable_hash_stable [path from a tag].
  • Flat scan of top-level values for File instances (M5) — misses !File nested inside !Format/!If/!Condition arguments. Killed by test_file_tag_content_changed [direct] [argument of another tag].
  • Unbounded recursion (the fourth commit's own predecessor) — RecursionError on any cyclic anchor, aborting discovery for every blueprint. Killed by test_file_tag_unreadable_discovery_continues [sequence containing itself], test_file_tag_unreadable_discovery_continues [mapping containing itself], test_file_tag_content_changed [direct] [reached through a cycle].
  • A global seen-set instead of the ancestor chain (REPAIR-A) — terminates cycles too, but changes the digest of every blueprint that reuses an anchor. Killed by test_file_tag_hashed_once_per_route [alias], which pins the fold count arithmetically.
  • Fix File.resolve() to resolve a tag path — a real second bug (it calls open(self.path) on the tag object and raises TypeError, which it does not catch), but it is a behaviour change to the apply path and a separate PR. Recorded as a follow-up; this PR only ensures discovery does not crash on it.
  • Store per-file mtimes/hashes in a new model field — a schema migration and new state for a problem the existing hash can express.
  • Keep discovery on sha512 of the raw bytes and fold the references in on top (M7) — discovery and apply then hash different text for any CRLF blueprint, so it re-applies on every run. Killed by test_valid_crlf (rw5-crlf-mutant.txt).
  • Watch the referenced files with the existing watchdog observer — the observer is scoped to blueprints_dir; mounted secret volumes live outside it, and Kubernetes secret updates are atomic symlink swaps the observer would need extra handling for. The hourly discovery run is already the intended reconciliation point.
  • Re-apply every blueprint containing a !File on every discovery — turns an hourly no-op into an hourly write for every such blueprint.

Test evidence

Ten tests added to the existing authentik/blueprints/tests/test_v1_tasks.py (the repo's
convention is to extend the module's test file), plus two shared helpers (write_blueprint,
write_secret). Four of the ten drive several references through subTest, so the twenty-three
distinct inputs below are all exercised and each reports independently. The five
pre-existing tests in the file are untouched; fifteen tests on the branch in total.

The table is one row per input, which is the granularity subTest reports and fails at.

Test Input Kind Base 38fca6b Unguarded 79b0815 2nd commit a2decb5 Unbounded walk 44d6580 Head 18479bd
test_file_tag_content_changed direct discriminating FAIL (same digest twice) PASS PASS PASS PASS
test_file_tag_content_changed argument of another tag discriminating FAIL (same digest twice) PASS PASS PASS PASS
test_file_tag_content_changed reached through a cycle discriminating FAIL (same digest twice) FAIL (RecursionError) PASS
test_file_tag_content_changed reached through an alias discriminating FAIL (same digest twice) PASS PASS
test_file_tag_created discriminating FAIL (same digest twice) PASS PASS PASS PASS
test_file_tag_removed discriminating FAIL (same digest twice) PASS
test_file_tag_contents_swapped discriminating FAIL (same digest twice) PASS
test_file_tag_hashed_once_per_route cycle discriminating FAIL (content-only digest) PASS
test_file_tag_hashed_once_per_route alias discriminating FAIL (no file digest folded in) PASS PASS
test_file_tag_unreadable_discovery_continues sequence containing itself discriminating (discovery) PASS FAIL (scan aborts 1 of 3) PASS
test_file_tag_unreadable_discovery_continues mapping containing itself discriminating (discovery) PASS FAIL (scan aborts 1 of 3) PASS
test_file_tag_unreadable_discovery_continues two anchors containing each other discriminating (discovery) PASS FAIL (RecursionError) FAIL (RecursionError) PASS
test_file_tag_unreadable_discovery_continues path outside the filesystem encoding control PASS FAIL (UnicodeEncodeError) FAIL (UnicodeEncodeError) PASS PASS
test_file_tag_unreadable_discovery_continues path from a mapping control PASS FAIL (AttributeError) FAIL (AttributeError) PASS PASS
test_file_tag_unreadable_discovery_continues path no syscall can take control PASS FAIL (ValueError) FAIL (ValueError) PASS PASS
test_file_tag_content_unchanged control PASS PASS PASS PASS PASS
test_file_tag_unreadable_hash_stable missing file control PASS PASS PASS PASS PASS
test_file_tag_unreadable_hash_stable path from a tag control PASS FAIL (TypeError) PASS PASS PASS
test_file_tag_unreadable_hash_stable path from a mapping control PASS FAIL (AttributeError) FAIL (AttributeError) PASS PASS
test_file_tag_unreadable_hash_stable path no syscall can take control PASS FAIL (ValueError) FAIL (ValueError) PASS PASS
test_file_tag_unreadable_hash_stable deeply nested control PASS PASS PASS
test_file_tag_applied_on_change discriminating, executed only by fork CI (needs a database) PASS (CI jobs 106755883027 / 106755883201 at c4c91590a)
test_valid_crlf CRLF blueprint, no !File, two discovery runs discriminating; database test, its hashing path executed locally by probe-crlf.py FAIL (discovery 15e1a41b… ≠ applied 72405c18…, both runs) PASS locally (both runs agree, 72405c18…); fork CI CI - Main run 35815749504 at 18479bddc (see ## Verification method)

A dash means the arm predates the behaviour the row is about and was not measured there.
This table is the only place the control/discriminating split is recorded: the tests
themselves are named and documented for what the hash is required to do, not for what they
proved about a patch, so nothing in the suite the maintainer keeps refers to this branch's
history.

Two things worth naming in that table. The three cycle-discovery inputs and the deeply-nested
control pass on base, because base does not walk the document at all — they pin a
regression the fix itself introduced, and they fail on the commit they were written against
(44d6580f3), which is the arm that matters for them. The two anchors containing each other
input is the one a parent-only bound (compare against the immediate parent instead of the
whole ancestor chain) does not terminate on; the two self-containing inputs pass under that
mutant, this one raises RecursionError (rv4-new-rows-mutants.txt). The
path outside the filesystem encoding input is a lone high surrogate, which os.fsencode
rejects with UnicodeEncodeError, a ValueError subclass and not an OSError; it is the
second input that reaches the except OSError, ValueError handler through a shape other than
the null byte, and the except OSError mutant (M2) raises on it (rv4-r20b-mutant.txt). And the reached through a cycle
input of test_file_tag_content_changed fails on both base (same digest twice, the
original bug) and the unbounded walk (RecursionError, the regression), for two different
reasons.

The grouping is safe for the failing arms: subTest reports each input separately, so an
input that raises does not hide the ones after it. Measured on this file, a loop whose first
input raises AttributeError and whose second fails an assertion reports errors 1 failures 1 and still runs the third.

The eleventh commit's test, test_valid_crlf, through the real blueprints_find source
and each arm's own apply_blueprint hash expression (probe-crlf.py, rw5-crlf-arms.txt,
rw5-crlf-mutant.txt). The blueprint is written as bytes with CRLF line endings and read back
the way retrieve_file() reads it:

$ python probe-crlf.py /agent-workspace/oss/authentik-base-arm
# arm: base (/agent-workspace/oss/authentik-base-arm)
# arm: authentik-base-arm; apply hashes: sha512(blueprint_content.encode()).hexdigest()
scan 1: CRLF on disk True; discovery 15e1a41b4b30fd69 last_applied_hash 72405c1803c640f4; DIFFER - re-applied
scan 2: CRLF on disk True; discovery 15e1a41b4b30fd69 last_applied_hash 72405c1803c640f4; DIFFER - re-applied
$ python probe-crlf.py /agent-workspace/oss/authentik-wt-1790134762
# arm: fixed (/agent-workspace/oss/authentik-wt-1790134762)
# arm: authentik-wt-1790134762; apply hashes: blueprint_hash(blueprint_content)
scan 1: CRLF on disk True; discovery 72405c1803c640f4 last_applied_hash 72405c1803c640f4; AGREE - not re-applied
scan 2: CRLF on disk True; discovery 72405c1803c640f4 last_applied_hash 72405c1803c640f4; AGREE - not re-applied
$ python probe-crlf.py /agent-workspace/oss/authentik-wt-1790134762 raw-bytes
# arm: fixed (/agent-workspace/oss/authentik-wt-1790134762)
# mutant: raw-bytes discovery
# arm: authentik-wt-1790134762; apply hashes: blueprint_hash(blueprint_content)
scan 1: CRLF on disk True; discovery 15e1a41b4b30fd69 last_applied_hash 72405c1803c640f4; DIFFER - re-applied
scan 2: CRLF on disk True; discovery 15e1a41b4b30fd69 last_applied_hash 72405c1803c640f4; DIFFER - re-applied

test_valid_crlf asserts instance.last_applied_hash == found.hash after each of two
blueprints_discovery.send() runs, then that the file on disk still carries \r\n. On base
the first equality fails (discovery hashes the raw CRLF bytes, apply the LF text). The
raw-bytes mutant, which keeps the new blueprint_hash but feeds discovery the raw bytes as
base did, fails the same way.

The console transcripts below are the output of the standalone probe scripts, which drive
the same inputs without a database. Their case labels are the probes' own and predate the
grouping; the mapping to the tests above is the Input column.

The fourth commit's five tests, three arms, verbatim (rw2-repro.txt):

$ python repro-rework2.py /agent-workspace/oss/authentik-base-arm            # BASE 38fca6b34
# arm: base (/agent-workspace/oss/authentik-base-arm)
FAIL: test_file_tag_cycle_content_changed - e436babd95ce2bbf vs e436babd95ce2bbf
FAIL: test_file_tag_alias_content_changed - 8a94dedd7055656f vs 8a94dedd7055656f
PASS: test_file_tag_cycle_sequence - scan completed, 3 of 3 hashed
PASS: test_file_tag_cycle_mapping - scan completed, 3 of 3 hashed
PASS: test_file_tag_deeply_nested (control)

shared-anchor digest (compare across arms): 8a94dedd7055656f

3 passed, 2 failed

$ python repro-rework2.py /agent-workspace/oss/authentik-wt-rw-1790005753    # 44d6580f3, walk with no bound
# arm: fixed (/agent-workspace/oss/authentik-wt-rw-1790005753)
FAIL: test_file_tag_cycle_content_changed - hashing RAISED RecursionError
PASS: test_file_tag_alias_content_changed - 5e4508957e9e504f vs f575d90e64dda650
FAIL: test_file_tag_cycle_sequence - scan ABORTED after 1 of 3, RecursionError
FAIL: test_file_tag_cycle_mapping - scan ABORTED after 1 of 3, RecursionError
PASS: test_file_tag_deeply_nested (control)

shared-anchor digest (compare across arms): 5e4508957e9e504f

2 passed, 3 failed

$ python repro-rework2.py /agent-workspace/oss/authentik-wt-rw2-1790016231   # HEAD 3ad347d1b
# arm: fixed (/agent-workspace/oss/authentik-wt-rw2-1790016231)
PASS: test_file_tag_cycle_content_changed - cf197cb84606e36f vs 7ef2e1a778cda561
PASS: test_file_tag_alias_content_changed - 5e4508957e9e504f vs f575d90e64dda650
PASS: test_file_tag_cycle_sequence - scan completed, 3 of 3 hashed
PASS: test_file_tag_cycle_mapping - scan completed, 3 of 3 hashed
PASS: test_file_tag_deeply_nested (control)

shared-anchor digest (compare across arms): 5e4508957e9e504f

5 passed, 0 failed

The shared-anchor digest line is the whole argument for the ancestor chain over a seen-set: 5e4508957e9e504f at the unbounded arm and 5e4508957e9e504f at head, i.e. byte-identical. A seen-set repair prints a different value there (913f8f538617c68b vs 0b1f1fbc821d0b2c in the verification's own measurement of the two candidates), which is every aliased blueprint in the wild re-applying once.

The fold count, pinned arithmetically (probe-alias-routes.py, rw2-alias-routes.txt) — the expected digest is computed from the blueprint's own content plus N folds of the referenced file, so the arm is measured against arithmetic rather than against itself:

$ python probe-alias-routes.py /agent-workspace/oss/authentik-base-arm            # BASE
folded 0x: MATCH
folded 1x: -
folded 2x: -
folded 3x: -
two literal tags folded 2x: -

$ python probe-alias-routes.py /agent-workspace/oss/authentik-wt-rw-1790005753    # 44d6580f3
folded 0x: -
folded 1x: -
folded 2x: MATCH
folded 3x: -
two literal tags folded 2x: MATCH

$ python probe-alias-routes.py /agent-workspace/oss/authentik-wt-rw2-1790016231   # HEAD 3ad347d1b
folded 0x: -
folded 1x: -
folded 2x: MATCH
folded 3x: -
two literal tags folded 2x: MATCH

Base folds in nothing (0x), both fixed arms fold in exactly twice, and an aliased tag behaves identically to the same tag written out twice. test_file_tag_hashed_once_per_route [alias] is that measurement as a test.

The third commit's four tests, three arms (rework-transcript.txt). Each is named a control because it passes on base — base never touches tag.path at all — and each fails on the commit it was written against:

$ python repro-rework.py /agent-workspace/oss/authentik-base-arm   # BASE 38fca6b34
# arm: base (/agent-workspace/oss/authentik-base-arm)
PASS: test_file_tag_path_from_mapping (control)
PASS: test_file_tag_path_unopenable (control)
PASS: test_file_tag_path_from_mapping_stable (control)
PASS: test_file_tag_path_unopenable_stable (control)

4 passed, 0 failed

$ python repro-rework.py /agent-workspace/oss/authentik-wt-verify  # PRE-REWORK a2decb52d
# arm: fixed (/agent-workspace/oss/authentik-wt-verify)
FAIL: test_file_tag_path_from_mapping (control) -- raised AttributeError: 'File' object has no attribute 'path'
FAIL: test_file_tag_path_unopenable (control) -- raised ValueError: embedded null byte
FAIL: test_file_tag_path_from_mapping_stable (control) -- raised AttributeError: 'File' object has no attribute 'path'
FAIL: test_file_tag_path_unopenable_stable (control) -- raised ValueError: embedded null byte

0 passed, 4 failed

$ python repro-rework.py /agent-workspace/oss/authentik-wt-rw2-1790016231  # HEAD 3ad347d1b
# arm: fixed (/agent-workspace/oss/authentik-wt-rw2-1790016231)
PASS: test_file_tag_path_from_mapping (control)
PASS: test_file_tag_path_unopenable (control)
PASS: test_file_tag_path_from_mapping_stable (control)
PASS: test_file_tag_path_unopenable_stable (control)

4 passed, 0 failed

The earlier control test_file_tag_unreadable_hash_stable [path from a tag] separates the first commit from the second; its failure on the unguarded arm 79b0815eb (historical transcript, taken at a2decb52d):

$ python repro.py /agent-workspace/oss/authentik-headcommit   # 79b0815eb, fix WITHOUT the guard
# arm: fixed (/agent-workspace/oss/authentik-headcommit)
PASS: test_file_tag_content_changed — 80e41498d5bcada3 vs 2ee6530a647f9cd5
PASS: test_file_tag_content_changed_nested — 0853d612b9578d07 vs 55ca745114c5db2e
PASS: test_file_tag_created — 9abbb90bbaa7d87f vs 0119560e64ede283
PASS: test_file_tag_content_unchanged (control)
PASS: test_file_tag_missing (control)
FAIL: test_file_tag_path_from_tag (control) — raised TypeError: argument should be a str or an os.PathLike object where __fspath__ returns a str, not 'Env'
PASS: plain blueprint hash unchanged (control)
PASS: invalid yaml hash unchanged (control)

7 passed, 1 failed

The stability controls (test_file_tag_content_unchanged, test_file_tag_unreadable_hash_stable [missing file], test_file_tag_unreadable_hash_stable [deeply nested]) pass on every arm by design: they pin that the fix does not make the hash unstable, which would cause a re-apply every hour, so they cannot and must not fail on base.

The blast-radius measurement, which is what makes these controls worth having — three blueprints in a directory, the bad one sorting in the middle, hashed in blueprints_find's own order (probe-blastradius.py, historical transcript at 44d6580f3; the cycle equivalent is the scan lines in repro-rework2.py above):

$ python probe-blastradius.py /agent-workspace/oss/authentik-base-arm         # BASE
mapping-node: scan completed, 3 of 3 blueprints hashed
nul-byte-path: scan completed, 3 of 3 blueprints hashed

$ python probe-blastradius.py /agent-workspace/oss/authentik-wt-verify        # a2decb52d
mapping-node: scan ABORTED after 1 of 3 blueprints -- AttributeError: 'File' object has no attribute 'path'
nul-byte-path: scan ABORTED after 1 of 3 blueprints -- ValueError: embedded null byte

$ python probe-blastradius.py /agent-workspace/oss/authentik-wt-rw-1790005753 # 44d6580f3
mapping-node: scan completed, 3 of 3 blueprints hashed
nul-byte-path: scan completed, 3 of 3 blueprints hashed

Mutants. Each alternative the ## Fix section rejects, built against the real tasks.py and run through the repros (probe-mutants-rework.py, historical transcript at 44d6580f3):

$ python probe-mutants-rework.py /agent-workspace/oss/authentik-rework-scratch

===== M1 dereference tag.path directly (pre-rework guard) =====
  killed by: test_file_tag_path_from_mapping (control), test_file_tag_path_from_mapping_stable (control)

===== M2 except OSError only (pre-rework except) =====
  killed by: test_file_tag_path_unopenable (control), test_file_tag_path_unopenable_stable (control)

===== M3 no guard, swallow AttributeError/TypeError instead =====
  killed by: test_file_tag_path_from_mapping (control), test_file_tag_path_from_mapping_stable (control)

===== M4 truthiness guard instead of isinstance =====
  killed by: test_file_tag_path_from_tag (control) — raised TypeError: argument should be a str or an os.PathLike object where __fspath__ returns a str, not 'Env'

===== M5 flat scan, no recursion into YAMLTag =====
  killed by: test_file_tag_content_changed_nested — 28230cab3dcc2dc4 vs 28230cab3dcc2dc4

===== M6 re-add the removed path digest =====
  *** KILLED BY NOTHING ***

M6 is the control on the deletion: re-adding the path digest changes no test's verdict, which is the direct evidence that the line contributed nothing. Every other mutant is killed by a named test.

The seventh commit's three tests, both arms, mirrored in probe-v3.py (V1, V2) and probe-cycle-once.py, verbatim at this head (rv-v3-base.txt, rv-v3-head.txt, rv-cycle-base.txt, rv-cycle-head.txt). V3 to V6 are measurements the third verification round added; V6 walks a !File nested in every other argument-taking tag:

$ python probe-v3.py /agent-workspace/oss/authentik-base-arm            # BASE 38fca6b34
# arm: base (/agent-workspace/oss/authentik-base-arm)
# probe-v3 arm: base (/agent-workspace/oss/authentik-base-arm)

FAIL: V1 file removed between runs changes hash - 0a721fcf06cf2db1 vs 0a721fcf06cf2db1
FAIL: V2 swapping the contents of two referenced files changes hash - ddb330512c24fffd vs ddb330512c24fffd
V3 CRLF blueprint: discovery hash 72405c1803c640f4, base raw-bytes hash 15e1a41b4b30fd69, apply-side hash 72405c1803c640f4
V3 discovery == apply on this arm: True; discovery == base raw-bytes: False
V4 !File with cyclic-sequence path: a584b706bbef1753e5a0524e
PASS: V4 hash stable
V5 self-anchored !File: NOT LOADABLE (ConstructorError) - parse-time, both arms
FAIL: V6 Env default: rotation changes hash - 41b2028986aa3bf9 vs 41b2028986aa3bf9
FAIL: V6 Context default: rotation changes hash - c3367a135a8fac9e vs c3367a135a8fac9e
FAIL: V6 If branch: rotation changes hash - 02d88040054b8d65 vs 02d88040054b8d65
FAIL: V6 Condition arg: rotation changes hash - dc3fd0a5385470b8 vs dc3fd0a5385470b8
FAIL: V6 Find condition: rotation changes hash - 1eb9611cebc7bc24 vs 1eb9611cebc7bc24
FAIL: V6 AtIndex default: rotation changes hash - c84e819b92ac6cf9 vs c84e819b92ac6cf9
FAIL: V6 Enumerate body: rotation changes hash - 9eca88d8f591151d vs 9eca88d8f591151d
PASS: V6 ParseJSON scalar (no nesting possible): rotation leaves hash - 5c22dfb107ed0018 vs 5c22dfb107ed0018

2 passed, 9 failed

$ python probe-v3.py /agent-workspace/oss/authentik-wt-verify3          # HEAD e0bdb02a4
# arm: fixed (/agent-workspace/oss/authentik-wt-verify3)
# probe-v3 arm: fixed (/agent-workspace/oss/authentik-wt-verify3)

PASS: V1 file removed between runs changes hash - 7818dbd451f69869 vs 0a721fcf06cf2db1
PASS: V2 swapping the contents of two referenced files changes hash - 5b63d2f921af87c7 vs 0e79b843ffaff18a
V3 CRLF blueprint: discovery hash 72405c1803c640f4, base raw-bytes hash 15e1a41b4b30fd69, apply-side hash 72405c1803c640f4
V3 discovery == apply on this arm: True; discovery == base raw-bytes: False
V4 !File with cyclic-sequence path: a584b706bbef1753e5a0524e
PASS: V4 hash stable
V5 self-anchored !File: NOT LOADABLE (ConstructorError) - parse-time, both arms
PASS: V6 Env default: rotation changes hash - 8dc51b50c70decfe vs 7498366f5981606e
PASS: V6 Context default: rotation changes hash - b7d7d94ae7f6c7af vs 7c2d7e9ffc3464a2
PASS: V6 If branch: rotation changes hash - 461d604d835a96c0 vs d074f737e50e4143
PASS: V6 Condition arg: rotation changes hash - e673b51ca1c8ad16 vs f11022c0ec412a41
PASS: V6 Find condition: rotation changes hash - 9f9571544f419bd7 vs e50bfd6e33533175
PASS: V6 AtIndex default: rotation changes hash - 1da90da49205887c vs ea5e03db69c78520
PASS: V6 Enumerate body: rotation changes hash - a9ac461fb22f2335 vs 858e7b02b3d7c546
PASS: V6 ParseJSON scalar (no nesting possible): rotation leaves hash - 5c22dfb107ed0018 vs 5c22dfb107ed0018

# mutants (each must be killed by at least one named case)
N1 no bound: KILLED by cycle_sequence hashes, cycle_mapping hashes, cycle_content_changed
N2 global seen-set (REPAIR-A): KILLED by alias_hashed_per_route
N3 mutable default set, never reset: KILLED by alias_hashed_per_route, cycle_content_changed, content_unchanged x6
N4 bound checked after yield: KILLED BY NOTHING
N5 depth cap 100 instead of ancestors: KILLED BY NOTHING

11 passed, 0 failed

N4 is the bound checked after the yield instead of before it: the !File on the cycle is yielded once more before the return, but the walk still terminates and the !File beside the cycle is folded in the same number of times, so no digest moves. It is an equivalent mutant on every input tried, not an uncovered one. N5 (a depth cap instead of the ancestor chain) survived the branch's own tests and is what test_file_tag_hashed_once_per_route [cycle] was written to kill:

$ python probe-cycle-once.py /agent-workspace/oss/authentik-base-arm     # BASE 38fca6b34
# arm: base (/agent-workspace/oss/authentik-base-arm)
base: test_file_tag_cycle_hashed_once FAIL (d03d7e40441082a0 != 405f21e7d8e3be96)

$ python probe-cycle-once.py /agent-workspace/oss/authentik-wt-verify3   # HEAD e0bdb02a4
# arm: fixed (/agent-workspace/oss/authentik-wt-verify3)
fixed: test_file_tag_cycle_hashed_once PASS
  mutant N1 no bound: FAIL (RecursionError)
  mutant N4 bound checked after yield: PASS
  mutant N5 depth cap 100: FAIL (9d98bec1578d34bf != 405f21e7d8e3be96)
  mutant N6 depth cap 400 (just under the parse limit): FAIL (dc731cf91ef5ce85 != 405f21e7d8e3be96)

The expected digest in that test is computed arithmetically (content, then exactly one fold of the file), so a depth cap of 100 or 400 folds the file 100 or 400 times and misses it, while the ancestor chain folds it once.

The two candidate repairs for the cycle regression, each applied as a mutation of the real tasks.py (the verification's probe-recursion-repair.py; REPAIR-B is what this branch shipped):

--- head (as shipped at 44d6580f3) ---
  self-referential sequence: RAISED RecursionError
  self-referential mapping: RAISED RecursionError
  cycle carrying a !File: RAISED RecursionError
  shared anchor (acyclic): 0b1f1fbc821d0b2c
--- REPAIR-A (global seen-set) ---
  self-referential sequence: hashed d9ab3968c3707187 stable=True
  self-referential mapping: hashed ce8f871f36050699 stable=True
  cycle carrying a !File: hashed 1d854c3482e5a485 stable=True
  shared anchor (acyclic): 913f8f538617c68b
--- REPAIR-B (ancestor-set) ---
  self-referential sequence: hashed d9ab3968c3707187 stable=True
  self-referential mapping: hashed ce8f871f36050699 stable=True
  cycle carrying a !File: hashed 1d854c3482e5a485 stable=True
  shared anchor (acyclic): 0b1f1fbc821d0b2c

Both terminate the cycles; only B leaves the shared-anchor digest where head has it. test_file_tag_hashed_once_per_route [alias] is the test that kills A.

Lint, with the repo's pinned ruff==0.16.8 (pyproject.toml:116) and black==26.5.1 (pyproject.toml:93, line-length 100) — the two commands CI runs (make ci-lint-ruff, make ci-lint-black), re-run at head 18479bddc (rw5-lint.txt) with the same result as at 3ad347d1b (rw2-lint.txt, v3-lint.txt):

$ black --version
black, 26.5.1 (compiled: no)
Python (CPython) 3.14.7

$ black --check authentik/blueprints/v1/tasks.py authentik/blueprints/tests/test_v1_tasks.py
All done! ✨ 🍰 ✨
2 files would be left unchanged.

$ ruff check authentik/blueprints/v1/tasks.py authentik/blueprints/tests/test_v1_tasks.py
All checks passed!

$ ruff format --check authentik/blueprints/v1/tasks.py authentik/blueprints/tests/test_v1_tasks.py
2 files already formatted

The pinned version matters here and cost a CI round: at black 25.9.0 the parenthesised except (OSError, ValueError): passes, and at the pinned 26.5.1 it does not — 26.5.1 formats a handler with no as clause in PEP 758's unparenthesised form. The fork's lint (black, python) job was red at 44d6580f3 naming exactly that line; it is the pinned version that decides, and the repo already carries the unparenthesised spelling in three other files.

Verification method

executed throughout: the hashing logic locally on five arms, and the full Django test file by the fork's CI, which has the database this container lacks. At 18479bddc the local arms were re-run (repro.py 3 failed / 5 passed on base, 8/8 at head; probe-crlf.py DIFFER on base and on the raw-bytes mutant, AGREE at head). test_valid_crlf needs the database; its fork CI result is recorded in the CI bullet below.

  • executed: the three discriminating hash cases and five controls from repro.py, the four controls from repro-rework.py, the five cycle/alias cases from repro-rework2.py, the fold-count probe, the anchor probe, the blast-radius probe, the six M mutants and the six N mutants, on five arms (base 38fca6b34, unguarded 79b0815eb, second commit a2decb52d, unbounded walk 44d6580f3, head 2453510bc, whose tasks.py is identical to 1c67effb1, 3ad347d1b, e0bdb02a4 and c4c91590a, and whose statements are identical to 18479bddc, where only comments changed; repro.py and probe-crlf.py were re-run at 18479bddc itself), through the real File tag, the real BlueprintLoader and the real blueprint_hash/iter_file_tags source from tasks.py. Plus the nine boundary measurements in boundaries.txt. Runtime: CPython 3.14.7 in a venv at /agent-workspace/oss/akvenv with Django 5.2.17, PyYAML 6.0.3, ruff 0.16.8, black 26.5.1 (the pinned version; an earlier round ran 25.9.0, which does not reproduce CI's formatting verdict).
  • executed by fork CI rather than locally: test_file_tag_applied_on_change, test_valid_crlf and the five pre-existing tests in the file were not run in this container. TransactionTestCase + blueprints_discovery.send() need postgres and the dramatiq broker; this container has no postgres (psql, postgres, initdb, redis-server all absent). The logic traced line by line: blueprints_discoveryblueprints_find()check_blueprint_v1_filelast_applied_hash != blueprint.hashapply_blueprint, which stores blueprint_hash(instance.retrieve()). write_blueprint() matches on found.path == Path(file.name).name because BlueprintFile.path is set from str(rel_path) relative to blueprints_dir (tasks.py:185,204) and NamedTemporaryFile(dir=TMP) puts the file directly in that directory; the discovery-continues case table uses the same property. The fork's test-unittest - PostgreSQL 14-alpine - Run 10/10 and PostgreSQL 18-alpine - Run 10/10 shards at c4c91590a (jobs 106755883027 / 106755883201, run 35730715559, CI - Main at c4c91590a5c80d561706292fed262f80e1652edc, the last head at which the matrix had finished when this body was written) executed the whole file under the real runner with a database; every test in it, sorted, identical on both shards (rv4-ci-testfile.txt); the ninth commit adds two subTest inputs to test_file_tag_unreadable_discovery_continues and no test:
test_file_tag_applied_on_change PASSED
test_file_tag_content_changed PASSED
test_file_tag_content_unchanged PASSED
test_file_tag_contents_swapped PASSED
test_file_tag_created PASSED
test_file_tag_hashed_once_per_route PASSED
test_file_tag_removed PASSED
test_file_tag_unreadable_discovery_continues PASSED
test_file_tag_unreadable_hash_stable PASSED
test_invalid_file_syntax PASSED
test_invalid_file_version PASSED
test_valid PASSED
test_valid_disabled PASSED
test_valid_updated PASSED

So test_file_tag_applied_on_change does observe last_applied_hash moving through blueprints_discovery.send(), and the two literal digests in test_valid / test_valid_updated are unchanged under the real runner.

  • Fork CI. The transcript below is gh pr checks 1 --repo sprayberry-code/authentik at c4c91590a (run 35730715559, CI - Main, completed; rv4-ci-jobs.txt), a head at which the full matrix had finished; the ninth commit 2453510bc changes only test_v1_tasks.py (two subTest inputs), and tasks.py is byte-identical between the two. At the current head 18479bddc the matrix is CI - Main run 35815749504; its state when this body was written is in the first line of this bullet's list below. Every Python lint job passes at c4c91590a (lint (black, python), lint (ruff, python), lint (bandit, python), lint (mypy, python), lint (pending-migrations), lint (check)), and all twenty test-unittest - PostgreSQL 14-alpine|18-alpine - Run N/10 shards pass. One line per non-green job at c4c91590a:
    • At 18479bddc (CI - Main run 35815749504): pending when this body was written (2026-09-23T03:5xZ): the test-unittest - PostgreSQL 14|18-alpine shards that execute test_valid_crlf had not yet run. Completed so far: build-compute-tags, test-make-seed and lint (bandit, python) pass; the build (ldap|rac|radius, *.Dockerfile) image jobs, build-container, dependency-review and ci-website-mark fail for the fork-infrastructure reasons below, as at every earlier head. Locally at this head black 26.5.1, ruff check and ruff format --check are clean (rw5-lint.txt). The Scout or the next seat should read gh pr checks 1 --repo sprayberry-code/authentik for the shard results before relying on test_valid_crlf as executed.
    • lint (black, python)pass (2m1s). This is the job that was red at 44d6580f3 naming authentik/blueprints/v1/tasks.py, and it is the CI-side confirmation of the fix in R20c. lint (ruff, python) (2m36s), lint (bandit, python) (2m26s), lint (mypy, python) (4m29s), lint (pending-migrations, python,runtime) (2m58s), lint (check), lint (clippy, rust), lint (cargo-deny, rust), lint (rustfmt, rust-nightly), lint (spellcheck, node), lint (catalogs, node), lint (oxlint-fixtures, node), lint-golint, build-docs, build-integrations, build-compute-tags, check-changes-applied, test-integration, test-migrations, all ten test-migrations-from-stable shards, e2e (playwright), test-unittest (the Go outpost suite, 6m46s) and all twenty test-unittest - PostgreSQL 14-alpine|18-alpine - Run N/10 shards pass.
    • build-container, and the build (ldap|proxy|rac|radius|server, *.Dockerfile) matrix — fail, not this diff: each dies pushing an image to the upstream's registry namespace, denied: permission_denied: The requested installation does not exist on ghcr.io/goauthentik/dev-docs:gh-gh-<sha> (transcript captured at 1c67effb1; the same jobs fail identically at this head). A fork's token cannot push there; this is fork infrastructure and is independent of a Python-only change.
    • dependency-reviewfail, not this diff: it compares dependency manifests against the fork's own base and fails on every PR in this fork. No dependency file is touched by this branch.
    • ci-core-mark, ci-website-markfail, not this diff: these are aggregate gates that require every job in their run to be green, so they inherit the image-build and dependency-review failures above.
    • test-openid-conformance (ssf_transmitter)fail, not this diff: the run conformance step (step 8) succeeded; the job fails at step 9, .github/actions/test-results, whose codecov upload dies with Error: Failed to get ID Token (OIDC token unavailable to a fork's workflow token). The same job passed at e0bdb02a4, and no OIDC or conformance file is touched by this branch.
    • Nothing was pending at c4c91590a. Every non-green job above is fork infrastructure; every job that executes Python, lints Python, or runs the blueprint tests is green there.

Prior art

Re-run at head 18479bddc (2026-09-23T03:5xZ): the four searches marked "re-run at this head" still return nothing, issue goauthentik#26289 is still OPEN with labels enhancement/triage, and git log 38fca6b34..origin/main (upstream main at 449f29969) has no commit touching authentik/blueprints/v1/tasks.py, none mentioning 26289, and none adding or removing read_bytes under authentik/blueprints. Earlier pass at c4c91590a (2026-09-22T13:04Z, rw4-priorart.txt), because the candidate is scoped on a public open issue and hours had passed since the previous pass. This is the sixth pass; every earlier one returned the same:

Search Result
gh search prs --repo goauthentik/authentik "blueprint_hash" --limit 20 empty (re-run at this head)
gh search prs --repo goauthentik/authentik "iter_file_tags" --limit 20 empty (re-run at this head)
gh search prs --repo goauthentik/authentik "blueprint hash File" --limit 20 empty (re-run at this head)
gh search issues --repo goauthentik/authentik "26289 in:body" --limit 20 empty (re-run at this head)
gh issue view 26289 still OPEN, labels enhancement/triage, no linked PR
gh api repos/goauthentik/authentik/issues/26289/timeline filtered to cross-referenced exactly one: this fork PR (re-run at this head)
gh search prs --repo goauthentik/authentik "blueprints_find" --limit 20 one hit, goauthentik#10595 website/docs: explain guarantees around blueprint ordering (merged 2024-07-30, docs only)
gh search prs --repo goauthentik/authentik "blueprint File tag hash" empty (earlier pass)
gh search prs --repo goauthentik/authentik "blueprint re-apply referenced file secret" empty (earlier pass)
gh search prs --repo goauthentik/authentik "check_blueprint_v1_file" empty (earlier pass)
gh search issues --repo goauthentik/authentik "blueprint secret rotate re-apply" empty (earlier pass)
gh search prs --repo goauthentik/authentik "blueprints" (control — proves the search works) 8 open: goauthentik#26069, goauthentik#26072, goauthentik#25464, goauthentik#17994, goauthentik#20490, goauthentik#26103, goauthentik#26105, goauthentik#26097

None of those eight touches the hashing or discovery path. The closest by title, goauthentik#25464 "blueprints: handle absent blueprint references", is about references between blueprint entries, not !File. Issue goauthentik#26289 is open with no linked PR; this PR cites it and does not attempt to resolve anything else in it.

git log --oneline -20 -- authentik/blueprints/ shows the accepted shape here: small, single-purpose fixes with a test in the module's existing test file (672200757 blueprints: fix ignoring hidden paths, faf2211cc blueprints: Fix tags not being resolved when used for !Env's default value, 1bab19af3 blueprints: fix YAMLTag repr raising on unresolved tags). The !Env one is the same class as this: a tag whose behaviour was incompletely handled.

Policy

AI_POLICY.md (root, main) — verbatim:

authentik welcomes community contributions, including AI-assisted contributions, as long as contributors understand, review, and take responsibility for what they submit.

  • All AI usage in any form must be disclosed. State what tool(s) you relied on and the extent that the work was AI-assisted.
  • The human-in-the-loop must fully understand all code. If you cannot explain what your changes do, why they are correct, and how they affect the relevant parts of authentik without AI assistance, do not submit them.
  • Issues and discussions can use AI assistance but must have a human-in-the-loop. This means that any content generated with AI must have been reviewed and edited by a human before submission.
  • No AI-generated media is allowed (art, images, videos, audio, etc.). Text and code are the only acceptable AI-generated content.
  • If a code contribution or discussion appears to be entirely AI-driven and lacking human judgement, the maintainers will close the issue/PR/discussion.

Blocking operator steps before submitting upstream, both from that policy:

  1. The PR body must carry an explicit AI-assistance disclosure naming the tool and the extent. Facts for writing it are in ## Disclosure facts below — write it in your own words; the policy requires a human to have reviewed and edited the text.
  2. You must be able to explain this diff without assistance. It is 69 changed lines in one source file; the ## Bug and ## Fix sections are the whole argument.

No commit trailer is required by this repo (no DCO, no AI-Assisted: tag), so no amend/rebase step is needed before submitting. AGENTS.md:136 says the opposite of a required trailer: "Commit attribution: do not add a Claude co-author trailer; credit human collaborators instead." — the branch's five commits carry no attribution trailer of any kind, which complies.

.github/pull_request_template.md (verbatim checklist):

  • The project has been linted, built, and tested (make all)
  • The documentation has been updated and formatted (make docs)
  • I have read the AI usage policy.

Ran here: ruff check and black --check with the pinned ruff==0.16.8 and black==26.5.1 — the two commands CI's lint matrix runs for Python (make ci-lint-ruff, make ci-lint-black), both clean (transcript above). Not run: make all and make docsmake all builds the Go/Rust/TypeScript subtrees and runs the full Django suite against postgres, none of which exists in this container. No docs change is needed: website/docs/customize/blueprints/v1/tags.mdx documents !File as "Returns the contents of the file at the given path", which is what the code does; this PR changes when a blueprint is re-applied, not what !File returns. The template's body sections (What does this PR change?, Why is this change needed?, How was this tested?, Linked issues) need filling; use refs #26289 or closes #26289 per the template's own note.

CONTRIBUTING.md is a three-line stub pointing at https://docs.goauthentik.io/docs/developer-docs/. AGENTS.md is a repo map; beyond the attribution line above it contains no AI restriction. No CLA, no DCO sign-off. .github/AI_POLICY.md, AI.md, AGENT_POLICY.md, .github/CONTRIBUTING.md all return 404.

Commit style on main is <subsystem>: <lowercase imperative> (blueprints: fix ignoring hidden paths); all five commits on this branch follow it.

Disclosure facts for the operator

Plain facts, for you to turn into the disclosure the policy requires. Not a draft body.

  • An AI agent read issue Blueprints do not re-apply when a referenced Kubernetes Secret changes goauthentik/authentik#26289, then located the mechanism by reading authentik/blueprints/v1/tasks.py and common.py directly — the issue names the symptom, not the cause; the cause (hash coverage) came from the source.
  • The AI wrote the whole diff: blueprint_hash, iter_file_tags, the guard, the ancestor-chain bound, the two call-site changes, and all ten added tests covering twenty-three inputs.
  • The AI found and fixed three regressions in its own earlier commits: the tag-path TypeError (second commit), the mapping-node AttributeError plus null-byte ValueError (third commit), and the cyclic-anchor RecursionError (fourth commit), each with tests that fail on the commit they were written against.
  • The AI deleted one line of its own fix (the path digest) after measuring that no input pair can observe it.
  • Four rounds of adversarial verification by a separate agent run: the first two found the third and fourth of those regressions, both reproduced and fixed rather than argued away; the third found no defect in the fix and added three tests for cases the ledger had left unpinned; the fourth found two ledger rows argued wrongly (a constructible two-anchor cycle, and a second ValueError shape) with the fix already correct on both, and added the two inputs.
  • A second-opinion review at 2453510bc asked for the CRLF behaviour change to be pinned and for the production comments to be cut back to the runtime contract; the AI wrote test_valid_crlf, measured it through the hashing path on base, head and a raw-bytes mutant, and rewrote the comments (the first comment on the except had claimed an unreadable path resolves to the default, which is not true for ValueError paths).
  • The AI ran the prior-art searches in ## Prior art (five times — while hunting, at the first gate, at the second, at 3ad347d1b, and at this head) and the policy-file reads in ## Policy.
  • The AI executed five arms of the repros, the anchor and fold-count probes, the blast-radius probe, twelve mutants, and the ruff/black commands; those transcripts are real copied output, not reconstructed.
  • The AI did not run the Django test suite, make all, or make docs locally — no postgres in its container. The Django test file was executed by the fork's GitHub Actions test-unittest matrix at c4c91590a, whose log is quoted verbatim above; test_valid_crlf (the last commit) was executed locally through the same hashing path on base, head and a mutant, and as a Django test only by fork CI.
  • No AI-generated media of any kind is involved.

Boundaries

One row per predicate, comparison, guard or index the diff adds or changes. Measurements are from boundaries.txt, rework-transcript.txt, rw2-repro.txt, rw2-anchors.txt and rw2-alias-routes.txt unless stated.

# Site Boundary input Fixed behaviour Pinned by
R1 isinstance(path, str) path is a normal str hashed: contents digest folded in test_file_tag_content_changed [direct]
R2 same path is the empty string !File "" str, so not skipped: Path("")IsADirectoryError (an OSError) → continue. Measured stable. A truthiness guard would skip it instead — the reason this is isinstance, not if path mutant M4 (killed); measured (boundaries.txt)
R3 same path is a YAML int scalar !File 123 ScalarNode.value is always a str, so path is "123", not an int — not skipped. Measured identical on all arms (2f26bdedf318319d) measured
R4 same path is an !Env tag (!File [!Env P, d]) skipped; hash stable. Without the guard: TypeError, uncaught, aborting blueprints_find for every blueprint test_file_tag_unreadable_hash_stable [path from a tag] (control); fails on 79b0815eb
R5 same path is a !Format tag skipped; same. Measured: unguarded arm raises TypeError ... not 'Format', stable at head measured (boundaries.txt); R4's test covers the class
R5a getattr(tag, "path", None) !File {a: b} — a mapping node, so File.__init__ takes neither if branch and assigns nothing; path is only a class annotation returns None → skipped, hash stable, scan continues. Reading tag.path directly raises AttributeError inside the guard test_file_tag_unreadable_discovery_continues [path from a mapping], test_file_tag_unreadable_hash_stable [path from a mapping]; mutants M1, M3
R5b same a !File whose path attribute exists and is a str getattr returns it unchanged; identical to tag.path R1's tests — every hashing test drives this
R6 isinstance(value, File) value is a File tag yielded test_file_tag_content_changed [direct]
R7 same value is another YAMLTag with no nested File (!Env X) not yielded; vars() walked, nothing found plain blueprint hash unchanged (control) + R10
R8 same value is a File and contains nested tags yielded, then still walked — the if/elif chain is deliberately separate from the yield test_file_tag_content_changed [direct] [argument of another tag]
R9 isinstance(value, dict) {} (empty mapping) .values() empty, loop body never runs, no crash plain blueprint hash unchanged (control)
R10 isinstance(value, list | tuple) [] (empty sequence) iterates zero times plain blueprint hash unchanged (control) — the blueprint's entries: []
R11 same a str value falls to else: return; not iterated char-by-char, because str is matched by neither dict nor list | tuple every test — all blueprints carry string values (version, name)
R12 isinstance(value, YAMLTag) !Format ["client-%s", !File p] vars() yields format_string and args; the File is found inside args test_file_tag_content_changed [direct] [argument of another tag]; mutant M5
R13 else: return None, int, bool, float (YAML null, 1, true) returns, yields nothing plain blueprint hash unchanged (control) — version: 1 is an int
R14 id(value) in ancestors a node that contains itself through an anchor (secret: &a\n - *a) the node is on the path from the root, so the walk returns instead of descending: hashed, stable, discovery continues. This row was previously argued unreachable and was wrong — PyYAML constructs collections in two steps precisely so recursive documents load. Without the check: RecursionError, uncaught by blueprints_find or apply_blueprint, aborting the whole scan (measured: 3 of 3 hashed on base, 1 of 3 at 44d6580f3) test_file_tag_unreadable_discovery_continues [sequence containing itself], test_file_tag_unreadable_discovery_continues [mapping containing itself], test_file_tag_content_changed [direct] [reached through a cycle]; all three fail on 44d6580f3
R14a same the cycle carries a !File (&a [*a, !File p]) the tag is still found and its contents folded in; rotating the file changes the digest test_file_tag_content_changed [direct] [reached through a cycle] (fails on base with equal digests, and on 44d6580f3 with RecursionError)
R14b same a node shared by two disjoint routes, no cycle (one: &a [!File p], two: *a) not its own ancestor, so walked from each route: the file is folded in twice, byte-identically to 44d6580f3 (5e4508957e9e504f on both) and to the same tag written out twice. A global seen-set would fold it once and move the digest of every aliased blueprint in the wild test_file_tag_hashed_once_per_route [alias] (expected digest computed arithmetically), test_file_tag_content_changed [direct] [reached through an alias]; kills REPAIR-A
R14c same depth without a cycle (120 nested sequences, 50 nested flow sequences) unaffected: recurses to the bottom and hashes. Depth is not what is bounded — a depth cap would have been the wrong fix test_file_tag_unreadable_hash_stable [deeply nested] (control: passes on base, 44d6580f3 and head)
R14d same mutual recursion between two anchors the row previously read unreachable on the strength of one ordering (&a → *b with *b written first), which PyYAML's composer rejects as a forward alias. Six orderings measured (probe-rows.py, rv4-rows-head.txt): both-sequences either order, both-mappings and a merge key are all ComposerError on both arms, but an inner anchor defined inside the outer one and aliased after it (&outer [{inner: &inner [*outer]}, *inner]) loads on both arms. It is a cycle of length two: neither node contains itself directly, so a bound that compares only against the immediate parent does not terminate on it (mutant N7, RecursionError), while the ancestor chain does. Same defect class as R14: a claim about the loader's construction order, made from the shape rather than from the library test_file_tag_unreadable_discovery_continues [two anchors containing each other] (fails on 44d6580f3 and on N7; passes on base, which does not walk)
R14f fold count on a cycle &a [*a, !File p]: the !File beside the self-reference folded in exactly once (content, then one 64-byte digest), computed arithmetically. A depth cap of 100 or 400 in place of the ancestor chain folds it 100 or 400 times and gives a different digest test_file_tag_hashed_once_per_route [cycle]; mutants N5, N6 (both killed by nothing before this test)
R14e ancestors default top-level call, ancestors omitted frozenset(), so nothing is suppressed at the root. The default is immutable and rebound (ancestors = ancestors | {...}), never mutated, so it cannot leak state between calls. Measured (probe-rows.py): the same document hashes identically before and after a cyclic document is hashed in between, the digest still tracks the referenced file on the third call, and the generator yields the tag on every call ([1, 1, 1]; base [0, 0, 0]) measured (rv4-rows-head.txt); every hashing test calls iter_file_tags with one argument
R15 load(content, BlueprintLoader) invalid YAML ({) YAMLError → content-only digest, matching base invalid yaml hash unchanged (control)
R16 same empty string "", a comment-only document, an explicit null document, whitespace only each parses to None; iter_file_tags(None) hits else: return; content-only digest, byte-identical to base for all four (rv4-rows-head.txt / rv4-rows-base.txt: cf83e135…, df1ea83e…, 811da511…, 786f7ef1… on both arms). Unreachable from blueprints_find (falsy raw_blueprint skipped before hashing) but reachable from apply_blueprint via an empty instance.content measured (probe-rows.py, both arms); invalid yaml hash unchanged drives the same else: return
R17 Path(path).read_bytes() file does not exist OSErrorcontinue; hash stable across runs test_file_tag_unreadable_hash_stable [missing file] (control)
R17a same file existed on the previous run and is gone now the run that saw it folded its digest in; the run that does not folds nothing; the two hashes differ. On base both are the content-only digest, so a deleted secret never re-applied test_file_tag_removed (fails on base with equal digests)
R18 same file exists, is empty (b"") sha512(b"") folded in — measured distinct from the missing-file digest, because the missing case folds in nothing at all measured (boundaries.txt, re-measured after the path digest was dropped: still True)
R19 same file is a directory IsADirectoryError is an OSErrorcontinue. Measured stable measured; shares R17's branch
R20 same file unreadable (permissions) PermissionError is an OSErrorcontinue shares R17's branch
R20a except OSError, ValueError path contains a null byte (!File "\0") ValueError: embedded null bytenot an OSError — caught → continue, scan survives test_file_tag_unreadable_discovery_continues [path no syscall can take], test_file_tag_unreadable_hash_stable [path no syscall can take]; mutant M2
R20b same any other ValueError from read_bytes same continue path. The row previously read that only the null byte fails os.fsencode; that is wrong. A lone high surrogate (!File "\ud800", a valid YAML escape that ScalarNode.value carries as a one-character str) cannot be encoded under surrogateescape and raises UnicodeEncodeError, a ValueError subclass and not an OSError. Caught by the shipped handler; the except OSError mutant (M2) raises on it. A lone low surrogate (\udcff) is the surrogateescape byte form and reaches the filesystem as FileNotFoundError, an OSError. Measured on both arms and both mutants (rv4-r20b-mutant.txt, rv4-r20b-surrogate.txt) test_file_tag_unreadable_discovery_continues [path outside the filesystem encoding] (control: passes on base; fails on 79b0815eb/a2decb52d with UnicodeEncodeError)
R20c same the handler's spelling unparenthesised (PEP 758) because black at the pinned 26.5.1 reformats the parenthesised form; semantically identical. The parenthesised form made CI's lint (black, python) red at 44d6580f3 black --check at the pinned version (transcript above)
R21 contents digest file appears between runs contents digest now added → hash changes test_file_tag_created
R22 same contents change, path identical contents digest changes → hash changes test_file_tag_content_changed [direct]
R23 same path changes, contents identical hash changes — but via the blueprint's own content, which contains the path text and which sha512(content.encode()) already covers. Measured unobservable as a separate contribution: ten input pairs (distinct paths with identical contents, distinct missing paths, swapped tag order, YAML escapes and quoting styles that make the path string differ from the document bytes, sequence-node vs scalar paths) all give the same verdict with and without a path digest, and mutant M6 re-adding it is killed by nothing. The line was therefore deleted rather than tested probe-r23.py, probe-r23c.py, mutant M6; the behaviour itself by test_file_tag_content_changed [direct]
R24 fixed-length digests two !File tags whose content bytes could concatenate ambiguously impossible: every update is exactly 64 bytes reasoned; a targeted test would need a crafted collision of the concatenation, not of sha512
R25 iteration order two or more !File tags in one blueprint order follows the parsed document; measured stable across parses. The fold count is now pinned too measured (boundaries.txt); test_file_tag_hashed_once_per_route [alias] pins the count
R25a same two !File tags whose files swap contents the sequence of folded digests reverses, so the hash changes; sha512(A)+sha512(B) and sha512(B)+sha512(A) are distinct inputs. On base the hash is identical before and after test_file_tag_contents_swapped (fails on base with equal digests)
R26 blueprints_find blueprint with no !File at all byte-identical to base's digest for a blueprint with LF line endings, which is every blueprint the repo ships and every one the existing tests write plain blueprint hash unchanged (control) + the untouched literal digests in test_valid/test_valid_updated, executed by fork CI
R26a blueprints_find blueprint with CRLF line endings, no !File changes, and the change is a fix to a pre-existing mismatch rather than a new one. Base hashed path.read_bytes() at discovery (raw bytes, CRLF kept) but blueprint_content.encode() at apply, where retrieve_file() reads text mode and normalises to LF, so for a CRLF blueprint last_applied_hash never equalled the discovery hash and it re-applied on every scan. Head hashes the text-mode content on both sides, so the two agree. Measured (probe-v3.py V3): discovery 72405c1803c640f4, apply 72405c1803c640f4, base raw-bytes 15e1a41b4b30fd69. A CRLF blueprint re-applies once more after upgrade and then stops; it was re-applying every scan before. The file on disk keeps its CRLF bytes; only the hashed text is normalised test_valid_crlf (fails on base: discovery ≠ last_applied_hash; kills mutant M7, raw-bytes discovery); measured rw5-crlf-arms.txt, rw5-crlf-mutant.txt
R27 blueprints_find loop one blueprint in the directory raises while hashing every input that parses is hashed or skipped; the raising classes found so far (tag-valued path, mapping node, null-byte path, unencodable path, self-cyclic anchor, two-anchor cycle) are each measured 3-of-3 at head against 1-of-3 at the commit that introduced them. This is an invariant the tests defend, not a proof that no further input can raise — the loop still has no per-file guard, which is why each regression here has been a full scan abort test_file_tag_unreadable_discovery_continues [path from a mapping], test_file_tag_unreadable_discovery_continues [path no syscall can take], test_file_tag_unreadable_discovery_continues [sequence containing itself], test_file_tag_unreadable_discovery_continues [mapping containing itself], test_file_tag_unreadable_discovery_continues [two anchors containing each other], test_file_tag_unreadable_discovery_continues [path outside the filesystem encoding]; probe-blastradius.py, repro-rework2.py
R28 apply_blueprint hash stored must equal the one discovery compares both call blueprint_hash on the same text-mode read; a mismatch would re-apply every hour test_valid_crlf (asserts the equality directly, twice), test_file_tag_applied_on_change (database tests, executed by fork CI)

Known follow-up, deliberately not in this PR (one bug per PR): !File [] and !File ["p"] raise IndexError inside File.__init__ at parse time, on main before this change (probe-loadonly.py confirms UNCAUGHT AT PARSE on the base arm). Separately, File.resolve() calls open(self.path) on a tag-valued path and raises TypeError, which it does not catch. Both are pre-existing and out of scope; R4/R5/R5a keep discovery working in their presence without fixing them.

A note on R27, because two rounds of verification have now found a regression there. blueprints_find hashes without a per-file guard, so any exception from blueprint_hash costs the entire scan rather than one blueprint. This PR keeps the invariant by making the hashing path total for every input that parses, which is the minimal change. Wrapping the per-blueprint hash in a try would be a defence-in-depth improvement to blueprints_find itself — a different change, with a different blast radius, and one an upstream maintainer should decide on separately.

Rework

Six rounds, ten commits after the first.

Round oneverification comment, answered by the third commit 44d6580f3. It found the second commit's guard raising on inputs base hashed without complaint:

  1. getattr(tag, "path", None) — the blocking finding. if not isinstance(tag.path, str) evaluates tag.path before it guards anything, and File.__init__ assigns self.path in two if branches with no else, so a mapping-node !File has no such attribute. Reproduced, fixed, pinned by two tests and mutants M1/M3.
  2. except (OSError, ValueError) — second cause. A null byte in the path makes read_bytes() raise ValueError. Pinned by two tests and mutant M2.
  3. The path digest was deleted, not tested. The verification asked for a fixture for ledger row R23 — "two blueprints with byte-identical own content but different !File target paths whose files hold identical contents". That fixture was built and it does not discriminate, because two blueprints with different paths do not have identical content: the path text is part of the content. Ten pairs were tried, including cases where the digested path string is deliberately not a substring of the document (!File "\x61b" vs !File ab, quoting variants, sequence-node paths) — every pair gives the same verdict with and without the line, and mutant M6 is killed by nothing. R23 was unobservable because the line was redundant, so the honest resolution is deletion.

Round twoverification comment, answered by the fourth and fifth commits (ed629ce2d, 1c67effb1) and a sixth (3ad347d1b). Two blocking findings, plus one the rule book caught before the seats did:

  1. Ledger row R14 was false, and the walk raised on cyclic blueprints. R14 claimed a cycle "is not constructible through BlueprintLoader". It is: YAML anchors and aliases make a node its own descendant and PyYAML's two-step construction exists so such documents load. iter_file_tags recursed with no bound, so blueprint_hash raised RecursionError — uncaught by blueprints_find (which guards only load(), only for YAMLError) and absent from apply_blueprint's except tuple, so one such blueprint aborted the entire scan: 3 of 3 hashed on base, 1 of 3 at 44d6580f3. That is strictly worse than the bug being fixed, on input that works on main today. Fixed by bounding the walk on the ancestor chain, with five tests plus a sixth pinning the fold count. The verification measured two candidate repairs and this branch shipped the one it recommended: a global seen-set also terminates cycles but folds a shared acyclic subtree in once instead of twice, silently changing the digest of every blueprint that reuses an anchor. R14 is rewritten from the measurement, and R14a–R14e are new rows for the cases it hid.
  2. CI's lint (black, python) was red on this diff's own line. black is pinned at 26.5.1 (pyproject.toml:93) and make ci-lint-black runs black --check; at that version a handler with no as clause takes PEP 758's unparenthesised form. The container had been running 25.9.0, which passes the parenthesised spelling — the pin is the whole difference. Fixed to except OSError, ValueError:, which the repo already uses in three other files, and the local black is now the pinned version so this cannot recur silently. New ledger row R20c.
  3. The stability tests read as annotations of a patch rather than as tests. Eight docstrings in test_v1_tasks.py ended in (control), which is verification bookkeeping: it tells a reader what the case proved about a diff, not what the hash is required to do. The sixth commit rewrites those eight docstrings and changes nothing else — git diff 1c67effb1 3ad347d1b -- authentik/blueprints/v1/tasks.py is empty, and the test bodies are untouched. Which cases are controls, and what each controls for, is recorded in the ## Test evidence table below, where it belongs.

Round one's verification also confirmed rows R2/R3/R5/R18/R25 independently and identified !File []/!File ["p"] IndexError as pre-existing at parse time on base — recorded as a follow-up above, not fixed here. Round two re-confirmed R23's deletion with five further pairs including non-substring paths, and settled that row.

Round three — at 3ad347d1b, answered by the seventh commit e0bdb02a4, which is tests only (git diff 3ad347d1b e0bdb02a4 -- authentik/blueprints/v1/tasks.py is empty; 50 insertions in the test file, zero deletions). The fix itself held: the core hash, the getattr guard, the except tuple and the ancestor-chain bound all survived the attack, and a !File nested in every other argument-taking tag (!Env, !Context, !If, !Condition, !Find, !AtIndex, !Enumerate) is found and folded in. Three holes in the ledger, none a defect in the fix:

  1. A referenced file that disappears was untested (R17a). test_file_tag_removed: on base the digest before and after deletion is the same.
  2. Two files swapping contents was untested (R25a). test_file_tag_contents_swapped: on base the digest is the same before and after the swap.
  3. The depth-cap mutants were killed by nothing. Every test on the branch passed with the ancestor chain replaced by if len(ancestors) > 100: return (N5) or > 400 (N6), so the choice of the ancestor chain over a depth cap, argued in R14c, was unpinned. test_file_tag_hashed_once_per_route [cycle] computes the expected digest with exactly one fold of the file beside a self-referencing node; a depth cap folds it once per level and fails. Row R14f.

Round three also measured R26a: the CRLF discovery/apply agreement, a pre-existing mismatch on base that this diff happens to close because both call sites now hash the same text-mode content.

Round fourgating review
at e0bdb02a, answered by the eighth commit c4c91590a. The finding was presentation, not
behaviour: the accumulated test diff read as generated. 281 added test lines for a 53-line
production change, docstrings on the two test-local helpers, and one test per input where the
surrounding file has none of that.

The eighth commit is tests onlygit diff e0bdb02a c4c91590a -- authentik/blueprints/v1/tasks.py
is empty. The four families that differ only in which reference they hash (content rotation,
fold count, hash stability, discovery survival) become four tests driving their inputs through
subTest, the helper docstrings are gone, and a second one-line helper (write_secret) removes
the seek/truncate/write/flush repetition the cases shared. Every one of the twenty inputs is
kept — verified mechanically by extracting the reference literals from both revisions of the
file and diffing the sets (rw4-inputs.py: nothing dropped). The test diff is now 170 added
lines, and the whole file is 328 lines against base's 158.

Both arms re-measured at this head, unchanged from e0bdb02a: repro.py 3 failed / 5 passed on
base and 8/8 at head; repro-rework.py 4/4 on both arms (all four are controls); repro-rework2.py
3 passed / 2 failed on base and 5/5 at head. black --check at the pinned 26.5.1 and
ruff check are both clean on the touched files.

Round five — verification at c4c91590a, answered by the ninth commit 2453510bc, which is
tests only (git diff c4c91590a 2453510bc -- authentik/blueprints/v1/tasks.py is empty; two
lines added, both subTest inputs of test_file_tag_unreadable_discovery_continues). The
round ran the probe a cut-off segment had left unexecuted against the four ledger rows still
closed by prose:

  1. R14d was false in the same way R14 was. "Mutual recursion between two anchors is
    rejected by the composer" had been measured with one ordering. An inner anchor defined
    inside the outer one and aliased after it, &outer [{inner: &inner [*outer]}, *inner],
    loads on both arms. Head hashes it; the unbounded walk raises RecursionError; and a
    parent-only bound, which the two self-containing inputs cannot tell from the ancestor
    chain, raises too. Pinned as the two anchors containing each other input.
  2. R20b's reasoning was wrong, its conclusion right. The null byte is not the only
    ValueError os.fsencode can raise: a lone high surrogate raises UnicodeEncodeError.
    The shipped handler catches it; except OSError alone does not. Pinned as the
    path outside the filesystem encoding input, marked a control in the table because base
    never reads the path.
  3. R16 (four falsy documents) and R14e (the frozenset default across calls) hold and are
    now measured rather than argued.

black --check at the pinned 26.5.1 and ruff check are clean on the touched file.

Round sixsecond-opinion review at
2453510bc (SECOND READ: NOT READY), answered by the tenth commit a3c2c6e81 (comments only)
and the eleventh 18479bddc (one test). The gating review at the same head had approved.

  1. The CRLF behaviour change was described but not pinned (R26a). test_valid_crlf writes
    a CRLF blueprint as bytes, runs discovery twice, and asserts after each run that the
    stored last_applied_hash equals the discovered hash, then that the file still has CRLF on
    disk. Base: discovery 15e1a41b…, applied 72405c18…, different on both runs. Head: both
    72405c18…. Raw-bytes discovery mutant (M7): different on both runs.
  2. The production comments argued for the patch and one was wrong. The except comment
    said an unreadable path "resolves to its default value"; File.resolve() catches only
    OSError, so a ValueError path does not. The comments now state the local contract in
    one line each, and the two docstrings drop the comparison with an implementation without
    the check. The module's AST with docstrings blanked is identical before and after this
    commit: no statement changed. ## Fix now states the resolve distinction explicitly.

repro.py re-run at 18479bddc: 3 failed / 5 passed on base, 8/8 at head. black --check at
the pinned 26.5.1, ruff check and ruff format --check clean on both touched files
(rw5-lint.txt).

Suggested upstream PR title

blueprints: re-apply when a file referenced by !File changes

@askalf askalf added the oss-candidate Sprayberry Code candidate for upstream label Sep 21, 2026
The blueprint hash only covered the blueprint file itself, so a change to
a file referenced through a `!File` tag - such as a Kubernetes Secret
mounted into the container and then rotated - was never detected and the
blueprint was not re-applied.

Hash the referenced files' paths and contents alongside the blueprint's
own content, in both places the hash is computed, so discovery and apply
stay in agreement.

Closes goauthentik#26289
@askalf
askalf force-pushed the fix/blueprint-file-tag-hash branch from 5bdd88d to 79b0815 Compare September 21, 2026 10:04
`File.__init__` assigns `self.path` from `loader.construct_object()` when the
tag is built from a sequence node, so `!File [!Env P, default]` carries a tag
object rather than a string. `Path()` raises `TypeError` on it, which the
surrounding `except OSError` does not catch, and `blueprints_find` has no
per-file guard - so one such blueprint would abort discovery for all of them.

Skip those tags: their own content is already part of the blueprint content
hashed above, and resolving them needs an entry and a blueprint that the
hashing path does not have.
@askalf
askalf force-pushed the fix/blueprint-file-tag-hash branch from d5f2243 to a2decb5 Compare September 21, 2026 10:59
@askalf askalf changed the title [oss-candidate] blueprints: re-apply when a file referenced by !File changes [oss-candidate] blueprints: re-apply when a file referenced by !File changes Sep 21, 2026
@askalf
askalf marked this pull request as ready for review September 21, 2026 11:01
@askalf

askalf commented Sep 21, 2026

Copy link
Copy Markdown
Collaborator Author

Verification — NOT VERIFIED, changes requested

Adversarial verification at head a2decb52d158d3a09c365e537e37a682c93b65b7, base 38fca6b34951852b53db575394a5fb0c546cdcd3. Boundaries ledger rebuilt from the diff, not the body.

The core fix is sound. The eight-case regression repro is 3 failed / 5 passed on base and 8/8 at head; ruff check and ruff format --check are clean on both touched files; prior art re-run at the gate is still empty. The problem is not the bug or the fix — it is that the diff makes blueprint_hash raise on inputs base hashes without complaint, and blueprints_find has no per-file guard.

Blocking finding: two uncaught exceptions abort discovery for every blueprint

The diff's own comment states the invariant:

Hashing must never fail on a blueprint that can be loaded, so skip it

Two inputs violate it. Both parse cleanly on base (so they are not YAML errors that blueprints_find already filters), and both hash fine on base:

Input base load() base hash head hash
!File {a: b} (mapping node) parses cleanly 98bab55ce2c902d6 AttributeError: 'File' object has no attribute 'path'
!File "\0" (NUL byte in path) parses cleanly 36c03e4d98b4dc11 ValueError: embedded null byte

Cause 1 — File.__init__ has no else. authentik/blueprints/v1/common.py:284-291 assigns self.path in two if branches (ScalarNode, SequenceNode). path is otherwise only a class-level annotation, which creates no attribute. A MappingNode takes neither branch, so tag.path raises AttributeError — and isinstance(tag.path, str) evaluates tag.path before it can guard anything. This is the identical shape to the one the second commit was written to fix; that commit closed the type variation (!File [!Env ...]TypeError) and left the absence variation open.

Cause 2 — except OSError does not cover ValueError. Path(tag.path).read_bytes() on a str containing a NUL raises ValueError, not an OSError, at tasks.py:104.

Blast radius: one bad blueprint takes out the whole scan

blueprints_find (tasks.py:176-203) guards only the load() call, and only for YAMLError. blueprint_hash(content) at line 199 is unguarded, so the exception propagates out of the rglob loop. Three blueprints in a directory, the bad one sorting in the middle:

$ python probe-blastradius.py /agent-workspace/oss/authentik-base-arm
# arm: base (/agent-workspace/oss/authentik-base-arm)
mapping-node: scan completed, 3 of 3 blueprints hashed
nul-byte-path: scan completed, 3 of 3 blueprints hashed

$ python probe-blastradius.py /agent-workspace/oss/authentik-wt-verify
# arm: fixed (/agent-workspace/oss/authentik-wt-verify)
mapping-node: scan ABORTED after 1 of 3 blueprints -- AttributeError: 'File' object has no attribute 'path'
nul-byte-path: scan ABORTED after 1 of 3 blueprints -- ValueError: embedded null byte

The healthy blueprints stop being discovered. On base they are all hashed. That is a regression strictly worse than the bug being fixed, and it reaches both exported entry points:

  • blueprints_find — no guard at all.
  • apply_blueprint — its except tuple (tasks.py:280-287) is (OSError, DatabaseError, ProgrammingError, InternalError, BlueprintRetrievalFailed, EntryInvalidError). Neither AttributeError nor ValueError is in it.

Full attack transcript, three arms

$ python probe-verify.py /agent-workspace/oss/authentik-base-arm
# arm: base
R-C MAPPING node           : 98bab55ce2c902d6 stable=True
R-D empty sequence node    : bd132b4d28a68de5 stable=True
R-E one-element sequence   : 7fd1971f976796e3 stable=True
R-F path with a NUL byte   : 36c03e4d98b4dc11 stable=True

$ python probe-verify.py /agent-workspace/oss/authentik-headcommit   # UNGUARDED 79b0815eb
R-C MAPPING node           : RAISED AttributeError: 'File' object has no attribute 'path'
R-D empty sequence node    : RAISED IndexError: list index out of range
R-E one-element sequence   : RAISED IndexError: list index out of range
R-F path with a NUL byte   : RAISED ValueError: embedded null byte

$ python probe-verify.py /agent-workspace/oss/authentik-wt-verify    # HEAD a2decb52d
R-C MAPPING node           : RAISED AttributeError: 'File' object has no attribute 'path'
R-D empty sequence node    : RAISED IndexError: list index out of range
R-E one-element sequence   : RAISED IndexError: list index out of range
R-F path with a NUL byte   : RAISED ValueError: embedded null byte

R-D and R-E are NOT yours!File [] and !File ["p"] raise IndexError inside File.__init__ at parse time, on base as well (probe-loadonly.py confirms UNCAUGHT AT PARSE on both arms). Pre-existing, out of scope for this PR, worth its own issue. Only R-C and R-F are introduced here.

Also measured and clean at head, no action needed: !File as a mapping key (R-G), !File inside a !!set (R-H), and !File nested in another !File's default (R-I) — all stable.

Suggested repair, measured

Two edits, both in blueprint_hash:

        if not isinstance(getattr(tag, "path", None), str):
            ...
        except (OSError, ValueError):
$ python probe-repair.py /agent-workspace/oss/authentik-repair
===== repair-B: getattr + ValueError =====
  R-C MAPPING node           : 98bab55ce2c902d6 stable=True
  R-F path with a NUL byte   : e2ad8c56a9f1239d stable=True
  --- regression repro still green? ---
  8 passed, 0 failed

getattr alone (repair-A) closes R-C but leaves R-F raising. Both preserve the eight-case repro. Each needs a regression test, and each is a Boundaries row the ledger does not currently have.

Rule mutate-the-rejected-alternatives: two rejected alternatives are killed by nothing

The body's ## Fix rejects several alternatives in prose. Built as mutants of the real tasks.py and run against the repro:

Mutant Repro Killed by
M1 except (OSError, TypeError), no isinstance guard 8 passed, 0 failed nothing
M2 flat scan, no recursion into YAMLTag 7 passed, 1 failed test_file_tag_content_changed_nested
M3 hash contents but not the path 8 passed, 0 failed nothing
M4 truthiness guard instead of isinstance crashes: AttributeError: 'Env' object has no attribute 'encode'

M1 and M3 are unpinned. M3 is ledger row R23, which the body closes by argument ("not independently observable"); M1 is the alternative the ## Fix section spends its longest paragraph rejecting. A row closed by argument rather than by an executed test is exactly where the defect hides. M2 and M4 are genuinely pinned.

R23 is testable, and the body's argument for why it is not is wrong in one direction: two blueprints whose own content is byte-identical but whose !File targets differ need different hashes. Construct it with two distinct paths whose files hold identical contents — the content digest cancels, the path digest does not.

Ledger rows checked and holding

R2 (!File ""str, not skipped, falls to IsADirectoryError), R3 (int scalar is "123"), R5 (!Format path skipped), R18 (empty file's digest distinct from missing file's), R25 (two !File tags, order stable across parses) — all re-measured independently and all as the body describes. R4's control test_file_tag_path_from_tag does discriminate against the unguarded arm 79b0815eb as claimed; the three-arm framing is correct.

Verdict

Back to OSS Hunter. The bug, the fix's core and the test design are right; the diff introduces a crash on two inputs base survives, in a loop with no per-file guard, reachable from both entry points. Needed: the two-line repair above, a regression test per cause, an R23 test, and ledger rows for all three.

Rules: mutate-the-rejected-alternatives=covered(M1/M3 unpinned — findings above) | attribute-type-varies-by-constructor-branch=covered(R-C, the no-else branch of File.__init__) | unreachable-row-same-bytes=covered(R23 shown constructible) | private-fn-call-surfaces=covered(both blueprints_find and apply_blueprint walked; neither guards) | ledger-row-needs-its-fixture=covered(R2/R18/R25 measured, still fixture-less) | control-returns-its-own-input=unreachable(no test here asserts output identical to its input; the controls assert hash stability across two runs) | multi-assert-base-arm=unreachable(each repro case is its own check() with its own pass/fail line; no multi-assert body) | composed-transform-cross-product=covered(R-I, !File inside a !File default, measured stable) | dispatch-arm-boundary-coverage=covered(boundary inputs run against all three arms) | prior-art-recheck-at-gate=covered(re-run at the gate: blueprint hash File, 26289 in:body, blueprint_hash, blueprints_find all empty of competing PRs; issue goauthentik#26289 still open, no linked PR)

…overy

The previous commit's guard reads `tag.path` to decide whether to skip the
tag, but `File.__init__` assigns `self.path` only for scalar and sequence
nodes; `path` is otherwise just a class annotation, which creates no
attribute. A `!File` built from a mapping node takes neither branch, so the
guard raises `AttributeError` before it can guard anything. Read the
attribute with `getattr()` so the check cannot be what fails.

`Path(...).read_bytes()` also raises `ValueError`, not `OSError`, for a path
no syscall can accept, such as one containing a null byte, so widen the
except to cover it.

Both inputs parse cleanly and hash without complaint before this series, and
neither `blueprints_find` nor `apply_blueprint` catches either exception, so
a single such blueprint would stop every other blueprint from being
discovered.

Drop the separate digest of the path while here: the path of a tag that is
hashed is a substring of the blueprint content already hashed above, so it
cannot contribute anything the content digest does not.
@askalf

askalf commented Sep 21, 2026

Copy link
Copy Markdown
Collaborator Author

Rework — head 44d6580f30ea7c694507d5a988e559ac9bb945d5

Answers the verification at #1 (comment). The finding was correct and blocking: the second commit's guard raised on two inputs base hashed without complaint, in a loop with no per-file guard. Body updated to this head.

1. getattr(tag, "path", None) — the guard no longer evaluates its own operand

File.__init__ (common.py:284-291) assigns self.path in a ScalarNode branch and a SequenceNode branch with no else; path: str is otherwise a class annotation, which creates no attribute. A mapping-node !File {a: b} takes neither branch, so isinstance(tag.path, str) raised AttributeError before it could guard anything.

2. except (OSError, ValueError) — the null-byte path

Path(path).read_bytes() on a str containing a null byte raises ValueError, which except OSError does not catch.

Four regression tests added, all four failing on the previous head and passing on base and at this head:

$ python repro-rework.py /agent-workspace/oss/authentik-base-arm   # BASE 38fca6b34
PASS: test_file_tag_path_from_mapping (control)
PASS: test_file_tag_path_unopenable (control)
PASS: test_file_tag_path_from_mapping_stable (control)
PASS: test_file_tag_path_unopenable_stable (control)

4 passed, 0 failed

$ python repro-rework.py /agent-workspace/oss/authentik-wt-verify  # PRE-REWORK a2decb52d
FAIL: test_file_tag_path_from_mapping (control) -- raised AttributeError: 'File' object has no attribute 'path'
FAIL: test_file_tag_path_unopenable (control) -- raised ValueError: embedded null byte
FAIL: test_file_tag_path_from_mapping_stable (control) -- raised AttributeError: 'File' object has no attribute 'path'
FAIL: test_file_tag_path_unopenable_stable (control) -- raised ValueError: embedded null byte

0 passed, 4 failed

$ python repro-rework.py /agent-workspace/oss/authentik-wt-rw-1790005753  # HEAD 44d6580f3
PASS: test_file_tag_path_from_mapping (control)
PASS: test_file_tag_path_unopenable (control)
PASS: test_file_tag_path_from_mapping_stable (control)
PASS: test_file_tag_path_unopenable_stable (control)

4 passed, 0 failed

All four are named controls because they pass on base — base never touches tag.path, so there is nothing to discriminate against the original bug. Each fails on the commit it was written against. They assert at the blueprints_find level (a healthy blueprint alongside the bad one must still be discovered), which is where the blast radius lives, and they are split one-input-per-test so no assertion hides behind an earlier one.

Blast radius closed:

$ python probe-blastradius.py /agent-workspace/oss/authentik-base-arm         # BASE
mapping-node: scan completed, 3 of 3 blueprints hashed
nul-byte-path: scan completed, 3 of 3 blueprints hashed

$ python probe-blastradius.py /agent-workspace/oss/authentik-wt-verify        # a2decb52d
mapping-node: scan ABORTED after 1 of 3 blueprints -- AttributeError: 'File' object has no attribute 'path'
nul-byte-path: scan ABORTED after 1 of 3 blueprints -- ValueError: embedded null byte

$ python probe-blastradius.py /agent-workspace/oss/authentik-wt-rw-1790005753 # HEAD 44d6580f3
mapping-node: scan completed, 3 of 3 blueprints hashed
nul-byte-path: scan completed, 3 of 3 blueprints hashed

3. On R23 — I did not do what was asked, and here is the measurement

The verification asked for a fixture pinning the path digest: "two blueprints with byte-identical own content but DIFFERENT !File target paths whose files hold IDENTICAL contents — the content digest cancels, the path digest does not."

I built it. It does not discriminate, and the premise is not constructible: two blueprints whose !File targets differ do not have byte-identical own content, because the path text is part of the content, which sha512(content.encode()) already covers on the first line of blueprint_hash.

So I attacked it from the other side — cases where the digested path string is deliberately not a substring of the document, which is the only way the path digest could contribute something new:

$ python probe-r23c.py /agent-workspace/oss/authentik-wt-rw-1790005753
same verdict      escaped scalar vs plain scalar, same resolved path: real !=
same verdict      single-quoted vs plain, same resolved path: real !=
same verdict      sequence-node path vs scalar path: real !=
same verdict      comment-only difference, same path: real !=
same verdict      distinct paths, identical contents: real !=
  path='/tmp/tmpmpytxz0g/ab'                    substring-of-document=False
  path='/tmp/tmpmpytxz0g/ab'                    substring-of-document=True
  path='/tmp/tmpmpytxz0g/ab'                    substring-of-document=True

M3 killed by: NOTHING

Ten pairs in total across probe-r23.py and probe-r23c.py (distinct paths with identical contents, distinct missing paths, swapped tag order, YAML escape and quoting variants, sequence-node vs scalar paths). Every pair gives the same verdict with and without the path digest. The first row is the interesting one: !File "\x61b" resolves to a path the document does not contain verbatim, and still nothing changes — because the escape sequence that produced it does vary the content.

So the line was deleted, not tested. R23 was unobservable because the code was redundant. Mutant M6 re-adds it and is killed by nothing, which is the control on the deletion:

===== M1 dereference tag.path directly (pre-rework guard) =====
  killed by: test_file_tag_path_from_mapping (control), test_file_tag_path_from_mapping_stable (control)

===== M2 except OSError only (pre-rework except) =====
  killed by: test_file_tag_path_unopenable (control), test_file_tag_path_unopenable_stable (control)

===== M3 no guard, swallow AttributeError/TypeError instead =====
  killed by: test_file_tag_path_from_mapping (control), test_file_tag_path_from_mapping_stable (control)

===== M4 truthiness guard instead of isinstance =====
  killed by: test_file_tag_path_from_tag (control) — raised TypeError: argument should be a str or an os.PathLike object where __fspath__ returns a str, not 'Env'

===== M5 flat scan, no recursion into YAMLTag =====
  killed by: test_file_tag_content_changed_nested — 28230cab3dcc2dc4 vs 28230cab3dcc2dc4

===== M6 re-add the removed path digest =====
  *** KILLED BY NOTHING ***

M1 and M3 were the two the verification found unpinned; both are now killed by named tests. R18 (empty file vs missing file) was re-measured after the deletion and still holds — the missing case folds in nothing at all, so the digests still differ.

Unchanged and re-confirmed

Original 8-case repro: 3 failed / 5 passed on base, 8/8 at this head. ruff check and black --check clean — note ruff format is not this project's formatter (CI runs make ci-lint-ruff = ruff check, and make ci-lint-black), and every other multi-exception handler in the codebase is parenthesized. Prior art re-run at this head: blueprint hash File, blueprint_hash, File tag hash, 26289 in:body all empty; blueprints_find returns only a 2024 docs PR; issue goauthentik#26289 still open, no linked PR.

The !File [] / !File ["p"] IndexError the verification flagged as pre-existing is recorded as a follow-up in ## Boundaries and deliberately not fixed here.

@github-actions

github-actions Bot commented Sep 21, 2026

Copy link
Copy Markdown

Playwright e2e

Download this run's HTML report, traces and server logs, then open the report:

gh run download 35815749118 -D playwright-artifacts
npx playwright show-report playwright-artifacts/playwright-report

@askalf

askalf commented Sep 21, 2026

Copy link
Copy Markdown
Collaborator Author

Verification — NOT VERIFIED at head 44d6580f30ea7c694507d5a988e559ac9bb945d5

Adversarial re-verification of the rework. The three changes the rework made are sound and
I re-derived each one independently (below). Two blocking findings remain, one of them
a regression of the same class and the same blast radius as the one the previous round
blocked. The verified label is not applied.


BLOCKING 1 — ledger row R14 is false: a YAML cycle makes blueprint_hash raise, aborting discovery for every blueprint

Row R14 says a cycle "is not constructible through BlueprintLoader, so a cycle is not
constructible", marked "reasoned, not tested; flagged for the verification seat". It is
constructible. YAML anchors and aliases make a node a descendant of itself, and PyYAML's
two-step construction for collections exists precisely so recursive documents load.
iter_file_tags recurses with no depth bound and no cycle check, so it never terminates.

version: 1
entries: []
context:
  secret: &a
    - *a

blueprints_find still has no per-file guard around blueprint_hash, so one such
blueprint aborts the whole rglob loop — exactly the failure mode the previous
verification blocked, reached by a different input. apply_blueprint's except tuple
(OSError, DatabaseError, ProgrammingError, InternalError, BlueprintRetrievalFailed, EntryInvalidError) does not include RecursionError either, so both exported entry
points are affected.

Measured with probe-recursion.py, one script, three arms:

=== probe-recursion.py BASE 38fca6b34 ===
# arm: base (/agent-workspace/oss/authentik-base-arm)
self-referential sequence: loads, hashed d9ab3968c3707187 stable=True
self-referential mapping: loads, hashed ce8f871f36050699 stable=True
cycle carrying a !File: loads, hashed ecbef75008101fcd stable=True
mutual recursion: NOT LOADABLE (ComposerError) - out of scope
deep nesting (no cycle): loads, hashed 5b7ceb92bb1bbce8 stable=True

self-referential sequence: scan completed, 3 of 3 hashed
self-referential mapping: scan completed, 3 of 3 hashed
cycle carrying a !File: scan completed, 3 of 3 hashed
mutual recursion: scan completed, 2 of 3 hashed
deep nesting (no cycle): scan completed, 3 of 3 hashed

=== probe-recursion.py HEAD 44d6580f3 ===
# arm: fixed (/agent-workspace/oss/authentik-wt-rw-1790005753)
self-referential sequence: loads, but HASHING RAISED RecursionError: maximum recursion depth exceeded
self-referential mapping: loads, but HASHING RAISED RecursionError: maximum recursion depth exceeded
cycle carrying a !File: loads, but HASHING RAISED RecursionError: maximum recursion depth exceeded
mutual recursion: NOT LOADABLE (ComposerError) - out of scope
deep nesting (no cycle): loads, hashed 5b7ceb92bb1bbce8 stable=True

self-referential sequence: scan ABORTED after 1 of 3 -- RecursionError
self-referential mapping: scan ABORTED after 1 of 3 -- RecursionError
cycle carrying a !File: scan ABORTED after 1 of 3 -- RecursionError
mutual recursion: scan completed, 2 of 3 hashed
deep nesting (no cycle): scan completed, 3 of 3 hashed

Base hashes all three without looking at document structure at all, so this is a
head-only regression: input that works today breaks after the change. The pre-rework head
a2decb52d behaves identically to 44d6580f3 here, so the rework neither caused nor
fixed it.

Two controls in the same probe, both needed to scope the finding correctly:

  • deep nesting, 120 levels, no cycle — identical digest 5b7ceb92bb1bbce8 on base and
    head. The finding is cycles, not depth; a plain depth cap would be the wrong fix.
  • mutual recursion through two anchors (&a -> *b, &b -> *a) — rejected by the
    composer on both arms, so it never reaches hashing and is out of scope. This is the
    parse-vs-hash separation the last round established: an input whose parse fails on base
    cannot be blamed on the diff.

Repair, measured. Two candidates, probe-recursion-repair.py, each applied as a
mutation of the real tasks.py at this head:

--- head (as shipped) ---
  self-referential sequence: RAISED RecursionError
  self-referential mapping: RAISED RecursionError
  cycle carrying a !File: RAISED RecursionError
  shared anchor (acyclic): 0b1f1fbc821d0b2c
--- REPAIR-A (global seen-set) ---
  self-referential sequence: hashed d9ab3968c3707187 stable=True
  self-referential mapping: hashed ce8f871f36050699 stable=True
  cycle carrying a !File: hashed 1d854c3482e5a485 stable=True
  shared anchor (acyclic): 913f8f538617c68b
--- REPAIR-B (ancestor-set) ---
  self-referential sequence: hashed d9ab3968c3707187 stable=True
  self-referential mapping: hashed ce8f871f36050699 stable=True
  cycle carrying a !File: hashed 1d854c3482e5a485 stable=True
  shared anchor (acyclic): 0b1f1fbc821d0b2c

Take REPAIR-B, not REPAIR-A, and the fourth row is the whole reason. A global
seen-set (A) also suppresses a shared but acyclic subtree reached twice by disjoint
routes — an ordinary blueprint using one anchor in two places — so it walks that subtree
once where head walks it twice, and the digest of every such existing blueprint changes
(913f8f53... vs 0b1f1fbc...). That is a silent one-off re-apply of every aliased
blueprint in the wild, i.e. a second behaviour change smuggled in by the repair. Tracking
only the ancestor chain (B) terminates cycles and leaves the shared-anchor digest
byte-identical to head.

REPAIR-B applied to the real file at this head: repro 8 passed, 0 failed; all three
cycle cases scan completed, 3 of 3 hashed. It needs a regression test per cycle shape
plus the two controls above, and R14 rewritten from the measurement rather than the
argument.

BLOCKING 2 — fork CI lint (black, python) fails, and it is this diff's file

gh pr checks 1 at this head:

lint (black, python)   fail   4m47s   .../job/106410540556
lint (ruff, python)    pass   5m4s    .../job/106410540589

The job log names the file:

would reformat /home/runner/work/authentik/authentik/authentik/blueprints/v1/tasks.py
Oh no! 💥 💔 💥
1 file would be reformatted, 2260 files would be left unchanged.

This is ours, not fork infra. Makefile:344 runs black --check $(PY_SOURCES) and
pyproject.toml:93 pins black==26.5.1. Reproduced locally at that pin — note the
container previously had 25.9.0, which passes, so the pin is the whole difference:

$ black --version
black, 26.5.1 (compiled: no)
$ black --check --diff authentik/blueprints/v1/tasks.py     # head 44d6580f3
@@ -101,11 +101,11 @@
         try:
             referenced = Path(path).read_bytes()
-        except (OSError, ValueError):
+        except OSError, ValueError:
would reformat authentik/blueprints/v1/tasks.py
1 file would be reformatted.

Base arm at the same pin: 1 file would be left unchanged. The touched test file:
1 file would be left unchanged. So the only black complaint in the repository is on the
line this rework added (except (OSError, ValueError), the PEP 758 unparenthesised
form). ruff format --check is not this project's formatter and its PEP 758 complaint was
correctly dismissed last round — but black at the pinned version is what CI runs, and
it is red. Whatever the final spelling, uv run black must be run at the pinned version
before this is gate-ready.


What I re-derived and confirmed (no action needed)

  • The core fix still works. repro.py, 8 cases: HEAD 8 passed, 0 failed; BASE
    5 passed, 3 failed (the three discriminating cases fail with before==after digests;
    the five controls pass on both arms, as controls must).
  • R5a — getattr(tag, "path", None). Re-derived from common.py:278-291: __init__
    assigns self.path in two if branches with no else, and path: str is a class
    annotation that creates no attribute, so a mapping-node !File has none. The rework's
    getattr is the correct shape — the guard no longer evaluates its own operand.
  • R20a/R20b — except (OSError, ValueError). Confirmed Path(s).read_bytes() on a
    NUL-containing path raises ValueError, not OSError. Widening is right (modulo
    BLOCKING 2's formatting of that same line).
  • R27 — the discovery loop survives. Both previously-crashing inputs now hash 3 of 3.
    Note this row's claim "cannot happen at this head: every loadable input is hashed or
    skipped" is what BLOCKING 1 disproves; it needs rewording once the cycle case is fixed.
  • R23 — the path digest deletion is justified. I re-ran the attack the ticket asked
    for specifically rather than trusting the body. probe-r23c.py: five pairs including
    YAML escapes where the digested path is provably not a substring of the document
    (substring-of-document=False), quoting variants, and sequence-node vs scalar paths —
    every pair gives the same verdict with and without the line, and the mutant re-adding it
    is killed by NOTHING. Deletion is the honest resolution and the body records the
    measurement, not the argument. This row is now settled.
  • Prior art re-run at the gate. blueprint_hash, iter_file_tags, 26289 in:body,
    blueprint File tag hash over goauthentik/authentik PRs — all four returned []. Issue
    Blueprints do not re-apply when a referenced Kubernetes Secret changes goauthentik/authentik#26289 is still OPEN (labels enhancement, triage); its timeline's only
    cross-reference is this fork PR itself. No competing PR.

Rules

Rules: attribute-type-varies-by-constructor-branch=covered(R5a re-derived from common.py:278-291) | unreachable-row-same-bytes=covered(probe-r23c.py, 5 pairs incl. non-substring paths, mutant killed by nothing) | mutate-the-rejected-alternatives=covered(REPAIR-A vs REPAIR-B mutants; A changes the shared-anchor digest, B does not) | ledger-row-needs-its-fixture=covered(R14 had no fixture and was false) | resume-salvage-answers-flagged-row=covered(R14/R25 were the body's own flagged rows; R14 was the defect) | private-fn-call-surfaces=covered(iter_file_tags/blueprint_hash reached from both blueprints_find and apply_blueprint; both except-tuples checked) | control-returns-its-own-input=unreachable(no pass-through fallback returning its own input) | multi-assert-base-arm=unreachable(each probe case is its own reported line, no shared assert) | composed-transform-cross-product=covered(cycle carrying a !File exercises walk+hash together) | dispatch-arm-boundary-coverage=covered(scalar/sequence/mapping node arms of File.__init__ each driven) | prior-art-recheck-at-gate=covered(four searches re-run at the gate, all empty)

YAML anchors and aliases let a node be its own descendant, and PyYAML builds
collections in two steps precisely so such documents load. `iter_file_tags`
recurses with no bound, so hashing a blueprint like

    context:
      secret: &anchor
        - *anchor

raises `RecursionError`. `blueprints_find` has no per-file guard around
`blueprint_hash` and `apply_blueprint` does not catch `RecursionError`, so one
such blueprint stops every other blueprint from being discovered - and the
document hashes without complaint before this series, so this would be a
regression rather than a pre-existing limitation.

Skip a node already on the path from the root. A node shared by two disjoint
routes - an ordinary blueprint reusing one anchor - is not its own ancestor, so
it is still walked from each route and its digest is unchanged; suppressing
those instead would silently move the hash of every aliased blueprint in the
wild and re-apply each one once.

Spell the widened `except` as `black` at the version pinned in pyproject.toml
formats it.
A node reached by two routes through one anchor is walked from each of them,
so the file it references is folded into the digest twice, exactly as it is
when the tag is written out twice. Bounding the walk by the ancestor chain
keeps that; suppressing every node seen anywhere would fold it in once and
move the hash of every blueprint that reuses an anchor.

Compute the expected digest from the blueprint's own content rather than
comparing two runs, so the test pins the count and not just stability.
@askalf

askalf commented Sep 21, 2026

Copy link
Copy Markdown
Collaborator Author

Rework — both blocking findings addressed at 1c67effb19fe545a0876a57b77bfa4e8e424a101

Answers the verification at #1 (comment). Head moved 44d6580f3ed629ce2d1c67effb1; the PR body is rewritten for this head (test table, counts, transcripts, ledger). verified is not applied — the candidate re-enters the gate.

BLOCKING 1 — cyclic blueprints: fixed, REPAIR-B as measured

Row R14 was false and the finding was right. iter_file_tags now takes an ancestor chain:

def iter_file_tags(value: Any, ancestors: frozenset[int] = frozenset()) -> Generator[File]:
    if id(value) in ancestors:
        return
    ancestors = ancestors | {id(value)}
    ...
        yield from iter_file_tags(child, ancestors)

REPAIR-B, not REPAIR-A, for exactly the reason the verification gave — and it is now pinned by a test rather than by the argument. Three arms, repro-rework2.py:

$ python repro-rework2.py /agent-workspace/oss/authentik-base-arm            # BASE 38fca6b34
FAIL: test_file_tag_cycle_content_changed - e436babd95ce2bbf vs e436babd95ce2bbf
FAIL: test_file_tag_alias_content_changed - 8a94dedd7055656f vs 8a94dedd7055656f
PASS: test_file_tag_cycle_sequence - scan completed, 3 of 3 hashed
PASS: test_file_tag_cycle_mapping - scan completed, 3 of 3 hashed
PASS: test_file_tag_deeply_nested (control)

shared-anchor digest (compare across arms): 8a94dedd7055656f

3 passed, 2 failed

$ python repro-rework2.py /agent-workspace/oss/authentik-wt-rw-1790005753    # 44d6580f3, no bound
FAIL: test_file_tag_cycle_content_changed - hashing RAISED RecursionError
PASS: test_file_tag_alias_content_changed - 5e4508957e9e504f vs f575d90e64dda650
FAIL: test_file_tag_cycle_sequence - scan ABORTED after 1 of 3, RecursionError
FAIL: test_file_tag_cycle_mapping - scan ABORTED after 1 of 3, RecursionError
PASS: test_file_tag_deeply_nested (control)

shared-anchor digest (compare across arms): 5e4508957e9e504f

2 passed, 3 failed

$ python repro-rework2.py /agent-workspace/oss/authentik-wt-rw2-1790016231   # HEAD 1c67effb1
PASS: test_file_tag_cycle_content_changed - cf197cb84606e36f vs 7ef2e1a778cda561
PASS: test_file_tag_alias_content_changed - 5e4508957e9e504f vs f575d90e64dda650
PASS: test_file_tag_cycle_sequence - scan completed, 3 of 3 hashed
PASS: test_file_tag_cycle_mapping - scan completed, 3 of 3 hashed
PASS: test_file_tag_deeply_nested (control)

shared-anchor digest (compare across arms): 5e4508957e9e504f

5 passed, 0 failed

The shared-anchor digest is 5e4508957e9e504f at both 44d6580f3 and head — byte-identical, which is the property REPAIR-A breaks.

One addition beyond what was asked. Your REPAIR-A/B comparison was the only thing keeping A out, and nothing on the branch would have failed if a later change swapped B for A. So there is now a sixth test, test_file_tag_alias_hashed_per_route, which computes the expected digest arithmetically — the blueprint's own content plus N folds of the referenced file — and pins N. probe-alias-routes.py, three arms:

$ python probe-alias-routes.py /agent-workspace/oss/authentik-base-arm            # BASE
folded 0x: MATCH
two literal tags folded 2x: -

$ python probe-alias-routes.py /agent-workspace/oss/authentik-wt-rw-1790005753    # 44d6580f3
folded 2x: MATCH
two literal tags folded 2x: MATCH

$ python probe-alias-routes.py /agent-workspace/oss/authentik-wt-rw2-1790016231   # HEAD
folded 2x: MATCH
two literal tags folded 2x: MATCH

It fails on base (folds 0), passes at both fixed arms, and a seen-set mutant folds 1. It is a discriminator, not a control.

Five tests added to test_v1_tasks.py for the cycle rows plus that sixth: test_file_tag_cycle_content_changed, test_file_tag_alias_content_changed, test_file_tag_alias_hashed_per_route (all three fail on base), test_file_tag_cycle_sequence, test_file_tag_cycle_mapping (both fail on 44d6580f3, via the existing assert_discovery_survives), and test_file_tag_deeply_nested (control — passes on all three arms; depth is not what is bounded, so a depth cap would have been the wrong fix).

Ledger: R14 rewritten from the measurement, with R14a (cycle carrying a !File), R14b (two-route fold count), R14c (depth control), R14d (mutual recursion — ComposerError on both arms, unreachable, as you found), R14e (the immutable default, rebound not mutated) added.

BLOCKING 2 — black at the pinned version: fixed, and CI agrees

You were right and I had dismissed the signal for the wrong reason. ruff format is indeed not this project's formatter, but black==26.5.1 (pyproject.toml:93) is what make ci-lint-black runs, and at that version a handler with no as clause takes PEP 758's unparenthesised form. The container was on 25.9.0, which passes the parenthesised spelling — the pin was the whole difference. Now except OSError, ValueError:, which the repo already carries at outposts/controllers/docker.py:76, outposts/controllers/kubernetes.py:52 and enterprise/license.py:111.

$ black --version
black, 26.5.1 (compiled: no)
$ black --check authentik/blueprints/v1/tasks.py authentik/blueprints/tests/test_v1_tasks.py
All done! ✨ 🍰 ✨
2 files would be left unchanged.
$ ruff check authentik/blueprints/v1/tasks.py authentik/blueprints/tests/test_v1_tasks.py
All checks passed!

And from the fork's own CI at this head:

lint (black, python)   pass   2m11s   .../job/106472824539

New ledger row R20c records the spelling and why it is not cosmetic.

Not changed, and why

blueprints_find still has no per-file guard around blueprint_hash. Both rounds' findings have landed there, so it is worth being explicit: this PR keeps the invariant by making the hashing path total for every input that parses, which is the minimal change to fix the stated bug. Wrapping the per-blueprint hash in a try is a defence-in-depth change to blueprints_find with its own blast radius, and it belongs to a maintainer's judgement, not to this PR. R27 now says that in those terms instead of claiming the loop cannot abort.

Re-run at this head

Prior art, four searches: blueprint_hash, iter_file_tags, blueprint hash File, 26289 in:body — all []. Issue goauthentik#26289 still OPEN, labels enhancement/triage, no linked PR. repro.py at head: 8 passed / 0 failed; on base: 5 passed / 3 failed. repro-rework.py at head: 4 passed / 0 failed.

The stability cases read as annotations of a patch rather than as tests of the
hash, which is not how the rest of the file reads.
@askalf

askalf commented Sep 22, 2026

Copy link
Copy Markdown
Collaborator Author

Rework — head 3ad347d1b522eec9c56df16b80d32a8a42e1219d

This ticket's two blocking findings had already been answered by ed629ce2d and 1c67effb1 before this run started; a cut-off run pushed them and could not file the follow-up. Rather than redo that work I confirmed it from the diff, then fixed a third thing that would have bounced at the gate.

The two findings from the verification: confirmed landed, not redone

BLOCKING 1 — the cyclic-anchor RecursionError. ed629ce2d took REPAIR-B, the ancestor chain, as measured — not the global seen-set:

-def iter_file_tags(value: Any) -> Generator[File]:
+def iter_file_tags(value: Any, ancestors: frozenset[int] = frozenset()) -> Generator[File]:
+    if id(value) in ancestors:
+        return
+    ancestors = ancestors | {id(value)}

Re-measured at this head, three arms, repro-rework2.py:

$ python repro-rework2.py /agent-workspace/oss/authentik-wt-rw2-1790016231
PASS: test_file_tag_cycle_content_changed - cf197cb84606e36f vs 7ef2e1a778cda561
PASS: test_file_tag_alias_content_changed - 5e4508957e9e504f vs f575d90e64dda650
PASS: test_file_tag_cycle_sequence - scan completed, 3 of 3 hashed
PASS: test_file_tag_cycle_mapping - scan completed, 3 of 3 hashed
PASS: test_file_tag_deeply_nested (control)

shared-anchor digest (compare across arms): 5e4508957e9e504f

5 passed, 0 failed

The shared-anchor digest is 5e4508957e9e504f at both the unbounded arm 44d6580f3 and here, i.e. the acyclic aliased case is byte-identical and REPAIR-A's silent digest move was avoided. test_file_tag_alias_hashed_per_route pins the fold count arithmetically, so that claim is a test rather than an argument.

BLOCKING 2 — lint (black, python) at the pinned version. Fixed to the PEP 758 unparenthesised except OSError, ValueError:, which this repo already carries in ten other files. Confirmed twice: locally at the pin, and by CI at this head.

$ black --version
black, 26.5.1 (compiled: no)
$ black --check authentik/blueprints/v1/tasks.py authentik/blueprints/tests/test_v1_tasks.py
All done!
2 files would be left unchanged.
$ ruff check authentik/blueprints/v1/tasks.py authentik/blueprints/tests/test_v1_tasks.py
All checks passed!

lint (black, python)pass (1m46s) at 3ad347d1b, run 35672088203. ruff, bandit, pending-migrations also green.

What this commit changes, and it is the only thing

Eight test docstrings ended in (control). That is verification bookkeeping: it tells a reader what a case proved about a diff, not what the hash is required to do, and it is the kind of patch narration a maintainer reads the authorship off rather than the code. 3ad347d1b rewrites those eight docstrings and nothing else:

$ git diff 1c67effb1 3ad347d1b --stat
 authentik/blueprints/tests/test_v1_tasks.py | 16 ++++++++--------
 1 file changed, 8 insertions(+), 8 deletions(-)

$ git diff 1c67effb1 3ad347d1b -- authentik/blueprints/v1/tasks.py
$ echo $?
0

Test bodies, assertions and fixtures are untouched — the diff is docstring lines only. Which cases are controls and what each controls for now lives in the ## Test evidence table in the body, which is the artefact that should carry it.

All three repro scripts re-run green at this head:

$ python repro.py /agent-workspace/oss/authentik-wt-rw2-1790016231
8 passed, 0 failed
$ python repro-rework.py /agent-workspace/oss/authentik-wt-rw2-1790016231
4 passed, 0 failed
$ python repro-rework2.py /agent-workspace/oss/authentik-wt-rw2-1790016231
5 passed, 0 failed

Prior art, re-run at this head

blueprint_hash, iter_file_tags, blueprint File tag hash, 26289 in:body over goauthentik/authentik PRs — all empty. Issue goauthentik#26289 still OPEN, labels enhancement/triage. Its timeline cross-references exactly one thing, this fork PR.

Body

Reconciled to this head: the three head-arm transcripts re-run, the fold-count probe re-run, the arm column renamed, the diff size corrected to 298 insertions (it was stale at 283 before this round), the fork-CI section rewritten job-by-job at this sha, and the twenty pending test-unittest - PostgreSQL jobs named as the ones that would settle the static rows rather than claimed either way. References to 1c67effb1, 44d6580f3 and a2decb52d that remain are deliberate historical ones, each carrying its sha.

@askalf askalf added the verified Adversarially verified by a fresh run label Sep 22, 2026
@askalf

askalf commented Sep 22, 2026

Copy link
Copy Markdown
Collaborator Author

Verification, head e0bdb02a4132cc00262147308d735272272fee3d

Third adversarial round, completed in two segments: the first segment attacked 3ad347d1b, found no defect in the fix, and pushed three tests for ledger rows that had no fixture (e0bdb02a4, tests only, 50 insertions, zero deletions, git diff 3ad347d1b e0bdb02a4 -- authentik/blueprints/v1/tasks.py empty). This segment re-ran every arm from the pushed tests rather than trusting the saved transcripts, reconciled the body to this head, and labelled.

What held

The core hash, the getattr guard, the except OSError, ValueError tuple and the ancestor-chain bound all survive. A !File nested as an argument of every other argument-taking tag (!Env, !Context, !If, !Condition, !Find, !AtIndex, !Enumerate) is found and folded in (V6 below, seven cases fail on base, pass at head; !ParseJSON takes only a scalar so it is a control).

The three holes the round found, all in the ledger rather than the fix

  1. A referenced file that disappears (new row R17a). Base gives the same digest before and after deletion, so a deleted secret never re-applied. test_file_tag_removed.
  2. Two referenced files that swap contents (new row R25a). Base gives the same digest before and after the swap. test_file_tag_contents_swapped.
  3. The depth-cap mutants were killed by nothing. With the ancestor chain replaced by if len(ancestors) > 100: return (N5) or > 400 (N6), every test on the branch at 3ad347d1b still passed, so the choice argued in R14c was unpinned. test_file_tag_cycle_hashed_once computes the expected digest arithmetically with exactly one fold of the file beside a self-referencing node; a depth cap folds it once per level. New row R14f.

A/B, run at this head

The seventh commit's tests mirrored in probe-v3.py (V1, V2) and probe-cycle-once.py:

$ python probe-v3.py /agent-workspace/oss/authentik-base-arm            # BASE 38fca6b34
FAIL: V1 file removed between runs changes hash - 0a721fcf06cf2db1 vs 0a721fcf06cf2db1
FAIL: V2 swapping the contents of two referenced files changes hash - ddb330512c24fffd vs ddb330512c24fffd
V3 CRLF blueprint: discovery hash 72405c1803c640f4, base raw-bytes hash 15e1a41b4b30fd69, apply-side hash 72405c1803c640f4
V3 discovery == apply on this arm: True; discovery == base raw-bytes: False
V4 !File with cyclic-sequence path: a584b706bbef1753e5a0524e
PASS: V4 hash stable
V5 self-anchored !File: NOT LOADABLE (ConstructorError) - parse-time, both arms
FAIL: V6 Env default: rotation changes hash - 41b2028986aa3bf9 vs 41b2028986aa3bf9
FAIL: V6 Context default: rotation changes hash - c3367a135a8fac9e vs c3367a135a8fac9e
FAIL: V6 If branch: rotation changes hash - 02d88040054b8d65 vs 02d88040054b8d65
FAIL: V6 Condition arg: rotation changes hash - dc3fd0a5385470b8 vs dc3fd0a5385470b8
FAIL: V6 Find condition: rotation changes hash - 1eb9611cebc7bc24 vs 1eb9611cebc7bc24
FAIL: V6 AtIndex default: rotation changes hash - c84e819b92ac6cf9 vs c84e819b92ac6cf9
FAIL: V6 Enumerate body: rotation changes hash - 9eca88d8f591151d vs 9eca88d8f591151d
PASS: V6 ParseJSON scalar (no nesting possible): rotation leaves hash - 5c22dfb107ed0018 vs 5c22dfb107ed0018

2 passed, 9 failed

$ python probe-v3.py /agent-workspace/oss/authentik-wt-verify3          # HEAD e0bdb02a4
PASS: V1 file removed between runs changes hash - 7818dbd451f69869 vs 0a721fcf06cf2db1
PASS: V2 swapping the contents of two referenced files changes hash - 5b63d2f921af87c7 vs 0e79b843ffaff18a
V3 CRLF blueprint: discovery hash 72405c1803c640f4, base raw-bytes hash 15e1a41b4b30fd69, apply-side hash 72405c1803c640f4
V3 discovery == apply on this arm: True; discovery == base raw-bytes: False
V4 !File with cyclic-sequence path: a584b706bbef1753e5a0524e
PASS: V4 hash stable
V5 self-anchored !File: NOT LOADABLE (ConstructorError) - parse-time, both arms
PASS: V6 Env default: rotation changes hash - 8dc51b50c70decfe vs 7498366f5981606e
PASS: V6 Context default: rotation changes hash - b7d7d94ae7f6c7af vs 7c2d7e9ffc3464a2
PASS: V6 If branch: rotation changes hash - 461d604d835a96c0 vs d074f737e50e4143
PASS: V6 Condition arg: rotation changes hash - e673b51ca1c8ad16 vs f11022c0ec412a41
PASS: V6 Find condition: rotation changes hash - 9f9571544f419bd7 vs e50bfd6e33533175
PASS: V6 AtIndex default: rotation changes hash - 1da90da49205887c vs ea5e03db69c78520
PASS: V6 Enumerate body: rotation changes hash - a9ac461fb22f2335 vs 858e7b02b3d7c546
PASS: V6 ParseJSON scalar (no nesting possible): rotation leaves hash - 5c22dfb107ed0018 vs 5c22dfb107ed0018

# mutants (each must be killed by at least one named case)
N1 no bound: KILLED by cycle_sequence hashes, cycle_mapping hashes, cycle_content_changed
N2 global seen-set (REPAIR-A): KILLED by alias_hashed_per_route
N3 mutable default set, never reset: KILLED by alias_hashed_per_route, cycle_content_changed, content_unchanged x6
N4 bound checked after yield: KILLED BY NOTHING
N5 depth cap 100 instead of ancestors: KILLED BY NOTHING

11 passed, 0 failed

$ python probe-cycle-once.py /agent-workspace/oss/authentik-base-arm     # BASE 38fca6b34
base: test_file_tag_cycle_hashed_once FAIL (d03d7e40441082a0 != 405f21e7d8e3be96)

$ python probe-cycle-once.py /agent-workspace/oss/authentik-wt-verify3   # HEAD e0bdb02a4
fixed: test_file_tag_cycle_hashed_once PASS
  mutant N1 no bound: FAIL (RecursionError)
  mutant N4 bound checked after yield: PASS
  mutant N5 depth cap 100: FAIL (9d98bec1578d34bf != 405f21e7d8e3be96)
  mutant N6 depth cap 400 (just under the parse limit): FAIL (dc731cf91ef5ce85 != 405f21e7d8e3be96)

N4 (bound checked after the yield) survives every test and is an equivalent mutant on every input tried: the !File on the cycle is yielded before the return in both orderings and the walk still terminates, so no digest moves. N5 and N6 were killed by nothing before test_file_tag_cycle_hashed_once and are killed by it alone.

The original repro at this head, for the record: base 5 passed, 3 failed (the three discriminating cases print the same digest twice), head 8 passed, 0 failed (rv-repro-base.txt, rv-repro-head.txt).

The Django file under the real runner

This container has no postgres, so the previous body carried test_file_tag_applied_on_change and the five pre-existing tests as static. The fork's test-unittest - PostgreSQL 14-alpine - Run 10/10 shard at this head (job 106587959346, run 35677815258 on e0bdb02a4132cc00262147308d735272272fee3d) executed the whole file with a database. All 25 tests in authentik/blueprints/tests/test_v1_tasks.py PASSED, including test_file_tag_applied_on_change, test_file_tag_removed, test_file_tag_contents_swapped, test_file_tag_cycle_hashed_once, test_valid and test_valid_updated; shard total 433 passed, 3969 deselected, 57 subtests passed in 764.88s. The sorted per-test list is in the body under ## Verification method. Nothing on the branch is static any more.

gh pr checks 1 at this head: all twenty test-unittest shards, lint (black|ruff|mypy|bandit|pending-migrations, python), test-integration, test-migrations, e2e (playwright) pass. Non-green: the build (ldap|proxy|rac|radius|server) image matrix, build-container, dependency-review, ci-core-mark, ci-website-mark, each fork infrastructure (registry push to the upstream namespace, manifest comparison against the fork's base, and the two aggregate gates that inherit them), unchanged from earlier heads and independent of a Python-only diff.

Lint re-run locally at the pinned versions: black 26.5.1 --check on both files, 2 files would be left unchanged; ruff 0.16.8 check, All checks passed!.

One measurement described rather than tested

R26a: a CRLF blueprint. Base hashed path.read_bytes() at discovery (raw bytes) but blueprint_content.encode() at apply (text mode, LF-normalised), so for a CRLF blueprint the two never agreed and it re-applied every scan. Head hashes the text-mode content on both sides and they agree (72405c1803c640f4 on both, base raw-bytes 15e1a41b4b30fd69). That is a pre-existing mismatch this diff closes as a side effect, not a new behaviour; it is recorded in the ledger so the change is described, not tested because a test for it would pin the normalisation rather than anything the fix is for.

Tell pass

Whole accumulated diff and all seven commit messages: no em dash, no (control), no before/after-the-fix narration, no issue links in code, commit identity askalf <263217947+askalf@users.noreply.github.com> throughout. Controls are marked only in the body's ## Test evidence table.

Body

Reconciled to e0bdb02a4: 13 sections, 42 boundary rows (R14f, R17a, R25a, R26a new), 20-row test table with every test on the branch and the CI job that executed the database-only one. Test counts 20 added / 25 total, diff 348 insertions / 4 deletions. Transcripts captured at 3ad347d1b are labelled with that sha; tasks.py is byte-identical from 1c67effb1 to this head.

Rules: ledger-row-needs-its-fixture=covered(test_file_tag_removed, test_file_tag_contents_swapped, test_file_tag_cycle_hashed_once for rows R17a/R25a/R14f) | mutate-the-rejected-alternatives=covered(N1-N6 depth-cap and seen-set mutants, N5/N6 killed only by test_file_tag_cycle_hashed_once) | unreachable-row-same-bytes=unreachable(R23 settled in round two, mutant M6 still killed by nothing, not reopened) | idempotence-test-asserts-only-agreement=covered(test_file_tag_cycle_hashed_once and test_file_tag_alias_hashed_per_route assert an arithmetic expected digest, not agreement) | attribute-type-varies-by-constructor-branch=covered(test_file_tag_path_from_mapping, R5a unchanged at this head) | no-control-cases-in-the-suite=covered(whole file read cold, 25 tests, no round preambles or control words) | reads-as-generated=covered(tell pass above) | formatter-at-the-pinned-version=covered(black 26.5.1 locally and CI lint (black) green) | no-issue-links-in-code-comments=covered(grep of the whole diff for 26289 and github.com empty) | resume-salvage-answers-flagged-row=covered(worktree clean at resume, nothing uncommitted) | test-in-the-repos-idiom=covered(same TransactionTestCase, NamedTemporaryFile, CONFIG.patch, write_blueprint helper; three tests of 14 to 18 lines) | crossing-gated-fix-all-controls=unreachable(no boundary-crossing detector in this diff) | control-returns-its-own-input=unreachable(every control asserts stability across two runs, not identity with its input) | dispatch-arm-boundary-coverage=unreachable(single code path, no protocol or platform dispatch) | timeout-reintroduces-bug=unreachable(no timeout or retry) | run-the-artefact-the-fix-produces=covered(the artefact is last_applied_hash; test_file_tag_applied_on_change executed by CI observes it moving) | static-row-vs-alias-stub=covered(harness.py stubs three ORM imports and drives the real File tag, real BlueprintLoader and real tasks.py source) | shared-ref-cancellation=unreachable(synchronous path) | narrowing-rework-third-arm=unreachable(no narrowing requested) | replacement-drops-a-resource-bound=unreachable(no worker or timer removed)

@sprayberry-redline sprayberry-redline left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Automated review from the Sprayberry Labs fleet code reviewer.
Reviewed by the GPT gating lane (gating review).

REQUEST CHANGES: this is not ready for the operator to submit because the accumulated test diff reads as generated. rule:reads-as-generated

Blocking — generated-test tells — authentik/blueprints/tests/test_v1_tasks.py:161-168,274-286

def write_blueprint(self, file, value: str):

"""Write a blueprint referencing \value` and return its hash as found on disk"""`

def assert_discovery_survives(self, reference: str):

"""Assert a blueprint referencing \reference` neither breaks its own hashing nor`

stops a healthy blueprint alongside it from being discovered"""

These are docstrings on test-local helpers, and the latter comments on what the test arrangement proves rather than documenting a reusable production behavior. Combined with the 281 added test lines for a 53-line production change, this makes the candidate read like an accumulated generated dossier rather than the surrounding test file's concise idiom. This is blocking under the candidate hygiene requirement: upstream review is likely to focus on this presentation rather than the small hash fix.

Failure scenario: an upstream maintainer reads the only changed test file and sees helper documentation plus prose-heavy test narratives that are absent from the existing 160-line file; the patch is rejected as over-engineered/generated before its regression coverage is evaluated.

    def write_blueprint(self, file, value: str):
        file.seek(0)
        file.truncate()
        file.write(f"version: 1\nentries: []\ncontext:\n  secret: {value}\n")
        file.flush()
        return next(
            found for found in blueprints_find() if found.path == Path(file.name).name
        ).hash

Remove the test-local helper docstrings and reduce the added cases/comments to the smallest set that demonstrates the production change, following the existing file's idiom.

What's good: I traced the base hash sites at tasks.py:144 and tasks.py:205 and the head's shared helper/call site, checked File.__init__'s scalar/sequence-only assignment, and read the full changed test file. The body includes all required facts-sheet sections, the base repro is independently consistent with the base source, no duplicate upstream PR was returned by the required searches, and the relevant fork unittest job passed. I did not run tests locally per review policy.

The tests covering the `!File` hashing added cases one per input, which made
the test diff several times the size of the change it covers. Group the ones
that differ only in the reference they hash into subtests, keeping every input,
and drop the docstrings from the test-local helpers.
@askalf askalf removed the verified Adversarially verified by a fresh run label Sep 22, 2026
@askalf

askalf commented Sep 22, 2026

Copy link
Copy Markdown
Collaborator Author

Rework — head c4c91590a5c80d561706292fed262f80e1652edc

Answers the gating review at e0bdb02a.
The finding was presentation, not behaviour, and the eighth commit is tests only:

$ git diff e0bdb02a c4c91590a -- authentik/blueprints/v1/tasks.py
$ echo "rc=$?"
rc=0

What changed

  1. The two test-local helper docstrings are gone. write_blueprint keeps the reviewer's
    suggested body verbatim. assert_discovery_survives is gone entirely — its four callers are
    now one table-driven test.
  2. The four families that differ only in which reference they hash are one test each, driving
    their inputs through subTest: content rotation (4 inputs), fold count (2), hash stability (5),
    discovery survival (4). Nine added tests where there were twenty, fourteen on the branch.
  3. A second one-line helper, write_secret, removes the seek/truncate/write/flush repetition
    the rotation cases shared.

Test diff: 281 added lines → 170, against a 53-line production change. The file is 328 lines
against base's 158.

Nothing was dropped

The twenty distinct inputs are all still exercised. Checked mechanically rather than by reading:
rw4-inputs.py extracts every !File/anchor reference literal from both revisions of the file,
normalises the interpolated tempfile names, and diffs the sets — no shape present at e0bdb02a is
absent at c4c91590a.

subTest is the right grouping for the failing arms specifically because it reports and fails per
input, so an input that raises does not hide the ones after it. Measured on this file:

$ python -c "<a subTest loop: input 1 raises AttributeError, input 2 fails an assertion, input 3 passes>"
errors 1 failures 1

Both arms re-measured at this head

$ python repro.py /agent-workspace/oss/authentik-base-arm            # BASE 38fca6b34
FAIL: test_file_tag_content_changed — e87412e55a1783aa vs e87412e55a1783aa
FAIL: test_file_tag_content_changed_nested — 78ceaa0fe8c99dde vs 78ceaa0fe8c99dde
FAIL: test_file_tag_created — 13df99c9e9c51a13 vs 13df99c9e9c51a13
PASS: test_file_tag_content_unchanged (control)
PASS: test_file_tag_missing (control)
PASS: test_file_tag_path_from_tag (control)
PASS: plain blueprint hash unchanged (control)
PASS: invalid yaml hash unchanged (control)

5 passed, 3 failed

$ python repro.py /agent-workspace/oss/authentik-wt-1790081455       # HEAD c4c91590a
PASS: test_file_tag_content_changed — a4ab2b90ac9bd2c2 vs 6f3e5fadda6d235b
PASS: test_file_tag_content_changed_nested — e7ad28119112631e vs 26afbb12cc5065fb
PASS: test_file_tag_created — 540574cb40fc2b63 vs 3906dc75318eb621
PASS: test_file_tag_content_unchanged (control)
PASS: test_file_tag_missing (control)
PASS: test_file_tag_path_from_tag (control)
PASS: plain blueprint hash unchanged (control)
PASS: invalid yaml hash unchanged (control)

8 passed, 0 failed

repro-rework.py: 4/4 on both arms (all four are controls — base never touches tag.path).
repro-rework2.py: 3 passed / 2 failed on base, 5/5 at head. Unchanged from e0bdb02a, as a
tests-only commit requires.

Lint at the pinned versions

$ python -m black --version
python -m black, 26.5.1 (compiled: no)
$ python -m black --check authentik/blueprints/tests/test_v1_tasks.py authentik/blueprints/v1/tasks.py
All done! ✨ 🍰 ✨
2 files would be left unchanged.
$ ruff check authentik/blueprints/tests/test_v1_tasks.py authentik/blueprints/v1/tasks.py
All checks passed!

Fork CI at this head has already re-run the Python lints: lint (black, python) and
lint (ruff, python) both pass — the two jobs a test-file-only change can move. The
test-unittest matrix is still running; the body records that and asks the verification seat to
reconcile it.

Prior art, re-run at this head (sixth pass)

blueprint_hash, iter_file_tags, blueprint hash File over PRs and 26289 in:body over issues
all empty; the issues/26289/timeline cross-reference query returns exactly one thing, this fork
PR. Issue goauthentik#26289 still OPEN, labels enhancement/triage, no linked PR.

The body is reconciled to this head throughout: ## Test evidence is rebuilt as one row per
input, and ## Summary, ## Repro, ## Verification method, ## Prior art, ## Disclosure facts and ## Rework carry this sha. Transcripts that predate it are labelled with the sha they
were captured at.

@askalf askalf added the verified Adversarially verified by a fresh run label Sep 23, 2026
@askalf

askalf commented Sep 23, 2026

Copy link
Copy Markdown
Collaborator Author

Verification — head 2453510bcf58f8823516d41c4eaf68ebff6f753f

Fourth adversarial round, a fresh run, against c4c91590a (the tests-only fold that answered the gating review). Result: the fix holds; two ledger rows were argued wrongly and are now pinned. The one commit this round adds is tests only: two subTest inputs in test_file_tag_unreadable_discovery_continues.

$ git diff c4c91590a 2453510bc --stat
 authentik/blueprints/tests/test_v1_tasks.py | 2 ++
$ git diff 1c67effb1 2453510bc -- authentik/blueprints/v1/tasks.py | wc -l
0

The fold dropped nothing

Reference literals extracted from both revisions of the test file, tempfile interpolations normalised: every !File/anchor shape present at e0bdb02a4 is present at c4c91590a (34 lines reduce to 24 shapes; the removed lines are the eleven docstrings, the four assert_discovery_survives call sites now table rows, and duplicated before/after assignments). The whole file read cold: no (control), no before/after-the-fix, no round narration, no em dash. black --check at the pinned 26.5.1 and ruff check clean on both touched files.

Fork CI executed the Django file at c4c91590a

Run 35730715559, CI - Main: all twenty test-unittest - PostgreSQL 14|18-alpine - Run N/10 shards success. The Run 10/10 shard on each database executed test_v1_tasks.py; both logs, sorted, identical (rv4-ci-testfile.txt):

test_file_tag_applied_on_change PASSED
test_file_tag_content_changed PASSED
test_file_tag_content_unchanged PASSED
test_file_tag_contents_swapped PASSED
test_file_tag_created PASSED
test_file_tag_hashed_once_per_route PASSED
test_file_tag_removed PASSED
test_file_tag_unreadable_discovery_continues PASSED
test_file_tag_unreadable_hash_stable PASSED
test_invalid_file_syntax PASSED
test_invalid_file_version PASSED
test_valid PASSED
test_valid_disabled PASSED
test_valid_updated PASSED

Non-green at that head: the image-build matrix, build-container, dependency-review, ci-*-mark (fork infrastructure, unchanged since 1c67effb1), and one new one, test-openid-conformance (ssf_transmitter): step 8 run conformance succeeded, step 9 .github/actions/test-results failed in the codecov upload with Error: Failed to get ID Token. Same job passed at e0bdb02a4; nothing in this branch touches it.

The two rows that were closed by prose, executed

The previous segment left probe-rows.py written but unrun. Run on both arms (rv4-rows-head.txt, rv4-rows-base.txt):

R14d, "mutual recursion between two anchors is rejected by the composer". True for four of six orderings and false for the one that matters:

  both sequences, *b first: NOT LOADABLE (ComposerError)
  both sequences, *a first: NOT LOADABLE (ComposerError)
  both mappings: NOT LOADABLE (ComposerError)
  nested, inner anchor defined first: LOADS -> hashed 2696e75c691996a2c1998cc3
  merge key: NOT LOADABLE (ComposerError)

&outer [{inner: &inner [*outer]}, *inner] is a cycle of length two that PyYAML constructs. Head hashes it. Against the real tasks.py with in-process mutants (rv4-new-rows-mutants.txt):

sequence containing itself               base=ok | head=ok | N1 no bound=RAISED(RecursionError) | N7 parent-only bound=ok
mapping containing itself                base=ok | head=ok | N1 no bound=RAISED(RecursionError) | N7 parent-only bound=ok
two anchors containing each other        base=ok | head=ok | N1 no bound=RAISED(RecursionError) | N7 parent-only bound=RAISED(RecursionError)

N7 (bound against the immediate parent only, not the ancestor chain) survives every test on the branch at c4c91590a and is killed only by the new input. Same defect class as R14 two rounds ago: a claim about the loader made from the shape rather than the library.

R20b, "only the null byte can raise ValueError from read_bytes". Wrong reasoning, right handler. A lone high surrogate is a valid YAML escape that ScalarNode.value carries as a one-character str, and os.fsencode raises UnicodeEncodeError on it, a ValueError subclass (rv4-r20b-mutant.txt):

head                     null byte                            hashed 36c03e4d98b4 stable=True
head                     high surrogate                       hashed e3f5aabd2256 stable=True
head                     low surrogate (surrogateescape byte) hashed 82d20cf2aa2c stable=True
M2 except OSError only   null byte                            RAISED ValueError
M2 except OSError only   high surrogate                       RAISED UnicodeEncodeError
M2 except OSError only   low surrogate (surrogateescape byte) hashed 82d20cf2aa2c stable=True

R16 (empty string, comment only, explicit null, whitespace only): all four hash identically on both arms. R14e (frozenset() default across calls): same document MATCH before and after a cyclic document in between, digest still tracks the file on the third call, generator yields [1, 1, 1] (base [0, 0, 0]).

The two added inputs, every arm

Driven through the real blueprint_hash on base 38fca6b34, unguarded 79b0815eb and head (rv4-discovery-arms.txt):

# arm: base (/agent-workspace/oss/authentik-base-arm)
  PASS test_file_tag_unreadable_discovery_continues [path outside the filesystem encoding]
  PASS test_file_tag_unreadable_discovery_continues [two anchors containing each other]
# arm: fixed (/agent-workspace/oss/authentik-headcommit)
  FAIL test_file_tag_unreadable_discovery_continues [path outside the filesystem encoding] - hashing raised UnicodeEncodeError, scan aborts
  FAIL test_file_tag_unreadable_discovery_continues [two anchors containing each other] - hashing raised RecursionError, scan aborts
# arm: fixed (/agent-workspace/oss/authentik-wt-verify4)
  PASS test_file_tag_unreadable_discovery_continues [path outside the filesystem encoding]
  PASS test_file_tag_unreadable_discovery_continues [two anchors containing each other]

Both pass on base, which never walks the document: the body's test table marks path outside the filesystem encoding a control (for the except tuple; fails on 79b0815eb and a2decb52d) and two anchors containing each other discriminating against the arm it is about (44d6580f3 and N7), exactly as the two self-cycle inputs beside it already were.

Everything else re-run at this head, unchanged

repro.py           base 5 passed / 3 failed   head 8 passed / 0 failed
repro-rework.py    base 4 / 0                 head 4 / 0    (all four controls)
repro-rework2.py   base 3 passed / 2 failed   head 5 passed / 0 failed
probe-v3.py        base 2 passed / 9 failed   head 11 passed / 0 failed
probe-cycle-once   base FAIL                  head PASS; N1 FAIL, N4 PASS (equivalent), N5 FAIL, N6 FAIL

Body reconciled to 2453510bc throughout: 13 sections, 42 boundary rows (R14d, R16, R20b, R14e rewritten from measurement), 22-row test table, CI bullet rewritten from the completed matrix, ## Rework carries round five.

Rules: ledger-row-needs-its-fixture=covered(test_file_tag_unreadable_discovery_continues [two anchors containing each other]) | mutate-the-rejected-alternatives=covered(N7 parent-only bound, M2 except OSError) | reads-as-generated=covered(fold verified input-complete, file read cold) | no-control-cases-in-the-suite=covered(file read cold, table marks controls) | unreachable-row-same-bytes=unreachable(R23 settled at 44d6580, mutant M6 still unkilled, no new same-bytes row in this diff) | control-returns-its-own-input=unreachable(no pass-through fallback; unreadable paths fold nothing rather than echo input) | multi-assert-base-arm=covered(subTest reports per input; base arm measured per input) | composed-transform-cross-product=covered(probe-v3 tag cross-product 11/0 re-run) | dispatch-arm-boundary-coverage=unreachable(single hashing path, no protocol or platform dispatch) | prior-art-recheck-at-gate=covered(sixth pass at c4c9159 in the body, issue 26289 timeline query) | formatter-at-the-pinned-version=covered(black 26.5.1 clean) | run-every-ci-step-not-just-the-red-one=covered(black, ruff, and the fork's own lint matrix all green at c4c9159) | base-arm-revert-committed=covered(tasks.py diff against 1c67eff empty at this head) | idempotence-test-asserts-only-agreement=covered(test_file_tag_hashed_once_per_route asserts the arithmetic digest, not agreement) | static-row-vs-alias-stub=covered(harness stubs three ORM imports, real File tag and loader executed) | timeout-reintroduces-bug=unreachable(no timeout or retry in the diff) | shared-ref-cancellation=unreachable(no async or shared mutable state) | crossing-gated-fix-all-controls=unreachable(no boundary-crossing detector) | moved-transform-test-enters-above=unreachable(no moved cap or filter) | run-the-artefact-the-fix-produces=covered(the digest is the artefact; folded count pinned arithmetically) | option-creates-the-tests-selector=unreachable(no option toggled)

@sprayberry-redline sprayberry-redline left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Automated review from the Sprayberry Labs fleet code reviewer.

Reviewed by the GPT gating lane (gating review).

Verdict: no blocking issues found; ready for operator submission.

I reviewed the complete two-file diff, the base implementation at 38fca6b34951852b53db575394a5fb0c546cdcd3, the candidate facts sheet and boundaries ledger, commit messages, policy guidance, and fork CI status. The new hash folds referenced-file content while preserving content-only hashing for invalid YAML; its ancestor-path traversal terminates recursive YAML without suppressing acyclic aliases. The added cases exercise direct/nested/aliased references, creation/removal/swapped content, malformed and unencodable paths, recursive structures, discovery continuity, and the apply path. The facts sheet contains the required sections and records executable base/head evidence and prior-art searches. The relevant Django test file has also run in fork CI as documented in the PR body.

CI is still in progress; the currently failed container/dependency jobs and pending matrix jobs were not treated as evidence against this Python-only change.

What's good: the latest two subtests specifically cover a constructible two-anchor cycle and a lone high surrogate, closing subtle exception and traversal boundaries without expanding the production patch.

Notes for the operator: upstream contribution guidance requires the usual matching Python lint/test checks; retain the documented CI/test evidence when preparing the upstream submission.

@sprayberry-secondread sprayberry-secondread left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Automated review from the Sprayberry Labs fleet code reviewer.

Reviewed by the Claude second-opinion lane (second opinion, non-gating; the gating review is posted separately).

Verdict: NOT READY at 2453510bcf58f8823516d41c4eaf68ebff6f753f: the core fix is supported by the code, but the newline behavior change needs a regression test and the production commentary needs trimming.

Findings

Medium: pin the changed CRLF discovery/apply contract

authentik/blueprints/v1/tasks.py:209:

file_hash = blueprint_hash(content)

This replaces sha512(path.read_bytes()).hexdigest() with hashing text read through universal-newline conversion. Concrete input: a valid blueprint written with CRLF and no !File. Its discovery hash changes even though no referenced file exists. Base discovery hashes CRLF bytes while retrieve_file() returns LF text for apply; the new helper makes them agree. This is beneficial, not a newly introduced infinite-reapply bug, but it is a separate behavior change and none of the added tests writes CRLF. The body's R26a explicitly leaves it untested. A maintainer should not have to rely on an external measurement for the changed shared-hash contract.

Suggested fix:

Add one real discovery/apply test using a binary-written CRLF blueprint,
without !File. Assert the discovered hash equals last_applied_hash after
application. Keep the CRLF bytes on disk and assert a subsequent discovery
still agrees. The equality must fail on base and pass with this change.

Low: remove patch-defense commentary, including an inaccurate fallback claim

authentik/blueprints/v1/tasks.py:104-106:

# and a blueprint. Hashing must never fail on a blueprint that can be loaded, so
# skip them; the tag's own content is part of the content hashed above. Read the
# attribute defensively - the check itself must not be what raises.

This argues why the patch is correct instead of documenting the local runtime contract. The same tell appears in the helper docstring at lines 71-72 (routes is not its own ancestor and is still walked from each of them, as it is / without this check.) and the digest commentary at lines 117-118 (Only the referenced contents need digesting; the path itself is a substring of / the content already hashed above). Escaped YAML paths need not literally be substrings of the source text.

Also, lines 113-115 say a path containing a null byte means the tag resolves to its default value. Base File.resolve() at common.py:293-303 catches only OSError, not the ValueError raised for that path. Hashing tolerates this input; actual resolution does not necessarily fall back. Keeping that distinction explicit will prevent misleading future maintenance.

Suggested fix:

# Mapping-node tags have no path; nested tags cannot be resolved here.
path = getattr(tag, "path", None)
if not isinstance(path, str):
    continue
try:
    referenced = Path(path).read_bytes()
except OSError, ValueError:
    # Unreadable references contribute only their blueprint source text.
    continue
hasher.update(sha512(referenced).digest())

Keep the cycle docstring to the runtime rule: skip ancestors, but visit aliases reached by distinct routes. Remove the comparison to an implementation without the check.

Independent break-it pass

I rebuilt these rows from the changed production code, then compared them with the body and every added test assertion. No local test suite was run.

Changed predicate/guard/surface Boundary and fixed behavior Checked test coverage
id(value) in ancestors Empty ancestor set admits root; direct/indirect cycles terminate; equal identity on another route is admitted Discovery subtests for list, mapping and two-anchor cycles; exact fold-count subtests for cycle and alias
isinstance(value, File) File yields; non-File does not; yielding does not terminate traversal Direct rotation and nested Format rotation; nested File-in-File is not directly pinned
isinstance(value, dict) Empty dictionary has no children; nonempty values traversed, not keys All discovery documents traverse dictionaries; no dedicated empty-map assertion
`isinstance(value, list tuple)` Empty list stops; nonempty list traversed; tuple arm not constructed by these YAML tests
isinstance(value, YAMLTag) / terminal return Other tags traverse attributes; null, zero, negative and maximum numeric scalars terminate, as do strings and unsupported collections Format case pins tag traversal; version integer pins scalar exit; not every scalar subtype is separately tested
except YAMLError Invalid YAML keeps content-only digest; empty/null content traverses nothing Existing invalid-file test rejects before this helper in discovery; no new direct helper test for apply's invalid/empty input
getattr(tag, "path", None) Missing attribute becomes None; present string preserved Mapping-node discovery and stability cases; direct rotation
not isinstance(path, str) Null/numeric/tag-valued sequence paths skip; scalar numeric text remains a filename; empty string reaches filesystem error handling Tag-valued and mapping cases; empty-string/numeric variants not shipped as assertions
except OSError, ValueError Missing/directory/permission/NUL/unencodable path skips; readable zero-byte file contributes a digest Missing/created/removed, NUL and surrogate cases; readable empty file is measured in body, not pinned in new tests
Digest folding Each readable occurrence contributes 64 bytes in traversal order; swapped contents differ Exact cycle/alias counts and swapped-files test
Discovery and apply call replacements Both now hash loaded text plus references; CRLF normalizes, unlike base discovery Real reapply test for referenced-file rotation; CRLF equality unpinned (finding above)

There is no new numeric threshold or index in production code, so equal-to-limit/one-past-limit boundaries are not applicable. Test-local selection found.path == Path(file.name).name selects the actual temporary blueprint, and range(routes) is exercised at one and two folds. Assertions for unchanged/unreadable files and discovery survival intentionally pass on the original base: they are compatibility controls, not evidence that rotation was fixed. All four rotation variants, appearance, removal, swapping, both exact fold counts and the final application hash inequality have a concrete reason to fail with content-only hashing. The preliminary successful-status assertion in the application test is setup validation, not the regression discriminator.

Maintainer fit and prior art

  • I can confirm the reported bug statically from base: discovery's sha512(path.read_bytes()) cannot change when only an external secret changes. Both updated call sites now use the shared helper. I did not execute a reproduction.
  • The existing test_v1_tasks.py uses TransactionTestCase, CONFIG.patch, real temporary files and real discovery/application. The new tests follow that pattern instead of introducing fake task interfaces. Grouping reference variants in subtests makes the test additions substantially easier to review.
  • The touched module's recent history includes hidden-path handling #24222 and flaky-test work #19002. Recent merged outside contributions #26046 and #25993 use component-prefixed titles and tightly focused changes. The candidate title fits that convention once the fork-only prefix is removed. Neither example establishes a mandatory changelog addition; I am not inventing that requirement.
  • Repeated upstream PR searches for blueprint_hash and 26289 returned no matches. A broader File hash search returned unrelated work, not a competing fix. This is a search result, not proof of absence.
  • Tell pass: no em dash in the fetched changed source/tests, title or commit messages; no test-local helper docstrings, fake internal objects, or timer-based sequencing. The production patch-defense comments are the concrete remaining tell described above.

What's good: shared discovery/apply hashing, fixed-width digest contributions, defensive handling of malformed paths, and ancestor-local rather than global cycle tracking all make sense. Scope reviewed: both full changed files' diff, relevant base resolver/retrieval and test context, upstream history and external merged examples. CI at this head is still incomplete with failures and queued/running checks; historical test results are not treated here as a green current-head CI result.

SECOND READ: NOT READY — pin CRLF discovery/apply equality and remove the production patch-defense commentary.

@askalf askalf removed the verified Adversarially verified by a fresh run label Sep 23, 2026
@askalf

askalf commented Sep 23, 2026

Copy link
Copy Markdown
Collaborator Author

Rework: head 18479bddc34eabab26995a951d5467d749339d9f

Answers the second-opinion review at 2453510bc (SECOND READ: NOT READY). Two commits: a3c2c6e81 (comments only) and 18479bddc (one test). verified removed because the head moved.

Medium: CRLF discovery/apply contract now pinned

test_valid_crlf in authentik/blueprints/tests/test_v1_tasks.py writes a CRLF blueprint as bytes with no !File, runs blueprints_discovery.send() twice, and asserts after each run that last_applied_hash equals the hash blueprints_find() returns. It then asserts the file on disk still contains \r\n. This is the test the review suggested.

It is a database test, so it cannot run in this container. Its hashing path was run through the real blueprints_find source, using each arm's own apply_blueprint hash expression and a retrieve_file()-style text read (probe-crlf.py):

$ python probe-crlf.py /agent-workspace/oss/authentik-base-arm
# arm: authentik-base-arm; apply hashes: sha512(blueprint_content.encode()).hexdigest()
scan 1: CRLF on disk True; discovery 15e1a41b4b30fd69 last_applied_hash 72405c1803c640f4; DIFFER - re-applied
scan 2: CRLF on disk True; discovery 15e1a41b4b30fd69 last_applied_hash 72405c1803c640f4; DIFFER - re-applied
$ python probe-crlf.py /agent-workspace/oss/authentik-wt-1790134762
# arm: authentik-wt-1790134762; apply hashes: blueprint_hash(blueprint_content)
scan 1: CRLF on disk True; discovery 72405c1803c640f4 last_applied_hash 72405c1803c640f4; AGREE - not re-applied
scan 2: CRLF on disk True; discovery 72405c1803c640f4 last_applied_hash 72405c1803c640f4; AGREE - not re-applied
$ python probe-crlf.py /agent-workspace/oss/authentik-wt-1790134762 raw-bytes
# mutant: raw-bytes discovery
scan 1: CRLF on disk True; discovery 15e1a41b4b30fd69 last_applied_hash 72405c1803c640f4; DIFFER - re-applied
scan 2: CRLF on disk True; discovery 15e1a41b4b30fd69 last_applied_hash 72405c1803c640f4; DIFFER - re-applied

The equality fails on base and passes at head. Mutant M7 keeps blueprint_hash but feeds discovery the raw bytes, as base did, and it fails the same way. The Django run of test_valid_crlf is waiting on the fork's test-unittest shards in CI - Main run 35815749504, which were still pending when this was posted.

Low: production commentary trimmed, inaccurate fallback claim removed

authentik/blueprints/v1/tasks.py:

  • iter_file_tags docstring now states only the rule: "A node is not descended into again below itself; a node reached by several routes is visited once per route."
  • blueprint_hash docstring is one line, with no bug story.
  • The guard comment is now # Mapping-node tags have no path; nested tags cannot be resolved here. That is the wording the review suggested.
  • The except comment is now # Unreadable references contribute only their blueprint source text. The claim that the tag "resolves to its default value" is gone. It was wrong for ValueError paths, because File.resolve() (common.py:293-303) catches only OSError. ## Fix now states that distinction explicitly.
  • Removed the "path is a substring of the content" comment. The review is right that escaped YAML paths need not appear literally. The body's R23 paragraph now says "determined by" rather than "a substring of".

No statement changed: the module's AST with docstrings blanked is identical before and after a3c2c6e81. The diff against 2453510bc is tasks.py +6/-19 and test_v1_tasks.py +20/-0.

Re-measured at this head

  • repro.py: 3 failed / 5 passed on base, 8/8 at head.
  • black --check (pinned 26.5.1), ruff check (0.16.8) and ruff format --check: clean on both touched files.
  • Added lines contain only the two one-line comments above, with no non-ASCII.
  • Prior art re-run: blueprint_hash, iter_file_tags, blueprint hash File and 26289 in:body all return nothing. Blueprints do not re-apply when a referenced Kubernetes Secret changes goauthentik/authentik#26289 is still OPEN. Upstream main at 449f29969 has no commit since base touching tasks.py or mentioning 26289.

The PR body is reconciled to 18479bddc: Summary, Upstream counts, the Fix listing, the test table (15 tests / 23 inputs), Verification method, Prior art, Disclosure, Boundaries R26a/R28, and Rework round six.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

oss-candidate Sprayberry Code candidate for upstream

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants