check-script: a wrong lines[].shot name passes validate and fails at run time #873
Description
Split out of #861.
What happened
Ep 6 (TV path): templates/check-script with lines: [{"text": ..., "shot": "cold_open"}, ...] on the episode's output: cut validated clean, then the job failed at run time because shot must be the shot's full filename as the cut's shot map records it (e.g. ...-shot@cold_open.0-0.0.mp4), not the short name used in the shot list. Re-ran with full filenames.
dw/script_lines.py: shot_names_error already produces a good message ("names shot 'x', which the take does not have - its shots are ...") but it runs inside the task, after queueing and Whisper load.
Expected
- Validate runs
shot_names_erroragainst the input's shot map wheninput_audiois anoutput:reference whose manifest is readable at validate time (the same way other preflights read inputs -slice_preflight,shot_span_preflight). - Accept the short shot name (
cold_open) when it matches exactly one recorded shot's@name- that's what an agent writing a script has in hand.
Source: H3 quality matrix on lem (2x RTX 3090 24 GB, 125 GB host RAM), workspace h3-quality, Known Issues ep 6 shots, seed 42. Full data and per-run assessments: ~/testing/opus/known-issues/quality/matrix.json, report quality/REPORT.md; every run in h3-quality carries a note with its config, minutes and score. Related: #861 (follow-up experiments).
Activity
fix(check-script): #873 - validate a lines[].shot name against the re…
dkackman commentedon Oct 14, 2026
Fixed and deployed (develop @ 61838a9, lem restarted healthy). Model: claude-sonnet-5-5 (anthropic).
script_shot_names(dw/shot_span_preflight.py). For acheck_scriptstep with literallinesentries naming ashot, noshotsargument, and anaudiothat resolves at validate time (output:/asset:/path) to a file whose run manifest records shots, it runsshot_names_errorand reports the same message the task gives, onsteps[..].task.arguments.lines. Aprevious_result:audio, ashotsargument or no readable manifest is left to the run.lines[].shotmay be the exact recorded name, or the short@name(cold_open) when exactly one recorded shot carries@cold_open(asshot@cold_openor...-shot@cold_open.0-0.0.mp4). Two matches is refused, listing them. The task resolves the short name to the full one before checking, so findings carry the full name. The literal-shotsstatic check and the run-time refusal share this rule.output:reference in tests/test_shot_span_preflight.py; short-name matching in tests/test_script_check.py. Full suite: only the pre-existing kandinsky6 failures remain (they fail on the base commit too).To verify: validate
templates/check-scriptwithinput_audioanoutput:cut that has a shot map and a wrongshot(expect an error naming the take's shots), thenshot: "<short name>"(expect clean; the run succeeds).dkackman commentedon Oct 14, 2026
Architecture review queued by the loop driver: this hand-off changed
dw/ordw_mcp/ondevelop(c38f8af6dc..61838a9c71: dw/assessment_rules.py dw/script_lines.py dw/shot_span_preflight.py dw/tasks/concat_videos.py dw/tasks/joins.py dw/tasks/script_check.py dw/validation.py). The reviewer checks it againstdocs/ARCHITECTURE.mdbefore the tester verifies.dkackman commentedon Oct 14, 2026
Architecture review: bounced. Reviewer: claude-opus-5-5 (anthropic).
I read
61838a9c(the #873 commit:dw/script_lines.py,dw/shot_span_preflight.py,dw/tasks/script_check.py,dw/validation.py) againstdocs/ARCHITECTURE.mdand item 3 ofdocs/stabilization/harness/stage-c-guardrails.md.68f9efcain the same range belongs to #876, so I didn't review it here.Most of the change is fine. The validate-time check calls the existing owners:
references.iter_stepsfor the walk,probe_paths.resolve_probe_pathfor the staticaudiopath, andruns.shots_besidefor the manifest, which is the same sourceassess.resolve_shotsuses for a file. It reusesshot_names_errorso the message matches the task's, and it registers as oneCheckinERROR_CHECKS(the Validation registry row). Nothing hand-rolls what apyproject.tomldependency already does, and noCLAUDE.mdchanged.Finding 1: a second owner of the for_each member name (blocks)
Map row "for_each expansion" (
dw/for_each.py:expand_for_each,group_of) owns the member name<step>@<entry>. Its separator isdw/references.py: MEMBER_SEPARATOR, whichfor_each.pyandvram_estimate.pyimport. The newscript_lines.matching_shot_namesbuilds that rule from a literal:re.compile(r"@" + re.escape(shot) + r"(?![A-Za-z0-9_-])"). It hard-codes the separator and also guesses where an entry key ends. Because.isn't in the excluded class,cold_openwould also matchshot@cold_open.v2.To resolve: take the separator from
MEMBER_SEPARATOR. Better, put the "which member key does this recorded shot name or filename carry" rule next tomember_nameandgroup_ofindw/for_each.py, or indw/references.pyif importing for_each fromscript_lineswould create a cycle, and call it frommatching_shot_names. The key's end should come from the same rule.Finding 2: the map wasn't updated (fix in the same change)
The "Script check" row says where
check_scriptarguments are checked at validation: literallinesandshotsthroughtask_problems(script_lines_errors,parse_shots,shot_names_error). It listsdw/script_lines.py's owners asparse_lines,parse_shotsandshot_names_error. This change adds two things the row doesn't mention:dw/shot_span_preflight.py: script_shot_name_errors, registered as thescript_shot_nameserror check, which reads the take's recorded shot map throughruns.shots_beside;dw/script_lines.py: a line's short@nameresolves to the one recorded shot that carries it (matching_shot_names,with_full_shot_names), and the task and the literal-shotscheck share it.To resolve: update the Script check row to name both, with a test node for each, such as the
output:preflight test intests/test_shot_span_preflight.pyand the short-name test intests/test_script_check.py.dw/shot_span_preflight.pyhas no row of its own. Either name it in this row or add one now that it holds an error check as well as warnings.12 remaining items
dkackman commentedon Oct 14, 2026
Architecture review: bounced (second round). Reviewer: claude-opus-5-5 (anthropic).
I read
f8fda594(the #873 follow-up:dw/references.py,dw/script_lines.py,docs/ARCHITECTURE.md,tests/test_script_check.py) againstdocs/ARCHITECTURE.mdand item 3 ofdocs/stabilization/harness/stage-c-guardrails.md.c1b0d2da(#880) is outside the range.Finding 2 from the first round is resolved. The Script check row now names
script_shot_name_errors(thescript_shot_namescheck, viaruns.shots_beside) and the short-name rule (matching_shot_names,with_full_shot_names,references.member_keys). Both test nodes it cites exist:tests/test_script_check.py::TestShotValidation::test_a_short_member_name_matches_the_one_shot_carrying_itandtests/test_shot_span_preflight.py::TestScriptShotNames::test_a_wrong_shot_name_is_an_error_at_validate_time. Most of Finding 1 is resolved too. The separator now comes fromMEMBER_SEPARATOR, andscript_linesno longer builds a pattern of its own. Nothing hand-rolls what apyproject.tomldependency does, and noCLAUDE.mdchanged.Finding: the key's end is still a second copy of the entry-name rule (blocks)
The first round asked that "the key's end should come from the same rule" as the key. A member key is either an index or an entry
name.dw/for_each.py: _entry_keyschecks the name withdw/security.py: validate_variable_name, whose pattern^[a-zA-Z_][a-zA-Z0-9_-]*\Zis where the character set lives.references._MEMBER_KEY_CHARSre-types that set as a literal. Its comment says the key ends "wheremember_name's key charset does", butmember_namehas no charset: it isf"{group}{MEMBER_SEPARATOR}{key}". Ifvalidate_variable_nameever admits another character (., say),member_keyswould silently cut keys short and short-name matching would diverge. The new map text ("never a re-derived pattern") would then be untrue.To resolve: keep one owner of the variable-name character set and have both readers use it.
dw/references.pymust import nothing fromdw(Reference prefixes row,tests/test_references.py::test_references_imports_nothing_from_dw). So one way is a named constant inreferences.py, for exampleVARIABLE_NAME_CHARACTERS = "a-zA-Z0-9_-", thatsecurity.validate_variable_namebuilds its pattern from andmember_keysreads.security.pyimports nothing fromdwtoday, so that adds no cycle. That follows thePROMPT_REFERENCE_CHARACTERSprecedent insecurity.py. Any arrangement where one constant serves both works. Then fix the comment onmember_keys, and add an assertion that ties the two together, e.g. a namevalidate_variable_nameaccepts comes back whole frommember_keys(member_name("shot", key) + ".0-0.0.mp4").dkackman commentedon Oct 14, 2026
triage: work — second arch-review bounce on f8fda59: give the variable-name character set one owner (e.g. a constant in
dw/references.pythatsecurity.validate_variable_namebuilds its pattern from andreferences.member_keysreads; references must still import nothing fromdw), fix themember_keyscomment, add a test tying the two together, then redeploy and hand off. (claude-opus-5-5, anthropic)dkackman commentedon Oct 14, 2026
Second arch-review finding addressed; deployed develop @ dfc7c49 (fix commit
c467a543, lem healthy, 2026-10-14T02:54:53Z). Model: claude-opus-5-5 (anthropic). This issue had bounced twice, so this round ran on a stronger model than the sessions that made the earlier fixes.dw/references.py: VARIABLE_NAME_CHARACTERS(a plain string; references still imports nothing, not evenre, sotest_references_imports_nothing_from_dwholds).security.validate_variable_namebuilds its pattern from it (^[a-zA-Z_][{re.escape(VARIABLE_NAME_CHARACTERS)}]*\Z), andreferences.member_keysends a key at the first character outside it. The private_MEMBER_KEY_CHARScopy is gone. security.py imported nothing from dw before, so this adds no cycle.member_keys's docstring no longer claimsmember_namehas a charset. It says a key is an entry name (a variable name) or an index and ends outsideVARIABLE_NAME_CHARACTERS.tests/test_script_check.py::TestShotValidation::test_a_valid_variable_name_comes_back_whole_as_a_member_keychecks everystring.printablecharacter c. A keya{c}bthatvalidate_variable_nameaccepts comes back whole frommember_keys(for_each.member_name("shot", key) + ".0-0.0.mp4"). One it refuses does not.docs/ARCHITECTURE.mdnow namesVARIABLE_NAME_CHARACTERSas where a key ends and cites the new test.No behaviour change for callers. Verify as in the earlier hand-off: a wrong
shoton anoutput:cut gives a validate error naming the take's shots, and a short name validates clean. Unrelated: 17 Kandinsky 6 catalog/plugin tests fail on develop both before and after this change.dkackman commentedon Oct 14, 2026
Architecture review queued by the loop driver: this hand-off changed
dw/ordw_mcp/ondevelop(f8fda594ea..dfc7c49c4a: dw/references.py dw/security.py). The reviewer checks it againstdocs/ARCHITECTURE.mdbefore the tester verifies.dkackman commentedon Oct 14, 2026
Architecture review: passed (third round). Reviewer: claude-opus-5-5 (anthropic).
I read
c467a543, the #873 commit inf8fda594..dfc7c49c(dw/references.py,dw/security.py,docs/ARCHITECTURE.md,tests/test_script_check.py), againstdocs/ARCHITECTURE.mdand item 3 ofdocs/stabilization/harness/stage-c-guardrails.md. The rest of the range is other work, and I did not review it here.references.VARIABLE_NAME_CHARACTERS.security.validate_variable_namebuilds its pattern from it, andmember_keysreads it. The private_MEMBER_KEY_CHARScopy is gone. The first-character class[a-zA-Z_]is still written out insecurity.py, butmember_keysdoesn't need it because a key can also be an index, so that isn't a second copy.dw/references.pystill has no imports at all, so the Reference prefixes row (test_references_imports_nothing_from_dw) holds.security.pydidn't import anything fromdwbefore, so the new import can't create a cycle. No map row limits whatsecurity.pymay import.pyproject.tomldependency already does.references.VARIABLE_NAME_CHARACTERSas the point where a key ends, and it cites the new testTestShotValidation::test_a_valid_variable_name_comes_back_whole_as_a_member_key, which is in the diff. Themember_keysdocstring no longer saysmember_namehas a charset.CLAUDE.mdchanged.Ready for the tester to verify the behaviour as described in the hand-off.
dkackman commentedon Oct 14, 2026
Verified against lem develop @ fba35f2. Tester: claude-opus-5-5 (anthropic).
Take:
output:RestorePointCinema/20261013-222436-6e127da4/final/RestorePointCinema-final.5-0.0.mp4(ep 6 cinema cut, workspaceh3-quality, 5 recorded shots).validate_workflow(name="templates/check-script", workspace="h3-quality", arguments={input_audio: <cut>, lines: [{text, shot: "no_such_shot"}]})→valid: false, error atsteps[0].task.arguments.lines: "names shot 'no_such_shot', which the take does not have - its shots (manifest) are '...-shot@cold_open.0-0.0.mp4', ... '...-shot@tag.0-0.0.mp4'". Nothing was queued.lineswithshot: "cold_open",shot: "H3Ladder_C2_..._patcu-shot@deflect.0-0.0.mp4"(a full filename) andshot: "tag"→valid: true, no warnings.shot: "cold"→valid: falsewith the same "which the take does not have" message. So a prefix ofcold_opendoesn't match.1c34221cf826) and it succeeded in 15 s. In the output JSON, every line'sshotand every finding'sat.shotuses the full recorded filename (for examplecold_open→H3Ladder_L5_768_int8_base30_sage-shot@cold_open.0-0.0.mp4). I deleted the run afterwards.Both expectations in the issue hold. I didn't test the ambiguous case, a short name that two recorded shots carry, because this take has no such pair.