Skip to content

Treat an absent storyboard key as no change - #203

Merged
christophervoelpel merged 11 commits into
mainfrom
fix/project-patch-absent-storyboard
Sep 24, 2026
Merged

christophervoelpel merged 11 commits into
mainfrom
fix/project-patch-absent-storyboard

Conversation

@christophervoelpel

@christophervoelpel christophervoelpel commented Sep 21, 2026 •

Copy link
Copy Markdown
Collaborator

Summary

Fixes SM-6: In orch.py, both _write_project_doc and _write_editor_project_doc previously coerced an absent storyboard key in a PATCH payload to []. This resulted in an empty keep_ids set, inadvertently deleting every scene document in the scenes subcollection (e.g. PATCH {"name": "Renamed"} or PATCH {} wiped all scenes).

This change distinguishes an absent storyboard key from an explicit empty list:

  • When 'storyboard' is omitted from the PATCH payload, the scenes subcollection is left completely untouched. In _write_editor_project_doc, omitting storyboard also preserves the root doc's storyboard: [] without marking it DELETE_FIELD.
  • On the default full PATCH route, other omitted mutable root fields (including inputConfig) are still removed. Editor PATCH additionally preserves omitted inputConfig; other omitted mutable root fields retain their replacement behavior.
  • An explicit 'storyboard': [] continues to delete all scenes.
  • Existing project creation semantics (create=True) are preserved.

Reachability

