Skip to content

Instantly share code, notes, and snippets.

Show Gist options
  • Select an option

  • Save benedikt-buchert/ba4ba43be8acf694707c4027821bfd45 to your computer and use it in GitHub Desktop.

Select an option

Save benedikt-buchert/ba4ba43be8acf694707c4027821bfd45 to your computer and use it in GitHub Desktop.
Research for pts-extended/braze-cli#28: portable Braze artifact paths

Issue #28 research: portable Braze artifact paths

Status: decision-complete research for pts-extended/braze-cli#28, prepared 2026-07-18. This document separates already-agreed project behavior, externally established platform facts, and proposed project policy. It is a specification input, not an implementation.

Executive conclusion

The smallest implementation that honestly satisfies broad ASCII transliteration is one pinned runtime dependency, anyascii==0.3.3, plus a small project-owned naming module. A standard-library-only unicodedata.normalize(...).encode("ascii", "ignore") implementation strips decomposable accents but is not broad transliteration: it has no mapping table for scripts such as Cyrillic or Han. AnyAscii states that it supplies printable-ASCII replacements for practically all Unicode characters, covers about 124,000 of 155,000 Unicode 16.0 characters, supports Python 3.3+, and uses the permissive ISC license (description and coverage, Python support, license). Python 3.9 is therefore supported.

The exact readable-prefix pipeline should be:

  1. Transliterate the display label directly with pinned AnyAscii 0.3.3, with no locale-specific overrides.
  2. Lowercase.
  3. Replace each run outside [a-z0-9] with one underscore and strip leading/trailing underscores.
  4. Take the first 40 ASCII characters, then remove any trailing underscore created at the cut.
  5. If nothing remains, use unnamed; for a missing or unusable channel only, use message.

The raw metadata names and identifiers are never normalized. Generated components are <readable-prefix>__<exact-id> (plus the fixed extension where applicable). IDs are copied byte-for-byte/case-for-case after validating that they are non-empty portable ASCII identifiers; they are never silently sanitized or truncated.

The portable tree policy should distinguish three independent limits:

  • component: at most 255 ASCII characters;
  • generated-tree Windows feasibility: every generated relative file path must fit at 256 ASCII characters and every generated relative directory path at 244, measured from the command's caller-selected output root (including translations/ or sources/ beneath a pull root). These values are derived by placing the tree beneath the shortest absolute Windows root, C:\, and applying the absolute limits below;
  • actual destination: checked separately because those feasibility limits cannot guarantee an absolute path beneath an arbitrary output root. On Windows, reject an absolute file destination longer than 259 UTF-16 code units and a directory path longer than a conservative 247 units rather than relying on optional long-path configuration; on Unix, check the destination filesystem's PC_PATH_MAX/PC_NAME_MAX where available.

With three 40-character prefixes and 36-character IDs, a translation/source leaf is 241 characters relative to its translation/source root. Under pull, translations/ makes that 254 and sources/ makes it 249, so both are feasible beneath a minimal Windows root; their longest generated parent-directory paths are only 170 and 165 characters. The default textual path braze-data/translations/... is 265 characters in that worst case even before resolving the working directory. The CLI must therefore preflight the actual destination and tell the user to choose a shallower --out-dir; it must not claim that any arbitrary root is portable or shorten prefixes based on the root, because root-dependent names would not be stable.

1. Recovered project contract

1.1 Agreed behavior

Parent map #27 records the feature contract; #28 is its open research child. On 2026-07-18, both issue bodies were updated to replace the original German-specific ü -> ue example with direct AnyAscii 0.3.3 transliteration and no locale-specific overrides. The following items are requirements, not recommendations:

  • Generated readable prefixes are lowercase ASCII snake_case, use pinned AnyAscii 0.3.3's transliteration directly with no locale-specific overrides, and are capped at 40 characters.
  • Every generated component retains the exact full Braze identifier after __.
  • Translation bundle and Source text paths have parallel three-level layouts: welcome_canvas__CANVAS_ID/welcome_email__STEP_ID/email__MESSAGE_VARIATION_ID.json.
  • The Step directory is always present. An unavailable channel uses message.
  • Canvas content uses welcome_canvas__CANVAS_ID.json.
  • Bulk Email template and Content Block artifacts use welcome_email__TEMPLATE_ID/ and shared_header__BLOCK_ID/.
  • Every single-message and multi-file Canvas translation and Source text JSON document gets exact top-level canvas_name and step_name metadata. Translation and Source text values are untouched.
  • Canvas content details and Email template/Content Block metadata do not gain duplicate top-level name fields; they already contain their API names.
  • Caller-selected --out paths, stdout, inventory paths, manifest.json, and fixed filenames inside template directories are unchanged.
  • No migration or compatibility layer is needed for the pre-release ID-only layout. Reruns retain the current no-cleanup behavior; a successful manifest identifies the current run's artifacts.
  • Translation and Source text paths produced by translations canvas pull remain parallel.

Earlier project decisions constrain the implementation:

  • Issue #5 and PR #11 established the persisted artifact/stdout contract and kept Email template and Content Block content split from metadata.
  • Issue #10 and PR #12 put filesystem layout, document construction, cleanup, and manifests in domain modules. Bulk processing remains sequential and fail-fast: earlier completed artifacts survive a later failure, the old success manifest is removed before processing, and a new manifest is written only after full success.
  • The repository's release workflow builds one-file executables with Python 3.9 and PyInstaller on Linux, macOS, and Windows (release workflow source).
  • Commit b096266 introduced translations canvas pull by invoking the translation and Source text bulk operations independently under translations/ and sources/. The new contract must retain those roots while listing once, caching each Canvas's immutable metadata as the translation-first pass reaches it, and reusing that cache for Source text. This prevents rename drift without prefetching every Canvas or weakening sequential partial-failure behavior.

1.2 Explicit non-goals

These follow #27 and are important deletion tests for the implementation:

  • no old-path migration, aliases, symlinks, or cleanup sweep;
  • no exact-snapshot semantics for bulk output directories;
  • no renaming of explicit --out destinations or fixed files;
  • no new schema-version field merely because two additive name fields are introduced;
  • no changes to User artifacts, inventory records, translation/source values, or template content splitting;
  • no generic storage/repository abstraction.

2. Primary-source findings

2.1 Braze supplies the needed metadata, but does not specify portable ID syntax

Braze's official GET /canvas/details response contains the Canvas name, step id and name, and each message variation's ID, channel, and optional has_translatable_content; the flag include_has_translatable_content=true requests the latter (Canvas details endpoint). The translation and Source text endpoints address an artifact by workflow_id, step_id, and message_variation_id (translation endpoint, Source text endpoint). The authoritative metadata record combines the exact workflow_id from the resolved request or inventory item with one Canvas-details response supplying canvas_name, step_name, step_id, channel, and message_variation_id; the workflow ID is not assumed to come from the details body.

Braze's Email template information response contains email_template_id and template_name (Email template information); Content Block information contains content_block_id and name (Content Block information). The detailed responses, already fetched for content, should supply both path labels and unchanged metadata.

Braze calls Canvas and template identifiers unique random keys, but its identifier reference does not publish a character grammar or maximum length (Braze API identifier types). Examples look UUID-like, but that is evidence about examples, not a normative UUID guarantee. Uncertainty: no primary Braze source found specifies a character set or maximum length for Canvas, step, message-variation, Email template, or Content Block IDs. The implementation must validate actual values and fail explicitly when preserving an exact ID would make a non-portable path; it must not silently rewrite an undocumented identifier.

2.2 Why standard-library normalization is insufficient

Python 3.9's unicodedata.normalize implements NFC/NFKC/NFD/NFKD using the bundled Unicode Character Database. NFD decomposes canonical composites and NFKD also applies compatibility decomposition; neither API promises romanization of arbitrary scripts (Python 3.9 unicodedata documentation). For example, accent removal can turn é into e, but there is no decomposition that turns Cyrillic Борис into Boris or Han 深圳 into ShenZhen. A hand-maintained project table broad enough to fill that gap would be substantially larger and less reviewable than using a dedicated mapping library.

AnyAscii 0.3.3 is a small, pure-Python/data implementation. Its Python entry point reads compressed per-block mappings through importlib.resources and returns printable ASCII (0.3.3 Python source). It supports Python 3.3+, claims practical coverage across Unicode 16.0, and uses an ISC license; these properties make it a better fit than:

  • Unidecode 1.4.0, whose README warns that mapping output can change across releases and which is GPL-only (Unidecode README); or
  • text-unidecode 1.3, whose README describes it as the most basic port, says GPL Unidecode has better transliteration quality, and offers Artistic/GPL licensing (text-unidecode README).

Policy choice: pin anyascii==0.3.3 in project metadata and the lockfile. Persisted paths must not change merely because an unconstrained dependency upgrades its transliteration table. This intentionally changes the README's current “standard library only” runtime-dependency claim; the standalone executable remains self-contained.

2.3 Python 3.9 / PyInstaller probe

An actual local probe used CPython 3.9.6, PyInstaller 6.21.0 with pyinstaller-hooks-contrib 2026.6, AnyAscii 0.3.3, and macOS arm64. The one-line program was:

from anyascii import anyascii
print(anyascii("Борис 深圳 ü"))

Observed results:

Build Result
normal CPython 3.9 Boris ShenZhen u
pyinstaller --onefile probe.py runtime ModuleNotFoundError: anyascii._data
pyinstaller --onefile --collect-data anyascii probe.py runtime failure because the anyascii._data resource package was not importable
pyinstaller --onefile --collect-data anyascii --hidden-import anyascii._data probe.py Boris ShenZhen u
pyinstaller --onefile --collect-all anyascii probe.py Boris ShenZhen u

This matches the package source: the transliteration mappings are package data under the dynamically referenced anyascii._data resource package. PyInstaller documents --collect-all MODULENAME as collecting a package's submodules, data files, and binaries (PyInstaller 6.21 usage).

Policy choice: add --collect-all anyascii to the existing release build command. --collect-data anyascii alone is demonstrably insufficient. The macOS probe is not proof for the other runners; the release matrix must execute a non-ASCII frozen probe using the production prefix helper on Linux, macOS, and Windows. A --version smoke test does not load AnyAscii's mapping data and cannot catch this packaging failure.

2.4 Filesystem facts

The following are platform facts, not project-selected budgets:

Platform Component fact Total-path fact Naming/case fact
Linux Linux's UAPI header defines NAME_MAX as 255, while the Linux man-pages project notes that the real filename byte limit is filesystem-specific and queryable with _PC_NAME_MAX (kernel header, pathname(7)). The same header defines PATH_MAX as 4096 including NUL; pathname(7) explains that longer paths can sometimes be traversed piecewise. NUL cannot appear and / is always a separator (pathname(7)).
macOS Apple's current XNU syslimits.h defines NAME_MAX as 255 bytes and explicitly notes that HFS/APFS may support longer names (XNU source). The same header defines PATH_MAX as 1024 bytes. Filesystem capabilities vary; the fixed header values are conservative API limits, not a promise about every mounted filesystem.
Windows Extended paths use a per-volume component length returned by GetVolumeInformation, commonly 255 characters (Microsoft maximum-path documentation). Legacy MAX_PATH is 260 characters including terminating NUL. For directory creation Microsoft additionally requires enough room to append an 8.3 filename, stated as MAX_PATH - 12. Extended paths are approximately 32,767 characters, but long-path behavior requires both system configuration and an application manifest; relative paths remain MAX_PATH-limited (Microsoft maximum-path documentation). Windows forbids `< > : " / \

POSIX deliberately allows limits to vary by mounted filesystem. pathconf() returns the current limit associated with a file or directory, and PATH_MAX is the maximum relative path from the queried directory without crossing a mount point (POSIX pathconf); Python 3.9 exposes it as os.pathconf on Unix (Python documentation).

These facts yield two important conclusions:

  1. ASCII-only generated parts make “40 characters,” component bytes, and UTF-16 code units coincide for the generated portion of a path.
  2. No generated-relative rule can guarantee an absolute path beneath an arbitrary caller-provided root. The contract must prove that the generated tree could fit beneath the shortest Windows root and validate the actual destination independently.

3. Exact naming policy

Everything in this section is project policy chosen to satisfy #27, not a claim that an operating system mandates this particular algorithm.

3.1 Readable prefix

The reference behavior is:

import re

from anyascii import anyascii


def readable_prefix(label: str, *, fallback: str = "unnamed") -> str:
    ascii_label = anyascii(label)
    snake = re.sub(r"[^a-z0-9]+", "_", ascii_label.lower()).strip("_")
    return snake[:40].rstrip("_") or fallback

The order is normative:

  • AnyAscii 0.3.3's output is authoritative. Do not add Unicode normalization or language-specific mappings around it.
  • The 40-character cut happens after the result is ASCII lowercase snake case, so the unit is unambiguous.
  • The cut is a hard prefix, not a word-boundary algorithm. Removing a trailing underscore prevents a separator-only tail; it may make the visible prefix shorter than 40.
  • Raw labels remain exact in JSON. Only path prefixes go through this lossy pipeline.

Normative examples with AnyAscii 0.3.3:

Input Prefix
Frühstück für Jörg & Straße fruhstuck_fur_jorg_strasse
Fru\u0308hstu\u0308ck fruhstuck
René François Lacôte rene_francois_lacote
Trần Hưng Đạo tran_hung_dao
Борис Николаевич Ельцин boris_nikolaevich_el_tsin
深圳 shenzhen
👑 🌴 crown_palm_tree
!!! or a label whose mapped characters are all removed unnamed

For channel labels, call the same function with fallback="message". “Unavailable channel” means missing, not a string, empty/whitespace-only, or empty after transliteration and filtering. Other resource-name fallbacks are unnamed.

3.2 Exact identifier rule

An identifier suffix is valid only when all of the following hold:

  • it is a non-empty str;
  • it is ASCII;
  • it contains only A-Z, a-z, 0-9, _, -, and internal .;
  • it does not start or end with . (a leading - or _ is harmless after the mandatory readable prefix; trailing - and _ are allowed);
  • the final component and total path pass the limits below.

A suitable full match is [A-Za-z0-9_-](?:[A-Za-z0-9._-]*[A-Za-z0-9_-])?.

The original identifier is appended unchanged. Case is not folded, Unicode is not normalized, and unsafe bytes are not escaped. Failure says which Braze field/value cannot be represented portably. This is stricter than Braze's documented contract because Braze publishes no grammar; it is an explicit export policy needed to reconcile “exact ID” with portable filenames.

3.3 Reserved-name rule

Validate every complete final component, including fixed components. Reject a component when, case-insensitively, its base before the first period is one of:

CON, PRN, AUX, NUL, COM1COM9, or LPT1LPT9.

Also reject ./.., control characters, Windows-reserved punctuation, and a trailing space/period. Generated components normally cannot equal a device name because they contain __<id>; the check is still retained as a centralized invariant and protects future callers. Do not rewrite a reserved name by adding an underscore: rewriting would obscure the exact policy and can create a second collision class. Fixed names in scope (manifest.json, metadata.json, subject.txt, preheader.txt, body.html, amp.html, plaintext.txt, content.html, and content.txt) pass this rule.

3.4 Length rules

Use these policy constants:

PORTABLE_COMPONENT_MAX = 255        # ASCII characters/bytes
WINDOWS_FEASIBLE_RELATIVE_FILE_MAX = 256       # ASCII characters
WINDOWS_FEASIBLE_RELATIVE_DIRECTORY_MAX = 244  # ASCII characters
WINDOWS_ABSOLUTE_FILE_MAX = 259                # UTF-16 code units, NUL excluded
WINDOWS_ABSOLUTE_DIRECTORY_MAX = 247           # UTF-16 code units, NUL excluded

Rules:

  1. Check every generated directory and file component, including the extension, at 255 ASCII characters or fewer.
  2. Prove that the generated tree could fit somewhere on legacy Windows by hypothetically placing it directly beneath C:\. Using / as the counting separator, allow each generated file path relative to the command's caller-selected output root through 256 ASCII characters (259 - len("C:\\")) and each directory that the command may create through 244 (247 - len("C:\\")). For pull, these paths include translations/ or sources/. These are Windows-feasibility limits, not a promise that the caller's actual root is shallow enough.
  3. Before each resource writes, check every native absolute file destination and every directory that the resource may create. On Windows, count UTF-16 code units excluding NUL: allow file paths through 259 and, as a conservative interpretation of Microsoft's MAX_PATH - 12 directory-creation rule, directory paths through 247. Check intermediate directories as well as the leaf's parent. On Unix, use the filesystem's PC_NAME_MAX and PC_PATH_MAX when queryable, counting os.fsencode(...) bytes and the terminating NUL as the reported limit requires. A clear preflight ValueError is preferable to a late platform-specific OSError.
  4. Do not shorten the identifier. Do not dynamically shorten the 40-character prefix to accommodate a deep root. Fail with the offending relative/absolute length and advise a shallower --out-dir; stable names are more important than making one deep destination happen to work.

Worst-case arithmetic for the agreed three-level layout with 36-character IDs:

canvas directory: 40 + 2 + 36                         = 78
step directory:   40 + 2 + 36                         = 78
message file:     40 + 2 + 36 + len(".json")          = 83
two separators:                                             2
relative to translations/ or sources/ root:              241
relative to pull root under translations/ (13 chars):     254
relative to pull root under sources/ (8 chars):            249
translations/ Canvas + Step parent directory:              170
sources/ Canvas + Step parent directory:                   165

The 254/249 file paths fit the derived 256-character feasibility limit, and the 170/165 parent paths fit the 244-character directory limit. In contrast, braze-data/translations/ plus the 241-character leaf is 265, already above legacy Windows capacity before resolving the working directory. That is not a contradiction: braze-data is caller/root context, not generated layout. The actual-destination check is mandatory and the Windows error should recommend a shallow absolute root such as C:\b for an extreme case.

4. Exact artifact and metadata matrix

P(x) below means readable_prefix(x). IDs are exact validated identifiers. WORKFLOW_ID comes from the resolved request for a targeted operation or the resolved inventory item for a bulk operation, never by assumption from the Canvas-details body. Paths are relative to the command's stated output root.

Operation/artifact Generated relative path Persisted metadata decision
`canvas download CANVAS --out FILE -` Caller path unchanged.
canvas download --all --out-dir ROOT P(details.name)__WORKFLOW_ID.json Existing canvas_details shape unchanged. Copy WORKFLOW_ID exactly from the resolved inventory item; use the same GET /canvas/details response for the path label and document.
`translations canvas download CANVAS MESSAGE [--locale ...] --out FILE -` Caller path unchanged.
`translations canvas source CANVAS MESSAGE --out FILE -` Caller path unchanged.
Canvas-wide/workspace-wide Translation bundle P(canvas_name)__WORKFLOW_ID/P(step_name)__STEP_ID/P(channel, fallback=message)__MESSAGE_VARIATION_ID.json Add exact canvas_name; retain exact step_name and the existing channel field. Translation array/maps stay unchanged.
Canvas-wide/workspace-wide Source text Same relative path as the matching Translation bundle. Add exact canvas_name; retain exact step_name and the existing channel field. Source map stays unchanged.
translations canvas pull --out-dir ROOT Translation leaf under translations/; exactly matching Source text leaf under sources/. Each subtree retains its fixed manifest.json. Both files use the exact workflow ID from the one resolved inventory item and the same cached Canvas-details names, step/message IDs, and channel. Their value field/type remains target-specific.
templates email download TEMPLATE --out DIR Caller directory unchanged. Existing metadata.json unchanged; it already contains email_template_id and template_name. Fixed content filenames unchanged.
templates email download --all --out-dir ROOT P(template_name)__EMAIL_TEMPLATE_ID/ Existing metadata/content split and filenames unchanged. Use the fetched info response's exact template_name.
templates content-blocks download BLOCK --out DIR Caller directory unchanged. Existing metadata.json unchanged; it already contains content_block_id and name. Fixed content filename unchanged.
templates content-blocks download --all --out-dir ROOT P(name)__CONTENT_BLOCK_ID/ Existing metadata/content split and filenames unchanged. Use the fetched info response's exact name.
Any bulk manifest Fixed manifest.json in its existing root. Manifest type/count fields remain unchanged; file/directory entries naturally contain the new generated paths and identify only the successful current run.

Normative JSON field matrices for the documents that change are:

canvas_translation (one locale)
  type, workflow_id, canvas_name, step_id, step_name,
  message_variation_id, locale_id, locale, translation_map

canvas_translations (all locales)
  type, workflow_id, canvas_name, step_id, step_name,
  message_variation_id, translations
  + channel only in auto-named multi-file output, as today

canvas_translation_source
  type, workflow_id, canvas_name, step_id, step_name,
  message_variation_id, source_translation_map
  + channel only in auto-named multi-file output, as today

JSON member order is not a schema promise, but writers should use the shown logical order for readable diffs. canvas_name/step_name must be strings copied from one Canvas-details response. Empty strings remain exact metadata and use unnamed for the path prefix; a missing or non-string required name is a malformed Braze response and fails rather than being converted to null or an invented metadata value.

5. Deterministic collision contract

5.1 Portable key

For collision purposes, use the generated relative path with / separators and ASCII case folded. Because every generated part is ASCII and trailing dots/spaces are forbidden, this models the important Windows/default-macOS collision class without host-dependent os.path.normcase behavior.

Track both files and directories. Reusing the same Canvas directory for several messages and the same Step directory for message variations is valid only when the logical owner ID is the same. The following are errors:

  • two different logical resources own the same case-folded directory key;
  • two planned files have the same case-folded key;
  • a planned file and directory use the same key;
  • a generated key conflicts with a fixed control path such as manifest.json;
  • the same resource/step identifier is repeated with conflicting metadata in one API snapshot;
  • two exact IDs differ only by ASCII case and would alias on a case-insensitive filesystem.

Slug collisions alone are not errors when exact IDs keep complete paths distinct (Résumé and Resume may both use resume, but different IDs disambiguate them).

5.2 Scope and failure timing

Collision state is per command output root and per current run. It does not walk or clean arbitrary stale/unmanaged files. An exact existing target of the correct kind may be replaced on rerun; a file/directory type mismatch fails. Old paths left by a rename remain outside the new manifest by the agreed no-cleanup policy.

Preserve bulk sequencing:

  • process Canvas/content/template resources in Braze inventory order;
  • before writing a resource, derive and validate all of that resource's path nodes against the accumulated current-run keys;
  • if a later resource collides, earlier completed artifacts remain, the new manifest is absent, and no artifact for the colliding resource is written;
  • collision messages list the conflicting raw paths and logical IDs in lexical order so the error is independent of API discovery order.

For pull, list the ordered inventory once, then preserve the existing translation-first/source-second sequence. As the Translation pass reaches each Canvas, fetch its details exactly once and cache an immutable path-metadata record: the exact workflow ID from the resolved inventory item, plus the Canvas name, Step names/IDs, message-variation IDs, and channels from that details response. Derive and validate that Canvas's Translation paths immediately before its value fetches and writes; do not prefetch every Canvas or build both complete trees up front. If any Translation fetch or write fails, do not start the Source text pass.

Only after the Translation pass fully succeeds does the Source text pass begin. It reuses the ordered inventory and cached metadata without a second list/details call, derives each Source path from the same record, and checks it against the matching Translation relative path before writing. Thus renames cannot make the two trees drift, while a later failure still leaves precisely the partial output and manifest state implied by the existing sequential operations.

6. Existing code seams and smallest change set

The current seams are already close to the desired design:

  • documents.safe_filename merely substitutes disallowed characters in IDs. It neither transliterates names nor detects reserved names, length, or collisions. Remove it rather than extending its ambiguous “safe” semantics.
  • Translation/source document builders currently own the stable JSON shapes (documents.py); add required name arguments there so single and bulk outputs cannot drift.
  • canvas_translations.py already discovers exact step names and channels, but drops the Canvas name, converts missing names to empty strings, and builds ID-only paths. It should retain one strict Canvas-details snapshot and pass labels/IDs to the shared path code.
  • canvas_content.py, email_templates.py, and content_blocks.py each own their bulk layout and already fetch detail/info responses containing exact names.
  • template_files.py owns fixed filenames and managed-file replacement; it should not learn readable naming.
  • The release command currently has no package-data collection flag (workflow).

Smallest cohesive implementation:

  1. Add src/braze_api/artifact_paths.py containing the pinned-prefix function, exact-ID/final-component validation, length checks, portable-key collision bookkeeping, and only the concrete path builders required by the five layouts. Keeping this out of documents.py prevents JSON and filesystem policy from becoming one shallow grab bag.
  2. Add and lock anyascii==0.3.3; update the runtime-dependency statement and notices/licensing as required by ISC.
  3. Make translation/source document builders require exact Canvas and Step names.
  4. Change the four domain modules at their existing path-construction lines. Keep CLI parsing and explicit destinations untouched.
  5. Give pull one domain operation that lists once, incrementally caches Canvas-details path metadata during its Translation pass, and reuses it during its Source text pass; do not move orchestration back into cli.py or prefetch every Canvas.
  6. Add --collect-all anyascii and a frozen non-ASCII smoke to the three-OS release matrix.

No generic path class, storage interface, migration layer, or third-party slug library is necessary.

7. TDD-ready acceptance scenarios

Tests should drive public domain operations with fake Braze responses and temporary directories, as the repository already does. A few pure policy tests for the prefix/validator are appropriate because those functions define a cross-platform contract that cannot be exercised reliably through the host filesystem alone.

7.1 Prefix and transliteration

  1. AnyAscii 0.3.3 output is used directly with no locale-specific override; precomposed Frühstück and decomposed Fru\u0308hstu\u0308ck both produce fruhstuck.
  2. The normative French, Vietnamese, Cyrillic, Han, and emoji examples above produce their exact AnyAscii-0.3.3 prefixes.
  3. Case becomes lowercase; every punctuation/whitespace run becomes one underscore; ends are stripped.
  4. A >40-character result is cut at exactly 40 ASCII characters and a cut-ending underscore is removed.
  5. Punctuation-only/unknown-only resource names use unnamed; missing/empty/unusable channels use message.
  6. JSON metadata retains the original Unicode string byte-for-byte despite the lossy path prefix.

7.2 Exact paths and metadata

  1. Workspace Canvas content writes welcome_canvas__CANVAS_ID.json; its document is otherwise unchanged.
  2. Canvas-wide and workspace-wide Translation bundles and Source text write the exact three-level example path, always including the Step directory.
  3. Single-locale, all-locale, and Source text explicit-output documents contain exact canvas_name and step_name; translation/source maps and locale metadata are unchanged.
  4. Bulk documents retain their existing channel metadata and gain only canvas_name beyond the required exact step_name; explicit single-message documents do not gain a new channel field.
  5. Email template and Content Block bulk directories use the detailed response's exact name for the prefix and retain their existing metadata/content split and fixed filenames.
  6. Explicit --out, stdout, inventories, fixed files, and manifest filenames are not renamed.

7.3 Validation, limits, and reserved names

  1. Uppercase characters in an ID are preserved in the emitted path; no ID character is lowercased or transliterated.
  2. Empty, non-ASCII, slash/backslash/colon-containing, leading/trailing-dot, and otherwise non-policy IDs fail with the field and value before that resource writes.
  3. Full components at 255 ASCII characters pass the component check and at 256 fail it. The 255-character case may still independently fail a generated-directory or total-path check, so isolate this boundary with a shallow, single-component file target. The test computes identifier length from the actual prefix/extension instead of assuming a Braze ID length.
  4. Reserved names are rejected case-insensitively, with and without extensions; con__VALID_ID is accepted because the complete base is not the device name.
  5. A generated relative file path at 256 ASCII characters succeeds and one at 257 fails; a generated relative directory path at 244 succeeds and one at 245 fails. These boundary tests model placement immediately beneath C:\ and are host-independent.
  6. A worst-case 40/36/40/36/40/36 translation layout is 241 characters; under pull it is 254 in translations/ and 249 in sources/, and both pass. Their respective Canvas/Step parent-directory paths are 170 and 165 and also pass.
  7. A Windows absolute file path of 259 UTF-16 units passes and 260 fails; an absolute directory path of 247 passes and 248 fails. A Windows integration test shows a deep default/current-directory root fails before writes with advice to shorten --out-dir, while a shallow root succeeds, and verifies that every planned intermediate directory is checked.
  8. Unix integration tests honor queryable PC_NAME_MAX/PC_PATH_MAX without baking Linux's 4096 into macOS behavior.

7.4 Collisions, order, reruns, and manifests

  1. Different Unicode names that slug identically but have different safe IDs write distinct paths.
  2. Case-only ID/path aliases, duplicate exact leaves, different owners of one directory key, and file/directory conflicts fail with both logical owners listed deterministically.
  3. Several messages legitimately share one Canvas directory and several variations legitimately share one Step directory.
  4. A collision discovered in resource N leaves resources 1..N-1 in place, writes nothing for N, and leaves no new success manifest.
  5. Successful files/directories and manifest entries retain Braze inventory/message order; collision error ordering is lexical and independent.
  6. Complete success writes the manifest last. A content-fetch failure keeps completed artifacts and removes the old success manifest, matching current behavior.
  7. A resource rename writes the new readable path, does not delete the old path, and the new manifest names only the new current-run path.
  8. An existing exact target of the expected kind is updated; an existing wrong-kind target fails. No recursive cleanup or unmanaged-tree scan occurs.

7.5 Pull snapshot and packaging

  1. pull lists inventory once. Its Translation pass fetches each Canvas's details exactly once when that Canvas is reached and caches the resolved inventory workflow ID plus details-derived names, step/message IDs, and channel. The Source pass makes no inventory/details calls, and its ordered relative leaves exactly match the Translation leaves even when a fake would return renamed metadata on a repeat call.
  2. A Translation failure prevents the Source pass from starting and preserves only earlier Translation artifacts with no new Translation success manifest; the Source subtree is untouched. A Source failure occurs only after a fully successful Translation pass, retains its completed Translation tree and manifest plus any earlier Source artifacts, and leaves no false Source success manifest.
  3. Locked tests run on Python 3.9. A PyInstaller 6.21 one-file probe importing the production prefix helper is built with --collect-all anyascii and produces a non-ASCII expected value on Linux, macOS, and Windows.
  4. A release-workflow test asserts the --collect-all anyascii flag; the frozen smoke exercises transliteration rather than only --version.

8. Remaining uncertainties and explicit follow-ups

  • Braze ID grammar/length: undocumented in the primary sources reviewed. The strict exact-ID validator and actionable failure are the contract; do not infer UUID-only behavior from examples.
  • Windows long-path manifest: PyInstaller may provide a long-path-aware manifest and a particular machine may enable long paths, but the machine-level opt-in is outside this CLI's control and relative paths remain limited. This specification deliberately targets legacy MAX_PATH interoperability rather than making long paths a prerequisite.
  • Mounted filesystem limits: Linux and macOS can use filesystems with different limits. The fixed component and Windows-feasibility policy handles generated layout; native pathconf/OS errors remain authoritative for the caller's actual root.
  • Cross-platform PyInstaller evidence: the completed probe is macOS arm64 only. The required release-matrix smoke closes the Linux/Windows evidence gap before implementation is complete.
  • Transliteration is necessarily lossy and language-neutral. Exact names stay in metadata and exact IDs supply identity, so readable prefixes are hints rather than identifiers.

9. Primary sources

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