Not reachable from the shipped UI: there is exactly one project PATCH call site (ui/.../config.ts:1293) and it always sends a full cloned ProjectConfig where storyboard is required (config.ts:295) and initialised to [] (config.ts:604). Every .patch( in ui/src, scripts and tools was audited. It matters because a partial PATCH is the most natural third-party call to make against a documented endpoint.

Note on Ownership & Identity Check

Investigation into per-user ownership check on orch.py:1434-1438:

  • Scene Machine intentionally uses a shared-team model documented in README.md ("Projects and generated media are shared across everyone who is admitted. Scene Machine is built for a trusted team: any admitted user can see, open, edit, and delete any project... There is no per-user or per-group ownership.") and in the docstring of project_detail_handler in orch.py:1403-1408 ("SHARED-TEAM MODEL (intentional): there is deliberately NO per-user ownership check on any method. Every IAP-admitted user may read, edit and delete every project (createdBy is a display label, not an access gate)").
  • Under AUTH_MODE=none (local dev), _request_email() returns None, so projects have no owner recorded.
  • Existing tests (test_projects_crud_flow_under_iap in test/test_frontdoor_data.py) explicitly assert that User 2 may modify User 1's project.
  • Following instructions, we do not invent or retrofit an ownership model, deferring this product/design decision to the repository owner.

Tests

Extended test/test_frontdoor_data.py using the in-file FakeUiDb and FakeBatch covering:

  • PATCH {"name": "Renamed"} leaves all 3 scenes intact (the regression);
  • PATCH {} leaves scenes intact;
  • explicit PATCH {"storyboard": []} still deletes all scenes (must not regress);
  • PATCH with 3 scenes when 5 exist deletes exactly the trailing 2 (control);
  • covers both _write_project_doc (/api/projects/<id>) and _write_editor_project_doc (/api/projects/<id>/editor and ?view=editor) paths.

Stacked on #198 (refactor/dedupe-gcs-signing).

Follow-up validation

orch.py and util/gcs_wrapper.py contained two copies of the same GCS IAM signing-credential caching logic. PR #195 fixed only the gcs_wrapper.py copy, leaving the hot path in orch.py (upload URLs, single GETs, and batch signing) running the stale, un-locked copy with dead refresh-on-expiry logic.

Delete the duplicate caching logic in orch.py and delegate to the single public get_signing_context() in util/gcs_wrapper.py. In addition, fix util/gcs_wrapper.py to detect non-service-account ADC credentials from local development and raise a clear, actionable RuntimeError explaining that URL signing requires a service account identity (pointing to impersonation or service account key instructions in DEVELOPING.md) rather than raising an unhelpful AttributeError.

TAG=agy
CONV=ec862d59-a9a7-4a7c-9eae-b59095e8cd08
SM-6: In orch.py, _write_project_doc and _write_editor_project_doc
previously coerced an absent storyboard key in a PATCH payload to [],
causing keep_ids to be empty and deleting every scene document in the
scenes subcollection.

Distinguish absent storyboard from an explicit empty list. When
'storyboard' is omitted from the PATCH payload, leave the scenes
subcollection completely untouched. An explicit 'storyboard': []
continues to delete all scenes. Existing create semantics are preserved.

Reachability:
Not reachable from the shipped UI: there is exactly one project PATCH call
site (ui/.../config.ts:1293) and it always sends a full cloned ProjectConfig
where storyboard is required (config.ts:295) and initialised to []
(config.ts:604). Every .patch( in ui/src, scripts and tools was audited.
It matters because a partial PATCH is the most natural third-party call to
make against a documented endpoint.

Stacked on #198.
@gps-readability-bot

Copy link
Copy Markdown

Still need readability approvals from:

@gps-readability-bot

Copy link
Copy Markdown

Still need readability approvals from:

@gps-readability-bot

Copy link
Copy Markdown

Still need readability approvals from:

@gps-readability-bot

Copy link
Copy Markdown

Still need readability approvals from:

@gps-readability-bot

Copy link
Copy Markdown

Still need readability approvals from:

@christophervoelpel

Copy link
Copy Markdown
Collaborator Author

Review: merge after small fixes

Multi-agent review (reviewer → independent critique agent re-verifying each claim against the code).

The mechanism is the simplest thing that works — 'storyboard' in payload, no sentinel objects, no Pydantic model_fields_set tricks — and it's correctly applied to both writers. The scoping is right too: storyboard is the only field backed by a subcollection, so fixing it alone beats building a speculative "PATCH framework". No regression in clearing: explicit {"storyboard": []} still deletes all scenes, and create=True is untouched.

Worth doing: pin the asymmetry this creates

The default PATCH still does a full set() replacement of the root doc — ops = [('create' if create else 'set', doc_ref, root)], no merge=True (confirmed against FakeBatch.commit → ref.set(data), and project_detail_handler at orch.py:1482-1491 passes the payload straight through with no read-merge). So after this PR:

PATCH {"name": "Renamed"}  →  all 3 scenes survive  ✅
                           →  inputConfig, aspectRatio, … still dropped  ⚠️

Storyboard becomes the one field with PATCH semantics inside a PUT-shaped endpoint. I'd keep it that way — widening the fix is a much bigger change than this PR should carry — but it should be deliberate rather than latent:

  • The new test_project_patch_omitting_storyboard_leaves_scenes_intact currently masks it: on the non-editor path it asserts only name and storyboard, both of which survive the root wipe. That test is exactly where an inputConfig-is-gone assertion belongs.
  • One line in the handler docstring, and one sentence at DEVELOPING.md:252, which now reads false: "Full GET/PATCH remains the Setup and legacy detail contract; full PATCH keeps its replacement semantics."

Suggested deletions (~90 of 178 test lines)

  • test_project_patch_shrinking_storyboard_deletes_trailing_scenes (~48 lines) — duplicates the existing test_project_patch_prunes_removed_scenes and never touches the branch this PR changes.
  • test_project_patch_empty_payload_leaves_scenes_intact (~40 lines) — same code path as the omitting test. If you want it, it's two extra lines (client.patch(url, json={})) inside that test.
  • The '/api/projects/{id}?view=editor' param (~3 lines) — both editor forms funnel into the identical branch (view == 'editor', orch.py:1456/1468/1488), so the 12 parametrized cases currently cover 8 behaviours.

That leaves absent→keep and explicit-[]→clear across both the default and editor paths, which is the whole of what changed.

Nits

  • {"storyboard": null} (or {}, or a string) is silently coerced to [] and deletes every scene — if not isinstance(scenes, list): scenes = [] survives inside the new if 'storyboard' in payload. Only absent is now safe. Pre-existing, but this change makes the absent/null divergence sharper, so it deserves either a comment marking the coercion intentional or a 400 next to the existing _MAX_CREATE_SCENES check. Not in this PR.
  • ops.insert(0, ...) at orch.py:1222-1235 reads backwards after ops = [], but it's load-bearing (the root update must commit first). A half-line comment saying why beats restructuring a working batch order.
  • PR body says the tests use test/firestore_fake.py; they actually use the in-file FakeUiDb / FakeBatch.
  • One blank line before @pytest.mark.parametrize at test_frontdoor_data.py:1666; pyink/PEP 8 want two.

Explicitly not recommended

  • Don't add merge=True or a non-list-storyboard 400 in this PR. Both are real improvements; both are separate changes with their own blast radius.
  • Don't churn the ops.insert ordering.

Process notes

@christophervoelpel

Copy link
Copy Markdown
Collaborator Author

Consolidated Review

Verdict: Merge after one small fix, once #198 has landed. This note reconciles the 19:50 comment on this PR against head b50f5a2, and every item below was re-verified by tracing both writers' callers and running the tests and probes listed under Evidence (there was no 19:29 comment on this PR).

Do before merge

  1. Pin the asymmetry this PR creates so it stays deliberate. In test_project_patch_omitting_storyboard_leaves_scenes_intact (test/test_frontdoor_data.py:1675), on the default (non-editor) path, assert that an omitted root field such as inputConfig is replaced (comes back absent) while the scenes survive; add one line to the handler docstring and a one-sentence caveat at DEVELOPING.md:252 saying that storyboard is the one field with patch semantics inside an otherwise full-replacement PATCH. Why: a probe that created a project with inputConfig and two scenes and sent PATCH {"name": "Renamed"} on the default route got scenes intact and inputConfig gone; the current test asserts only name and storyboard, so it reads as full preservation. Acceptance: the new assertion passes at head and the docs match the behaviour.

Optional, does not block

  • PR body says tests use test/firestore_fake.py; FakeUiDb/FakeBatch are actually defined in-file. Free to fix.
  • test_project_patch_empty_payload_leaves_scenes_intact runs the same code path as the omitting-storyboard test; could be trimmed but costs nothing to keep.
  • The ?view=editor parametrize case is redundant with /editor, both hit the same branch in project_detail_handler; cosmetic trimming only.

Rejected or superseded, do not re-litigate

  • The suggestion to delete test_project_patch_shrinking_storyboard_deletes_trailing_scenes as a duplicate: it is the only coverage for editor-path storyboard shrinking via /editor and ?view=editor. Deleting it loses real coverage for no gain; keep it.
  • Null or non-list storyboard still wiping all scenes (orch.py:1185-1187, :1224-1226): pre-existing, not introduced here, out of scope by the author's own note, and unreachable since the sole caller always sends a proper array. Not a blocker.
  • The ops.insert(0, ...) reordering at orch.py:1235: verified load-bearing and correct, root update still lands in the first commit batch. Restructuring it further stays out of scope, as already argued.
  • Adding merge=True or a 400 for non-list storyboard: both are real but separate changes with their own blast radius, correctly deferred.

Evidence

  • pytest -q test/test_frontdoor_data.py -k 'storyboard or patch' at head: 20 passed.
  • Base-code regression check (orch.py swapped for the pre-fix version from refs/review-20260921b/pr198): the two new regression tests fail across all parametrizations, the two duplicate-flagged tests pass on both revisions.
  • Independent probe: created a project with inputConfig and two scenes, PATCHed {"name": "Renamed"} on the default route, read it back: inputConfig was None, scenes were intact. Confirms the default-path asymmetry directly.
  • git diff --check refs/review-20260921b/pr198..HEAD: clean, no overlap with PR 198's orch.py hunks.
  • Read config.ts PATCH call site and DEFAULT_PROJECT_CONFIG, searched for a second call site: none found, confirming the asymmetry is unreachable from the shipped UI today.
  • Not run: live Firestore, UI build or typecheck, full backend suite, CI on this PR's own branch (it targets a feature branch, not main).

Way forward

@christophervoelpel
christophervoelpel changed the base branch from refactor/dedupe-gcs-signing to main September 22, 2026 11:19
@christophervoelpel
christophervoelpel dismissed uenke’s stale review September 22, 2026 11:19

The base branch was changed.

@gps-readability-bot

Copy link
Copy Markdown

Still need readability approvals from:

2 similar comments
@gps-readability-bot

Copy link
Copy Markdown

Still need readability approvals from:

@gps-readability-bot

Copy link
Copy Markdown

Still need readability approvals from:

@gps-readability-bot

Copy link
Copy Markdown

Readability approvals granted:

Comment thread orch.py Outdated
Comment on lines +1184 to +1187
if create or 'storyboard' in payload:
scenes = payload.get('storyboard')
if not isinstance(scenes, list):
scenes = []

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

If a caller sends "storyboard": null (or another non-list value) in a PATCH payload—which is common in clients that serialize unset optional fields as null—'storyboard' in payload evaluates to True, scenes is coerced to [], keep_ids becomes empty, and every scene in projects/<id>/scenes is still deleted. Additionally, when create=True and storyboard is omitted or None, root['storyboard'] = [] is already set on line 1182 and the loop body is a no-op.

Checking isinstance(scenes, list) directly ensures the scenes subcollection is only written or pruned when an explicit list ([...] or []) is provided:

Suggested change
if create or 'storyboard' in payload:
scenes = payload.get('storyboard')
if not isinstance(scenes, list):
scenes = []
scenes = payload.get('storyboard')
if isinstance(scenes, list):

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Agreed, and applied. I reproduced it on the PR head: PATCH {"name": "n", "storyboard": null} deleted all 3 scenes on every route.

_write_project_doc now writes and prunes scenes only when isinstance(scenes, list) is true. An explicit [] still clears the storyboard. For create=True with the storyboard omitted, the result is the same as before (root storyboard: [], no scene writes). I also updated the docstring.

New test test_project_patch_non_list_storyboard_leaves_scenes_intact runs 3 routes × 3 values (null, a string, an object). It fails 9/9 against the previous PR head and passes with the fix. Full Python suite is green.

Comment thread orch.py Outdated
Comment on lines +1223 to +1227
if 'storyboard' in payload:
scenes = payload.get('storyboard')
if not isinstance(scenes, list):
scenes = []
root_updates[field_path.FieldPath('storyboard').to_api_repr()] = []

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Similar to _write_project_doc, if a PATCH payload to the editor endpoint includes "storyboard": null (or a non-list value), 'storyboard' in payload is True and scenes = [] deletes every scene in the subcollection. Furthermore, because lines 1218–1221 copy all payload keys into root_updates, setting root_updates['storyboard'] = [] unconditionally before checking isinstance(scenes, list) ensures a non-list storyboard value in payload can never overwrite the root document's storyboard: [] placeholder:

Suggested change
if 'storyboard' in payload:
scenes = payload.get('storyboard')
if not isinstance(scenes, list):
scenes = []
root_updates[field_path.FieldPath('storyboard').to_api_repr()] = []
root_updates[field_path.FieldPath('storyboard').to_api_repr()] = []
scenes = payload.get('storyboard')
if isinstance(scenes, list):

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Agreed, and applied as suggested. The editor path now sets the root storyboard placeholder to [] unconditionally, before the isinstance(scenes, list) check. As a result, a non-list value copied from payload can never land on the root document, and scenes are written or pruned only for an explicit list.

Mutation check: dropping that unconditional line makes the editor/?view=editor cases of both the new non-list test and the shrink test fail (8 failures).

Comment on lines +1702 to +1704
scenes = _scenes_docs(fake_db, project_id)
assert len(scenes) == 3
assert [scenes[f'{i:06d}']['d'] for i in range(3)] == ['0', '1', '2']

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Because _read_project_doc (GET /api/projects/<id>) unconditionally overwrites data['storyboard'] from scenes_ref.stream(), the GET assertion on line 1708 would still pass even if _write_editor_project_doc marked the root document's storyboard field with DELETE_FIELD. Asserting on the stored root document in fake_db directly verifies the root storyboard == [] preservation invariant across all three routes:

Suggested change
scenes = _scenes_docs(fake_db, project_id)
assert len(scenes) == 3
assert [scenes[f'{i:06d}']['d'] for i in range(3)] == ['0', '1', '2']
assert fake_db.collection('projects').docs[project_id]['storyboard'] == []
scenes = _scenes_docs(fake_db, project_id)
assert len(scenes) == 3
assert [scenes[f'{i:06d}']['d'] for i in range(3)] == ['0', '1', '2']

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Good point: GET rebuilds storyboard from the subcollection, so it would hide this regression. I added the direct fake_db root assertion here (with a short comment explaining why). The new non-list test uses the same check.

Comment on lines +1834 to +1836
scenes = _scenes_docs(fake_db, project_id)
assert len(scenes) == 3
assert set(scenes.keys()) == {'000000', '000001', '000002'}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

In _write_editor_project_doc, root_updates.update(...) initially copies the full payload['storyboard'] list into root_updates before root_updates['storyboard'] = [] resets it to an empty list. Asserting that the stored root document in fake_db still has storyboard == [] after a PATCH that supplies scenes verifies that scenes are never persisted inline on the root document:

Suggested change
scenes = _scenes_docs(fake_db, project_id)
assert len(scenes) == 3
assert set(scenes.keys()) == {'000000', '000001', '000002'}
assert fake_db.collection('projects').docs[project_id]['storyboard'] == []
scenes = _scenes_docs(fake_db, project_id)
assert len(scenes) == 3
assert set(scenes.keys()) == {'000000', '000001', '000002'}

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Applied. The shrink test now asserts the stored root storyboard == [] after a PATCH that supplies scenes. It catches the regression: removing the editor path's root_updates['storyboard'] = [] makes this test fail for /editor and ?view=editor, because the scenes are then stored inline on the root.

A PATCH carrying "storyboard": null (or any non-list value) was treated
like [] and deleted every scene. Both the full and editor write paths now
touch the scenes subcollection only when storyboard is a list, and the
editor path always pins the root storyboard placeholder to [] so a
non-list value can never be stored on the root document.

Tests assert the stored root placeholder directly (GET rebuilds storyboard
from the subcollection and would mask a regression) and cover null,
string and object storyboard values on all three PATCH routes.

Addresses review feedback from victor-paunescu on #203.
@christophervoelpel
christophervoelpel merged commit 3138f3a into main Sep 24, 2026
13 checks passed
@christophervoelpel
christophervoelpel deleted the fix/project-patch-absent-storyboard branch September 24, 2026 09:30
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants