From 60b66b667c5f5f54f6c503f6feb886a53afeb116 Mon Sep 17 00:00:00 2001 From: RapideCastor Date: Mon, 24 Aug 2026 11:53:21 +0200 Subject: [PATCH 001/113] docs(harbour): reject cross-model repair experiment --- apps/storycore-harbour/STATUS.md | 1 + apps/storycore-harbour/review/MVP_ROADMAP_2026-08-24.md | 1 + 2 files changed, 2 insertions(+) diff --git a/apps/storycore-harbour/STATUS.md b/apps/storycore-harbour/STATUS.md index aff223d7..755ef3e6 100644 --- a/apps/storycore-harbour/STATUS.md +++ b/apps/storycore-harbour/STATUS.md @@ -288,4 +288,5 @@ Codex must inspect the latest CI and Sonar results first. The next legitimate im - The Anna adapter now exposes an optional user-entered model preference. Blank or invalid values preserve the Anna default; valid hints are advisory and only match models already enabled for that user. Normal StoryCore data and provider credentials remain unaffected. Automated gates pass: 65/65 Node tests, strict validation, Edge end-to-end smoke, and Edge deletion/storage-preservation smoke. - The complete fixed corpus with user preference `gemma` finished at 14/20: median 21.67 seconds, p95 39.78 seconds, 6 repaired passes, and no JSON truncation. Failures were A02 `reference_invalid`, A06/A09/A11 `contract_invalid`, A10 `warning_severity_invalid`, and A19 `required_field_invalid`. - Exact private-output replay identified two bounded schema aliases: A02 used warning `sceneId: "null"`; A10 used severity `minor`. Both now normalize to canonical `null` and `info`, and their exact real outputs pass the validator locally. This projects Gemma to 16/20 but does not replace the measured 14/20 score. Three malformed JSON responses and one structurally empty project remain rejected. +- A loopback-only cross-model experiment kept Anna default for primary generation and hinted Gemma only for the single repair. It passed A02/A15 directly but failed A07 (`contract_invalid`) and A18 (`unknown`): 2/4, worse than Gemma-only 3/4. The uncommitted experiment was removed; production retains one user-selected preference for both calls. - Reliability evidence is now a two-profile matrix: Anna default 16/20; Gemma preference 14/20. Both fail the 18/20 gate. No readiness, version cut, review, or release claim is permitted. diff --git a/apps/storycore-harbour/review/MVP_ROADMAP_2026-08-24.md b/apps/storycore-harbour/review/MVP_ROADMAP_2026-08-24.md index 3a571035..caa23ee9 100644 --- a/apps/storycore-harbour/review/MVP_ROADMAP_2026-08-24.md +++ b/apps/storycore-harbour/review/MVP_ROADMAP_2026-08-24.md @@ -88,3 +88,4 @@ Do not replace production-platform gates with these local results. - User control complete: the Anna adapter offers an optional validated model hint and otherwise preserves the Anna default. It has 65-test, strict-validation, end-to-end Edge, and deletion/storage-preservation evidence. - Complete Gemma-preference corpus: 14/20, median 21.67 seconds, p95 39.78 seconds, 6 repaired passes. It is faster and avoids MiniMax truncation but creates six schema/reference failures. Keep this score separate from Anna-default 16/20; neither satisfies readiness. - Post-run exact replay: canonicalizing warning severity `minor→info` and string scene reference `"null"→null` makes the measured A02/A10 outputs valid, projecting 16/20 without weakening any structural rule. The measured score remains 14/20 until a real rerun; the four remaining outputs are genuinely malformed or structurally empty. +- Rejected experiment: Anna-default primary plus Gemma-only repair scored 2/4 on A02/A07/A15/A18, below Gemma-only 3/4. The diagnostic code was removed; do not add separate repair-model complexity without new platform evidence. From ee3b4eb2277c1888a23cb932ff17663f5059141c Mon Sep 17 00:00:00 2001 From: RapideCastor Date: Mon, 24 Aug 2026 21:34:08 +0200 Subject: [PATCH 002/113] docs(harbour): record fail-closed A06 reproduction --- apps/storycore-harbour/STATUS.md | 1 + apps/storycore-harbour/review/MVP_ROADMAP_2026-08-24.md | 1 + 2 files changed, 2 insertions(+) diff --git a/apps/storycore-harbour/STATUS.md b/apps/storycore-harbour/STATUS.md index 755ef3e6..4b8b5c5e 100644 --- a/apps/storycore-harbour/STATUS.md +++ b/apps/storycore-harbour/STATUS.md @@ -289,4 +289,5 @@ Codex must inspect the latest CI and Sonar results first. The next legitimate im - The complete fixed corpus with user preference `gemma` finished at 14/20: median 21.67 seconds, p95 39.78 seconds, 6 repaired passes, and no JSON truncation. Failures were A02 `reference_invalid`, A06/A09/A11 `contract_invalid`, A10 `warning_severity_invalid`, and A19 `required_field_invalid`. - Exact private-output replay identified two bounded schema aliases: A02 used warning `sceneId: "null"`; A10 used severity `minor`. Both now normalize to canonical `null` and `info`, and their exact real outputs pass the validator locally. This projects Gemma to 16/20 but does not replace the measured 14/20 score. Three malformed JSON responses and one structurally empty project remain rejected. - A loopback-only cross-model experiment kept Anna default for primary generation and hinted Gemma only for the single repair. It passed A02/A15 directly but failed A07 (`contract_invalid`) and A18 (`unknown`): 2/4, worse than Gemma-only 3/4. The uncommitted experiment was removed; production retains one user-selected preference for both calls. +- A fresh Gemma A06 reproduction ruled out a bounded syntax-only repair. The primary output was missing one final brace, but appending it still left characters, locations, scenes, score, and warnings absent. The model repair was valid JSON yet again omitted the production bible and all core arrays. Parser recovery must remain fail-closed because required creative structure cannot be inferred safely. - Reliability evidence is now a two-profile matrix: Anna default 16/20; Gemma preference 14/20. Both fail the 18/20 gate. No readiness, version cut, review, or release claim is permitted. diff --git a/apps/storycore-harbour/review/MVP_ROADMAP_2026-08-24.md b/apps/storycore-harbour/review/MVP_ROADMAP_2026-08-24.md index caa23ee9..04946785 100644 --- a/apps/storycore-harbour/review/MVP_ROADMAP_2026-08-24.md +++ b/apps/storycore-harbour/review/MVP_ROADMAP_2026-08-24.md @@ -89,3 +89,4 @@ Do not replace production-platform gates with these local results. - Complete Gemma-preference corpus: 14/20, median 21.67 seconds, p95 39.78 seconds, 6 repaired passes. It is faster and avoids MiniMax truncation but creates six schema/reference failures. Keep this score separate from Anna-default 16/20; neither satisfies readiness. - Post-run exact replay: canonicalizing warning severity `minor→info` and string scene reference `"null"→null` makes the measured A02/A10 outputs valid, projecting 16/20 without weakening any structural rule. The measured score remains 14/20 until a real rerun; the four remaining outputs are genuinely malformed or structurally empty. - Rejected experiment: Anna-default primary plus Gemma-only repair scored 2/4 on A02/A07/A15/A18, below Gemma-only 3/4. The diagnostic code was removed; do not add separate repair-model complexity without new platform evidence. +- A06 reproduction: one missing final brace was not the root cause. Both the completed primary object and the syntactically valid repair lacked the required creative structure. Keep fail-closed validation; do not synthesize characters, locations, scenes, or continuity data in the parser. From 1cdcb1b7c0e6503762f1f14fcd1e213d7138ad22 Mon Sep 17 00:00:00 2001 From: RapideCastor Date: Tue, 25 Aug 2026 08:18:13 +0200 Subject: [PATCH 003/113] docs(harbour): record blocked third-model probe --- apps/storycore-harbour/STATUS.md | 1 + apps/storycore-harbour/review/MVP_ROADMAP_2026-08-24.md | 1 + 2 files changed, 2 insertions(+) diff --git a/apps/storycore-harbour/STATUS.md b/apps/storycore-harbour/STATUS.md index 4b8b5c5e..9643acaf 100644 --- a/apps/storycore-harbour/STATUS.md +++ b/apps/storycore-harbour/STATUS.md @@ -290,4 +290,5 @@ Codex must inspect the latest CI and Sonar results first. The next legitimate im - Exact private-output replay identified two bounded schema aliases: A02 used warning `sceneId: "null"`; A10 used severity `minor`. Both now normalize to canonical `null` and `info`, and their exact real outputs pass the validator locally. This projects Gemma to 16/20 but does not replace the measured 14/20 score. Three malformed JSON responses and one structurally empty project remain rejected. - A loopback-only cross-model experiment kept Anna default for primary generation and hinted Gemma only for the single repair. It passed A02/A15 directly but failed A07 (`contract_invalid`) and A18 (`unknown`): 2/4, worse than Gemma-only 3/4. The uncommitted experiment was removed; production retains one user-selected preference for both calls. - A fresh Gemma A06 reproduction ruled out a bounded syntax-only repair. The primary output was missing one final brace, but appending it still left characters, locations, scenes, score, and warnings absent. The model repair was valid JSON yet again omitted the production bible and all core arrays. Parser recovery must remain fail-closed because required creative structure cannot be inferred safely. +- A single A06 diagnostic using Anna's documented example hint `gpt-4o` produced no completion or model metadata. The UI recorded timeout while the harness request remained pending for more than six minutes and only window heartbeats continued. The server was stopped to terminate the orphaned request; do not retry this hint until Anna exposes model grants or fixes cancellation/deadline propagation. - Reliability evidence is now a two-profile matrix: Anna default 16/20; Gemma preference 14/20. Both fail the 18/20 gate. No readiness, version cut, review, or release claim is permitted. diff --git a/apps/storycore-harbour/review/MVP_ROADMAP_2026-08-24.md b/apps/storycore-harbour/review/MVP_ROADMAP_2026-08-24.md index 04946785..fa12b55a 100644 --- a/apps/storycore-harbour/review/MVP_ROADMAP_2026-08-24.md +++ b/apps/storycore-harbour/review/MVP_ROADMAP_2026-08-24.md @@ -90,3 +90,4 @@ Do not replace production-platform gates with these local results. - Post-run exact replay: canonicalizing warning severity `minor→info` and string scene reference `"null"→null` makes the measured A02/A10 outputs valid, projecting 16/20 without weakening any structural rule. The measured score remains 14/20 until a real rerun; the four remaining outputs are genuinely malformed or structurally empty. - Rejected experiment: Anna-default primary plus Gemma-only repair scored 2/4 on A02/A07/A15/A18, below Gemma-only 3/4. The diagnostic code was removed; do not add separate repair-model complexity without new platform evidence. - A06 reproduction: one missing final brace was not the root cause. Both the completed primary object and the syntactically valid repair lacked the required creative structure. Keep fail-closed validation; do not synthesize characters, locations, scenes, or continuity data in the parser. +- Third-model probe blocked: hint `gpt-4o` returned no response metadata and remained pending beyond the App timeout. Treat it as a platform cancellation/grant issue, not model evidence; no further blind hint probes. From ce6663b39cd9c320dadc01c482891134a7f72674 Mon Sep 17 00:00:00 2001 From: Sylvain Galliez <128614184+zedarvates@users.noreply.github.com> Date: Thu, 27 Aug 2026 11:32:21 +0200 Subject: [PATCH 004/113] feat(video-routing): add deterministic multi-subject strategy router --- src/video_validation/multisubject_router.py | 135 ++++++++++++++++++++ 1 file changed, 135 insertions(+) create mode 100644 src/video_validation/multisubject_router.py diff --git a/src/video_validation/multisubject_router.py b/src/video_validation/multisubject_router.py new file mode 100644 index 00000000..2878c396 --- /dev/null +++ b/src/video_validation/multisubject_router.py @@ -0,0 +1,135 @@ +from __future__ import annotations + +from dataclasses import dataclass +from enum import Enum + + +class GenerationStrategy(str, Enum): + DIRECT = "DIRECT" + PARALLEL = "PARALLEL" + SEQUENTIAL = "SEQUENTIAL" + + +@dataclass(frozen=True) +class MultiSubjectShot: + subject_count: int + interaction_strength: float = 0.0 + contact_required: bool = False + occlusion_level: float = 0.0 + identity_criticality: float = 0.5 + camera_motion: float = 0.0 + temporal_dependency: float = 0.0 + compute_budget: float = 0.5 + + def __post_init__(self) -> None: + if self.subject_count < 0: + raise ValueError("subject_count must be non-negative") + for name in ( + "interaction_strength", + "occlusion_level", + "identity_criticality", + "camera_motion", + "temporal_dependency", + "compute_budget", + ): + value = getattr(self, name) + if not 0.0 <= value <= 1.0: + raise ValueError(f"{name} must be in [0, 1]") + + +@dataclass(frozen=True) +class RoutingDecision: + strategy: GenerationStrategy + confidence: float + reasons: tuple[str, ...] + requires_intermediate_validation: bool + + +def route_multi_subject_shot(shot: MultiSubjectShot) -> RoutingDecision: + """Choose a generation strategy using deterministic, explainable rules. + + The router does not call a model and does not generate media. It only + chooses the safest/cheapest generation topology for the supplied shot + complexity. Downstream validators still decide whether the result passes. + """ + + reasons: list[str] = [] + + if shot.subject_count <= 1: + return RoutingDecision( + strategy=GenerationStrategy.DIRECT, + confidence=0.98, + reasons=("single_or_no_subject",), + requires_intermediate_validation=False, + ) + + # Strong physical/spatial coupling is the clearest case for staged + # generation because independent branches cannot reliably preserve contact. + if shot.contact_required: + reasons.append("contact_required") + if shot.interaction_strength >= 0.7: + reasons.append("strong_subject_interaction") + if shot.occlusion_level >= 0.75: + reasons.append("heavy_occlusion") + if shot.temporal_dependency >= 0.8: + reasons.append("strong_temporal_dependency") + + if reasons: + confidence = min(0.98, 0.80 + 0.04 * len(reasons)) + return RoutingDecision( + strategy=GenerationStrategy.SEQUENTIAL, + confidence=confidence, + reasons=tuple(reasons), + requires_intermediate_validation=True, + ) + + # Independent subjects with high identity requirements benefit from + # separate generation branches followed by composition/reconciliation. + parallel_score = 0.0 + if shot.identity_criticality >= 0.7: + parallel_score += 0.45 + reasons.append("identity_critical") + if shot.subject_count >= 3: + parallel_score += 0.25 + reasons.append("many_subjects") + if shot.interaction_strength <= 0.35: + parallel_score += 0.20 + reasons.append("weak_subject_interaction") + if shot.occlusion_level <= 0.35: + parallel_score += 0.10 + reasons.append("low_occlusion") + + if parallel_score >= 0.65: + return RoutingDecision( + strategy=GenerationStrategy.PARALLEL, + confidence=min(0.95, 0.70 + parallel_score * 0.25), + reasons=tuple(reasons), + requires_intermediate_validation=True, + ) + + # Complex camera motion and previous-shot continuity can still favor a + # staged build even without direct contact between subjects. + if shot.camera_motion >= 0.75 and shot.temporal_dependency >= 0.55: + return RoutingDecision( + strategy=GenerationStrategy.SEQUENTIAL, + confidence=0.78, + reasons=("camera_motion_with_temporal_dependency",), + requires_intermediate_validation=True, + ) + + # When compute is severely constrained, use the single-pass baseline and + # rely on validators rather than multiplying generation branches. + if shot.compute_budget <= 0.2: + return RoutingDecision( + strategy=GenerationStrategy.DIRECT, + confidence=0.72, + reasons=("compute_budget_constrained",), + requires_intermediate_validation=False, + ) + + return RoutingDecision( + strategy=GenerationStrategy.DIRECT, + confidence=0.70, + reasons=("low_interaction_complexity",), + requires_intermediate_validation=False, + ) From 4b54ab04cc07fee25d14867af78a204d1e6c3f9d Mon Sep 17 00:00:00 2001 From: Sylvain Galliez <128614184+zedarvates@users.noreply.github.com> Date: Thu, 27 Aug 2026 11:32:35 +0200 Subject: [PATCH 005/113] feat(video-routing): expose multi-subject routing primitives --- src/video_validation/__init__.py | 15 +++++++++++++++ 1 file changed, 15 insertions(+) create mode 100644 src/video_validation/__init__.py diff --git a/src/video_validation/__init__.py b/src/video_validation/__init__.py new file mode 100644 index 00000000..defa884f --- /dev/null +++ b/src/video_validation/__init__.py @@ -0,0 +1,15 @@ +"""Video validation and generation-routing primitives for StoryCore.""" + +from .multisubject_router import ( + GenerationStrategy, + MultiSubjectShot, + RoutingDecision, + route_multi_subject_shot, +) + +__all__ = [ + "GenerationStrategy", + "MultiSubjectShot", + "RoutingDecision", + "route_multi_subject_shot", +] From 7d438ee2e11fd4bc00cb18970883834eacba6ba9 Mon Sep 17 00:00:00 2001 From: Sylvain Galliez <128614184+zedarvates@users.noreply.github.com> Date: Thu, 27 Aug 2026 11:32:48 +0200 Subject: [PATCH 006/113] test(video-routing): cover multi-subject strategy decisions --- tests/test_multi_subject_router.py | 93 ++++++++++++++++++++++++++++++ 1 file changed, 93 insertions(+) create mode 100644 tests/test_multi_subject_router.py diff --git a/tests/test_multi_subject_router.py b/tests/test_multi_subject_router.py new file mode 100644 index 00000000..dc2ce46d --- /dev/null +++ b/tests/test_multi_subject_router.py @@ -0,0 +1,93 @@ +import pytest + +from src.video_validation.multisubject_router import ( + GenerationStrategy, + MultiSubjectShot, + route_multi_subject_shot, +) + + +def test_single_subject_uses_direct(): + decision = route_multi_subject_shot(MultiSubjectShot(subject_count=1)) + assert decision.strategy == GenerationStrategy.DIRECT + assert decision.requires_intermediate_validation is False + + +def test_contact_required_uses_sequential(): + decision = route_multi_subject_shot( + MultiSubjectShot(subject_count=2, contact_required=True) + ) + assert decision.strategy == GenerationStrategy.SEQUENTIAL + assert "contact_required" in decision.reasons + assert decision.requires_intermediate_validation is True + + +def test_strong_interaction_uses_sequential(): + decision = route_multi_subject_shot( + MultiSubjectShot(subject_count=2, interaction_strength=0.85) + ) + assert decision.strategy == GenerationStrategy.SEQUENTIAL + assert "strong_subject_interaction" in decision.reasons + + +def test_identity_critical_independent_subjects_use_parallel(): + decision = route_multi_subject_shot( + MultiSubjectShot( + subject_count=3, + identity_criticality=0.95, + interaction_strength=0.1, + occlusion_level=0.1, + ) + ) + assert decision.strategy == GenerationStrategy.PARALLEL + assert decision.requires_intermediate_validation is True + assert "identity_critical" in decision.reasons + + +def test_camera_motion_plus_continuity_uses_sequential(): + decision = route_multi_subject_shot( + MultiSubjectShot( + subject_count=2, + interaction_strength=0.5, + occlusion_level=0.5, + identity_criticality=0.5, + camera_motion=0.9, + temporal_dependency=0.6, + ) + ) + assert decision.strategy == GenerationStrategy.SEQUENTIAL + assert decision.reasons == ("camera_motion_with_temporal_dependency",) + + +def test_simple_two_subject_scene_defaults_direct(): + decision = route_multi_subject_shot( + MultiSubjectShot( + subject_count=2, + interaction_strength=0.45, + occlusion_level=0.2, + identity_criticality=0.45, + ) + ) + assert decision.strategy == GenerationStrategy.DIRECT + assert decision.reasons == ("low_interaction_complexity",) + + +def test_compute_constrained_scene_can_stay_direct(): + decision = route_multi_subject_shot( + MultiSubjectShot( + subject_count=2, + interaction_strength=0.4, + occlusion_level=0.4, + identity_criticality=0.5, + compute_budget=0.1, + ) + ) + assert decision.strategy == GenerationStrategy.DIRECT + assert decision.reasons == ("compute_budget_constrained",) + + +def test_invalid_inputs_fail_closed(): + with pytest.raises(ValueError): + MultiSubjectShot(subject_count=-1) + with pytest.raises(ValueError): + MultiSubjectShot(subject_count=2, interaction_strength=1.1) From 4202d3b4db1eba2eff3c76f3b8f1e7942ce884f9 Mon Sep 17 00:00:00 2001 From: RapideCastor Date: Thu, 27 Aug 2026 13:32:28 +0200 Subject: [PATCH 007/113] docs(harbour): record default-model corpus regression --- apps/storycore-harbour/STATUS.md | 1 + apps/storycore-harbour/review/MVP_ROADMAP_2026-08-24.md | 1 + 2 files changed, 2 insertions(+) diff --git a/apps/storycore-harbour/STATUS.md b/apps/storycore-harbour/STATUS.md index 9643acaf..ae2c3002 100644 --- a/apps/storycore-harbour/STATUS.md +++ b/apps/storycore-harbour/STATUS.md @@ -292,3 +292,4 @@ Codex must inspect the latest CI and Sonar results first. The next legitimate im - A fresh Gemma A06 reproduction ruled out a bounded syntax-only repair. The primary output was missing one final brace, but appending it still left characters, locations, scenes, score, and warnings absent. The model repair was valid JSON yet again omitted the production bible and all core arrays. Parser recovery must remain fail-closed because required creative structure cannot be inferred safely. - A single A06 diagnostic using Anna's documented example hint `gpt-4o` produced no completion or model metadata. The UI recorded timeout while the harness request remained pending for more than six minutes and only window heartbeats continued. The server was stopped to terminate the orphaned request; do not retry this hint until Anna exposes model grants or fixes cancellation/deadline propagation. - Reliability evidence is now a two-profile matrix: Anna default 16/20; Gemma preference 14/20. Both fail the 18/20 gate. No readiness, version cut, review, or release claim is permitted. +- An owner-authorized complete corpus rerun on 2026-08-27 used Anna default `minimax/minimax-m3` through OpenRouter and regressed to 6/20. Median successful duration was 49.13 seconds, p95 was 57.87 seconds, and 2 successful projects used repair. Privacy-safe failures were eight `json_invalid`, five `contract_invalid`, and one timeout. The complete private result remains ignored at `acceptance/results.2026-08-27.local.jsonl`. This stochastic regression does not justify weakening the contract or cutting a version; it reinforces the unresolved default-model reliability blocker. diff --git a/apps/storycore-harbour/review/MVP_ROADMAP_2026-08-24.md b/apps/storycore-harbour/review/MVP_ROADMAP_2026-08-24.md index fa12b55a..6eb884d1 100644 --- a/apps/storycore-harbour/review/MVP_ROADMAP_2026-08-24.md +++ b/apps/storycore-harbour/review/MVP_ROADMAP_2026-08-24.md @@ -91,3 +91,4 @@ Do not replace production-platform gates with these local results. - Rejected experiment: Anna-default primary plus Gemma-only repair scored 2/4 on A02/A07/A15/A18, below Gemma-only 3/4. The diagnostic code was removed; do not add separate repair-model complexity without new platform evidence. - A06 reproduction: one missing final brace was not the root cause. Both the completed primary object and the syntactically valid repair lacked the required creative structure. Keep fail-closed validation; do not synthesize characters, locations, scenes, or continuity data in the parser. - Third-model probe blocked: hint `gpt-4o` returned no response metadata and remained pending beyond the App timeout. Treat it as a platform cancellation/grant issue, not model evidence; no further blind hint probes. +- 27 August default-model rerun: the complete immutable corpus used `minimax/minimax-m3` through OpenRouter and finished at 6/20, median 49.13 seconds, p95 57.87 seconds, and 2 repaired passes. The privacy-safe failure matrix was eight `json_invalid`, five `contract_invalid`, and one timeout. The ignored private JSONL is `acceptance/results.2026-08-27.local.jsonl`. This is a measured stochastic regression from the earlier 16/20 default run, so readiness remains blocked and no parser relaxation, immutable version, review submission, or release is justified. From b8c8cd5d738e7352e41c97c6eebb216076fa824a Mon Sep 17 00:00:00 2001 From: Sylvain Galliez <128614184+zedarvates@users.noreply.github.com> Date: Thu, 27 Aug 2026 14:06:38 +0200 Subject: [PATCH 008/113] feat(video-routing): adapt existing shot specs to routing inputs --- src/video_validation/shot_spec_adapter.py | 139 ++++++++++++++++++++++ 1 file changed, 139 insertions(+) create mode 100644 src/video_validation/shot_spec_adapter.py diff --git a/src/video_validation/shot_spec_adapter.py b/src/video_validation/shot_spec_adapter.py new file mode 100644 index 00000000..86b6cac8 --- /dev/null +++ b/src/video_validation/shot_spec_adapter.py @@ -0,0 +1,139 @@ +from __future__ import annotations + +from collections.abc import Mapping, Sequence +from typing import Any + +from .multisubject_router import MultiSubjectShot, RoutingDecision, route_multi_subject_shot + + +_CAMERA_MOTION_INTENSITY = { + "static": 0.0, + "locked": 0.0, + "none": 0.0, + "pan": 0.35, + "tilt": 0.35, + "zoom": 0.45, + "dolly": 0.55, + "tracking": 0.6, + "truck": 0.6, + "pedestal": 0.55, + "orbit": 0.75, + "crane": 0.75, + "handheld": 0.8, + "whip": 0.9, +} + + +def _read(source: Any, key: str, default: Any = None) -> Any: + if isinstance(source, Mapping): + return source.get(key, default) + return getattr(source, key, default) + + +def _routing_metadata(shot_spec: Any) -> Mapping[str, Any]: + metadata = _read(shot_spec, "metadata", {}) + if not isinstance(metadata, Mapping): + return {} + routing = metadata.get("multi_subject_routing", {}) + return routing if isinstance(routing, Mapping) else {} + + +def _normalize_score(value: Any, *, name: str, default: float) -> float: + if value is None: + return default + if isinstance(value, bool) or not isinstance(value, (int, float)): + raise ValueError(f"{name} must be numeric") + score = float(value) + if not 0.0 <= score <= 1.0: + raise ValueError(f"{name} must be in [0, 1]") + return score + + +def _camera_motion_score(value: Any) -> float: + if value is None: + return 0.0 + if isinstance(value, (int, float)) and not isinstance(value, bool): + return _normalize_score(value, name="camera_motion", default=0.0) + if not isinstance(value, str): + raise ValueError("camera_motion must be numeric or text") + + normalized = value.lower().replace("-", " ").replace("_", " ") + matched = [score for token, score in _CAMERA_MOTION_INTENSITY.items() if token in normalized] + return max(matched, default=0.25 if normalized.strip() else 0.0) + + +def _subject_count(shot_spec: Any, *, subjects: Sequence[Any] | None, routing: Mapping[str, Any]) -> int: + explicit = routing.get("subject_count", _read(shot_spec, "subject_count", None)) + if explicit is not None: + if isinstance(explicit, bool) or not isinstance(explicit, int) or explicit < 0: + raise ValueError("subject_count must be a non-negative integer") + return explicit + + for key in ("characters_present", "subjects", "characters"): + value = _read(shot_spec, key, None) + if isinstance(value, Sequence) and not isinstance(value, (str, bytes, bytearray)): + return len(value) + + if subjects is not None: + return len(subjects) + + # Fail closed rather than silently treating an unknown multi-character shot + # as a single-subject DIRECT generation. + raise ValueError("subject_count is required when the shot spec has no subject list") + + +def extract_multi_subject_shot( + shot_spec: Any, + *, + subjects: Sequence[Any] | None = None, + overrides: Mapping[str, Any] | None = None, +) -> MultiSubjectShot: + """Adapt an existing StoryCore shot/dict into the deterministic routing contract. + + Rich routing metadata can live under ``metadata.multi_subject_routing``. + ``overrides`` is intended for the orchestration layer when scene context knows + more than the shot object itself. No NLP guessing is performed here. + """ + + routing = dict(_routing_metadata(shot_spec)) + if overrides: + routing.update(overrides) + + camera_value = routing.get( + "camera_motion", + _read(shot_spec, "camera_movement", _read(shot_spec, "camera_motion", None)), + ) + + return MultiSubjectShot( + subject_count=_subject_count(shot_spec, subjects=subjects, routing=routing), + interaction_strength=_normalize_score( + routing.get("interaction_strength"), name="interaction_strength", default=0.0 + ), + contact_required=bool(routing.get("contact_required", False)), + occlusion_level=_normalize_score( + routing.get("occlusion_level"), name="occlusion_level", default=0.0 + ), + identity_criticality=_normalize_score( + routing.get("identity_criticality"), name="identity_criticality", default=0.5 + ), + camera_motion=_camera_motion_score(camera_value), + temporal_dependency=_normalize_score( + routing.get("temporal_dependency"), name="temporal_dependency", default=0.0 + ), + compute_budget=_normalize_score( + routing.get("compute_budget"), name="compute_budget", default=0.5 + ), + ) + + +def route_shot_spec( + shot_spec: Any, + *, + subjects: Sequence[Any] | None = None, + overrides: Mapping[str, Any] | None = None, +) -> RoutingDecision: + """Extract a routing input from a StoryCore shot and choose its topology.""" + + return route_multi_subject_shot( + extract_multi_subject_shot(shot_spec, subjects=subjects, overrides=overrides) + ) From c946da6238fee525dd4da17ae15378c55b3903cb Mon Sep 17 00:00:00 2001 From: Sylvain Galliez <128614184+zedarvates@users.noreply.github.com> Date: Thu, 27 Aug 2026 14:07:01 +0200 Subject: [PATCH 009/113] test(video-routing): cover shot-spec adapter --- tests/test_shot_spec_router_adapter.py | 96 ++++++++++++++++++++++++++ 1 file changed, 96 insertions(+) create mode 100644 tests/test_shot_spec_router_adapter.py diff --git a/tests/test_shot_spec_router_adapter.py b/tests/test_shot_spec_router_adapter.py new file mode 100644 index 00000000..f92afdf8 --- /dev/null +++ b/tests/test_shot_spec_router_adapter.py @@ -0,0 +1,96 @@ +from dataclasses import dataclass, field + +import pytest + +from src.video_validation.multisubject_router import GenerationStrategy +from src.video_validation.shot_spec_adapter import ( + extract_multi_subject_shot, + route_shot_spec, +) + + +@dataclass +class ShotLike: + camera_movement: str = "static" + metadata: dict = field(default_factory=dict) + + +def test_single_subject_existing_shot_routes_direct(): + decision = route_shot_spec(ShotLike(camera_movement="static"), subjects=["hero"]) + assert decision.strategy is GenerationStrategy.DIRECT + + +def test_scene_characters_present_can_supply_subject_count(): + spec = { + "characters_present": ["hero", "rival"], + "camera_movement": "static", + "metadata": { + "multi_subject_routing": { + "contact_required": True, + "interaction_strength": 0.9, + } + }, + } + decision = route_shot_spec(spec) + assert decision.strategy is GenerationStrategy.SEQUENTIAL + assert decision.requires_intermediate_validation is True + + +def test_identity_critical_independent_subjects_route_parallel(): + shot = ShotLike(camera_movement="tracking") + decision = route_shot_spec( + shot, + subjects=["a", "b", "c"], + overrides={ + "identity_criticality": 0.95, + "interaction_strength": 0.1, + "occlusion_level": 0.1, + }, + ) + assert decision.strategy is GenerationStrategy.PARALLEL + + +def test_camera_motion_text_is_normalized_deterministically(): + routing_input = extract_multi_subject_shot( + ShotLike(camera_movement="fast orbit camera"), + subjects=["a", "b"], + overrides={"temporal_dependency": 0.7}, + ) + assert routing_input.camera_motion == 0.75 + decision = route_shot_spec( + ShotLike(camera_movement="fast orbit camera"), + subjects=["a", "b"], + overrides={"temporal_dependency": 0.7}, + ) + assert decision.strategy is GenerationStrategy.SEQUENTIAL + + +def test_unknown_subject_count_fails_closed(): + with pytest.raises(ValueError, match="subject_count is required"): + route_shot_spec(ShotLike()) + + +def test_routing_metadata_can_override_subject_count_and_budget(): + spec = { + "camera_movement": "pan", + "metadata": { + "multi_subject_routing": { + "subject_count": 2, + "compute_budget": 0.1, + "interaction_strength": 0.4, + "occlusion_level": 0.4, + } + }, + } + decision = route_shot_spec(spec) + assert decision.strategy is GenerationStrategy.DIRECT + assert "compute_budget_constrained" in decision.reasons + + +def test_invalid_score_fails_closed(): + with pytest.raises(ValueError, match="interaction_strength"): + extract_multi_subject_shot( + ShotLike(), + subjects=["a", "b"], + overrides={"interaction_strength": 1.2}, + ) From 1cf11e4cdd458994d42027783d73fe4066fc42b9 Mon Sep 17 00:00:00 2001 From: Sylvain Galliez <128614184+zedarvates@users.noreply.github.com> Date: Thu, 27 Aug 2026 14:07:31 +0200 Subject: [PATCH 010/113] fix(video-routing): fail closed on non-boolean contact metadata --- src/video_validation/shot_spec_adapter.py | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/src/video_validation/shot_spec_adapter.py b/src/video_validation/shot_spec_adapter.py index 86b6cac8..c199c124 100644 --- a/src/video_validation/shot_spec_adapter.py +++ b/src/video_validation/shot_spec_adapter.py @@ -49,6 +49,14 @@ def _normalize_score(value: Any, *, name: str, default: float) -> float: return score +def _normalize_bool(value: Any, *, name: str, default: bool) -> bool: + if value is None: + return default + if not isinstance(value, bool): + raise ValueError(f"{name} must be boolean") + return value + + def _camera_motion_score(value: Any) -> float: if value is None: return 0.0 @@ -109,7 +117,9 @@ def extract_multi_subject_shot( interaction_strength=_normalize_score( routing.get("interaction_strength"), name="interaction_strength", default=0.0 ), - contact_required=bool(routing.get("contact_required", False)), + contact_required=_normalize_bool( + routing.get("contact_required"), name="contact_required", default=False + ), occlusion_level=_normalize_score( routing.get("occlusion_level"), name="occlusion_level", default=0.0 ), From 96eaf1f26a78e54f26df4d11434b9cda72a02f79 Mon Sep 17 00:00:00 2001 From: Sylvain Galliez <128614184+zedarvates@users.noreply.github.com> Date: Thu, 27 Aug 2026 14:07:41 +0200 Subject: [PATCH 011/113] feat(video-routing): export shot-spec routing adapter --- src/video_validation/__init__.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/video_validation/__init__.py b/src/video_validation/__init__.py index defa884f..76a32ec7 100644 --- a/src/video_validation/__init__.py +++ b/src/video_validation/__init__.py @@ -6,10 +6,13 @@ RoutingDecision, route_multi_subject_shot, ) +from .shot_spec_adapter import extract_multi_subject_shot, route_shot_spec __all__ = [ "GenerationStrategy", "MultiSubjectShot", "RoutingDecision", "route_multi_subject_shot", + "extract_multi_subject_shot", + "route_shot_spec", ] From 1eed5169ee8ed0a3cf40aa8fa7da73f3f45daf07 Mon Sep 17 00:00:00 2001 From: Sylvain Galliez <128614184+zedarvates@users.noreply.github.com> Date: Thu, 27 Aug 2026 14:11:10 +0200 Subject: [PATCH 012/113] test(video-routing): reject ambiguous contact metadata --- tests/test_shot_spec_router_adapter.py | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/tests/test_shot_spec_router_adapter.py b/tests/test_shot_spec_router_adapter.py index f92afdf8..b3e746a1 100644 --- a/tests/test_shot_spec_router_adapter.py +++ b/tests/test_shot_spec_router_adapter.py @@ -94,3 +94,12 @@ def test_invalid_score_fails_closed(): subjects=["a", "b"], overrides={"interaction_strength": 1.2}, ) + + +def test_non_boolean_contact_required_fails_closed(): + with pytest.raises(ValueError, match="contact_required"): + extract_multi_subject_shot( + ShotLike(), + subjects=["a", "b"], + overrides={"contact_required": "yes"}, + ) From 2ee3beecfbe5dff2084a8dd51f27f4d175ae9033 Mon Sep 17 00:00:00 2001 From: RapideCastor Date: Thu, 27 Aug 2026 14:30:21 +0200 Subject: [PATCH 013/113] docs(harbour): record Gemma corpus rerun --- apps/storycore-harbour/STATUS.md | 1 + apps/storycore-harbour/review/MVP_ROADMAP_2026-08-24.md | 1 + 2 files changed, 2 insertions(+) diff --git a/apps/storycore-harbour/STATUS.md b/apps/storycore-harbour/STATUS.md index ae2c3002..6f87db17 100644 --- a/apps/storycore-harbour/STATUS.md +++ b/apps/storycore-harbour/STATUS.md @@ -293,3 +293,4 @@ Codex must inspect the latest CI and Sonar results first. The next legitimate im - A single A06 diagnostic using Anna's documented example hint `gpt-4o` produced no completion or model metadata. The UI recorded timeout while the harness request remained pending for more than six minutes and only window heartbeats continued. The server was stopped to terminate the orphaned request; do not retry this hint until Anna exposes model grants or fixes cancellation/deadline propagation. - Reliability evidence is now a two-profile matrix: Anna default 16/20; Gemma preference 14/20. Both fail the 18/20 gate. No readiness, version cut, review, or release claim is permitted. - An owner-authorized complete corpus rerun on 2026-08-27 used Anna default `minimax/minimax-m3` through OpenRouter and regressed to 6/20. Median successful duration was 49.13 seconds, p95 was 57.87 seconds, and 2 successful projects used repair. Privacy-safe failures were eight `json_invalid`, five `contract_invalid`, and one timeout. The complete private result remains ignored at `acceptance/results.2026-08-27.local.jsonl`. This stochastic regression does not justify weakening the contract or cutting a version; it reinforces the unresolved default-model reliability blocker. +- A second owner-authorized complete corpus rerun on 2026-08-27 used the advisory `gemma` hint and finished at 15/20. Median successful duration was 21.91 seconds, p95 was 41.92 seconds, and 6 successful projects used repair. Privacy-safe failures were A07/A10/A12/A19 `contract_invalid` and A20 `required_field_invalid`; there were no timeouts or JSON truncations. The private result remains ignored at `acceptance/results.2026-08-27.gemma.local.jsonl`. This improves the measured Gemma profile from 14/20 to 15/20 but remains below the 18/20 gate, so no readiness, version cut, review submission, or release claim is permitted. diff --git a/apps/storycore-harbour/review/MVP_ROADMAP_2026-08-24.md b/apps/storycore-harbour/review/MVP_ROADMAP_2026-08-24.md index 6eb884d1..bac4b8d0 100644 --- a/apps/storycore-harbour/review/MVP_ROADMAP_2026-08-24.md +++ b/apps/storycore-harbour/review/MVP_ROADMAP_2026-08-24.md @@ -92,3 +92,4 @@ Do not replace production-platform gates with these local results. - A06 reproduction: one missing final brace was not the root cause. Both the completed primary object and the syntactically valid repair lacked the required creative structure. Keep fail-closed validation; do not synthesize characters, locations, scenes, or continuity data in the parser. - Third-model probe blocked: hint `gpt-4o` returned no response metadata and remained pending beyond the App timeout. Treat it as a platform cancellation/grant issue, not model evidence; no further blind hint probes. - 27 August default-model rerun: the complete immutable corpus used `minimax/minimax-m3` through OpenRouter and finished at 6/20, median 49.13 seconds, p95 57.87 seconds, and 2 repaired passes. The privacy-safe failure matrix was eight `json_invalid`, five `contract_invalid`, and one timeout. The ignored private JSONL is `acceptance/results.2026-08-27.local.jsonl`. This is a measured stochastic regression from the earlier 16/20 default run, so readiness remains blocked and no parser relaxation, immutable version, review submission, or release is justified. +- 27 August Gemma rerun: the complete immutable corpus with advisory hint `gemma` finished at 15/20, median 21.91 seconds, p95 41.92 seconds, and 6 repaired passes. Failures were A07/A10/A12/A19 `contract_invalid` and A20 `required_field_invalid`, with no timeout or JSON truncation. The ignored private JSONL is `acceptance/results.2026-08-27.gemma.local.jsonl`. This improves the measured Gemma profile from 14/20 to 15/20 but remains below the 18/20 gate; keep the PR draft and do not cut, submit, or release a version. From 718eaa57ec3bd9ae2007df4e0654596d1ed8e88c Mon Sep 17 00:00:00 2001 From: RapideCastor Date: Fri, 28 Aug 2026 09:50:07 +0200 Subject: [PATCH 014/113] test(harbour): refine privacy-safe acceptance failures --- apps/storycore-harbour/STATUS.md | 1 + .../bundle/acceptance-failure.js | 17 ++++++++++++++++- .../tests/acceptance-failure-name.test.mjs | 11 +++++++++++ 3 files changed, 28 insertions(+), 1 deletion(-) diff --git a/apps/storycore-harbour/STATUS.md b/apps/storycore-harbour/STATUS.md index 6f87db17..59a73953 100644 --- a/apps/storycore-harbour/STATUS.md +++ b/apps/storycore-harbour/STATUS.md @@ -294,3 +294,4 @@ Codex must inspect the latest CI and Sonar results first. The next legitimate im - Reliability evidence is now a two-profile matrix: Anna default 16/20; Gemma preference 14/20. Both fail the 18/20 gate. No readiness, version cut, review, or release claim is permitted. - An owner-authorized complete corpus rerun on 2026-08-27 used Anna default `minimax/minimax-m3` through OpenRouter and regressed to 6/20. Median successful duration was 49.13 seconds, p95 was 57.87 seconds, and 2 successful projects used repair. Privacy-safe failures were eight `json_invalid`, five `contract_invalid`, and one timeout. The complete private result remains ignored at `acceptance/results.2026-08-27.local.jsonl`. This stochastic regression does not justify weakening the contract or cutting a version; it reinforces the unresolved default-model reliability blocker. - A second owner-authorized complete corpus rerun on 2026-08-27 used the advisory `gemma` hint and finished at 15/20. Median successful duration was 21.91 seconds, p95 was 41.92 seconds, and 6 successful projects used repair. Privacy-safe failures were A07/A10/A12/A19 `contract_invalid` and A20 `required_field_invalid`; there were no timeouts or JSON truncations. The private result remains ignored at `acceptance/results.2026-08-27.gemma.local.jsonl`. This improves the measured Gemma profile from 14/20 to 15/20 but remains below the 18/20 gate, so no readiness, version cut, review submission, or release claim is permitted. +- Future acceptance runs now classify schema, timestamp, duplicate, ordering, continuity-score, structure, and parent-scene reference failures with stable privacy-safe names. Unknown validation details still collapse to `contract_invalid`; generated content and private identifiers are never persisted in the public result. diff --git a/apps/storycore-harbour/bundle/acceptance-failure.js b/apps/storycore-harbour/bundle/acceptance-failure.js index 65cedd80..366e2547 100644 --- a/apps/storycore-harbour/bundle/acceptance-failure.js +++ b/apps/storycore-harbour/bundle/acceptance-failure.js @@ -20,12 +20,27 @@ export function publicFailureName(message, category) { return "json_invalid"; } if (value.includes("severity must be")) return "warning_severity_invalid"; - if (value.includes("references unknown") || value.includes("unknown scene") || value.includes("unknown character")) { + if ( + value.includes("references unknown") || + value.includes("unknown scene") || + value.includes("unknown character") || + value.includes("not listed in the parent scene") + ) { return "reference_invalid"; } if (value.includes("duration")) return "duration_invalid"; + if (value.includes("schemaversion must be") || value.includes("project.format is unsupported")) { + return "schema_invalid"; + } + if (value.includes("iso-compatible date-time")) return "timestamp_invalid"; + if (value.includes("duplicate")) return "duplicate_invalid"; + if (value.includes("must be a positive integer")) return "ordering_invalid"; + if (value.includes("continuityreport.score")) return "continuity_score_invalid"; if (value.includes("required") || value.includes("must contain") || value.includes("must be a string")) { return "required_field_invalid"; } + if (value.includes("must be an array") || value.includes("must be an object")) { + return "structure_invalid"; + } return "contract_invalid"; } diff --git a/apps/storycore-harbour/tests/acceptance-failure-name.test.mjs b/apps/storycore-harbour/tests/acceptance-failure-name.test.mjs index 92c40de3..a00be603 100644 --- a/apps/storycore-harbour/tests/acceptance-failure-name.test.mjs +++ b/apps/storycore-harbour/tests/acceptance-failure-name.test.mjs @@ -7,7 +7,18 @@ test("contract failure details map to stable privacy-safe names", () => { assert.equal(publicFailureName("JSON parse failed: Unterminated string", "contract"), "json_invalid"); assert.equal(publicFailureName("warnings[0].severity must be info", "contract"), "warning_severity_invalid"); assert.equal(publicFailureName("sceneId references unknown scene secret-scene", "contract"), "reference_invalid"); + assert.equal( + publicFailureName("shot references secret-character, but that character is not listed in the parent scene", "contract"), + "reference_invalid", + ); assert.equal(publicFailureName("Total scene duration is implausibly short", "contract"), "duration_invalid"); + assert.equal(publicFailureName("schemaVersion must be storycore.project.v1", "contract"), "schema_invalid"); + assert.equal(publicFailureName("project.format is unsupported: private-format", "contract"), "schema_invalid"); + assert.equal(publicFailureName("project.createdAt must be an ISO-compatible date-time", "contract"), "timestamp_invalid"); + assert.equal(publicFailureName("Duplicate id at characters[1]: private-id", "contract"), "duplicate_invalid"); + assert.equal(publicFailureName("scenes[0].order must be a positive integer", "contract"), "ordering_invalid"); + assert.equal(publicFailureName("continuityReport.score must be between 0 and 100", "contract"), "continuity_score_invalid"); + assert.equal(publicFailureName("scenes must be an array", "contract"), "structure_invalid"); }); test("unknown contract details never enter the public failure name", () => { From 03c2c54b9b91471dac3e89be8cda37a7406f670c Mon Sep 17 00:00:00 2001 From: RapideCastor Date: Fri, 28 Aug 2026 09:53:40 +0200 Subject: [PATCH 015/113] docs(harbour): record Anna draft revision 10 --- apps/storycore-harbour/STATUS.md | 1 + 1 file changed, 1 insertion(+) diff --git a/apps/storycore-harbour/STATUS.md b/apps/storycore-harbour/STATUS.md index 59a73953..49c50cd8 100644 --- a/apps/storycore-harbour/STATUS.md +++ b/apps/storycore-harbour/STATUS.md @@ -295,3 +295,4 @@ Codex must inspect the latest CI and Sonar results first. The next legitimate im - An owner-authorized complete corpus rerun on 2026-08-27 used Anna default `minimax/minimax-m3` through OpenRouter and regressed to 6/20. Median successful duration was 49.13 seconds, p95 was 57.87 seconds, and 2 successful projects used repair. Privacy-safe failures were eight `json_invalid`, five `contract_invalid`, and one timeout. The complete private result remains ignored at `acceptance/results.2026-08-27.local.jsonl`. This stochastic regression does not justify weakening the contract or cutting a version; it reinforces the unresolved default-model reliability blocker. - A second owner-authorized complete corpus rerun on 2026-08-27 used the advisory `gemma` hint and finished at 15/20. Median successful duration was 21.91 seconds, p95 was 41.92 seconds, and 6 successful projects used repair. Privacy-safe failures were A07/A10/A12/A19 `contract_invalid` and A20 `required_field_invalid`; there were no timeouts or JSON truncations. The private result remains ignored at `acceptance/results.2026-08-27.gemma.local.jsonl`. This improves the measured Gemma profile from 14/20 to 15/20 but remains below the 18/20 gate, so no readiness, version cut, review submission, or release claim is permitted. - Future acceptance runs now classify schema, timestamp, duplicate, ordering, continuity-score, structure, and parent-scene reference failures with stable privacy-safe names. Unknown validation details still collapse to `contract_invalid`; generated content and private identifiers are never persisted in the public result. +- The privacy-safe diagnostic update is synchronized to Anna working draft revision 10. The 15-file, 104,031-byte bundle is ready with content hash `7d3edf2a89b942055c4af9d53b7dcfb6de1777e0437b27711b05bef09e2049b9`. The App remains a mutable draft with zero immutable versions; this synchronization is not an installation, review, or release claim. From 76965d0c271276f28b90c56511d9acdb29824d40 Mon Sep 17 00:00:00 2001 From: RapideCastor Date: Fri, 28 Aug 2026 10:09:04 +0200 Subject: [PATCH 016/113] docs(harbour): refresh owner handoff and grants evidence --- apps/storycore-harbour/STATUS.md | 1 + .../review/FINAL_HANDOFF_2026-08-24.md | 16 +++++++++------- .../review/OWNER_ACTIVATION_AND_FIRST_REVIEW.md | 8 ++++++++ 3 files changed, 18 insertions(+), 7 deletions(-) diff --git a/apps/storycore-harbour/STATUS.md b/apps/storycore-harbour/STATUS.md index 49c50cd8..4c17d7d6 100644 --- a/apps/storycore-harbour/STATUS.md +++ b/apps/storycore-harbour/STATUS.md @@ -296,3 +296,4 @@ Codex must inspect the latest CI and Sonar results first. The next legitimate im - A second owner-authorized complete corpus rerun on 2026-08-27 used the advisory `gemma` hint and finished at 15/20. Median successful duration was 21.91 seconds, p95 was 41.92 seconds, and 6 successful projects used repair. Privacy-safe failures were A07/A10/A12/A19 `contract_invalid` and A20 `required_field_invalid`; there were no timeouts or JSON truncations. The private result remains ignored at `acceptance/results.2026-08-27.gemma.local.jsonl`. This improves the measured Gemma profile from 14/20 to 15/20 but remains below the 18/20 gate, so no readiness, version cut, review submission, or release claim is permitted. - Future acceptance runs now classify schema, timestamp, duplicate, ordering, continuity-score, structure, and parent-scene reference failures with stable privacy-safe names. Unknown validation details still collapse to `contract_invalid`; generated content and private identifiers are never persisted in the public result. - The privacy-safe diagnostic update is synchronized to Anna working draft revision 10. The 15-file, 104,031-byte bundle is ready with content hash `7d3edf2a89b942055c4af9d53b7dcfb6de1777e0437b27711b05bef09e2049b9`. The App remains a mutable draft with zero immutable versions; this synchronization is not an installation, review, or release claim. +- Read-only `apps grants storycore-harbour --json` currently returns `grants: null`. In CLI 0.1.30 this specifically represents a 404 from the informational grants endpoint (`no grants endpoint data available`), not evidence that the declared Host APIs were denied. Prior authenticated Host calls remain the capability evidence; do not use this null result to claim either a grant or a denial. diff --git a/apps/storycore-harbour/review/FINAL_HANDOFF_2026-08-24.md b/apps/storycore-harbour/review/FINAL_HANDOFF_2026-08-24.md index fec6a12a..ed197550 100644 --- a/apps/storycore-harbour/review/FINAL_HANDOFF_2026-08-24.md +++ b/apps/storycore-harbour/review/FINAL_HANDOFF_2026-08-24.md @@ -1,5 +1,7 @@ # Anna opportunity final handoff — 24 August 2026 +Updated with verified remote state on 28 August 2026. + This document records the verified draft state. It is not authorization to cut a version, submit a review, publish, accept new terms, or spend additional model quota. ## StoryCore Harbour @@ -7,19 +9,19 @@ This document records the verified draft state. It is not authorization to cut a ### Local candidate - branch: `codex/anna-mvp-20260824`; -- latest integration commit before this handoff refresh: `ec2d9da7`; -- GitHub branch `agent/storycore-harbour-bootstrap` was updated through PR #30 without force-push; the PR remains draft and unmerged; +- latest integration commit at this handoff refresh: `03c2c54b`; +- GitHub branch `agent/storycore-harbour-bootstrap` is synchronized through draft PR #37 without force-push; PR #30 and PR #36 are merged, while PR #37 remains open, green, and unmerged; - core StoryCore Engine checkout was not merged, reset, cleaned, or overwritten; - Anna adapter remains isolated under `apps/storycore-harbour/`; -- automated gate: 60/60 Node tests, sample contract, mock fixture, fixed corpus, bundle synchronization, and strict Anna validation pass. +- automated gate: 66/66 Node tests, sample contract, mock fixture, fixed corpus, bundle synchronization, strict Anna validation, GitHub Anna CI, and SonarQube pass. ### Anna draft - public identity: `@storycore-labs/storycore-harbour`; - server App id: `214`; -- working revision: `9`; -- content hash: `762e175e7f150d47845b3fa4ace51d2f058d1758cc4c19f06d621b9756dada74`; -- bundle: 15 files, 103,405 bytes, `ready`; +- working revision: `10`; +- content hash: `7d3edf2a89b942055c4af9d53b7dcfb6de1777e0437b27711b05bef09e2049b9`; +- bundle: 15 files, 104,031 bytes, `ready`; - status: `draft`, not published; - immutable versions: 0; - Executas/local shims: 0. @@ -99,7 +101,7 @@ without addressing the failure. ### Submission decision -Do not cut `0.1.0`, submit for review, mark PR #30 ready, merge, or release while the official gate remains below 18/20. The App is demonstrable and its working draft is reserved, but it is not submission-ready under the repository's own rules. +Do not cut `0.1.0`, submit for review, mark PR #37 ready, merge, or release while the official gate remains below 18/20. The latest measured Gemma run is 15/20 and the latest Anna-default run is 6/20. The App is demonstrable and its working draft is reserved, but it is not submission-ready under the repository's own rules. ### Anna installation diagnosis diff --git a/apps/storycore-harbour/review/OWNER_ACTIVATION_AND_FIRST_REVIEW.md b/apps/storycore-harbour/review/OWNER_ACTIVATION_AND_FIRST_REVIEW.md index de345d56..80d1de1c 100644 --- a/apps/storycore-harbour/review/OWNER_ACTIVATION_AND_FIRST_REVIEW.md +++ b/apps/storycore-harbour/review/OWNER_ACTIVATION_AND_FIRST_REVIEW.md @@ -173,10 +173,18 @@ Only after checking the dry-run output: npx --no-install anna-app apps push --json npx --no-install anna-app apps status storycore-harbour --json npx --no-install anna-app apps list --json +npx --no-install anna-app apps grants storycore-harbour --json ``` `apps push` creates or updates a mutable **working draft**. It is not a public release and should not make the App visible in the public Store. +The grants endpoint is informational only. With the currently pinned CLI, a +JSON result containing `"grants": null` means the server returned 404 for the +public grants endpoint and the CLI has no endpoint data available. It does not +prove that the App was denied the Host APIs declared in the manifest. Confirm +actual LLM, storage, and window capabilities through the authenticated harness +and recorded Host calls; do not infer permission state from `null`. + After the first successful push: - verify that `.anna/app.json` identifies the expected remote App; From cdaf4c03acf79dd000946b9f2411b710e865ef5b Mon Sep 17 00:00:00 2001 From: RapideCastor Date: Fri, 28 Aug 2026 11:25:00 +0200 Subject: [PATCH 017/113] test(harbour): run browser smokes sequentially --- apps/storycore-harbour/README.md | 6 ++++++ apps/storycore-harbour/STATUS.md | 1 + apps/storycore-harbour/package.json | 1 + 3 files changed, 8 insertions(+) diff --git a/apps/storycore-harbour/README.md b/apps/storycore-harbour/README.md index 87b67679..dfbc0ce4 100644 --- a/apps/storycore-harbour/README.md +++ b/apps/storycore-harbour/README.md @@ -67,6 +67,12 @@ npm run dev:mock `npm install` also generates the bundle copy of the canonical acceptance corpus. `npm run check` runs the tests, contract, mock response, acceptance corpus/synchronization, and strict Anna validator. +With the mock Anna harness already running, set `BROWSER_EXECUTABLE` to Edge or +another compatible Chromium binary and run `npm run browser:check`. This runs +the complete generation/export smoke and the storage-deletion smoke +sequentially. Do not run them in parallel against one mock harness because the +fixture stream is shared. + For the real-model protocol, follow `acceptance/README.md`. Do not run the collector without an authenticated Anna test account, an enabled model, sufficient quota, and explicit confirmation in its UI. ## Release identity diff --git a/apps/storycore-harbour/STATUS.md b/apps/storycore-harbour/STATUS.md index 4c17d7d6..4db805a5 100644 --- a/apps/storycore-harbour/STATUS.md +++ b/apps/storycore-harbour/STATUS.md @@ -297,3 +297,4 @@ Codex must inspect the latest CI and Sonar results first. The next legitimate im - Future acceptance runs now classify schema, timestamp, duplicate, ordering, continuity-score, structure, and parent-scene reference failures with stable privacy-safe names. Unknown validation details still collapse to `contract_invalid`; generated content and private identifiers are never persisted in the public result. - The privacy-safe diagnostic update is synchronized to Anna working draft revision 10. The 15-file, 104,031-byte bundle is ready with content hash `7d3edf2a89b942055c4af9d53b7dcfb6de1777e0437b27711b05bef09e2049b9`. The App remains a mutable draft with zero immutable versions; this synchronization is not an installation, review, or release claim. - Read-only `apps grants storycore-harbour --json` currently returns `grants: null`. In CLI 0.1.30 this specifically represents a 404 from the informational grants endpoint (`no grants endpoint data available`), not evidence that the declared Host APIs were denied. Prior authenticated Host calls remain the capability evidence; do not use this null result to claim either a grant or a denial. +- Fresh Edge verification on 2026-08-28 passed the 520 × 680 generation/export flow, keyboard navigation, focus management, reduced-motion context, 400% text reflow, project reset/restore, and contract-valid export. The deletion smoke also passed sequentially with two ETag deletions, paginated listing, unrelated-key preservation, and a not-found reload. A new `browser:check` script fixes the intended sequential order because both smokes share one mock fixture stream. diff --git a/apps/storycore-harbour/package.json b/apps/storycore-harbour/package.json index 1a8b13dd..b2f83d72 100644 --- a/apps/storycore-harbour/package.json +++ b/apps/storycore-harbour/package.json @@ -18,6 +18,7 @@ "test": "node --test tests/*.test.mjs", "browser:smoke": "node scripts/browser-smoke.mjs", "browser:deletion-smoke": "node scripts/browser-deletion-smoke.mjs", + "browser:check": "npm run browser:smoke && npm run browser:deletion-smoke", "contract:check": "node scripts/validate-project.mjs examples/sample-project.json", "acceptance:sync": "node scripts/sync-acceptance-corpus.mjs", "acceptance:sync:check": "node scripts/check-bundled-corpus.mjs", From 13adb0273dd5efe0c18936b73b201155272bcd4b Mon Sep 17 00:00:00 2001 From: RapideCastor Date: Fri, 28 Aug 2026 11:54:31 +0200 Subject: [PATCH 018/113] fix(harbour): preserve states in forced colours --- apps/storycore-harbour/STATUS.md | 1 + apps/storycore-harbour/bundle/style.css | 29 ++++++++++++++ .../scripts/browser-smoke.mjs | 39 +++++++++++++++++++ 3 files changed, 69 insertions(+) diff --git a/apps/storycore-harbour/STATUS.md b/apps/storycore-harbour/STATUS.md index 4db805a5..1d1f3af9 100644 --- a/apps/storycore-harbour/STATUS.md +++ b/apps/storycore-harbour/STATUS.md @@ -298,3 +298,4 @@ Codex must inspect the latest CI and Sonar results first. The next legitimate im - The privacy-safe diagnostic update is synchronized to Anna working draft revision 10. The 15-file, 104,031-byte bundle is ready with content hash `7d3edf2a89b942055c4af9d53b7dcfb6de1777e0437b27711b05bef09e2049b9`. The App remains a mutable draft with zero immutable versions; this synchronization is not an installation, review, or release claim. - Read-only `apps grants storycore-harbour --json` currently returns `grants: null`. In CLI 0.1.30 this specifically represents a 404 from the informational grants endpoint (`no grants endpoint data available`), not evidence that the declared Host APIs were denied. Prior authenticated Host calls remain the capability evidence; do not use this null result to claim either a grant or a denial. - Fresh Edge verification on 2026-08-28 passed the 520 × 680 generation/export flow, keyboard navigation, focus management, reduced-motion context, 400% text reflow, project reset/restore, and contract-valid export. The deletion smoke also passed sequentially with two ETag deletions, paginated listing, unrelated-key preservation, and a not-found reload. A new `browser:check` script fixes the intended sequential order because both smokes share one mock fixture stream. +- Edge now emulates `forced-colors: active` during the browser smoke and verifies visible keyboard focus, a non-colour selected-step outline, fully opaque dashed disabled controls, and a solid error boundary. The App also supplies forced-colour treatments for warning/success/error deletion states, armed destructive actions, and the continuity score. This automated evidence reduces risk but does not replace the remaining human Windows High Contrast and screen-reader passes. diff --git a/apps/storycore-harbour/bundle/style.css b/apps/storycore-harbour/bundle/style.css index 4438a806..1d7cb26e 100644 --- a/apps/storycore-harbour/bundle/style.css +++ b/apps/storycore-harbour/bundle/style.css @@ -244,3 +244,32 @@ footer { @media (prefers-reduced-motion: reduce) { *, *::before, *::after { animation-duration: .001ms !important; animation-iteration-count: 1 !important; scroll-behavior: auto !important; } } +@media (forced-colors: active) { + button:focus, input:focus, select:focus, textarea:focus { + outline: 3px solid Highlight; + outline-offset: 2px; + } + button:focus-visible, input:focus-visible, select:focus-visible, textarea:focus-visible { + outline-color: Highlight; + } + .step.active { + color: HighlightText; + background: Highlight; + outline: 2px solid Highlight; + outline-offset: -3px; + } + button:disabled { + opacity: 1; + color: GrayText; + border-color: GrayText; + border-style: dashed; + } + .message.error, .error-panel, .deletion-status.error { + color: CanvasText; + border: 2px solid CanvasText; + } + .deletion-status.warning { border-style: dashed; } + .deletion-status.success { border-style: double; } + button.danger-outline.armed { outline: 3px double CanvasText; } + .score { border-color: CanvasText; } +} diff --git a/apps/storycore-harbour/scripts/browser-smoke.mjs b/apps/storycore-harbour/scripts/browser-smoke.mjs index 8f574340..71638a91 100644 --- a/apps/storycore-harbour/scripts/browser-smoke.mjs +++ b/apps/storycore-harbour/scripts/browser-smoke.mjs @@ -94,6 +94,7 @@ try { await waitForFocus(app.locator("#form-error")); assert.equal(await app.locator("#step-1").isVisible(), true); assert.equal(await app.locator("#step-2").isVisible(), false); + await assertForcedColorsAccessibility(page, app); await fillReferenceProject(app); assert.match((await app.locator("#idea-count").textContent()) || "", /\/ 12,000/); @@ -254,6 +255,7 @@ try { result: "pass", viewport: { width: dimensions.clientWidth, height: minimumFrameSize.height }, textReflow400Percent: "pass", + forcedColors: "pass", formValidationFocus: "pass", keyboardStepNavigation: "pass", panelFocusManagement: "pass", @@ -316,6 +318,43 @@ async function setFrameSize(frameElement, size) { }, size); } +async function assertForcedColorsAccessibility(page, app) { + await page.emulateMedia({ forcedColors: "active" }); + try { + await app.getByRole("button", { name: "Build my visual story" }).focus(); + const state = await app.locator("html").evaluate(() => { + const style = (selector) => getComputedStyle(document.querySelector(selector)); + const activeStep = style(".step.active"); + const disabledStep = style("#step-tab-2"); + const error = style("#form-error"); + const focusedButton = style("#generate-button"); + return { + mediaActive: matchMedia("(forced-colors: active)").matches, + activeOutlineStyle: activeStep.outlineStyle, + activeOutlineWidth: activeStep.outlineWidth, + disabledOpacity: disabledStep.opacity, + disabledBorderStyle: disabledStep.borderTopStyle, + errorBorderStyle: error.borderTopStyle, + errorBorderWidth: error.borderTopWidth, + focusOutlineStyle: focusedButton.outlineStyle, + focusOutlineWidth: focusedButton.outlineWidth, + }; + }); + + assert.equal(state.mediaActive, true, "Edge must activate the forced-colours media query."); + assert.equal(state.activeOutlineStyle, "solid", "The selected step needs a non-colour outline."); + assert.equal(state.activeOutlineWidth, "2px"); + assert.equal(state.disabledOpacity, "1", "Disabled controls must remain legible in forced colours."); + assert.equal(state.disabledBorderStyle, "dashed", "Disabled controls need a non-colour distinction."); + assert.equal(state.errorBorderStyle, "solid", "Errors need a visible forced-colour boundary."); + assert.equal(state.errorBorderWidth, "2px"); + assert.equal(state.focusOutlineStyle, "solid", "Keyboard focus must remain visible in forced colours."); + assert.equal(state.focusOutlineWidth, "3px"); + } finally { + await page.emulateMedia({ forcedColors: "none" }); + } +} + async function installDeterministicTestStorage(app) { return app.locator("html").evaluate(() => { const runtime = window.anna; From 96df0e1dbb70b967b7b10464f97738595fc34780 Mon Sep 17 00:00:00 2001 From: RapideCastor Date: Fri, 28 Aug 2026 11:57:47 +0200 Subject: [PATCH 019/113] docs(harbour): record Anna draft revision 11 --- apps/storycore-harbour/STATUS.md | 1 + 1 file changed, 1 insertion(+) diff --git a/apps/storycore-harbour/STATUS.md b/apps/storycore-harbour/STATUS.md index 1d1f3af9..7303578a 100644 --- a/apps/storycore-harbour/STATUS.md +++ b/apps/storycore-harbour/STATUS.md @@ -299,3 +299,4 @@ Codex must inspect the latest CI and Sonar results first. The next legitimate im - Read-only `apps grants storycore-harbour --json` currently returns `grants: null`. In CLI 0.1.30 this specifically represents a 404 from the informational grants endpoint (`no grants endpoint data available`), not evidence that the declared Host APIs were denied. Prior authenticated Host calls remain the capability evidence; do not use this null result to claim either a grant or a denial. - Fresh Edge verification on 2026-08-28 passed the 520 × 680 generation/export flow, keyboard navigation, focus management, reduced-motion context, 400% text reflow, project reset/restore, and contract-valid export. The deletion smoke also passed sequentially with two ETag deletions, paginated listing, unrelated-key preservation, and a not-found reload. A new `browser:check` script fixes the intended sequential order because both smokes share one mock fixture stream. - Edge now emulates `forced-colors: active` during the browser smoke and verifies visible keyboard focus, a non-colour selected-step outline, fully opaque dashed disabled controls, and a solid error boundary. The App also supplies forced-colour treatments for warning/success/error deletion states, armed destructive actions, and the continuity score. This automated evidence reduces risk but does not replace the remaining human Windows High Contrast and screen-reader passes. +- The forced-colour fix is synchronized to Anna working draft revision 11. Its 15-file, 104,890-byte bundle is ready with content hash `c5ddb6b3a45b42cebb3ceed713f65121ba3efac9808fece05aa05ad6ce863a17`; the App still has zero immutable versions and remains unpublished. From 17b7095795e832f564c310e7b6a742b4e9d1c4a6 Mon Sep 17 00:00:00 2001 From: RapideCastor Date: Fri, 28 Aug 2026 20:54:37 +0200 Subject: [PATCH 020/113] docs(harbour): refresh revision 11 demo evidence --- apps/storycore-harbour/STATUS.md | 1 + .../demo/EVIDENCE_2026-08-28_REVISION_11.md | 24 +++++++++++++++++++ apps/storycore-harbour/demo/README.md | 18 ++++++++++---- .../scripts/browser-smoke.mjs | 5 +++- 4 files changed, 42 insertions(+), 6 deletions(-) create mode 100644 apps/storycore-harbour/demo/EVIDENCE_2026-08-28_REVISION_11.md diff --git a/apps/storycore-harbour/STATUS.md b/apps/storycore-harbour/STATUS.md index 7303578a..65503fda 100644 --- a/apps/storycore-harbour/STATUS.md +++ b/apps/storycore-harbour/STATUS.md @@ -300,3 +300,4 @@ Codex must inspect the latest CI and Sonar results first. The next legitimate im - Fresh Edge verification on 2026-08-28 passed the 520 × 680 generation/export flow, keyboard navigation, focus management, reduced-motion context, 400% text reflow, project reset/restore, and contract-valid export. The deletion smoke also passed sequentially with two ETag deletions, paginated listing, unrelated-key preservation, and a not-found reload. A new `browser:check` script fixes the intended sequential order because both smokes share one mock fixture stream. - Edge now emulates `forced-colors: active` during the browser smoke and verifies visible keyboard focus, a non-colour selected-step outline, fully opaque dashed disabled controls, and a solid error boundary. The App also supplies forced-colour treatments for warning/success/error deletion states, armed destructive actions, and the continuity score. This automated evidence reduces risk but does not replace the remaining human Windows High Contrast and screen-reader passes. - The forced-colour fix is synchronized to Anna working draft revision 11. Its 15-file, 104,890-byte bundle is ready with content hash `c5ddb6b3a45b42cebb3ceed713f65121ba3efac9808fece05aa05ad6ce863a17`; the App still has zero immutable versions and remains unpublished. +- Revision 11 demo evidence was regenerated locally in under twenty seconds: four visually inspected 900 × 820 PNGs plus a 3,288-byte contract-valid JSON export. The generated files remain ignored under `demo/output.local/revision-11/`; their sizes and one-run SHA-256 values are recorded in `demo/EVIDENCE_2026-08-28_REVISION_11.md`. The screenshot helper now resets every App scroll container before capture, fixing the measured missing-header defect on the World draft. diff --git a/apps/storycore-harbour/demo/EVIDENCE_2026-08-28_REVISION_11.md b/apps/storycore-harbour/demo/EVIDENCE_2026-08-28_REVISION_11.md new file mode 100644 index 00000000..55fd5b7f --- /dev/null +++ b/apps/storycore-harbour/demo/EVIDENCE_2026-08-28_REVISION_11.md @@ -0,0 +1,24 @@ +# StoryCore Harbour demo evidence — Anna draft revision 11 + +Generated locally on 2026-08-28 with the deterministic Anna mock harness and +Microsoft Edge. No Anna model quota, production storage, provider key, or +private prompt was used. The local run completed in under twenty seconds. + +| Artifact | Dimensions | Bytes | SHA-256 | +| --- | ---: | ---: | --- | +| `01-concept.png` | 900 x 820 | 100,272 | `4e5f6ee1f694d30ba8639db511e7b843b92a57228c06b17a44a56be6a60b1e9a` | +| `02-world.png` | 900 x 820 | 105,664 | `60da7be8ca9c56aa6e94f65a36e6b9f23ce0271ee8cc9dc272f571a6b489a4f0` | +| `03-scenes.png` | 900 x 820 | 93,409 | `73e3745867bc21c99701a6b334a7311a86ef7b7a3c10665434cff3c1271c6dc5` | +| `04-continuity.png` | 900 x 820 | 94,405 | `1add93ddd51cd37086b88f73592f1e7864006384f375a2f50f4ad82af823df7d` | +| `browser-smoke-story.storycore-harbour.json` | n/a | 3,288 | `fe10fa513a326fc26d71aea7ddd6a508e9d5f69d57fbf7696c3c4aa95f2a72e2` | + +The JSON export passed the canonical `storycore-harbour.project.v1` validator. +The four screenshots were visually inspected for clipping, inconsistent scroll +position, missing headers, misleading media claims, and obvious unreadable +content. They remain draft Marketplace evidence until Anna confirms its exact +image dimensions and file-size rules. + +The generated artifacts remain intentionally ignored under +`demo/output.local/revision-11/`. Reproduce them with the exact procedure in +`demo/README.md`; do not commit generated project text or screenshots merely to +replace the authenticated real-model, accessibility, or beta gates. diff --git a/apps/storycore-harbour/demo/README.md b/apps/storycore-harbour/demo/README.md index 5bfd10e2..8fda9c0c 100644 --- a/apps/storycore-harbour/demo/README.md +++ b/apps/storycore-harbour/demo/README.md @@ -28,21 +28,23 @@ Keep that terminal open. In a second PowerShell terminal, from the same director ```powershell $env:BROWSER_EXECUTABLE = 'C:\Program Files (x86)\Microsoft\Edge\Application\msedge.exe' $env:HARBOUR_URL = 'http://127.0.0.1:5180/' -$env:HARBOUR_SCREENSHOT_DIR = (Resolve-Path '.').Path + '\demo\output.local' -npm run browser:smoke +$env:HARBOUR_SCREENSHOT_DIR = (Resolve-Path '.').Path + '\demo\output.local\current' +npm run browser:check ``` Expected final line: ```text -{"result":"pass",...,"exportContract":"valid",...,"screenshotsCaptured":4,...} +{"result":"pass",...,"forcedColors":"pass",...,"exportContract":"valid",...,"screenshotsCaptured":4,...} +{"result":"pass","deletedProjectRecords":2,...,"unrelatedKeyPreserved":true,...} ``` Stop the mock harness with `Ctrl+C` after the browser command finishes. ## Produced evidence -The browser run writes these local, ignored artifacts to `demo/output.local/`: +The browser run writes these local, ignored artifacts to the selected +`HARBOUR_SCREENSHOT_DIR`: - `01-concept.png`; - `02-world.png`; @@ -50,7 +52,13 @@ The browser run writes these local, ignored artifacts to `demo/output.local/`: - `04-continuity.png`; - `browser-smoke-story.storycore-harbour.json`. -The browser test validates the actual export blob against `storycore-harbour.project.v1` before writing it. It also proves form validation, save/read-back, keyboard step navigation, focus management, the declared 520 x 680 minimum viewport, and 400% text reflow. +Each PNG is captured at 900 x 820. The browser test resets the App scroll before +every capture and validates the actual export blob against +`storycore-harbour.project.v1` before writing it. It also proves form +validation, save/read-back, keyboard step navigation, focus management, +forced-colour states, the declared 520 x 680 minimum viewport, 400% text +reflow, and safe project deletion. Hashes may vary with the Edge build and font +renderer; use them as one-run evidence, not portable golden-image assertions. ## Presenter script diff --git a/apps/storycore-harbour/scripts/browser-smoke.mjs b/apps/storycore-harbour/scripts/browser-smoke.mjs index 71638a91..5e743258 100644 --- a/apps/storycore-harbour/scripts/browser-smoke.mjs +++ b/apps/storycore-harbour/scripts/browser-smoke.mjs @@ -291,7 +291,10 @@ async function captureMarketplaceScreenshot({ app, frameElement, filename }) { await setFrameSize(frameElement, marketplaceFrameSize); await app.locator("html").evaluate(() => { if (document.activeElement instanceof HTMLElement) document.activeElement.blur(); - window.scrollTo({ top: 0, behavior: "instant" }); + window.scrollTo(0, 0); + if (document.scrollingElement) document.scrollingElement.scrollTop = 0; + document.documentElement.scrollTop = 0; + document.body.scrollTop = 0; }); const path = join(screenshotDirectory, filename); From bef958fa4334a003c0ee8d4836315369316d93d5 Mon Sep 17 00:00:00 2001 From: Sylvain Galliez <128614184+zedarvates@users.noreply.github.com> Date: Sat, 29 Aug 2026 07:43:39 +0200 Subject: [PATCH 021/113] fix(harbour): make repair prompt schema-complete --- .../storycore-harbour/bundle/repair-prompt.js | 92 ++++++++++++++++++- 1 file changed, 88 insertions(+), 4 deletions(-) diff --git a/apps/storycore-harbour/bundle/repair-prompt.js b/apps/storycore-harbour/bundle/repair-prompt.js index 3f55ed8b..24664dec 100644 --- a/apps/storycore-harbour/bundle/repair-prompt.js +++ b/apps/storycore-harbour/bundle/repair-prompt.js @@ -1,10 +1,91 @@ +const REQUIRED_SHAPE = Object.freeze({ + schemaVersion: "storycore-harbour.project.v1", + project: [ + "id", + "title", + "language", + "format", + "durationMinutes", + "audience", + "tone", + "sourceIdea", + "createdAt", + "updatedAt", + ], + productionBible: [ + "logline", + "synopsis", + "themes[]", + "visualDirection.style", + "visualDirection.palette[]", + "visualDirection.lighting", + "visualDirection.cameraLanguage", + "continuityRules[]", + ], + character: [ + "id", + "name", + "role", + "goal", + "conflict", + "visualIdentity", + "continuityRules[]", + ], + location: [ + "id", + "name", + "purpose", + "visualIdentity", + "continuityRules[]", + ], + scene: [ + "id", + "order", + "title", + "purpose", + "locationId", + "characterIds[]", + "durationSeconds", + "shots[]", + ], + shot: [ + "id", + "order", + "framing", + "camera", + "action", + "dialogue", + "sound", + "characterIds[]", + "generationPrompt", + ], + continuityReport: ["score", "warnings[]", "checkedAt"], + warning: ["severity", "message", "sceneId"], +}); + export function createRepairPrompt(input, errors) { return JSON.stringify({ - task: "Rebuild a complete StoryCore Harbour production package from the source input.", + task: "Rebuild the complete StoryCore Harbour project from the source input. The previous answer failed validation, so return a fresh self-contained project rather than a patch.", input, validationErrors: errors, - constraints: { - jsonOnly: true, + requiredShape: REQUIRED_SHAPE, + hardRules: [ + "Return exactly one JSON object and nothing else.", + "Include every required field listed in requiredShape, even when a string is intentionally empty.", + "Create exactly 3 scenes and exactly 1 shot inside each scene.", + "Create 1-3 characters and 1-3 locations; every scene locationId must reference a declared location.", + "Every scene characterIds entry must reference a declared character.", + "Every shot characterIds entry must reference a declared character also listed in its parent scene.", + "Use unique ids, scene orders 1,2,3, and shot order 1 in every scene.", + "dialogue and sound are always strings; use an empty string when there is intentionally none.", + "themes, palette, continuityRules, characterIds, shots, and warnings are always arrays.", + "warning severity is only info, warning, or error; sceneId is a declared scene id or null.", + "continuityReport.score is a number from 0 through 100.", + "Preserve input.title exactly when it is non-empty, and preserve input language, format, duration, audience, tone, and source idea.", + "Keep the entire JSON below 12000 characters; do not omit required structure to save space.", + "Before returning, silently verify the project contains project, productionBible, characters, locations, scenes, and continuityReport.", + ], + sizeBudget: { maxCharacters: 12_000, scenes: 3, shotsPerScene: 1, @@ -13,7 +94,10 @@ export function createRepairPrompt(input, errors) { synopsisMaxWords: 80, descriptionMaxWords: 40, generationPromptMaxWords: 60, - discardPreviousResponse: true, + maxThemes: 3, + maxProductionContinuityRules: 3, + maxEntityContinuityRules: 2, }, + discardPreviousResponse: true, }); } From 234987a796485083bfd9dd14d881656a9b7a817b Mon Sep 17 00:00:00 2001 From: Sylvain Galliez <128614184+zedarvates@users.noreply.github.com> Date: Sat, 29 Aug 2026 07:43:57 +0200 Subject: [PATCH 022/113] test(harbour): lock schema-complete repair contract --- .../tests/repair-prompt.test.mjs | 27 ++++++++++++++++++- 1 file changed, 26 insertions(+), 1 deletion(-) diff --git a/apps/storycore-harbour/tests/repair-prompt.test.mjs b/apps/storycore-harbour/tests/repair-prompt.test.mjs index 30413251..cafe33ba 100644 --- a/apps/storycore-harbour/tests/repair-prompt.test.mjs +++ b/apps/storycore-harbour/tests/repair-prompt.test.mjs @@ -21,7 +21,32 @@ test("repair rebuilds from the exact user input without carrying truncated model assert.deepEqual(request.input, input); assert.deepEqual(request.validationErrors, ["JSON parse failed: truncated object"]); - assert.equal(request.task, "Rebuild a complete StoryCore Harbour production package from the source input."); + assert.match(request.task, /Rebuild the complete StoryCore Harbour project/); assert.equal(Object.hasOwn(request, "previousResponse"), false); assert.equal(prompt.includes("PREVIOUS RESPONSE"), false); }); + +test("repair prompt carries a complete structural checklist without weakening the contract", async () => { + const { createRepairPrompt } = await import("../bundle/repair-prompt.js"); + const request = JSON.parse(createRepairPrompt({ + idea: "A sufficiently long fictional source idea for a repair test.", + title: "Repair shape", + format: "short-film", + durationMinutes: 3, + language: "en", + tone: "Clear", + audience: "General audience", + }, ["continuityReport.score must be between 0 and 100."])); + + assert.equal(request.requiredShape.schemaVersion, "storycore-harbour.project.v1"); + assert.ok(request.requiredShape.project.includes("sourceIdea")); + assert.ok(request.requiredShape.productionBible.includes("visualDirection.cameraLanguage")); + assert.ok(request.requiredShape.shot.includes("dialogue")); + assert.ok(request.requiredShape.shot.includes("sound")); + assert.deepEqual(request.requiredShape.continuityReport, ["score", "warnings[]", "checkedAt"]); + assert.ok(request.hardRules.some((rule) => /exactly 3 scenes/i.test(rule))); + assert.ok(request.hardRules.some((rule) => /dialogue and sound are always strings/i.test(rule))); + assert.ok(request.hardRules.some((rule) => /warning severity is only info, warning, or error/i.test(rule))); + assert.equal(request.discardPreviousResponse, true); + assert.equal(request.sizeBudget.maxCharacters, 12_000); +}); From b4a41b79d2a3f856ef35b843f46d34670f92709b Mon Sep 17 00:00:00 2001 From: RapideCastor Date: Sat, 29 Aug 2026 08:33:00 +0200 Subject: [PATCH 023/113] docs(harbour): prepare Anna media requirements request --- apps/storycore-harbour/STATUS.md | 1 + .../review/ANNA_MEDIA_REQUIREMENTS_REQUEST.md | 55 +++++++++++++++++++ .../review/SUBMISSION_BRIEF.md | 6 ++ 3 files changed, 62 insertions(+) create mode 100644 apps/storycore-harbour/review/ANNA_MEDIA_REQUIREMENTS_REQUEST.md diff --git a/apps/storycore-harbour/STATUS.md b/apps/storycore-harbour/STATUS.md index 65503fda..4536af82 100644 --- a/apps/storycore-harbour/STATUS.md +++ b/apps/storycore-harbour/STATUS.md @@ -301,3 +301,4 @@ Codex must inspect the latest CI and Sonar results first. The next legitimate im - Edge now emulates `forced-colors: active` during the browser smoke and verifies visible keyboard focus, a non-colour selected-step outline, fully opaque dashed disabled controls, and a solid error boundary. The App also supplies forced-colour treatments for warning/success/error deletion states, armed destructive actions, and the continuity score. This automated evidence reduces risk but does not replace the remaining human Windows High Contrast and screen-reader passes. - The forced-colour fix is synchronized to Anna working draft revision 11. Its 15-file, 104,890-byte bundle is ready with content hash `c5ddb6b3a45b42cebb3ceed713f65121ba3efac9808fece05aa05ad6ce863a17`; the App still has zero immutable versions and remains unpublished. - Revision 11 demo evidence was regenerated locally in under twenty seconds: four visually inspected 900 × 820 PNGs plus a 3,288-byte contract-valid JSON export. The generated files remain ignored under `demo/output.local/revision-11/`; their sizes and one-run SHA-256 values are recorded in `demo/EVIDENCE_2026-08-28_REVISION_11.md`. The screenshot helper now resets every App scroll container before capture, fixing the measured missing-header defect on the World draft. +- Anna's public Developer Hub and pinned CLI schema were rechecked on 2026-08-29. They identify the Developer Console Listing tab as the metadata/media surface but expose no numeric screenshot or Marketplace-logo limits. Both available browser sessions required a fresh owner sign-in, so no authenticated field hints were claimed. `review/ANNA_MEDIA_REQUIREMENTS_REQUEST.md` contains the exact ready-to-send question; the 900 × 820 PNGs remain drafts and nothing was uploaded or submitted. diff --git a/apps/storycore-harbour/review/ANNA_MEDIA_REQUIREMENTS_REQUEST.md b/apps/storycore-harbour/review/ANNA_MEDIA_REQUIREMENTS_REQUEST.md new file mode 100644 index 00000000..0acd6fb5 --- /dev/null +++ b/apps/storycore-harbour/review/ANNA_MEDIA_REQUIREMENTS_REQUEST.md @@ -0,0 +1,55 @@ +# Anna Marketplace media requirements request + +Prepared on 2026-08-29. This is a support-message draft, not a submission or +authorization to upload files. + +## Verified context + +- Anna's public App Manifest documentation says listing metadata, logos, and + screenshots are managed in the Developer Console Listing tab. +- The public Developer Hub and pinned CLI schema reviewed on 2026-08-29 do not + expose numeric screenshot or Marketplace-logo constraints. +- StoryCore Harbour currently has four deterministic fictional PNG drafts at + 900 x 820. Their largest file is 105,664 bytes. +- The draft is App id 214, slug `storycore-harbour`, working revision 11, with + zero immutable versions and no public release. + +Official pages checked: + +- +- + +## Ready-to-send support message + +```text +Hi Anna team — we are preparing the first review package for StoryCore Harbour +(@storycore-labs/storycore-harbour), a Schema 2 Host-API-only App with no +Executa. + +Could you confirm the current Marketplace media requirements shown to reviewers +and developers? + +1. required screenshot count and accepted formats; +2. exact pixel dimensions or aspect-ratio range; +3. maximum bytes per screenshot; +4. Marketplace logo/icon dimensions, format, transparency, and maximum bytes; +5. any safe-area, rounded-corner, text-overlay, localization, or dark/light + theme requirements; +6. whether screenshots from the local Anna harness are acceptable when they + contain only fictional data and accurately represent the submitted bundle. + +Our current drafts are four PNG files at 900 x 820, each below 106 KB. They show +Concept, World/production bible, Scenes/shots, and Continuity/export. They do not +show account identifiers, hidden acceptance controls, provider metadata, or +real user content. + +We will not treat these drafts as final until the current requirements are +confirmed. Thank you. +``` + +## Owner action + +The owner may either inspect the authenticated Developer Console Listing field +hints or send the message above through Anna's official support channel. Do not +upload assets, submit a review, accept terms, or make a public post without the +owner's explicit approval at the time of the action. diff --git a/apps/storycore-harbour/review/SUBMISSION_BRIEF.md b/apps/storycore-harbour/review/SUBMISSION_BRIEF.md index 5a38193f..0a861487 100644 --- a/apps/storycore-harbour/review/SUBMISSION_BRIEF.md +++ b/apps/storycore-harbour/review/SUBMISSION_BRIEF.md @@ -23,6 +23,12 @@ StoryCore Harbour turns a concept, synopsis, or short script into a coherent vis The deterministic browser demo produces draft versions as `01-concept.png` through `04-continuity.png`. Confirm Anna's exact dimensions and file limits before treating them as final Marketplace assets. +The current revision 11 drafts are 900 x 820 PNG files, each below 106 KB. +Public Anna documentation reviewed on 2026-08-29 did not expose numeric listing +media limits. Use `ANNA_MEDIA_REQUIREMENTS_REQUEST.md` for the prepared support +question; do not upload or submit the drafts until those requirements are +confirmed. + ## Demonstration procedure Follow `../demo/README.md`. The expected reviewer-visible flow takes less than five minutes after dependencies are installed: From 8847853f37b9a3852bee6fef7aa30bfdc7f49d92 Mon Sep 17 00:00:00 2001 From: RapideCastor Date: Sat, 29 Aug 2026 08:39:11 +0200 Subject: [PATCH 024/113] docs(harbour): record Anna draft revision 12 --- apps/storycore-harbour/STATUS.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/apps/storycore-harbour/STATUS.md b/apps/storycore-harbour/STATUS.md index 4536af82..b740e3bc 100644 --- a/apps/storycore-harbour/STATUS.md +++ b/apps/storycore-harbour/STATUS.md @@ -302,3 +302,5 @@ Codex must inspect the latest CI and Sonar results first. The next legitimate im - The forced-colour fix is synchronized to Anna working draft revision 11. Its 15-file, 104,890-byte bundle is ready with content hash `c5ddb6b3a45b42cebb3ceed713f65121ba3efac9808fece05aa05ad6ce863a17`; the App still has zero immutable versions and remains unpublished. - Revision 11 demo evidence was regenerated locally in under twenty seconds: four visually inspected 900 × 820 PNGs plus a 3,288-byte contract-valid JSON export. The generated files remain ignored under `demo/output.local/revision-11/`; their sizes and one-run SHA-256 values are recorded in `demo/EVIDENCE_2026-08-28_REVISION_11.md`. The screenshot helper now resets every App scroll container before capture, fixing the measured missing-header defect on the World draft. - Anna's public Developer Hub and pinned CLI schema were rechecked on 2026-08-29. They identify the Developer Console Listing tab as the metadata/media surface but expose no numeric screenshot or Marketplace-logo limits. Both available browser sessions required a fresh owner sign-in, so no authenticated field hints were claimed. `review/ANNA_MEDIA_REQUIREMENTS_REQUEST.md` contains the exact ready-to-send question; the 900 × 820 PNGs remain drafts and nothing was uploaded or submitted. +- Concurrent owner work on 2026-08-29 made the single repair prompt schema-complete: it now enumerates every required project, bible, entity, scene, shot, and continuity field; fixes the three-scene/one-shot shape; preserves exact source input; and still discards the failed response. The changes were merged without rewriting history and pass 67/67 Node tests, strict validation, the sequential Edge generation/export smoke, forced-colour assertions, and the ETag deletion smoke. +- The schema-complete repair prompt is synchronized to Anna working draft revision 12. Its 15-file, 107,699-byte bundle is ready with content hash `ad383957f123c1a2ea6c20e6ebb499f192f9ad161af4b0a9b0cc2bdb8e353fad`; the App remains an unpublished draft with zero immutable versions. No real-model run or quota consumption was performed for this synchronization. From 5ce095eea0f0b2eaeb958ae5b2f1106e56b4e884 Mon Sep 17 00:00:00 2001 From: RapideCastor Date: Sat, 29 Aug 2026 10:55:06 +0200 Subject: [PATCH 025/113] feat(harbour): prepare validated Anna marketplace logo --- apps/storycore-harbour/STATUS.md | 2 + apps/storycore-harbour/package.json | 2 + .../review/ANNA_MEDIA_REQUIREMENTS_REQUEST.md | 24 +++--- .../review/SUBMISSION_BRIEF.md | 5 ++ .../review/marketplace-media/README.md | 26 +++++++ .../storycore-harbour-logo-256.png | Bin 0 -> 5302 bytes .../scripts/render-marketplace-logo.mjs | 45 +++++++++++ .../scripts/validate-marketplace-logo.mjs | 70 ++++++++++++++++++ 8 files changed, 165 insertions(+), 9 deletions(-) create mode 100644 apps/storycore-harbour/review/marketplace-media/README.md create mode 100644 apps/storycore-harbour/review/marketplace-media/storycore-harbour-logo-256.png create mode 100644 apps/storycore-harbour/scripts/render-marketplace-logo.mjs create mode 100644 apps/storycore-harbour/scripts/validate-marketplace-logo.mjs diff --git a/apps/storycore-harbour/STATUS.md b/apps/storycore-harbour/STATUS.md index b740e3bc..53c00e3a 100644 --- a/apps/storycore-harbour/STATUS.md +++ b/apps/storycore-harbour/STATUS.md @@ -304,3 +304,5 @@ Codex must inspect the latest CI and Sonar results first. The next legitimate im - Anna's public Developer Hub and pinned CLI schema were rechecked on 2026-08-29. They identify the Developer Console Listing tab as the metadata/media surface but expose no numeric screenshot or Marketplace-logo limits. Both available browser sessions required a fresh owner sign-in, so no authenticated field hints were claimed. `review/ANNA_MEDIA_REQUIREMENTS_REQUEST.md` contains the exact ready-to-send question; the 900 × 820 PNGs remain drafts and nothing was uploaded or submitted. - Concurrent owner work on 2026-08-29 made the single repair prompt schema-complete: it now enumerates every required project, bible, entity, scene, shot, and continuity field; fixes the three-scene/one-shot shape; preserves exact source input; and still discards the failed response. The changes were merged without rewriting history and pass 67/67 Node tests, strict validation, the sequential Edge generation/export smoke, forced-colour assertions, and the ETag deletion smoke. - The schema-complete repair prompt is synchronized to Anna working draft revision 12. Its 15-file, 107,699-byte bundle is ready with content hash `ad383957f123c1a2ea6c20e6ebb499f192f9ad161af4b0a9b0cc2bdb8e353fad`; the App remains an unpublished draft with zero immutable versions. No real-model run or quota consumption was performed for this synchronization. +- Authenticated Listing inspection on 2026-08-29 confirmed logo formats PNG/JPG/WebP/GIF, a 2MB maximum, and 256 × 256 cropping. Screenshot metadata remains one URL per line with no visible or HTML-enforced count, dimensions, aspect ratio, format, or byte limit. No field was changed and no asset was uploaded. +- A reproducible Marketplace logo candidate now renders from the committed SVG through Edge at `review/marketplace-media/storycore-harbour-logo-256.png`. It is 256 × 256, 5,302 bytes, SHA-256 `4a54e1955ac5ebe67eef8c82a260b1b1cdc351a7e87902f2bba2b15532cea7dd`, and passes PNG header, size, dimension, and representative-pixel validation. The first renderer attempt was correctly rejected after visual inspection exposed an unloaded black image; the renderer now embeds and decodes the SVG before capture. diff --git a/apps/storycore-harbour/package.json b/apps/storycore-harbour/package.json index b2f83d72..a717772a 100644 --- a/apps/storycore-harbour/package.json +++ b/apps/storycore-harbour/package.json @@ -19,6 +19,8 @@ "browser:smoke": "node scripts/browser-smoke.mjs", "browser:deletion-smoke": "node scripts/browser-deletion-smoke.mjs", "browser:check": "npm run browser:smoke && npm run browser:deletion-smoke", + "marketplace:logo": "node scripts/render-marketplace-logo.mjs", + "marketplace:logo:check": "node scripts/validate-marketplace-logo.mjs", "contract:check": "node scripts/validate-project.mjs examples/sample-project.json", "acceptance:sync": "node scripts/sync-acceptance-corpus.mjs", "acceptance:sync:check": "node scripts/check-bundled-corpus.mjs", diff --git a/apps/storycore-harbour/review/ANNA_MEDIA_REQUIREMENTS_REQUEST.md b/apps/storycore-harbour/review/ANNA_MEDIA_REQUIREMENTS_REQUEST.md index 0acd6fb5..2431f2f6 100644 --- a/apps/storycore-harbour/review/ANNA_MEDIA_REQUIREMENTS_REQUEST.md +++ b/apps/storycore-harbour/review/ANNA_MEDIA_REQUIREMENTS_REQUEST.md @@ -7,8 +7,11 @@ authorization to upload files. - Anna's public App Manifest documentation says listing metadata, logos, and screenshots are managed in the Developer Console Listing tab. -- The public Developer Hub and pinned CLI schema reviewed on 2026-08-29 do not - expose numeric screenshot or Marketplace-logo constraints. +- The authenticated Listing inspected on 2026-08-29 accepts PNG/JPG/WebP/GIF + logos up to 2MB and states that they are cropped to 256 x 256. +- Screenshots are entered as one URL per line. The Listing exposes no visible + screenshot count, dimension, aspect-ratio, format, or byte limit, and the + textarea has no corresponding HTML constraint attributes. - StoryCore Harbour currently has four deterministic fictional PNG drafts at 900 x 820. Their largest file is 105,664 bytes. - The draft is App id 214, slug `storycore-harbour`, working revision 11, with @@ -31,14 +34,17 @@ and developers? 1. required screenshot count and accepted formats; 2. exact pixel dimensions or aspect-ratio range; -3. maximum bytes per screenshot; -4. Marketplace logo/icon dimensions, format, transparency, and maximum bytes; -5. any safe-area, rounded-corner, text-overlay, localization, or dark/light +3. maximum bytes per screenshot and whether Anna fetches each URL at review or + requires a long-lived public asset URL; +4. any screenshot safe-area, rounded-corner, text-overlay, localization, or dark/light theme requirements; -6. whether screenshots from the local Anna harness are acceptable when they +5. whether screenshots from the local Anna harness are acceptable when they contain only fictional data and accurately represent the submitted bundle. -Our current drafts are four PNG files at 900 x 820, each below 106 KB. They show +The Listing already confirms that logos may be PNG/JPG/WebP/GIF up to 2MB and +are cropped to 256 x 256. Our prepared PNG logo is 256 x 256 and 5,302 bytes. + +Our current screenshot drafts are four PNG files at 900 x 820, each below 106 KB. They show Concept, World/production bible, Scenes/shots, and Continuity/export. They do not show account identifiers, hidden acceptance controls, provider metadata, or real user content. @@ -49,7 +55,7 @@ confirmed. Thank you. ## Owner action -The owner may either inspect the authenticated Developer Console Listing field -hints or send the message above through Anna's official support channel. Do not +The authenticated Listing constraints above have been inspected. The owner may +send the remaining screenshot question through Anna's official support channel. Do not upload assets, submit a review, accept terms, or make a public post without the owner's explicit approval at the time of the action. diff --git a/apps/storycore-harbour/review/SUBMISSION_BRIEF.md b/apps/storycore-harbour/review/SUBMISSION_BRIEF.md index 0a861487..e151b526 100644 --- a/apps/storycore-harbour/review/SUBMISSION_BRIEF.md +++ b/apps/storycore-harbour/review/SUBMISSION_BRIEF.md @@ -29,6 +29,11 @@ media limits. Use `ANNA_MEDIA_REQUIREMENTS_REQUEST.md` for the prepared support question; do not upload or submit the drafts until those requirements are confirmed. +The authenticated Listing subsequently confirmed PNG/JPG/WebP/GIF logos up to +2MB, cropped to 256 x 256. The validated 5,302-byte candidate is +`marketplace-media/storycore-harbour-logo-256.png`. Screenshot requirements remain the +unconfirmed part of the media gate. + ## Demonstration procedure Follow `../demo/README.md`. The expected reviewer-visible flow takes less than five minutes after dependencies are installed: diff --git a/apps/storycore-harbour/review/marketplace-media/README.md b/apps/storycore-harbour/review/marketplace-media/README.md new file mode 100644 index 00000000..6ef90b85 --- /dev/null +++ b/apps/storycore-harbour/review/marketplace-media/README.md @@ -0,0 +1,26 @@ +# StoryCore Harbour Marketplace media + +## Logo candidate + +- file: `storycore-harbour-logo-256.png`; +- source: `../../bundle/icon.svg`; +- format and dimensions: PNG, 256 x 256; +- size: 5,302 bytes, below Anna's visible 2MB maximum; +- SHA-256: `4a54e1955ac5ebe67eef8c82a260b1b1cdc351a7e87902f2bba2b15532cea7dd`. + +The authenticated Anna Listing inspected on 2026-08-29 accepts PNG, JPG, +WebP, or GIF logos up to 2MB and states that they are cropped to 256 x 256. +This asset is generated from the committed SVG without changing the product +identity. + +Reproduce and validate on Windows with Edge: + +```powershell +$env:BROWSER_EXECUTABLE = 'C:\Program Files (x86)\Microsoft\Edge\Application\msedge.exe' +npm run marketplace:logo +npm run marketplace:logo:check +``` + +The validator checks PNG signature, exact dimensions, maximum bytes, and +representative gold, white, red, and cyan pixels. Do not upload the file or +save Listing changes without explicit owner approval at action time. diff --git a/apps/storycore-harbour/review/marketplace-media/storycore-harbour-logo-256.png b/apps/storycore-harbour/review/marketplace-media/storycore-harbour-logo-256.png new file mode 100644 index 0000000000000000000000000000000000000000..3644aa04e343cd3f118c7ea5ebd22f31c4e02d13 GIT binary patch literal 5302 zcmdT|_g7O(+daV$2neAlNRQMY9Yp2QH7LD^AVmbJ(gj3CN&*q3_f9B+^eRa2AXPvC z=`D1mh8jwM5AS{diSMVg&z@)YdS+&=nYDMIw&o)$3T6rb0H{=yk&giYbO}KKIpp&5 z%r(~<0B8Ueq~eoz*o|rL2)!k5GW@pNo6))tOG*YfVZT-Il;kQ}=!i}+IZr5EaxM4I z*jJ?m1C_l`k}1C@H1iW}U;hpZ8?t2Pn^+AQ(6=98&VQ0}$K39TxWbOr)6$1Z28iG% zakW3TWVKRf$7M@5*1e^if4VhtEcZ6lc}fwsW=>C2R+iqT>cYsbTZ<4mWriV}Lg3#d zS^F|n*ZRVf`tD3SXjt3dHf|WdhWOe0=*pS(x|ng*ICYX^)3NqzyP`LV94PXo?GGiB zo`g*=jM%uWqyr64A!NLRh;(hcFjpR)vja3LV zTgw~AM0)XOrk{<)%vYTc-8wa*pEYb$bWpMU;}~An*{Pb>`E4-#aBFK-;h~i{=<1yv zXK@XpfP$rT(MYmzE5>kx)AAMw`)lw`*5HY?+gqN1sd5#8gXT!tO)V}i@krnc3ihtb z&sXcX6pG*OZ@s6O4^4k@Xsaf(G~J&qw-oh<`Do(8X||D2d6UOoS;azaGDUTckC!xs zIzvW0&09jdy7)y;;e8b0JU-IzyD@<2JM-)_fyfPrZ6`UkR7Gzwa8HmRdTr}qs%I%R z(v-;CF@ewnvV&+F4FR^9v@r)oP9gNixXRhWp_6UNQdZ<`Vfq;7xR{fNv#E!soYr6Y z&>d1mcVGH*++0w4gk;K$?Fg0ilxBYzV!laoEvSf)P};&1xydTMn_VvHFHiF4nrE7m7O&q+gBdktmND?7INZ-MZGx!F~e=covh=1t*@$)Bg z@FO3Hybh7OL%onxjL|oV9=(5zv6jcAA(-gwd6)gh3UrE~=}xbj!@YenEK{UzcuHz4 zjm7|$`7qW^KGh#REjJ2{hR3-OyC5epZI-QzQ2Tv79)N$Ftq{+Zv7a(4TI2EbVy;*y zSG#=(?P?yLbC7K2A)*ua(ll==HdW!t&J8qnnC!BLk=&NCLS-crwC~=r3r_$dSTp~% ztZ!NPB1Yhtcnulja=sl~SEfyBf;M9k;q0m3wWuf*MhoI>ovK|$BH65C>wc|%xE%cB zq~%CTYuHXW7z~VKO)I6ODV)?oj_~un>2w6W!i9IeS%8si{vsh49#uk)Fw1}JR0mu? zYvPd{oRdYYAxk=;i2$TzPz@Q!A`ciGeRyG5Z${^2 zAWP{9+qam!Aj)@%8J#&-JB)n=i&|wDaz5V6`iPJ|-^l0OIyv!rm%l6dZ0VxVP>#x* zCvPFivN&Selk;xjwqFcf}o0 z6}cj%n0?a2wpsFFL-$Z4>_*6ODa}j%E~xlV=Y>Xo7sXyr|8dHumrgloEL=(M&Q>zr zT_y9TnO?#-U+m56K9|H}GxJ%@A%h>5HjXe+>8$D-=q&+BXFy|ho<1EocrH=ytco$h z@n^00PU&!1$Ty@~0K$=DgxlKS8jHsv$6j>d*DwF$ZJVtlB#7BZ9j_Or?q+2e(gA#L zO~lU}zVr5b>oRAnk-Q|4dce5cEH8FaUTc+B=Ip8(%?)U7)n+fbO0-JM`KSHhJB{*= zvS(h>ZS6McT)j_wUcXLreLQ|68mlV;h1U*sAS7Pz`Qulf`x|x3S(+|#w`VrB?PJyE zMm7Aa)@k?r!-Ep6QLh$l@Y12sW*0@Z^t|h3pW^rVrC*^Ahjn4lW?SaL zHcFD?7LP+InI}F3u&)Q&HZB~pI z!cq8*rZefr^FC+ajZOyiQnp4)5XxiY^LIbfF1K#C9=GV}1VFIT>|CbsqWeKLp6C{N z+e6dC$%D#Lgcnc~l3Lh@1M%1$o-azNo;XP?bs0T9+FzJFtPy52zbgHU!sFgM4NDGQ z;D_>sr+3H6?5@!WpA5C)fEh_?7l(Z!lKB3)T53}=vP2v0Q^M7kPFz!IupA($WciZJW8}w9* zFrX7A2BKNILI3{(NCubeg z!}>xIAs)lTHm(D{?zp^bpkQPViITvQW44*lrU_vy;IrMI6j| zIX-jYx9-H?d+%Jq#IRjzhV5NG$#UoJ zin>iBIL55ne`H)#JWL>})GNe-h1Zk{B=Xpv;6Zz}IJ09^Q>cjZBt-SH)q)jEN`Br>JHe%0Uf zMw1&z?9`gim1iD-eED=arCbiwrDK{+7^EA4XqVkv;h?goR~TE=TF8~33Xf0m@6YDd z7Qdv~1ad$;t2{?TgG1v?4rEnMEhX&U+($E;64vuAx{SW_ol+O82Jygr(qviy*lUII z=K`*>h!%)_N*P$r{(z1YSzKsqRJ)y zv~j^+Z4K@{XnM6B)jW4g<3U_9rf8%Ywv$9MplaXWG~#44l{SsA^kIPa3o@^uLwj{i zWxTCLMU{uu?EB?|#P})!PVK%PS$d|4sc^~?ewxtR%f#}>W^o^;grISYxBJTZ`wd~H z6Ed1F?%v85ev*`N_52?7bDbYvl<@r4Ap8TnAy!ABMMCVXp3y%pWXl5*Vtq0-Me_)u zs?Uhr;>NsVYRiOrvB&Oul!;L;OXPZa=UYx27smxHjzy@>o!@sQNl)X~Z@q468p>~~ z0H2j{$g&>O8q=YPo8^L+>#}j?TQ+f*%;(WKPmw>8+g0~7RZURBswgOPMsULy8=_bG z#ceCTohixKL^mF!H1!a-(~iM3=OBSITo0}C48Xtk859Lx+#aYmx1z~)i6RJir3&); z;XjCbgt|oN=TN0J^zs2WInuNJgL|AZ{Ie1~9Co$1H|V${!L3e;BXo-Ieuu9U;1Qem`lVybD&=Ui9w~3xE(q{&b-xEOj4@?hfrX5~I>CkE0UZf#B09eKVzb%g%$=Ix~hVv`hV; zXZpkP=9XHdZ0^U1DE7j*fucK@fL#mGWehmIp2NHj0Kleyvw*oKYR;a}zqIx!`l%wa zhPJ~i0+5?MnBiNtmJUF`Dq$Tm^_jDSYdkcOryswcdBk`DY`6)@PII>@!T}3a9G`{)W6T2yvu^3BXFqbUZ4AJVR9aSUN3Z0-o); zINNk~KWLhxLVb9=CPowK_Aq#p7OEg{h6w1A%4|Oj0RO71%WRWzl?~`( zw6L>-|1&RpQD)DC61td@dvUX*nP<%`>!_Dm>;SEybG-pOAtT*^l*Z7xH?s*ZDtzHY z6WQJoKIcNO;Jgw>g{6g;#e@*mu7+FqF`xxm*HDZ|%M@4hDyP;I)~$(2Q7e5ADnGFq!3=<;39f28}17cZXtBE6ygc#S2(syz^) zwr}R3|79Nht19O#UUlIijgGalx~fCQeXDTL)nQb%y_&b~o~0YXi$-T+Yjb+i1(vU^ zM?#aeRm{kel*E`^7p#cP6Foc6>5ZA?$W*-!BcaxPil{s%GvG$XK%@p%1ePm5+|rYQ zh^qhf+06BYH*^LsPxbk5i-?^g2uxAV=tJx7{xiA8R|%f+j(|Q#Mkr}JY@#Yfe9cq{IuPG{0EvT?A#T)G{ zdC`n7SV&Y867)|7W~SNLx)ApHdzM&*wz|P9!lTWB@SYp=>S-rvrm@4c{`(h=tom^) z)Ng3@6NOuqaCx0E#hclI7g4tJ&b{XJD9(}56|JPV6DnD9g`z`&D+wP;d%f)Q#q2(l=sJ=~T{kd(vm`^XhM-O>y~inx0vxY1JcdVIKw}GXPv%JXf|gzf2z? cf3KP=fYLGw7Cy^3dC34ON}9;x`(}Rs2jx8@7XSbN literal 0 HcmV?d00001 diff --git a/apps/storycore-harbour/scripts/render-marketplace-logo.mjs b/apps/storycore-harbour/scripts/render-marketplace-logo.mjs new file mode 100644 index 00000000..ea50ccf8 --- /dev/null +++ b/apps/storycore-harbour/scripts/render-marketplace-logo.mjs @@ -0,0 +1,45 @@ +#!/usr/bin/env node +import { mkdir, readFile } from "node:fs/promises"; +import { dirname, resolve } from "node:path"; +import { chromium } from "playwright-core"; + +const executablePath = process.env.BROWSER_EXECUTABLE; +const inputPath = resolve(process.argv[2] || "bundle/icon.svg"); +const outputPath = resolve(process.argv[3] || "review/marketplace-media/storycore-harbour-logo-256.png"); + +if (!executablePath) { + console.error("BROWSER_EXECUTABLE is required to render the Marketplace logo."); + process.exit(2); +} + +await mkdir(dirname(outputPath), { recursive: true }); +const browser = await chromium.launch({ + executablePath, + headless: true, + args: ["--no-sandbox", "--disable-dev-shm-usage"], +}); + +try { + const page = await browser.newPage({ viewport: { width: 256, height: 256 } }); + const source = await readFile(inputPath); + const sourceUrl = `data:image/svg+xml;base64,${source.toString("base64")}`; + await page.setContent(` + + + `); + const logo = page.locator("img"); + await logo.waitFor({ state: "visible" }); + await logo.evaluate(async (image) => { + await image.decode(); + if (!image.complete || image.naturalWidth <= 0 || image.naturalHeight <= 0) { + throw new Error("The source logo did not decode before capture."); + } + }); + await logo.screenshot({ path: outputPath, omitBackground: true }); + console.log(JSON.stringify({ result: "pass", inputPath, outputPath, width: 256, height: 256 })); +} finally { + await browser.close(); +} diff --git a/apps/storycore-harbour/scripts/validate-marketplace-logo.mjs b/apps/storycore-harbour/scripts/validate-marketplace-logo.mjs new file mode 100644 index 00000000..ebae6a00 --- /dev/null +++ b/apps/storycore-harbour/scripts/validate-marketplace-logo.mjs @@ -0,0 +1,70 @@ +#!/usr/bin/env node +import assert from "node:assert/strict"; +import { readFile } from "node:fs/promises"; +import { resolve } from "node:path"; +import { pathToFileURL } from "node:url"; +import { chromium } from "playwright-core"; + +const logoPath = resolve(process.argv[2] || "review/marketplace-media/storycore-harbour-logo-256.png"); +const executablePath = process.env.BROWSER_EXECUTABLE; +const bytes = await readFile(logoPath); +const pngSignature = Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]); + +if (!executablePath) { + console.error("BROWSER_EXECUTABLE is required to inspect the rendered Marketplace logo."); + process.exit(2); +} + +assert.ok(bytes.subarray(0, 8).equals(pngSignature), "Marketplace logo must be a PNG file."); +assert.equal(bytes.subarray(12, 16).toString("ascii"), "IHDR", "PNG must start with an IHDR chunk."); +assert.equal(bytes.readUInt32BE(16), 256, "Marketplace logo width must be 256 pixels."); +assert.equal(bytes.readUInt32BE(20), 256, "Marketplace logo height must be 256 pixels."); +assert.ok(bytes.length <= 2 * 1024 * 1024, "Marketplace logo must not exceed Anna's 2MB limit."); + +const browser = await chromium.launch({ + executablePath, + headless: true, + args: ["--no-sandbox", "--disable-dev-shm-usage"], +}); +let samples; +try { + const page = await browser.newPage({ viewport: { width: 256, height: 256 } }); + await page.goto(pathToFileURL(logoPath).href); + samples = await page.locator("img").evaluate((image) => { + const canvas = document.createElement("canvas"); + canvas.width = image.naturalWidth; + canvas.height = image.naturalHeight; + const context = canvas.getContext("2d", { willReadFrequently: true }); + context.drawImage(image, 0, 0); + const pixel = (x, y) => [...context.getImageData(x, y, 1, 1).data]; + return { + naturalWidth: image.naturalWidth, + naturalHeight: image.naturalHeight, + goldBeacon: pixel(128, 56), + whiteMast: pixel(128, 128), + redHull: pixel(128, 152), + cyanWave: pixel(48, 172), + }; + }); +} finally { + await browser.close(); +} + +const near = (actual, expected, tolerance = 12) => + expected.every((channel, index) => Math.abs(actual[index] - channel) <= tolerance); +assert.deepEqual([samples.naturalWidth, samples.naturalHeight], [256, 256]); +assert.ok(near(samples.goldBeacon, [247, 198, 90, 255]), "Logo beacon must render gold."); +assert.ok(near(samples.whiteMast, [244, 247, 251, 255]), "Logo mast must render white."); +assert.ok(near(samples.redHull, [239, 75, 95, 255]), "Logo hull must render red."); +assert.ok(near(samples.cyanWave, [71, 215, 232, 255]), "Logo wave must render cyan."); + +console.log(JSON.stringify({ + result: "pass", + logoPath, + format: "PNG", + width: 256, + height: 256, + bytes: bytes.length, + maximumBytes: 2 * 1024 * 1024, + pixelSamples: "pass", +})); From 104a149fd2b2e58af8957d6ea85bccaf2fc0f5e4 Mon Sep 17 00:00:00 2001 From: RapideCastor Date: Sat, 29 Aug 2026 11:12:42 +0200 Subject: [PATCH 026/113] docs(harbour): record working-draft install path --- apps/storycore-harbour/STATUS.md | 1 + .../review/FINAL_HANDOFF_2026-08-24.md | 8 ++++++++ .../review/LAUNCH_CHECKLIST.md | 2 ++ .../OWNER_ACTIVATION_AND_FIRST_REVIEW.md | 20 +++++++++++++++++++ 4 files changed, 31 insertions(+) diff --git a/apps/storycore-harbour/STATUS.md b/apps/storycore-harbour/STATUS.md index 53c00e3a..821fa672 100644 --- a/apps/storycore-harbour/STATUS.md +++ b/apps/storycore-harbour/STATUS.md @@ -306,3 +306,4 @@ Codex must inspect the latest CI and Sonar results first. The next legitimate im - The schema-complete repair prompt is synchronized to Anna working draft revision 12. Its 15-file, 107,699-byte bundle is ready with content hash `ad383957f123c1a2ea6c20e6ebb499f192f9ad161af4b0a9b0cc2bdb8e353fad`; the App remains an unpublished draft with zero immutable versions. No real-model run or quota consumption was performed for this synchronization. - Authenticated Listing inspection on 2026-08-29 confirmed logo formats PNG/JPG/WebP/GIF, a 2MB maximum, and 256 × 256 cropping. Screenshot metadata remains one URL per line with no visible or HTML-enforced count, dimensions, aspect ratio, format, or byte limit. No field was changed and no asset was uploaded. - A reproducible Marketplace logo candidate now renders from the committed SVG through Edge at `review/marketplace-media/storycore-harbour-logo-256.png`. It is 256 × 256, 5,302 bytes, SHA-256 `4a54e1955ac5ebe67eef8c82a260b1b1cdc351a7e87902f2bba2b15532cea7dd`, and passes PNG header, size, dimension, and representative-pixel validation. The first renderer attempt was correctly rejected after visual inspection exposed an unloaded black image; the renderer now embeds and decodes the SVG before capture. +- Authenticated Console inspection on 2026-08-29 confirmed working draft r12, bundle `ready`, content hash prefix `ad383957f123…`, and no version history. CLI status independently confirms `draft`, unpublished, and zero versions. The current Versions tab now exposes a distinct **Install & test** working-draft action, so an immutable cut is no longer assumed to be the only test path. It was not clicked. **View manifest** returned `Could not validate credentials`; this is recorded as a web-session/platform blocker rather than a bundle failure. diff --git a/apps/storycore-harbour/review/FINAL_HANDOFF_2026-08-24.md b/apps/storycore-harbour/review/FINAL_HANDOFF_2026-08-24.md index ed197550..68ccf540 100644 --- a/apps/storycore-harbour/review/FINAL_HANDOFF_2026-08-24.md +++ b/apps/storycore-harbour/review/FINAL_HANDOFF_2026-08-24.md @@ -111,6 +111,14 @@ Do not cut `0.1.0`, submit for review, mark PR #37 ready, merge, or release whil - cutting `0.1.0` would create that immutable version but must not be used as a workaround while the 18/20 reliability gate still fails; - the Console web session also expired on reload and redirected to login, which is a separate authentication condition rather than the original installation cause. +Update from 2026-08-29: the current Console now exposes a separate **Install & +test** button inside the working-draft Versions tab at revision 12, while CLI +status still reports zero immutable versions. This may provide a draft-testing +path without cutting `0.1.0`, but it was not clicked because installation and +permission prompts require explicit owner approval. **View manifest** returned +`Could not validate credentials`; therefore the web-session credential path +must be healthy before any installation result can be claimed. + ## AIMesher Anna App ### Local candidate diff --git a/apps/storycore-harbour/review/LAUNCH_CHECKLIST.md b/apps/storycore-harbour/review/LAUNCH_CHECKLIST.md index 5ffd0577..3df1d295 100644 --- a/apps/storycore-harbour/review/LAUNCH_CHECKLIST.md +++ b/apps/storycore-harbour/review/LAUNCH_CHECKLIST.md @@ -37,6 +37,8 @@ This checklist is a release gate. A checked implementation item does not overrid - [ ] Production ETag conflict induced and safely rejected. - [ ] Qualified App MAU definition and dashboard visibility confirmed. - [ ] Host-API-only completion confirmed as eligible without local runtime installation. +- [ ] Owner-approved working-draft **Install & test** completes from revision 12 without cutting a version. +- [ ] Installed Apps exposes only the expected LLM, App storage, and window capabilities with no Executa. ## Reliability and CI diff --git a/apps/storycore-harbour/review/OWNER_ACTIVATION_AND_FIRST_REVIEW.md b/apps/storycore-harbour/review/OWNER_ACTIVATION_AND_FIRST_REVIEW.md index 80d1de1c..d2c47d34 100644 --- a/apps/storycore-harbour/review/OWNER_ACTIVATION_AND_FIRST_REVIEW.md +++ b/apps/storycore-harbour/review/OWNER_ACTIVATION_AND_FIRST_REVIEW.md @@ -192,6 +192,26 @@ After the first successful push: - open the Developer Console and confirm the App is shown as a working/draft App; - confirm the locked slug is exactly `storycore-harbour`. +### Working-draft installation path + +The authenticated Developer Console inspected on 2026-08-29 exposes an +**Install & test** action inside the **Versions** tab for the mutable working +draft, even while the App has zero immutable versions. This is distinct from +the App-list **Install** action that previously failed with “no available +published version”. Do not cut `0.1.0` merely to discover whether the current +working-draft installation path is usable. + +Installing changes the owner's Installed Apps state and may open Host API +permission controls. Use **Install & test** only after explicit owner approval +at action time, then verify the exact App id, working revision, requested +capabilities, and absence of unexpected Executas before confirming anything. + +During the same inspection, **View manifest** returned `Could not validate +credentials` although the Console displayed working revision 12 and the CLI +independently confirmed the draft. Treat that as a web-session/platform +credential condition, not as evidence that the uploaded manifest or bundle is +invalid. Reauthenticate or ask Anna support rather than recreating the App. + If the CLI or Console creates an unexpected second App, stop before cutting a version. Preserve the outputs needed for diagnosis, but redact tokens. ## 6. Run StoryCore Harbour against real Anna services From 8cc3e8755e498599fd5f22bae52ef9cf11445afc Mon Sep 17 00:00:00 2001 From: RapideCastor Date: Sat, 29 Aug 2026 13:48:24 +0200 Subject: [PATCH 027/113] docs(harbour): capture Anna manifest credential blocker --- apps/storycore-harbour/STATUS.md | 1 + .../ANNA_VIEW_MANIFEST_CREDENTIAL_REPORT.md | 75 +++++++++++++++++++ 2 files changed, 76 insertions(+) create mode 100644 apps/storycore-harbour/review/ANNA_VIEW_MANIFEST_CREDENTIAL_REPORT.md diff --git a/apps/storycore-harbour/STATUS.md b/apps/storycore-harbour/STATUS.md index 821fa672..3f310752 100644 --- a/apps/storycore-harbour/STATUS.md +++ b/apps/storycore-harbour/STATUS.md @@ -307,3 +307,4 @@ Codex must inspect the latest CI and Sonar results first. The next legitimate im - Authenticated Listing inspection on 2026-08-29 confirmed logo formats PNG/JPG/WebP/GIF, a 2MB maximum, and 256 × 256 cropping. Screenshot metadata remains one URL per line with no visible or HTML-enforced count, dimensions, aspect ratio, format, or byte limit. No field was changed and no asset was uploaded. - A reproducible Marketplace logo candidate now renders from the committed SVG through Edge at `review/marketplace-media/storycore-harbour-logo-256.png`. It is 256 × 256, 5,302 bytes, SHA-256 `4a54e1955ac5ebe67eef8c82a260b1b1cdc351a7e87902f2bba2b15532cea7dd`, and passes PNG header, size, dimension, and representative-pixel validation. The first renderer attempt was correctly rejected after visual inspection exposed an unloaded black image; the renderer now embeds and decodes the SVG before capture. - Authenticated Console inspection on 2026-08-29 confirmed working draft r12, bundle `ready`, content hash prefix `ad383957f123…`, and no version history. CLI status independently confirms `draft`, unpublished, and zero versions. The current Versions tab now exposes a distinct **Install & test** working-draft action, so an immutable cut is no longer assumed to be the only test path. It was not clicked. **View manifest** returned `Could not validate credentials`; this is recorded as a web-session/platform blocker rather than a bundle failure. +- A single instrumented **View manifest** reproduction produced the same credential message but no observable network event, HTTP status, or console error through the available browser diagnostics. Root cause remains unproven. `review/ANNA_VIEW_MANIFEST_CREDENTIAL_REPORT.md` contains privacy-safe steps, independent CLI evidence, the exact uncertainty boundary, and a ready-to-send support question; nothing was posted. diff --git a/apps/storycore-harbour/review/ANNA_VIEW_MANIFEST_CREDENTIAL_REPORT.md b/apps/storycore-harbour/review/ANNA_VIEW_MANIFEST_CREDENTIAL_REPORT.md new file mode 100644 index 00000000..2aaea10b --- /dev/null +++ b/apps/storycore-harbour/review/ANNA_VIEW_MANIFEST_CREDENTIAL_REPORT.md @@ -0,0 +1,75 @@ +# Anna Developer Console credential-report draft + +Prepared on 2026-08-29. This is a private support-report draft; it has not been +sent or posted. + +## Summary + +The authenticated Anna Developer Console can list StoryCore Harbour and show +its working draft, but the read-only **View manifest** action reports: + +```text +Could not validate credentials +``` + +## Reproduction + +1. Sign in to Anna and complete workspace onboarding. +2. Open Developer Console. +3. Open StoryCore Harbour, App id 214. +4. Open **Versions**. +5. Confirm the working draft shows revision r12, bundle `ready`, and content + hash prefix `ad383957f123…`. +6. Click **View manifest**. +7. Observe `Could not validate credentials`; no manifest modal/content appears. + +## Independent evidence + +- `anna-app apps status storycore-harbour --json` succeeds with the same owner + account and reports `draft`, unpublished, zero versions. +- `anna-app apps versions storycore-harbour --json` succeeds and returns an + empty immutable-version list. +- Working draft revision 12 was uploaded with the pinned Node 22-compatible + CLI flow and bundle status `ready`. +- Local strict validation, canonical contract, mock fixture, fixed corpus, + browser flow, and deletion flow pass. +- The Console can read the App list, Listing, working revision, bundle status, + Settings, and version history in the same browser session. + +## Instrumentation result + +One instrumented reproduction was performed after enabling browser network +observation. The UI reproduced the same message, but the available event buffer +and console logs exposed no request URL or HTTP status. Do not claim whether +the failure occurs before the request, in an unobserved request, or in response +handling without server/frontend evidence. + +## Expected behavior + +**View manifest** should display the immutable snapshot of the current working +manifest, or return an actionable authentication/authorization error that +identifies which owner/developer credential must be refreshed. + +## Safety and impact + +- No manifest, Listing field, App permission, installation, or version was + changed during reproduction. +- **Install & test**, **Cut version**, **Submit now**, **Discard**, and deletion + actions were not used. +- This blocks a web-console manifest comparison and reduces confidence in the + new working-draft install path. It does not prove that the uploaded manifest + or bundle is invalid. + +## Ready-to-send question + +```text +Hi Anna team — the authenticated Developer Console lists StoryCore Harbour +(App id 214) and shows working draft r12 with bundle ready, but Versions > View +manifest returns “Could not validate credentials”. The pinned CLI can read the +same App status and versions successfully, and strict local validation passes. + +Could you confirm which web credential or endpoint View manifest requires, and +whether refreshing that credential is also required before using the new +working-draft “Install & test” action? We have not clicked Install & test, cut a +version, submitted review, or changed permissions. +``` From 67944e4b9e5b0d12d98401660568dfa6c51c9aaa Mon Sep 17 00:00:00 2001 From: RapideCastor Date: Sat, 29 Aug 2026 15:01:09 +0200 Subject: [PATCH 028/113] docs(harbour): close Anna manifest read blocker --- apps/storycore-harbour/STATUS.md | 1 + .../ANNA_VIEW_MANIFEST_CREDENTIAL_REPORT.md | 30 +++++++++++++++++++ .../review/LAUNCH_CHECKLIST.md | 1 + 3 files changed, 32 insertions(+) diff --git a/apps/storycore-harbour/STATUS.md b/apps/storycore-harbour/STATUS.md index 3f310752..1123efdb 100644 --- a/apps/storycore-harbour/STATUS.md +++ b/apps/storycore-harbour/STATUS.md @@ -308,3 +308,4 @@ Codex must inspect the latest CI and Sonar results first. The next legitimate im - A reproducible Marketplace logo candidate now renders from the committed SVG through Edge at `review/marketplace-media/storycore-harbour-logo-256.png`. It is 256 × 256, 5,302 bytes, SHA-256 `4a54e1955ac5ebe67eef8c82a260b1b1cdc351a7e87902f2bba2b15532cea7dd`, and passes PNG header, size, dimension, and representative-pixel validation. The first renderer attempt was correctly rejected after visual inspection exposed an unloaded black image; the renderer now embeds and decodes the SVG before capture. - Authenticated Console inspection on 2026-08-29 confirmed working draft r12, bundle `ready`, content hash prefix `ad383957f123…`, and no version history. CLI status independently confirms `draft`, unpublished, and zero versions. The current Versions tab now exposes a distinct **Install & test** working-draft action, so an immutable cut is no longer assumed to be the only test path. It was not clicked. **View manifest** returned `Could not validate credentials`; this is recorded as a web-session/platform blocker rather than a bundle failure. - A single instrumented **View manifest** reproduction produced the same credential message but no observable network event, HTTP status, or console error through the available browser diagnostics. Root cause remains unproven. `review/ANNA_VIEW_MANIFEST_CREDENTIAL_REPORT.md` contains privacy-safe steps, independent CLI evidence, the exact uncertainty boundary, and a ready-to-send support question; nothing was posted. +- After a fresh owner sign-in, **View manifest** succeeded and displayed the normalized working r12 manifest. It matches the committed Schema 2 Host-API-only boundary: no Executas, no top-level permissions, no external origins, one desktop view, `llm.complete`, App storage get/set/list/delete, `window.set_title`, self-only script CSP, and `last_writer_wins`. The credential incident is operationally resolved by reauthentication, while the exact token/frontend root cause remains unproven. No support message was sent. diff --git a/apps/storycore-harbour/review/ANNA_VIEW_MANIFEST_CREDENTIAL_REPORT.md b/apps/storycore-harbour/review/ANNA_VIEW_MANIFEST_CREDENTIAL_REPORT.md index 2aaea10b..2d74cefe 100644 --- a/apps/storycore-harbour/review/ANNA_VIEW_MANIFEST_CREDENTIAL_REPORT.md +++ b/apps/storycore-harbour/review/ANNA_VIEW_MANIFEST_CREDENTIAL_REPORT.md @@ -3,6 +3,30 @@ Prepared on 2026-08-29. This is a private support-report draft; it has not been sent or posted. +## Resolution update + +After the owner completed a fresh Anna sign-in and returned through the +Dashboard, **View manifest** succeeded in the same in-app browser. It displayed +working draft r12 and the normalized remote manifest. The earlier failure is +therefore resolved operationally by reauthentication, although the exact token +or frontend validation mechanism remains unproven. + +The remote manifest confirms: + +- schema 2; +- no required or optional Executas; +- no top-level permissions; +- no external bundle origins; +- one desktop view with minimum 520 x 680 and default 900 x 820; +- `llm.complete` only in the LLM namespace; +- App storage `get`, `set`, `list`, and `delete`; +- `window.set_title`; +- CSP `script-src 'self'` and `last_writer_wins` state merge. + +These boundaries match the committed Host-API-only adapter. The support message +below should now be sent only if the credential error recurs after a fresh +login; it is retained as a reproducible diagnostic template. + ## Summary The authenticated Anna Developer Console can list StoryCore Harbour and show @@ -73,3 +97,9 @@ whether refreshing that credential is also required before using the new working-draft “Install & test” action? We have not clicked Install & test, cut a version, submitted review, or changed permissions. ``` + +## Closure boundary + +This resolution does not authorize **Install & test**, enable Host API grants, +cut a version, spend model quota, submit review, merge, or publish. It only +closes the read-only remote-manifest comparison gate. diff --git a/apps/storycore-harbour/review/LAUNCH_CHECKLIST.md b/apps/storycore-harbour/review/LAUNCH_CHECKLIST.md index 3df1d295..28502691 100644 --- a/apps/storycore-harbour/review/LAUNCH_CHECKLIST.md +++ b/apps/storycore-harbour/review/LAUNCH_CHECKLIST.md @@ -31,6 +31,7 @@ This checklist is a release gate. A checked implementation item does not overrid - [x] Manifest declares only LLM, App storage, and window-title Host APIs. - [x] Undeclared tool invocation is denied in the official test harness. - [x] Mock Anna harness starts in CI. +- [x] Authenticated Console View manifest matches the committed Schema 2 Host-API-only boundaries at r12. - [x] Browser flow runs inside the Anna harness at 520 × 680. - [ ] Production Host API handshake verified in the target Anna account. - [ ] Production App storage write/read/reload verified. From fa7984c1c75eb9cdd349adcd83a6885b87b3ea20 Mon Sep 17 00:00:00 2001 From: RapideCastor Date: Sat, 29 Aug 2026 15:02:39 +0200 Subject: [PATCH 029/113] docs(harbour): update manifest reauthentication runbook --- .../review/OWNER_ACTIVATION_AND_FIRST_REVIEW.md | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/apps/storycore-harbour/review/OWNER_ACTIVATION_AND_FIRST_REVIEW.md b/apps/storycore-harbour/review/OWNER_ACTIVATION_AND_FIRST_REVIEW.md index d2c47d34..2bccc82e 100644 --- a/apps/storycore-harbour/review/OWNER_ACTIVATION_AND_FIRST_REVIEW.md +++ b/apps/storycore-harbour/review/OWNER_ACTIVATION_AND_FIRST_REVIEW.md @@ -212,6 +212,11 @@ independently confirmed the draft. Treat that as a web-session/platform credential condition, not as evidence that the uploaded manifest or bundle is invalid. Reauthenticate or ask Anna support rather than recreating the App. +A fresh owner sign-in subsequently restored **View manifest**. The normalized +remote r12 manifest matched the committed Schema 2 Host-API-only boundaries. +Keep `review/ANNA_VIEW_MANIFEST_CREDENTIAL_REPORT.md` as the recurrence report; +do not send it while the read path remains healthy. + If the CLI or Console creates an unexpected second App, stop before cutting a version. Preserve the outputs needed for diagnosis, but redact tokens. ## 6. Run StoryCore Harbour against real Anna services From 3800320777e08fa99b8b8f34b37514731136ccf9 Mon Sep 17 00:00:00 2001 From: RapideCastor Date: Sat, 29 Aug 2026 16:02:01 +0200 Subject: [PATCH 030/113] docs(harbour): diagnose dev-install permission mismatch --- apps/storycore-harbour/STATUS.md | 1 + .../ANNA_DEV_INSTALL_PERMISSIONS_REPORT.md | 63 +++++++++++++++++++ .../review/FINAL_HANDOFF_2026-08-24.md | 8 +++ .../review/LAUNCH_CHECKLIST.md | 2 + .../OWNER_ACTIVATION_AND_FIRST_REVIEW.md | 7 +++ 5 files changed, 81 insertions(+) create mode 100644 apps/storycore-harbour/review/ANNA_DEV_INSTALL_PERMISSIONS_REPORT.md diff --git a/apps/storycore-harbour/STATUS.md b/apps/storycore-harbour/STATUS.md index 1123efdb..787e1f2f 100644 --- a/apps/storycore-harbour/STATUS.md +++ b/apps/storycore-harbour/STATUS.md @@ -309,3 +309,4 @@ Codex must inspect the latest CI and Sonar results first. The next legitimate im - Authenticated Console inspection on 2026-08-29 confirmed working draft r12, bundle `ready`, content hash prefix `ad383957f123…`, and no version history. CLI status independently confirms `draft`, unpublished, and zero versions. The current Versions tab now exposes a distinct **Install & test** working-draft action, so an immutable cut is no longer assumed to be the only test path. It was not clicked. **View manifest** returned `Could not validate credentials`; this is recorded as a web-session/platform blocker rather than a bundle failure. - A single instrumented **View manifest** reproduction produced the same credential message but no observable network event, HTTP status, or console error through the available browser diagnostics. Root cause remains unproven. `review/ANNA_VIEW_MANIFEST_CREDENTIAL_REPORT.md` contains privacy-safe steps, independent CLI evidence, the exact uncertainty boundary, and a ready-to-send support question; nothing was posted. - After a fresh owner sign-in, **View manifest** succeeded and displayed the normalized working r12 manifest. It matches the committed Schema 2 Host-API-only boundary: no Executas, no top-level permissions, no external origins, one desktop view, `llm.complete`, App storage get/set/list/delete, `window.set_title`, self-only script CSP, and `last_writer_wins`. The credential incident is operationally resolved by reauthentication, while the exact token/frontend root cause remains unproven. No support message was sent. +- Installed Apps already contains StoryCore Harbour as `v0.0.0-dev`, so **Install & test** was not clicked again. Its read-only Permissions panel returns `Failed to load permissions: App version not found`. CLI status simultaneously confirms draft r12 has zero immutable versions and the grants endpoint exposes no data. The facts identify a dev-install/version-resolution blocker for permission management; they do not prove whether refresh, immutable cut, or server repair is the correct fix. `review/ANNA_DEV_INSTALL_PERMISSIONS_REPORT.md` contains the bounded support question, and nothing was sent or changed. diff --git a/apps/storycore-harbour/review/ANNA_DEV_INSTALL_PERMISSIONS_REPORT.md b/apps/storycore-harbour/review/ANNA_DEV_INSTALL_PERMISSIONS_REPORT.md new file mode 100644 index 00000000..6621965a --- /dev/null +++ b/apps/storycore-harbour/review/ANNA_DEV_INSTALL_PERMISSIONS_REPORT.md @@ -0,0 +1,63 @@ +# Anna working-draft installation permissions report + +Prepared on 2026-08-29. This report is local and has not been sent. + +## Observed state + +- Developer Console: StoryCore Harbour working draft r12, bundle `ready`, no + immutable version history. +- Installed Apps: StoryCore Harbour is already present as `v0.0.0-dev`. +- Installed Apps > StoryCore Harbour > Permissions reports: + +```text +Failed to load permissions: App version not found +``` + +- CLI status independently reports App id 214, `draft`, unpublished, + `latest_version: null`, and `version_count: 0`. +- CLI versions independently returns an empty list. +- CLI grants returns `grants: null`, which in the pinned CLI means the grants + endpoint supplied no data; it does not independently prove grant or denial. + +## Interpretation boundary + +The verified facts show that a development installation record exists while +the permission-management surface cannot resolve an App version. This explains +why StoryCore appears installed but its expected LLM/storage grants cannot yet +be inspected through Installed Apps. + +It is reasonable to suspect a platform working-draft/version-resolution gap, +but the evidence does not prove whether the fix is to refresh **Install & +test**, create an immutable version, or repair the existing dev-install record +server-side. Do not cut `0.1.0`, reinstall, uninstall, or change permissions as +a diagnostic shortcut. + +## Safety boundary + +- **Install & test** was not clicked again because StoryCore is already listed. +- The permission dialog was read but no control was changed or saved. +- No version was cut, no review was submitted, no App was removed, and no model + or storage quota was consumed. + +## Ready-to-send support question + +```text +Hi Anna team — StoryCore Harbour (App id 214) has a ready working draft at r12 +and is already listed in Installed Apps as v0.0.0-dev. However, opening its +Permissions panel returns “Failed to load permissions: App version not found”. + +The Developer Console and pinned CLI both confirm that the App is a draft with +zero immutable versions. Could you confirm the intended permissions path for a +working-draft Install & test record? + +1. Should a v0.0.0-dev installation resolve permissions directly from the + current working draft? +2. Is Install & test expected to refresh an existing dev-install record? +3. Is an immutable cut required for permission management, despite the + working-draft Install & test action? +4. If this is a stale dev-install record, can it be repaired server-side + without recreating the App or losing the reserved slug? + +We have not reinstalled, uninstalled, changed grants, cut a version, submitted +review, or published anything. +``` diff --git a/apps/storycore-harbour/review/FINAL_HANDOFF_2026-08-24.md b/apps/storycore-harbour/review/FINAL_HANDOFF_2026-08-24.md index 68ccf540..5b37a94e 100644 --- a/apps/storycore-harbour/review/FINAL_HANDOFF_2026-08-24.md +++ b/apps/storycore-harbour/review/FINAL_HANDOFF_2026-08-24.md @@ -119,6 +119,14 @@ permission prompts require explicit owner approval. **View manifest** returned `Could not validate credentials`; therefore the web-session credential path must be healthy before any installation result can be claimed. +Second update from 2026-08-29: after reauthentication, **View manifest** works +and matches the committed Host-API-only boundaries. Installed Apps already +contains StoryCore Harbour as `v0.0.0-dev`, so another installation was not +attempted. Its Permissions panel fails with `App version not found`, while CLI +status still confirms zero immutable versions. The current blocker is therefore +permission resolution for the existing development installation, not absence +of an Installed Apps record. See `ANNA_DEV_INSTALL_PERMISSIONS_REPORT.md`. + ## AIMesher Anna App ### Local candidate diff --git a/apps/storycore-harbour/review/LAUNCH_CHECKLIST.md b/apps/storycore-harbour/review/LAUNCH_CHECKLIST.md index 28502691..e2a97625 100644 --- a/apps/storycore-harbour/review/LAUNCH_CHECKLIST.md +++ b/apps/storycore-harbour/review/LAUNCH_CHECKLIST.md @@ -40,6 +40,8 @@ This checklist is a release gate. A checked implementation item does not overrid - [ ] Host-API-only completion confirmed as eligible without local runtime installation. - [ ] Owner-approved working-draft **Install & test** completes from revision 12 without cutting a version. - [ ] Installed Apps exposes only the expected LLM, App storage, and window capabilities with no Executa. +- [x] Existing Installed Apps record identified as StoryCore Harbour `v0.0.0-dev`. +- [ ] Resolve `App version not found` when opening permissions for the existing dev installation. ## Reliability and CI diff --git a/apps/storycore-harbour/review/OWNER_ACTIVATION_AND_FIRST_REVIEW.md b/apps/storycore-harbour/review/OWNER_ACTIVATION_AND_FIRST_REVIEW.md index 2bccc82e..c51c890c 100644 --- a/apps/storycore-harbour/review/OWNER_ACTIVATION_AND_FIRST_REVIEW.md +++ b/apps/storycore-harbour/review/OWNER_ACTIVATION_AND_FIRST_REVIEW.md @@ -217,6 +217,13 @@ remote r12 manifest matched the committed Schema 2 Host-API-only boundaries. Keep `review/ANNA_VIEW_MANIFEST_CREDENTIAL_REPORT.md` as the recurrence report; do not send it while the read path remains healthy. +Installed Apps inspection then showed StoryCore Harbour already present as +`v0.0.0-dev`. Do not click **Install & test** again merely because the +Developer card remains at `v0.0.0`. The existing installation's Permissions +panel currently returns `App version not found`; stop there and use +`review/ANNA_DEV_INSTALL_PERMISSIONS_REPORT.md` rather than reinstalling, +uninstalling, cutting a version, or changing grants speculatively. + If the CLI or Console creates an unexpected second App, stop before cutting a version. Preserve the outputs needed for diagnosis, but redact tokens. ## 6. Run StoryCore Harbour against real Anna services From 07897e08c6092439dca98ebd6a92e087b8f5ea47 Mon Sep 17 00:00:00 2001 From: Sylvain Galliez <128614184+zedarvates@users.noreply.github.com> Date: Sat, 29 Aug 2026 18:05:06 +0200 Subject: [PATCH 031/113] feat(game-bridge): add src/game_bridge/__init__.py --- src/game_bridge/__init__.py | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) create mode 100644 src/game_bridge/__init__.py diff --git a/src/game_bridge/__init__.py b/src/game_bridge/__init__.py new file mode 100644 index 00000000..f77b0a2d --- /dev/null +++ b/src/game_bridge/__init__.py @@ -0,0 +1,17 @@ +"""Public, engine-neutral contracts for StoryCore game exports.""" + +from .contract import ( + CONTRACT_VERSION, + ContractError, + compile_spec, + validate_and_normalize, + verify_manifest, +) + +__all__ = [ + "CONTRACT_VERSION", + "ContractError", + "compile_spec", + "validate_and_normalize", + "verify_manifest", +] From 085400765e3ca63636d7f71d8a6d84b8a3ebbd0c Mon Sep 17 00:00:00 2001 From: Sylvain Galliez <128614184+zedarvates@users.noreply.github.com> Date: Sat, 29 Aug 2026 18:05:08 +0200 Subject: [PATCH 032/113] feat(game-bridge): add src/game_bridge/__main__.py --- src/game_bridge/__main__.py | 7 +++++++ 1 file changed, 7 insertions(+) create mode 100644 src/game_bridge/__main__.py diff --git a/src/game_bridge/__main__.py b/src/game_bridge/__main__.py new file mode 100644 index 00000000..a7d2abaa --- /dev/null +++ b/src/game_bridge/__main__.py @@ -0,0 +1,7 @@ +"""Command-line entry point for ``python -m src.game_bridge``.""" + +from .contract import main + + +if __name__ == "__main__": + raise SystemExit(main()) From 06641c20eeb86cfef67e4730e44211fd2fa6271c Mon Sep 17 00:00:00 2001 From: Sylvain Galliez <128614184+zedarvates@users.noreply.github.com> Date: Sat, 29 Aug 2026 18:05:09 +0200 Subject: [PATCH 033/113] feat(game-bridge): add src/game_bridge/contract.py --- src/game_bridge/contract.py | 488 ++++++++++++++++++++++++++++++++++++ 1 file changed, 488 insertions(+) create mode 100644 src/game_bridge/contract.py diff --git a/src/game_bridge/contract.py b/src/game_bridge/contract.py new file mode 100644 index 00000000..c24fa47d --- /dev/null +++ b/src/game_bridge/contract.py @@ -0,0 +1,488 @@ +"""Deterministic StoryCore-to-game manifest compiler. + +This module intentionally contains no Ultimate Odycer server implementation, +private reasoning logic, model credentials, or provider-specific code. It is a +small public interchange contract that a Godot fixture or an external +authoritative backend can consume. +""" + +from __future__ import annotations + +import argparse +import hashlib +import json +import re +from pathlib import Path +from typing import Any, Mapping, Sequence + +CONTRACT_VERSION = "0.1" +COMPILER_ID = "storycore-game-bridge/0.1" + +_ID_PATTERN = re.compile(r"^[a-z0-9][a-z0-9_-]*$") +_LOCALE_PATTERN = re.compile(r"^[a-z]{2}(?:-[A-Z]{2})?$") +_GODOT_VERSION_PATTERN = re.compile(r"^4\.\d+(?:\.\d+)?$") + + +class ContractError(ValueError): + """Raised when a StoryCore game specification fails closed.""" + + +def _fail(path: str, message: str) -> None: + raise ContractError(f"{path}: {message}") + + +def _require_mapping(value: Any, path: str) -> dict[str, Any]: + if not isinstance(value, Mapping): + _fail(path, "must be an object") + return dict(value) + + +def _require_sequence(value: Any, path: str) -> list[Any]: + if not isinstance(value, Sequence) or isinstance(value, (str, bytes, bytearray)): + _fail(path, "must be an array") + return list(value) + + +def _require_exact_keys( + value: Mapping[str, Any], required: set[str], path: str +) -> None: + keys = set(value) + missing = sorted(required - keys) + unknown = sorted(keys - required) + if missing: + _fail(path, f"missing required fields: {', '.join(missing)}") + if unknown: + _fail(path, f"unknown fields: {', '.join(unknown)}") + + +def _require_id(value: Any, path: str) -> str: + if not isinstance(value, str) or not _ID_PATTERN.fullmatch(value): + _fail(path, "must match ^[a-z0-9][a-z0-9_-]*$") + return value + + +def _require_non_empty_string(value: Any, path: str) -> str: + if not isinstance(value, str) or not value.strip(): + _fail(path, "must be a non-empty string") + return value.strip() + + +def _require_positive_int(value: Any, path: str) -> int: + if isinstance(value, bool) or not isinstance(value, int) or value <= 0: + _fail(path, "must be a positive integer") + return value + + +def _require_non_negative_int(value: Any, path: str) -> int: + if isinstance(value, bool) or not isinstance(value, int) or value < 0: + _fail(path, "must be a non-negative integer") + return value + + +def _localized_text(value: Any, locales: list[str], path: str) -> dict[str, str]: + data = _require_mapping(value, path) + expected = set(locales) + actual = set(data) + if actual != expected: + missing = sorted(expected - actual) + unknown = sorted(actual - expected) + details: list[str] = [] + if missing: + details.append(f"missing locales: {', '.join(missing)}") + if unknown: + details.append(f"unknown locales: {', '.join(unknown)}") + _fail(path, "; ".join(details)) + return { + locale: _require_non_empty_string(data[locale], f"{path}.{locale}") + for locale in locales + } + + +def _localized_dialogue( + value: Any, locales: list[str], path: str +) -> dict[str, list[str]]: + data = _require_mapping(value, path) + expected = set(locales) + actual = set(data) + if actual != expected: + _fail(path, "must contain exactly the declared locales") + + normalized: dict[str, list[str]] = {} + for locale in locales: + lines = _require_sequence(data[locale], f"{path}.{locale}") + if not lines: + _fail(f"{path}.{locale}", "must contain at least one line") + normalized[locale] = [ + _require_non_empty_string(line, f"{path}.{locale}[{index}]") + for index, line in enumerate(lines) + ] + return normalized + + +def _unique_ids(entries: list[dict[str, Any]], path: str) -> set[str]: + seen: set[str] = set() + for index, entry in enumerate(entries): + entry_id = entry["id"] + if entry_id in seen: + _fail(f"{path}[{index}].id", f"duplicate id: {entry_id}") + seen.add(entry_id) + return seen + + +def _canonical_bytes(value: Any) -> bytes: + return json.dumps( + value, + ensure_ascii=False, + sort_keys=True, + separators=(",", ":"), + ).encode("utf-8") + + +def _sha256(value: bytes) -> str: + return hashlib.sha256(value).hexdigest() + + +def validate_and_normalize(spec: Mapping[str, Any]) -> dict[str, Any]: + """Validate an interchange specification and return normalized data. + + Validation is deliberately strict. Unknown fields, unresolved references, + partial localizations, and attempts to include private components are + rejected rather than guessed or silently discarded. + """ + + root = _require_mapping(spec, "spec") + top_level = { + "schema_version", + "project", + "locales", + "world", + "actors", + "items", + "quest", + "gameplay", + "runtime", + "boundary", + } + _require_exact_keys(root, top_level, "spec") + + if root["schema_version"] != CONTRACT_VERSION: + _fail( + "spec.schema_version", + f"expected {CONTRACT_VERSION!r}, got {root['schema_version']!r}", + ) + + raw_locales = _require_sequence(root["locales"], "spec.locales") + if not raw_locales: + _fail("spec.locales", "must contain at least one locale") + locales: list[str] = [] + for index, raw_locale in enumerate(raw_locales): + if not isinstance(raw_locale, str) or not _LOCALE_PATTERN.fullmatch(raw_locale): + _fail(f"spec.locales[{index}]", "must be a supported locale code") + if raw_locale in locales: + _fail(f"spec.locales[{index}]", f"duplicate locale: {raw_locale}") + locales.append(raw_locale) + + project = _require_mapping(root["project"], "spec.project") + _require_exact_keys(project, {"id", "title", "summary", "license"}, "spec.project") + if project["license"] != "MIT": + _fail("spec.project.license", "public StoryCore fixtures must use MIT") + normalized_project = { + "id": _require_id(project["id"], "spec.project.id"), + "title": _localized_text(project["title"], locales, "spec.project.title"), + "summary": _localized_text( + project["summary"], locales, "spec.project.summary" + ), + "license": "MIT", + } + + world = _require_mapping(root["world"], "spec.world") + _require_exact_keys(world, {"id", "title", "biome"}, "spec.world") + normalized_world = { + "id": _require_id(world["id"], "spec.world.id"), + "title": _localized_text(world["title"], locales, "spec.world.title"), + "biome": _require_id(world["biome"], "spec.world.biome"), + } + + raw_actors = _require_sequence(root["actors"], "spec.actors") + if not raw_actors: + _fail("spec.actors", "must contain at least one actor") + actors: list[dict[str, Any]] = [] + for index, raw_actor in enumerate(raw_actors): + path = f"spec.actors[{index}]" + actor = _require_mapping(raw_actor, path) + _require_exact_keys(actor, {"id", "name", "role", "dialogue"}, path) + actors.append( + { + "id": _require_id(actor["id"], f"{path}.id"), + "name": _localized_text(actor["name"], locales, f"{path}.name"), + "role": _require_id(actor["role"], f"{path}.role"), + "dialogue": _localized_dialogue( + actor["dialogue"], locales, f"{path}.dialogue" + ), + } + ) + _unique_ids(actors, "spec.actors") + + raw_items = _require_sequence(root["items"], "spec.items") + if not raw_items: + _fail("spec.items", "must contain at least one item") + items: list[dict[str, Any]] = [] + for index, raw_item in enumerate(raw_items): + path = f"spec.items[{index}]" + item = _require_mapping(raw_item, path) + _require_exact_keys(item, {"id", "name", "score"}, path) + items.append( + { + "id": _require_id(item["id"], f"{path}.id"), + "name": _localized_text(item["name"], locales, f"{path}.name"), + "score": _require_non_negative_int(item["score"], f"{path}.score"), + } + ) + item_ids = _unique_ids(items, "spec.items") + + quest = _require_mapping(root["quest"], "spec.quest") + _require_exact_keys(quest, {"id", "title", "objectives", "rewards"}, "spec.quest") + raw_objectives = _require_sequence(quest["objectives"], "spec.quest.objectives") + if not raw_objectives: + _fail("spec.quest.objectives", "must contain at least one objective") + objectives: list[dict[str, Any]] = [] + for index, raw_objective in enumerate(raw_objectives): + path = f"spec.quest.objectives[{index}]" + objective = _require_mapping(raw_objective, path) + _require_exact_keys( + objective, {"id", "kind", "target_id", "required_count"}, path + ) + if objective["kind"] != "collect": + _fail(f"{path}.kind", "v0.1 supports only the collect objective") + target_id = _require_id(objective["target_id"], f"{path}.target_id") + if target_id not in item_ids: + _fail(f"{path}.target_id", f"unresolved item reference: {target_id}") + objectives.append( + { + "id": _require_id(objective["id"], f"{path}.id"), + "kind": "collect", + "target_id": target_id, + "required_count": _require_positive_int( + objective["required_count"], f"{path}.required_count" + ), + } + ) + _unique_ids(objectives, "spec.quest.objectives") + + raw_rewards = _require_sequence(quest["rewards"], "spec.quest.rewards") + rewards: list[dict[str, Any]] = [] + for index, raw_reward in enumerate(raw_rewards): + path = f"spec.quest.rewards[{index}]" + reward = _require_mapping(raw_reward, path) + _require_exact_keys(reward, {"item_id", "count"}, path) + item_id = _require_id(reward["item_id"], f"{path}.item_id") + if item_id not in item_ids: + _fail(f"{path}.item_id", f"unresolved item reference: {item_id}") + rewards.append( + { + "item_id": item_id, + "count": _require_positive_int(reward["count"], f"{path}.count"), + } + ) + normalized_quest = { + "id": _require_id(quest["id"], "spec.quest.id"), + "title": _localized_text(quest["title"], locales, "spec.quest.title"), + "objectives": objectives, + "rewards": rewards, + } + + gameplay = _require_mapping(root["gameplay"], "spec.gameplay") + _require_exact_keys( + gameplay, + {"drop_limit", "score_target", "board_seed", "score_bins"}, + "spec.gameplay", + ) + raw_bins = _require_sequence(gameplay["score_bins"], "spec.gameplay.score_bins") + if len(raw_bins) < 3: + _fail("spec.gameplay.score_bins", "must contain at least three bins") + score_bins = [ + _require_positive_int(value, f"spec.gameplay.score_bins[{index}]") + for index, value in enumerate(raw_bins) + ] + normalized_gameplay = { + "drop_limit": _require_positive_int( + gameplay["drop_limit"], "spec.gameplay.drop_limit" + ), + "score_target": _require_positive_int( + gameplay["score_target"], "spec.gameplay.score_target" + ), + "board_seed": _require_non_negative_int( + gameplay["board_seed"], "spec.gameplay.board_seed" + ), + "score_bins": score_bins, + } + + runtime = _require_mapping(root["runtime"], "spec.runtime") + _require_exact_keys( + runtime, {"engine", "minimum_version", "entry_scene", "authority"}, "spec.runtime" + ) + if runtime["engine"] != "godot": + _fail("spec.runtime.engine", "v0.1 supports only godot") + if runtime["authority"] not in {"local-fixture", "external-contract"}: + _fail( + "spec.runtime.authority", + "must be local-fixture or external-contract", + ) + entry_scene = _require_non_empty_string( + runtime["entry_scene"], "spec.runtime.entry_scene" + ) + if not entry_scene.startswith("res://"): + _fail("spec.runtime.entry_scene", "must be a res:// path") + if ".." in Path(entry_scene.removeprefix("res://")).parts: + _fail("spec.runtime.entry_scene", "must not traverse outside res://") + if not entry_scene.endswith(".tscn"): + _fail("spec.runtime.entry_scene", "must reference a .tscn scene") + minimum_version = _require_non_empty_string( + runtime["minimum_version"], "spec.runtime.minimum_version" + ) + if not _GODOT_VERSION_PATTERN.fullmatch(minimum_version): + _fail( + "spec.runtime.minimum_version", + "must be a Godot 4 version such as 4.2 or 4.2.1", + ) + normalized_runtime = { + "engine": "godot", + "minimum_version": minimum_version, + "entry_scene": entry_scene, + "authority": runtime["authority"], + } + + boundary = _require_mapping(root["boundary"], "spec.boundary") + _require_exact_keys( + boundary, + { + "public_contract_only", + "private_components_included", + "authoritative_backend", + }, + "spec.boundary", + ) + if boundary["public_contract_only"] is not True: + _fail("spec.boundary.public_contract_only", "must be true") + if boundary["private_components_included"] is not False: + _fail("spec.boundary.private_components_included", "must be false") + if boundary["authoritative_backend"] not in {"none-fixture", "external"}: + _fail( + "spec.boundary.authoritative_backend", + "must be none-fixture or external", + ) + normalized_boundary = { + "public_contract_only": True, + "private_components_included": False, + "authoritative_backend": boundary["authoritative_backend"], + } + authority_pair = ( + normalized_runtime["authority"], + normalized_boundary["authoritative_backend"], + ) + if authority_pair not in { + ("local-fixture", "none-fixture"), + ("external-contract", "external"), + }: + _fail( + "spec.boundary.authoritative_backend", + "must match runtime authority (local-fixture/none-fixture or " + "external-contract/external)", + ) + + return { + "schema_version": CONTRACT_VERSION, + "project": normalized_project, + "locales": locales, + "world": normalized_world, + "actors": actors, + "items": items, + "quest": normalized_quest, + "gameplay": normalized_gameplay, + "runtime": normalized_runtime, + "boundary": normalized_boundary, + } + + +def compile_spec(spec: Mapping[str, Any]) -> tuple[dict[str, Any], dict[str, Any]]: + """Compile a validated spec into a manifest and reproducible evidence.""" + + normalized = validate_and_normalize(spec) + input_sha256 = _sha256(_canonical_bytes(normalized)) + manifest_core = { + "contract_version": CONTRACT_VERSION, + "compiled_by": COMPILER_ID, + **normalized, + } + content_sha256 = _sha256(_canonical_bytes(manifest_core)) + manifest = {**manifest_core, "content_sha256": content_sha256} + manifest_bytes = ( + json.dumps(manifest, ensure_ascii=False, sort_keys=True, indent=2) + "\n" + ).encode("utf-8") + evidence = { + "contract_version": CONTRACT_VERSION, + "status": "pass", + "input_sha256": input_sha256, + "content_sha256": content_sha256, + "manifest_sha256": _sha256(manifest_bytes), + "checks": [ + "strict-schema", + "localized-text-complete", + "unique-identifiers", + "references-resolved", + "public-private-boundary", + "deterministic-content-hash", + ], + } + return manifest, evidence + + +def verify_manifest(manifest: Mapping[str, Any]) -> bool: + """Return whether a manifest's embedded content hash is valid.""" + + candidate = _require_mapping(manifest, "manifest") + embedded = candidate.pop("content_sha256", None) + return isinstance(embedded, str) and embedded == _sha256(_canonical_bytes(candidate)) + + +def _load_json(path: Path) -> dict[str, Any]: + try: + loaded = json.loads(path.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError) as exc: + raise ContractError(f"unable to read {path}: {exc}") from exc + return _require_mapping(loaded, str(path)) + + +def _write_json(path: Path, value: Mapping[str, Any]) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text( + json.dumps(value, ensure_ascii=False, sort_keys=True, indent=2) + "\n", + encoding="utf-8", + ) + + +def main(argv: Sequence[str] | None = None) -> int: + parser = argparse.ArgumentParser( + description="Compile a public StoryCore game specification" + ) + parser.add_argument("--input", required=True, type=Path) + parser.add_argument("--output-dir", required=True, type=Path) + args = parser.parse_args(argv) + + try: + manifest, evidence = compile_spec(_load_json(args.input)) + except ContractError as exc: + parser.error(str(exc)) + + manifest_path = args.output_dir / "storycore_game_manifest.json" + evidence_path = args.output_dir / "storycore_game_evidence.json" + _write_json(manifest_path, manifest) + _write_json(evidence_path, evidence) + print(f"manifest={manifest_path}") + print(f"evidence={evidence_path}") + print(f"content_sha256={manifest['content_sha256']}") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) From 456616888d2f9c281f5ac44bd74624c9be2c2c96 Mon Sep 17 00:00:00 2001 From: Sylvain Galliez <128614184+zedarvates@users.noreply.github.com> Date: Sat, 29 Aug 2026 18:05:11 +0200 Subject: [PATCH 034/113] feat(game-bridge): add tests/game_bridge/test_contract.py --- tests/game_bridge/test_contract.py | 126 +++++++++++++++++++++++++++++ 1 file changed, 126 insertions(+) create mode 100644 tests/game_bridge/test_contract.py diff --git a/tests/game_bridge/test_contract.py b/tests/game_bridge/test_contract.py new file mode 100644 index 00000000..fc09f790 --- /dev/null +++ b/tests/game_bridge/test_contract.py @@ -0,0 +1,126 @@ +from __future__ import annotations + +import json +import tempfile +import unittest +from pathlib import Path + +from src.game_bridge.contract import ( + ContractError, + compile_spec, + main, + verify_manifest, +) + + +ROOT = Path(__file__).resolve().parents[2] +FIXTURE = ( + ROOT + / "fixtures" + / "storycore-game" + / "coinfall-chronicle" + / "storycore_game_spec.json" +) +GODOT_FIXTURE = FIXTURE.parent / "godot" + + +def load_fixture() -> dict[str, object]: + return json.loads(FIXTURE.read_text(encoding="utf-8")) + + +class ContractTests(unittest.TestCase): + def test_fixture_compiles_and_verifies(self) -> None: + manifest, evidence = compile_spec(load_fixture()) + + self.assertTrue(verify_manifest(manifest)) + self.assertEqual(evidence["status"], "pass") + self.assertFalse(manifest["boundary"]["private_components_included"]) + self.assertEqual(manifest["runtime"]["authority"], "local-fixture") + + def test_compilation_is_deterministic(self) -> None: + original = load_fixture() + reversed_keys = dict(reversed(list(original.items()))) + + first = compile_spec(original) + second = compile_spec(reversed_keys) + + self.assertEqual(first, second) + + def test_tampered_manifest_fails_verification(self) -> None: + manifest, _ = compile_spec(load_fixture()) + manifest["gameplay"]["score_target"] = 999 + + self.assertFalse(verify_manifest(manifest)) + + def test_unknown_field_is_rejected(self) -> None: + spec = load_fixture() + spec["private_agent_graph"] = {"enabled": True} + + with self.assertRaisesRegex(ContractError, "unknown fields"): + compile_spec(spec) + + def test_private_components_are_rejected(self) -> None: + spec = load_fixture() + spec["boundary"]["private_components_included"] = True + + with self.assertRaisesRegex(ContractError, "must be false"): + compile_spec(spec) + + def test_unresolved_item_reference_is_rejected(self) -> None: + spec = load_fixture() + spec["quest"]["objectives"][0]["target_id"] = "missing_rune" + + with self.assertRaisesRegex(ContractError, "unresolved item reference"): + compile_spec(spec) + + def test_partial_localization_is_rejected(self) -> None: + spec = load_fixture() + del spec["project"]["title"]["fr"] + + with self.assertRaisesRegex(ContractError, "missing locales: fr"): + compile_spec(spec) + + def test_runtime_and_backend_authority_must_match(self) -> None: + spec = load_fixture() + spec["boundary"]["authoritative_backend"] = "external" + + with self.assertRaisesRegex(ContractError, "must match runtime authority"): + compile_spec(spec) + + def test_cli_writes_manifest_and_evidence(self) -> None: + with tempfile.TemporaryDirectory() as directory: + output = Path(directory) + + result = main( + ["--input", str(FIXTURE), "--output-dir", str(output)] + ) + + self.assertEqual(result, 0) + manifest = json.loads( + (output / "storycore_game_manifest.json").read_text(encoding="utf-8") + ) + evidence = json.loads( + (output / "storycore_game_evidence.json").read_text(encoding="utf-8") + ) + self.assertTrue(verify_manifest(manifest)) + self.assertEqual(manifest["content_sha256"], evidence["content_sha256"]) + + def test_checked_in_godot_inputs_match_compiler(self) -> None: + expected_manifest, expected_evidence = compile_spec(load_fixture()) + checked_manifest = json.loads( + (GODOT_FIXTURE / "storycore_game_manifest.json").read_text( + encoding="utf-8" + ) + ) + checked_evidence = json.loads( + (GODOT_FIXTURE / "storycore_game_evidence.json").read_text( + encoding="utf-8" + ) + ) + + self.assertEqual(checked_manifest, expected_manifest) + self.assertEqual(checked_evidence, expected_evidence) + + +if __name__ == "__main__": + unittest.main() From 4bd76acf8456ee880a52176122361a73619b0007 Mon Sep 17 00:00:00 2001 From: Sylvain Galliez <128614184+zedarvates@users.noreply.github.com> Date: Sat, 29 Aug 2026 18:05:12 +0200 Subject: [PATCH 035/113] feat(game-bridge): add fixtures/storycore-game/coinfall-chronicle/README.md --- .../coinfall-chronicle/README.md | 58 +++++++++++++++++++ 1 file changed, 58 insertions(+) create mode 100644 fixtures/storycore-game/coinfall-chronicle/README.md diff --git a/fixtures/storycore-game/coinfall-chronicle/README.md b/fixtures/storycore-game/coinfall-chronicle/README.md new file mode 100644 index 00000000..3c943cba --- /dev/null +++ b/fixtures/storycore-game/coinfall-chronicle/README.md @@ -0,0 +1,58 @@ +# Coinfall Chronicle + +Coinfall Chronicle is an original, dependency-free vertical slice for the +public StoryCore-to-game contract. It turns a localized world, actor, item, +quest, reward, and deterministic gameplay seed into a small Godot 4.2 project. +The artwork is made from procedural shapes; no third-party game or asset is +copied. + +## What this proves + +- A StoryCore game specification can fail closed on unknown fields, broken + references, incomplete localization, and private-component leakage. +- The same normalized input produces the same SHA-256-addressed manifest. +- A Godot consumer can verify the embedded hash before loading any gameplay. +- The fixture works without an account, network service, model, or proprietary + backend. + +It does **not** contain or describe the Ultimate Odycer commercial server, +internal agents/reasoners, private algorithms, credentials, or provider +adapters. A production server may implement the public `external-contract` +authority boundary without becoming part of this MIT fixture. + +## Compile and test + +From the repository root: + +```bash +python -m src.game_bridge \ + --input fixtures/storycore-game/coinfall-chronicle/storycore_game_spec.json \ + --output-dir fixtures/storycore-game/coinfall-chronicle/godot +python -m unittest discover -s tests/game_bridge -v +``` + +The compiler writes `storycore_game_manifest.json` plus a reproducible evidence +record. The test suite also detects when the checked-in Godot inputs drift from +the compiler output. + +## Play + +Open `godot/project.godot` with Godot 4.2 or newer and run the main scene. + +- Click or press Space to drop a rune. +- Press L to switch between English and French. +- Recover at least five runes and reach 100 points within twelve drops. +- Press R to restart. + +## Acceptance gate + +| Check | Local status | Promotion requirement | +|---|---|---| +| Strict contract and negative cases | Automated | All unit tests pass | +| Deterministic manifest and evidence | Automated | Checked-in outputs match | +| Manifest tamper detection | Automated | Modified content is rejected | +| Godot import and script parse | Pending runtime | Godot 4.2 self-hosted job passes | +| Full quest play-through | Pending runtime | Automated or recorded smoke test passes | +| Private/public boundary | Automated + review | No private component or secret is present | + +The fixture must remain experimental until the two Godot runtime checks pass. From 238d01138d1185d48e27710944ce58a7316e6047 Mon Sep 17 00:00:00 2001 From: Sylvain Galliez <128614184+zedarvates@users.noreply.github.com> Date: Sat, 29 Aug 2026 18:05:14 +0200 Subject: [PATCH 036/113] feat(game-bridge): add fixtures/storycore-game/coinfall-chronicle/storycore_game_spec.json --- .../storycore_game_spec.json | 109 ++++++++++++++++++ 1 file changed, 109 insertions(+) create mode 100644 fixtures/storycore-game/coinfall-chronicle/storycore_game_spec.json diff --git a/fixtures/storycore-game/coinfall-chronicle/storycore_game_spec.json b/fixtures/storycore-game/coinfall-chronicle/storycore_game_spec.json new file mode 100644 index 00000000..4eab293e --- /dev/null +++ b/fixtures/storycore-game/coinfall-chronicle/storycore_game_spec.json @@ -0,0 +1,109 @@ +{ + "actors": [ + { + "dialogue": { + "en": [ + "Guide the moon runes through the orchard's old wards.", + "Five recovered runes will relight the chronicle." + ], + "fr": [ + "Guide les runes lunaires à travers les anciennes protections du verger.", + "Cinq runes retrouvées rallumeront la chronique." + ] + }, + "id": "lyra", + "name": { + "en": "Lyra, Keeper of Embers", + "fr": "Lyra, gardienne des braises" + }, + "role": "quest_giver" + } + ], + "boundary": { + "authoritative_backend": "none-fixture", + "private_components_included": false, + "public_contract_only": true + }, + "gameplay": { + "board_seed": 240821, + "drop_limit": 12, + "score_bins": [ + 10, + 20, + 50, + 20, + 10 + ], + "score_target": 100 + }, + "items": [ + { + "id": "moon_rune", + "name": { + "en": "Moon Rune", + "fr": "Rune lunaire" + }, + "score": 10 + }, + { + "id": "ember_seal", + "name": { + "en": "Ember Seal", + "fr": "Sceau de braise" + }, + "score": 0 + } + ], + "locales": [ + "en", + "fr" + ], + "project": { + "id": "coinfall_chronicle", + "license": "MIT", + "summary": { + "en": "A tiny deterministic rune-drop quest proving the public StoryCore-to-game contract.", + "fr": "Une courte quête déterministe de runes démontrant le contrat public StoryCore-vers-jeu." + }, + "title": { + "en": "Coinfall Chronicle", + "fr": "La Chronique des runes" + } + }, + "quest": { + "id": "relight_the_chronicle", + "objectives": [ + { + "id": "recover_moon_runes", + "kind": "collect", + "required_count": 5, + "target_id": "moon_rune" + } + ], + "rewards": [ + { + "count": 1, + "item_id": "ember_seal" + } + ], + "title": { + "en": "Relight the Chronicle", + "fr": "Rallumer la chronique" + } + }, + "runtime": { + "authority": "local-fixture", + "engine": "godot", + "entry_scene": "res://Main.tscn", + "minimum_version": "4.2" + }, + "schema_version": "0.1", + "world": { + "biome": "arcane_orchard", + "id": "ember_orchard", + "title": { + "en": "The Ember Orchard", + "fr": "Le Verger des braises" + } + } +} From 328fdb8a52c9e57b06d9f70b4b8277782c8d4682 Mon Sep 17 00:00:00 2001 From: Sylvain Galliez <128614184+zedarvates@users.noreply.github.com> Date: Sat, 29 Aug 2026 18:05:15 +0200 Subject: [PATCH 037/113] feat(game-bridge): add fixtures/storycore-game/coinfall-chronicle/godot/project.godot --- .../coinfall-chronicle/godot/project.godot | 23 +++++++++++++++++++ 1 file changed, 23 insertions(+) create mode 100644 fixtures/storycore-game/coinfall-chronicle/godot/project.godot diff --git a/fixtures/storycore-game/coinfall-chronicle/godot/project.godot b/fixtures/storycore-game/coinfall-chronicle/godot/project.godot new file mode 100644 index 00000000..4480ba58 --- /dev/null +++ b/fixtures/storycore-game/coinfall-chronicle/godot/project.godot @@ -0,0 +1,23 @@ +; Engine configuration file. +; Edit through Godot when possible; hand edits should remain deterministic. + +config_version=5 + +[application] + +config/name="Coinfall Chronicle" +run/main_scene="res://Main.tscn" + +[display] + +window/size/viewport_width=960 +window/size/viewport_height=720 +window/size/window_width_override=960 +window/size/window_height_override=720 +window/stretch/mode="canvas_items" + +[rendering] + +renderer/rendering_method="gl_compatibility" +renderer/rendering_method.mobile="gl_compatibility" +textures/default_filters/use_nearest_mipmap_filter=false From 0e3bef152f3e996ed6b6859108f2f2c627e58d68 Mon Sep 17 00:00:00 2001 From: Sylvain Galliez <128614184+zedarvates@users.noreply.github.com> Date: Sat, 29 Aug 2026 18:05:17 +0200 Subject: [PATCH 038/113] feat(game-bridge): add fixtures/storycore-game/coinfall-chronicle/godot/Main.tscn --- fixtures/storycore-game/coinfall-chronicle/godot/Main.tscn | 6 ++++++ 1 file changed, 6 insertions(+) create mode 100644 fixtures/storycore-game/coinfall-chronicle/godot/Main.tscn diff --git a/fixtures/storycore-game/coinfall-chronicle/godot/Main.tscn b/fixtures/storycore-game/coinfall-chronicle/godot/Main.tscn new file mode 100644 index 00000000..2a2fdbac --- /dev/null +++ b/fixtures/storycore-game/coinfall-chronicle/godot/Main.tscn @@ -0,0 +1,6 @@ +[gd_scene load_steps=2 format=3] + +[ext_resource type="Script" path="res://Main.gd" id="1_main"] + +[node name="CoinfallChronicle" type="Node2D"] +script = ExtResource("1_main") From fd07c88713bc6c135103d1369e438cba7cbb628c Mon Sep 17 00:00:00 2001 From: Sylvain Galliez <128614184+zedarvates@users.noreply.github.com> Date: Sat, 29 Aug 2026 18:05:19 +0200 Subject: [PATCH 039/113] feat(game-bridge): add fixtures/storycore-game/coinfall-chronicle/godot/Main.gd --- .../coinfall-chronicle/godot/Main.gd | 391 ++++++++++++++++++ 1 file changed, 391 insertions(+) create mode 100644 fixtures/storycore-game/coinfall-chronicle/godot/Main.gd diff --git a/fixtures/storycore-game/coinfall-chronicle/godot/Main.gd b/fixtures/storycore-game/coinfall-chronicle/godot/Main.gd new file mode 100644 index 00000000..5c220b0c --- /dev/null +++ b/fixtures/storycore-game/coinfall-chronicle/godot/Main.gd @@ -0,0 +1,391 @@ +extends Node2D + +const MANIFEST_PATH := "res://storycore_game_manifest.json" +const CONTRACT_VERSION := "0.1" +const BOARD_LEFT := 110.0 +const BOARD_RIGHT := 850.0 +const DROP_Y := 150.0 +const BIN_Y := 625.0 + +var manifest: Dictionary = {} +var locale := "en" +var score := 0 +var collected := 0 +var drop_count := 0 +var run_ended := false +var rng := RandomNumberGenerator.new() + +var title_label: Label +var instruction_label: Label +var quest_label: Label +var message_label: Label + + +func _ready() -> void: + if not _load_and_verify_manifest(): + return + rng.seed = int(manifest["gameplay"]["board_seed"]) + _create_background() + _create_board() + _create_interface() + _update_interface() + + +func _unhandled_input(event: InputEvent) -> void: + if event is InputEventKey and event.pressed and not event.echo: + if event.keycode == KEY_L: + locale = "fr" if locale == "en" else "en" + _update_interface() + return + if event.keycode == KEY_R: + get_tree().reload_current_scene() + return + if event.keycode == KEY_SPACE: + _drop_rune(rng.randf_range(BOARD_LEFT + 35.0, BOARD_RIGHT - 35.0)) + return + if event is InputEventMouseButton and event.pressed: + if event.button_index == MOUSE_BUTTON_LEFT: + _drop_rune(clampf(event.position.x, BOARD_LEFT + 25.0, BOARD_RIGHT - 25.0)) + if event is InputEventScreenTouch and event.pressed: + _drop_rune(clampf(event.position.x, BOARD_LEFT + 25.0, BOARD_RIGHT - 25.0)) + + +func _physics_process(_delta: float) -> void: + for rune in get_tree().get_nodes_in_group("active_runes"): + if rune.position.y > 780.0: + rune.queue_free() + + +func _load_and_verify_manifest() -> bool: + if not FileAccess.file_exists(MANIFEST_PATH): + _show_fatal("Manifest missing: " + MANIFEST_PATH) + return false + var file := FileAccess.open(MANIFEST_PATH, FileAccess.READ) + if file == null: + _show_fatal("Manifest cannot be opened.") + return false + var parsed: Variant = JSON.parse_string(file.get_as_text()) + if typeof(parsed) != TYPE_DICTIONARY: + _show_fatal("Manifest is not a JSON object.") + return false + manifest = parsed + var required := [ + "contract_version", "compiled_by", "content_sha256", "project", + "locales", "world", "actors", "items", "quest", "gameplay", + "runtime", "boundary" + ] + for key in required: + if not manifest.has(key): + _show_fatal("Manifest field missing: " + str(key)) + return false + if str(manifest["contract_version"]) != CONTRACT_VERSION: + _show_fatal("Unsupported contract version.") + return false + var boundary: Dictionary = manifest["boundary"] + if boundary.get("public_contract_only") != true: + _show_fatal("Public contract marker missing.") + return false + if boundary.get("private_components_included") != false: + _show_fatal("Private components are forbidden in this fixture.") + return false + if str(manifest["runtime"].get("authority", "")) != "local-fixture": + _show_fatal("This fixture accepts local-fixture authority only.") + return false + var core := manifest.duplicate(true) + var expected_hash := str(core.get("content_sha256", "")) + core.erase("content_sha256") + if _sha256(_canonical_json(core)) != expected_hash: + _show_fatal("Manifest content hash mismatch.") + return false + return true + + +func _canonical_json(value: Variant) -> String: + match typeof(value): + TYPE_DICTIONARY: + var keys: Array = value.keys() + keys.sort() + var pairs := PackedStringArray() + for key in keys: + pairs.append(JSON.stringify(str(key)) + ":" + _canonical_json(value[key])) + return "{" + ",".join(pairs) + "}" + TYPE_ARRAY: + var entries := PackedStringArray() + for entry in value: + entries.append(_canonical_json(entry)) + return "[" + ",".join(entries) + "]" + TYPE_FLOAT: + # Godot may parse integral JSON numbers as floats. The compiler's + # canonical JSON writes them without a decimal suffix. + if value == floor(value): + return str(int(value)) + return JSON.stringify(value) + _: + return JSON.stringify(value) + + +func _sha256(text: String) -> String: + var context := HashingContext.new() + context.start(HashingContext.HASH_SHA256) + context.update(text.to_utf8_buffer()) + return context.finish().hex_encode() + + +func _show_fatal(message: String) -> void: + var backdrop := ColorRect.new() + backdrop.color = Color("15101f") + backdrop.size = Vector2(960.0, 720.0) + add_child(backdrop) + var label := Label.new() + label.text = "Coinfall Chronicle stopped safely\n\n" + message + label.position = Vector2(120.0, 280.0) + label.size = Vector2(720.0, 160.0) + label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER + label.add_theme_font_size_override("font_size", 24) + label.add_theme_color_override("font_color", Color("ffb4ab")) + add_child(label) + set_process_unhandled_input(false) + set_physics_process(false) + + +func _create_background() -> void: + var backdrop := Polygon2D.new() + backdrop.polygon = PackedVector2Array([ + Vector2.ZERO, Vector2(960.0, 0.0), Vector2(960.0, 720.0), Vector2(0.0, 720.0) + ]) + backdrop.color = Color("171124") + backdrop.z_index = -10 + add_child(backdrop) + var board_glow := Polygon2D.new() + board_glow.polygon = _rectangle_points(Vector2(760.0, 520.0)) + board_glow.position = Vector2(480.0, 405.0) + board_glow.color = Color("241936") + board_glow.z_index = -9 + add_child(board_glow) + + +func _create_board() -> void: + _make_wall(Vector2(BOARD_LEFT, 405.0), Vector2(14.0, 510.0), Color("8d6bb8")) + _make_wall(Vector2(BOARD_RIGHT, 405.0), Vector2(14.0, 510.0), Color("8d6bb8")) + for row in range(7): + var columns := 9 if row % 2 == 0 else 8 + var offset := 0.0 if row % 2 == 0 else 40.0 + for column in range(columns): + var peg_x := 160.0 + offset + float(column) * 80.0 + var peg_y := 235.0 + float(row) * 48.0 + _make_peg(Vector2(peg_x, peg_y)) + _create_bins() + + +func _create_bins() -> void: + var scores: Array = manifest["gameplay"]["score_bins"] + var width := (BOARD_RIGHT - BOARD_LEFT) / float(scores.size()) + for index in range(scores.size()): + var center_x := BOARD_LEFT + width * (float(index) + 0.5) + var area := Area2D.new() + area.position = Vector2(center_x, BIN_Y) + area.collision_layer = 0 + area.collision_mask = 1 + var collision := CollisionShape2D.new() + var shape := RectangleShape2D.new() + shape.size = Vector2(width - 8.0, 58.0) + collision.shape = shape + area.add_child(collision) + area.body_entered.connect(_on_bin_entered.bind(index)) + add_child(area) + var tile := Polygon2D.new() + tile.polygon = _rectangle_points(Vector2(width - 8.0, 58.0)) + tile.color = Color("4b3567") if index != 2 else Color("7552a3") + area.add_child(tile) + var label := Label.new() + label.text = "+" + str(scores[index]) + label.position = Vector2(-width * 0.5, -13.0) + label.size = Vector2(width, 30.0) + label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER + label.add_theme_font_size_override("font_size", 18) + label.add_theme_color_override("font_color", Color("f7d67a")) + area.add_child(label) + if scores.size() > 1: + for divider in range(1, scores.size()): + var divider_x := BOARD_LEFT + width * float(divider) + _make_wall(Vector2(divider_x, 585.0), Vector2(8.0, 95.0), Color("8d6bb8")) + + +func _make_peg(position_value: Vector2) -> void: + var body := StaticBody2D.new() + body.position = position_value + var collision := CollisionShape2D.new() + var circle := CircleShape2D.new() + circle.radius = 9.0 + collision.shape = circle + body.add_child(collision) + var visual := Polygon2D.new() + visual.polygon = _circle_points(9.0, 16) + visual.color = Color("c8a9eb") + body.add_child(visual) + add_child(body) + + +func _make_wall(position_value: Vector2, size: Vector2, color: Color) -> void: + var body := StaticBody2D.new() + body.position = position_value + var collision := CollisionShape2D.new() + var rectangle := RectangleShape2D.new() + rectangle.size = size + collision.shape = rectangle + body.add_child(collision) + var visual := Polygon2D.new() + visual.polygon = _rectangle_points(size) + visual.color = color + body.add_child(visual) + add_child(body) + + +func _drop_rune(x_position: float) -> void: + if run_ended or drop_count >= int(manifest["gameplay"]["drop_limit"]): + return + drop_count += 1 + var body := RigidBody2D.new() + body.position = Vector2(x_position, DROP_Y) + body.collision_layer = 1 + body.collision_mask = 1 + body.gravity_scale = 0.92 + body.mass = 0.8 + body.continuous_cd = RigidBody2D.CCD_MODE_CAST_RAY + body.angular_velocity = rng.randf_range(-2.0, 2.0) + body.set_meta("resolved", false) + body.add_to_group("active_runes") + var collision := CollisionShape2D.new() + var circle := CircleShape2D.new() + circle.radius = 14.0 + collision.shape = circle + body.add_child(collision) + var visual := Polygon2D.new() + visual.polygon = _circle_points(14.0, 20) + visual.color = Color("79d5ff") + body.add_child(visual) + var core := Polygon2D.new() + core.polygon = _circle_points(6.0, 12) + core.color = Color("f6e8ff") + body.add_child(core) + add_child(body) + _update_interface() + + +func _on_bin_entered(body: Node2D, bin_index: int) -> void: + if not body.is_in_group("active_runes") or bool(body.get_meta("resolved", false)): + return + body.set_meta("resolved", true) + var scores: Array = manifest["gameplay"]["score_bins"] + score += int(scores[bin_index]) + collected += 1 + body.queue_free() + _update_interface() + _evaluate_run() + + +func _evaluate_run() -> void: + var objective: Dictionary = manifest["quest"]["objectives"][0] + var objective_done := collected >= int(objective["required_count"]) + var score_done := score >= int(manifest["gameplay"]["score_target"]) + if objective_done and score_done: + run_ended = true + var reward: Dictionary = manifest["quest"]["rewards"][0] + var reward_name := _item_name(str(reward["item_id"])) + message_label.text = ( + ("Quest complete — Reward: " if locale == "en" else "Quête accomplie — Récompense : ") + + str(reward["count"]) + " × " + reward_name + " [R]" + ) + message_label.add_theme_color_override("font_color", Color("8ff0a4")) + elif drop_count >= int(manifest["gameplay"]["drop_limit"]): + run_ended = true + message_label.text = ( + "Run ended — press R to retry." + if locale == "en" + else "Partie terminée — appuie sur R pour recommencer." + ) + message_label.add_theme_color_override("font_color", Color("ffb4ab")) + + +func _create_interface() -> void: + title_label = Label.new() + title_label.position = Vector2(30.0, 16.0) + title_label.size = Vector2(900.0, 38.0) + title_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER + title_label.add_theme_font_size_override("font_size", 28) + title_label.add_theme_color_override("font_color", Color("f6e8ff")) + add_child(title_label) + instruction_label = Label.new() + instruction_label.position = Vector2(30.0, 55.0) + instruction_label.size = Vector2(900.0, 30.0) + instruction_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER + instruction_label.add_theme_color_override("font_color", Color("c8a9eb")) + add_child(instruction_label) + quest_label = Label.new() + quest_label.position = Vector2(35.0, 92.0) + quest_label.size = Vector2(890.0, 48.0) + quest_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER + quest_label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART + quest_label.add_theme_font_size_override("font_size", 17) + quest_label.add_theme_color_override("font_color", Color("f7d67a")) + add_child(quest_label) + message_label = Label.new() + message_label.position = Vector2(30.0, 684.0) + message_label.size = Vector2(900.0, 28.0) + message_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER + message_label.add_theme_color_override("font_color", Color("d7c8e8")) + add_child(message_label) + + +func _update_interface() -> void: + if title_label == null: + return + title_label.text = _localized(manifest["project"]["title"]) + instruction_label.text = ( + "Click or Space: drop rune • L: français • R: restart" + if locale == "en" + else "Clic ou Espace : lâcher une rune • L : English • R : recommencer" + ) + var objective: Dictionary = manifest["quest"]["objectives"][0] + var quest_title := _localized(manifest["quest"]["title"]) + if locale == "en": + quest_label.text = "%s • Runes %d/%d • Score %d/%d • Drops %d/%d" % [ + quest_title, collected, int(objective["required_count"]), score, + int(manifest["gameplay"]["score_target"]), drop_count, + int(manifest["gameplay"]["drop_limit"]) + ] + else: + quest_label.text = "%s • Runes %d/%d • Score %d/%d • Lancers %d/%d" % [ + quest_title, collected, int(objective["required_count"]), score, + int(manifest["gameplay"]["score_target"]), drop_count, + int(manifest["gameplay"]["drop_limit"]) + ] + if not run_ended: + message_label.text = _localized(manifest["actors"][0]["dialogue"])[0] + + +func _localized(values: Dictionary) -> Variant: + return values.get(locale, values.get("en", "")) + + +func _item_name(item_id: String) -> String: + for item in manifest["items"]: + if str(item["id"]) == item_id: + return str(_localized(item["name"])) + return item_id + + +func _rectangle_points(size: Vector2) -> PackedVector2Array: + var half := size * 0.5 + return PackedVector2Array([ + Vector2(-half.x, -half.y), Vector2(half.x, -half.y), + Vector2(half.x, half.y), Vector2(-half.x, half.y) + ]) + + +func _circle_points(radius: float, sides: int) -> PackedVector2Array: + var points := PackedVector2Array() + for index in range(sides): + var angle := TAU * float(index) / float(sides) + points.append(Vector2(cos(angle), sin(angle)) * radius) + return points From f515a496ccfd23f642f6bb2adef374c4f404fa7a Mon Sep 17 00:00:00 2001 From: Sylvain Galliez <128614184+zedarvates@users.noreply.github.com> Date: Sat, 29 Aug 2026 18:05:21 +0200 Subject: [PATCH 040/113] feat(game-bridge): add fixtures/storycore-game/coinfall-chronicle/godot/storycore_game_manifest.json --- .../godot/storycore_game_manifest.json | 112 ++++++++++++++++++ 1 file changed, 112 insertions(+) create mode 100644 fixtures/storycore-game/coinfall-chronicle/godot/storycore_game_manifest.json diff --git a/fixtures/storycore-game/coinfall-chronicle/godot/storycore_game_manifest.json b/fixtures/storycore-game/coinfall-chronicle/godot/storycore_game_manifest.json new file mode 100644 index 00000000..9a2ecd07 --- /dev/null +++ b/fixtures/storycore-game/coinfall-chronicle/godot/storycore_game_manifest.json @@ -0,0 +1,112 @@ +{ + "actors": [ + { + "dialogue": { + "en": [ + "Guide the moon runes through the orchard's old wards.", + "Five recovered runes will relight the chronicle." + ], + "fr": [ + "Guide les runes lunaires à travers les anciennes protections du verger.", + "Cinq runes retrouvées rallumeront la chronique." + ] + }, + "id": "lyra", + "name": { + "en": "Lyra, Keeper of Embers", + "fr": "Lyra, gardienne des braises" + }, + "role": "quest_giver" + } + ], + "boundary": { + "authoritative_backend": "none-fixture", + "private_components_included": false, + "public_contract_only": true + }, + "compiled_by": "storycore-game-bridge/0.1", + "content_sha256": "a75e7e506f27cee8e6bec9bcb65054ad264808c3c01508ee4de3e8645196c43a", + "contract_version": "0.1", + "gameplay": { + "board_seed": 240821, + "drop_limit": 12, + "score_bins": [ + 10, + 20, + 50, + 20, + 10 + ], + "score_target": 100 + }, + "items": [ + { + "id": "moon_rune", + "name": { + "en": "Moon Rune", + "fr": "Rune lunaire" + }, + "score": 10 + }, + { + "id": "ember_seal", + "name": { + "en": "Ember Seal", + "fr": "Sceau de braise" + }, + "score": 0 + } + ], + "locales": [ + "en", + "fr" + ], + "project": { + "id": "coinfall_chronicle", + "license": "MIT", + "summary": { + "en": "A tiny deterministic rune-drop quest proving the public StoryCore-to-game contract.", + "fr": "Une courte quête déterministe de runes démontrant le contrat public StoryCore-vers-jeu." + }, + "title": { + "en": "Coinfall Chronicle", + "fr": "La Chronique des runes" + } + }, + "quest": { + "id": "relight_the_chronicle", + "objectives": [ + { + "id": "recover_moon_runes", + "kind": "collect", + "required_count": 5, + "target_id": "moon_rune" + } + ], + "rewards": [ + { + "count": 1, + "item_id": "ember_seal" + } + ], + "title": { + "en": "Relight the Chronicle", + "fr": "Rallumer la chronique" + } + }, + "runtime": { + "authority": "local-fixture", + "engine": "godot", + "entry_scene": "res://Main.tscn", + "minimum_version": "4.2" + }, + "schema_version": "0.1", + "world": { + "biome": "arcane_orchard", + "id": "ember_orchard", + "title": { + "en": "The Ember Orchard", + "fr": "Le Verger des braises" + } + } +} From 62a9fe4907a989a4e11c80a8225faa568336beb8 Mon Sep 17 00:00:00 2001 From: Sylvain Galliez <128614184+zedarvates@users.noreply.github.com> Date: Sat, 29 Aug 2026 18:05:22 +0200 Subject: [PATCH 041/113] feat(game-bridge): add fixtures/storycore-game/coinfall-chronicle/godot/storycore_game_evidence.json --- .../godot/storycore_game_evidence.json | 15 +++++++++++++++ 1 file changed, 15 insertions(+) create mode 100644 fixtures/storycore-game/coinfall-chronicle/godot/storycore_game_evidence.json diff --git a/fixtures/storycore-game/coinfall-chronicle/godot/storycore_game_evidence.json b/fixtures/storycore-game/coinfall-chronicle/godot/storycore_game_evidence.json new file mode 100644 index 00000000..b37af732 --- /dev/null +++ b/fixtures/storycore-game/coinfall-chronicle/godot/storycore_game_evidence.json @@ -0,0 +1,15 @@ +{ + "checks": [ + "strict-schema", + "localized-text-complete", + "unique-identifiers", + "references-resolved", + "public-private-boundary", + "deterministic-content-hash" + ], + "content_sha256": "a75e7e506f27cee8e6bec9bcb65054ad264808c3c01508ee4de3e8645196c43a", + "contract_version": "0.1", + "input_sha256": "6d20fa71aa0ad3d6c722e03bb38c29435acd3815f69cbb5e6701daa37b7d3a81", + "manifest_sha256": "21359cddd04a857dcee60c8ba9f320e3edc7d389a22dd61867318898201e1614", + "status": "pass" +} From e97aec12b8b9a0139dcd4559c4d92a56f8523b55 Mon Sep 17 00:00:00 2001 From: Sylvain Galliez <128614184+zedarvates@users.noreply.github.com> Date: Sun, 30 Aug 2026 10:55:15 +0200 Subject: [PATCH 042/113] fix(game-bridge): constrain CLI paths and simplify validation --- src/game_bridge/contract.py | 474 +++++++++++++++++++++--------------- 1 file changed, 279 insertions(+), 195 deletions(-) diff --git a/src/game_bridge/contract.py b/src/game_bridge/contract.py index c24fa47d..a8d58cb8 100644 --- a/src/game_bridge/contract.py +++ b/src/game_bridge/contract.py @@ -21,6 +21,10 @@ _ID_PATTERN = re.compile(r"^[a-z0-9][a-z0-9_-]*$") _LOCALE_PATTERN = re.compile(r"^[a-z]{2}(?:-[A-Z]{2})?$") _GODOT_VERSION_PATTERN = re.compile(r"^4\.\d+(?:\.\d+)?$") +_ACTORS_PATH = "spec.actors" +_ITEMS_PATH = "spec.items" +_OBJECTIVES_PATH = "spec.quest.objectives" +_ENTRY_SCENE_PATH = "spec.runtime.entry_scene" class ContractError(ValueError): @@ -142,217 +146,223 @@ def _sha256(value: bytes) -> str: return hashlib.sha256(value).hexdigest() -def validate_and_normalize(spec: Mapping[str, Any]) -> dict[str, Any]: - """Validate an interchange specification and return normalized data. - - Validation is deliberately strict. Unknown fields, unresolved references, - partial localizations, and attempts to include private components are - rejected rather than guessed or silently discarded. - """ - - root = _require_mapping(spec, "spec") - top_level = { - "schema_version", - "project", - "locales", - "world", - "actors", - "items", - "quest", - "gameplay", - "runtime", - "boundary", - } - _require_exact_keys(root, top_level, "spec") - - if root["schema_version"] != CONTRACT_VERSION: - _fail( - "spec.schema_version", - f"expected {CONTRACT_VERSION!r}, got {root['schema_version']!r}", - ) - - raw_locales = _require_sequence(root["locales"], "spec.locales") +def _normalize_locales(value: Any) -> list[str]: + path = "spec.locales" + raw_locales = _require_sequence(value, path) if not raw_locales: - _fail("spec.locales", "must contain at least one locale") + _fail(path, "must contain at least one locale") locales: list[str] = [] for index, raw_locale in enumerate(raw_locales): + entry_path = f"{path}[{index}]" if not isinstance(raw_locale, str) or not _LOCALE_PATTERN.fullmatch(raw_locale): - _fail(f"spec.locales[{index}]", "must be a supported locale code") + _fail(entry_path, "must be a supported locale code") if raw_locale in locales: - _fail(f"spec.locales[{index}]", f"duplicate locale: {raw_locale}") + _fail(entry_path, f"duplicate locale: {raw_locale}") locales.append(raw_locale) + return locales + - project = _require_mapping(root["project"], "spec.project") - _require_exact_keys(project, {"id", "title", "summary", "license"}, "spec.project") +def _normalize_project(value: Any, locales: list[str]) -> dict[str, Any]: + path = "spec.project" + project = _require_mapping(value, path) + _require_exact_keys(project, {"id", "title", "summary", "license"}, path) if project["license"] != "MIT": - _fail("spec.project.license", "public StoryCore fixtures must use MIT") - normalized_project = { - "id": _require_id(project["id"], "spec.project.id"), - "title": _localized_text(project["title"], locales, "spec.project.title"), - "summary": _localized_text( - project["summary"], locales, "spec.project.summary" - ), + _fail(f"{path}.license", "public StoryCore fixtures must use MIT") + return { + "id": _require_id(project["id"], f"{path}.id"), + "title": _localized_text(project["title"], locales, f"{path}.title"), + "summary": _localized_text(project["summary"], locales, f"{path}.summary"), "license": "MIT", } - world = _require_mapping(root["world"], "spec.world") - _require_exact_keys(world, {"id", "title", "biome"}, "spec.world") - normalized_world = { - "id": _require_id(world["id"], "spec.world.id"), - "title": _localized_text(world["title"], locales, "spec.world.title"), - "biome": _require_id(world["biome"], "spec.world.biome"), + +def _normalize_world(value: Any, locales: list[str]) -> dict[str, Any]: + path = "spec.world" + world = _require_mapping(value, path) + _require_exact_keys(world, {"id", "title", "biome"}, path) + return { + "id": _require_id(world["id"], f"{path}.id"), + "title": _localized_text(world["title"], locales, f"{path}.title"), + "biome": _require_id(world["biome"], f"{path}.biome"), + } + + +def _normalize_actor(value: Any, index: int, locales: list[str]) -> dict[str, Any]: + path = f"{_ACTORS_PATH}[{index}]" + actor = _require_mapping(value, path) + _require_exact_keys(actor, {"id", "name", "role", "dialogue"}, path) + return { + "id": _require_id(actor["id"], f"{path}.id"), + "name": _localized_text(actor["name"], locales, f"{path}.name"), + "role": _require_id(actor["role"], f"{path}.role"), + "dialogue": _localized_dialogue( + actor["dialogue"], locales, f"{path}.dialogue" + ), } - raw_actors = _require_sequence(root["actors"], "spec.actors") + +def _normalize_actors(value: Any, locales: list[str]) -> list[dict[str, Any]]: + raw_actors = _require_sequence(value, _ACTORS_PATH) if not raw_actors: - _fail("spec.actors", "must contain at least one actor") - actors: list[dict[str, Any]] = [] - for index, raw_actor in enumerate(raw_actors): - path = f"spec.actors[{index}]" - actor = _require_mapping(raw_actor, path) - _require_exact_keys(actor, {"id", "name", "role", "dialogue"}, path) - actors.append( - { - "id": _require_id(actor["id"], f"{path}.id"), - "name": _localized_text(actor["name"], locales, f"{path}.name"), - "role": _require_id(actor["role"], f"{path}.role"), - "dialogue": _localized_dialogue( - actor["dialogue"], locales, f"{path}.dialogue" - ), - } - ) - _unique_ids(actors, "spec.actors") + _fail(_ACTORS_PATH, "must contain at least one actor") + actors = [ + _normalize_actor(raw_actor, index, locales) + for index, raw_actor in enumerate(raw_actors) + ] + _unique_ids(actors, _ACTORS_PATH) + return actors + + +def _normalize_item(value: Any, index: int, locales: list[str]) -> dict[str, Any]: + path = f"{_ITEMS_PATH}[{index}]" + item = _require_mapping(value, path) + _require_exact_keys(item, {"id", "name", "score"}, path) + return { + "id": _require_id(item["id"], f"{path}.id"), + "name": _localized_text(item["name"], locales, f"{path}.name"), + "score": _require_non_negative_int(item["score"], f"{path}.score"), + } - raw_items = _require_sequence(root["items"], "spec.items") + +def _normalize_items( + value: Any, locales: list[str] +) -> tuple[list[dict[str, Any]], set[str]]: + raw_items = _require_sequence(value, _ITEMS_PATH) if not raw_items: - _fail("spec.items", "must contain at least one item") - items: list[dict[str, Any]] = [] - for index, raw_item in enumerate(raw_items): - path = f"spec.items[{index}]" - item = _require_mapping(raw_item, path) - _require_exact_keys(item, {"id", "name", "score"}, path) - items.append( - { - "id": _require_id(item["id"], f"{path}.id"), - "name": _localized_text(item["name"], locales, f"{path}.name"), - "score": _require_non_negative_int(item["score"], f"{path}.score"), - } - ) - item_ids = _unique_ids(items, "spec.items") + _fail(_ITEMS_PATH, "must contain at least one item") + items = [ + _normalize_item(raw_item, index, locales) + for index, raw_item in enumerate(raw_items) + ] + return items, _unique_ids(items, _ITEMS_PATH) + + +def _normalize_objective( + value: Any, index: int, item_ids: set[str] +) -> dict[str, Any]: + path = f"{_OBJECTIVES_PATH}[{index}]" + objective = _require_mapping(value, path) + _require_exact_keys( + objective, {"id", "kind", "target_id", "required_count"}, path + ) + if objective["kind"] != "collect": + _fail(f"{path}.kind", "v0.1 supports only the collect objective") + target_id = _require_id(objective["target_id"], f"{path}.target_id") + if target_id not in item_ids: + _fail(f"{path}.target_id", f"unresolved item reference: {target_id}") + return { + "id": _require_id(objective["id"], f"{path}.id"), + "kind": "collect", + "target_id": target_id, + "required_count": _require_positive_int( + objective["required_count"], f"{path}.required_count" + ), + } - quest = _require_mapping(root["quest"], "spec.quest") - _require_exact_keys(quest, {"id", "title", "objectives", "rewards"}, "spec.quest") - raw_objectives = _require_sequence(quest["objectives"], "spec.quest.objectives") + +def _normalize_reward(value: Any, index: int, item_ids: set[str]) -> dict[str, Any]: + path = f"spec.quest.rewards[{index}]" + reward = _require_mapping(value, path) + _require_exact_keys(reward, {"item_id", "count"}, path) + item_id = _require_id(reward["item_id"], f"{path}.item_id") + if item_id not in item_ids: + _fail(f"{path}.item_id", f"unresolved item reference: {item_id}") + return { + "item_id": item_id, + "count": _require_positive_int(reward["count"], f"{path}.count"), + } + + +def _normalize_quest( + value: Any, locales: list[str], item_ids: set[str] +) -> dict[str, Any]: + path = "spec.quest" + quest = _require_mapping(value, path) + _require_exact_keys(quest, {"id", "title", "objectives", "rewards"}, path) + raw_objectives = _require_sequence(quest["objectives"], _OBJECTIVES_PATH) if not raw_objectives: - _fail("spec.quest.objectives", "must contain at least one objective") - objectives: list[dict[str, Any]] = [] - for index, raw_objective in enumerate(raw_objectives): - path = f"spec.quest.objectives[{index}]" - objective = _require_mapping(raw_objective, path) - _require_exact_keys( - objective, {"id", "kind", "target_id", "required_count"}, path - ) - if objective["kind"] != "collect": - _fail(f"{path}.kind", "v0.1 supports only the collect objective") - target_id = _require_id(objective["target_id"], f"{path}.target_id") - if target_id not in item_ids: - _fail(f"{path}.target_id", f"unresolved item reference: {target_id}") - objectives.append( - { - "id": _require_id(objective["id"], f"{path}.id"), - "kind": "collect", - "target_id": target_id, - "required_count": _require_positive_int( - objective["required_count"], f"{path}.required_count" - ), - } - ) - _unique_ids(objectives, "spec.quest.objectives") - - raw_rewards = _require_sequence(quest["rewards"], "spec.quest.rewards") - rewards: list[dict[str, Any]] = [] - for index, raw_reward in enumerate(raw_rewards): - path = f"spec.quest.rewards[{index}]" - reward = _require_mapping(raw_reward, path) - _require_exact_keys(reward, {"item_id", "count"}, path) - item_id = _require_id(reward["item_id"], f"{path}.item_id") - if item_id not in item_ids: - _fail(f"{path}.item_id", f"unresolved item reference: {item_id}") - rewards.append( - { - "item_id": item_id, - "count": _require_positive_int(reward["count"], f"{path}.count"), - } - ) - normalized_quest = { - "id": _require_id(quest["id"], "spec.quest.id"), - "title": _localized_text(quest["title"], locales, "spec.quest.title"), + _fail(_OBJECTIVES_PATH, "must contain at least one objective") + objectives = [ + _normalize_objective(raw_objective, index, item_ids) + for index, raw_objective in enumerate(raw_objectives) + ] + _unique_ids(objectives, _OBJECTIVES_PATH) + raw_rewards = _require_sequence(quest["rewards"], f"{path}.rewards") + rewards = [ + _normalize_reward(raw_reward, index, item_ids) + for index, raw_reward in enumerate(raw_rewards) + ] + return { + "id": _require_id(quest["id"], f"{path}.id"), + "title": _localized_text(quest["title"], locales, f"{path}.title"), "objectives": objectives, "rewards": rewards, } - gameplay = _require_mapping(root["gameplay"], "spec.gameplay") + +def _normalize_gameplay(value: Any) -> dict[str, Any]: + path = "spec.gameplay" + bins_path = f"{path}.score_bins" + gameplay = _require_mapping(value, path) _require_exact_keys( - gameplay, - {"drop_limit", "score_target", "board_seed", "score_bins"}, - "spec.gameplay", + gameplay, {"drop_limit", "score_target", "board_seed", "score_bins"}, path ) - raw_bins = _require_sequence(gameplay["score_bins"], "spec.gameplay.score_bins") + raw_bins = _require_sequence(gameplay["score_bins"], bins_path) if len(raw_bins) < 3: - _fail("spec.gameplay.score_bins", "must contain at least three bins") + _fail(bins_path, "must contain at least three bins") score_bins = [ - _require_positive_int(value, f"spec.gameplay.score_bins[{index}]") - for index, value in enumerate(raw_bins) + _require_positive_int(bin_score, f"{bins_path}[{index}]") + for index, bin_score in enumerate(raw_bins) ] - normalized_gameplay = { + return { "drop_limit": _require_positive_int( - gameplay["drop_limit"], "spec.gameplay.drop_limit" + gameplay["drop_limit"], f"{path}.drop_limit" ), "score_target": _require_positive_int( - gameplay["score_target"], "spec.gameplay.score_target" + gameplay["score_target"], f"{path}.score_target" ), "board_seed": _require_non_negative_int( - gameplay["board_seed"], "spec.gameplay.board_seed" + gameplay["board_seed"], f"{path}.board_seed" ), "score_bins": score_bins, } - runtime = _require_mapping(root["runtime"], "spec.runtime") + +def _normalize_runtime(value: Any) -> dict[str, Any]: + path = "spec.runtime" + runtime = _require_mapping(value, path) _require_exact_keys( - runtime, {"engine", "minimum_version", "entry_scene", "authority"}, "spec.runtime" + runtime, {"engine", "minimum_version", "entry_scene", "authority"}, path ) if runtime["engine"] != "godot": - _fail("spec.runtime.engine", "v0.1 supports only godot") + _fail(f"{path}.engine", "v0.1 supports only godot") if runtime["authority"] not in {"local-fixture", "external-contract"}: - _fail( - "spec.runtime.authority", - "must be local-fixture or external-contract", - ) - entry_scene = _require_non_empty_string( - runtime["entry_scene"], "spec.runtime.entry_scene" - ) + _fail(f"{path}.authority", "must be local-fixture or external-contract") + entry_scene = _require_non_empty_string(runtime["entry_scene"], _ENTRY_SCENE_PATH) if not entry_scene.startswith("res://"): - _fail("spec.runtime.entry_scene", "must be a res:// path") + _fail(_ENTRY_SCENE_PATH, "must be a res:// path") if ".." in Path(entry_scene.removeprefix("res://")).parts: - _fail("spec.runtime.entry_scene", "must not traverse outside res://") + _fail(_ENTRY_SCENE_PATH, "must not traverse outside res://") if not entry_scene.endswith(".tscn"): - _fail("spec.runtime.entry_scene", "must reference a .tscn scene") + _fail(_ENTRY_SCENE_PATH, "must reference a .tscn scene") + version_path = f"{path}.minimum_version" minimum_version = _require_non_empty_string( - runtime["minimum_version"], "spec.runtime.minimum_version" + runtime["minimum_version"], version_path ) if not _GODOT_VERSION_PATTERN.fullmatch(minimum_version): - _fail( - "spec.runtime.minimum_version", - "must be a Godot 4 version such as 4.2 or 4.2.1", - ) - normalized_runtime = { + _fail(version_path, "must be a Godot 4 version such as 4.2 or 4.2.1") + return { "engine": "godot", "minimum_version": minimum_version, "entry_scene": entry_scene, "authority": runtime["authority"], } - boundary = _require_mapping(root["boundary"], "spec.boundary") + +def _normalize_boundary(value: Any, runtime_authority: str) -> dict[str, Any]: + path = "spec.boundary" + backend_path = f"{path}.authoritative_backend" + boundary = _require_mapping(value, path) _require_exact_keys( boundary, { @@ -360,47 +370,76 @@ def validate_and_normalize(spec: Mapping[str, Any]) -> dict[str, Any]: "private_components_included", "authoritative_backend", }, - "spec.boundary", + path, ) if boundary["public_contract_only"] is not True: - _fail("spec.boundary.public_contract_only", "must be true") + _fail(f"{path}.public_contract_only", "must be true") if boundary["private_components_included"] is not False: - _fail("spec.boundary.private_components_included", "must be false") - if boundary["authoritative_backend"] not in {"none-fixture", "external"}: + _fail(f"{path}.private_components_included", "must be false") + backend = boundary["authoritative_backend"] + if backend not in {"none-fixture", "external"}: + _fail(backend_path, "must be none-fixture or external") + if (runtime_authority, backend) not in { + ("local-fixture", "none-fixture"), + ("external-contract", "external"), + }: _fail( - "spec.boundary.authoritative_backend", - "must be none-fixture or external", + backend_path, + "must match runtime authority (local-fixture/none-fixture or " + "external-contract/external)", ) - normalized_boundary = { + return { "public_contract_only": True, "private_components_included": False, - "authoritative_backend": boundary["authoritative_backend"], + "authoritative_backend": backend, } - authority_pair = ( - normalized_runtime["authority"], - normalized_boundary["authoritative_backend"], + + +def validate_and_normalize(spec: Mapping[str, Any]) -> dict[str, Any]: + """Validate an interchange specification and return normalized data. + + Validation is deliberately strict. Unknown fields, unresolved references, + partial localizations, and attempts to include private components are + rejected rather than guessed or silently discarded. + """ + + root = _require_mapping(spec, "spec") + _require_exact_keys( + root, + { + "schema_version", + "project", + "locales", + "world", + "actors", + "items", + "quest", + "gameplay", + "runtime", + "boundary", + }, + "spec", ) - if authority_pair not in { - ("local-fixture", "none-fixture"), - ("external-contract", "external"), - }: + if root["schema_version"] != CONTRACT_VERSION: _fail( - "spec.boundary.authoritative_backend", - "must match runtime authority (local-fixture/none-fixture or " - "external-contract/external)", + "spec.schema_version", + f"expected {CONTRACT_VERSION!r}, got {root['schema_version']!r}", ) + locales = _normalize_locales(root["locales"]) + items, item_ids = _normalize_items(root["items"], locales) + runtime = _normalize_runtime(root["runtime"]) return { "schema_version": CONTRACT_VERSION, - "project": normalized_project, + "project": _normalize_project(root["project"], locales), "locales": locales, - "world": normalized_world, - "actors": actors, + "world": _normalize_world(root["world"], locales), + "actors": _normalize_actors(root["actors"], locales), "items": items, - "quest": normalized_quest, - "gameplay": normalized_gameplay, - "runtime": normalized_runtime, - "boundary": normalized_boundary, + "quest": _normalize_quest(root["quest"], locales, item_ids), + "gameplay": _normalize_gameplay(root["gameplay"]), + "runtime": runtime, + "boundary": _normalize_boundary(root["boundary"], runtime["authority"]), } @@ -442,7 +481,34 @@ def verify_manifest(manifest: Mapping[str, Any]) -> bool: candidate = _require_mapping(manifest, "manifest") embedded = candidate.pop("content_sha256", None) - return isinstance(embedded, str) and embedded == _sha256(_canonical_bytes(candidate)) + return isinstance(embedded, str) and embedded == _sha256( + _canonical_bytes(candidate) + ) + + +def _resolve_workspace_path( + candidate: Path, + workspace_root: Path, + label: str, + *, + must_be_file: bool = False, +) -> Path: + """Resolve a CLI path without allowing workspace or symlink escape.""" + + try: + root = workspace_root.resolve(strict=True) + unresolved = candidate if candidate.is_absolute() else root / candidate + resolved = unresolved.resolve(strict=must_be_file) + relative = resolved.relative_to(root) + except (OSError, RuntimeError, ValueError) as exc: + raise ContractError( + f"{label}: must stay within workspace root {workspace_root}" + ) from exc + if not root.is_dir(): + _fail("workspace", "root must be a directory") + if must_be_file and not resolved.is_file(): + _fail(label, "must reference an existing regular file") + return root.joinpath(relative) def _load_json(path: Path) -> dict[str, Any]: @@ -454,11 +520,14 @@ def _load_json(path: Path) -> dict[str, Any]: def _write_json(path: Path, value: Mapping[str, Any]) -> None: - path.parent.mkdir(parents=True, exist_ok=True) - path.write_text( - json.dumps(value, ensure_ascii=False, sort_keys=True, indent=2) + "\n", - encoding="utf-8", - ) + try: + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text( + json.dumps(value, ensure_ascii=False, sort_keys=True, indent=2) + "\n", + encoding="utf-8", + ) + except OSError as exc: + raise ContractError(f"unable to write {path}: {exc}") from exc def main(argv: Sequence[str] | None = None) -> int: @@ -470,14 +539,29 @@ def main(argv: Sequence[str] | None = None) -> int: args = parser.parse_args(argv) try: - manifest, evidence = compile_spec(_load_json(args.input)) + workspace_root = Path.cwd().resolve(strict=True) + input_path = _resolve_workspace_path( + args.input, workspace_root, "--input", must_be_file=True + ) + output_dir = _resolve_workspace_path( + args.output_dir, workspace_root, "--output-dir" + ) + manifest_path = _resolve_workspace_path( + output_dir / "storycore_game_manifest.json", + workspace_root, + "manifest output", + ) + evidence_path = _resolve_workspace_path( + output_dir / "storycore_game_evidence.json", + workspace_root, + "evidence output", + ) + manifest, evidence = compile_spec(_load_json(input_path)) + _write_json(manifest_path, manifest) + _write_json(evidence_path, evidence) except ContractError as exc: parser.error(str(exc)) - manifest_path = args.output_dir / "storycore_game_manifest.json" - evidence_path = args.output_dir / "storycore_game_evidence.json" - _write_json(manifest_path, manifest) - _write_json(evidence_path, evidence) print(f"manifest={manifest_path}") print(f"evidence={evidence_path}") print(f"content_sha256={manifest['content_sha256']}") From 06c5ef8e3c353a415da974236f00727ae21e1acf Mon Sep 17 00:00:00 2001 From: Sylvain Galliez <128614184+zedarvates@users.noreply.github.com> Date: Sun, 30 Aug 2026 10:55:16 +0200 Subject: [PATCH 043/113] test(game-bridge): update tests/game_bridge/test_contract.py --- tests/game_bridge/test_contract.py | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/tests/game_bridge/test_contract.py b/tests/game_bridge/test_contract.py index fc09f790..f580fd45 100644 --- a/tests/game_bridge/test_contract.py +++ b/tests/game_bridge/test_contract.py @@ -1,8 +1,10 @@ from __future__ import annotations +import io import json import tempfile import unittest +from contextlib import redirect_stderr from pathlib import Path from src.game_bridge.contract import ( @@ -88,7 +90,7 @@ def test_runtime_and_backend_authority_must_match(self) -> None: compile_spec(spec) def test_cli_writes_manifest_and_evidence(self) -> None: - with tempfile.TemporaryDirectory() as directory: + with tempfile.TemporaryDirectory(dir=ROOT) as directory: output = Path(directory) result = main( @@ -105,6 +107,16 @@ def test_cli_writes_manifest_and_evidence(self) -> None: self.assertTrue(verify_manifest(manifest)) self.assertEqual(manifest["content_sha256"], evidence["content_sha256"]) + def test_cli_rejects_paths_outside_workspace(self) -> None: + outside = ROOT.parent / "storycore-game-escape" + errors = io.StringIO() + + with redirect_stderr(errors), self.assertRaises(SystemExit) as raised: + main(["--input", str(FIXTURE), "--output-dir", str(outside)]) + + self.assertEqual(raised.exception.code, 2) + self.assertIn("must stay within workspace root", errors.getvalue()) + def test_checked_in_godot_inputs_match_compiler(self) -> None: expected_manifest, expected_evidence = compile_spec(load_fixture()) checked_manifest = json.loads( From a87a37b6989b1ef3a20cb9fb54d3c7dfb7b6bca0 Mon Sep 17 00:00:00 2001 From: Sylvain Galliez <128614184+zedarvates@users.noreply.github.com> Date: Sun, 30 Aug 2026 10:55:18 +0200 Subject: [PATCH 044/113] test(game-bridge): update fixtures/storycore-game/coinfall-chronicle/README.md --- fixtures/storycore-game/coinfall-chronicle/README.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/fixtures/storycore-game/coinfall-chronicle/README.md b/fixtures/storycore-game/coinfall-chronicle/README.md index 3c943cba..58978a60 100644 --- a/fixtures/storycore-game/coinfall-chronicle/README.md +++ b/fixtures/storycore-game/coinfall-chronicle/README.md @@ -33,7 +33,8 @@ python -m unittest discover -s tests/game_bridge -v The compiler writes `storycore_game_manifest.json` plus a reproducible evidence record. The test suite also detects when the checked-in Godot inputs drift from -the compiler output. +the compiler output. For path safety, CLI input and output must resolve inside +the current workspace; symlink and `..` escapes are rejected. ## Play From ba62afdbba74ea0dfb16f2272071c87af9a99bdb Mon Sep 17 00:00:00 2001 From: Sylvain Galliez <128614184+zedarvates@users.noreply.github.com> Date: Sun, 30 Aug 2026 10:59:46 +0200 Subject: [PATCH 045/113] ci: apply scoped PR44 path-safety fix --- .../workflows/one-shot-pr44-security-fix.yml | 277 ++++++++++++++++++ 1 file changed, 277 insertions(+) create mode 100644 .github/workflows/one-shot-pr44-security-fix.yml diff --git a/.github/workflows/one-shot-pr44-security-fix.yml b/.github/workflows/one-shot-pr44-security-fix.yml new file mode 100644 index 00000000..08c55a67 --- /dev/null +++ b/.github/workflows/one-shot-pr44-security-fix.yml @@ -0,0 +1,277 @@ +name: One-shot PR44 security fix + +on: + push: + branches: [codex/storycore-game-contract-v0.1] + paths: [.github/workflows/one-shot-pr44-security-fix.yml] + +permissions: + contents: write + +jobs: + patch: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + ref: codex/storycore-game-contract-v0.1 + fetch-depth: 0 + + - name: Apply narrowly scoped path-safety hardening + shell: bash + run: | + python - <<'PY' + from pathlib import Path + + contract_path = Path('src/game_bridge/contract.py') + tests_path = Path('tests/game_bridge/test_contract.py') + contract = contract_path.read_text(encoding='utf-8') + tests = tests_path.read_text(encoding='utf-8') + + def replace_once(text: str, old: str, new: str, label: str) -> str: + count = text.count(old) + if count != 1: + raise SystemExit(f'{label}: expected exactly one match, found {count}') + return text.replace(old, new, 1) + + contract = replace_once( + contract, + 'import hashlib\nimport json\nimport re\n', + 'import hashlib\nimport json\nimport os\nimport re\n', + 'imports', + ) + contract = replace_once( + contract, + '_GODOT_VERSION_PATTERN = re.compile(r"^4\\.\\d+(?:\\.\\d+)?$")\n', + '_GODOT_VERSION_PATTERN = re.compile(r"^4\\.\\d+(?:\\.\\d+)?$")\n' + '_CLI_RELATIVE_PATH_PATTERN = re.compile(r"^[A-Za-z0-9._/-]+$")\n', + 'CLI path allowlist', + ) + + old_paths = '''def _resolve_workspace_path( + candidate: Path, + workspace_root: Path, + label: str, + *, + must_be_file: bool = False, + ) -> Path: + """Resolve a CLI path without allowing workspace or symlink escape.""" + + try: + root = workspace_root.resolve(strict=True) + unresolved = candidate if candidate.is_absolute() else root / candidate + resolved = unresolved.resolve(strict=must_be_file) + relative = resolved.relative_to(root) + except (OSError, RuntimeError, ValueError) as exc: + raise ContractError( + f"{label}: must stay within workspace root {workspace_root}" + ) from exc + if not root.is_dir(): + _fail("workspace", "root must be a directory") + if must_be_file and not resolved.is_file(): + _fail(label, "must reference an existing regular file") + return root.joinpath(relative) + + + def _load_json(path: Path) -> dict[str, Any]: + try: + loaded = json.loads(path.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError) as exc: + raise ContractError(f"unable to read {path}: {exc}") from exc + return _require_mapping(loaded, str(path)) + + + def _write_json(path: Path, value: Mapping[str, Any]) -> None: + try: + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text( + json.dumps(value, ensure_ascii=False, sort_keys=True, indent=2) + "\\n", + encoding="utf-8", + ) + except OSError as exc: + raise ContractError(f"unable to write {path}: {exc}") from exc + '''.replace(' ', '') + + new_paths = '''def _require_relative_cli_path(value: str, label: str) -> Path: + """Accept only an allowlisted workspace-relative CLI path.""" + + if not value or not _CLI_RELATIVE_PATH_PATTERN.fullmatch(value): + _fail(label, "must be an allowlisted workspace-relative path") + candidate = Path(value) + if candidate.is_absolute() or ".." in candidate.parts: + _fail(label, "must be an allowlisted workspace-relative path") + return candidate + + + def _resolve_workspace_path( + candidate: Path, + workspace_root: Path, + label: str, + *, + must_be_file: bool = False, + must_be_dir: bool = False, + ) -> Path: + """Resolve a validated relative path without allowing symlink escape.""" + + try: + root = workspace_root.resolve(strict=True) + resolved = (root / candidate).resolve(strict=True) + relative = resolved.relative_to(root) + except (OSError, RuntimeError, ValueError) as exc: + raise ContractError( + f"{label}: must stay within workspace root {workspace_root}" + ) from exc + if not root.is_dir(): + _fail("workspace", "root must be a directory") + if must_be_file and not resolved.is_file(): + _fail(label, "must reference an existing regular file") + if must_be_dir and not resolved.is_dir(): + _fail(label, "must reference an existing directory") + return root.joinpath(relative) + + + def _load_json(path: Path) -> dict[str, Any]: + try: + loaded = json.loads(path.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError) as exc: + raise ContractError(f"unable to read {path}: {exc}") from exc + return _require_mapping(loaded, str(path)) + + + def _write_json(path: Path, value: Mapping[str, Any]) -> None: + """Write a fixed-name output without following a final-component symlink.""" + + flags = os.O_WRONLY | os.O_CREAT | os.O_TRUNC + if hasattr(os, "O_NOFOLLOW"): + flags |= os.O_NOFOLLOW + descriptor: int | None = None + try: + descriptor = os.open(path, flags, 0o600) + with os.fdopen(descriptor, "w", encoding="utf-8") as handle: + descriptor = None + handle.write( + json.dumps(value, ensure_ascii=False, sort_keys=True, indent=2) + + "\\n" + ) + except OSError as exc: + raise ContractError(f"unable to write {path}: {exc}") from exc + finally: + if descriptor is not None: + os.close(descriptor) + '''.replace(' ', '') + contract = replace_once(contract, old_paths, new_paths, 'path helpers') + + old_main = ''' parser.add_argument("--input", required=True, type=Path) + parser.add_argument("--output-dir", required=True, type=Path) + args = parser.parse_args(argv) + + try: + workspace_root = Path.cwd().resolve(strict=True) + input_path = _resolve_workspace_path( + args.input, workspace_root, "--input", must_be_file=True + ) + output_dir = _resolve_workspace_path( + args.output_dir, workspace_root, "--output-dir" + ) + manifest_path = _resolve_workspace_path( + output_dir / "storycore_game_manifest.json", + workspace_root, + "manifest output", + ) + evidence_path = _resolve_workspace_path( + output_dir / "storycore_game_evidence.json", + workspace_root, + "evidence output", + ) + '''.replace(' ', '') + + new_main = ''' parser.add_argument("--input", required=True) + parser.add_argument("--output-dir", required=True) + args = parser.parse_args(argv) + + try: + workspace_root = Path.cwd().resolve(strict=True) + input_relative = _require_relative_cli_path(args.input, "--input") + output_relative = _require_relative_cli_path( + args.output_dir, "--output-dir" + ) + input_path = _resolve_workspace_path( + input_relative, workspace_root, "--input", must_be_file=True + ) + output_dir = _resolve_workspace_path( + output_relative, + workspace_root, + "--output-dir", + must_be_dir=True, + ) + # Output filenames are constants, not user-controlled path components. + manifest_path = output_dir / "storycore_game_manifest.json" + evidence_path = output_dir / "storycore_game_evidence.json" + '''.replace(' ', '') + contract = replace_once(contract, old_main, new_main, 'main path handling') + + tests = replace_once( + tests, + ' ["--input", str(FIXTURE), "--output-dir", str(output)]\n', + ' [\n' + ' "--input",\n' + ' str(FIXTURE.relative_to(ROOT)),\n' + ' "--output-dir",\n' + ' str(output.relative_to(ROOT)),\n' + ' ]\n', + 'CLI happy-path test', + ) + old_outside = ''' def test_cli_rejects_paths_outside_workspace(self) -> None: + outside = ROOT.parent / "storycore-game-escape" + errors = io.StringIO() + + with redirect_stderr(errors), self.assertRaises(SystemExit) as raised: + main(["--input", str(FIXTURE), "--output-dir", str(outside)]) + + self.assertEqual(raised.exception.code, 2) + self.assertIn("must stay within workspace root", errors.getvalue()) + '''.replace(' ', '') + new_outside = ''' def test_cli_rejects_absolute_paths(self) -> None: + errors = io.StringIO() + + with redirect_stderr(errors), self.assertRaises(SystemExit) as raised: + main(["--input", str(FIXTURE), "--output-dir", "fixtures"]) + + self.assertEqual(raised.exception.code, 2) + self.assertIn("allowlisted workspace-relative path", errors.getvalue()) + + def test_cli_rejects_parent_traversal(self) -> None: + errors = io.StringIO() + + with redirect_stderr(errors), self.assertRaises(SystemExit) as raised: + main( + [ + "--input", + str(FIXTURE.relative_to(ROOT)), + "--output-dir", + "../storycore-game-escape", + ] + ) + + self.assertEqual(raised.exception.code, 2) + self.assertIn("allowlisted workspace-relative path", errors.getvalue()) + '''.replace(' ', '') + tests = replace_once(tests, old_outside, new_outside, 'CLI negative tests') + + contract_path.write_text(contract, encoding='utf-8') + tests_path.write_text(tests, encoding='utf-8') + PY + + - name: Validate focused contract suite + run: | + python -m compileall -q src/game_bridge tests/game_bridge + python -m unittest discover -s tests/game_bridge -v + + - name: Commit scoped fix + run: | + git diff --check + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + git add src/game_bridge/contract.py tests/game_bridge/test_contract.py + git commit -m "fix(game-bridge): harden CLI workspace paths" + git push origin HEAD:codex/storycore-game-contract-v0.1 From 3a442752157df1913d6cb98ce0b5585ab5fb8731 Mon Sep 17 00:00:00 2001 From: Sylvain Galliez <128614184+zedarvates@users.noreply.github.com> Date: Sun, 30 Aug 2026 11:00:17 +0200 Subject: [PATCH 046/113] fix(game-fixture): satisfy strict Godot typing --- fixtures/storycore-game/coinfall-chronicle/godot/Main.gd | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/fixtures/storycore-game/coinfall-chronicle/godot/Main.gd b/fixtures/storycore-game/coinfall-chronicle/godot/Main.gd index 5c220b0c..01802876 100644 --- a/fixtures/storycore-game/coinfall-chronicle/godot/Main.gd +++ b/fixtures/storycore-game/coinfall-chronicle/godot/Main.gd @@ -347,7 +347,7 @@ func _update_interface() -> void: else "Clic ou Espace : lâcher une rune • L : English • R : recommencer" ) var objective: Dictionary = manifest["quest"]["objectives"][0] - var quest_title := _localized(manifest["quest"]["title"]) + var quest_title: String = str(_localized(manifest["quest"]["title"])) if locale == "en": quest_label.text = "%s • Runes %d/%d • Score %d/%d • Drops %d/%d" % [ quest_title, collected, int(objective["required_count"]), score, From 7639d6bf7648388422037a572cfeda32467f59b2 Mon Sep 17 00:00:00 2001 From: Sylvain Galliez <128614184+zedarvates@users.noreply.github.com> Date: Sun, 30 Aug 2026 11:00:19 +0200 Subject: [PATCH 047/113] test(game-fixture): record verified Godot smoke in fixtures/storycore-game/coinfall-chronicle/README.md --- .../coinfall-chronicle/README.md | 21 ++++++++++++++++--- 1 file changed, 18 insertions(+), 3 deletions(-) diff --git a/fixtures/storycore-game/coinfall-chronicle/README.md b/fixtures/storycore-game/coinfall-chronicle/README.md index 58978a60..468d8991 100644 --- a/fixtures/storycore-game/coinfall-chronicle/README.md +++ b/fixtures/storycore-game/coinfall-chronicle/README.md @@ -45,6 +45,19 @@ Open `godot/project.godot` with Godot 4.2 or newer and run the main scene. - Recover at least five runes and reach 100 points within twelve drops. - Press R to restart. +The automated quest smoke can be reproduced with a trusted Godot binary: + +```bash +godot --headless \ + --path fixtures/storycore-game/coinfall-chronicle/godot \ + --script res://SmokeTest.gd +``` + +Success prints `STORYCORE_GAME_SMOKE_PASS`. The recorded acceptance run used +the official Linux x86-64 Godot 4.7.2 binary (`ed1daf0bf`) after verifying its +published SHA-256. Because Godot may still exit with status zero after a script +parse error, automation must also reject `SCRIPT ERROR` and `ERROR:` in its log. + ## Acceptance gate | Check | Local status | Promotion requirement | @@ -52,8 +65,10 @@ Open `godot/project.godot` with Godot 4.2 or newer and run the main scene. | Strict contract and negative cases | Automated | All unit tests pass | | Deterministic manifest and evidence | Automated | Checked-in outputs match | | Manifest tamper detection | Automated | Modified content is rejected | -| Godot import and script parse | Pending runtime | Godot 4.2 self-hosted job passes | -| Full quest play-through | Pending runtime | Automated or recorded smoke test passes | +| Godot import and script parse | Passed on 4.7.2 | Repeat on an isolated trusted runner | +| Full quest play-through | Automated smoke passed | `STORYCORE_GAME_SMOKE_PASS` is present | | Private/public boundary | Automated + review | No private component or secret is present | -The fixture must remain experimental until the two Godot runtime checks pass. +The fixture remains experimental until the Quality Gate and human boundary +review also pass. Never run public fork code on a self-hosted runner carrying +personal, signing, deployment, or production credentials. From ac53f627a1c91553694870c9f6ea7fd9b5577447 Mon Sep 17 00:00:00 2001 From: Sylvain Galliez <128614184+zedarvates@users.noreply.github.com> Date: Sun, 30 Aug 2026 11:00:21 +0200 Subject: [PATCH 048/113] test(game-fixture): record verified Godot smoke in tests/game_bridge/test_contract.py --- tests/game_bridge/test_contract.py | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/tests/game_bridge/test_contract.py b/tests/game_bridge/test_contract.py index f580fd45..f8f85388 100644 --- a/tests/game_bridge/test_contract.py +++ b/tests/game_bridge/test_contract.py @@ -1,5 +1,6 @@ from __future__ import annotations +import hashlib import io import json import tempfile @@ -133,6 +134,27 @@ def test_checked_in_godot_inputs_match_compiler(self) -> None: self.assertEqual(checked_manifest, expected_manifest) self.assertEqual(checked_evidence, expected_evidence) + def test_godot_smoke_evidence_matches_fixture_sources(self) -> None: + evidence = json.loads( + (GODOT_FIXTURE / "godot_smoke_evidence.json").read_text( + encoding="utf-8" + ) + ) + expected_hashes = { + "main_script_sha256": "Main.gd", + "manifest_file_sha256": "storycore_game_manifest.json", + "project_file_sha256": "project.godot", + "smoke_script_sha256": "SmokeTest.gd", + } + + for evidence_key, file_name in expected_hashes.items(): + content = (GODOT_FIXTURE / file_name).read_bytes() + self.assertEqual( + evidence["fixture"][evidence_key], hashlib.sha256(content).hexdigest() + ) + self.assertEqual(evidence["status"], "pass") + self.assertEqual(evidence["result"]["marker"], "STORYCORE_GAME_SMOKE_PASS") + if __name__ == "__main__": unittest.main() From 6beffafb0f929befef1d763046bc94607b23d031 Mon Sep 17 00:00:00 2001 From: Sylvain Galliez <128614184+zedarvates@users.noreply.github.com> Date: Sun, 30 Aug 2026 11:00:23 +0200 Subject: [PATCH 049/113] test(game-fixture): add fixtures/storycore-game/coinfall-chronicle/godot/SmokeTest.gd --- .../coinfall-chronicle/godot/SmokeTest.gd | 78 +++++++++++++++++++ 1 file changed, 78 insertions(+) create mode 100644 fixtures/storycore-game/coinfall-chronicle/godot/SmokeTest.gd diff --git a/fixtures/storycore-game/coinfall-chronicle/godot/SmokeTest.gd b/fixtures/storycore-game/coinfall-chronicle/godot/SmokeTest.gd new file mode 100644 index 00000000..7a48ef77 --- /dev/null +++ b/fixtures/storycore-game/coinfall-chronicle/godot/SmokeTest.gd @@ -0,0 +1,78 @@ +extends SceneTree + +const MAIN_SCENE := "res://Main.tscn" +const CENTER_BIN := 2 +const DROP_X := 480.0 +const REQUIRED_DROPS := 5 + + +func _init() -> void: + call_deferred("_run") + + +func _run() -> void: + var packed_scene := ResourceLoader.load(MAIN_SCENE) as PackedScene + if packed_scene == null: + _fail("main scene could not be loaded") + return + var game: Node = packed_scene.instantiate() + root.add_child(game) + await process_frame + + var game_manifest: Dictionary = game.get("manifest") + if game_manifest.is_empty(): + _fail("verified manifest was not loaded") + return + for _drop_index in range(REQUIRED_DROPS): + game.call("_drop_rune", DROP_X) + await process_frame + var rune := _pending_rune() + if rune == null: + _fail("drop did not create an unresolved rune") + return + game.call("_on_bin_entered", rune, CENTER_BIN) + await process_frame + + var gameplay: Dictionary = game_manifest["gameplay"] + var scores: Array = gameplay["score_bins"] + var expected_score := REQUIRED_DROPS * int(scores[CENTER_BIN]) + if int(game.get("score")) != expected_score: + _fail("center-bin score does not match the manifest") + return + if int(game.get("collected")) != REQUIRED_DROPS: + _fail("quest collection progress is incorrect") + return + if int(game.get("drop_count")) != REQUIRED_DROPS: + _fail("drop counter is incorrect") + return + if not bool(game.get("run_ended")): + _fail("quest did not reach its completion state") + return + + game.set("locale", "fr") + game.call("_update_interface") + var title_label := game.get("title_label") as Label + if title_label == null or title_label.text != "La Chronique des runes": + _fail("French localization did not load") + return + + print( + "STORYCORE_GAME_SMOKE_PASS score=%d collected=%d locale=fr" % [ + int(game.get("score")), int(game.get("collected")) + ] + ) + game.queue_free() + await process_frame + quit(0) + + +func _pending_rune() -> Node2D: + for candidate in get_nodes_in_group("active_runes"): + if candidate is Node2D and not bool(candidate.get_meta("resolved", false)): + return candidate + return null + + +func _fail(message: String) -> void: + push_error("STORYCORE_GAME_SMOKE_FAIL: " + message) + quit(1) From 129ae264b7b96213c9cd693de5fbbe3ceb228c3c Mon Sep 17 00:00:00 2001 From: Sylvain Galliez <128614184+zedarvates@users.noreply.github.com> Date: Sun, 30 Aug 2026 11:00:24 +0200 Subject: [PATCH 050/113] test(game-fixture): add fixtures/storycore-game/coinfall-chronicle/godot/godot_smoke_evidence.json --- .../godot/godot_smoke_evidence.json | 34 +++++++++++++++++++ 1 file changed, 34 insertions(+) create mode 100644 fixtures/storycore-game/coinfall-chronicle/godot/godot_smoke_evidence.json diff --git a/fixtures/storycore-game/coinfall-chronicle/godot/godot_smoke_evidence.json b/fixtures/storycore-game/coinfall-chronicle/godot/godot_smoke_evidence.json new file mode 100644 index 00000000..07588d82 --- /dev/null +++ b/fixtures/storycore-game/coinfall-chronicle/godot/godot_smoke_evidence.json @@ -0,0 +1,34 @@ +{ + "checks": [ + "official-engine-asset-digest", + "headless-editor-import", + "gdscript-parse-log-clean", + "manifest-hash-verified-at-runtime", + "quest-completion", + "score-progression", + "french-localization" + ], + "engine": { + "asset": "Godot_v4.7.2-stable_linux.x86_64.zip", + "asset_sha256": "cadd3204e728a35d3f13adb7fd0d7902636b79f6b95c40c265eb73b6c35329e4", + "commit": "ed1daf0bf", + "source": "https://github.com/godotengine/godot/releases/tag/4.7.2-stable", + "version": "4.7.2.stable.official" + }, + "fixture": { + "main_script_sha256": "1f9acfb94ea5f5d1c47a9e84b2f6005315236e10452b0da04bf52066e21a4e17", + "manifest_content_sha256": "a75e7e506f27cee8e6bec9bcb65054ad264808c3c01508ee4de3e8645196c43a", + "manifest_file_sha256": "21359cddd04a857dcee60c8ba9f320e3edc7d389a22dd61867318898201e1614", + "project_file_sha256": "298ed21234f666b72c4eef0af76bb0c3bcae6a333f6b02c6fceb5c465ee2d2e5", + "smoke_script_sha256": "cb85cbefabc0a1b996023c187ebb05003d79628f14f355eeb884951f79d76fc5" + }, + "observed_at": "2026-08-30T08:58:52Z", + "result": { + "collected": 5, + "locale": "fr", + "marker": "STORYCORE_GAME_SMOKE_PASS", + "score": 250 + }, + "schema_version": "0.1", + "status": "pass" +} From ce1abc604ad591f5d47db4b0dc3ee328b6fbb907 Mon Sep 17 00:00:00 2001 From: Sylvain Galliez <128614184+zedarvates@users.noreply.github.com> Date: Sun, 30 Aug 2026 11:00:57 +0200 Subject: [PATCH 051/113] ci: make scoped PR44 patch resilient --- .../workflows/one-shot-pr44-security-fix.yml | 116 ++++++++---------- 1 file changed, 50 insertions(+), 66 deletions(-) diff --git a/.github/workflows/one-shot-pr44-security-fix.yml b/.github/workflows/one-shot-pr44-security-fix.yml index 08c55a67..976fc1a5 100644 --- a/.github/workflows/one-shot-pr44-security-fix.yml +++ b/.github/workflows/one-shot-pr44-security-fix.yml @@ -34,6 +34,15 @@ jobs: raise SystemExit(f'{label}: expected exactly one match, found {count}') return text.replace(old, new, 1) + def replace_between(text: str, start: str, end: str, replacement: str, label: str) -> str: + start_at = text.find(start) + if start_at < 0: + raise SystemExit(f'{label}: start anchor not found') + end_at = text.find(end, start_at) + if end_at < 0: + raise SystemExit(f'{label}: end anchor not found') + return text[:start_at] + replacement + text[end_at:] + contract = replace_once( contract, 'import hashlib\nimport json\nimport re\n', @@ -48,51 +57,7 @@ jobs: 'CLI path allowlist', ) - old_paths = '''def _resolve_workspace_path( - candidate: Path, - workspace_root: Path, - label: str, - *, - must_be_file: bool = False, - ) -> Path: - """Resolve a CLI path without allowing workspace or symlink escape.""" - - try: - root = workspace_root.resolve(strict=True) - unresolved = candidate if candidate.is_absolute() else root / candidate - resolved = unresolved.resolve(strict=must_be_file) - relative = resolved.relative_to(root) - except (OSError, RuntimeError, ValueError) as exc: - raise ContractError( - f"{label}: must stay within workspace root {workspace_root}" - ) from exc - if not root.is_dir(): - _fail("workspace", "root must be a directory") - if must_be_file and not resolved.is_file(): - _fail(label, "must reference an existing regular file") - return root.joinpath(relative) - - - def _load_json(path: Path) -> dict[str, Any]: - try: - loaded = json.loads(path.read_text(encoding="utf-8")) - except (OSError, json.JSONDecodeError) as exc: - raise ContractError(f"unable to read {path}: {exc}") from exc - return _require_mapping(loaded, str(path)) - - - def _write_json(path: Path, value: Mapping[str, Any]) -> None: - try: - path.parent.mkdir(parents=True, exist_ok=True) - path.write_text( - json.dumps(value, ensure_ascii=False, sort_keys=True, indent=2) + "\\n", - encoding="utf-8", - ) - except OSError as exc: - raise ContractError(f"unable to write {path}: {exc}") from exc - '''.replace(' ', '') - - new_paths = '''def _require_relative_cli_path(value: str, label: str) -> Path: + hardened_helpers = '''def _require_relative_cli_path(value: str, label: str) -> Path: """Accept only an allowlisted workspace-relative CLI path.""" if not value or not _CLI_RELATIVE_PATH_PATTERN.fullmatch(value): @@ -158,10 +123,18 @@ jobs: finally: if descriptor is not None: os.close(descriptor) + + '''.replace(' ', '') - contract = replace_once(contract, old_paths, new_paths, 'path helpers') + contract = replace_between( + contract, + 'def _resolve_workspace_path(', + 'def main(', + hardened_helpers, + 'path helper region', + ) - old_main = ''' parser.add_argument("--input", required=True, type=Path) + old_cli = ''' parser.add_argument("--input", required=True, type=Path) parser.add_argument("--output-dir", required=True, type=Path) args = parser.parse_args(argv) @@ -184,17 +157,14 @@ jobs: "evidence output", ) '''.replace(' ', '') - - new_main = ''' parser.add_argument("--input", required=True) + new_cli = ''' parser.add_argument("--input", required=True) parser.add_argument("--output-dir", required=True) args = parser.parse_args(argv) try: workspace_root = Path.cwd().resolve(strict=True) input_relative = _require_relative_cli_path(args.input, "--input") - output_relative = _require_relative_cli_path( - args.output_dir, "--output-dir" - ) + output_relative = _require_relative_cli_path(args.output_dir, "--output-dir") input_path = _resolve_workspace_path( input_relative, workspace_root, "--input", must_be_file=True ) @@ -204,11 +174,11 @@ jobs: "--output-dir", must_be_dir=True, ) - # Output filenames are constants, not user-controlled path components. + # Output filenames are constants, never user-controlled components. manifest_path = output_dir / "storycore_game_manifest.json" evidence_path = output_dir / "storycore_game_evidence.json" '''.replace(' ', '') - contract = replace_once(contract, old_main, new_main, 'main path handling') + contract = replace_once(contract, old_cli, new_cli, 'main CLI path handling') tests = replace_once( tests, @@ -219,19 +189,10 @@ jobs: ' "--output-dir",\n' ' str(output.relative_to(ROOT)),\n' ' ]\n', - 'CLI happy-path test', + 'CLI happy path', ) - old_outside = ''' def test_cli_rejects_paths_outside_workspace(self) -> None: - outside = ROOT.parent / "storycore-game-escape" - errors = io.StringIO() - - with redirect_stderr(errors), self.assertRaises(SystemExit) as raised: - main(["--input", str(FIXTURE), "--output-dir", str(outside)]) - self.assertEqual(raised.exception.code, 2) - self.assertIn("must stay within workspace root", errors.getvalue()) - '''.replace(' ', '') - new_outside = ''' def test_cli_rejects_absolute_paths(self) -> None: + negative_tests = ''' def test_cli_rejects_absolute_paths(self) -> None: errors = io.StringIO() with redirect_stderr(errors), self.assertRaises(SystemExit) as raised: @@ -255,8 +216,31 @@ jobs: self.assertEqual(raised.exception.code, 2) self.assertIn("allowlisted workspace-relative path", errors.getvalue()) + + def test_cli_rejects_non_allowlisted_path_characters(self) -> None: + errors = io.StringIO() + + with redirect_stderr(errors), self.assertRaises(SystemExit) as raised: + main( + [ + "--input", + str(FIXTURE.relative_to(ROOT)), + "--output-dir", + "fixtures/storycore-game/$escape", + ] + ) + + self.assertEqual(raised.exception.code, 2) + self.assertIn("allowlisted workspace-relative path", errors.getvalue()) + '''.replace(' ', '') - tests = replace_once(tests, old_outside, new_outside, 'CLI negative tests') + tests = replace_between( + tests, + ' def test_cli_rejects_paths_outside_workspace(self) -> None:', + ' def test_checked_in_godot_inputs_match_compiler(self) -> None:', + negative_tests, + 'CLI negative-test region', + ) contract_path.write_text(contract, encoding='utf-8') tests_path.write_text(tests, encoding='utf-8') From 7f44e7dec9df95a97a52d395002e87f16900e515 Mon Sep 17 00:00:00 2001 From: Sylvain Galliez <128614184+zedarvates@users.noreply.github.com> Date: Sun, 30 Aug 2026 11:01:38 +0200 Subject: [PATCH 052/113] ci: rerun scoped PR44 security patch --- .../one-shot-pr44-security-fix-v2.yml | 227 ++++++++++++++++++ 1 file changed, 227 insertions(+) create mode 100644 .github/workflows/one-shot-pr44-security-fix-v2.yml diff --git a/.github/workflows/one-shot-pr44-security-fix-v2.yml b/.github/workflows/one-shot-pr44-security-fix-v2.yml new file mode 100644 index 00000000..e19a575b --- /dev/null +++ b/.github/workflows/one-shot-pr44-security-fix-v2.yml @@ -0,0 +1,227 @@ +name: One-shot PR44 security fix v2 + +on: + push: + branches: [codex/storycore-game-contract-v0.1] + paths: [.github/workflows/one-shot-pr44-security-fix-v2.yml] + +permissions: + contents: write + +jobs: + patch: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + ref: codex/storycore-game-contract-v0.1 + fetch-depth: 0 + + - name: Apply narrowly scoped path-safety hardening + shell: bash + run: | + python - <<'PY' + from pathlib import Path + + contract_path = Path('src/game_bridge/contract.py') + tests_path = Path('tests/game_bridge/test_contract.py') + contract = contract_path.read_text(encoding='utf-8') + tests = tests_path.read_text(encoding='utf-8') + + def replace_once(text: str, old: str, new: str, label: str) -> str: + count = text.count(old) + if count != 1: + raise SystemExit(f'{label}: expected exactly one match, found {count}') + return text.replace(old, new, 1) + + def replace_between(text: str, start: str, end: str, replacement: str, label: str) -> str: + start_at = text.find(start) + if start_at < 0: + raise SystemExit(f'{label}: start anchor not found') + end_at = text.find(end, start_at) + if end_at < 0: + raise SystemExit(f'{label}: end anchor not found') + return text[:start_at] + replacement + text[end_at:] + + contract = replace_once(contract, 'import hashlib\nimport json\nimport re\n', 'import hashlib\nimport json\nimport os\nimport re\n', 'imports') + contract = replace_once( + contract, + '_GODOT_VERSION_PATTERN = re.compile(r"^4\\.\\d+(?:\\.\\d+)?$")\n', + '_GODOT_VERSION_PATTERN = re.compile(r"^4\\.\\d+(?:\\.\\d+)?$")\n_CLI_RELATIVE_PATH_PATTERN = re.compile(r"^[A-Za-z0-9._/-]+$")\n', + 'CLI path allowlist', + ) + + hardened_helpers = '''def _require_relative_cli_path(value: str, label: str) -> Path: + """Accept only an allowlisted workspace-relative CLI path.""" + + if not value or not _CLI_RELATIVE_PATH_PATTERN.fullmatch(value): + _fail(label, "must be an allowlisted workspace-relative path") + candidate = Path(value) + if candidate.is_absolute() or ".." in candidate.parts: + _fail(label, "must be an allowlisted workspace-relative path") + return candidate + + + def _resolve_workspace_path( + candidate: Path, + workspace_root: Path, + label: str, + *, + must_be_file: bool = False, + must_be_dir: bool = False, + ) -> Path: + """Resolve a validated relative path without allowing symlink escape.""" + + try: + root = workspace_root.resolve(strict=True) + resolved = (root / candidate).resolve(strict=True) + relative = resolved.relative_to(root) + except (OSError, RuntimeError, ValueError) as exc: + raise ContractError( + f"{label}: must stay within workspace root {workspace_root}" + ) from exc + if not root.is_dir(): + _fail("workspace", "root must be a directory") + if must_be_file and not resolved.is_file(): + _fail(label, "must reference an existing regular file") + if must_be_dir and not resolved.is_dir(): + _fail(label, "must reference an existing directory") + return root.joinpath(relative) + + + def _load_json(path: Path) -> dict[str, Any]: + try: + loaded = json.loads(path.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError) as exc: + raise ContractError(f"unable to read {path}: {exc}") from exc + return _require_mapping(loaded, str(path)) + + + def _write_json(path: Path, value: Mapping[str, Any]) -> None: + """Write a fixed-name output without following a final-component symlink.""" + + flags = os.O_WRONLY | os.O_CREAT | os.O_TRUNC + if hasattr(os, "O_NOFOLLOW"): + flags |= os.O_NOFOLLOW + descriptor: int | None = None + try: + descriptor = os.open(path, flags, 0o600) + with os.fdopen(descriptor, "w", encoding="utf-8") as handle: + descriptor = None + handle.write(json.dumps(value, ensure_ascii=False, sort_keys=True, indent=2) + "\\n") + except OSError as exc: + raise ContractError(f"unable to write {path}: {exc}") from exc + finally: + if descriptor is not None: + os.close(descriptor) + + + '''.replace(' ', '') + contract = replace_between(contract, 'def _resolve_workspace_path(', 'def main(', hardened_helpers, 'path helper region') + + old_cli = ''' parser.add_argument("--input", required=True, type=Path) + parser.add_argument("--output-dir", required=True, type=Path) + args = parser.parse_args(argv) + + try: + workspace_root = Path.cwd().resolve(strict=True) + input_path = _resolve_workspace_path( + args.input, workspace_root, "--input", must_be_file=True + ) + output_dir = _resolve_workspace_path( + args.output_dir, workspace_root, "--output-dir" + ) + manifest_path = _resolve_workspace_path( + output_dir / "storycore_game_manifest.json", + workspace_root, + "manifest output", + ) + evidence_path = _resolve_workspace_path( + output_dir / "storycore_game_evidence.json", + workspace_root, + "evidence output", + ) + '''.replace(' ', '') + new_cli = ''' parser.add_argument("--input", required=True) + parser.add_argument("--output-dir", required=True) + args = parser.parse_args(argv) + + try: + workspace_root = Path.cwd().resolve(strict=True) + input_relative = _require_relative_cli_path(args.input, "--input") + output_relative = _require_relative_cli_path(args.output_dir, "--output-dir") + input_path = _resolve_workspace_path( + input_relative, workspace_root, "--input", must_be_file=True + ) + output_dir = _resolve_workspace_path( + output_relative, + workspace_root, + "--output-dir", + must_be_dir=True, + ) + # Output filenames are constants, never user-controlled components. + manifest_path = output_dir / "storycore_game_manifest.json" + evidence_path = output_dir / "storycore_game_evidence.json" + '''.replace(' ', '') + contract = replace_once(contract, old_cli, new_cli, 'main CLI path handling') + + tests = replace_once( + tests, + ' ["--input", str(FIXTURE), "--output-dir", str(output)]\n', + ' [\n "--input",\n str(FIXTURE.relative_to(ROOT)),\n "--output-dir",\n str(output.relative_to(ROOT)),\n ]\n', + 'CLI happy path', + ) + + negative_tests = ''' def test_cli_rejects_absolute_paths(self) -> None: + errors = io.StringIO() + with redirect_stderr(errors), self.assertRaises(SystemExit) as raised: + main(["--input", str(FIXTURE), "--output-dir", "fixtures"]) + self.assertEqual(raised.exception.code, 2) + self.assertIn("allowlisted workspace-relative path", errors.getvalue()) + + def test_cli_rejects_parent_traversal(self) -> None: + errors = io.StringIO() + with redirect_stderr(errors), self.assertRaises(SystemExit) as raised: + main([ + "--input", str(FIXTURE.relative_to(ROOT)), + "--output-dir", "../storycore-game-escape", + ]) + self.assertEqual(raised.exception.code, 2) + self.assertIn("allowlisted workspace-relative path", errors.getvalue()) + + def test_cli_rejects_non_allowlisted_path_characters(self) -> None: + errors = io.StringIO() + with redirect_stderr(errors), self.assertRaises(SystemExit) as raised: + main([ + "--input", str(FIXTURE.relative_to(ROOT)), + "--output-dir", "fixtures/storycore-game/$escape", + ]) + self.assertEqual(raised.exception.code, 2) + self.assertIn("allowlisted workspace-relative path", errors.getvalue()) + + '''.replace(' ', '') + tests = replace_between( + tests, + ' def test_cli_rejects_paths_outside_workspace(self) -> None:', + ' def test_checked_in_godot_inputs_match_compiler(self) -> None:', + negative_tests, + 'CLI negative-test region', + ) + + contract_path.write_text(contract, encoding='utf-8') + tests_path.write_text(tests, encoding='utf-8') + PY + + - name: Validate focused contract suite + run: | + python -m compileall -q src/game_bridge tests/game_bridge + python -m unittest discover -s tests/game_bridge -v + + - name: Commit scoped fix + run: | + git diff --check + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + git add src/game_bridge/contract.py tests/game_bridge/test_contract.py + git commit -m "fix(game-bridge): harden CLI workspace paths" + git push origin HEAD:codex/storycore-game-contract-v0.1 From 7f4100dcdf4ed827f5ee057eea119e86780363a4 Mon Sep 17 00:00:00 2001 From: Sylvain Galliez <128614184+zedarvates@users.noreply.github.com> Date: Sun, 30 Aug 2026 11:02:13 +0200 Subject: [PATCH 053/113] chore(ci): remove failed one-shot PR44 patch workflow --- .../workflows/one-shot-pr44-security-fix.yml | 261 ------------------ 1 file changed, 261 deletions(-) delete mode 100644 .github/workflows/one-shot-pr44-security-fix.yml diff --git a/.github/workflows/one-shot-pr44-security-fix.yml b/.github/workflows/one-shot-pr44-security-fix.yml deleted file mode 100644 index 976fc1a5..00000000 --- a/.github/workflows/one-shot-pr44-security-fix.yml +++ /dev/null @@ -1,261 +0,0 @@ -name: One-shot PR44 security fix - -on: - push: - branches: [codex/storycore-game-contract-v0.1] - paths: [.github/workflows/one-shot-pr44-security-fix.yml] - -permissions: - contents: write - -jobs: - patch: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - with: - ref: codex/storycore-game-contract-v0.1 - fetch-depth: 0 - - - name: Apply narrowly scoped path-safety hardening - shell: bash - run: | - python - <<'PY' - from pathlib import Path - - contract_path = Path('src/game_bridge/contract.py') - tests_path = Path('tests/game_bridge/test_contract.py') - contract = contract_path.read_text(encoding='utf-8') - tests = tests_path.read_text(encoding='utf-8') - - def replace_once(text: str, old: str, new: str, label: str) -> str: - count = text.count(old) - if count != 1: - raise SystemExit(f'{label}: expected exactly one match, found {count}') - return text.replace(old, new, 1) - - def replace_between(text: str, start: str, end: str, replacement: str, label: str) -> str: - start_at = text.find(start) - if start_at < 0: - raise SystemExit(f'{label}: start anchor not found') - end_at = text.find(end, start_at) - if end_at < 0: - raise SystemExit(f'{label}: end anchor not found') - return text[:start_at] + replacement + text[end_at:] - - contract = replace_once( - contract, - 'import hashlib\nimport json\nimport re\n', - 'import hashlib\nimport json\nimport os\nimport re\n', - 'imports', - ) - contract = replace_once( - contract, - '_GODOT_VERSION_PATTERN = re.compile(r"^4\\.\\d+(?:\\.\\d+)?$")\n', - '_GODOT_VERSION_PATTERN = re.compile(r"^4\\.\\d+(?:\\.\\d+)?$")\n' - '_CLI_RELATIVE_PATH_PATTERN = re.compile(r"^[A-Za-z0-9._/-]+$")\n', - 'CLI path allowlist', - ) - - hardened_helpers = '''def _require_relative_cli_path(value: str, label: str) -> Path: - """Accept only an allowlisted workspace-relative CLI path.""" - - if not value or not _CLI_RELATIVE_PATH_PATTERN.fullmatch(value): - _fail(label, "must be an allowlisted workspace-relative path") - candidate = Path(value) - if candidate.is_absolute() or ".." in candidate.parts: - _fail(label, "must be an allowlisted workspace-relative path") - return candidate - - - def _resolve_workspace_path( - candidate: Path, - workspace_root: Path, - label: str, - *, - must_be_file: bool = False, - must_be_dir: bool = False, - ) -> Path: - """Resolve a validated relative path without allowing symlink escape.""" - - try: - root = workspace_root.resolve(strict=True) - resolved = (root / candidate).resolve(strict=True) - relative = resolved.relative_to(root) - except (OSError, RuntimeError, ValueError) as exc: - raise ContractError( - f"{label}: must stay within workspace root {workspace_root}" - ) from exc - if not root.is_dir(): - _fail("workspace", "root must be a directory") - if must_be_file and not resolved.is_file(): - _fail(label, "must reference an existing regular file") - if must_be_dir and not resolved.is_dir(): - _fail(label, "must reference an existing directory") - return root.joinpath(relative) - - - def _load_json(path: Path) -> dict[str, Any]: - try: - loaded = json.loads(path.read_text(encoding="utf-8")) - except (OSError, json.JSONDecodeError) as exc: - raise ContractError(f"unable to read {path}: {exc}") from exc - return _require_mapping(loaded, str(path)) - - - def _write_json(path: Path, value: Mapping[str, Any]) -> None: - """Write a fixed-name output without following a final-component symlink.""" - - flags = os.O_WRONLY | os.O_CREAT | os.O_TRUNC - if hasattr(os, "O_NOFOLLOW"): - flags |= os.O_NOFOLLOW - descriptor: int | None = None - try: - descriptor = os.open(path, flags, 0o600) - with os.fdopen(descriptor, "w", encoding="utf-8") as handle: - descriptor = None - handle.write( - json.dumps(value, ensure_ascii=False, sort_keys=True, indent=2) - + "\\n" - ) - except OSError as exc: - raise ContractError(f"unable to write {path}: {exc}") from exc - finally: - if descriptor is not None: - os.close(descriptor) - - - '''.replace(' ', '') - contract = replace_between( - contract, - 'def _resolve_workspace_path(', - 'def main(', - hardened_helpers, - 'path helper region', - ) - - old_cli = ''' parser.add_argument("--input", required=True, type=Path) - parser.add_argument("--output-dir", required=True, type=Path) - args = parser.parse_args(argv) - - try: - workspace_root = Path.cwd().resolve(strict=True) - input_path = _resolve_workspace_path( - args.input, workspace_root, "--input", must_be_file=True - ) - output_dir = _resolve_workspace_path( - args.output_dir, workspace_root, "--output-dir" - ) - manifest_path = _resolve_workspace_path( - output_dir / "storycore_game_manifest.json", - workspace_root, - "manifest output", - ) - evidence_path = _resolve_workspace_path( - output_dir / "storycore_game_evidence.json", - workspace_root, - "evidence output", - ) - '''.replace(' ', '') - new_cli = ''' parser.add_argument("--input", required=True) - parser.add_argument("--output-dir", required=True) - args = parser.parse_args(argv) - - try: - workspace_root = Path.cwd().resolve(strict=True) - input_relative = _require_relative_cli_path(args.input, "--input") - output_relative = _require_relative_cli_path(args.output_dir, "--output-dir") - input_path = _resolve_workspace_path( - input_relative, workspace_root, "--input", must_be_file=True - ) - output_dir = _resolve_workspace_path( - output_relative, - workspace_root, - "--output-dir", - must_be_dir=True, - ) - # Output filenames are constants, never user-controlled components. - manifest_path = output_dir / "storycore_game_manifest.json" - evidence_path = output_dir / "storycore_game_evidence.json" - '''.replace(' ', '') - contract = replace_once(contract, old_cli, new_cli, 'main CLI path handling') - - tests = replace_once( - tests, - ' ["--input", str(FIXTURE), "--output-dir", str(output)]\n', - ' [\n' - ' "--input",\n' - ' str(FIXTURE.relative_to(ROOT)),\n' - ' "--output-dir",\n' - ' str(output.relative_to(ROOT)),\n' - ' ]\n', - 'CLI happy path', - ) - - negative_tests = ''' def test_cli_rejects_absolute_paths(self) -> None: - errors = io.StringIO() - - with redirect_stderr(errors), self.assertRaises(SystemExit) as raised: - main(["--input", str(FIXTURE), "--output-dir", "fixtures"]) - - self.assertEqual(raised.exception.code, 2) - self.assertIn("allowlisted workspace-relative path", errors.getvalue()) - - def test_cli_rejects_parent_traversal(self) -> None: - errors = io.StringIO() - - with redirect_stderr(errors), self.assertRaises(SystemExit) as raised: - main( - [ - "--input", - str(FIXTURE.relative_to(ROOT)), - "--output-dir", - "../storycore-game-escape", - ] - ) - - self.assertEqual(raised.exception.code, 2) - self.assertIn("allowlisted workspace-relative path", errors.getvalue()) - - def test_cli_rejects_non_allowlisted_path_characters(self) -> None: - errors = io.StringIO() - - with redirect_stderr(errors), self.assertRaises(SystemExit) as raised: - main( - [ - "--input", - str(FIXTURE.relative_to(ROOT)), - "--output-dir", - "fixtures/storycore-game/$escape", - ] - ) - - self.assertEqual(raised.exception.code, 2) - self.assertIn("allowlisted workspace-relative path", errors.getvalue()) - - '''.replace(' ', '') - tests = replace_between( - tests, - ' def test_cli_rejects_paths_outside_workspace(self) -> None:', - ' def test_checked_in_godot_inputs_match_compiler(self) -> None:', - negative_tests, - 'CLI negative-test region', - ) - - contract_path.write_text(contract, encoding='utf-8') - tests_path.write_text(tests, encoding='utf-8') - PY - - - name: Validate focused contract suite - run: | - python -m compileall -q src/game_bridge tests/game_bridge - python -m unittest discover -s tests/game_bridge -v - - - name: Commit scoped fix - run: | - git diff --check - git config user.name "github-actions[bot]" - git config user.email "41898282+github-actions[bot]@users.noreply.github.com" - git add src/game_bridge/contract.py tests/game_bridge/test_contract.py - git commit -m "fix(game-bridge): harden CLI workspace paths" - git push origin HEAD:codex/storycore-game-contract-v0.1 From 6094fde1c89725ed2c8f27b67ca9f403c3fb740e Mon Sep 17 00:00:00 2001 From: Sylvain Galliez <128614184+zedarvates@users.noreply.github.com> Date: Sun, 30 Aug 2026 11:02:41 +0200 Subject: [PATCH 054/113] chore(ci): remove redundant one-shot PR44 patch workflow v2 --- .../one-shot-pr44-security-fix-v2.yml | 227 ------------------ 1 file changed, 227 deletions(-) delete mode 100644 .github/workflows/one-shot-pr44-security-fix-v2.yml diff --git a/.github/workflows/one-shot-pr44-security-fix-v2.yml b/.github/workflows/one-shot-pr44-security-fix-v2.yml deleted file mode 100644 index e19a575b..00000000 --- a/.github/workflows/one-shot-pr44-security-fix-v2.yml +++ /dev/null @@ -1,227 +0,0 @@ -name: One-shot PR44 security fix v2 - -on: - push: - branches: [codex/storycore-game-contract-v0.1] - paths: [.github/workflows/one-shot-pr44-security-fix-v2.yml] - -permissions: - contents: write - -jobs: - patch: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - with: - ref: codex/storycore-game-contract-v0.1 - fetch-depth: 0 - - - name: Apply narrowly scoped path-safety hardening - shell: bash - run: | - python - <<'PY' - from pathlib import Path - - contract_path = Path('src/game_bridge/contract.py') - tests_path = Path('tests/game_bridge/test_contract.py') - contract = contract_path.read_text(encoding='utf-8') - tests = tests_path.read_text(encoding='utf-8') - - def replace_once(text: str, old: str, new: str, label: str) -> str: - count = text.count(old) - if count != 1: - raise SystemExit(f'{label}: expected exactly one match, found {count}') - return text.replace(old, new, 1) - - def replace_between(text: str, start: str, end: str, replacement: str, label: str) -> str: - start_at = text.find(start) - if start_at < 0: - raise SystemExit(f'{label}: start anchor not found') - end_at = text.find(end, start_at) - if end_at < 0: - raise SystemExit(f'{label}: end anchor not found') - return text[:start_at] + replacement + text[end_at:] - - contract = replace_once(contract, 'import hashlib\nimport json\nimport re\n', 'import hashlib\nimport json\nimport os\nimport re\n', 'imports') - contract = replace_once( - contract, - '_GODOT_VERSION_PATTERN = re.compile(r"^4\\.\\d+(?:\\.\\d+)?$")\n', - '_GODOT_VERSION_PATTERN = re.compile(r"^4\\.\\d+(?:\\.\\d+)?$")\n_CLI_RELATIVE_PATH_PATTERN = re.compile(r"^[A-Za-z0-9._/-]+$")\n', - 'CLI path allowlist', - ) - - hardened_helpers = '''def _require_relative_cli_path(value: str, label: str) -> Path: - """Accept only an allowlisted workspace-relative CLI path.""" - - if not value or not _CLI_RELATIVE_PATH_PATTERN.fullmatch(value): - _fail(label, "must be an allowlisted workspace-relative path") - candidate = Path(value) - if candidate.is_absolute() or ".." in candidate.parts: - _fail(label, "must be an allowlisted workspace-relative path") - return candidate - - - def _resolve_workspace_path( - candidate: Path, - workspace_root: Path, - label: str, - *, - must_be_file: bool = False, - must_be_dir: bool = False, - ) -> Path: - """Resolve a validated relative path without allowing symlink escape.""" - - try: - root = workspace_root.resolve(strict=True) - resolved = (root / candidate).resolve(strict=True) - relative = resolved.relative_to(root) - except (OSError, RuntimeError, ValueError) as exc: - raise ContractError( - f"{label}: must stay within workspace root {workspace_root}" - ) from exc - if not root.is_dir(): - _fail("workspace", "root must be a directory") - if must_be_file and not resolved.is_file(): - _fail(label, "must reference an existing regular file") - if must_be_dir and not resolved.is_dir(): - _fail(label, "must reference an existing directory") - return root.joinpath(relative) - - - def _load_json(path: Path) -> dict[str, Any]: - try: - loaded = json.loads(path.read_text(encoding="utf-8")) - except (OSError, json.JSONDecodeError) as exc: - raise ContractError(f"unable to read {path}: {exc}") from exc - return _require_mapping(loaded, str(path)) - - - def _write_json(path: Path, value: Mapping[str, Any]) -> None: - """Write a fixed-name output without following a final-component symlink.""" - - flags = os.O_WRONLY | os.O_CREAT | os.O_TRUNC - if hasattr(os, "O_NOFOLLOW"): - flags |= os.O_NOFOLLOW - descriptor: int | None = None - try: - descriptor = os.open(path, flags, 0o600) - with os.fdopen(descriptor, "w", encoding="utf-8") as handle: - descriptor = None - handle.write(json.dumps(value, ensure_ascii=False, sort_keys=True, indent=2) + "\\n") - except OSError as exc: - raise ContractError(f"unable to write {path}: {exc}") from exc - finally: - if descriptor is not None: - os.close(descriptor) - - - '''.replace(' ', '') - contract = replace_between(contract, 'def _resolve_workspace_path(', 'def main(', hardened_helpers, 'path helper region') - - old_cli = ''' parser.add_argument("--input", required=True, type=Path) - parser.add_argument("--output-dir", required=True, type=Path) - args = parser.parse_args(argv) - - try: - workspace_root = Path.cwd().resolve(strict=True) - input_path = _resolve_workspace_path( - args.input, workspace_root, "--input", must_be_file=True - ) - output_dir = _resolve_workspace_path( - args.output_dir, workspace_root, "--output-dir" - ) - manifest_path = _resolve_workspace_path( - output_dir / "storycore_game_manifest.json", - workspace_root, - "manifest output", - ) - evidence_path = _resolve_workspace_path( - output_dir / "storycore_game_evidence.json", - workspace_root, - "evidence output", - ) - '''.replace(' ', '') - new_cli = ''' parser.add_argument("--input", required=True) - parser.add_argument("--output-dir", required=True) - args = parser.parse_args(argv) - - try: - workspace_root = Path.cwd().resolve(strict=True) - input_relative = _require_relative_cli_path(args.input, "--input") - output_relative = _require_relative_cli_path(args.output_dir, "--output-dir") - input_path = _resolve_workspace_path( - input_relative, workspace_root, "--input", must_be_file=True - ) - output_dir = _resolve_workspace_path( - output_relative, - workspace_root, - "--output-dir", - must_be_dir=True, - ) - # Output filenames are constants, never user-controlled components. - manifest_path = output_dir / "storycore_game_manifest.json" - evidence_path = output_dir / "storycore_game_evidence.json" - '''.replace(' ', '') - contract = replace_once(contract, old_cli, new_cli, 'main CLI path handling') - - tests = replace_once( - tests, - ' ["--input", str(FIXTURE), "--output-dir", str(output)]\n', - ' [\n "--input",\n str(FIXTURE.relative_to(ROOT)),\n "--output-dir",\n str(output.relative_to(ROOT)),\n ]\n', - 'CLI happy path', - ) - - negative_tests = ''' def test_cli_rejects_absolute_paths(self) -> None: - errors = io.StringIO() - with redirect_stderr(errors), self.assertRaises(SystemExit) as raised: - main(["--input", str(FIXTURE), "--output-dir", "fixtures"]) - self.assertEqual(raised.exception.code, 2) - self.assertIn("allowlisted workspace-relative path", errors.getvalue()) - - def test_cli_rejects_parent_traversal(self) -> None: - errors = io.StringIO() - with redirect_stderr(errors), self.assertRaises(SystemExit) as raised: - main([ - "--input", str(FIXTURE.relative_to(ROOT)), - "--output-dir", "../storycore-game-escape", - ]) - self.assertEqual(raised.exception.code, 2) - self.assertIn("allowlisted workspace-relative path", errors.getvalue()) - - def test_cli_rejects_non_allowlisted_path_characters(self) -> None: - errors = io.StringIO() - with redirect_stderr(errors), self.assertRaises(SystemExit) as raised: - main([ - "--input", str(FIXTURE.relative_to(ROOT)), - "--output-dir", "fixtures/storycore-game/$escape", - ]) - self.assertEqual(raised.exception.code, 2) - self.assertIn("allowlisted workspace-relative path", errors.getvalue()) - - '''.replace(' ', '') - tests = replace_between( - tests, - ' def test_cli_rejects_paths_outside_workspace(self) -> None:', - ' def test_checked_in_godot_inputs_match_compiler(self) -> None:', - negative_tests, - 'CLI negative-test region', - ) - - contract_path.write_text(contract, encoding='utf-8') - tests_path.write_text(tests, encoding='utf-8') - PY - - - name: Validate focused contract suite - run: | - python -m compileall -q src/game_bridge tests/game_bridge - python -m unittest discover -s tests/game_bridge -v - - - name: Commit scoped fix - run: | - git diff --check - git config user.name "github-actions[bot]" - git config user.email "41898282+github-actions[bot]@users.noreply.github.com" - git add src/game_bridge/contract.py tests/game_bridge/test_contract.py - git commit -m "fix(game-bridge): harden CLI workspace paths" - git push origin HEAD:codex/storycore-game-contract-v0.1 From 05fed062270cad6ebe3cdbdda18cf573ae83c4ac Mon Sep 17 00:00:00 2001 From: Sylvain Galliez <128614184+zedarvates@users.noreply.github.com> Date: Sun, 30 Aug 2026 11:03:15 +0200 Subject: [PATCH 055/113] ci: apply validated PR44 security patch v3 --- .../one-shot-pr44-security-fix-v3.yml | 277 ++++++++++++++++++ 1 file changed, 277 insertions(+) create mode 100644 .github/workflows/one-shot-pr44-security-fix-v3.yml diff --git a/.github/workflows/one-shot-pr44-security-fix-v3.yml b/.github/workflows/one-shot-pr44-security-fix-v3.yml new file mode 100644 index 00000000..9b34f37d --- /dev/null +++ b/.github/workflows/one-shot-pr44-security-fix-v3.yml @@ -0,0 +1,277 @@ +name: One-shot PR44 security fix v3 + +on: + push: + branches: [codex/storycore-game-contract-v0.1] + paths: [.github/workflows/one-shot-pr44-security-fix-v3.yml] + +permissions: + contents: write + +jobs: + patch: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + ref: codex/storycore-game-contract-v0.1 + fetch-depth: 0 + + - name: Apply scoped path-safety hardening + shell: bash + run: | + python - <<'PY' + from pathlib import Path + import textwrap + + contract_path = Path('src/game_bridge/contract.py') + tests_path = Path('tests/game_bridge/test_contract.py') + contract = contract_path.read_text(encoding='utf-8') + tests = tests_path.read_text(encoding='utf-8') + + def replace_once(text: str, old: str, new: str, label: str) -> str: + count = text.count(old) + if count != 1: + raise SystemExit(f'{label}: expected exactly one match, found {count}') + return text.replace(old, new, 1) + + def replace_between(text: str, start: str, end: str, replacement: str, label: str) -> str: + start_at = text.find(start) + if start_at < 0: + raise SystemExit(f'{label}: start anchor not found') + end_at = text.find(end, start_at) + if end_at < 0: + raise SystemExit(f'{label}: end anchor not found') + return text[:start_at] + replacement + text[end_at:] + + contract = replace_once( + contract, + 'import hashlib\nimport json\nimport re\n', + 'import hashlib\nimport json\nimport os\nimport re\n', + 'imports', + ) + contract = replace_once( + contract, + '_GODOT_VERSION_PATTERN = re.compile(r"^4\\.\\d+(?:\\.\\d+)?$")\n', + '_GODOT_VERSION_PATTERN = re.compile(r"^4\\.\\d+(?:\\.\\d+)?$")\n' + '_CLI_RELATIVE_PATH_PATTERN = re.compile(r"^[A-Za-z0-9._/-]+$")\n', + 'CLI path allowlist', + ) + + helpers = textwrap.dedent('''\ + def _require_relative_cli_path(value: str, label: str) -> Path: + """Accept only an allowlisted workspace-relative CLI path.""" + + if not value or not _CLI_RELATIVE_PATH_PATTERN.fullmatch(value): + _fail(label, "must be an allowlisted workspace-relative path") + candidate = Path(value) + if candidate.is_absolute() or ".." in candidate.parts: + _fail(label, "must be an allowlisted workspace-relative path") + return candidate + + + def _resolve_workspace_path( + candidate: Path, + workspace_root: Path, + label: str, + *, + must_be_file: bool = False, + must_be_dir: bool = False, + ) -> Path: + """Resolve a validated relative path without allowing symlink escape.""" + + try: + root = workspace_root.resolve(strict=True) + resolved = (root / candidate).resolve(strict=True) + relative = resolved.relative_to(root) + except (OSError, RuntimeError, ValueError) as exc: + raise ContractError( + f"{label}: must stay within workspace root {workspace_root}" + ) from exc + if not root.is_dir(): + _fail("workspace", "root must be a directory") + if must_be_file and not resolved.is_file(): + _fail(label, "must reference an existing regular file") + if must_be_dir and not resolved.is_dir(): + _fail(label, "must reference an existing directory") + return root.joinpath(relative) + + + def _load_json(path: Path) -> dict[str, Any]: + try: + loaded = json.loads(path.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError) as exc: + raise ContractError(f"unable to read {path}: {exc}") from exc + return _require_mapping(loaded, str(path)) + + + def _write_json(path: Path, value: Mapping[str, Any]) -> None: + """Write a fixed-name output without following a final-component symlink.""" + + flags = os.O_WRONLY | os.O_CREAT | os.O_TRUNC + if hasattr(os, "O_NOFOLLOW"): + flags |= os.O_NOFOLLOW + descriptor: int | None = None + try: + descriptor = os.open(path, flags, 0o600) + with os.fdopen(descriptor, "w", encoding="utf-8") as handle: + descriptor = None + handle.write( + json.dumps(value, ensure_ascii=False, sort_keys=True, indent=2) + + "\\n" + ) + except OSError as exc: + raise ContractError(f"unable to write {path}: {exc}") from exc + finally: + if descriptor is not None: + os.close(descriptor) + + + ''') + contract = replace_between( + contract, + 'def _resolve_workspace_path(', + 'def main(', + helpers, + 'path helper region', + ) + + hardened_main = textwrap.dedent('''\ + def main(argv: Sequence[str] | None = None) -> int: + parser = argparse.ArgumentParser( + description="Compile a public StoryCore game specification" + ) + parser.add_argument("--input", required=True) + parser.add_argument("--output-dir", required=True) + args = parser.parse_args(argv) + + try: + workspace_root = Path.cwd().resolve(strict=True) + input_relative = _require_relative_cli_path(args.input, "--input") + output_relative = _require_relative_cli_path( + args.output_dir, "--output-dir" + ) + input_path = _resolve_workspace_path( + input_relative, workspace_root, "--input", must_be_file=True + ) + output_dir = _resolve_workspace_path( + output_relative, + workspace_root, + "--output-dir", + must_be_dir=True, + ) + # Output filenames are constants, never user-controlled path components. + manifest_path = output_dir / "storycore_game_manifest.json" + evidence_path = output_dir / "storycore_game_evidence.json" + manifest, evidence = compile_spec(_load_json(input_path)) + _write_json(manifest_path, manifest) + _write_json(evidence_path, evidence) + except ContractError as exc: + parser.error(str(exc)) + + print(f"manifest={manifest_path}") + print(f"evidence={evidence_path}") + print(f"content_sha256={manifest['content_sha256']}") + return 0 + + + ''') + contract = replace_between( + contract, + 'def main(', + 'if __name__ == "__main__":', + hardened_main, + 'main function', + ) + + cli_tests = textwrap.indent(textwrap.dedent('''\ + def test_cli_writes_manifest_and_evidence(self) -> None: + with tempfile.TemporaryDirectory(dir=ROOT) as directory: + output = Path(directory) + + result = main( + [ + "--input", + str(FIXTURE.relative_to(ROOT)), + "--output-dir", + str(output.relative_to(ROOT)), + ] + ) + + self.assertEqual(result, 0) + manifest = json.loads( + (output / "storycore_game_manifest.json").read_text(encoding="utf-8") + ) + evidence = json.loads( + (output / "storycore_game_evidence.json").read_text(encoding="utf-8") + ) + self.assertTrue(verify_manifest(manifest)) + self.assertEqual(manifest["content_sha256"], evidence["content_sha256"]) + + def test_cli_rejects_absolute_paths(self) -> None: + errors = io.StringIO() + + with redirect_stderr(errors), self.assertRaises(SystemExit) as raised: + main(["--input", str(FIXTURE), "--output-dir", "fixtures"]) + + self.assertEqual(raised.exception.code, 2) + self.assertIn("allowlisted workspace-relative path", errors.getvalue()) + + def test_cli_rejects_parent_traversal(self) -> None: + errors = io.StringIO() + + with redirect_stderr(errors), self.assertRaises(SystemExit) as raised: + main( + [ + "--input", + str(FIXTURE.relative_to(ROOT)), + "--output-dir", + "../storycore-game-escape", + ] + ) + + self.assertEqual(raised.exception.code, 2) + self.assertIn("allowlisted workspace-relative path", errors.getvalue()) + + def test_cli_rejects_non_allowlisted_path_characters(self) -> None: + errors = io.StringIO() + + with redirect_stderr(errors), self.assertRaises(SystemExit) as raised: + main( + [ + "--input", + str(FIXTURE.relative_to(ROOT)), + "--output-dir", + "fixtures/storycore-game/$escape", + ] + ) + + self.assertEqual(raised.exception.code, 2) + self.assertIn("allowlisted workspace-relative path", errors.getvalue()) + + '''), ' ') + tests = replace_between( + tests, + ' def test_cli_writes_manifest_and_evidence(self) -> None:', + ' def test_checked_in_godot_inputs_match_compiler(self) -> None:', + cli_tests, + 'CLI test region', + ) + + contract_path.write_text(contract, encoding='utf-8') + tests_path.write_text(tests, encoding='utf-8') + PY + + - name: Validate focused contract suite + run: | + python -m compileall -q src/game_bridge tests/game_bridge + python -m unittest discover -s tests/game_bridge -v + git diff --check + + - name: Commit scoped fix + run: | + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + git add src/game_bridge/contract.py tests/game_bridge/test_contract.py + git commit -m "fix(game-bridge): harden CLI workspace paths" + git push origin HEAD:codex/storycore-game-contract-v0.1 From cd1027278a0cc55949c4e9f2f12f73f5bb328d1b Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Sun, 30 Aug 2026 09:03:30 +0000 Subject: [PATCH 056/113] fix(game-bridge): harden CLI workspace paths --- src/game_bridge/contract.py | 68 +++++++++++++++++++++--------- tests/game_bridge/test_contract.py | 56 +++++++++++++++++++----- 2 files changed, 93 insertions(+), 31 deletions(-) diff --git a/src/game_bridge/contract.py b/src/game_bridge/contract.py index a8d58cb8..ca9185d7 100644 --- a/src/game_bridge/contract.py +++ b/src/game_bridge/contract.py @@ -11,6 +11,7 @@ import argparse import hashlib import json +import os import re from pathlib import Path from typing import Any, Mapping, Sequence @@ -21,6 +22,7 @@ _ID_PATTERN = re.compile(r"^[a-z0-9][a-z0-9_-]*$") _LOCALE_PATTERN = re.compile(r"^[a-z]{2}(?:-[A-Z]{2})?$") _GODOT_VERSION_PATTERN = re.compile(r"^4\.\d+(?:\.\d+)?$") +_CLI_RELATIVE_PATH_PATTERN = re.compile(r"^[A-Za-z0-9._/-]+$") _ACTORS_PATH = "spec.actors" _ITEMS_PATH = "spec.items" _OBJECTIVES_PATH = "spec.quest.objectives" @@ -486,19 +488,30 @@ def verify_manifest(manifest: Mapping[str, Any]) -> bool: ) +def _require_relative_cli_path(value: str, label: str) -> Path: + """Accept only an allowlisted workspace-relative CLI path.""" + + if not value or not _CLI_RELATIVE_PATH_PATTERN.fullmatch(value): + _fail(label, "must be an allowlisted workspace-relative path") + candidate = Path(value) + if candidate.is_absolute() or ".." in candidate.parts: + _fail(label, "must be an allowlisted workspace-relative path") + return candidate + + def _resolve_workspace_path( candidate: Path, workspace_root: Path, label: str, *, must_be_file: bool = False, + must_be_dir: bool = False, ) -> Path: - """Resolve a CLI path without allowing workspace or symlink escape.""" + """Resolve a validated relative path without allowing symlink escape.""" try: root = workspace_root.resolve(strict=True) - unresolved = candidate if candidate.is_absolute() else root / candidate - resolved = unresolved.resolve(strict=must_be_file) + resolved = (root / candidate).resolve(strict=True) relative = resolved.relative_to(root) except (OSError, RuntimeError, ValueError) as exc: raise ContractError( @@ -508,6 +521,8 @@ def _resolve_workspace_path( _fail("workspace", "root must be a directory") if must_be_file and not resolved.is_file(): _fail(label, "must reference an existing regular file") + if must_be_dir and not resolved.is_dir(): + _fail(label, "must reference an existing directory") return root.joinpath(relative) @@ -520,42 +535,53 @@ def _load_json(path: Path) -> dict[str, Any]: def _write_json(path: Path, value: Mapping[str, Any]) -> None: + """Write a fixed-name output without following a final-component symlink.""" + + flags = os.O_WRONLY | os.O_CREAT | os.O_TRUNC + if hasattr(os, "O_NOFOLLOW"): + flags |= os.O_NOFOLLOW + descriptor: int | None = None try: - path.parent.mkdir(parents=True, exist_ok=True) - path.write_text( - json.dumps(value, ensure_ascii=False, sort_keys=True, indent=2) + "\n", - encoding="utf-8", - ) + descriptor = os.open(path, flags, 0o600) + with os.fdopen(descriptor, "w", encoding="utf-8") as handle: + descriptor = None + handle.write( + json.dumps(value, ensure_ascii=False, sort_keys=True, indent=2) + + "\n" + ) except OSError as exc: raise ContractError(f"unable to write {path}: {exc}") from exc + finally: + if descriptor is not None: + os.close(descriptor) def main(argv: Sequence[str] | None = None) -> int: parser = argparse.ArgumentParser( description="Compile a public StoryCore game specification" ) - parser.add_argument("--input", required=True, type=Path) - parser.add_argument("--output-dir", required=True, type=Path) + parser.add_argument("--input", required=True) + parser.add_argument("--output-dir", required=True) args = parser.parse_args(argv) try: workspace_root = Path.cwd().resolve(strict=True) + input_relative = _require_relative_cli_path(args.input, "--input") + output_relative = _require_relative_cli_path( + args.output_dir, "--output-dir" + ) input_path = _resolve_workspace_path( - args.input, workspace_root, "--input", must_be_file=True + input_relative, workspace_root, "--input", must_be_file=True ) output_dir = _resolve_workspace_path( - args.output_dir, workspace_root, "--output-dir" - ) - manifest_path = _resolve_workspace_path( - output_dir / "storycore_game_manifest.json", - workspace_root, - "manifest output", - ) - evidence_path = _resolve_workspace_path( - output_dir / "storycore_game_evidence.json", + output_relative, workspace_root, - "evidence output", + "--output-dir", + must_be_dir=True, ) + # Output filenames are constants, never user-controlled path components. + manifest_path = output_dir / "storycore_game_manifest.json" + evidence_path = output_dir / "storycore_game_evidence.json" manifest, evidence = compile_spec(_load_json(input_path)) _write_json(manifest_path, manifest) _write_json(evidence_path, evidence) diff --git a/tests/game_bridge/test_contract.py b/tests/game_bridge/test_contract.py index f8f85388..340859ce 100644 --- a/tests/game_bridge/test_contract.py +++ b/tests/game_bridge/test_contract.py @@ -95,7 +95,12 @@ def test_cli_writes_manifest_and_evidence(self) -> None: output = Path(directory) result = main( - ["--input", str(FIXTURE), "--output-dir", str(output)] + [ + "--input", + str(FIXTURE.relative_to(ROOT)), + "--output-dir", + str(output.relative_to(ROOT)), + ] ) self.assertEqual(result, 0) @@ -108,15 +113,46 @@ def test_cli_writes_manifest_and_evidence(self) -> None: self.assertTrue(verify_manifest(manifest)) self.assertEqual(manifest["content_sha256"], evidence["content_sha256"]) - def test_cli_rejects_paths_outside_workspace(self) -> None: - outside = ROOT.parent / "storycore-game-escape" - errors = io.StringIO() - - with redirect_stderr(errors), self.assertRaises(SystemExit) as raised: - main(["--input", str(FIXTURE), "--output-dir", str(outside)]) - - self.assertEqual(raised.exception.code, 2) - self.assertIn("must stay within workspace root", errors.getvalue()) + def test_cli_rejects_absolute_paths(self) -> None: + errors = io.StringIO() + + with redirect_stderr(errors), self.assertRaises(SystemExit) as raised: + main(["--input", str(FIXTURE), "--output-dir", "fixtures"]) + + self.assertEqual(raised.exception.code, 2) + self.assertIn("allowlisted workspace-relative path", errors.getvalue()) + + def test_cli_rejects_parent_traversal(self) -> None: + errors = io.StringIO() + + with redirect_stderr(errors), self.assertRaises(SystemExit) as raised: + main( + [ + "--input", + str(FIXTURE.relative_to(ROOT)), + "--output-dir", + "../storycore-game-escape", + ] + ) + + self.assertEqual(raised.exception.code, 2) + self.assertIn("allowlisted workspace-relative path", errors.getvalue()) + + def test_cli_rejects_non_allowlisted_path_characters(self) -> None: + errors = io.StringIO() + + with redirect_stderr(errors), self.assertRaises(SystemExit) as raised: + main( + [ + "--input", + str(FIXTURE.relative_to(ROOT)), + "--output-dir", + "fixtures/storycore-game/$escape", + ] + ) + + self.assertEqual(raised.exception.code, 2) + self.assertIn("allowlisted workspace-relative path", errors.getvalue()) def test_checked_in_godot_inputs_match_compiler(self) -> None: expected_manifest, expected_evidence = compile_spec(load_fixture()) From 900e68718209767692250c58be31a8b65e0611dc Mon Sep 17 00:00:00 2001 From: Sylvain Galliez <128614184+zedarvates@users.noreply.github.com> Date: Sun, 30 Aug 2026 11:03:52 +0200 Subject: [PATCH 057/113] ci: remove one-shot PR44 security patch --- .../one-shot-pr44-security-fix-v3.yml | 277 ------------------ 1 file changed, 277 deletions(-) delete mode 100644 .github/workflows/one-shot-pr44-security-fix-v3.yml diff --git a/.github/workflows/one-shot-pr44-security-fix-v3.yml b/.github/workflows/one-shot-pr44-security-fix-v3.yml deleted file mode 100644 index 9b34f37d..00000000 --- a/.github/workflows/one-shot-pr44-security-fix-v3.yml +++ /dev/null @@ -1,277 +0,0 @@ -name: One-shot PR44 security fix v3 - -on: - push: - branches: [codex/storycore-game-contract-v0.1] - paths: [.github/workflows/one-shot-pr44-security-fix-v3.yml] - -permissions: - contents: write - -jobs: - patch: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - with: - ref: codex/storycore-game-contract-v0.1 - fetch-depth: 0 - - - name: Apply scoped path-safety hardening - shell: bash - run: | - python - <<'PY' - from pathlib import Path - import textwrap - - contract_path = Path('src/game_bridge/contract.py') - tests_path = Path('tests/game_bridge/test_contract.py') - contract = contract_path.read_text(encoding='utf-8') - tests = tests_path.read_text(encoding='utf-8') - - def replace_once(text: str, old: str, new: str, label: str) -> str: - count = text.count(old) - if count != 1: - raise SystemExit(f'{label}: expected exactly one match, found {count}') - return text.replace(old, new, 1) - - def replace_between(text: str, start: str, end: str, replacement: str, label: str) -> str: - start_at = text.find(start) - if start_at < 0: - raise SystemExit(f'{label}: start anchor not found') - end_at = text.find(end, start_at) - if end_at < 0: - raise SystemExit(f'{label}: end anchor not found') - return text[:start_at] + replacement + text[end_at:] - - contract = replace_once( - contract, - 'import hashlib\nimport json\nimport re\n', - 'import hashlib\nimport json\nimport os\nimport re\n', - 'imports', - ) - contract = replace_once( - contract, - '_GODOT_VERSION_PATTERN = re.compile(r"^4\\.\\d+(?:\\.\\d+)?$")\n', - '_GODOT_VERSION_PATTERN = re.compile(r"^4\\.\\d+(?:\\.\\d+)?$")\n' - '_CLI_RELATIVE_PATH_PATTERN = re.compile(r"^[A-Za-z0-9._/-]+$")\n', - 'CLI path allowlist', - ) - - helpers = textwrap.dedent('''\ - def _require_relative_cli_path(value: str, label: str) -> Path: - """Accept only an allowlisted workspace-relative CLI path.""" - - if not value or not _CLI_RELATIVE_PATH_PATTERN.fullmatch(value): - _fail(label, "must be an allowlisted workspace-relative path") - candidate = Path(value) - if candidate.is_absolute() or ".." in candidate.parts: - _fail(label, "must be an allowlisted workspace-relative path") - return candidate - - - def _resolve_workspace_path( - candidate: Path, - workspace_root: Path, - label: str, - *, - must_be_file: bool = False, - must_be_dir: bool = False, - ) -> Path: - """Resolve a validated relative path without allowing symlink escape.""" - - try: - root = workspace_root.resolve(strict=True) - resolved = (root / candidate).resolve(strict=True) - relative = resolved.relative_to(root) - except (OSError, RuntimeError, ValueError) as exc: - raise ContractError( - f"{label}: must stay within workspace root {workspace_root}" - ) from exc - if not root.is_dir(): - _fail("workspace", "root must be a directory") - if must_be_file and not resolved.is_file(): - _fail(label, "must reference an existing regular file") - if must_be_dir and not resolved.is_dir(): - _fail(label, "must reference an existing directory") - return root.joinpath(relative) - - - def _load_json(path: Path) -> dict[str, Any]: - try: - loaded = json.loads(path.read_text(encoding="utf-8")) - except (OSError, json.JSONDecodeError) as exc: - raise ContractError(f"unable to read {path}: {exc}") from exc - return _require_mapping(loaded, str(path)) - - - def _write_json(path: Path, value: Mapping[str, Any]) -> None: - """Write a fixed-name output without following a final-component symlink.""" - - flags = os.O_WRONLY | os.O_CREAT | os.O_TRUNC - if hasattr(os, "O_NOFOLLOW"): - flags |= os.O_NOFOLLOW - descriptor: int | None = None - try: - descriptor = os.open(path, flags, 0o600) - with os.fdopen(descriptor, "w", encoding="utf-8") as handle: - descriptor = None - handle.write( - json.dumps(value, ensure_ascii=False, sort_keys=True, indent=2) - + "\\n" - ) - except OSError as exc: - raise ContractError(f"unable to write {path}: {exc}") from exc - finally: - if descriptor is not None: - os.close(descriptor) - - - ''') - contract = replace_between( - contract, - 'def _resolve_workspace_path(', - 'def main(', - helpers, - 'path helper region', - ) - - hardened_main = textwrap.dedent('''\ - def main(argv: Sequence[str] | None = None) -> int: - parser = argparse.ArgumentParser( - description="Compile a public StoryCore game specification" - ) - parser.add_argument("--input", required=True) - parser.add_argument("--output-dir", required=True) - args = parser.parse_args(argv) - - try: - workspace_root = Path.cwd().resolve(strict=True) - input_relative = _require_relative_cli_path(args.input, "--input") - output_relative = _require_relative_cli_path( - args.output_dir, "--output-dir" - ) - input_path = _resolve_workspace_path( - input_relative, workspace_root, "--input", must_be_file=True - ) - output_dir = _resolve_workspace_path( - output_relative, - workspace_root, - "--output-dir", - must_be_dir=True, - ) - # Output filenames are constants, never user-controlled path components. - manifest_path = output_dir / "storycore_game_manifest.json" - evidence_path = output_dir / "storycore_game_evidence.json" - manifest, evidence = compile_spec(_load_json(input_path)) - _write_json(manifest_path, manifest) - _write_json(evidence_path, evidence) - except ContractError as exc: - parser.error(str(exc)) - - print(f"manifest={manifest_path}") - print(f"evidence={evidence_path}") - print(f"content_sha256={manifest['content_sha256']}") - return 0 - - - ''') - contract = replace_between( - contract, - 'def main(', - 'if __name__ == "__main__":', - hardened_main, - 'main function', - ) - - cli_tests = textwrap.indent(textwrap.dedent('''\ - def test_cli_writes_manifest_and_evidence(self) -> None: - with tempfile.TemporaryDirectory(dir=ROOT) as directory: - output = Path(directory) - - result = main( - [ - "--input", - str(FIXTURE.relative_to(ROOT)), - "--output-dir", - str(output.relative_to(ROOT)), - ] - ) - - self.assertEqual(result, 0) - manifest = json.loads( - (output / "storycore_game_manifest.json").read_text(encoding="utf-8") - ) - evidence = json.loads( - (output / "storycore_game_evidence.json").read_text(encoding="utf-8") - ) - self.assertTrue(verify_manifest(manifest)) - self.assertEqual(manifest["content_sha256"], evidence["content_sha256"]) - - def test_cli_rejects_absolute_paths(self) -> None: - errors = io.StringIO() - - with redirect_stderr(errors), self.assertRaises(SystemExit) as raised: - main(["--input", str(FIXTURE), "--output-dir", "fixtures"]) - - self.assertEqual(raised.exception.code, 2) - self.assertIn("allowlisted workspace-relative path", errors.getvalue()) - - def test_cli_rejects_parent_traversal(self) -> None: - errors = io.StringIO() - - with redirect_stderr(errors), self.assertRaises(SystemExit) as raised: - main( - [ - "--input", - str(FIXTURE.relative_to(ROOT)), - "--output-dir", - "../storycore-game-escape", - ] - ) - - self.assertEqual(raised.exception.code, 2) - self.assertIn("allowlisted workspace-relative path", errors.getvalue()) - - def test_cli_rejects_non_allowlisted_path_characters(self) -> None: - errors = io.StringIO() - - with redirect_stderr(errors), self.assertRaises(SystemExit) as raised: - main( - [ - "--input", - str(FIXTURE.relative_to(ROOT)), - "--output-dir", - "fixtures/storycore-game/$escape", - ] - ) - - self.assertEqual(raised.exception.code, 2) - self.assertIn("allowlisted workspace-relative path", errors.getvalue()) - - '''), ' ') - tests = replace_between( - tests, - ' def test_cli_writes_manifest_and_evidence(self) -> None:', - ' def test_checked_in_godot_inputs_match_compiler(self) -> None:', - cli_tests, - 'CLI test region', - ) - - contract_path.write_text(contract, encoding='utf-8') - tests_path.write_text(tests, encoding='utf-8') - PY - - - name: Validate focused contract suite - run: | - python -m compileall -q src/game_bridge tests/game_bridge - python -m unittest discover -s tests/game_bridge -v - git diff --check - - - name: Commit scoped fix - run: | - git config user.name "github-actions[bot]" - git config user.email "41898282+github-actions[bot]@users.noreply.github.com" - git add src/game_bridge/contract.py tests/game_bridge/test_contract.py - git commit -m "fix(game-bridge): harden CLI workspace paths" - git push origin HEAD:codex/storycore-game-contract-v0.1 From f6c17061f5a58a0d2b8ee25858d061970b4368c1 Mon Sep 17 00:00:00 2001 From: Sylvain Galliez <128614184+zedarvates@users.noreply.github.com> Date: Sun, 30 Aug 2026 11:06:12 +0200 Subject: [PATCH 058/113] fix(game-bridge): close final symlink path boundary --- src/game_bridge/contract.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/game_bridge/contract.py b/src/game_bridge/contract.py index ca9185d7..3bdd9fe9 100644 --- a/src/game_bridge/contract.py +++ b/src/game_bridge/contract.py @@ -537,6 +537,8 @@ def _load_json(path: Path) -> dict[str, Any]: def _write_json(path: Path, value: Mapping[str, Any]) -> None: """Write a fixed-name output without following a final-component symlink.""" + if path.is_symlink(): + _fail(str(path), "refuses to replace a symbolic link") flags = os.O_WRONLY | os.O_CREAT | os.O_TRUNC if hasattr(os, "O_NOFOLLOW"): flags |= os.O_NOFOLLOW @@ -579,7 +581,7 @@ def main(argv: Sequence[str] | None = None) -> int: "--output-dir", must_be_dir=True, ) - # Output filenames are constants, never user-controlled path components. + # Output filenames are constants, never user-controlled components. manifest_path = output_dir / "storycore_game_manifest.json" evidence_path = output_dir / "storycore_game_evidence.json" manifest, evidence = compile_spec(_load_json(input_path)) From 8c834d95132e58340ca8fd6dc5b960a7ed7b6876 Mon Sep 17 00:00:00 2001 From: Sylvain Galliez <128614184+zedarvates@users.noreply.github.com> Date: Sun, 30 Aug 2026 11:06:14 +0200 Subject: [PATCH 059/113] test(game-bridge): expose all CLI path regressions --- tests/game_bridge/test_contract.py | 67 ++++++++++++++++++++---------- 1 file changed, 45 insertions(+), 22 deletions(-) diff --git a/tests/game_bridge/test_contract.py b/tests/game_bridge/test_contract.py index 340859ce..852fc279 100644 --- a/tests/game_bridge/test_contract.py +++ b/tests/game_bridge/test_contract.py @@ -3,6 +3,7 @@ import hashlib import io import json +import os import tempfile import unittest from contextlib import redirect_stderr @@ -113,32 +114,54 @@ def test_cli_writes_manifest_and_evidence(self) -> None: self.assertTrue(verify_manifest(manifest)) self.assertEqual(manifest["content_sha256"], evidence["content_sha256"]) - def test_cli_rejects_absolute_paths(self) -> None: - errors = io.StringIO() + def test_cli_rejects_absolute_paths(self) -> None: + errors = io.StringIO() - with redirect_stderr(errors), self.assertRaises(SystemExit) as raised: - main(["--input", str(FIXTURE), "--output-dir", "fixtures"]) + with redirect_stderr(errors), self.assertRaises(SystemExit) as raised: + main(["--input", str(FIXTURE), "--output-dir", "fixtures"]) - self.assertEqual(raised.exception.code, 2) - self.assertIn("allowlisted workspace-relative path", errors.getvalue()) + self.assertEqual(raised.exception.code, 2) + self.assertIn("allowlisted workspace-relative path", errors.getvalue()) - def test_cli_rejects_parent_traversal(self) -> None: - errors = io.StringIO() + def test_cli_rejects_parent_traversal(self) -> None: + errors = io.StringIO() - with redirect_stderr(errors), self.assertRaises(SystemExit) as raised: - main( - [ - "--input", - str(FIXTURE.relative_to(ROOT)), - "--output-dir", - "../storycore-game-escape", - ] - ) + with redirect_stderr(errors), self.assertRaises(SystemExit) as raised: + main( + [ + "--input", + str(FIXTURE.relative_to(ROOT)), + "--output-dir", + "../storycore-game-escape", + ] + ) - self.assertEqual(raised.exception.code, 2) - self.assertIn("allowlisted workspace-relative path", errors.getvalue()) + self.assertEqual(raised.exception.code, 2) + self.assertIn("allowlisted workspace-relative path", errors.getvalue()) - def test_cli_rejects_non_allowlisted_path_characters(self) -> None: + def test_cli_rejects_non_allowlisted_path_characters(self) -> None: + errors = io.StringIO() + + with redirect_stderr(errors), self.assertRaises(SystemExit) as raised: + main( + [ + "--input", + str(FIXTURE.relative_to(ROOT)), + "--output-dir", + "fixtures/storycore-game/$escape", + ] + ) + + self.assertEqual(raised.exception.code, 2) + self.assertIn("allowlisted workspace-relative path", errors.getvalue()) + + @unittest.skipUnless(hasattr(os, "O_NOFOLLOW"), "requires O_NOFOLLOW") + def test_cli_refuses_final_output_symlinks(self) -> None: + with tempfile.TemporaryDirectory(dir=ROOT) as directory: + output = Path(directory) + target = output / "target.json" + target.write_text("unchanged", encoding="utf-8") + (output / "storycore_game_manifest.json").symlink_to(target) errors = io.StringIO() with redirect_stderr(errors), self.assertRaises(SystemExit) as raised: @@ -147,12 +170,12 @@ def test_cli_rejects_non_allowlisted_path_characters(self) -> None: "--input", str(FIXTURE.relative_to(ROOT)), "--output-dir", - "fixtures/storycore-game/$escape", + str(output.relative_to(ROOT)), ] ) self.assertEqual(raised.exception.code, 2) - self.assertIn("allowlisted workspace-relative path", errors.getvalue()) + self.assertEqual(target.read_text(encoding="utf-8"), "unchanged") def test_checked_in_godot_inputs_match_compiler(self) -> None: expected_manifest, expected_evidence = compile_spec(load_fixture()) From 7de191cb7792d9efb8cd62eadcf64c7b20d8cf6c Mon Sep 17 00:00:00 2001 From: Sylvain Galliez <128614184+zedarvates@users.noreply.github.com> Date: Sun, 30 Aug 2026 11:11:46 +0200 Subject: [PATCH 060/113] test(game-bridge): isolate exception invocations for Sonar --- tests/game_bridge/test_contract.py | 48 ++++++++++++++---------------- 1 file changed, 23 insertions(+), 25 deletions(-) diff --git a/tests/game_bridge/test_contract.py b/tests/game_bridge/test_contract.py index 852fc279..2183b755 100644 --- a/tests/game_bridge/test_contract.py +++ b/tests/game_bridge/test_contract.py @@ -116,41 +116,40 @@ def test_cli_writes_manifest_and_evidence(self) -> None: def test_cli_rejects_absolute_paths(self) -> None: errors = io.StringIO() + argv = ["--input", str(FIXTURE), "--output-dir", "fixtures"] with redirect_stderr(errors), self.assertRaises(SystemExit) as raised: - main(["--input", str(FIXTURE), "--output-dir", "fixtures"]) + main(argv) self.assertEqual(raised.exception.code, 2) self.assertIn("allowlisted workspace-relative path", errors.getvalue()) def test_cli_rejects_parent_traversal(self) -> None: errors = io.StringIO() + argv = [ + "--input", + str(FIXTURE.relative_to(ROOT)), + "--output-dir", + "../storycore-game-escape", + ] with redirect_stderr(errors), self.assertRaises(SystemExit) as raised: - main( - [ - "--input", - str(FIXTURE.relative_to(ROOT)), - "--output-dir", - "../storycore-game-escape", - ] - ) + main(argv) self.assertEqual(raised.exception.code, 2) self.assertIn("allowlisted workspace-relative path", errors.getvalue()) def test_cli_rejects_non_allowlisted_path_characters(self) -> None: errors = io.StringIO() + argv = [ + "--input", + str(FIXTURE.relative_to(ROOT)), + "--output-dir", + "fixtures/storycore-game/$escape", + ] with redirect_stderr(errors), self.assertRaises(SystemExit) as raised: - main( - [ - "--input", - str(FIXTURE.relative_to(ROOT)), - "--output-dir", - "fixtures/storycore-game/$escape", - ] - ) + main(argv) self.assertEqual(raised.exception.code, 2) self.assertIn("allowlisted workspace-relative path", errors.getvalue()) @@ -163,16 +162,15 @@ def test_cli_refuses_final_output_symlinks(self) -> None: target.write_text("unchanged", encoding="utf-8") (output / "storycore_game_manifest.json").symlink_to(target) errors = io.StringIO() + argv = [ + "--input", + str(FIXTURE.relative_to(ROOT)), + "--output-dir", + str(output.relative_to(ROOT)), + ] with redirect_stderr(errors), self.assertRaises(SystemExit) as raised: - main( - [ - "--input", - str(FIXTURE.relative_to(ROOT)), - "--output-dir", - str(output.relative_to(ROOT)), - ] - ) + main(argv) self.assertEqual(raised.exception.code, 2) self.assertEqual(target.read_text(encoding="utf-8"), "unchanged") From d0e2d03897342b5e912e056b36f7522fb5998bc8 Mon Sep 17 00:00:00 2001 From: Sylvain Galliez <128614184+zedarvates@users.noreply.github.com> Date: Sun, 30 Aug 2026 11:16:59 +0200 Subject: [PATCH 061/113] docs(game-bridge): explain no-follow output protection --- src/game_bridge/contract.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/game_bridge/contract.py b/src/game_bridge/contract.py index 3bdd9fe9..6300452b 100644 --- a/src/game_bridge/contract.py +++ b/src/game_bridge/contract.py @@ -537,6 +537,8 @@ def _load_json(path: Path) -> dict[str, Any]: def _write_json(path: Path, value: Mapping[str, Any]) -> None: """Write a fixed-name output without following a final-component symlink.""" + # The explicit check covers platforms without O_NOFOLLOW; the flag closes + # the check/open race on platforms that provide it. if path.is_symlink(): _fail(str(path), "refuses to replace a symbolic link") flags = os.O_WRONLY | os.O_CREAT | os.O_TRUNC From 0b76c86a252a92662c689cb074d89053965db8b8 Mon Sep 17 00:00:00 2001 From: RapideCastor Date: Sun, 30 Aug 2026 15:43:59 +0200 Subject: [PATCH 062/113] docs(harbour): reconcile Anna draft-install guidance --- apps/storycore-harbour/STATUS.md | 1 + .../ANNA_DEV_INSTALL_PERMISSIONS_REPORT.md | 22 +++++++++++++++++++ 2 files changed, 23 insertions(+) diff --git a/apps/storycore-harbour/STATUS.md b/apps/storycore-harbour/STATUS.md index 787e1f2f..feb790ae 100644 --- a/apps/storycore-harbour/STATUS.md +++ b/apps/storycore-harbour/STATUS.md @@ -310,3 +310,4 @@ Codex must inspect the latest CI and Sonar results first. The next legitimate im - A single instrumented **View manifest** reproduction produced the same credential message but no observable network event, HTTP status, or console error through the available browser diagnostics. Root cause remains unproven. `review/ANNA_VIEW_MANIFEST_CREDENTIAL_REPORT.md` contains privacy-safe steps, independent CLI evidence, the exact uncertainty boundary, and a ready-to-send support question; nothing was posted. - After a fresh owner sign-in, **View manifest** succeeded and displayed the normalized working r12 manifest. It matches the committed Schema 2 Host-API-only boundary: no Executas, no top-level permissions, no external origins, one desktop view, `llm.complete`, App storage get/set/list/delete, `window.set_title`, self-only script CSP, and `last_writer_wins`. The credential incident is operationally resolved by reauthentication, while the exact token/frontend root cause remains unproven. No support message was sent. - Installed Apps already contains StoryCore Harbour as `v0.0.0-dev`, so **Install & test** was not clicked again. Its read-only Permissions panel returns `Failed to load permissions: App version not found`. CLI status simultaneously confirms draft r12 has zero immutable versions and the grants endpoint exposes no data. The facts identify a dev-install/version-resolution blocker for permission management; they do not prove whether refresh, immutable cut, or server repair is the correct fix. `review/ANNA_DEV_INSTALL_PERMISSIONS_REPORT.md` contains the bounded support question, and nothing was sent or changed. +- Public Anna guidance rechecked on 2026-08-30 is internally ambiguous for this exact boundary: one guide moves installation/permissions after an immutable cut, another team response directs working-draft installation, and beta.126 excludes `0.0.0-draft` projections from release-candidate selection. The evidence is recorded in the permissions report and does not authorize a speculative reinstall or `0.1.0` cut. diff --git a/apps/storycore-harbour/review/ANNA_DEV_INSTALL_PERMISSIONS_REPORT.md b/apps/storycore-harbour/review/ANNA_DEV_INSTALL_PERMISSIONS_REPORT.md index 6621965a..fcf4cac7 100644 --- a/apps/storycore-harbour/review/ANNA_DEV_INSTALL_PERMISSIONS_REPORT.md +++ b/apps/storycore-harbour/review/ANNA_DEV_INSTALL_PERMISSIONS_REPORT.md @@ -32,6 +32,28 @@ test**, create an immutable version, or repair the existing dev-install record server-side. Do not cut `0.1.0`, reinstall, uninstall, or change permissions as a diagnostic shortcut. +## Public guidance checked on 2026-08-30 + +Current Anna guidance is not unambiguous enough to choose a destructive or +immutable workaround: + +- the beginner publishing guide describes a working draft as testable, but its + permission walkthrough says to cut a version and then install it; +- an Anna Forum team response separately instructs developers to install a + working draft from Developer Console; +- the beta.126 changelog calls `0.0.0-draft` a projection row and explicitly + excludes it from release-candidate selection. + +Sources: + +- +- +- + +This conflict reinforces the support question below. It does not justify +cutting `0.1.0` while StoryCore's measured reliability gate remains below +18/20. + ## Safety boundary - **Install & test** was not clicked again because StoryCore is already listed. From e384c7cb61d763a9e83192e4c3733221cb390b77 Mon Sep 17 00:00:00 2001 From: Sylvain Galliez <128614184+zedarvates@users.noreply.github.com> Date: Sun, 30 Aug 2026 17:05:46 +0200 Subject: [PATCH 063/113] fix(harbour): confine marketplace logo renderer paths --- .../scripts/render-marketplace-logo.mjs | 19 ++++++++++++------- 1 file changed, 12 insertions(+), 7 deletions(-) diff --git a/apps/storycore-harbour/scripts/render-marketplace-logo.mjs b/apps/storycore-harbour/scripts/render-marketplace-logo.mjs index ea50ccf8..fd32b797 100644 --- a/apps/storycore-harbour/scripts/render-marketplace-logo.mjs +++ b/apps/storycore-harbour/scripts/render-marketplace-logo.mjs @@ -1,18 +1,20 @@ #!/usr/bin/env node import { mkdir, readFile } from "node:fs/promises"; -import { dirname, resolve } from "node:path"; +import { dirname, fileURLToPath } from "node:url"; +import { resolve } from "node:path"; import { chromium } from "playwright-core"; +const APP_ROOT = fileURLToPath(new URL("../", import.meta.url)); +const inputPath = resolve(APP_ROOT, "bundle/icon.svg"); +const outputPath = resolve(APP_ROOT, "review/marketplace-media/storycore-harbour-logo-256.png"); const executablePath = process.env.BROWSER_EXECUTABLE; -const inputPath = resolve(process.argv[2] || "bundle/icon.svg"); -const outputPath = resolve(process.argv[3] || "review/marketplace-media/storycore-harbour-logo-256.png"); if (!executablePath) { console.error("BROWSER_EXECUTABLE is required to render the Marketplace logo."); process.exit(2); } -await mkdir(dirname(outputPath), { recursive: true }); +await mkdir(resolve(APP_ROOT, "review/marketplace-media"), { recursive: true }); const browser = await chromium.launch({ executablePath, headless: true, @@ -28,9 +30,12 @@ try { html, body { margin: 0; width: 256px; height: 256px; background: transparent; } img { display: block; width: 256px; height: 256px; } - + `); - const logo = page.locator("img"); + const logo = page.locator("#marketplace-logo"); + await logo.evaluate((image, src) => { + image.src = src; + }, sourceUrl); await logo.waitFor({ state: "visible" }); await logo.evaluate(async (image) => { await image.decode(); @@ -39,7 +44,7 @@ try { } }); await logo.screenshot({ path: outputPath, omitBackground: true }); - console.log(JSON.stringify({ result: "pass", inputPath, outputPath, width: 256, height: 256 })); + console.log(JSON.stringify({ result: "pass", width: 256, height: 256 })); } finally { await browser.close(); } From 0d8d1deea134376a96b756e347542ca1d0d7b561 Mon Sep 17 00:00:00 2001 From: Sylvain Galliez <128614184+zedarvates@users.noreply.github.com> Date: Sun, 30 Aug 2026 17:05:59 +0200 Subject: [PATCH 064/113] fix(harbour): correct marketplace renderer imports --- apps/storycore-harbour/scripts/render-marketplace-logo.mjs | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/apps/storycore-harbour/scripts/render-marketplace-logo.mjs b/apps/storycore-harbour/scripts/render-marketplace-logo.mjs index fd32b797..adb34b74 100644 --- a/apps/storycore-harbour/scripts/render-marketplace-logo.mjs +++ b/apps/storycore-harbour/scripts/render-marketplace-logo.mjs @@ -1,12 +1,13 @@ #!/usr/bin/env node import { mkdir, readFile } from "node:fs/promises"; -import { dirname, fileURLToPath } from "node:url"; +import { fileURLToPath } from "node:url"; import { resolve } from "node:path"; import { chromium } from "playwright-core"; const APP_ROOT = fileURLToPath(new URL("../", import.meta.url)); const inputPath = resolve(APP_ROOT, "bundle/icon.svg"); -const outputPath = resolve(APP_ROOT, "review/marketplace-media/storycore-harbour-logo-256.png"); +const outputDirectory = resolve(APP_ROOT, "review/marketplace-media"); +const outputPath = resolve(outputDirectory, "storycore-harbour-logo-256.png"); const executablePath = process.env.BROWSER_EXECUTABLE; if (!executablePath) { @@ -14,7 +15,7 @@ if (!executablePath) { process.exit(2); } -await mkdir(resolve(APP_ROOT, "review/marketplace-media"), { recursive: true }); +await mkdir(outputDirectory, { recursive: true }); const browser = await chromium.launch({ executablePath, headless: true, From 6118b208f91eee6a3325d1b0d31b190a64c35860 Mon Sep 17 00:00:00 2001 From: Sylvain Galliez <128614184+zedarvates@users.noreply.github.com> Date: Sun, 30 Aug 2026 17:06:18 +0200 Subject: [PATCH 065/113] fix(harbour): confine marketplace logo validation path --- .../storycore-harbour/scripts/validate-marketplace-logo.mjs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/apps/storycore-harbour/scripts/validate-marketplace-logo.mjs b/apps/storycore-harbour/scripts/validate-marketplace-logo.mjs index ebae6a00..e2909254 100644 --- a/apps/storycore-harbour/scripts/validate-marketplace-logo.mjs +++ b/apps/storycore-harbour/scripts/validate-marketplace-logo.mjs @@ -2,10 +2,11 @@ import assert from "node:assert/strict"; import { readFile } from "node:fs/promises"; import { resolve } from "node:path"; -import { pathToFileURL } from "node:url"; +import { fileURLToPath, pathToFileURL } from "node:url"; import { chromium } from "playwright-core"; -const logoPath = resolve(process.argv[2] || "review/marketplace-media/storycore-harbour-logo-256.png"); +const APP_ROOT = fileURLToPath(new URL("../", import.meta.url)); +const logoPath = resolve(APP_ROOT, "review/marketplace-media/storycore-harbour-logo-256.png"); const executablePath = process.env.BROWSER_EXECUTABLE; const bytes = await readFile(logoPath); const pngSignature = Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]); @@ -60,7 +61,6 @@ assert.ok(near(samples.cyanWave, [71, 215, 232, 255]), "Logo wave must render cy console.log(JSON.stringify({ result: "pass", - logoPath, format: "PNG", width: 256, height: 256, From 5df61e2319990b222168d140b607f5f55b895361 Mon Sep 17 00:00:00 2001 From: Sylvain Galliez <128614184+zedarvates@users.noreply.github.com> Date: Sun, 30 Aug 2026 17:09:04 +0200 Subject: [PATCH 066/113] test(harbour): lock marketplace logo file contract --- .../tests/marketplace-logo.test.mjs | 20 +++++++++++++++++++ 1 file changed, 20 insertions(+) create mode 100644 apps/storycore-harbour/tests/marketplace-logo.test.mjs diff --git a/apps/storycore-harbour/tests/marketplace-logo.test.mjs b/apps/storycore-harbour/tests/marketplace-logo.test.mjs new file mode 100644 index 00000000..8c6b2232 --- /dev/null +++ b/apps/storycore-harbour/tests/marketplace-logo.test.mjs @@ -0,0 +1,20 @@ +import assert from "node:assert/strict"; +import { readFile } from "node:fs/promises"; +import { fileURLToPath } from "node:url"; +import { resolve } from "node:path"; +import test from "node:test"; + +const APP_ROOT = fileURLToPath(new URL("../", import.meta.url)); +const logoPath = resolve(APP_ROOT, "review/marketplace-media/storycore-harbour-logo-256.png"); +const pngSignature = Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]); + +test("committed Marketplace logo is a bounded 256px PNG", async () => { + const bytes = await readFile(logoPath); + + assert.ok(bytes.subarray(0, 8).equals(pngSignature)); + assert.equal(bytes.subarray(12, 16).toString("ascii"), "IHDR"); + assert.equal(bytes.readUInt32BE(16), 256); + assert.equal(bytes.readUInt32BE(20), 256); + assert.ok(bytes.length > 0); + assert.ok(bytes.length <= 2 * 1024 * 1024); +}); From ab63ce558a8c6dbb77bedc197ccbfab4b541cbb5 Mon Sep 17 00:00:00 2001 From: Sylvain Galliez <128614184+zedarvates@users.noreply.github.com> Date: Sun, 30 Aug 2026 17:11:48 +0200 Subject: [PATCH 067/113] docs(harbour): record bounded Sonar security remediation --- .../SONAR_SECURITY_FOLLOWUP_2026-08-30.md | 60 +++++++++++++++++++ 1 file changed, 60 insertions(+) create mode 100644 apps/storycore-harbour/review/SONAR_SECURITY_FOLLOWUP_2026-08-30.md diff --git a/apps/storycore-harbour/review/SONAR_SECURITY_FOLLOWUP_2026-08-30.md b/apps/storycore-harbour/review/SONAR_SECURITY_FOLLOWUP_2026-08-30.md new file mode 100644 index 00000000..9732fdc8 --- /dev/null +++ b/apps/storycore-harbour/review/SONAR_SECURITY_FOLLOWUP_2026-08-30.md @@ -0,0 +1,60 @@ +# Sonar security follow-up — 2026-08-30 + +## Context + +Draft PR #37 previously passed the StoryCore Harbour functional CI while SonarQube Cloud reported a **C Security Rating on New Code** with two annotations. The regression appeared after the Marketplace-logo helper scripts were added. + +A subsequent Sonar attempt reported only `The last analysis has failed`, so that attempt is not evidence that the security rating recovered. The release/security gate remains open until a completed Sonar analysis reports the required rating. + +## Bounded remediation + +The two new Marketplace-logo scripts were hardened without broadening App permissions or runtime capabilities. + +### Renderer + +`./scripts/render-marketplace-logo.mjs` + +- no longer accepts CLI-supplied input or output paths; +- derives the App root from `import.meta.url`; +- reads only committed `bundle/icon.svg`; +- writes only `review/marketplace-media/storycore-harbour-logo-256.png`; +- no longer interpolates the SVG data URL into an HTML template; +- assigns the fixed local source as the image `src` property; +- no longer prints local filesystem paths in its result log. + +### Validator + +`./scripts/validate-marketplace-logo.mjs` + +- no longer accepts a CLI-supplied logo path; +- reads only the committed StoryCore Harbour Marketplace PNG; +- checks PNG signature, IHDR, 256 × 256 dimensions, the 2 MB limit, and representative pixels; +- no longer prints the local filesystem path. + +### Regression coverage + +`tests/marketplace-logo.test.mjs` independently verifies that the committed asset: + +- is a PNG; +- begins with IHDR; +- is exactly 256 × 256; +- is non-empty; +- remains below 2 MB. + +The normal Harbour CI remains green after these changes. + +## Evidence boundary + +This report does **not** claim that the two former Sonar annotations were definitively those two path flows, because the annotation detail was not exposed through the available connector. It records the smallest plausible remediation from the exact code introduced in the same change window. + +Do not mark the security gate resolved from CI success alone. + +## Exit condition + +A fresh completed SonarQube Cloud analysis for the current PR head must show: + +- Quality Gate passed; +- Security Rating on New Code = A; +- zero unresolved new security issues/hotspots relevant to this change. + +If Sonar reports another concrete issue, fix only that issue and rerun. Do not suppress, accept, or lower the Quality Gate merely to unblock Harbour. From 9e80b5490dd10f25a5cbf54e27d0ff62b367d06b Mon Sep 17 00:00:00 2001 From: Sylvain Galliez <128614184+zedarvates@users.noreply.github.com> Date: Sun, 30 Aug 2026 17:14:44 +0200 Subject: [PATCH 068/113] docs(harbour): record suspected upstream Sonar autoscan failure --- .../SONAR_SECURITY_FOLLOWUP_2026-08-30.md | 26 ++++++++++++++++--- 1 file changed, 23 insertions(+), 3 deletions(-) diff --git a/apps/storycore-harbour/review/SONAR_SECURITY_FOLLOWUP_2026-08-30.md b/apps/storycore-harbour/review/SONAR_SECURITY_FOLLOWUP_2026-08-30.md index 9732fdc8..fedb20f9 100644 --- a/apps/storycore-harbour/review/SONAR_SECURITY_FOLLOWUP_2026-08-30.md +++ b/apps/storycore-harbour/review/SONAR_SECURITY_FOLLOWUP_2026-08-30.md @@ -4,7 +4,7 @@ Draft PR #37 previously passed the StoryCore Harbour functional CI while SonarQube Cloud reported a **C Security Rating on New Code** with two annotations. The regression appeared after the Marketplace-logo helper scripts were added. -A subsequent Sonar attempt reported only `The last analysis has failed`, so that attempt is not evidence that the security rating recovered. The release/security gate remains open until a completed Sonar analysis reports the required rating. +The two helper surfaces were hardened, but subsequent Sonar Automatic Analysis attempts no longer produced a Quality Gate result and instead reported only `The last analysis has failed`. Therefore there is currently **no evidence that the security rating recovered**. The release/security gate remains open until a completed Sonar analysis reports the required rating. ## Bounded remediation @@ -41,11 +41,31 @@ The two new Marketplace-logo scripts were hardened without broadening App permis - is non-empty; - remains below 2 MB. -The normal Harbour CI remains green after these changes. +StoryCore Harbour CI run #135 is green after these changes. + +## Current Sonar service evidence + +Public Sonar Community reports dated 2026-08-28 and 2026-08-29 describe contemporaneous SonarQube Cloud Automatic Analysis failures with the same broad symptom seen here: GitHub checks are queued or ordinary CI remains green while Automatic Analysis reports `The last analysis has failed` and, in at least one report, no Compute Engine task is created at all. + +Relevant public reports: + +- `Automatic Analysis silently failing since 2026-08-28 — failing analysis ID provided` (2026-08-29); +- `SonarQube Cloud Automatic Analysis queues GitHub checks but starts no CE task for PR #8` (2026-08-28); +- `New repo, Error: The last analysis has failed` (2026-08-28). + +This is **corroborating evidence of a possible upstream service incident**, not proof that StoryCore's current analysis failure has the same root cause. Do not treat it as a waiver of the Harbour security gate. ## Evidence boundary -This report does **not** claim that the two former Sonar annotations were definitively those two path flows, because the annotation detail was not exposed through the available connector. It records the smallest plausible remediation from the exact code introduced in the same change window. +This report does **not** claim that the two former Sonar annotations were definitively the two Marketplace path flows, because the annotation details were not exposed through the available connector. It records the smallest plausible remediation from the exact code introduced in the same change window. + +It also does not claim the Sonar service is definitively at fault. The current state is: + +- functional Harbour CI: green; +- previous completed Sonar result: Security Rating C; +- relevant code surfaces: hardened; +- subsequent Sonar Automatic Analysis attempts: analysis failure before a usable Quality Gate result; +- security gate: still open. Do not mark the security gate resolved from CI success alone. From 6100dd98b43c5d9f63a6943f76f50971d17e854d Mon Sep 17 00:00:00 2001 From: Sylvain Galliez <128614184+zedarvates@users.noreply.github.com> Date: Sun, 30 Aug 2026 17:15:45 +0200 Subject: [PATCH 069/113] chore(license): align package metadata with MIT --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index a113840b..a9fc362f 100644 --- a/package.json +++ b/package.json @@ -42,7 +42,7 @@ "electron" ], "author": "StoryCore Team", - "license": "ISC", + "license": "MIT", "type": "commonjs", "bugs": { "url": "https://github.com/zedarvates/StoryCore-Engine/issues" From 09ef8fe01e9764f8d091c3390ddec2a2c9bf49ee Mon Sep 17 00:00:00 2001 From: Sylvain Galliez <128614184+zedarvates@users.noreply.github.com> Date: Sun, 30 Aug 2026 17:16:06 +0200 Subject: [PATCH 070/113] chore(license): align root lockfile metadata with MIT --- package-lock.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package-lock.json b/package-lock.json index fb0a1c93..a7660cc6 100644 --- a/package-lock.json +++ b/package-lock.json @@ -7,7 +7,7 @@ "": { "name": "storycore-engine", "version": "1.0.0", - "license": "ISC", + "license": "MIT", "dependencies": { "@types/react": "^19.2.8", "@types/react-dom": "^19.2.3", From 4334cf54f9b4c61f322a5081348c0bc6ecf7096f Mon Sep 17 00:00:00 2001 From: Sylvain Galliez <128614184+zedarvates@users.noreply.github.com> Date: Sun, 30 Aug 2026 17:16:13 +0200 Subject: [PATCH 071/113] docs(harbour): close Sonar gate after clean analysis --- .../SONAR_SECURITY_FOLLOWUP_2026-08-30.md | 49 +++++++++---------- 1 file changed, 24 insertions(+), 25 deletions(-) diff --git a/apps/storycore-harbour/review/SONAR_SECURITY_FOLLOWUP_2026-08-30.md b/apps/storycore-harbour/review/SONAR_SECURITY_FOLLOWUP_2026-08-30.md index fedb20f9..183b0b72 100644 --- a/apps/storycore-harbour/review/SONAR_SECURITY_FOLLOWUP_2026-08-30.md +++ b/apps/storycore-harbour/review/SONAR_SECURITY_FOLLOWUP_2026-08-30.md @@ -1,10 +1,23 @@ # Sonar security follow-up — 2026-08-30 +## Result + +The StoryCore Harbour security gate is restored. + +After the Marketplace-logo helper hardening, SonarQube Cloud completed a fresh analysis on PR #37 and reported: + +- **Quality Gate passed**; +- **0 New issues**; +- **0 Accepted issues**; +- **0 Security Hotspots**. + +This supersedes the earlier C Security Rating result and the intervening Automatic Analysis failures. No issue was suppressed or accepted and the Quality Gate was not lowered. + ## Context -Draft PR #37 previously passed the StoryCore Harbour functional CI while SonarQube Cloud reported a **C Security Rating on New Code** with two annotations. The regression appeared after the Marketplace-logo helper scripts were added. +Draft PR #37 had previously passed the StoryCore Harbour functional CI while SonarQube Cloud reported a **C Security Rating on New Code** with two annotations. The regression appeared after the Marketplace-logo helper scripts were added. -The two helper surfaces were hardened, but subsequent Sonar Automatic Analysis attempts no longer produced a Quality Gate result and instead reported only `The last analysis has failed`. Therefore there is currently **no evidence that the security rating recovered**. The release/security gate remains open until a completed Sonar analysis reports the required rating. +The two helper surfaces were then hardened. Several intervening Sonar Automatic Analysis attempts reported only `The last analysis has failed`; those attempts were treated as inconclusive rather than as evidence of recovery. ## Bounded remediation @@ -41,40 +54,26 @@ The two new Marketplace-logo scripts were hardened without broadening App permis - is non-empty; - remains below 2 MB. -StoryCore Harbour CI run #135 is green after these changes. +StoryCore Harbour CI run #136 is green after the final evidence update. -## Current Sonar service evidence +## Sonar service evidence during diagnosis -Public Sonar Community reports dated 2026-08-28 and 2026-08-29 describe contemporaneous SonarQube Cloud Automatic Analysis failures with the same broad symptom seen here: GitHub checks are queued or ordinary CI remains green while Automatic Analysis reports `The last analysis has failed` and, in at least one report, no Compute Engine task is created at all. +Public Sonar Community reports dated 2026-08-28 and 2026-08-29 described contemporaneous SonarQube Cloud Automatic Analysis failures with the same broad symptom seen during the intervening attempts: ordinary CI remained green while Automatic Analysis reported `The last analysis has failed`, and one report described no Compute Engine task being created. -Relevant public reports: +Relevant public reports included: - `Automatic Analysis silently failing since 2026-08-28 — failing analysis ID provided` (2026-08-29); - `SonarQube Cloud Automatic Analysis queues GitHub checks but starts no CE task for PR #8` (2026-08-28); - `New repo, Error: The last analysis has failed` (2026-08-28). -This is **corroborating evidence of a possible upstream service incident**, not proof that StoryCore's current analysis failure has the same root cause. Do not treat it as a waiver of the Harbour security gate. +Those reports remain corroborating evidence that the intervening analysis failures may have involved a service-side problem. They are not needed to justify the final result because a subsequent completed StoryCore analysis now passes. ## Evidence boundary -This report does **not** claim that the two former Sonar annotations were definitively the two Marketplace path flows, because the annotation details were not exposed through the available connector. It records the smallest plausible remediation from the exact code introduced in the same change window. - -It also does not claim the Sonar service is definitively at fault. The current state is: - -- functional Harbour CI: green; -- previous completed Sonar result: Security Rating C; -- relevant code surfaces: hardened; -- subsequent Sonar Automatic Analysis attempts: analysis failure before a usable Quality Gate result; -- security gate: still open. - -Do not mark the security gate resolved from CI success alone. - -## Exit condition +The available connector did not expose the detailed text of the original two Sonar annotations, so this report does not claim a proven one-to-one mapping between those annotations and the two path flows. The remediation was the smallest security-oriented change to the executable code introduced in the same change window, and the completed post-remediation Sonar analysis now reports a clean Quality Gate. -A fresh completed SonarQube Cloud analysis for the current PR head must show: +## Current gate -- Quality Gate passed; -- Security Rating on New Code = A; -- zero unresolved new security issues/hotspots relevant to this change. +The Sonar security blocker is closed for the current PR state. It must reopen automatically if a later Harbour change produces a failed or missing required Sonar Quality Gate. -If Sonar reports another concrete issue, fix only that issue and rerun. Do not suppress, accept, or lower the Quality Gate merely to unblock Harbour. +This does **not** close StoryCore Harbour's separate real-model reliability gate. The immutable acceptance target remains at least 18/20 with median successful completion at or below 180 seconds. From 00abcead8f30bd8001f075c5fbf4151831107d02 Mon Sep 17 00:00:00 2001 From: Sylvain Galliez <128614184+zedarvates@users.noreply.github.com> Date: Sun, 30 Aug 2026 18:35:38 +0200 Subject: [PATCH 072/113] fix(video-routing): reject contradictory subject counts --- src/video_validation/shot_spec_adapter.py | 34 ++++++++++++++++------- 1 file changed, 24 insertions(+), 10 deletions(-) diff --git a/src/video_validation/shot_spec_adapter.py b/src/video_validation/shot_spec_adapter.py index c199c124..354a3afd 100644 --- a/src/video_validation/shot_spec_adapter.py +++ b/src/video_validation/shot_spec_adapter.py @@ -1,5 +1,6 @@ from __future__ import annotations +import math from collections.abc import Mapping, Sequence from typing import Any @@ -44,8 +45,8 @@ def _normalize_score(value: Any, *, name: str, default: float) -> float: if isinstance(value, bool) or not isinstance(value, (int, float)): raise ValueError(f"{name} must be numeric") score = float(value) - if not 0.0 <= score <= 1.0: - raise ValueError(f"{name} must be in [0, 1]") + if not math.isfinite(score) or not 0.0 <= score <= 1.0: + raise ValueError(f"{name} must be finite and in [0, 1]") return score @@ -70,24 +71,37 @@ def _camera_motion_score(value: Any) -> float: return max(matched, default=0.25 if normalized.strip() else 0.0) +def _sequence_count(value: Any) -> int | None: + if isinstance(value, Sequence) and not isinstance(value, (str, bytes, bytearray)): + return len(value) + return None + + def _subject_count(shot_spec: Any, *, subjects: Sequence[Any] | None, routing: Mapping[str, Any]) -> int: + candidates: list[tuple[str, int]] = [] + explicit = routing.get("subject_count", _read(shot_spec, "subject_count", None)) if explicit is not None: if isinstance(explicit, bool) or not isinstance(explicit, int) or explicit < 0: raise ValueError("subject_count must be a non-negative integer") - return explicit + candidates.append(("subject_count", explicit)) for key in ("characters_present", "subjects", "characters"): - value = _read(shot_spec, key, None) - if isinstance(value, Sequence) and not isinstance(value, (str, bytes, bytearray)): - return len(value) + count = _sequence_count(_read(shot_spec, key, None)) + if count is not None: + candidates.append((key, count)) if subjects is not None: - return len(subjects) + candidates.append(("subjects_argument", len(subjects))) + + if not candidates: + raise ValueError("subject_count is required when the shot spec has no subject list") - # Fail closed rather than silently treating an unknown multi-character shot - # as a single-subject DIRECT generation. - raise ValueError("subject_count is required when the shot spec has no subject list") + distinct = {count for _, count in candidates} + if len(distinct) != 1: + detail = ", ".join(f"{name}={count}" for name, count in candidates) + raise ValueError(f"contradictory subject counts: {detail}") + return candidates[0][1] def extract_multi_subject_shot( From 33d83467fa6353d13bd64e66d33ef164d9c8f7c1 Mon Sep 17 00:00:00 2001 From: Sylvain Galliez <128614184+zedarvates@users.noreply.github.com> Date: Sun, 30 Aug 2026 18:36:01 +0200 Subject: [PATCH 073/113] test(video-routing): cover contradictory counts and non-finite scores --- tests/test_shot_spec_router_adapter.py | 26 ++++++++++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/tests/test_shot_spec_router_adapter.py b/tests/test_shot_spec_router_adapter.py index b3e746a1..d482cc9a 100644 --- a/tests/test_shot_spec_router_adapter.py +++ b/tests/test_shot_spec_router_adapter.py @@ -87,6 +87,22 @@ def test_routing_metadata_can_override_subject_count_and_budget(): assert "compute_budget_constrained" in decision.reasons +def test_contradictory_subject_counts_fail_closed(): + spec = { + "subject_count": 1, + "characters_present": ["hero", "rival", "witness"], + "camera_movement": "static", + } + with pytest.raises(ValueError, match="contradictory subject counts"): + route_shot_spec(spec) + + +def test_explicit_subject_argument_must_match_shot_metadata(): + spec = {"characters_present": ["hero", "rival"], "camera_movement": "static"} + with pytest.raises(ValueError, match="contradictory subject counts"): + route_shot_spec(spec, subjects=["hero"]) + + def test_invalid_score_fails_closed(): with pytest.raises(ValueError, match="interaction_strength"): extract_multi_subject_shot( @@ -96,6 +112,16 @@ def test_invalid_score_fails_closed(): ) +def test_non_finite_score_fails_closed(): + for value in (float("nan"), float("inf"), float("-inf")): + with pytest.raises(ValueError, match="interaction_strength"): + extract_multi_subject_shot( + ShotLike(), + subjects=["a", "b"], + overrides={"interaction_strength": value}, + ) + + def test_non_boolean_contact_required_fails_closed(): with pytest.raises(ValueError, match="contact_required"): extract_multi_subject_shot( From 875a2dc8e9727bd194218de6a68254d7053d299a Mon Sep 17 00:00:00 2001 From: RapideCastor Date: Mon, 31 Aug 2026 14:18:31 +0200 Subject: [PATCH 074/113] docs(harbour): record post-repair Gemma corpus --- apps/storycore-harbour/STATUS.md | 2 ++ apps/storycore-harbour/review/FINAL_HANDOFF_2026-08-24.md | 6 ++++++ apps/storycore-harbour/review/MVP_ROADMAP_2026-08-24.md | 1 + 3 files changed, 9 insertions(+) diff --git a/apps/storycore-harbour/STATUS.md b/apps/storycore-harbour/STATUS.md index feb790ae..5033c2db 100644 --- a/apps/storycore-harbour/STATUS.md +++ b/apps/storycore-harbour/STATUS.md @@ -311,3 +311,5 @@ Codex must inspect the latest CI and Sonar results first. The next legitimate im - After a fresh owner sign-in, **View manifest** succeeded and displayed the normalized working r12 manifest. It matches the committed Schema 2 Host-API-only boundary: no Executas, no top-level permissions, no external origins, one desktop view, `llm.complete`, App storage get/set/list/delete, `window.set_title`, self-only script CSP, and `last_writer_wins`. The credential incident is operationally resolved by reauthentication, while the exact token/frontend root cause remains unproven. No support message was sent. - Installed Apps already contains StoryCore Harbour as `v0.0.0-dev`, so **Install & test** was not clicked again. Its read-only Permissions panel returns `Failed to load permissions: App version not found`. CLI status simultaneously confirms draft r12 has zero immutable versions and the grants endpoint exposes no data. The facts identify a dev-install/version-resolution blocker for permission management; they do not prove whether refresh, immutable cut, or server repair is the correct fix. `review/ANNA_DEV_INSTALL_PERMISSIONS_REPORT.md` contains the bounded support question, and nothing was sent or changed. - Public Anna guidance rechecked on 2026-08-30 is internally ambiguous for this exact boundary: one guide moves installation/permissions after an immutable cut, another team response directs working-draft installation, and beta.126 excludes `0.0.0-draft` projections from release-candidate selection. The evidence is recorded in the permissions report and does not authorize a speculative reinstall or `0.1.0` cut. +- An owner-authorized complete corpus on 2026-08-31 used the advisory `gemma` hint after the schema-complete repair change. The connected Anna UI finished at 15/20, median 23.90 seconds, p95 44.00 seconds, and 7 repaired passes. Failures were HBR-A01 `unknown` after one transport `fetch failed`, plus HBR-A06/A08/A15/A20 `json_invalid`. Every malformed primary/repair response used `gemma-4-E4B-it` through Runpod, ended with `endTurn`, stayed between 1,269 and 1,722 output tokens, and ended with a closing brace; the remaining failures are internal JSON syntax errors rather than 4,096-token truncation. The score remains below 18/20, so no readiness, immutable cut, review, merge, or release claim is permitted. +- The iframe sandbox did not surface its Blob download to Browser automation. The private result was therefore recovered from the same run's RPC log: exactly 15 `projects/current` writes were matched to the immutable corpus, each recovered project passed the canonical contract, UI-displayed successful durations were retained at 0.1-second precision, and failure durations came from the sequential RPC timestamps. The canonical evaluator independently reproduced 15/20 and the same five privacy-safe failures. Ignored evidence remains at `acceptance/results.2026-08-31.gemma.recovered.local.jsonl`, `acceptance/harness.2026-08-31.gemma.local.log`, and companion local logs; generated content was not committed or posted. diff --git a/apps/storycore-harbour/review/FINAL_HANDOFF_2026-08-24.md b/apps/storycore-harbour/review/FINAL_HANDOFF_2026-08-24.md index 5b37a94e..db0d6998 100644 --- a/apps/storycore-harbour/review/FINAL_HANDOFF_2026-08-24.md +++ b/apps/storycore-harbour/review/FINAL_HANDOFF_2026-08-24.md @@ -103,6 +103,12 @@ without addressing the failure. Do not cut `0.1.0`, submit for review, mark PR #37 ready, merge, or release while the official gate remains below 18/20. The latest measured Gemma run is 15/20 and the latest Anna-default run is 6/20. The App is demonstrable and its working draft is reserved, but it is not submission-ready under the repository's own rules. +The owner-authorized 2026-08-31 Gemma rerun after the schema-complete repair +also measured 15/20 (median 23.90 seconds, p95 44.00 seconds, 7 repaired +passes). A01 failed at transport; A06/A08/A15/A20 returned complete but +syntactically invalid JSON. This supersedes the 27 August Gemma run as the +latest real evidence without changing the submission decision. + ### Anna installation diagnosis - Developer Console shows the working draft as `v0.0.0`, `WORKING`, and `Unpublished`; diff --git a/apps/storycore-harbour/review/MVP_ROADMAP_2026-08-24.md b/apps/storycore-harbour/review/MVP_ROADMAP_2026-08-24.md index bac4b8d0..188a7bc4 100644 --- a/apps/storycore-harbour/review/MVP_ROADMAP_2026-08-24.md +++ b/apps/storycore-harbour/review/MVP_ROADMAP_2026-08-24.md @@ -93,3 +93,4 @@ Do not replace production-platform gates with these local results. - Third-model probe blocked: hint `gpt-4o` returned no response metadata and remained pending beyond the App timeout. Treat it as a platform cancellation/grant issue, not model evidence; no further blind hint probes. - 27 August default-model rerun: the complete immutable corpus used `minimax/minimax-m3` through OpenRouter and finished at 6/20, median 49.13 seconds, p95 57.87 seconds, and 2 repaired passes. The privacy-safe failure matrix was eight `json_invalid`, five `contract_invalid`, and one timeout. The ignored private JSONL is `acceptance/results.2026-08-27.local.jsonl`. This is a measured stochastic regression from the earlier 16/20 default run, so readiness remains blocked and no parser relaxation, immutable version, review submission, or release is justified. - 27 August Gemma rerun: the complete immutable corpus with advisory hint `gemma` finished at 15/20, median 21.91 seconds, p95 41.92 seconds, and 6 repaired passes. Failures were A07/A10/A12/A19 `contract_invalid` and A20 `required_field_invalid`, with no timeout or JSON truncation. The ignored private JSONL is `acceptance/results.2026-08-27.gemma.local.jsonl`. This improves the measured Gemma profile from 14/20 to 15/20 but remains below the 18/20 gate; keep the PR draft and do not cut, submit, or release a version. +- 31 August post-repair Gemma rerun: the complete connected corpus again finished at 15/20, median 23.90 seconds, p95 44.00 seconds, and 7 repaired passes. A01 failed at transport; A06/A08/A15/A20 were `json_invalid` after complete, non-truncated Gemma/Runpod responses. The schema-complete repair changed which prompts pass and increased repaired passes, but did not improve the total score. The private result was canonically re-evaluated from an RPC-log recovery because the iframe download was not surfaced to automation. Keep the release gate failed and do not fund another blind rerun without a measured syntax intervention or platform/model change. From ebf525af91cf2f123c43e68fc2bcf8b97963c371 Mon Sep 17 00:00:00 2001 From: RapideCastor Date: Mon, 31 Aug 2026 17:55:27 +0200 Subject: [PATCH 075/113] docs(harbour): reject insufficient JSON punctuation recovery --- apps/storycore-harbour/STATUS.md | 1 + apps/storycore-harbour/review/MVP_ROADMAP_2026-08-24.md | 1 + 2 files changed, 2 insertions(+) diff --git a/apps/storycore-harbour/STATUS.md b/apps/storycore-harbour/STATUS.md index 5033c2db..1e5a9bdc 100644 --- a/apps/storycore-harbour/STATUS.md +++ b/apps/storycore-harbour/STATUS.md @@ -313,3 +313,4 @@ Codex must inspect the latest CI and Sonar results first. The next legitimate im - Public Anna guidance rechecked on 2026-08-30 is internally ambiguous for this exact boundary: one guide moves installation/permissions after an immutable cut, another team response directs working-draft installation, and beta.126 excludes `0.0.0-draft` projections from release-candidate selection. The evidence is recorded in the permissions report and does not authorize a speculative reinstall or `0.1.0` cut. - An owner-authorized complete corpus on 2026-08-31 used the advisory `gemma` hint after the schema-complete repair change. The connected Anna UI finished at 15/20, median 23.90 seconds, p95 44.00 seconds, and 7 repaired passes. Failures were HBR-A01 `unknown` after one transport `fetch failed`, plus HBR-A06/A08/A15/A20 `json_invalid`. Every malformed primary/repair response used `gemma-4-E4B-it` through Runpod, ended with `endTurn`, stayed between 1,269 and 1,722 output tokens, and ended with a closing brace; the remaining failures are internal JSON syntax errors rather than 4,096-token truncation. The score remains below 18/20, so no readiness, immutable cut, review, merge, or release claim is permitted. - The iframe sandbox did not surface its Blob download to Browser automation. The private result was therefore recovered from the same run's RPC log: exactly 15 `projects/current` writes were matched to the immutable corpus, each recovered project passed the canonical contract, UI-displayed successful durations were retained at 0.1-second precision, and failure durations came from the sequential RPC timestamps. The canonical evaluator independently reproduced 15/20 and the same five privacy-safe failures. Ignored evidence remains at `acceptance/results.2026-08-31.gemma.recovered.local.jsonl`, `acceptance/harness.2026-08-31.gemma.local.log`, and companion local logs; generated content was not committed or posted. +- A private offline punctuation-recovery experiment made no model calls and never changed generated words. A bounded search of at most three edits from `{ } [ ] , :` recovered A08 and A15 with two edits each, so the best projected score was only 17/20. A06 remained unparsable after 28,876 bounded candidates. Closing A20's unbalanced JSON made it parse but left five canonical contract errors. There is no single bounded syntax rule that reaches the release gate, so the experimental helper was removed and production parsing remains fail-closed. diff --git a/apps/storycore-harbour/review/MVP_ROADMAP_2026-08-24.md b/apps/storycore-harbour/review/MVP_ROADMAP_2026-08-24.md index 188a7bc4..3d0367c8 100644 --- a/apps/storycore-harbour/review/MVP_ROADMAP_2026-08-24.md +++ b/apps/storycore-harbour/review/MVP_ROADMAP_2026-08-24.md @@ -94,3 +94,4 @@ Do not replace production-platform gates with these local results. - 27 August default-model rerun: the complete immutable corpus used `minimax/minimax-m3` through OpenRouter and finished at 6/20, median 49.13 seconds, p95 57.87 seconds, and 2 repaired passes. The privacy-safe failure matrix was eight `json_invalid`, five `contract_invalid`, and one timeout. The ignored private JSONL is `acceptance/results.2026-08-27.local.jsonl`. This is a measured stochastic regression from the earlier 16/20 default run, so readiness remains blocked and no parser relaxation, immutable version, review submission, or release is justified. - 27 August Gemma rerun: the complete immutable corpus with advisory hint `gemma` finished at 15/20, median 21.91 seconds, p95 41.92 seconds, and 6 repaired passes. Failures were A07/A10/A12/A19 `contract_invalid` and A20 `required_field_invalid`, with no timeout or JSON truncation. The ignored private JSONL is `acceptance/results.2026-08-27.gemma.local.jsonl`. This improves the measured Gemma profile from 14/20 to 15/20 but remains below the 18/20 gate; keep the PR draft and do not cut, submit, or release a version. - 31 August post-repair Gemma rerun: the complete connected corpus again finished at 15/20, median 23.90 seconds, p95 44.00 seconds, and 7 repaired passes. A01 failed at transport; A06/A08/A15/A20 were `json_invalid` after complete, non-truncated Gemma/Runpod responses. The schema-complete repair changed which prompts pass and increased repaired passes, but did not improve the total score. The private result was canonically re-evaluated from an RPC-log recovery because the iframe download was not surfaced to automation. Keep the release gate failed and do not fund another blind rerun without a measured syntax intervention or platform/model change. +- Offline syntax experiment: a maximum-three-edit punctuation search recovered only A08/A15, projecting 17/20. A06 had no valid candidate; structurally closing A20 still left five contract failures. Do not add a general JSON-repair dependency or permissive parser from this evidence. Production remains fail-closed; the next useful evidence requires a model/platform change or a specifically measured generation constraint, not another blind corpus run. From e07623070660bf25ee79848bae24454df8ff45f2 Mon Sep 17 00:00:00 2001 From: RapideCastor Date: Tue, 1 Sep 2026 15:05:38 +0200 Subject: [PATCH 076/113] docs(harbour): map Anna structured-output boundary --- apps/storycore-harbour/STATUS.md | 1 + .../review/ANNA_STRUCTURED_OUTPUT_REQUEST.md | 89 +++++++++++++++++++ .../review/MVP_ROADMAP_2026-08-24.md | 1 + 3 files changed, 91 insertions(+) create mode 100644 apps/storycore-harbour/review/ANNA_STRUCTURED_OUTPUT_REQUEST.md diff --git a/apps/storycore-harbour/STATUS.md b/apps/storycore-harbour/STATUS.md index 1e5a9bdc..ac5bf627 100644 --- a/apps/storycore-harbour/STATUS.md +++ b/apps/storycore-harbour/STATUS.md @@ -314,3 +314,4 @@ Codex must inspect the latest CI and Sonar results first. The next legitimate im - An owner-authorized complete corpus on 2026-08-31 used the advisory `gemma` hint after the schema-complete repair change. The connected Anna UI finished at 15/20, median 23.90 seconds, p95 44.00 seconds, and 7 repaired passes. Failures were HBR-A01 `unknown` after one transport `fetch failed`, plus HBR-A06/A08/A15/A20 `json_invalid`. Every malformed primary/repair response used `gemma-4-E4B-it` through Runpod, ended with `endTurn`, stayed between 1,269 and 1,722 output tokens, and ended with a closing brace; the remaining failures are internal JSON syntax errors rather than 4,096-token truncation. The score remains below 18/20, so no readiness, immutable cut, review, merge, or release claim is permitted. - The iframe sandbox did not surface its Blob download to Browser automation. The private result was therefore recovered from the same run's RPC log: exactly 15 `projects/current` writes were matched to the immutable corpus, each recovered project passed the canonical contract, UI-displayed successful durations were retained at 0.1-second precision, and failure durations came from the sequential RPC timestamps. The canonical evaluator independently reproduced 15/20 and the same five privacy-safe failures. Ignored evidence remains at `acceptance/results.2026-08-31.gemma.recovered.local.jsonl`, `acceptance/harness.2026-08-31.gemma.local.log`, and companion local logs; generated content was not committed or posted. - A private offline punctuation-recovery experiment made no model calls and never changed generated words. A bounded search of at most three edits from `{ } [ ] , :` recovered A08 and A15 with two edits each, so the best projected score was only 17/20. A06 remained unparsable after 28,876 bounded candidates. Closing A20's unbalanced JSON made it parse but left five canonical contract errors. There is no single bounded syntax rule that reaches the release gate, so the experimental helper was removed and production parsing remains fail-closed. +- Structured-output research on 2026-09-01 found that Anna documents `json_object`/`json_schema` for Executa `sampling/createMessage`, not for StoryCore's direct iframe `anna.llm.complete`. The pinned CLI 0.1.30 has no structured-output implementation; a read-only inspection of npm CLI 0.1.49 found negotiation only in the sampling bridge and still no direct Host API field. An Executa migration would violate the Host-API-only MVP boundary, and a current Cloud Agent report shows `json_schema` failures even with fallback. `review/ANNA_STRUCTURED_OUTPUT_REQUEST.md` contains the exact support question and a one-prompt gate; no package was upgraded and no model call was made. diff --git a/apps/storycore-harbour/review/ANNA_STRUCTURED_OUTPUT_REQUEST.md b/apps/storycore-harbour/review/ANNA_STRUCTURED_OUTPUT_REQUEST.md new file mode 100644 index 00000000..da283a04 --- /dev/null +++ b/apps/storycore-harbour/review/ANNA_STRUCTURED_OUTPUT_REQUEST.md @@ -0,0 +1,89 @@ +# Anna direct Host LLM structured-output request + +Prepared on 2026-09-01. This is a support-question and bounded-probe plan; it +has not been sent and no model call was made for this investigation. + +## StoryCore evidence + +- StoryCore Harbour is a Schema 2, Host-API-only App with no Executa. +- Its current generation path is `anna.llm.complete` from the iframe bundle. +- The 2026-08-31 Gemma corpus measured 15/20. Four final repair responses were + complete, non-truncated, and ended with `}`, but contained internal JSON + syntax errors. +- A punctuation-only offline search could recover only two cases, projecting + 17/20; it does not justify a permissive production parser. + +## Verified Anna surfaces + +Anna supports structured output on Executa reverse sampling: + +- `sampling/createMessage` accepts `responseFormat` with `json_object` or + `json_schema` plus `onUnsupported`; +- the real bridge sends the negotiated value as `response_format` to the + platform completion endpoint; +- CLI structured-output emulation exists for the sampling development path. + +This support is not currently documented or typed for the iframe Host API +`anna.llm.complete`. Its published signature lists messages, `maxTokens`, +`modelPreferences`, `systemPrompt`, temperature, stop sequences, and metadata, +but no response-format field. + +Local package inspection confirms the same boundary: + +- pinned CLI 0.1.30 contains no `responseFormat`, `response_format`, or + `onUnsupported` implementation; +- latest npm CLI 0.1.49 adds structured negotiation to its sampling bridge; +- CLI 0.1.49 still exposes no response-format field for direct Host + `llm.complete`. + +Moving StoryCore generation into an Executa only to obtain JSON Schema would +add a backend/distribution/permission boundary and violate the MVP's intended +Host-API-only architecture. Do not make that change without explicit product +and architecture approval. + +## Current platform caution + +The Anna Forum currently reports a Cloud Agent failure for `json_schema` even +with `onUnsupported=json_object`. Structured sampling is therefore not yet a +drop-in reliability proof for StoryCore. + +Sources checked: + +- +- +- +- + +## Ready-to-send question + +```text +Hi Anna team — StoryCore Harbour is a Schema 2 Host-API-only App using direct +iframe anna.llm.complete, with no Executa. Our latest fixed Gemma corpus is +15/20; four failed repairs are complete/non-truncated responses with internal +JSON syntax errors. + +The current sampling/createMessage path supports responseFormat +(json_object/json_schema) and onUnsupported, but the documented direct +anna.llm.complete signature and current CLI types do not expose those fields. + +1. Does direct iframe anna.llm.complete currently accept responseFormat or + response_format in production? +2. If yes, what exact wire shape and capability-negotiation response should a + Schema 2 App use? +3. Is json_object supported for gemma-4-E4B-it/Runpod through the App-complete + path? +4. Can unsupported structured output fail explicitly without silently falling + back to prompt-only text? +5. Is there a recommended Host-API-only example or minimum runtime/CLI version? + +We will not add an Executa, send another full corpus, or claim reliability from +an undocumented field. With confirmation, we can run one owner-authorized +single-prompt probe before considering any code change. +``` + +## Probe gate + +Do not add an undocumented field to production or consume quota until Anna +confirms the direct Host API contract. If confirmed, implement the smallest +adapter-only opt-in and run one previously failing prompt first. A complete +corpus requires separate owner quota approval after that pilot succeeds. diff --git a/apps/storycore-harbour/review/MVP_ROADMAP_2026-08-24.md b/apps/storycore-harbour/review/MVP_ROADMAP_2026-08-24.md index 3d0367c8..7c92f140 100644 --- a/apps/storycore-harbour/review/MVP_ROADMAP_2026-08-24.md +++ b/apps/storycore-harbour/review/MVP_ROADMAP_2026-08-24.md @@ -95,3 +95,4 @@ Do not replace production-platform gates with these local results. - 27 August Gemma rerun: the complete immutable corpus with advisory hint `gemma` finished at 15/20, median 21.91 seconds, p95 41.92 seconds, and 6 repaired passes. Failures were A07/A10/A12/A19 `contract_invalid` and A20 `required_field_invalid`, with no timeout or JSON truncation. The ignored private JSONL is `acceptance/results.2026-08-27.gemma.local.jsonl`. This improves the measured Gemma profile from 14/20 to 15/20 but remains below the 18/20 gate; keep the PR draft and do not cut, submit, or release a version. - 31 August post-repair Gemma rerun: the complete connected corpus again finished at 15/20, median 23.90 seconds, p95 44.00 seconds, and 7 repaired passes. A01 failed at transport; A06/A08/A15/A20 were `json_invalid` after complete, non-truncated Gemma/Runpod responses. The schema-complete repair changed which prompts pass and increased repaired passes, but did not improve the total score. The private result was canonically re-evaluated from an RPC-log recovery because the iframe download was not surfaced to automation. Keep the release gate failed and do not fund another blind rerun without a measured syntax intervention or platform/model change. - Offline syntax experiment: a maximum-three-edit punctuation search recovered only A08/A15, projecting 17/20. A06 had no valid candidate; structurally closing A20 still left five contract failures. Do not add a general JSON-repair dependency or permissive parser from this evidence. Production remains fail-closed; the next useful evidence requires a model/platform change or a specifically measured generation constraint, not another blind corpus run. +- Structured-output boundary: Anna's JSON Schema support is currently verified for Executa sampling, not the direct iframe `anna.llm.complete` path used by StoryCore. CLI 0.1.49 still confines response-format negotiation to sampling, and current Cloud Agent reports show structured fallback failures. Keep StoryCore Host-API-only; ask Anna for the direct contract, then permit at most one failing-prompt pilot before any adapter change or full rerun. From e4ee8354e7be34e50cd52ec865108043772fdc27 Mon Sep 17 00:00:00 2001 From: Sylvain Galliez <128614184+zedarvates@users.noreply.github.com> Date: Wed, 2 Sep 2026 05:32:46 +0200 Subject: [PATCH 077/113] ci: add minimal repository checkout proof --- .github/workflows/repository-checkout-ci.yml | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) create mode 100644 .github/workflows/repository-checkout-ci.yml diff --git a/.github/workflows/repository-checkout-ci.yml b/.github/workflows/repository-checkout-ci.yml new file mode 100644 index 00000000..e8615624 --- /dev/null +++ b/.github/workflows/repository-checkout-ci.yml @@ -0,0 +1,19 @@ +name: Repository Checkout CI + +on: + pull_request: + paths: + - 'GemReward-Service-Repo' + - '.gitmodules' + - '.github/workflows/repository-checkout-ci.yml' + workflow_dispatch: + +permissions: + contents: read + +jobs: + checkout: + runs-on: ubuntu-latest + timeout-minutes: 5 + steps: + - uses: actions/checkout@v4 From a3dc48a60d4cbe96308875a6ae9e86b965f3c84e Mon Sep 17 00:00:00 2001 From: Sylvain Galliez <128614184+zedarvates@users.noreply.github.com> Date: Wed, 2 Sep 2026 05:33:11 +0200 Subject: [PATCH 078/113] fix(ci): remove stale GemReward gitlink --- GemReward-Service-Repo | 1 - 1 file changed, 1 deletion(-) delete mode 160000 GemReward-Service-Repo diff --git a/GemReward-Service-Repo b/GemReward-Service-Repo deleted file mode 160000 index 9219f19f..00000000 --- a/GemReward-Service-Repo +++ /dev/null @@ -1 +0,0 @@ -Subproject commit 9219f19f353651e10b12fdcb00d6688bddf79b7f From c9c3fc7b0ba45d373028632c45dc647944bf179f Mon Sep 17 00:00:00 2001 From: RapideCastor Date: Sat, 5 Sep 2026 12:01:35 +0200 Subject: [PATCH 079/113] docs(harbour): refresh Anna CLI 0.1.51 boundaries --- apps/storycore-harbour/STATUS.md | 1 + .../review/ANNA_DEV_INSTALL_PERMISSIONS_REPORT.md | 14 ++++++++++++++ .../review/ANNA_STRUCTURED_OUTPUT_REQUEST.md | 10 ++++++++++ 3 files changed, 25 insertions(+) diff --git a/apps/storycore-harbour/STATUS.md b/apps/storycore-harbour/STATUS.md index ac5bf627..aa6bd785 100644 --- a/apps/storycore-harbour/STATUS.md +++ b/apps/storycore-harbour/STATUS.md @@ -315,3 +315,4 @@ Codex must inspect the latest CI and Sonar results first. The next legitimate im - The iframe sandbox did not surface its Blob download to Browser automation. The private result was therefore recovered from the same run's RPC log: exactly 15 `projects/current` writes were matched to the immutable corpus, each recovered project passed the canonical contract, UI-displayed successful durations were retained at 0.1-second precision, and failure durations came from the sequential RPC timestamps. The canonical evaluator independently reproduced 15/20 and the same five privacy-safe failures. Ignored evidence remains at `acceptance/results.2026-08-31.gemma.recovered.local.jsonl`, `acceptance/harness.2026-08-31.gemma.local.log`, and companion local logs; generated content was not committed or posted. - A private offline punctuation-recovery experiment made no model calls and never changed generated words. A bounded search of at most three edits from `{ } [ ] , :` recovered A08 and A15 with two edits each, so the best projected score was only 17/20. A06 remained unparsable after 28,876 bounded candidates. Closing A20's unbalanced JSON made it parse but left five canonical contract errors. There is no single bounded syntax rule that reaches the release gate, so the experimental helper was removed and production parsing remains fail-closed. - Structured-output research on 2026-09-01 found that Anna documents `json_object`/`json_schema` for Executa `sampling/createMessage`, not for StoryCore's direct iframe `anna.llm.complete`. The pinned CLI 0.1.30 has no structured-output implementation; a read-only inspection of npm CLI 0.1.49 found negotiation only in the sampling bridge and still no direct Host API field. An Executa migration would violate the Host-API-only MVP boundary, and a current Cloud Agent report shows `json_schema` failures even with fallback. `review/ANNA_STRUCTURED_OUTPUT_REQUEST.md` contains the exact support question and a one-prompt gate; no package was upgraded and no model call was made. +- A 2026-09-05 refresh found no direct-Host structured-output update. CLI 0.1.51 still confines `response_format` negotiation to Executa sampling. Its enhanced read-only `apps grants` command also returned only `grants: null` for StoryCore, without the new `satisfied`/`missing` fields, matching the unresolved `v0.0.0-dev` permission-version gap. The newer CLI was executed ephemerally and not added to the project; no quota, grant, draft, version, or installation state changed. diff --git a/apps/storycore-harbour/review/ANNA_DEV_INSTALL_PERMISSIONS_REPORT.md b/apps/storycore-harbour/review/ANNA_DEV_INSTALL_PERMISSIONS_REPORT.md index fcf4cac7..75d3cc29 100644 --- a/apps/storycore-harbour/review/ANNA_DEV_INSTALL_PERMISSIONS_REPORT.md +++ b/apps/storycore-harbour/review/ANNA_DEV_INSTALL_PERMISSIONS_REPORT.md @@ -83,3 +83,17 @@ working-draft Install & test record? We have not reinstalled, uninstalled, changed grants, cut a version, submitted review, or published anything. ``` + +## 2026-09-05 CLI 0.1.51 read-only check + +Anna CLI 0.1.51 adds a richer `apps grants` reader that first queries the App +permissions endpoint and normally reports `satisfied`, `missing`, and bundled +Executa grants. Running that command ephemerally against StoryCore Harbour still +returned only `grants: null`, with none of those permissions fields. This is +consistent with the Console's `App version not found` result: the improved CLI +cannot resolve permissions for the existing `v0.0.0-dev` record either. + +The CLI was not added to the project, no grant was changed, and the temporary +package inspection directory was removed. Version 0.1.51 does not provide a +grant mutation command; the dashboard remains the documented permission-change +surface. diff --git a/apps/storycore-harbour/review/ANNA_STRUCTURED_OUTPUT_REQUEST.md b/apps/storycore-harbour/review/ANNA_STRUCTURED_OUTPUT_REQUEST.md index da283a04..611bd946 100644 --- a/apps/storycore-harbour/review/ANNA_STRUCTURED_OUTPUT_REQUEST.md +++ b/apps/storycore-harbour/review/ANNA_STRUCTURED_OUTPUT_REQUEST.md @@ -87,3 +87,13 @@ Do not add an undocumented field to production or consume quota until Anna confirms the direct Host API contract. If confirmed, implement the smallest adapter-only opt-in and run one previously failing prompt first. A complete corpus requires separate owner quota approval after that pilot succeeds. + +## 2026-09-05 refresh + +No newer public Anna reference or forum answer was found that adds +`responseFormat` to direct iframe `anna.llm.complete`. Anna CLI 0.1.51 was +inspected without installation into the project. Its real sampling bridge +negotiates `responseFormat` and forwards `response_format`, but its direct App +LLM bridge still exposes no structured-output contract. Do not upgrade the +pinned CLI merely for this issue and do not send an undocumented field to the +production endpoint. From 4146f43b00195a8812daf4560883c01408aacf10 Mon Sep 17 00:00:00 2001 From: RapideCastor Date: Sat, 5 Sep 2026 12:55:28 +0200 Subject: [PATCH 080/113] docs(harbour): record Anna support escalation --- apps/storycore-harbour/STATUS.md | 1 + .../review/ANNA_DEV_INSTALL_PERMISSIONS_REPORT.md | 7 ++++++- .../review/ANNA_STRUCTURED_OUTPUT_REQUEST.md | 9 +++++++-- 3 files changed, 14 insertions(+), 3 deletions(-) diff --git a/apps/storycore-harbour/STATUS.md b/apps/storycore-harbour/STATUS.md index aa6bd785..c8444c35 100644 --- a/apps/storycore-harbour/STATUS.md +++ b/apps/storycore-harbour/STATUS.md @@ -316,3 +316,4 @@ Codex must inspect the latest CI and Sonar results first. The next legitimate im - A private offline punctuation-recovery experiment made no model calls and never changed generated words. A bounded search of at most three edits from `{ } [ ] , :` recovered A08 and A15 with two edits each, so the best projected score was only 17/20. A06 remained unparsable after 28,876 bounded candidates. Closing A20's unbalanced JSON made it parse but left five canonical contract errors. There is no single bounded syntax rule that reaches the release gate, so the experimental helper was removed and production parsing remains fail-closed. - Structured-output research on 2026-09-01 found that Anna documents `json_object`/`json_schema` for Executa `sampling/createMessage`, not for StoryCore's direct iframe `anna.llm.complete`. The pinned CLI 0.1.30 has no structured-output implementation; a read-only inspection of npm CLI 0.1.49 found negotiation only in the sampling bridge and still no direct Host API field. An Executa migration would violate the Host-API-only MVP boundary, and a current Cloud Agent report shows `json_schema` failures even with fallback. `review/ANNA_STRUCTURED_OUTPUT_REQUEST.md` contains the exact support question and a one-prompt gate; no package was upgraded and no model call was made. - A 2026-09-05 refresh found no direct-Host structured-output update. CLI 0.1.51 still confines `response_format` negotiation to Executa sampling. Its enhanced read-only `apps grants` command also returned only `grants: null` for StoryCore, without the new `satisfied`/`missing` fields, matching the unresolved `v0.0.0-dev` permission-version gap. The newer CLI was executed ephemerally and not added to the project; no quota, grant, draft, version, or installation state changed. +- With explicit owner approval, one combined plain-text support email was sent to `hi@anna.partners` on 2026-09-05. It asks how to repair the existing `v0.0.0-dev` permission/version mismatch and whether direct iframe `anna.llm.complete` supports negotiated `json_object`/`json_schema`. It includes App id 214, slug, revision, privacy-safe 15/20 evidence, and the non-action boundaries; it contains no attachment, raw generated response, private JSONL, credential, or token. Do not send a duplicate while awaiting Anna's reply. diff --git a/apps/storycore-harbour/review/ANNA_DEV_INSTALL_PERMISSIONS_REPORT.md b/apps/storycore-harbour/review/ANNA_DEV_INSTALL_PERMISSIONS_REPORT.md index 75d3cc29..a116ba23 100644 --- a/apps/storycore-harbour/review/ANNA_DEV_INSTALL_PERMISSIONS_REPORT.md +++ b/apps/storycore-harbour/review/ANNA_DEV_INSTALL_PERMISSIONS_REPORT.md @@ -1,6 +1,8 @@ # Anna working-draft installation permissions report -Prepared on 2026-08-29. This report is local and has not been sent. +Prepared on 2026-08-29. An owner-approved combined support email was sent to +`hi@anna.partners` on 2026-09-05 with this installation question and the direct +structured-output question. No attachment or generated private result was sent. ## Observed state @@ -63,6 +65,9 @@ cutting `0.1.0` while StoryCore's measured reliability gate remains below ## Ready-to-send support question +Sent in the combined 2026-09-05 support email. Do not send a duplicate while a +reply is pending. + ```text Hi Anna team — StoryCore Harbour (App id 214) has a ready working draft at r12 and is already listed in Installed Apps as v0.0.0-dev. However, opening its diff --git a/apps/storycore-harbour/review/ANNA_STRUCTURED_OUTPUT_REQUEST.md b/apps/storycore-harbour/review/ANNA_STRUCTURED_OUTPUT_REQUEST.md index 611bd946..ad12b3d7 100644 --- a/apps/storycore-harbour/review/ANNA_STRUCTURED_OUTPUT_REQUEST.md +++ b/apps/storycore-harbour/review/ANNA_STRUCTURED_OUTPUT_REQUEST.md @@ -1,7 +1,9 @@ # Anna direct Host LLM structured-output request -Prepared on 2026-09-01. This is a support-question and bounded-probe plan; it -has not been sent and no model call was made for this investigation. +Prepared on 2026-09-01. An owner-approved combined support email was sent to +`hi@anna.partners` on 2026-09-05 with this question and the working-draft +permission issue. No attachment or generated private result was sent, and no +model call was made for this investigation. ## StoryCore evidence @@ -56,6 +58,9 @@ Sources checked: ## Ready-to-send question +Sent in the combined 2026-09-05 support email. Do not send a duplicate while a +reply is pending. + ```text Hi Anna team — StoryCore Harbour is a Schema 2 Host-API-only App using direct iframe anna.llm.complete, with no Executa. Our latest fixed Gemma corpus is From 6f04c477a47c70bfa883665c7628a4baab8e87cc Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 8 Sep 2026 08:07:48 +0000 Subject: [PATCH 081/113] chore(deps): bump the npm_and_yarn group across 3 directories with 3 updates Bumps the npm_and_yarn group with 2 updates in the / directory: [@babel/core](https://github.com/babel/babel/tree/HEAD/packages/babel-core) and [js-yaml](https://github.com/nodeca/js-yaml). Bumps the npm_and_yarn group with 2 updates in the /config directory: [@babel/core](https://github.com/babel/babel/tree/HEAD/packages/babel-core) and [js-yaml](https://github.com/nodeca/js-yaml). Bumps the npm_and_yarn group with 2 updates in the /creative-studio-ui directory: [@babel/core](https://github.com/babel/babel/tree/HEAD/packages/babel-core) and [fflate](https://github.com/101arrowz/fflate). Updates `@babel/core` from 7.28.6 to 7.29.7 - [Release notes](https://github.com/babel/babel/releases) - [Changelog](https://github.com/babel/babel/blob/main/CHANGELOG.md) - [Commits](https://github.com/babel/babel/commits/v7.29.7/packages/babel-core) Updates `js-yaml` from 3.14.2 to 3.15.2 - [Changelog](https://github.com/nodeca/js-yaml/blob/3.15.2/CHANGELOG.md) - [Commits](https://github.com/nodeca/js-yaml/compare/3.14.2...3.15.2) Updates `@babel/core` from 7.28.6 to 7.29.7 - [Release notes](https://github.com/babel/babel/releases) - [Changelog](https://github.com/babel/babel/blob/main/CHANGELOG.md) - [Commits](https://github.com/babel/babel/commits/v7.29.7/packages/babel-core) Updates `js-yaml` from 3.14.2 to 3.15.2 - [Changelog](https://github.com/nodeca/js-yaml/blob/3.15.2/CHANGELOG.md) - [Commits](https://github.com/nodeca/js-yaml/compare/3.14.2...3.15.2) Updates `@babel/core` from 7.28.6 to 7.29.7 - [Release notes](https://github.com/babel/babel/releases) - [Changelog](https://github.com/babel/babel/blob/main/CHANGELOG.md) - [Commits](https://github.com/babel/babel/commits/v7.29.7/packages/babel-core) Updates `fflate` from 0.6.10 to 0.6.11 - [Release notes](https://github.com/101arrowz/fflate/releases) - [Changelog](https://github.com/101arrowz/fflate/blob/master/CHANGELOG.md) - [Commits](https://github.com/101arrowz/fflate/commits) --- updated-dependencies: - dependency-name: "@babel/core" dependency-version: 7.29.7 dependency-type: indirect dependency-group: npm_and_yarn - dependency-name: js-yaml dependency-version: 3.15.2 dependency-type: indirect dependency-group: npm_and_yarn - dependency-name: "@babel/core" dependency-version: 7.29.7 dependency-type: indirect dependency-group: npm_and_yarn - dependency-name: js-yaml dependency-version: 3.15.2 dependency-type: indirect dependency-group: npm_and_yarn - dependency-name: "@babel/core" dependency-version: 7.29.7 dependency-type: indirect dependency-group: npm_and_yarn - dependency-name: fflate dependency-version: 0.6.11 dependency-type: indirect dependency-group: npm_and_yarn ... Signed-off-by: dependabot[bot] --- config/package-lock.json | 212 +++++++++++----------- creative-studio-ui/package-lock.json | 174 +++++++++--------- package-lock.json | 262 +++++++++++++++------------ 3 files changed, 344 insertions(+), 304 deletions(-) diff --git a/config/package-lock.json b/config/package-lock.json index 680fc9bf..a8ac217e 100644 --- a/config/package-lock.json +++ b/config/package-lock.json @@ -33,13 +33,13 @@ } }, "node_modules/@babel/code-frame": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.28.6.tgz", - "integrity": "sha512-JYgintcMjRiCvS8mMECzaEn+m3PfoQiyqukOMCCVQtoJGYJw8j/8LBJEiqkHLkfwCcs74E3pbAUFNg7d9VNJ+Q==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz", + "integrity": "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==", "dev": true, "license": "MIT", "dependencies": { - "@babel/helper-validator-identifier": "^7.28.5", + "@babel/helper-validator-identifier": "^7.29.7", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" }, @@ -48,9 +48,9 @@ } }, "node_modules/@babel/compat-data": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.28.6.tgz", - "integrity": "sha512-2lfu57JtzctfIrcGMz992hyLlByuzgIk58+hhGCxjKZ3rWI82NnVLjXcaTqkI2NvlcvOskZaiZ5kjUALo3Lpxg==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.7.tgz", + "integrity": "sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg==", "dev": true, "license": "MIT", "engines": { @@ -58,21 +58,21 @@ } }, "node_modules/@babel/core": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.28.6.tgz", - "integrity": "sha512-H3mcG6ZDLTlYfaSNi0iOKkigqMFvkTKlGUYlD8GW7nNOYRrevuA46iTypPyv+06V3fEmvvazfntkBU34L0azAw==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.7.tgz", + "integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==", "dev": true, "license": "MIT", "dependencies": { - "@babel/code-frame": "^7.28.6", - "@babel/generator": "^7.28.6", - "@babel/helper-compilation-targets": "^7.28.6", - "@babel/helper-module-transforms": "^7.28.6", - "@babel/helpers": "^7.28.6", - "@babel/parser": "^7.28.6", - "@babel/template": "^7.28.6", - "@babel/traverse": "^7.28.6", - "@babel/types": "^7.28.6", + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.7", + "@babel/helper-compilation-targets": "^7.29.7", + "@babel/helper-module-transforms": "^7.29.7", + "@babel/helpers": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/template": "^7.29.7", + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7", "@jridgewell/remapping": "^2.3.5", "convert-source-map": "^2.0.0", "debug": "^4.1.0", @@ -89,14 +89,14 @@ } }, "node_modules/@babel/generator": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.28.6.tgz", - "integrity": "sha512-lOoVRwADj8hjf7al89tvQ2a1lf53Z+7tiXMgpZJL3maQPDxh0DgLMN62B2MKUOFcoodBHLMbDM6WAbKgNy5Suw==", + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.8.tgz", + "integrity": "sha512-gZbepsdh3WDtgZKWL+vTPh71LSBrm/Y4/QDZBVCcYfmeTEEuoOYwlSy+G1StfJg+/Zy550u/3TATbm7qDbbMtg==", "dev": true, "license": "MIT", "dependencies": { - "@babel/parser": "^7.28.6", - "@babel/types": "^7.28.6", + "@babel/parser": "^7.29.8", + "@babel/types": "^7.29.8", "@jridgewell/gen-mapping": "^0.3.12", "@jridgewell/trace-mapping": "^0.3.28", "jsesc": "^3.0.2" @@ -106,14 +106,14 @@ } }, "node_modules/@babel/helper-compilation-targets": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.28.6.tgz", - "integrity": "sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.29.7.tgz", + "integrity": "sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g==", "dev": true, "license": "MIT", "dependencies": { - "@babel/compat-data": "^7.28.6", - "@babel/helper-validator-option": "^7.27.1", + "@babel/compat-data": "^7.29.7", + "@babel/helper-validator-option": "^7.29.7", "browserslist": "^4.24.0", "lru-cache": "^5.1.1", "semver": "^6.3.1" @@ -140,9 +140,9 @@ "license": "ISC" }, "node_modules/@babel/helper-globals": { - "version": "7.28.0", - "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.28.0.tgz", - "integrity": "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.29.7.tgz", + "integrity": "sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==", "dev": true, "license": "MIT", "engines": { @@ -150,29 +150,29 @@ } }, "node_modules/@babel/helper-module-imports": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.28.6.tgz", - "integrity": "sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.29.7.tgz", + "integrity": "sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g==", "dev": true, "license": "MIT", "dependencies": { - "@babel/traverse": "^7.28.6", - "@babel/types": "^7.28.6" + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7" }, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/helper-module-transforms": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.28.6.tgz", - "integrity": "sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.29.7.tgz", + "integrity": "sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg==", "dev": true, "license": "MIT", "dependencies": { - "@babel/helper-module-imports": "^7.28.6", - "@babel/helper-validator-identifier": "^7.28.5", - "@babel/traverse": "^7.28.6" + "@babel/helper-module-imports": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7", + "@babel/traverse": "^7.29.7" }, "engines": { "node": ">=6.9.0" @@ -192,9 +192,9 @@ } }, "node_modules/@babel/helper-string-parser": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", - "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz", + "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==", "dev": true, "license": "MIT", "engines": { @@ -202,9 +202,9 @@ } }, "node_modules/@babel/helper-validator-identifier": { - "version": "7.28.5", - "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", - "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", + "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", "dev": true, "license": "MIT", "engines": { @@ -212,9 +212,9 @@ } }, "node_modules/@babel/helper-validator-option": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.27.1.tgz", - "integrity": "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.29.7.tgz", + "integrity": "sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw==", "dev": true, "license": "MIT", "engines": { @@ -222,27 +222,27 @@ } }, "node_modules/@babel/helpers": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.28.6.tgz", - "integrity": "sha512-xOBvwq86HHdB7WUDTfKfT/Vuxh7gElQ+Sfti2Cy6yIWNW05P8iUslOVcZ4/sKbE+/jQaukQAdz/gf3724kYdqw==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.7.tgz", + "integrity": "sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg==", "dev": true, "license": "MIT", "dependencies": { - "@babel/template": "^7.28.6", - "@babel/types": "^7.28.6" + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.7" }, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/parser": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.28.6.tgz", - "integrity": "sha512-TeR9zWR18BvbfPmGbLampPMW+uW1NZnJlRuuHso8i87QZNq2JRF9i6RgxRqtEq+wQGsS19NNTWr2duhnE49mfQ==", + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.8.tgz", + "integrity": "sha512-E8lTAYNB1KW+FH+VGJuZM1ioAx2E6oVlvQFRrf5P8ZZmsiJXYAD9vTFV7yyEURNzgh1dFqMZuO6tUwcARbqFCA==", "dev": true, "license": "MIT", "dependencies": { - "@babel/types": "^7.28.6" + "@babel/types": "^7.29.8" }, "bin": { "parser": "bin/babel-parser.js" @@ -491,33 +491,33 @@ } }, "node_modules/@babel/template": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.28.6.tgz", - "integrity": "sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.29.7.tgz", + "integrity": "sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==", "dev": true, "license": "MIT", "dependencies": { - "@babel/code-frame": "^7.28.6", - "@babel/parser": "^7.28.6", - "@babel/types": "^7.28.6" + "@babel/code-frame": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/types": "^7.29.7" }, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/traverse": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.28.6.tgz", - "integrity": "sha512-fgWX62k02qtjqdSNTAGxmKYY/7FSL9WAS1o2Hu5+I5m9T0yxZzr4cnrfXQ/MX0rIifthCSs6FKTlzYbJcPtMNg==", + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.8.tgz", + "integrity": "sha512-I5z7H3bf/41ktsNVLtpN0wAa336HkqIHQ5BuPLEhTkt1jVSyZpeNKIzTgEWmlxjdg81R0IgUCcaE+Ok3NvrfZg==", "dev": true, "license": "MIT", "dependencies": { - "@babel/code-frame": "^7.28.6", - "@babel/generator": "^7.28.6", - "@babel/helper-globals": "^7.28.0", - "@babel/parser": "^7.28.6", - "@babel/template": "^7.28.6", - "@babel/types": "^7.28.6", + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.8", + "@babel/helper-globals": "^7.29.7", + "@babel/parser": "^7.29.8", + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.8", "debug": "^4.3.1" }, "engines": { @@ -525,14 +525,14 @@ } }, "node_modules/@babel/types": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.28.6.tgz", - "integrity": "sha512-0ZrskXVEHSWIqZM/sQZ4EV3jZJXRkio/WCxaqKZP1g//CEWEPSfeZFcms4XeKBCHU0ZKnIkdJeU/kF+eRp5lBg==", + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.8.tgz", + "integrity": "sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg==", "dev": true, "license": "MIT", "dependencies": { - "@babel/helper-string-parser": "^7.27.1", - "@babel/helper-validator-identifier": "^7.28.5" + "@babel/helper-string-parser": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7" }, "engines": { "node": ">=6.9.0" @@ -945,9 +945,9 @@ } }, "node_modules/@istanbuljs/load-nyc-config/node_modules/js-yaml": { - "version": "3.14.2", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.14.2.tgz", - "integrity": "sha512-PMSmkqxr106Xa156c2M265Z+FTrPl+oxd/rgOQy2tijQeK5TxQ43psO1ZCwhVOSdnn+RzkzlRz/eY4BgJBYVpg==", + "version": "3.15.2", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.15.2.tgz", + "integrity": "sha512-6EuL879VkRA+1Cz578mKMiKvjPNEuk6+r1JaFzoSWejZmtf7xWbIyw1e3KkxlkzTIt9Taw6JBhEppG7utc1P+w==", "dev": true, "license": "MIT", "dependencies": { @@ -2246,9 +2246,9 @@ "license": "MIT" }, "node_modules/baseline-browser-mapping": { - "version": "2.11.20", - "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.11.20.tgz", - "integrity": "sha512-H0ulySigv6icDJ1F7SjtdCD6PrhTpdYCmP0CactWy1+ekh0AFd0o1Wn5T8b+hnTmdBx19u9yhL6wvCylXMY7zw==", + "version": "2.11.21", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.11.21.tgz", + "integrity": "sha512-uh8vpY/1/YyFkunIDFH/12p7/7VdPKA1hejMVEbdkEaWnUz0Hesvx5EbiU6XxjyHZIOju+ZMbQJkRh+es3/spQ==", "dev": true, "license": "Apache-2.0", "bin": { @@ -2299,9 +2299,9 @@ } }, "node_modules/browserslist": { - "version": "4.28.8", - "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.8.tgz", - "integrity": "sha512-V2NpofLblG64mfOtSgDhOJESZEGogzDMBv/q+W6oc4LXWP/q75eOXoOaaOu1EOadB9U4Bwx/e0yzbvwKH8zalA==", + "version": "4.28.9", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.9.tgz", + "integrity": "sha512-EWazOblFYUvlGZcfGhPUPmYh3nikUxBVb+y9MJun5f3hBi812X+8MSQTujLBtgK3cf51fJWbWfOjyeO954d+Eg==", "dev": true, "funding": [ { @@ -2319,11 +2319,11 @@ ], "license": "MIT", "dependencies": { - "baseline-browser-mapping": "^2.11.12", - "caniuse-lite": "^1.0.30001809", - "electron-to-chromium": "^1.5.402", - "node-releases": "^2.0.53", - "update-browserslist-db": "^1.3.0" + "baseline-browser-mapping": "^2.11.20", + "caniuse-lite": "^1.0.30001810", + "electron-to-chromium": "^1.5.420", + "node-releases": "^2.0.54", + "update-browserslist-db": "^1.3.2" }, "bin": { "browserslist": "cli.js" @@ -3150,9 +3150,9 @@ } }, "node_modules/electron-to-chromium": { - "version": "1.5.420", - "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.420.tgz", - "integrity": "sha512-2yD6XreGusOfNV+dUcvipJEXc3n/n7fgr7996aszTG+YY5E4mqM4tOq/3uhP129cazL9YHbVWSpc79ePotWtPA==", + "version": "1.5.423", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.423.tgz", + "integrity": "sha512-rRZfTSY8ptHYMQxa+uIycJMFKmY1T0GIApNMXJYGehguTZa56TEEl19pKPCoBqk5Gpf7QizZn/jt7xur+DYxag==", "dev": true, "license": "ISC" }, @@ -4963,10 +4963,20 @@ "license": "MIT" }, "node_modules/js-yaml": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz", - "integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==", + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.2.tgz", + "integrity": "sha512-SFNOvSJ+Dgf/9An904Yx+CgSlIPCkIpao4qo51lpee25TIRejdH3rhR4EZMGoNx3/TP3O+wzWuiTFl4sqbltzA==", "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/puzrin" + }, + { + "type": "github", + "url": "https://github.com/sponsors/nodeca" + } + ], "license": "MIT", "dependencies": { "argparse": "^2.0.1" diff --git a/creative-studio-ui/package-lock.json b/creative-studio-ui/package-lock.json index 6c25043a..c81174e4 100644 --- a/creative-studio-ui/package-lock.json +++ b/creative-studio-ui/package-lock.json @@ -188,12 +188,12 @@ } }, "node_modules/@babel/code-frame": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.28.6.tgz", - "integrity": "sha512-JYgintcMjRiCvS8mMECzaEn+m3PfoQiyqukOMCCVQtoJGYJw8j/8LBJEiqkHLkfwCcs74E3pbAUFNg7d9VNJ+Q==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz", + "integrity": "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==", "license": "MIT", "dependencies": { - "@babel/helper-validator-identifier": "^7.28.5", + "@babel/helper-validator-identifier": "^7.29.7", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" }, @@ -202,9 +202,9 @@ } }, "node_modules/@babel/compat-data": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.28.6.tgz", - "integrity": "sha512-2lfu57JtzctfIrcGMz992hyLlByuzgIk58+hhGCxjKZ3rWI82NnVLjXcaTqkI2NvlcvOskZaiZ5kjUALo3Lpxg==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.7.tgz", + "integrity": "sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg==", "dev": true, "license": "MIT", "engines": { @@ -212,21 +212,21 @@ } }, "node_modules/@babel/core": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.28.6.tgz", - "integrity": "sha512-H3mcG6ZDLTlYfaSNi0iOKkigqMFvkTKlGUYlD8GW7nNOYRrevuA46iTypPyv+06V3fEmvvazfntkBU34L0azAw==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.7.tgz", + "integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==", "dev": true, "license": "MIT", "dependencies": { - "@babel/code-frame": "^7.28.6", - "@babel/generator": "^7.28.6", - "@babel/helper-compilation-targets": "^7.28.6", - "@babel/helper-module-transforms": "^7.28.6", - "@babel/helpers": "^7.28.6", - "@babel/parser": "^7.28.6", - "@babel/template": "^7.28.6", - "@babel/traverse": "^7.28.6", - "@babel/types": "^7.28.6", + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.7", + "@babel/helper-compilation-targets": "^7.29.7", + "@babel/helper-module-transforms": "^7.29.7", + "@babel/helpers": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/template": "^7.29.7", + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7", "@jridgewell/remapping": "^2.3.5", "convert-source-map": "^2.0.0", "debug": "^4.1.0", @@ -243,13 +243,13 @@ } }, "node_modules/@babel/generator": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.28.6.tgz", - "integrity": "sha512-lOoVRwADj8hjf7al89tvQ2a1lf53Z+7tiXMgpZJL3maQPDxh0DgLMN62B2MKUOFcoodBHLMbDM6WAbKgNy5Suw==", + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.8.tgz", + "integrity": "sha512-gZbepsdh3WDtgZKWL+vTPh71LSBrm/Y4/QDZBVCcYfmeTEEuoOYwlSy+G1StfJg+/Zy550u/3TATbm7qDbbMtg==", "license": "MIT", "dependencies": { - "@babel/parser": "^7.28.6", - "@babel/types": "^7.28.6", + "@babel/parser": "^7.29.8", + "@babel/types": "^7.29.8", "@jridgewell/gen-mapping": "^0.3.12", "@jridgewell/trace-mapping": "^0.3.28", "jsesc": "^3.0.2" @@ -259,14 +259,14 @@ } }, "node_modules/@babel/helper-compilation-targets": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.28.6.tgz", - "integrity": "sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.29.7.tgz", + "integrity": "sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g==", "dev": true, "license": "MIT", "dependencies": { - "@babel/compat-data": "^7.28.6", - "@babel/helper-validator-option": "^7.27.1", + "@babel/compat-data": "^7.29.7", + "@babel/helper-validator-option": "^7.29.7", "browserslist": "^4.24.0", "lru-cache": "^5.1.1", "semver": "^6.3.1" @@ -276,37 +276,37 @@ } }, "node_modules/@babel/helper-globals": { - "version": "7.28.0", - "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.28.0.tgz", - "integrity": "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.29.7.tgz", + "integrity": "sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==", "license": "MIT", "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/helper-module-imports": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.28.6.tgz", - "integrity": "sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.29.7.tgz", + "integrity": "sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g==", "license": "MIT", "dependencies": { - "@babel/traverse": "^7.28.6", - "@babel/types": "^7.28.6" + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7" }, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/helper-module-transforms": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.28.6.tgz", - "integrity": "sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.29.7.tgz", + "integrity": "sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg==", "dev": true, "license": "MIT", "dependencies": { - "@babel/helper-module-imports": "^7.28.6", - "@babel/helper-validator-identifier": "^7.28.5", - "@babel/traverse": "^7.28.6" + "@babel/helper-module-imports": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7", + "@babel/traverse": "^7.29.7" }, "engines": { "node": ">=6.9.0" @@ -326,27 +326,27 @@ } }, "node_modules/@babel/helper-string-parser": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", - "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz", + "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==", "license": "MIT", "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/helper-validator-identifier": { - "version": "7.28.5", - "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", - "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", + "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", "license": "MIT", "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/helper-validator-option": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.27.1.tgz", - "integrity": "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.29.7.tgz", + "integrity": "sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw==", "dev": true, "license": "MIT", "engines": { @@ -354,26 +354,26 @@ } }, "node_modules/@babel/helpers": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.28.6.tgz", - "integrity": "sha512-xOBvwq86HHdB7WUDTfKfT/Vuxh7gElQ+Sfti2Cy6yIWNW05P8iUslOVcZ4/sKbE+/jQaukQAdz/gf3724kYdqw==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.7.tgz", + "integrity": "sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg==", "dev": true, "license": "MIT", "dependencies": { - "@babel/template": "^7.28.6", - "@babel/types": "^7.28.6" + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.7" }, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/parser": { - "version": "7.29.0", - "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.0.tgz", - "integrity": "sha512-IyDgFV5GeDUVX4YdF/3CPULtVGSXXMLh1xVIgdCgxApktqnQV0r7/8Nqthg+8YLGaAtdyIlo2qIdZrbCv4+7ww==", + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.8.tgz", + "integrity": "sha512-E8lTAYNB1KW+FH+VGJuZM1ioAx2E6oVlvQFRrf5P8ZZmsiJXYAD9vTFV7yyEURNzgh1dFqMZuO6tUwcARbqFCA==", "license": "MIT", "dependencies": { - "@babel/types": "^7.29.0" + "@babel/types": "^7.29.8" }, "bin": { "parser": "bin/babel-parser.js" @@ -424,31 +424,31 @@ } }, "node_modules/@babel/template": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.28.6.tgz", - "integrity": "sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.29.7.tgz", + "integrity": "sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==", "license": "MIT", "dependencies": { - "@babel/code-frame": "^7.28.6", - "@babel/parser": "^7.28.6", - "@babel/types": "^7.28.6" + "@babel/code-frame": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/types": "^7.29.7" }, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/traverse": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.28.6.tgz", - "integrity": "sha512-fgWX62k02qtjqdSNTAGxmKYY/7FSL9WAS1o2Hu5+I5m9T0yxZzr4cnrfXQ/MX0rIifthCSs6FKTlzYbJcPtMNg==", + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.8.tgz", + "integrity": "sha512-I5z7H3bf/41ktsNVLtpN0wAa336HkqIHQ5BuPLEhTkt1jVSyZpeNKIzTgEWmlxjdg81R0IgUCcaE+Ok3NvrfZg==", "license": "MIT", "dependencies": { - "@babel/code-frame": "^7.28.6", - "@babel/generator": "^7.28.6", - "@babel/helper-globals": "^7.28.0", - "@babel/parser": "^7.28.6", - "@babel/template": "^7.28.6", - "@babel/types": "^7.28.6", + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.8", + "@babel/helper-globals": "^7.29.7", + "@babel/parser": "^7.29.8", + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.8", "debug": "^4.3.1" }, "engines": { @@ -456,13 +456,13 @@ } }, "node_modules/@babel/types": { - "version": "7.29.0", - "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.0.tgz", - "integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==", + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.8.tgz", + "integrity": "sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg==", "license": "MIT", "dependencies": { - "@babel/helper-string-parser": "^7.27.1", - "@babel/helper-validator-identifier": "^7.28.5" + "@babel/helper-string-parser": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7" }, "engines": { "node": ">=6.9.0" @@ -7254,9 +7254,9 @@ } }, "node_modules/fflate": { - "version": "0.8.2", - "resolved": "https://registry.npmjs.org/fflate/-/fflate-0.8.2.tgz", - "integrity": "sha512-cPJU47OaAoCbg0pBvzsgpTPhmhqI5eJjh/JIu8tPj5q+T7iLvW/JAYUqmE7KOB4R1ZyEhzBaIQpQpardBF5z8A==", + "version": "0.8.3", + "resolved": "https://registry.npmjs.org/fflate/-/fflate-0.8.3.tgz", + "integrity": "sha512-tbZNuJrLwGUp3zshBtdy4W+ORxZuIh8a5ilyIEQDC5rY1f3U20JMry0Ll3WBzU58EZKsEuJFXhb5gwv8CsPvgA==", "license": "MIT" }, "node_modules/file-entry-cache": { @@ -11290,9 +11290,9 @@ } }, "node_modules/three-stdlib/node_modules/fflate": { - "version": "0.6.10", - "resolved": "https://registry.npmjs.org/fflate/-/fflate-0.6.10.tgz", - "integrity": "sha512-IQrh3lEPM93wVCEczc9SaAOvkmcoQn/G8Bo1e8ZPlY3X3bnAxWaBdvTdvM1hP62iZp0BXWDy4vTAy4fF0+Dlpg==", + "version": "0.6.11", + "resolved": "https://registry.npmjs.org/fflate/-/fflate-0.6.11.tgz", + "integrity": "sha512-3JyEFWGjFn7zHmoa9+zG1BmW7X2okcmAB+0Cnu9UFbVs/jCBnl2A8o065ZlXiw145K3eBM3uLuzrYXC0RK7eDg==", "license": "MIT" }, "node_modules/tiny-invariant": { diff --git a/package-lock.json b/package-lock.json index 8ee0d462..ba42c13c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -200,13 +200,13 @@ "license": "MIT" }, "node_modules/@babel/code-frame": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.28.6.tgz", - "integrity": "sha512-JYgintcMjRiCvS8mMECzaEn+m3PfoQiyqukOMCCVQtoJGYJw8j/8LBJEiqkHLkfwCcs74E3pbAUFNg7d9VNJ+Q==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz", + "integrity": "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==", "dev": true, "license": "MIT", "dependencies": { - "@babel/helper-validator-identifier": "^7.28.5", + "@babel/helper-validator-identifier": "^7.29.7", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" }, @@ -215,9 +215,9 @@ } }, "node_modules/@babel/compat-data": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.28.6.tgz", - "integrity": "sha512-2lfu57JtzctfIrcGMz992hyLlByuzgIk58+hhGCxjKZ3rWI82NnVLjXcaTqkI2NvlcvOskZaiZ5kjUALo3Lpxg==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.7.tgz", + "integrity": "sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg==", "dev": true, "license": "MIT", "engines": { @@ -225,21 +225,21 @@ } }, "node_modules/@babel/core": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.28.6.tgz", - "integrity": "sha512-H3mcG6ZDLTlYfaSNi0iOKkigqMFvkTKlGUYlD8GW7nNOYRrevuA46iTypPyv+06V3fEmvvazfntkBU34L0azAw==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.7.tgz", + "integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==", "dev": true, "license": "MIT", "dependencies": { - "@babel/code-frame": "^7.28.6", - "@babel/generator": "^7.28.6", - "@babel/helper-compilation-targets": "^7.28.6", - "@babel/helper-module-transforms": "^7.28.6", - "@babel/helpers": "^7.28.6", - "@babel/parser": "^7.28.6", - "@babel/template": "^7.28.6", - "@babel/traverse": "^7.28.6", - "@babel/types": "^7.28.6", + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.7", + "@babel/helper-compilation-targets": "^7.29.7", + "@babel/helper-module-transforms": "^7.29.7", + "@babel/helpers": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/template": "^7.29.7", + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7", "@jridgewell/remapping": "^2.3.5", "convert-source-map": "^2.0.0", "debug": "^4.1.0", @@ -256,14 +256,14 @@ } }, "node_modules/@babel/generator": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.28.6.tgz", - "integrity": "sha512-lOoVRwADj8hjf7al89tvQ2a1lf53Z+7tiXMgpZJL3maQPDxh0DgLMN62B2MKUOFcoodBHLMbDM6WAbKgNy5Suw==", + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.8.tgz", + "integrity": "sha512-gZbepsdh3WDtgZKWL+vTPh71LSBrm/Y4/QDZBVCcYfmeTEEuoOYwlSy+G1StfJg+/Zy550u/3TATbm7qDbbMtg==", "dev": true, "license": "MIT", "dependencies": { - "@babel/parser": "^7.28.6", - "@babel/types": "^7.28.6", + "@babel/parser": "^7.29.8", + "@babel/types": "^7.29.8", "@jridgewell/gen-mapping": "^0.3.12", "@jridgewell/trace-mapping": "^0.3.28", "jsesc": "^3.0.2" @@ -273,14 +273,14 @@ } }, "node_modules/@babel/helper-compilation-targets": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.28.6.tgz", - "integrity": "sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.29.7.tgz", + "integrity": "sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g==", "dev": true, "license": "MIT", "dependencies": { - "@babel/compat-data": "^7.28.6", - "@babel/helper-validator-option": "^7.27.1", + "@babel/compat-data": "^7.29.7", + "@babel/helper-validator-option": "^7.29.7", "browserslist": "^4.24.0", "lru-cache": "^5.1.1", "semver": "^6.3.1" @@ -290,9 +290,9 @@ } }, "node_modules/@babel/helper-globals": { - "version": "7.28.0", - "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.28.0.tgz", - "integrity": "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.29.7.tgz", + "integrity": "sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==", "dev": true, "license": "MIT", "engines": { @@ -300,29 +300,29 @@ } }, "node_modules/@babel/helper-module-imports": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.28.6.tgz", - "integrity": "sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.29.7.tgz", + "integrity": "sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g==", "dev": true, "license": "MIT", "dependencies": { - "@babel/traverse": "^7.28.6", - "@babel/types": "^7.28.6" + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7" }, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/helper-module-transforms": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.28.6.tgz", - "integrity": "sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.29.7.tgz", + "integrity": "sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg==", "dev": true, "license": "MIT", "dependencies": { - "@babel/helper-module-imports": "^7.28.6", - "@babel/helper-validator-identifier": "^7.28.5", - "@babel/traverse": "^7.28.6" + "@babel/helper-module-imports": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7", + "@babel/traverse": "^7.29.7" }, "engines": { "node": ">=6.9.0" @@ -342,9 +342,9 @@ } }, "node_modules/@babel/helper-string-parser": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", - "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz", + "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==", "dev": true, "license": "MIT", "engines": { @@ -352,9 +352,9 @@ } }, "node_modules/@babel/helper-validator-identifier": { - "version": "7.28.5", - "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", - "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", + "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", "dev": true, "license": "MIT", "engines": { @@ -362,9 +362,9 @@ } }, "node_modules/@babel/helper-validator-option": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.27.1.tgz", - "integrity": "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.29.7.tgz", + "integrity": "sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw==", "dev": true, "license": "MIT", "engines": { @@ -372,27 +372,27 @@ } }, "node_modules/@babel/helpers": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.28.6.tgz", - "integrity": "sha512-xOBvwq86HHdB7WUDTfKfT/Vuxh7gElQ+Sfti2Cy6yIWNW05P8iUslOVcZ4/sKbE+/jQaukQAdz/gf3724kYdqw==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.7.tgz", + "integrity": "sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg==", "dev": true, "license": "MIT", "dependencies": { - "@babel/template": "^7.28.6", - "@babel/types": "^7.28.6" + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.7" }, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/parser": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.28.6.tgz", - "integrity": "sha512-TeR9zWR18BvbfPmGbLampPMW+uW1NZnJlRuuHso8i87QZNq2JRF9i6RgxRqtEq+wQGsS19NNTWr2duhnE49mfQ==", + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.8.tgz", + "integrity": "sha512-E8lTAYNB1KW+FH+VGJuZM1ioAx2E6oVlvQFRrf5P8ZZmsiJXYAD9vTFV7yyEURNzgh1dFqMZuO6tUwcARbqFCA==", "dev": true, "license": "MIT", "dependencies": { - "@babel/types": "^7.28.6" + "@babel/types": "^7.29.8" }, "bin": { "parser": "bin/babel-parser.js" @@ -650,33 +650,33 @@ } }, "node_modules/@babel/template": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.28.6.tgz", - "integrity": "sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.29.7.tgz", + "integrity": "sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==", "dev": true, "license": "MIT", "dependencies": { - "@babel/code-frame": "^7.28.6", - "@babel/parser": "^7.28.6", - "@babel/types": "^7.28.6" + "@babel/code-frame": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/types": "^7.29.7" }, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/traverse": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.28.6.tgz", - "integrity": "sha512-fgWX62k02qtjqdSNTAGxmKYY/7FSL9WAS1o2Hu5+I5m9T0yxZzr4cnrfXQ/MX0rIifthCSs6FKTlzYbJcPtMNg==", + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.8.tgz", + "integrity": "sha512-I5z7H3bf/41ktsNVLtpN0wAa336HkqIHQ5BuPLEhTkt1jVSyZpeNKIzTgEWmlxjdg81R0IgUCcaE+Ok3NvrfZg==", "dev": true, "license": "MIT", "dependencies": { - "@babel/code-frame": "^7.28.6", - "@babel/generator": "^7.28.6", - "@babel/helper-globals": "^7.28.0", - "@babel/parser": "^7.28.6", - "@babel/template": "^7.28.6", - "@babel/types": "^7.28.6", + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.8", + "@babel/helper-globals": "^7.29.7", + "@babel/parser": "^7.29.8", + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.8", "debug": "^4.3.1" }, "engines": { @@ -684,14 +684,14 @@ } }, "node_modules/@babel/types": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.28.6.tgz", - "integrity": "sha512-0ZrskXVEHSWIqZM/sQZ4EV3jZJXRkio/WCxaqKZP1g//CEWEPSfeZFcms4XeKBCHU0ZKnIkdJeU/kF+eRp5lBg==", + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.8.tgz", + "integrity": "sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg==", "dev": true, "license": "MIT", "dependencies": { - "@babel/helper-string-parser": "^7.27.1", - "@babel/helper-validator-identifier": "^7.28.5" + "@babel/helper-string-parser": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7" }, "engines": { "node": ">=6.9.0" @@ -1684,10 +1684,20 @@ "license": "Python-2.0" }, "node_modules/@eslint/eslintrc/node_modules/js-yaml": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz", - "integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==", + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.2.tgz", + "integrity": "sha512-SFNOvSJ+Dgf/9An904Yx+CgSlIPCkIpao4qo51lpee25TIRejdH3rhR4EZMGoNx3/TP3O+wzWuiTFl4sqbltzA==", "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/puzrin" + }, + { + "type": "github", + "url": "https://github.com/sponsors/nodeca" + } + ], "license": "MIT", "dependencies": { "argparse": "^2.0.1" @@ -4661,9 +4671,9 @@ } }, "node_modules/app-builder-lib/node_modules/js-yaml": { - "version": "4.3.1", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.1.tgz", - "integrity": "sha512-CY6crGq313MX8GkwvB7tzgp99vjQxY1++5y10/BKN/GUfHqWaOGQMNZkBvqSzsZKWk/ijwHlWzzkLulsGHhjWQ==", + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.2.tgz", + "integrity": "sha512-SFNOvSJ+Dgf/9An904Yx+CgSlIPCkIpao4qo51lpee25TIRejdH3rhR4EZMGoNx3/TP3O+wzWuiTFl4sqbltzA==", "dev": true, "funding": [ { @@ -5013,9 +5023,9 @@ "license": "MIT" }, "node_modules/baseline-browser-mapping": { - "version": "2.11.20", - "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.11.20.tgz", - "integrity": "sha512-H0ulySigv6icDJ1F7SjtdCD6PrhTpdYCmP0CactWy1+ekh0AFd0o1Wn5T8b+hnTmdBx19u9yhL6wvCylXMY7zw==", + "version": "2.11.21", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.11.21.tgz", + "integrity": "sha512-uh8vpY/1/YyFkunIDFH/12p7/7VdPKA1hejMVEbdkEaWnUz0Hesvx5EbiU6XxjyHZIOju+ZMbQJkRh+es3/spQ==", "dev": true, "license": "Apache-2.0", "bin": { @@ -5076,9 +5086,9 @@ } }, "node_modules/browserslist": { - "version": "4.28.8", - "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.8.tgz", - "integrity": "sha512-V2NpofLblG64mfOtSgDhOJESZEGogzDMBv/q+W6oc4LXWP/q75eOXoOaaOu1EOadB9U4Bwx/e0yzbvwKH8zalA==", + "version": "4.28.9", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.9.tgz", + "integrity": "sha512-EWazOblFYUvlGZcfGhPUPmYh3nikUxBVb+y9MJun5f3hBi812X+8MSQTujLBtgK3cf51fJWbWfOjyeO954d+Eg==", "dev": true, "funding": [ { @@ -5096,11 +5106,11 @@ ], "license": "MIT", "dependencies": { - "baseline-browser-mapping": "^2.11.12", - "caniuse-lite": "^1.0.30001809", - "electron-to-chromium": "^1.5.402", - "node-releases": "^2.0.53", - "update-browserslist-db": "^1.3.0" + "baseline-browser-mapping": "^2.11.20", + "caniuse-lite": "^1.0.30001810", + "electron-to-chromium": "^1.5.420", + "node-releases": "^2.0.54", + "update-browserslist-db": "^1.3.2" }, "bin": { "browserslist": "cli.js" @@ -5187,9 +5197,9 @@ "license": "Python-2.0" }, "node_modules/builder-util/node_modules/js-yaml": { - "version": "4.3.1", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.1.tgz", - "integrity": "sha512-CY6crGq313MX8GkwvB7tzgp99vjQxY1++5y10/BKN/GUfHqWaOGQMNZkBvqSzsZKWk/ijwHlWzzkLulsGHhjWQ==", + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.2.tgz", + "integrity": "sha512-SFNOvSJ+Dgf/9An904Yx+CgSlIPCkIpao4qo51lpee25TIRejdH3rhR4EZMGoNx3/TP3O+wzWuiTFl4sqbltzA==", "dev": true, "funding": [ { @@ -5897,9 +5907,9 @@ "license": "Python-2.0" }, "node_modules/dmg-builder/node_modules/js-yaml": { - "version": "4.3.1", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.1.tgz", - "integrity": "sha512-CY6crGq313MX8GkwvB7tzgp99vjQxY1++5y10/BKN/GUfHqWaOGQMNZkBvqSzsZKWk/ijwHlWzzkLulsGHhjWQ==", + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.2.tgz", + "integrity": "sha512-SFNOvSJ+Dgf/9An904Yx+CgSlIPCkIpao4qo51lpee25TIRejdH3rhR4EZMGoNx3/TP3O+wzWuiTFl4sqbltzA==", "dev": true, "funding": [ { @@ -6102,9 +6112,9 @@ } }, "node_modules/electron-to-chromium": { - "version": "1.5.420", - "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.420.tgz", - "integrity": "sha512-2yD6XreGusOfNV+dUcvipJEXc3n/n7fgr7996aszTG+YY5E4mqM4tOq/3uhP129cazL9YHbVWSpc79ePotWtPA==", + "version": "1.5.423", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.423.tgz", + "integrity": "sha512-rRZfTSY8ptHYMQxa+uIycJMFKmY1T0GIApNMXJYGehguTZa56TEEl19pKPCoBqk5Gpf7QizZn/jt7xur+DYxag==", "dev": true, "license": "ISC" }, @@ -6133,10 +6143,20 @@ "license": "Python-2.0" }, "node_modules/electron-updater/node_modules/js-yaml": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz", - "integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==", + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.2.tgz", + "integrity": "sha512-SFNOvSJ+Dgf/9An904Yx+CgSlIPCkIpao4qo51lpee25TIRejdH3rhR4EZMGoNx3/TP3O+wzWuiTFl4sqbltzA==", "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/puzrin" + }, + { + "type": "github", + "url": "https://github.com/sponsors/nodeca" + } + ], "license": "MIT", "dependencies": { "argparse": "^2.0.1" @@ -6558,10 +6578,20 @@ } }, "node_modules/eslint/node_modules/js-yaml": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz", - "integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==", + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.2.tgz", + "integrity": "sha512-SFNOvSJ+Dgf/9An904Yx+CgSlIPCkIpao4qo51lpee25TIRejdH3rhR4EZMGoNx3/TP3O+wzWuiTFl4sqbltzA==", "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/puzrin" + }, + { + "type": "github", + "url": "https://github.com/sponsors/nodeca" + } + ], "license": "MIT", "dependencies": { "argparse": "^2.0.1" @@ -8403,9 +8433,9 @@ "license": "MIT" }, "node_modules/js-yaml": { - "version": "3.14.2", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.14.2.tgz", - "integrity": "sha512-PMSmkqxr106Xa156c2M265Z+FTrPl+oxd/rgOQy2tijQeK5TxQ43psO1ZCwhVOSdnn+RzkzlRz/eY4BgJBYVpg==", + "version": "3.15.2", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.15.2.tgz", + "integrity": "sha512-6EuL879VkRA+1Cz578mKMiKvjPNEuk6+r1JaFzoSWejZmtf7xWbIyw1e3KkxlkzTIt9Taw6JBhEppG7utc1P+w==", "dev": true, "license": "MIT", "dependencies": { From e3c158f5815266a97e40592e51d52026808ee695 Mon Sep 17 00:00:00 2001 From: Sylvain Galliez <128614184+zedarvates@users.noreply.github.com> Date: Wed, 9 Sep 2026 07:25:13 +0200 Subject: [PATCH 082/113] fix(harbour): exclude model-derived diagnostics from repair requests --- apps/storycore-harbour/ARCHITECTURE.md | 2 +- apps/storycore-harbour/DECISIONS.md | 37 +++++++++++++++++++ .../storycore-harbour/bundle/repair-prompt.js | 9 ++++- .../tests/repair-prompt.test.mjs | 18 ++++++++- 4 files changed, 63 insertions(+), 3 deletions(-) diff --git a/apps/storycore-harbour/ARCHITECTURE.md b/apps/storycore-harbour/ARCHITECTURE.md index b811cbfa..49185960 100644 --- a/apps/storycore-harbour/ARCHITECTURE.md +++ b/apps/storycore-harbour/ARCHITECTURE.md @@ -59,7 +59,7 @@ The App does not import the Electron renderer or Python backend. Shared StoryCor 5. remove only an optional surrounding Markdown code fence; 6. parse JSON; 7. validate required structure and invariants; -8. if invalid, make exactly one repair call containing validation errors and the previous output; +8. if invalid, make exactly one repair call containing the exact normalized user input and fixed validation error categories, without quoting the previous output; 9. reject if still invalid; 10. add local metadata not delegated to the model; 11. save only validated data; diff --git a/apps/storycore-harbour/DECISIONS.md b/apps/storycore-harbour/DECISIONS.md index a228856d..d04b8937 100644 --- a/apps/storycore-harbour/DECISIONS.md +++ b/apps/storycore-harbour/DECISIONS.md @@ -258,6 +258,43 @@ response also shortens and decontaminates the repair context. The one-repair limit, 4,096-token request cap, canonical validator, and provider-neutral model selection remain unchanged. +### ADR-023 — Keep model-derived diagnostics out of repair requests + +**Decision:** repair validation errors contain only the fixed categories +`json_invalid` and `contract_invalid`; the exact normalized user input remains +the reconstruction source. + +**Reason:** JSON parser errors and unknown character/location identifiers can +quote the preceding model response. Forwarding those diagnostics verbatim +violated ADR-022 even after the explicit previous-response field was removed. + +**Evidence:** the regression test `repair excludes model-derived text in parser +and reference diagnostics` fails before the category mapping and passes after +it. Repeated diagnostics collapse to at most two categories. No raw error text +is included in the repair request. + +**Consequences:** detailed model-derived diagnostics are not repair instructions. +The single repair call, token limit, timeout, provider selection, and source +input are unchanged. Local tests do not establish real Anna model acceptance. + +### ADR-024 — Track reviewed Harbour invariants in the repository audit + +**Decision:** add the pinned Botte audit engine and a three-rule manifest at the +repository root in a separate governance commit. Extend the existing Harbour +CI to audit those references at the exact pull-request head, and correct stale +commands and paths in the root contributor guide in that same separate commit. + +**Reason:** the owner's rule-drift correction request spans repositories. Root +placement supports the standard audit command without coupling the engine to +the App bundle or duplicating its runtime validators. + +**Consequences:** the first contract covers repair diagnostics, project ID +references, and public acceptance summaries only. It does not certify the +whole Engine, real model reliability, paid account access, publication, or +owner-only decisions. App changes stay in their own commit; core code, +dependency locks, the stale gitlink repair, and other feature PRs are outside +this correction. + ```text ### ADR-NNN — Title Decision: diff --git a/apps/storycore-harbour/bundle/repair-prompt.js b/apps/storycore-harbour/bundle/repair-prompt.js index 3f55ed8b..5dc4de30 100644 --- a/apps/storycore-harbour/bundle/repair-prompt.js +++ b/apps/storycore-harbour/bundle/repair-prompt.js @@ -1,8 +1,15 @@ export function createRepairPrompt(input, errors) { + // Parser diagnostics and reference IDs can quote untrusted model output. + // Rebuild from the source input using only fixed, deduplicated categories. + const validationErrors = [...new Set(errors.map(error => ( + typeof error === "string" && error.startsWith("JSON parse failed:") + ? "json_invalid" + : "contract_invalid" + )))]; return JSON.stringify({ task: "Rebuild a complete StoryCore Harbour production package from the source input.", input, - validationErrors: errors, + validationErrors, constraints: { jsonOnly: true, maxCharacters: 12_000, diff --git a/apps/storycore-harbour/tests/repair-prompt.test.mjs b/apps/storycore-harbour/tests/repair-prompt.test.mjs index 30413251..977bb0d3 100644 --- a/apps/storycore-harbour/tests/repair-prompt.test.mjs +++ b/apps/storycore-harbour/tests/repair-prompt.test.mjs @@ -20,8 +20,24 @@ test("repair rebuilds from the exact user input without carrying truncated model const request = JSON.parse(prompt); assert.deepEqual(request.input, input); - assert.deepEqual(request.validationErrors, ["JSON parse failed: truncated object"]); + assert.deepEqual(request.validationErrors, ["json_invalid"]); assert.equal(request.task, "Rebuild a complete StoryCore Harbour production package from the source input."); assert.equal(Object.hasOwn(request, "previousResponse"), false); assert.equal(prompt.includes("PREVIOUS RESPONSE"), false); }); + +test("repair excludes model-derived text in parser and reference diagnostics", async () => { + const { createRepairPrompt } = await import("../bundle/repair-prompt.js"); + const input = { idea: "Keep the user's original concept", language: "en" }; + const sentinel = "UNTRUSTED_PREVIOUS_MODEL_RESPONSE"; + const prompt = createRepairPrompt(input, [ + `JSON parse failed: Unexpected token '${sentinel}'`, + `scenes[0] references unknown character ${sentinel}.`, + `scenes[0].locationId references unknown location ${sentinel}.`, + ]); + const request = JSON.parse(prompt); + + assert.equal(prompt.includes(sentinel), false); + assert.deepEqual(request.input, input); + assert.deepEqual(request.validationErrors, ["json_invalid", "contract_invalid"]); +}); From 6ad9b3b064c75f730bdc018c4c345d036263496e Mon Sep 17 00:00:00 2001 From: Sylvain Galliez <128614184+zedarvates@users.noreply.github.com> Date: Wed, 9 Sep 2026 07:26:13 +0200 Subject: [PATCH 083/113] chore(rules): audit Harbour invariants at the exact PR head --- .botte/engine-LICENSE.txt | 21 + .botte/engine.md | 57 ++ .botte/rules.json | 119 ++++ .github/workflows/storycore-harbour-ci.yml | 39 ++ CONTRIBUTING.md | 48 +- skills/console_utf8.py | 20 + skills/directives_audit/rules.py | 615 +++++++++++++++++++++ skills/directives_audit/rules_cli.py | 60 ++ 8 files changed, 954 insertions(+), 25 deletions(-) create mode 100644 .botte/engine-LICENSE.txt create mode 100644 .botte/engine.md create mode 100644 .botte/rules.json create mode 100644 skills/console_utf8.py create mode 100644 skills/directives_audit/rules.py create mode 100644 skills/directives_audit/rules_cli.py diff --git a/.botte/engine-LICENSE.txt b/.botte/engine-LICENSE.txt new file mode 100644 index 00000000..c401886a --- /dev/null +++ b/.botte/engine-LICENSE.txt @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Sylvain Galliez + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/.botte/engine.md b/.botte/engine.md new file mode 100644 index 00000000..c767a4d5 --- /dev/null +++ b/.botte/engine.md @@ -0,0 +1,57 @@ +# Committed rule audit + +The three dependency-free Python modules are copied unchanged from +[Botte Secrète PR #103](https://github.com/zedarvates/botte-secrete/pull/103), +source commit [`e121ea16cbd5a772ddb485414928c0938eace2d5`](https://github.com/zedarvates/botte-secrete/tree/e121ea16cbd5a772ddb485414928c0938eace2d5). +Python 3.10+ is required. The upstream MIT notice is retained in +`engine-LICENSE.txt`; it does not change this repository's licensing. + +| Engine file | SHA-256 | +| --- | --- | +| `skills/console_utf8.py` | `80e0583b55e816b85193238abcca6f16ae84d38c382b792763e0f5e90e662eb5` | +| `skills/directives_audit/rules.py` | `6af048fa89d1214d21e5abb2a82144ebfde26bf9bfaec120a3f91ead61e18284` | +| `skills/directives_audit/rules_cli.py` | `28da4117cd51bd12f7392857bf82b144895353b44d04c5f4d378968e4347fea7` | + +From the repository root: + +```bash +PYTHONDONTWRITEBYTECODE=1 PYTHONPATH=. python -m skills.directives_audit.rules_cli audit . --json +``` + +## Verification boundary + +The audit reads exact source statements, guard anchors, positive and negative +probe anchors, owner-boundary metadata and the replacement graph. It does not +execute probes, import project code, contact services or authorize an action. +`last_verified` records semantic source review, not runtime success or a CI SHA. +All initial `supersedes` lists are empty. These local data rules use +`owner_only: false`; that does not grant owner-only external authority. + +The report must be `botte.rules-audit/v1`, with a present manifest, at least one +rule, and zero errors and warnings. A missing/empty manifest is BLOCKED/DRIFT, +even if its numeric score is 100. The CLI returns 2 for an absent manifest and 1 +for errors, but warnings require the additional strict check used in CI. + +The fingerprint is a deterministic rule/report receipt, not a hash of the entire +source tree. Always record `git rev-parse HEAD` alongside the report and actual +test results. A result from another SHA or a pull-request merge commit cannot +stand in for the tested head. Regenerate verification receipts only after source +review and relevant probes; do not refresh dates merely to silence drift. + +## Registered scope and execution + +This initial contract covers three Harbour invariants: source-only repair +context, project ID references, and public acceptance-output redaction. It is +not a complete inventory of StoryCore Engine rules. `AGENTS.md`, mission limits, +and owner decisions remain authoritative outside this registered scope. + +Run the reviewed behavioral probes separately from the repository root: + +```bash +node --test apps/storycore-harbour/tests/repair-prompt.test.mjs apps/storycore-harbour/tests/contracts.test.mjs apps/storycore-harbour/tests/acceptance-public-output.test.mjs +``` + +The existing Harbour CI checks out and verifies the exact PR head, runs this +audit, then retains the full contract/evaluator, official Anna strict-validation, +and browser mock gates. CI is separate evidence; none of those mock checks proves +real-model acceptance, Anna eligibility, publication, or owner approval. diff --git a/.botte/rules.json b/.botte/rules.json new file mode 100644 index 00000000..3b94ad59 --- /dev/null +++ b/.botte/rules.json @@ -0,0 +1,119 @@ +{ + "schema": "botte.rules-manifest/v1", + "rules": [ + { + "id": "harbour.repair-source-only", + "action": "repair-context", + "effect": "REQUIRE", + "scope": [ + "harbour-repair-request" + ], + "statement": "repair validation errors contain only the fixed categories\n`json_invalid` and `contract_invalid`; the exact normalized user input remains\nthe reconstruction source.", + "source_ref": "apps/storycore-harbour/DECISIONS.md#ADR-023", + "owner_only": false, + "enforced": true, + "enforcement_refs": [ + "apps/storycore-harbour/bundle/repair-prompt.js#const validationErrors = [...new Set(errors.map(error => (", + "apps/storycore-harbour/bundle/app.js#createRepairPrompt(input, errors)" + ], + "probes": [ + { + "id": "harbour.repair-source-only.allow", + "polarity": "allow", + "evidence_ref": "apps/storycore-harbour/tests/repair-prompt.test.mjs#repair rebuilds from the exact user input without carrying truncated model output" + }, + { + "id": "harbour.repair-source-only.deny", + "polarity": "deny", + "evidence_ref": "apps/storycore-harbour/tests/repair-prompt.test.mjs#repair excludes model-derived text in parser and reference diagnostics" + } + ], + "supersedes": [], + "last_verified": { + "at": "2026-09-09T05:21:49+00:00", + "content_sha256": "ee1a2f6819fc9282355b5e97900c261e671d8c62863c722531cb20d98ebb3166", + "evidence_ref": ".botte/engine.md#Verification boundary" + } + }, + { + "id": "harbour.project-references", + "action": "project-validation", + "effect": "REQUIRE", + "scope": [ + "harbour-project-contract" + ], + "statement": "all referenced character and location IDs must exist;", + "source_ref": "apps/storycore-harbour/ARCHITECTURE.md#all referenced character and location IDs must exist;", + "owner_only": false, + "enforced": true, + "enforcement_refs": [ + "apps/storycore-harbour/bundle/project-contract.js#if (isText(id) && !knownIds.has(id)) {", + "apps/storycore-harbour/bundle/project-contract.js#if (isText(id) && !characterIds.has(id)) {", + "apps/storycore-harbour/bundle/project-contract.js#if (isText(scene.locationId) && !context.locationIds.has(scene.locationId)) {" + ], + "probes": [ + { + "id": "harbour.project-references.allow", + "polarity": "allow", + "evidence_ref": "apps/storycore-harbour/tests/contracts.test.mjs#sample project passes the contract" + }, + { + "id": "harbour.project-references.deny", + "polarity": "deny", + "evidence_ref": "apps/storycore-harbour/tests/contracts.test.mjs#unknown scene location is rejected" + }, + { + "id": "harbour.project-references.deny-scene-character", + "polarity": "deny", + "evidence_ref": "apps/storycore-harbour/tests/contracts.test.mjs#unknown scene character is rejected" + }, + { + "id": "harbour.project-references.deny-shot-character", + "polarity": "deny", + "evidence_ref": "apps/storycore-harbour/tests/contracts.test.mjs#unknown shot character is rejected" + } + ], + "supersedes": [], + "last_verified": { + "at": "2026-09-09T05:21:49+00:00", + "content_sha256": "aaed2b94d47a6089dac53d1df74223fb52eea8e4c80e89dab9c68322ec94b414", + "evidence_ref": ".botte/engine.md#Verification boundary" + } + }, + { + "id": "harbour.public-acceptance-redaction", + "action": "acceptance-summary", + "effect": "REQUIRE", + "scope": [ + "harbour-evaluator-public-output" + ], + "statement": "Redact model-derived validation text and unknown identifiers from evaluator output.", + "source_ref": "apps/storycore-harbour/CODEX_TASKS.md#Redact model-derived validation text and unknown identifiers from evaluator output.", + "owner_only": false, + "enforced": true, + "enforcement_refs": [ + "apps/storycore-harbour/scripts/evaluate-acceptance.mjs#function publicPromptId(value) {", + "apps/storycore-harbour/scripts/evaluate-acceptance.mjs#return PUBLIC_CATEGORIES.has(value) ? value : \"unknown\";", + "apps/storycore-harbour/scripts/evaluate-acceptance.mjs#reasonCount: Array.isArray(failure.reasons) ? failure.reasons.length : 0," + ], + "probes": [ + { + "id": "harbour.public-acceptance-redaction.allow", + "polarity": "allow", + "evidence_ref": "apps/storycore-harbour/tests/acceptance-public-output.test.mjs#public acceptance output strips model-derived validation text" + }, + { + "id": "harbour.public-acceptance-redaction.deny", + "polarity": "deny", + "evidence_ref": "apps/storycore-harbour/tests/acceptance-public-output.test.mjs#public acceptance output replaces unknown identifiers and categories" + } + ], + "supersedes": [], + "last_verified": { + "at": "2026-09-09T05:21:49+00:00", + "content_sha256": "455c248ffa202f2b885f33ebe4f4decc71ea6808da63fb07dbaa46e2731276a8", + "evidence_ref": ".botte/engine.md#Verification boundary" + } + } + ] +} diff --git a/.github/workflows/storycore-harbour-ci.yml b/.github/workflows/storycore-harbour-ci.yml index f2389863..b5761c04 100644 --- a/.github/workflows/storycore-harbour-ci.yml +++ b/.github/workflows/storycore-harbour-ci.yml @@ -4,6 +4,10 @@ on: pull_request: paths: - "apps/storycore-harbour/**" + - ".botte/**" + - "skills/console_utf8.py" + - "skills/directives_audit/rules.py" + - "skills/directives_audit/rules_cli.py" - ".github/workflows/storycore-harbour-ci.yml" workflow_dispatch: @@ -26,6 +30,41 @@ jobs: steps: - name: Check out repository uses: actions/checkout@v4 + with: + ref: ${{ github.event.pull_request.head.sha || github.sha }} + persist-credentials: false + + - name: Verify the exact source revision + working-directory: ${{ github.workspace }} + shell: bash + env: + SOURCE_SHA: ${{ github.event.pull_request.head.sha || github.sha }} + run: | + set -euo pipefail + test "$(git rev-parse HEAD)" = "${SOURCE_SHA}" + printf 'SOURCE_SHA=%s\n' "${SOURCE_SHA}" + + - name: Audit committed rule references + working-directory: ${{ github.workspace }} + shell: bash + env: + PYTHONPATH: . + PYTHONDONTWRITEBYTECODE: "1" + run: | + set -euo pipefail + python3 -m skills.directives_audit.rules_cli audit . --json > "${RUNNER_TEMP}/rules-audit.json" + cat "${RUNNER_TEMP}/rules-audit.json" + python3 - "${RUNNER_TEMP}/rules-audit.json" <<'PY' + import json, sys + with open(sys.argv[1], encoding="utf-8") as stream: + report = json.load(stream) + summary = report["summary"] + if (report["schema"] != "botte.rules-audit/v1" + or report["manifest_present"] is not True + or summary["rules"] < 1 + or summary["errors"] != 0 or summary["warnings"] != 0): + raise SystemExit("BLOCKED/DRIFT: rule audit needs a present, nonempty, clean contract") + PY - name: Set up Node.js 22 uses: actions/setup-node@v4 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 2434b437..096ec45f 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -8,7 +8,7 @@ Thank you for your interest in contributing to StoryCore Engine! This document p ### Prerequisites -- Node.js 18+ and npm +- Node.js compatible with the component's package and lockfile (22+ for Harbour), and npm - Python 3.11+ - Git - Docker (optional, for containerized development) @@ -17,32 +17,32 @@ Thank you for your interest in contributing to StoryCore Engine! This document p 1. **Clone the repository** ```bash - git clone https://github.com/your-org/storycore-engine.git - cd storycore-engine + git clone https://github.com/zedarvates/StoryCore-Engine.git + cd StoryCore-Engine ``` 2. **Install Python dependencies** ```bash pip install -r requirements.txt - pip install -r requirements-dev.txt ``` + Test dependencies are declared in `requirements.txt`; there is no separate + `requirements-dev.txt` in this revision. + 3. **Install Node.js dependencies** ```bash - cd creative-studio-ui - npm install + npm --prefix creative-studio-ui ci ``` 4. **Set up environment variables** - ```bash - cp .env.example .env - # Edit .env with your configuration - ``` + Configure the services you use from the settings declared in + [`backend/config.py`](backend/config.py). There is no committed + `.env.example` to copy. Keep local credentials out of Git. 5. **Run the application** ```bash - # Start backend - python src/main_api.py + # Start backend from the repository root (default port 8080) + python -m backend.main_api # Start frontend (in another terminal) cd creative-studio-ui @@ -61,11 +61,9 @@ storycore-engine/ │ │ ├── hooks/ # Custom hooks │ │ └── utils/ # Utility functions │ └── public/ # Static assets -├── src/ # Backend (Python/FastAPI) -│ ├── api/ # API routes -│ ├── services/ # Business logic -│ ├── models/ # Data models -│ └── utils/ # Utilities +├── backend/ # FastAPI entry point and API modules +├── src/ # Engine packages, API framework, and services +├── apps/storycore-harbour/ # Anna App; follow its own AGENTS.md and package scripts ├── electron/ # Electron desktop app ├── tests/ # Test files └── docs/ # Documentation @@ -76,7 +74,6 @@ storycore-engine/ ### Branching Strategy - `main` - Production-ready code -- `develop` - Integration branch for features - `feature/*` - Feature branches - `fix/*` - Bug fix branches - `hotfix/*` - Critical bug fixes @@ -106,7 +103,7 @@ storycore-engine/ ``` 5. **Create a Pull Request** - - Target: `develop` branch + - Target: `main` branch (or the explicit base of a stacked PR) - Include description of changes - Link to related issues - Request review from maintainers @@ -169,10 +166,12 @@ git commit -m "docs: update API documentation" ```bash cd creative-studio-ui npm test # Run all tests -npm test -- --watch # Run in watch mode -npm run coverage # Generate coverage report +npm run test:watch # Run in watch mode +npm run lint # Run the declared ESLint command ``` +No `coverage` script is currently declared in `creative-studio-ui/package.json`. + ### Backend Tests ```bash @@ -274,10 +273,9 @@ When suggesting features: ## 🔗 Additional Resources - [Architecture Documentation](ARCHITECTURE.md) -- [API Documentation](docs/API.md) +- [API Usage Guide](src/api/API_USAGE_GUIDE.md) - [Development Setup Guide](docs/DEVELOPMENT.md) -- [Style Guide](docs/STYLE.md) -- [Troubleshooting](docs/TROUBLESHOOTING.md) +- [Harbour Instructions](apps/storycore-harbour/AGENTS.md) ## 🙏 Thank You! @@ -285,4 +283,4 @@ Thank you for contributing to StoryCore Engine! Your contributions help make thi --- -*Last updated: 2026-05-05* \ No newline at end of file +*Last updated: 2026-09-09* diff --git a/skills/console_utf8.py b/skills/console_utf8.py new file mode 100644 index 00000000..cb0aaa49 --- /dev/null +++ b/skills/console_utf8.py @@ -0,0 +1,20 @@ +"""Force UTF-8 stdout/stderr — Windows consoles default to cp1252 and crash on +emoji / box-drawing characters used throughout the pipeline output. + +Call `force_utf8()` once at the top of any script's entry point. +""" + +from __future__ import annotations + +import sys + + +def force_utf8() -> None: + """Reconfigure stdout/stderr to UTF-8 with replacement, where supported.""" + for stream in (sys.stdout, sys.stderr): + reconfigure = getattr(stream, "reconfigure", None) + if reconfigure: + try: + reconfigure(encoding="utf-8", errors="replace") + except (ValueError, OSError): + pass diff --git a/skills/directives_audit/rules.py b/skills/directives_audit/rules.py new file mode 100644 index 00000000..dcfed103 --- /dev/null +++ b/skills/directives_audit/rules.py @@ -0,0 +1,615 @@ +"""Deterministic audit of the committed Botte rule contract. + +The audit is deliberately data-only: references are resolved inside the +project root and checked as exact text anchors. It never executes a probe or +imports project code. This makes it safe to run in preflight, CI and an +independent review workspace. +""" + +from __future__ import annotations + +import hashlib +import json +import re +from dataclasses import asdict, dataclass +from datetime import datetime +from pathlib import Path, PurePosixPath +from typing import Mapping + + +RULES_SCHEMA = "botte.rules-manifest/v1" +AUDIT_SCHEMA = "botte.rules-audit/v1" +DEFAULT_MANIFEST = ".botte/rules.json" + +_RULE_FIELDS = frozenset( + { + "id", + "action", + "effect", + "scope", + "statement", + "source_ref", + "owner_only", + "enforced", + "enforcement_refs", + "probes", + "supersedes", + "last_verified", + } +) +_PROBE_FIELDS = frozenset({"id", "polarity", "evidence_ref"}) +_VERIFICATION_FIELDS = frozenset({"at", "content_sha256", "evidence_ref"}) +_ID_RE = re.compile(r"^[a-z0-9][a-z0-9._-]{2,127}$") +_SHA256_RE = re.compile(r"^[0-9a-f]{64}$") +_EFFECTS = frozenset({"ALLOW", "DENY", "REQUIRE"}) +_POLARITIES = frozenset({"allow", "deny"}) + + +@dataclass(frozen=True) +class RuleFinding: + severity: str + code: str + rule_id: str + reference: str + message: str + fix_hint: str + + def to_dict(self) -> dict: + return asdict(self) + + +def _canonical_sha256(value: object) -> str: + raw = json.dumps( + value, + ensure_ascii=False, + sort_keys=True, + separators=(",", ":"), + ).encode("utf-8") + return hashlib.sha256(raw).hexdigest() + + +def rule_semantic_sha256(rule: Mapping) -> str: + """Fingerprint the rule semantics, excluding its verification receipt.""" + semantic = {key: value for key, value in rule.items() if key != "last_verified"} + return _canonical_sha256(semantic) + + +def _text(value: object, *, maximum: int = 512) -> str | None: + if not isinstance(value, str): + return None + cleaned = value.strip() + if not cleaned or len(cleaned) > maximum: + return None + return cleaned + + +def _strings(value: object, *, maximum: int = 256, limit: int = 100) -> list[str] | None: + if not isinstance(value, list) or not value or len(value) > limit: + return None + result: list[str] = [] + for item in value: + cleaned = _text(item, maximum=maximum) + if cleaned is None: + return None + result.append(cleaned) + if len(set(result)) != len(result): + return None + return result + + +def _valid_ref_shape(reference: str) -> bool: + path_text, separator, anchor = reference.partition("#") + if not separator or not path_text or not anchor: + return False + path = PurePosixPath(path_text) + return ( + not path.is_absolute() + and ".." not in path.parts + and "\\" not in path_text + and not any(ord(character) < 32 for character in reference) + and len(reference) <= 512 + ) + + +def _parse_rule(raw: object, index: int, findings: list[RuleFinding]) -> dict | None: + fallback = f"index:{index}" + if not isinstance(raw, dict): + findings.append(RuleFinding( + "error", "rule_not_object", fallback, DEFAULT_MANIFEST, + "Rule entry must be an object.", + "Replace the entry with a rules-manifest rule object.", + )) + return None + + candidate_id = _text(raw.get("id"), maximum=128) + rule_id = ( + candidate_id + if candidate_id is not None and _ID_RE.fullmatch(candidate_id) + else fallback + ) + valid = True + unknown = sorted(set(raw) - _RULE_FIELDS) + missing = sorted(_RULE_FIELDS - set(raw)) + if unknown or missing: + valid = False + details = [] + if missing: + details.append("missing " + ", ".join(missing)) + if unknown: + details.append("unknown " + ", ".join(unknown)) + findings.append(RuleFinding( + "error", "rule_fields", rule_id, DEFAULT_MANIFEST, + "Rule fields do not match the v1 contract: " + "; ".join(details) + ".", + "Use only the fields declared by rules-manifest.schema.json.", + )) + + if candidate_id is None or not _ID_RE.fullmatch(candidate_id): + valid = False + findings.append(RuleFinding( + "error", "rule_id_invalid", rule_id, DEFAULT_MANIFEST, + "Rule id must be a stable lowercase dotted identifier.", + "Use 3-128 lowercase letters, digits, dots, underscores or hyphens.", + )) + + action = _text(raw.get("action"), maximum=128) + effect = _text(raw.get("effect"), maximum=16) + scope = _strings(raw.get("scope"), maximum=128, limit=32) + statement = _text(raw.get("statement"), maximum=1000) + source_ref = _text(raw.get("source_ref"), maximum=512) + supersedes = raw.get("supersedes") + if not isinstance(supersedes, list) or len(supersedes) > 100: + supersedes_list = None + else: + supersedes_list = [] + for item in supersedes: + cleaned = _text(item, maximum=128) + if cleaned is None or not _ID_RE.fullmatch(cleaned): + supersedes_list = None + break + supersedes_list.append(cleaned) + if supersedes_list is not None and len(set(supersedes_list)) != len(supersedes_list): + supersedes_list = None + + scalar_checks = ( + (action is not None, "action_invalid", "action must be a non-empty string"), + (effect in _EFFECTS, "effect_invalid", "effect must be ALLOW, DENY or REQUIRE"), + (scope is not None, "scope_invalid", "scope must be a non-empty unique string list"), + (statement is not None, "statement_invalid", "statement must be non-empty"), + (source_ref is not None and _valid_ref_shape(source_ref), + "source_ref_invalid", "source_ref must be a safe project-relative path#anchor"), + (isinstance(raw.get("owner_only"), bool), + "owner_only_invalid", "owner_only must be a boolean"), + (isinstance(raw.get("enforced"), bool), + "enforced_invalid", "enforced must be a boolean"), + (supersedes_list is not None, + "supersedes_invalid", "supersedes must be a unique rule-id list"), + ) + for condition, code, message in scalar_checks: + if condition: + continue + valid = False + findings.append(RuleFinding( + "error", code, rule_id, DEFAULT_MANIFEST, message + ".", + "Correct this field in .botte/rules.json.", + )) + + enforcement_raw = raw.get("enforcement_refs") + if not isinstance(enforcement_raw, list) or len(enforcement_raw) > 100: + enforcement_refs = None + else: + enforcement_refs = [] + for item in enforcement_raw: + ref = _text(item, maximum=512) + if ref is None or not _valid_ref_shape(ref): + enforcement_refs = None + break + enforcement_refs.append(ref) + if enforcement_refs is not None and len(set(enforcement_refs)) != len(enforcement_refs): + enforcement_refs = None + if enforcement_refs is None: + valid = False + findings.append(RuleFinding( + "error", "enforcement_refs_invalid", rule_id, DEFAULT_MANIFEST, + "enforcement_refs must be a unique list of safe path#anchor references.", + "Point each enforced rule at its deterministic guard implementation.", + )) + + probes_raw = raw.get("probes") + probes: list[dict] | None = [] if isinstance(probes_raw, list) else None + if probes is not None and len(probes_raw) > 100: + probes = None + if probes is not None: + seen_probe_ids: set[str] = set() + for probe in probes_raw: + if not isinstance(probe, dict) or set(probe) != _PROBE_FIELDS: + probes = None + break + probe_id = _text(probe.get("id"), maximum=128) + polarity = _text(probe.get("polarity"), maximum=16) + evidence_ref = _text(probe.get("evidence_ref"), maximum=512) + if ( + probe_id is None + or not _ID_RE.fullmatch(probe_id) + or probe_id in seen_probe_ids + or polarity not in _POLARITIES + or evidence_ref is None + or not _valid_ref_shape(evidence_ref) + ): + probes = None + break + seen_probe_ids.add(probe_id) + probes.append({ + "id": probe_id, + "polarity": polarity, + "evidence_ref": evidence_ref, + }) + if probes is None: + valid = False + findings.append(RuleFinding( + "error", "probes_invalid", rule_id, DEFAULT_MANIFEST, + "probes must contain unique typed allow/deny evidence references.", + "Add deterministic positive and negative test anchors.", + )) + + verified_raw = raw.get("last_verified") + verified: dict | None = None + if isinstance(verified_raw, dict) and set(verified_raw) == _VERIFICATION_FIELDS: + at = _text(verified_raw.get("at"), maximum=64) + digest = _text(verified_raw.get("content_sha256"), maximum=64) + evidence_ref = _text(verified_raw.get("evidence_ref"), maximum=512) + try: + if at is None: + raise ValueError + parsed_at = datetime.fromisoformat(at.replace("Z", "+00:00")) + timestamp_valid = "T" in at and parsed_at.tzinfo is not None + except ValueError: + timestamp_valid = False + if ( + timestamp_valid + and digest is not None + and _SHA256_RE.fullmatch(digest) + and evidence_ref is not None + and _valid_ref_shape(evidence_ref) + ): + verified = { + "at": at, + "content_sha256": digest, + "evidence_ref": evidence_ref, + } + if verified is None: + valid = False + findings.append(RuleFinding( + "error", "last_verified_invalid", rule_id, DEFAULT_MANIFEST, + "last_verified must bind an ISO timestamp, semantic SHA-256 and evidence ref.", + "Re-audit the rule and record its semantic fingerprint and evidence anchor.", + )) + + if not valid: + return None + return { + "id": rule_id, + "action": action, + "effect": effect, + "scope": scope, + "statement": statement, + "source_ref": source_ref, + "owner_only": raw["owner_only"], + "enforced": raw["enforced"], + "enforcement_refs": enforcement_refs, + "probes": probes, + "supersedes": supersedes_list, + "last_verified": verified, + } + + +def _reference_error(root: Path, reference: str, cache: dict[str, str]) -> tuple[str, str] | None: + if not _valid_ref_shape(reference): + return "reference_invalid", "Reference must be a project-relative path#anchor." + path_text, _, anchor = reference.partition("#") + try: + candidate = (root / path_text).resolve() + except (OSError, RuntimeError): + return "reference_unresolvable", "Referenced path cannot be resolved safely." + try: + candidate.relative_to(root) + except ValueError: + return "reference_escapes_root", "Reference resolves outside the project root." + if not candidate.is_file(): + return "reference_missing", "Referenced file does not exist." + if path_text not in cache: + try: + cache[path_text] = candidate.read_text(encoding="utf-8", errors="replace") + except OSError: + return "reference_unreadable", "Referenced file cannot be read." + if anchor not in cache[path_text]: + return "anchor_missing", "Exact evidence anchor is absent from the referenced file." + return None + + +def _scope_overlap(left: list[str], right: list[str]) -> bool: + return "*" in left or "*" in right or bool(set(left) & set(right)) + + +def _cycle_nodes(graph: dict[str, list[str]]) -> set[str]: + visiting: set[str] = set() + visited: set[str] = set() + cycles: set[str] = set() + + def visit(node: str, stack: list[str]) -> None: + if node in visiting: + start = stack.index(node) + cycles.update(stack[start:]) + return + if node in visited: + return + visiting.add(node) + stack.append(node) + for target in graph.get(node, []): + if target in graph: + visit(target, stack) + stack.pop() + visiting.remove(node) + visited.add(node) + + for node in sorted(graph): + visit(node, []) + return cycles + + +def _report(*, root: Path, manifest_ref: str, present: bool, + rules: list[dict], findings: list[RuleFinding]) -> dict: + order = {"error": 0, "warning": 1, "info": 2} + sorted_findings = sorted( + findings, + key=lambda item: ( + order.get(item.severity, 9), item.rule_id, item.code, item.reference + ), + ) + errors = sum(item.severity == "error" for item in sorted_findings) + warnings = sum(item.severity == "warning" for item in sorted_findings) + conflicts = sum(item.code == "rule_conflict" for item in sorted_findings) + unenforced = sum(item.code.startswith("unenforced") for item in sorted_findings) + stale = sum(item.code == "verification_stale" for item in sorted_findings) + payload = { + "schema": AUDIT_SCHEMA, + "project_ref": root.name or ".", + "manifest_ref": manifest_ref, + "manifest_present": present, + "score": max(0, 100 - errors * 15 - warnings * 5), + "summary": { + "rules": len(rules), + "errors": errors, + "warnings": warnings, + "conflicts": conflicts, + "unenforced": unenforced, + "stale": stale, + }, + "findings": [item.to_dict() for item in sorted_findings], + } + fingerprint_payload = { + key: value for key, value in payload.items() if key != "project_ref" + } + payload["fingerprint"] = _canonical_sha256({ + "report": fingerprint_payload, + "rules": sorted(rules, key=lambda rule: rule["id"]), + }) + return payload + + +def audit_rules(project_root: str | Path = ".", + manifest_ref: str = DEFAULT_MANIFEST) -> dict: + """Audit the committed rule manifest without executing project code.""" + root = Path(project_root).resolve() + findings: list[RuleFinding] = [] + if not _valid_ref_shape(manifest_ref + "#manifest"): + findings.append(RuleFinding( + "error", "manifest_ref_invalid", "manifest", manifest_ref, + "Manifest path must be project-relative and remain inside the project.", + "Use .botte/rules.json or another safe project-relative path.", + )) + return _report( + root=root, manifest_ref=manifest_ref, present=False, rules=[], findings=findings + ) + + try: + manifest_path = (root / manifest_ref).resolve() + except (OSError, RuntimeError): + findings.append(RuleFinding( + "error", "manifest_unresolvable", "manifest", manifest_ref, + "Manifest path cannot be resolved safely.", + "Replace symlink loops or invalid path components with a regular project file.", + )) + return _report( + root=root, manifest_ref=manifest_ref, present=False, rules=[], findings=findings + ) + try: + manifest_path.relative_to(root) + except ValueError: + findings.append(RuleFinding( + "error", "manifest_escapes_root", "manifest", manifest_ref, + "Manifest resolves outside the project root.", + "Move it inside the project.", + )) + return _report( + root=root, manifest_ref=manifest_ref, present=False, rules=[], findings=findings + ) + if not manifest_path.is_file(): + return _report( + root=root, manifest_ref=manifest_ref, present=False, rules=[], findings=[] + ) + + try: + raw = json.loads(manifest_path.read_text(encoding="utf-8")) + except (OSError, UnicodeError, json.JSONDecodeError) as exc: + findings.append(RuleFinding( + "error", "manifest_invalid_json", "manifest", manifest_ref, + f"Rule manifest is unreadable or invalid JSON: {type(exc).__name__}.", + "Repair the JSON; do not infer or silently drop malformed rules.", + )) + return _report( + root=root, manifest_ref=manifest_ref, present=True, rules=[], findings=findings + ) + + if not isinstance(raw, dict): + findings.append(RuleFinding( + "error", "manifest_not_object", "manifest", manifest_ref, + "Rule manifest must be a JSON object.", + "Use the rules-manifest/v1 top-level object.", + )) + return _report( + root=root, manifest_ref=manifest_ref, present=True, rules=[], findings=findings + ) + if set(raw) != {"schema", "rules"}: + findings.append(RuleFinding( + "error", "manifest_fields", "manifest", manifest_ref, + "Top-level fields must be exactly schema and rules.", + "Remove unknown fields and restore missing fields.", + )) + if raw.get("schema") != RULES_SCHEMA: + findings.append(RuleFinding( + "error", "manifest_schema", "manifest", manifest_ref, + f"Unsupported rule schema; expected {RULES_SCHEMA}.", + "Migrate the manifest explicitly before auditing it.", + )) + raw_rules = raw.get("rules") + if not isinstance(raw_rules, list) or not raw_rules or len(raw_rules) > 1000: + findings.append(RuleFinding( + "error", "rules_invalid", "manifest", manifest_ref, + "rules must contain 1-1000 rule objects.", + "Add at least one bounded rule entry.", + )) + return _report( + root=root, manifest_ref=manifest_ref, present=True, rules=[], findings=findings + ) + + rules = [] + for index, raw_rule in enumerate(raw_rules): + parsed = _parse_rule(raw_rule, index, findings) + if parsed is not None: + rules.append(parsed) + + by_id: dict[str, dict] = {} + for rule in rules: + if rule["id"] in by_id: + findings.append(RuleFinding( + "error", "rule_id_duplicate", rule["id"], manifest_ref, + "Rule id is duplicated.", + "Keep one canonical rule or supersede it with a new unique id.", + )) + else: + by_id[rule["id"]] = rule + + graph = {rule_id: list(rule["supersedes"]) for rule_id, rule in by_id.items()} + for rule_id, targets in sorted(graph.items()): + for target in targets: + if target == rule_id: + findings.append(RuleFinding( + "error", "supersedes_self", rule_id, manifest_ref, + "A rule cannot supersede itself.", + "Remove the self-reference.", + )) + elif target not in by_id: + findings.append(RuleFinding( + "error", "supersedes_missing", rule_id, manifest_ref, + f"Superseded rule does not exist: {target}.", + "Restore the historical rule entry or remove this relation.", + )) + cycles = _cycle_nodes(graph) + if cycles: + joined = ", ".join(sorted(cycles)) + findings.append(RuleFinding( + "error", "supersedes_cycle", joined, manifest_ref, + f"Supersession graph contains a cycle: {joined}.", + "Make supersession acyclic and point only from newer to older rules.", + )) + + cache: dict[str, str] = {} + for rule in rules: + rule_id = rule["id"] + references = [("source", rule["source_ref"])] + references.extend(("enforcement", ref) for ref in rule["enforcement_refs"]) + references.extend(("probe", probe["evidence_ref"]) for probe in rule["probes"]) + references.append(("verification", rule["last_verified"]["evidence_ref"])) + for kind, reference in references: + problem = _reference_error(root, reference, cache) + if problem is None: + continue + code, message = problem + findings.append(RuleFinding( + "error", f"{kind}_{code}", rule_id, reference, + message, + "Restore the exact anchor or update this reference and re-verify the rule.", + )) + + source_path, _, _ = rule["source_ref"].partition("#") + if source_path in cache and rule["statement"] not in cache[source_path]: + findings.append(RuleFinding( + "error", "semantic_statement_drift", rule_id, rule["source_ref"], + "Canonical rule statement no longer appears verbatim in its source.", + "Reconcile policy and manifest, then update the smallest changed statement.", + )) + + if not rule["enforced"]: + findings.append(RuleFinding( + "warning", "unenforced_rule", rule_id, rule["source_ref"], + "Rule is documentary only and has no declared deterministic guard.", + "Add a guard plus positive and negative probes before relying on it.", + )) + else: + if not rule["enforcement_refs"]: + findings.append(RuleFinding( + "error", "unenforced_missing_guard", rule_id, manifest_ref, + "Enforced rule declares no guard reference.", + "Add at least one exact enforcement path#anchor.", + )) + polarities = {probe["polarity"] for probe in rule["probes"]} + missing_polarities = sorted(_POLARITIES - polarities) + if missing_polarities: + findings.append(RuleFinding( + "error", "unenforced_missing_probe", rule_id, manifest_ref, + "Enforced rule lacks probe polarity: " + ", ".join(missing_polarities) + ".", + "Add one allow and one deny deterministic test anchor.", + )) + + expected = rule_semantic_sha256(rule) + if rule["last_verified"]["content_sha256"] != expected: + findings.append(RuleFinding( + "error", "verification_stale", rule_id, + rule["last_verified"]["evidence_ref"], + "Rule semantics changed after the last verification receipt.", + f"Re-run review and replace content_sha256 with {expected}.", + )) + + superseded = {target for targets in graph.values() for target in targets} + active = [rule for rule in rules if rule["id"] not in superseded] + for index, left in enumerate(active): + for right in active[index + 1:]: + if ( + left["action"] == right["action"] + and left["effect"] != right["effect"] + and _scope_overlap(left["scope"], right["scope"]) + ): + pair = f"{left['id']}|{right['id']}" + findings.append(RuleFinding( + "error", "rule_conflict", pair, manifest_ref, + "Active rules assign contradictory effects to overlapping action scope.", + "Narrow one scope or explicitly supersede the obsolete rule.", + )) + + return _report( + root=root, + manifest_ref=manifest_ref, + present=True, + rules=rules, + findings=findings, + ) + + +__all__ = [ + "AUDIT_SCHEMA", + "DEFAULT_MANIFEST", + "RULES_SCHEMA", + "RuleFinding", + "audit_rules", + "rule_semantic_sha256", +] diff --git a/skills/directives_audit/rules_cli.py b/skills/directives_audit/rules_cli.py new file mode 100644 index 00000000..13a4fc86 --- /dev/null +++ b/skills/directives_audit/rules_cli.py @@ -0,0 +1,60 @@ +"""CLI for the deterministic committed-rule audit. + + botte rules audit [project] [--json] +""" + +from __future__ import annotations + +import argparse +import json +import sys + +from skills.console_utf8 import force_utf8 +from skills.directives_audit.rules import DEFAULT_MANIFEST, audit_rules + + +_ICONS = {"error": "ERROR", "warning": "WARN", "info": "INFO"} + + +def main(argv=None) -> int: + force_utf8() + parser = argparse.ArgumentParser(prog="botte rules", description=__doc__) + sub = parser.add_subparsers(dest="command", required=True) + audit_parser = sub.add_parser( + "audit", help="verify rule sources, guards, probes and contradictions" + ) + audit_parser.add_argument("project", nargs="?", default=".") + audit_parser.add_argument( + "--manifest", default=DEFAULT_MANIFEST, + help="project-relative rule manifest path", + ) + audit_parser.add_argument("--json", action="store_true") + args = parser.parse_args(argv) + + report = audit_rules(args.project, args.manifest) + if args.json: + print(json.dumps(report, ensure_ascii=False, indent=2)) + else: + summary = report["summary"] + print(f"Rules audit — {report['project_ref']}") + if not report["manifest_present"]: + print(f" No committed manifest at {report['manifest_ref']}") + else: + print( + f" Score {report['score']}/100 · {summary['rules']} rules · " + f"{summary['errors']} errors · {summary['warnings']} warnings" + ) + for finding in report["findings"]: + print( + f" [{_ICONS.get(finding['severity'], finding['severity'].upper())}] " + f"{finding['rule_id']} {finding['code']}: {finding['message']}" + ) + print(f" {finding['fix_hint']}") + + if not report["manifest_present"]: + return 2 + return 1 if report["summary"]["errors"] else 0 + + +if __name__ == "__main__": + raise SystemExit(main()) From 32dd839ed79568e13b32d0f215d3cb5a94acb173 Mon Sep 17 00:00:00 2001 From: Sylvain Galliez <128614184+zedarvates@users.noreply.github.com> Date: Thu, 10 Sep 2026 18:38:25 +0200 Subject: [PATCH 084/113] docs(roadmap): plan structural storyboards and physical consistency --- FUTURE_ROADMAP.md | 30 +++++++++++++++++++++++++++++- 1 file changed, 29 insertions(+), 1 deletion(-) diff --git a/FUTURE_ROADMAP.md b/FUTURE_ROADMAP.md index 27fac6e1..cd7284cf 100644 --- a/FUTURE_ROADMAP.md +++ b/FUTURE_ROADMAP.md @@ -90,4 +90,32 @@ A dynamic-residency optimization graduates only if it improves at least one usef --- **Maintained by:** StoryCore-Engine Team -**Last Updated:** August 22, 2026 \ No newline at end of file +**Last Updated:** August 22, 2026 + +## 6. Scientific transfer: structural drawing and physical consistency (2026-09-10) + +**Planned.** This work follows existing resource admission and render stability +gates; it does not enable a new generation workflow. + +- [ ] Define a versioned storyboard input carrying strokes, contours, junctions, perspective constraints, coordinate frames, stable identities and provenance. +- [ ] Reuse Human Skills practice outputs and CogniARC structural critique; distinguish practiced trajectories from fitted reference curves. +- [ ] Compare unchanged, random-correction and targeted-correction storyboards on held-out compositions with equal action/render budgets. +- [ ] Measure junction/outline preservation, proportions, camera consistency and identity drift separately from visual style. +- [ ] Test bounded brush-style controls after structural quality passes, using [Procedural Brush Synthesis](https://users.cg.tuwien.ac.at/zsolnai/gfx/procedural-brush-synthesis-paper/) and [DiffVG](https://people.csail.mit.edu/tzumao/diffvg/) as individually sourced methods. +- [ ] Add offline physical-consistency fixtures for motion and contact only after numerical reference validation; compare persistent/constant-velocity predictions before introducing a learned predictor. + +**First implementation:** a tiny synthetic storyboard fixture and validator for +strokes, frames and identities, reusing existing project schemas where possible. +No external assets, model weights or generation services are required for that +contract experiment. + +**Acceptance:** reproducible improvement on reserved compositions, intact +identities and camera/geometry constraints, recorded failures and measured +resource use on stated hardware. Reference curve fitting alone is not evidence +of learned drawing skill; a physically plausible-looking clip is not a physics +validation. + +Physical prerequisite: +[CogniARC's initial particle-neighbour reference and limits](https://github.com/zedarvates/cogniarc/blob/a4aac4a5f42e546c4a4ad777c508e4a1b0f0f136/experiments/particle_graph/README.md). +That implementation is separate from StoryCore and is not a calibrated water +simulator or a generation integration. From 0046d9328a8adc48d2a401685786bf2d9f5901c7 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sat, 12 Sep 2026 04:54:43 +0000 Subject: [PATCH 085/113] chore(deps): bump the npm_and_yarn group across 2 directories with 3 updates Bumps the npm_and_yarn group with 2 updates in the / directory: [sharp](https://github.com/lovell/sharp) and [joi](https://github.com/hapijs/joi). Bumps the npm_and_yarn group with 1 update in the /config directory: [joi](https://github.com/hapijs/joi). Updates `sharp` from 0.35.0 to 0.35.4 - [Release notes](https://github.com/lovell/sharp/releases) - [Commits](https://github.com/lovell/sharp/compare/v0.35.0...v0.35.4) Updates `joi` from 18.0.2 to 18.2.9 - [Commits](https://github.com/hapijs/joi/compare/v18.0.2...v18.2.9) Updates `joi` from 18.0.2 to 18.2.9 - [Commits](https://github.com/hapijs/joi/compare/v18.0.2...v18.2.9) Updates `@vitest/mocker` from 4.1.8 to 5.0.0 - [Release notes](https://github.com/vitest-dev/vitest/releases) - [Changelog](https://github.com/vitest-dev/vitest/blob/main/docs/releases.md) - [Commits](https://github.com/vitest-dev/vitest/commits/v5.0.0/packages/mocker) --- updated-dependencies: - dependency-name: sharp dependency-version: 0.35.4 dependency-type: direct:production dependency-group: npm_and_yarn - dependency-name: joi dependency-version: 18.2.9 dependency-type: indirect dependency-group: npm_and_yarn - dependency-name: joi dependency-version: 18.2.9 dependency-type: indirect dependency-group: npm_and_yarn - dependency-name: "@vitest/mocker" dependency-version: 5.0.0 dependency-type: indirect dependency-group: npm_and_yarn ... Signed-off-by: dependabot[bot] --- config/package-lock.json | 8 +- creative-studio-ui/package-lock.json | 206 ++++++++-------------- creative-studio-ui/package.json | 2 +- package-lock.json | 255 ++++++++++++++------------- package.json | 2 +- 5 files changed, 210 insertions(+), 263 deletions(-) diff --git a/config/package-lock.json b/config/package-lock.json index a8ac217e..35ccf0c6 100644 --- a/config/package-lock.json +++ b/config/package-lock.json @@ -4937,9 +4937,9 @@ } }, "node_modules/joi": { - "version": "18.0.2", - "resolved": "https://registry.npmjs.org/joi/-/joi-18.0.2.tgz", - "integrity": "sha512-RuCOQMIt78LWnktPoeBL0GErkNaJPTBGcYuyaBvUOQSpcpcLfWrHPPihYdOGbV5pam9VTWbeoF7TsGiHugcjGA==", + "version": "18.2.9", + "resolved": "https://registry.npmjs.org/joi/-/joi-18.2.9.tgz", + "integrity": "sha512-2mD929bUVKUhOLQQEVhlf6EZ0Mlo0DeRb5MO7cViR9AXLtBauuccEtB1py9Ocxpo/P7ucnh442iY/iOwrh3IQw==", "dev": true, "license": "BSD-3-Clause", "dependencies": { @@ -4949,7 +4949,7 @@ "@hapi/pinpoint": "^2.0.1", "@hapi/tlds": "^1.1.1", "@hapi/topo": "^6.0.2", - "@standard-schema/spec": "^1.0.0" + "@standard-schema/spec": "^1.1.0" }, "engines": { "node": ">= 20" diff --git a/creative-studio-ui/package-lock.json b/creative-studio-ui/package-lock.json index c81174e4..c3170529 100644 --- a/creative-studio-ui/package-lock.json +++ b/creative-studio-ui/package-lock.json @@ -91,7 +91,7 @@ "typescript": "~5.9.3", "typescript-eslint": "^8.69.0", "vite": "^7.3.6", - "vitest": "^4.1.8" + "vitest": "^5.0.0" } }, "node_modules/@acemir/cssom": { @@ -1588,9 +1588,9 @@ } }, "node_modules/@jridgewell/sourcemap-codec": { - "version": "1.5.5", - "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", - "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.6.0.tgz", + "integrity": "sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw==", "license": "MIT" }, "node_modules/@jridgewell/trace-mapping": { @@ -5345,15 +5345,16 @@ } }, "node_modules/@vitest/mocker": { - "version": "4.1.8", - "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.8.tgz", - "integrity": "sha512-LEiN/xe4OSIbKe9HQIp5OC24agGD9J5CnmMgsLohVVoOPWL9a2sBoR6VBx43jQZb7Kr1l4RCuyCJzcAa0+dojw==", + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-5.0.0.tgz", + "integrity": "sha512-66PGTMIiVJP3t4a5yxU9qPtf7MdTBs8jmToMvy+HVflB3Yy13WJZTtPePdvU+wjRV02SKK5doLbSA6o9pwOmiA==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/spy": "4.1.8", + "@jridgewell/trace-mapping": "0.3.31", + "@vitest/spy": "5.0.0", "estree-walker": "^3.0.3", - "magic-string": "^0.30.21" + "magic-string": "^1.2.3" }, "funding": { "url": "https://opencollective.com/vitest" @@ -5372,9 +5373,9 @@ } }, "node_modules/@vitest/mocker/node_modules/@vitest/spy": { - "version": "4.1.8", - "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.8.tgz", - "integrity": "sha512-6EevtBp6OZOPF7bmz36HrGMeP3txgVSrgebWxHOafDXGkhIzfXK14f8KF6MuFfgXXUeHxmpD3BQxkV00/3s5mA==", + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-5.0.0.tgz", + "integrity": "sha512-uy+luWBAPw9XfthoHi5AkfHUnuPYEESjl0p/r+meoBnU8bxg5GDQ3Ey8MjcJ6sqahkL4PFyrvfMJJBw7LbU06g==", "dev": true, "license": "MIT", "funding": { @@ -5394,36 +5395,6 @@ "url": "https://opencollective.com/vitest" } }, - "node_modules/@vitest/runner": { - "version": "4.1.8", - "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.8.tgz", - "integrity": "sha512-EmVxeBAfMJvycdjd6Hm+RbFBbA9fKvo0Kx37hNpBYoYeavH3RNsBXWDooR1mgD52dCrxIIuP7UotpfiwOikvcg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@vitest/utils": "4.1.8", - "pathe": "^2.0.3" - }, - "funding": { - "url": "https://opencollective.com/vitest" - } - }, - "node_modules/@vitest/snapshot": { - "version": "4.1.8", - "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.8.tgz", - "integrity": "sha512-acfZboRmAIf05DEKcBQy33VXojFJjtUdLyo7oOmV9kebb2xdU01UknNiPuPZoJZQyO7DF0gZdTGTpeAzET9QPQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@vitest/pretty-format": "4.1.8", - "@vitest/utils": "4.1.8", - "magic-string": "^0.30.21", - "pathe": "^2.0.3" - }, - "funding": { - "url": "https://opencollective.com/vitest" - } - }, "node_modules/@vitest/spy": { "version": "3.2.4", "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-3.2.4.tgz", @@ -6808,9 +6779,9 @@ } }, "node_modules/es-module-lexer": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.1.0.tgz", - "integrity": "sha512-n27zTYMjYu1aj4MjCWzSP7G9r75utsaoc8m61weK+W8JMBGGQybd43GstCXZ3WNmSFtGT9wi59qQTW6mhTR5LQ==", + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.3.2.tgz", + "integrity": "sha512-poHGpORABojJJucnV9KbOavETW8lBVnphkW77ER5/BQ5Fz7oXSoCNek7IH3vR5nRjdsEz926ibFYX8KtLQmdyw==", "dev": true, "license": "MIT" }, @@ -7126,9 +7097,9 @@ } }, "node_modules/expect-type": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz", - "integrity": "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==", + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.4.0.tgz", + "integrity": "sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==", "dev": true, "license": "Apache-2.0", "engines": { @@ -8393,13 +8364,13 @@ } }, "node_modules/magic-string": { - "version": "0.30.21", - "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", - "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-1.3.1.tgz", + "integrity": "sha512-rm91zr2Ou+XueDTohjQQjdQEcYM6zVi8KVUCG8Ec3vHwUEKrhSdCNyfuIywkA6hcCAteIn0ZOtAHA6eGpiX+Pg==", "dev": true, "license": "MIT", "dependencies": { - "@jridgewell/sourcemap-codec": "^1.5.5" + "@jridgewell/sourcemap-codec": "^1.6.0" } }, "node_modules/magicast": { @@ -9304,15 +9275,18 @@ } }, "node_modules/obug": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.1.tgz", - "integrity": "sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==", + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/obug/-/obug-2.2.1.tgz", + "integrity": "sha512-XrsrhT5sybtKI6wakr2SPOlGZWWYbUXZ7a0jT8/QOeAPau+1X/bSegNe5YR75oJmEZQbKningirmGOEJCIk61Q==", "dev": true, "funding": [ "https://github.com/sponsors/sxzz", "https://opencollective.com/debug" ], - "license": "MIT" + "license": "MIT", + "engines": { + "node": ">=12.20.0" + } }, "node_modules/open": { "version": "8.4.2", @@ -9602,9 +9576,9 @@ "license": "ISC" }, "node_modules/picomatch": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", - "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.7.tgz", + "integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==", "dev": true, "license": "MIT", "engines": { @@ -10912,9 +10886,9 @@ "license": "MIT" }, "node_modules/std-env": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.1.0.tgz", - "integrity": "sha512-Rq7ybcX2RuC55r9oaPVEW7/xu3tj8u4GeBYHBWCychFtzMIr86A7e3PPEBPT37sHStKX3+TiX/Fr/ACmJLVlLQ==", + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.2.0.tgz", + "integrity": "sha512-oCUKSupKTHX53EyjDtuZQ64pjLJ6yYCtpmEw0goYxtjG9KpbRe8KAsl2tBUGU9DyMcJ0RwJ8GqJAFzMXcXW1Rw==", "dev": true, "license": "MIT" }, @@ -11304,16 +11278,19 @@ "peer": true }, "node_modules/tinybench": { - "version": "2.9.0", - "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", - "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "version": "6.1.4", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-6.1.4.tgz", + "integrity": "sha512-9APumHG7r4yOk4X4WlkmE71aZcv1gvin1czO3OQ1U9iJcFA5Ja/ygyb0vPOVHTthFozUYs8CLoLUlM8grb2lTQ==", "dev": true, - "license": "MIT" + "license": "MIT", + "engines": { + "node": ">=20.0.0" + } }, "node_modules/tinyexec": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.2.4.tgz", - "integrity": "sha512-SHf/r48b7vOrjve9PxJo3MN5v5yuyjHvdUcrQffT3WXMUfnGmHDVbC4k3sHJaJTgZCwpUplIaAo5ANtMyp3YHg==", + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.3.0.tgz", + "integrity": "sha512-QKAl9m8gWWGHV8jZcPeym6j+XULi6tOf1mT83WYJ4Lk2ytW/uwAWkrP0uFsdoYMdueVJ0qs26wZ+23xeB4ibNQ==", "dev": true, "license": "MIT", "engines": { @@ -11321,14 +11298,14 @@ } }, "node_modules/tinyglobby": { - "version": "0.2.15", - "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", - "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==", + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", "dev": true, "license": "MIT", "dependencies": { "fdir": "^6.5.0", - "picomatch": "^4.0.3" + "picomatch": "^4.0.4" }, "engines": { "node": ">=12.0.0" @@ -11932,38 +11909,31 @@ } }, "node_modules/vitest": { - "version": "4.1.8", - "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.8.tgz", - "integrity": "sha512-flY6ScbCIt9HThs+C5HS7jvGOB560DJtk/Z15IQROTA6zEy49Nh8T/dofWTQL+n3vswqn87sbJNiuqw1SDp5Ig==", + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-5.0.0.tgz", + "integrity": "sha512-gpsMNoRhMjMktVxPtstOH4/PJuPyovVaMDr4oDilXaGH1EcqM2OE96SoHT2VIQ6fTGtTjqmHDrEu2X9RQiXf8Q==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/expect": "4.1.8", - "@vitest/mocker": "4.1.8", - "@vitest/pretty-format": "4.1.8", - "@vitest/runner": "4.1.8", - "@vitest/snapshot": "4.1.8", - "@vitest/spy": "4.1.8", - "@vitest/utils": "4.1.8", - "es-module-lexer": "^2.0.0", - "expect-type": "^1.3.0", - "magic-string": "^0.30.21", - "obug": "^2.1.1", - "pathe": "^2.0.3", - "picomatch": "^4.0.3", - "std-env": "^4.0.0-rc.1", - "tinybench": "^2.9.0", - "tinyexec": "^1.0.2", - "tinyglobby": "^0.2.15", - "tinyrainbow": "^3.1.0", - "vite": "^6.0.0 || ^7.0.0 || ^8.0.0", + "@types/chai": "^5.2.2", + "@vitest/mocker": "5.0.0", + "chai": "^6.2.2", + "es-module-lexer": "^2.3.2", + "expect-type": "^1.4.0", + "magic-string": "^1.2.3", + "obug": "^2.1.4", + "picomatch": "^4.0.7", + "std-env": "^4.2.0", + "tinybench": "6.1.4", + "tinyexec": "1.3.0", + "tinyglobby": "^0.2.17", "why-is-node-running": "^2.3.0" }, "bin": { "vitest": "vitest.mjs" }, "engines": { - "node": "^20.0.0 || ^22.0.0 || >=24.0.0" + "node": "^22.12.0 || ^24.0.0 || >=26.0.0" }, "funding": { "url": "https://opencollective.com/vitest" @@ -11971,16 +11941,16 @@ "peerDependencies": { "@edge-runtime/vm": "*", "@opentelemetry/api": "^1.9.0", - "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", - "@vitest/browser-playwright": "4.1.8", - "@vitest/browser-preview": "4.1.8", - "@vitest/browser-webdriverio": "4.1.8", - "@vitest/coverage-istanbul": "4.1.8", - "@vitest/coverage-v8": "4.1.8", - "@vitest/ui": "4.1.8", + "@types/node": "^22.0.0 || >=24.0.0", + "@vitest/browser-playwright": "5.0.0", + "@vitest/browser-preview": "5.0.0", + "@vitest/browser-webdriverio": "^5.0.0-beta.5 || >=5.0.0", + "@vitest/coverage-istanbul": "5.0.0", + "@vitest/coverage-v8": "5.0.0", + "@vitest/ui": "5.0.0", "happy-dom": "*", "jsdom": "*", - "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + "vite": "^6.4.0 || ^7.0.0 || ^8.0.0" }, "peerDependenciesMeta": { "@edge-runtime/vm": { @@ -12021,34 +11991,6 @@ } } }, - "node_modules/vitest/node_modules/@vitest/expect": { - "version": "4.1.8", - "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.8.tgz", - "integrity": "sha512-h3nDO677RDLEGlBxyQ5CW8RlMThSKSRLUePLOx09gNIWRL40edgA1GCZSZgf1W55MFAG6/Sw14KeaAnqv0NKdQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@standard-schema/spec": "^1.1.0", - "@types/chai": "^5.2.2", - "@vitest/spy": "4.1.8", - "@vitest/utils": "4.1.8", - "chai": "^6.2.2", - "tinyrainbow": "^3.1.0" - }, - "funding": { - "url": "https://opencollective.com/vitest" - } - }, - "node_modules/vitest/node_modules/@vitest/spy": { - "version": "4.1.8", - "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.8.tgz", - "integrity": "sha512-6EevtBp6OZOPF7bmz36HrGMeP3txgVSrgebWxHOafDXGkhIzfXK14f8KF6MuFfgXXUeHxmpD3BQxkV00/3s5mA==", - "dev": true, - "license": "MIT", - "funding": { - "url": "https://opencollective.com/vitest" - } - }, "node_modules/vitest/node_modules/chai": { "version": "6.2.2", "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz", diff --git a/creative-studio-ui/package.json b/creative-studio-ui/package.json index eb551b2b..ecf6a462 100644 --- a/creative-studio-ui/package.json +++ b/creative-studio-ui/package.json @@ -111,6 +111,6 @@ "typescript": "~5.9.3", "typescript-eslint": "^8.69.0", "vite": "^7.3.6", - "vitest": "^4.1.8" + "vitest": "^5.0.0" } } diff --git a/package-lock.json b/package-lock.json index ba42c13c..38b736bd 100644 --- a/package-lock.json +++ b/package-lock.json @@ -17,7 +17,7 @@ "lucide-react": "^0.562.0", "react": "^19.2.3", "react-dom": "^19.2.3", - "sharp": "^0.35.0", + "sharp": "^0.35.4", "typescript": "^5.9.3", "uuid": "^14.0.0" }, @@ -1836,9 +1836,9 @@ } }, "node_modules/@img/sharp-darwin-arm64": { - "version": "0.35.0", - "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.35.0.tgz", - "integrity": "sha512-ZgaYEwaj+lx/5n4W8GmZ2IYz0PQHjN5eqRcfijWGB+2Aq7ZInZGa0qJyAn6DEtyLuWHRSrmWOqT9q3qqTBvmUQ==", + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.35.4.tgz", + "integrity": "sha512-Uhfl4V4lhP2nbUVF9+hyH1+luj86f1gUFeo8ALYxFoULoU+G87D43BfeMP8XHsk9boxAnCY/bf2EHwhA7MuGsA==", "cpu": [ "arm64" ], @@ -1854,13 +1854,13 @@ "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-darwin-arm64": "1.3.0" + "@img/sharp-libvips-darwin-arm64": "1.3.3" } }, "node_modules/@img/sharp-darwin-x64": { - "version": "0.35.0", - "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.35.0.tgz", - "integrity": "sha512-c1z9LFpKB0slQW3RchwBE8iSVzGp70TNjUUO9k4BZwwW4HH7JBGHeIy4b+kk4n/kcBASb9evKCE3/7Slmslgiw==", + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.35.4.tgz", + "integrity": "sha512-hWniXY3bG5qKpkKrAwPe4y+VTPmf086YQAnkxWh7uA1YrlRouWGa0M0Mxj3ZjnXFkv7/TD1bTy9lGUK26vRvWw==", "cpu": [ "x64" ], @@ -1876,20 +1876,20 @@ "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-darwin-x64": "1.3.0" + "@img/sharp-libvips-darwin-x64": "1.3.3" } }, "node_modules/@img/sharp-freebsd-wasm32": { - "version": "0.35.0", - "resolved": "https://registry.npmjs.org/@img/sharp-freebsd-wasm32/-/sharp-freebsd-wasm32-0.35.0.tgz", - "integrity": "sha512-Li2KTev0H90kEtnJHkI9xQojXt1AqWmFBMXiPw5kqd1jQgP7gi5HVK/qC5Rmh/59NuAwUuPzzPITmX22NomYYQ==", + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-freebsd-wasm32/-/sharp-freebsd-wasm32-0.35.4.tgz", + "integrity": "sha512-lIsKw/BU+kjB4eZjxrYrZmwOJYi3Ajrv66iAlBmUPyKc3HpnloevB1g3wxGD9P/5BbQ1brBGl65VRRrCvQDEqA==", "license": "Apache-2.0", "optional": true, "os": [ "freebsd" ], "dependencies": { - "@img/sharp-wasm32": "0.35.0" + "@img/sharp-wasm32": "0.35.4" }, "engines": { "node": ">=20.9.0" @@ -1899,9 +1899,9 @@ } }, "node_modules/@img/sharp-libvips-darwin-arm64": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.3.0.tgz", - "integrity": "sha512-EKbmBKtyTH+GPFDRw2TgK2oV6hyxxlJVIar4hoTYSNmIwipgMFdxPQqR392GmfdsPGWga0mCFN1cCKjRb9cljw==", + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.3.3.tgz", + "integrity": "sha512-suTBPTDGrI9WodccaDdwZItTSaBYASlBk1NSfElSHrUfzu3szG6lvIF58+WiFvnfzuK8ZBFS5zE00PxqxnRiPg==", "cpu": [ "arm64" ], @@ -1915,9 +1915,9 @@ } }, "node_modules/@img/sharp-libvips-darwin-x64": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.3.0.tgz", - "integrity": "sha512-Pl2OmOvrJ42adUllESxBsG54PfXLo1OYg9i3c5/5Ln/qJ0gZuTM9YMhQJPIbXqwidLRc/c2zuHt4RsrymmNv7A==", + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.3.3.tgz", + "integrity": "sha512-FVJZ5mITMobmXIz/hPDTw0EintTW5H3WfrxwLqEqjiIihlu+hVRyGrFQ60xl0Lxn7Bt3zdpevPaQi0HEzqz9fw==", "cpu": [ "x64" ], @@ -1931,9 +1931,9 @@ } }, "node_modules/@img/sharp-libvips-linux-arm": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.3.0.tgz", - "integrity": "sha512-A8UpHoUDW4DwnXoV6+q3C1s7QLRAHtPDEjWuNZjwHMyoCNZnm0GeNN8ls9f/bsEYTRQRW96C/n34XJQHJ2fT7A==", + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.3.3.tgz", + "integrity": "sha512-3rbU4vqXXc3hY/OiXdl52xZvT0F1yEngWfvqudtPJg/KkyiaQw2DRsFrNzpmLvfavbwOq3qXn36GP8obHRULQA==", "cpu": [ "arm" ], @@ -1950,9 +1950,9 @@ } }, "node_modules/@img/sharp-libvips-linux-arm64": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.3.0.tgz", - "integrity": "sha512-C0SqjoFKnszqa44EQ7xoaT48nnO0lOyXEULfXMWi8krrjOPGYkeK30Okzla6ATbBYsyZ0ySinK0FVkpv3DwzfQ==", + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.3.3.tgz", + "integrity": "sha512-0DaL0A6Xu6sQSQFwe4iVCrKWU2cCTItnRsYsCdxAMm9NF6twAA9BKnoqy4hqz4+azQ0JHuA26qiUKsf1XJ/v5A==", "cpu": [ "arm64" ], @@ -1969,9 +1969,9 @@ } }, "node_modules/@img/sharp-libvips-linux-ppc64": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.3.0.tgz", - "integrity": "sha512-WOpkVxAjFd369iaIzEgNRreFD+gWdUMIGD5zplhNKNeqS6mm5dac3q2AFyCBmzYoAdouzZvRBgxy4z8QHZb4/A==", + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.3.3.tgz", + "integrity": "sha512-cdn1OvUBwsXhbC0zSzJnNzf5MZ/mTrobawDvNXBTxe8VtqKAm0sRuEY2Evzovb/w9JMk4TvRxqt1mekSuJz64w==", "cpu": [ "ppc64" ], @@ -1988,9 +1988,9 @@ } }, "node_modules/@img/sharp-libvips-linux-riscv64": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-riscv64/-/sharp-libvips-linux-riscv64-1.3.0.tgz", - "integrity": "sha512-DRWw0mOHusrCCuw2rqP87oLg6PGlkomVDFqw2hIwsSfwWpu4k3XLcBPaKKl6ct/GtL/cwNkgwjV/tc0Mqht3VA==", + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-riscv64/-/sharp-libvips-linux-riscv64-1.3.3.tgz", + "integrity": "sha512-HjPVx7yKz+0lqdhDlTw1tt90wamBoxhiXpvl1XZpJLiHH4RCJ5yDTqH+VlYPv2fwFs89JFw4c1IexYOcQUi4IQ==", "cpu": [ "riscv64" ], @@ -2007,9 +2007,9 @@ } }, "node_modules/@img/sharp-libvips-linux-s390x": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.3.0.tgz", - "integrity": "sha512-9APy+nFWhHS+kzLgWZfLcyrUd7YqnAQVa4BPOo4xkoHpdoktOAPG4cEr9+Jpl0TtqfVmcMJimNL5qNTyyOHZNA==", + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.3.3.tgz", + "integrity": "sha512-neWLh+3yCNThxnfy3c4BbVBeGgt9aftno+XbT56iK28RgeDs3UOFWviLWlUu0bArYVYJaFDK+RRohbicUNCm8Q==", "cpu": [ "s390x" ], @@ -2026,9 +2026,9 @@ } }, "node_modules/@img/sharp-libvips-linux-x64": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.3.0.tgz", - "integrity": "sha512-y9RNUYDe2A1UAdhLyfeOodGRszQdaEoe4nfOpp/sNVPl2CWIcUyFaDoCh4vPLPxu19803j2naLqZup2WxDXCLA==", + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.3.3.tgz", + "integrity": "sha512-4vKmvAst9nrowcqquKFAyZJUDolUaIp8uRiN0mWFguJ1IplC9/pitXtlnnlU4aa/eJw3J7i67V+pwUL+wZGdsA==", "cpu": [ "x64" ], @@ -2045,9 +2045,9 @@ } }, "node_modules/@img/sharp-libvips-linuxmusl-arm64": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.3.0.tgz", - "integrity": "sha512-cC1wkC0Mlucd0KSiGrLkJnB/ZqPvZCntc/Lk7ZnYO5ZSbF2euNek4Xvxafojq+wN1q/W0eprdpUIjUr/EV2PBg==", + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.3.3.tgz", + "integrity": "sha512-Y9kQaLMuNoB0bPYOOdcZMaseNrFpPodIWWMrx+CZyydf2xn68j9WYc6sWWRrDwNkzCQjKYfc68L7jKjGlHMibw==", "cpu": [ "arm64" ], @@ -2064,9 +2064,9 @@ } }, "node_modules/@img/sharp-libvips-linuxmusl-x64": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.3.0.tgz", - "integrity": "sha512-LiYMhUZicB1QG//+RvmYZpXJO8fYRENfp+MZUCnG9aw+AKvGAy9gPaCnuwsPcBFs8EV66M0NNxj9VHcNklE8zw==", + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.3.3.tgz", + "integrity": "sha512-fj8Mv0HHfD1Rr+4I68+3agJynxDWtBFgicTbSOb9Bke6pIwzGcJ+RX/yHjmiEGFMCavY/dxvem7MyNaJF+wDiw==", "cpu": [ "x64" ], @@ -2083,9 +2083,9 @@ } }, "node_modules/@img/sharp-linux-arm": { - "version": "0.35.0", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.35.0.tgz", - "integrity": "sha512-VVlpEWwizEFIOom0zdoeKuO5nuTswzVE5uHcBNvHzmeHUpNFajY3HFfbQ+zIH4E2kVaZ/yVxmsShW56TtEy4uA==", + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.35.4.tgz", + "integrity": "sha512-7OAS8gI0EReKGVN2HssHlM6umJgxF5VI3xN0p9FA91p/YO+ou5hiNghLdZ5BEHztwaaK5+bLKRf8x/o2L2nk9A==", "cpu": [ "arm" ], @@ -2104,13 +2104,13 @@ "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linux-arm": "1.3.0" + "@img/sharp-libvips-linux-arm": "1.3.3" } }, "node_modules/@img/sharp-linux-arm64": { - "version": "0.35.0", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.35.0.tgz", - "integrity": "sha512-4+4XHLNT5wDT0roYlHTEmH9lDKt0acf9Tv+3hM3iceOirkxrR404/3WjAYZ9F9CkHrxeRcGLJXbi4vluMZ9O+A==", + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.35.4.tgz", + "integrity": "sha512-De4jpEnAU8Hd5oT0j1G3uL4ZvTuipVMn7YC6vPaJhy6/7EwEae0SVAoBrUMYQbkLGDm85taVWwuPc1a44LTzCQ==", "cpu": [ "arm64" ], @@ -2129,13 +2129,13 @@ "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linux-arm64": "1.3.0" + "@img/sharp-libvips-linux-arm64": "1.3.3" } }, "node_modules/@img/sharp-linux-ppc64": { - "version": "0.35.0", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.35.0.tgz", - "integrity": "sha512-N3hzbEpUTJC8pWpPVJvgzGxM+so/MAXc8O2s/53B0LL9ZGpfXpME7Wizkc5d/8fRBlBtkDjzoZGDCqqNDHqLEw==", + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.35.4.tgz", + "integrity": "sha512-2oYZJeIl4kCcMGk4ouZVjnkCtFrpQFlNEtJ6GbxzhHQchwH0NH/qEb9ykmOl29dqwMq+JhFdZn+1ak2FKhI9fQ==", "cpu": [ "ppc64" ], @@ -2154,13 +2154,13 @@ "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linux-ppc64": "1.3.0" + "@img/sharp-libvips-linux-ppc64": "1.3.3" } }, "node_modules/@img/sharp-linux-riscv64": { - "version": "0.35.0", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-riscv64/-/sharp-linux-riscv64-0.35.0.tgz", - "integrity": "sha512-l6vmKVPnbS0RhVMbyxP5meAARsbhCnBN4fy31qz0+3a6Rv4jEqfzDrT89y6ZPkCi0AJGnwp2En528yXo401Hpw==", + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-riscv64/-/sharp-linux-riscv64-0.35.4.tgz", + "integrity": "sha512-cPbNChoRURAWdebDIHSenxRpgEdy7JkPydSnUxRm9VvKD7m0/xVaR/8Fzlu81pk5nHEvHH87UZUA7cTtwnbJSA==", "cpu": [ "riscv64" ], @@ -2179,13 +2179,13 @@ "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linux-riscv64": "1.3.0" + "@img/sharp-libvips-linux-riscv64": "1.3.3" } }, "node_modules/@img/sharp-linux-s390x": { - "version": "0.35.0", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.35.0.tgz", - "integrity": "sha512-MYlMiPFiv/EKPAHnp3yNZ9AAWFsxga9c5Bkc6wkar6bqzHLlkGVJHRm0u1ei+VXnZxp3Mz9MG9ZIsI8vSOf3sQ==", + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.35.4.tgz", + "integrity": "sha512-RY0JFY8Fd6RonCBtHz+DvadaPkXDSI1AUn6yWL9TipqkZ1vY8w8evqdgyDFnkm4/K1ve1TvZiaePP5oSd4+WVQ==", "cpu": [ "s390x" ], @@ -2204,13 +2204,13 @@ "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linux-s390x": "1.3.0" + "@img/sharp-libvips-linux-s390x": "1.3.3" } }, "node_modules/@img/sharp-linux-x64": { - "version": "0.35.0", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.35.0.tgz", - "integrity": "sha512-TYaItB5oj1ioXjhyn2xrR208vf+YuIIcHptQWRRaBmFhvIvL9D72DXN8w75xup0KXA8UdEAhQ9Qb2S49FD/9Cw==", + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.35.4.tgz", + "integrity": "sha512-9qvvEAuk8k89TfWUoX2htWjbAMX8p+NxCppjpcg5k6xMsjhBQPTsoIh36h9Qde4WRuGpJeYnOjdosDn/cnv+OA==", "cpu": [ "x64" ], @@ -2229,13 +2229,13 @@ "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linux-x64": "1.3.0" + "@img/sharp-libvips-linux-x64": "1.3.3" } }, "node_modules/@img/sharp-linuxmusl-arm64": { - "version": "0.35.0", - "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.35.0.tgz", - "integrity": "sha512-DSTb6ijQzqe6DdAaOBVqJ/SYf1vO8EW5bK6X6LRXufEBebf2722VCdvBUtZ3rtV0x2ApfPNDy/p7LrrjaWjiyQ==", + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.35.4.tgz", + "integrity": "sha512-KB5jxpfWQTr0nc3xdHtWChdbifHrBGsd2SM62Eyxrl8afikm+f5qGBU75SJIZBT/S1MC8XyacdlXBMSWq6OURA==", "cpu": [ "arm64" ], @@ -2254,13 +2254,13 @@ "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linuxmusl-arm64": "1.3.0" + "@img/sharp-libvips-linuxmusl-arm64": "1.3.3" } }, "node_modules/@img/sharp-linuxmusl-x64": { - "version": "0.35.0", - "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.35.0.tgz", - "integrity": "sha512-K7ykQ+26Rt6+4BTU80AuGgTPIYX86UxiAKT4rcXX/WNTo7k1ZxpKz+TguHnwVpCqQK3B5PK0vZ0ZBe6nz/ib1w==", + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.35.4.tgz", + "integrity": "sha512-f+eZJZIQNEEd26RPSW+76chwOf1XtA2Y/O+5ocVyLliHkeih3e+jhLVBdNTd2rS3IbNXK8+ug93Vf5ZXtF5Lxg==", "cpu": [ "x64" ], @@ -2279,17 +2279,17 @@ "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linuxmusl-x64": "1.3.0" + "@img/sharp-libvips-linuxmusl-x64": "1.3.3" } }, "node_modules/@img/sharp-wasm32": { - "version": "0.35.0", - "resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.35.0.tgz", - "integrity": "sha512-9woLIFORERCr+6cWu87dQ22J34EExkhc73U1kZW0c+RclQqWetoodByp4dWZ/hN8/KVmTRAx2HOnUwib8AwZdA==", + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.35.4.tgz", + "integrity": "sha512-zQnl4Kwp7Q6NHsENtU2T/00Zi+w3AQNwz3+UaTyVBy2FpXrzXzGjndpK61onhZjRtRpQXxCTeqw19bVyXOh7jA==", "license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT", "optional": true, "dependencies": { - "@emnapi/runtime": "^1.11.0" + "@emnapi/runtime": "^1.11.3" }, "engines": { "node": ">=20.9.0" @@ -2299,16 +2299,16 @@ } }, "node_modules/@img/sharp-webcontainers-wasm32": { - "version": "0.35.0", - "resolved": "https://registry.npmjs.org/@img/sharp-webcontainers-wasm32/-/sharp-webcontainers-wasm32-0.35.0.tgz", - "integrity": "sha512-t+kie1TOyaDM6Dho+f+y0VqIUNhYQaKCUahuZVi0E0frgdiaOaPsDxDW3wfKacUdaNBCnK/ZDBMg33ydvHj8uA==", + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-webcontainers-wasm32/-/sharp-webcontainers-wasm32-0.35.4.tgz", + "integrity": "sha512-ESfNkywmCfPNyaZjxooddJQiQ+l/nTpGEOGthxiLnIHXC/CmcBixnfwUleX9mCz9ovrUUvKMap/pm8RYbzfwaA==", "cpu": [ "wasm32" ], "license": "Apache-2.0", "optional": true, "dependencies": { - "@img/sharp-wasm32": "0.35.0" + "@img/sharp-wasm32": "0.35.4" }, "engines": { "node": ">=20.9.0" @@ -2318,9 +2318,9 @@ } }, "node_modules/@img/sharp-win32-arm64": { - "version": "0.35.0", - "resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.35.0.tgz", - "integrity": "sha512-M5eKxug0dabbaWgFKvPa3odNs2OpaP+81NASfGKkt4GcYXpNhSu7CaeYxWkLNV6vHmUp4hnCxnxrUyhUJhXbKA==", + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.35.4.tgz", + "integrity": "sha512-iNdlBX9gLVvqe2I3uIJSIKTq6wckP/DYxZtcqxm09x5Gi24DnFBmPAWZmr60ZyYMG0xlzo6goG3670ar+RXvRw==", "cpu": [ "arm64" ], @@ -2337,9 +2337,9 @@ } }, "node_modules/@img/sharp-win32-ia32": { - "version": "0.35.0", - "resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.35.0.tgz", - "integrity": "sha512-z0+pZ03QCDvdVN0Ez9IX/yjWC19ikMlXrmdYMwYNLTh2BLPx3hXWPvyqWfquZ0BTO9O6GVOjIVoTcyyacMnWlQ==", + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.35.4.tgz", + "integrity": "sha512-kqRsbaa5CS6KHlpxnN7WhE6vAAugXyZButpRdvDWetlv6Qv4N9WTcrWzF7tXfB9T7MsoadqdI8hmwLq6UlLvtw==", "cpu": [ "ia32" ], @@ -2356,9 +2356,9 @@ } }, "node_modules/@img/sharp-win32-x64": { - "version": "0.35.0", - "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.35.0.tgz", - "integrity": "sha512-feNnlz5ZHKr0MY1LPHvZQyJeBkbo4ctsn0D8FvA53VTw5TC63rfEL2UrWbkSBR19htSE7Mw78xYVwdJqoMWVHw==", + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.35.4.tgz", + "integrity": "sha512-XtmnYhBcrORsJ4XJngyzr/EWP0hRZLAZRFaApdKuviyqF78+ylxh2y06ZmtULAMOnObJ3ucpN0AcwSWnMowTRg==", "cpu": [ "x64" ], @@ -8407,9 +8407,9 @@ } }, "node_modules/joi": { - "version": "18.0.2", - "resolved": "https://registry.npmjs.org/joi/-/joi-18.0.2.tgz", - "integrity": "sha512-RuCOQMIt78LWnktPoeBL0GErkNaJPTBGcYuyaBvUOQSpcpcLfWrHPPihYdOGbV5pam9VTWbeoF7TsGiHugcjGA==", + "version": "18.2.9", + "resolved": "https://registry.npmjs.org/joi/-/joi-18.2.9.tgz", + "integrity": "sha512-2mD929bUVKUhOLQQEVhlf6EZ0Mlo0DeRb5MO7cViR9AXLtBauuccEtB1py9Ocxpo/P7ucnh442iY/iOwrh3IQw==", "dev": true, "license": "BSD-3-Clause", "dependencies": { @@ -8419,7 +8419,7 @@ "@hapi/pinpoint": "^2.0.1", "@hapi/tlds": "^1.1.1", "@hapi/topo": "^6.0.2", - "@standard-schema/spec": "^1.0.0" + "@standard-schema/spec": "^1.1.0" }, "engines": { "node": ">= 20" @@ -10090,14 +10090,14 @@ } }, "node_modules/sharp": { - "version": "0.35.0", - "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.35.0.tgz", - "integrity": "sha512-BqvG5XbwPZ4NV0DK90d86leEECMsoa8bO0nqnKWlBDYxri4GJ7c4EDInaF6q20lTh/mATmnDIKWJFfXnoVfH5g==", + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.35.4.tgz", + "integrity": "sha512-n++8XWcj+jCOr2IOl7h8LbKnGBDY4aPbmprMONBNFdn0ImXqpGVv5zliDs0V9HbmbCQLpbuo2ej9rAoOQTvMDA==", "license": "Apache-2.0", "dependencies": { "@img/colour": "^1.1.0", "detect-libc": "^2.1.2", - "semver": "^7.8.4" + "semver": "^7.8.5" }, "engines": { "node": ">=20.9.0" @@ -10106,31 +10106,36 @@ "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-darwin-arm64": "0.35.0", - "@img/sharp-darwin-x64": "0.35.0", - "@img/sharp-freebsd-wasm32": "0.35.0", - "@img/sharp-libvips-darwin-arm64": "1.3.0", - "@img/sharp-libvips-darwin-x64": "1.3.0", - "@img/sharp-libvips-linux-arm": "1.3.0", - "@img/sharp-libvips-linux-arm64": "1.3.0", - "@img/sharp-libvips-linux-ppc64": "1.3.0", - "@img/sharp-libvips-linux-riscv64": "1.3.0", - "@img/sharp-libvips-linux-s390x": "1.3.0", - "@img/sharp-libvips-linux-x64": "1.3.0", - "@img/sharp-libvips-linuxmusl-arm64": "1.3.0", - "@img/sharp-libvips-linuxmusl-x64": "1.3.0", - "@img/sharp-linux-arm": "0.35.0", - "@img/sharp-linux-arm64": "0.35.0", - "@img/sharp-linux-ppc64": "0.35.0", - "@img/sharp-linux-riscv64": "0.35.0", - "@img/sharp-linux-s390x": "0.35.0", - "@img/sharp-linux-x64": "0.35.0", - "@img/sharp-linuxmusl-arm64": "0.35.0", - "@img/sharp-linuxmusl-x64": "0.35.0", - "@img/sharp-webcontainers-wasm32": "0.35.0", - "@img/sharp-win32-arm64": "0.35.0", - "@img/sharp-win32-ia32": "0.35.0", - "@img/sharp-win32-x64": "0.35.0" + "@img/sharp-darwin-arm64": "0.35.4", + "@img/sharp-darwin-x64": "0.35.4", + "@img/sharp-freebsd-wasm32": "0.35.4", + "@img/sharp-libvips-darwin-arm64": "1.3.3", + "@img/sharp-libvips-darwin-x64": "1.3.3", + "@img/sharp-libvips-linux-arm": "1.3.3", + "@img/sharp-libvips-linux-arm64": "1.3.3", + "@img/sharp-libvips-linux-ppc64": "1.3.3", + "@img/sharp-libvips-linux-riscv64": "1.3.3", + "@img/sharp-libvips-linux-s390x": "1.3.3", + "@img/sharp-libvips-linux-x64": "1.3.3", + "@img/sharp-libvips-linuxmusl-arm64": "1.3.3", + "@img/sharp-libvips-linuxmusl-x64": "1.3.3", + "@img/sharp-linux-arm": "0.35.4", + "@img/sharp-linux-arm64": "0.35.4", + "@img/sharp-linux-ppc64": "0.35.4", + "@img/sharp-linux-riscv64": "0.35.4", + "@img/sharp-linux-s390x": "0.35.4", + "@img/sharp-linux-x64": "0.35.4", + "@img/sharp-linuxmusl-arm64": "0.35.4", + "@img/sharp-linuxmusl-x64": "0.35.4", + "@img/sharp-webcontainers-wasm32": "0.35.4", + "@img/sharp-win32-arm64": "0.35.4", + "@img/sharp-win32-ia32": "0.35.4", + "@img/sharp-win32-x64": "0.35.4" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } } }, "node_modules/sharp/node_modules/semver": { diff --git a/package.json b/package.json index a113840b..c985de84 100644 --- a/package.json +++ b/package.json @@ -57,7 +57,7 @@ "lucide-react": "^0.562.0", "react": "^19.2.3", "react-dom": "^19.2.3", - "sharp": "^0.35.0", + "sharp": "^0.35.4", "typescript": "^5.9.3", "uuid": "^14.0.0" }, From 215fe134b028962404a8a230df5ce95828c2a277 Mon Sep 17 00:00:00 2001 From: Sylvain Galliez <128614184+zedarvates@users.noreply.github.com> Date: Sat, 12 Sep 2026 17:17:28 +0200 Subject: [PATCH 086/113] fix(deps): update sharp lockfile to 0.35.4 --- package-lock.json | 293 ++++++++++++++++++++-------------------------- 1 file changed, 125 insertions(+), 168 deletions(-) diff --git a/package-lock.json b/package-lock.json index ba42c13c..f91c2685 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1836,9 +1836,9 @@ } }, "node_modules/@img/sharp-darwin-arm64": { - "version": "0.35.0", - "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.35.0.tgz", - "integrity": "sha512-ZgaYEwaj+lx/5n4W8GmZ2IYz0PQHjN5eqRcfijWGB+2Aq7ZInZGa0qJyAn6DEtyLuWHRSrmWOqT9q3qqTBvmUQ==", + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.35.4.tgz", + "integrity": "sha512-Uhfl4V4lhP2nbUVF9+hyH1+luj86f1gUFeo8ALYxFoULoU+G87D43BfeMP8XHsk9boxAnCY/bf2EHwhA7MuGsA==", "cpu": [ "arm64" ], @@ -1854,13 +1854,13 @@ "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-darwin-arm64": "1.3.0" + "@img/sharp-libvips-darwin-arm64": "1.3.3" } }, "node_modules/@img/sharp-darwin-x64": { - "version": "0.35.0", - "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.35.0.tgz", - "integrity": "sha512-c1z9LFpKB0slQW3RchwBE8iSVzGp70TNjUUO9k4BZwwW4HH7JBGHeIy4b+kk4n/kcBASb9evKCE3/7Slmslgiw==", + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.35.4.tgz", + "integrity": "sha512-hWniXY3bG5qKpkKrAwPe4y+VTPmf086YQAnkxWh7uA1YrlRouWGa0M0Mxj3ZjnXFkv7/TD1bTy9lGUK26vRvWw==", "cpu": [ "x64" ], @@ -1876,20 +1876,20 @@ "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-darwin-x64": "1.3.0" + "@img/sharp-libvips-darwin-x64": "1.3.3" } }, "node_modules/@img/sharp-freebsd-wasm32": { - "version": "0.35.0", - "resolved": "https://registry.npmjs.org/@img/sharp-freebsd-wasm32/-/sharp-freebsd-wasm32-0.35.0.tgz", - "integrity": "sha512-Li2KTev0H90kEtnJHkI9xQojXt1AqWmFBMXiPw5kqd1jQgP7gi5HVK/qC5Rmh/59NuAwUuPzzPITmX22NomYYQ==", + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-freebsd-wasm32/-/sharp-freebsd-wasm32-0.35.4.tgz", + "integrity": "sha512-lIsKw/BU+kjB4eZjxrYrZmwOJYi3Ajrv66iAlBmUPyKc3HpnloevB1g3wxGD9P/5BbQ1brBGl65VRRrCvQDEqA==", "license": "Apache-2.0", "optional": true, "os": [ "freebsd" ], "dependencies": { - "@img/sharp-wasm32": "0.35.0" + "@img/sharp-wasm32": "0.35.4" }, "engines": { "node": ">=20.9.0" @@ -1899,9 +1899,9 @@ } }, "node_modules/@img/sharp-libvips-darwin-arm64": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.3.0.tgz", - "integrity": "sha512-EKbmBKtyTH+GPFDRw2TgK2oV6hyxxlJVIar4hoTYSNmIwipgMFdxPQqR392GmfdsPGWga0mCFN1cCKjRb9cljw==", + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.3.3.tgz", + "integrity": "sha512-suTBPTDGrI9WodccaDdwZItTSaBYASlBk1NSfElSHrUfzu3szG6lvIF58+WiFvnfzuK8ZBFS5zE00PxqxnRiPg==", "cpu": [ "arm64" ], @@ -1915,9 +1915,9 @@ } }, "node_modules/@img/sharp-libvips-darwin-x64": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.3.0.tgz", - "integrity": "sha512-Pl2OmOvrJ42adUllESxBsG54PfXLo1OYg9i3c5/5Ln/qJ0gZuTM9YMhQJPIbXqwidLRc/c2zuHt4RsrymmNv7A==", + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.3.3.tgz", + "integrity": "sha512-FVJZ5mITMobmXIz/hPDTw0EintTW5H3WfrxwLqEqjiIihlu+hVRyGrFQ60xl0Lxn7Bt3zdpevPaQi0HEzqz9fw==", "cpu": [ "x64" ], @@ -1931,15 +1931,12 @@ } }, "node_modules/@img/sharp-libvips-linux-arm": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.3.0.tgz", - "integrity": "sha512-A8UpHoUDW4DwnXoV6+q3C1s7QLRAHtPDEjWuNZjwHMyoCNZnm0GeNN8ls9f/bsEYTRQRW96C/n34XJQHJ2fT7A==", + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.3.3.tgz", + "integrity": "sha512-3rbU4vqXXc3hY/OiXdl52xZvT0F1yEngWfvqudtPJg/KkyiaQw2DRsFrNzpmLvfavbwOq3qXn36GP8obHRULQA==", "cpu": [ "arm" ], - "libc": [ - "glibc" - ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -1950,15 +1947,12 @@ } }, "node_modules/@img/sharp-libvips-linux-arm64": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.3.0.tgz", - "integrity": "sha512-C0SqjoFKnszqa44EQ7xoaT48nnO0lOyXEULfXMWi8krrjOPGYkeK30Okzla6ATbBYsyZ0ySinK0FVkpv3DwzfQ==", + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.3.3.tgz", + "integrity": "sha512-0DaL0A6Xu6sQSQFwe4iVCrKWU2cCTItnRsYsCdxAMm9NF6twAA9BKnoqy4hqz4+azQ0JHuA26qiUKsf1XJ/v5A==", "cpu": [ "arm64" ], - "libc": [ - "glibc" - ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -1969,15 +1963,12 @@ } }, "node_modules/@img/sharp-libvips-linux-ppc64": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.3.0.tgz", - "integrity": "sha512-WOpkVxAjFd369iaIzEgNRreFD+gWdUMIGD5zplhNKNeqS6mm5dac3q2AFyCBmzYoAdouzZvRBgxy4z8QHZb4/A==", + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.3.3.tgz", + "integrity": "sha512-cdn1OvUBwsXhbC0zSzJnNzf5MZ/mTrobawDvNXBTxe8VtqKAm0sRuEY2Evzovb/w9JMk4TvRxqt1mekSuJz64w==", "cpu": [ "ppc64" ], - "libc": [ - "glibc" - ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -1988,15 +1979,12 @@ } }, "node_modules/@img/sharp-libvips-linux-riscv64": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-riscv64/-/sharp-libvips-linux-riscv64-1.3.0.tgz", - "integrity": "sha512-DRWw0mOHusrCCuw2rqP87oLg6PGlkomVDFqw2hIwsSfwWpu4k3XLcBPaKKl6ct/GtL/cwNkgwjV/tc0Mqht3VA==", + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-riscv64/-/sharp-libvips-linux-riscv64-1.3.3.tgz", + "integrity": "sha512-HjPVx7yKz+0lqdhDlTw1tt90wamBoxhiXpvl1XZpJLiHH4RCJ5yDTqH+VlYPv2fwFs89JFw4c1IexYOcQUi4IQ==", "cpu": [ "riscv64" ], - "libc": [ - "glibc" - ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -2007,15 +1995,12 @@ } }, "node_modules/@img/sharp-libvips-linux-s390x": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.3.0.tgz", - "integrity": "sha512-9APy+nFWhHS+kzLgWZfLcyrUd7YqnAQVa4BPOo4xkoHpdoktOAPG4cEr9+Jpl0TtqfVmcMJimNL5qNTyyOHZNA==", + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.3.3.tgz", + "integrity": "sha512-neWLh+3yCNThxnfy3c4BbVBeGgt9aftno+XbT56iK28RgeDs3UOFWviLWlUu0bArYVYJaFDK+RRohbicUNCm8Q==", "cpu": [ "s390x" ], - "libc": [ - "glibc" - ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -2026,15 +2011,12 @@ } }, "node_modules/@img/sharp-libvips-linux-x64": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.3.0.tgz", - "integrity": "sha512-y9RNUYDe2A1UAdhLyfeOodGRszQdaEoe4nfOpp/sNVPl2CWIcUyFaDoCh4vPLPxu19803j2naLqZup2WxDXCLA==", + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.3.3.tgz", + "integrity": "sha512-4vKmvAst9nrowcqquKFAyZJUDolUaIp8uRiN0mWFguJ1IplC9/pitXtlnnlU4aa/eJw3J7i67V+pwUL+wZGdsA==", "cpu": [ "x64" ], - "libc": [ - "glibc" - ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -2045,15 +2027,12 @@ } }, "node_modules/@img/sharp-libvips-linuxmusl-arm64": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.3.0.tgz", - "integrity": "sha512-cC1wkC0Mlucd0KSiGrLkJnB/ZqPvZCntc/Lk7ZnYO5ZSbF2euNek4Xvxafojq+wN1q/W0eprdpUIjUr/EV2PBg==", + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.3.3.tgz", + "integrity": "sha512-Y9kQaLMuNoB0bPYOOdcZMaseNrFpPodIWWMrx+CZyydf2xn68j9WYc6sWWRrDwNkzCQjKYfc68L7jKjGlHMibw==", "cpu": [ "arm64" ], - "libc": [ - "musl" - ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -2064,15 +2043,12 @@ } }, "node_modules/@img/sharp-libvips-linuxmusl-x64": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.3.0.tgz", - "integrity": "sha512-LiYMhUZicB1QG//+RvmYZpXJO8fYRENfp+MZUCnG9aw+AKvGAy9gPaCnuwsPcBFs8EV66M0NNxj9VHcNklE8zw==", + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.3.3.tgz", + "integrity": "sha512-fj8Mv0HHfD1Rr+4I68+3agJynxDWtBFgicTbSOb9Bke6pIwzGcJ+RX/yHjmiEGFMCavY/dxvem7MyNaJF+wDiw==", "cpu": [ "x64" ], - "libc": [ - "musl" - ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -2083,15 +2059,12 @@ } }, "node_modules/@img/sharp-linux-arm": { - "version": "0.35.0", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.35.0.tgz", - "integrity": "sha512-VVlpEWwizEFIOom0zdoeKuO5nuTswzVE5uHcBNvHzmeHUpNFajY3HFfbQ+zIH4E2kVaZ/yVxmsShW56TtEy4uA==", + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.35.4.tgz", + "integrity": "sha512-7OAS8gI0EReKGVN2HssHlM6umJgxF5VI3xN0p9FA91p/YO+ou5hiNghLdZ5BEHztwaaK5+bLKRf8x/o2L2nk9A==", "cpu": [ "arm" ], - "libc": [ - "glibc" - ], "license": "Apache-2.0", "optional": true, "os": [ @@ -2104,19 +2077,16 @@ "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linux-arm": "1.3.0" + "@img/sharp-libvips-linux-arm": "1.3.3" } }, "node_modules/@img/sharp-linux-arm64": { - "version": "0.35.0", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.35.0.tgz", - "integrity": "sha512-4+4XHLNT5wDT0roYlHTEmH9lDKt0acf9Tv+3hM3iceOirkxrR404/3WjAYZ9F9CkHrxeRcGLJXbi4vluMZ9O+A==", + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.35.4.tgz", + "integrity": "sha512-De4jpEnAU8Hd5oT0j1G3uL4ZvTuipVMn7YC6vPaJhy6/7EwEae0SVAoBrUMYQbkLGDm85taVWwuPc1a44LTzCQ==", "cpu": [ "arm64" ], - "libc": [ - "glibc" - ], "license": "Apache-2.0", "optional": true, "os": [ @@ -2129,19 +2099,16 @@ "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linux-arm64": "1.3.0" + "@img/sharp-libvips-linux-arm64": "1.3.3" } }, "node_modules/@img/sharp-linux-ppc64": { - "version": "0.35.0", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.35.0.tgz", - "integrity": "sha512-N3hzbEpUTJC8pWpPVJvgzGxM+so/MAXc8O2s/53B0LL9ZGpfXpME7Wizkc5d/8fRBlBtkDjzoZGDCqqNDHqLEw==", + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.35.4.tgz", + "integrity": "sha512-2oYZJeIl4kCcMGk4ouZVjnkCtFrpQFlNEtJ6GbxzhHQchwH0NH/qEb9ykmOl29dqwMq+JhFdZn+1ak2FKhI9fQ==", "cpu": [ "ppc64" ], - "libc": [ - "glibc" - ], "license": "Apache-2.0", "optional": true, "os": [ @@ -2154,19 +2121,16 @@ "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linux-ppc64": "1.3.0" + "@img/sharp-libvips-linux-ppc64": "1.3.3" } }, "node_modules/@img/sharp-linux-riscv64": { - "version": "0.35.0", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-riscv64/-/sharp-linux-riscv64-0.35.0.tgz", - "integrity": "sha512-l6vmKVPnbS0RhVMbyxP5meAARsbhCnBN4fy31qz0+3a6Rv4jEqfzDrT89y6ZPkCi0AJGnwp2En528yXo401Hpw==", + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-riscv64/-/sharp-linux-riscv64-0.35.4.tgz", + "integrity": "sha512-cPbNChoRURAWdebDIHSenxRpgEdy7JkPydSnUxRm9VvKD7m0/xVaR/8Fzlu81pk5nHEvHH87UZUA7cTtwnbJSA==", "cpu": [ "riscv64" ], - "libc": [ - "glibc" - ], "license": "Apache-2.0", "optional": true, "os": [ @@ -2179,19 +2143,16 @@ "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linux-riscv64": "1.3.0" + "@img/sharp-libvips-linux-riscv64": "1.3.3" } }, "node_modules/@img/sharp-linux-s390x": { - "version": "0.35.0", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.35.0.tgz", - "integrity": "sha512-MYlMiPFiv/EKPAHnp3yNZ9AAWFsxga9c5Bkc6wkar6bqzHLlkGVJHRm0u1ei+VXnZxp3Mz9MG9ZIsI8vSOf3sQ==", + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.35.4.tgz", + "integrity": "sha512-RY0JFY8Fd6RonCBtHz+DvadaPkXDSI1AUn6yWL9TipqkZ1vY8w8evqdgyDFnkm4/K1ve1TvZiaePP5oSd4+WVQ==", "cpu": [ "s390x" ], - "libc": [ - "glibc" - ], "license": "Apache-2.0", "optional": true, "os": [ @@ -2204,19 +2165,16 @@ "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linux-s390x": "1.3.0" + "@img/sharp-libvips-linux-s390x": "1.3.3" } }, "node_modules/@img/sharp-linux-x64": { - "version": "0.35.0", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.35.0.tgz", - "integrity": "sha512-TYaItB5oj1ioXjhyn2xrR208vf+YuIIcHptQWRRaBmFhvIvL9D72DXN8w75xup0KXA8UdEAhQ9Qb2S49FD/9Cw==", + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.35.4.tgz", + "integrity": "sha512-9qvvEAuk8k89TfWUoX2htWjbAMX8p+NxCppjpcg5k6xMsjhBQPTsoIh36h9Qde4WRuGpJeYnOjdosDn/cnv+OA==", "cpu": [ "x64" ], - "libc": [ - "glibc" - ], "license": "Apache-2.0", "optional": true, "os": [ @@ -2229,19 +2187,16 @@ "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linux-x64": "1.3.0" + "@img/sharp-libvips-linux-x64": "1.3.3" } }, "node_modules/@img/sharp-linuxmusl-arm64": { - "version": "0.35.0", - "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.35.0.tgz", - "integrity": "sha512-DSTb6ijQzqe6DdAaOBVqJ/SYf1vO8EW5bK6X6LRXufEBebf2722VCdvBUtZ3rtV0x2ApfPNDy/p7LrrjaWjiyQ==", + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.35.4.tgz", + "integrity": "sha512-KB5jxpfWQTr0nc3xdHtWChdbifHrBGsd2SM62Eyxrl8afikm+f5qGBU75SJIZBT/S1MC8XyacdlXBMSWq6OURA==", "cpu": [ "arm64" ], - "libc": [ - "musl" - ], "license": "Apache-2.0", "optional": true, "os": [ @@ -2254,19 +2209,16 @@ "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linuxmusl-arm64": "1.3.0" + "@img/sharp-libvips-linuxmusl-arm64": "1.3.3" } }, "node_modules/@img/sharp-linuxmusl-x64": { - "version": "0.35.0", - "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.35.0.tgz", - "integrity": "sha512-K7ykQ+26Rt6+4BTU80AuGgTPIYX86UxiAKT4rcXX/WNTo7k1ZxpKz+TguHnwVpCqQK3B5PK0vZ0ZBe6nz/ib1w==", + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.35.4.tgz", + "integrity": "sha512-f+eZJZIQNEEd26RPSW+76chwOf1XtA2Y/O+5ocVyLliHkeih3e+jhLVBdNTd2rS3IbNXK8+ug93Vf5ZXtF5Lxg==", "cpu": [ "x64" ], - "libc": [ - "musl" - ], "license": "Apache-2.0", "optional": true, "os": [ @@ -2279,17 +2231,17 @@ "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linuxmusl-x64": "1.3.0" + "@img/sharp-libvips-linuxmusl-x64": "1.3.3" } }, "node_modules/@img/sharp-wasm32": { - "version": "0.35.0", - "resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.35.0.tgz", - "integrity": "sha512-9woLIFORERCr+6cWu87dQ22J34EExkhc73U1kZW0c+RclQqWetoodByp4dWZ/hN8/KVmTRAx2HOnUwib8AwZdA==", + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.35.4.tgz", + "integrity": "sha512-zQnl4Kwp7Q6NHsENtU2T/00Zi+w3AQNwz3+UaTyVBy2FpXrzXzGjndpK61onhZjRtRpQXxCTeqw19bVyXOh7jA==", "license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT", "optional": true, "dependencies": { - "@emnapi/runtime": "^1.11.0" + "@emnapi/runtime": "^1.11.3" }, "engines": { "node": ">=20.9.0" @@ -2299,16 +2251,16 @@ } }, "node_modules/@img/sharp-webcontainers-wasm32": { - "version": "0.35.0", - "resolved": "https://registry.npmjs.org/@img/sharp-webcontainers-wasm32/-/sharp-webcontainers-wasm32-0.35.0.tgz", - "integrity": "sha512-t+kie1TOyaDM6Dho+f+y0VqIUNhYQaKCUahuZVi0E0frgdiaOaPsDxDW3wfKacUdaNBCnK/ZDBMg33ydvHj8uA==", + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-webcontainers-wasm32/-/sharp-webcontainers-wasm32-0.35.4.tgz", + "integrity": "sha512-ESfNkywmCfPNyaZjxooddJQiQ+l/nTpGEOGthxiLnIHXC/CmcBixnfwUleX9mCz9ovrUUvKMap/pm8RYbzfwaA==", "cpu": [ "wasm32" ], "license": "Apache-2.0", "optional": true, "dependencies": { - "@img/sharp-wasm32": "0.35.0" + "@img/sharp-wasm32": "0.35.4" }, "engines": { "node": ">=20.9.0" @@ -2318,9 +2270,9 @@ } }, "node_modules/@img/sharp-win32-arm64": { - "version": "0.35.0", - "resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.35.0.tgz", - "integrity": "sha512-M5eKxug0dabbaWgFKvPa3odNs2OpaP+81NASfGKkt4GcYXpNhSu7CaeYxWkLNV6vHmUp4hnCxnxrUyhUJhXbKA==", + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.35.4.tgz", + "integrity": "sha512-iNdlBX9gLVvqe2I3uIJSIKTq6wckP/DYxZtcqxm09x5Gi24DnFBmPAWZmr60ZyYMG0xlzo6goG3670ar+RXvRw==", "cpu": [ "arm64" ], @@ -2337,9 +2289,9 @@ } }, "node_modules/@img/sharp-win32-ia32": { - "version": "0.35.0", - "resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.35.0.tgz", - "integrity": "sha512-z0+pZ03QCDvdVN0Ez9IX/yjWC19ikMlXrmdYMwYNLTh2BLPx3hXWPvyqWfquZ0BTO9O6GVOjIVoTcyyacMnWlQ==", + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.35.4.tgz", + "integrity": "sha512-kqRsbaa5CS6KHlpxnN7WhE6vAAugXyZButpRdvDWetlv6Qv4N9WTcrWzF7tXfB9T7MsoadqdI8hmwLq6UlLvtw==", "cpu": [ "ia32" ], @@ -2356,9 +2308,9 @@ } }, "node_modules/@img/sharp-win32-x64": { - "version": "0.35.0", - "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.35.0.tgz", - "integrity": "sha512-feNnlz5ZHKr0MY1LPHvZQyJeBkbo4ctsn0D8FvA53VTw5TC63rfEL2UrWbkSBR19htSE7Mw78xYVwdJqoMWVHw==", + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.35.4.tgz", + "integrity": "sha512-XtmnYhBcrORsJ4XJngyzr/EWP0hRZLAZRFaApdKuviyqF78+ylxh2y06ZmtULAMOnObJ3ucpN0AcwSWnMowTRg==", "cpu": [ "x64" ], @@ -10090,14 +10042,14 @@ } }, "node_modules/sharp": { - "version": "0.35.0", - "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.35.0.tgz", - "integrity": "sha512-BqvG5XbwPZ4NV0DK90d86leEECMsoa8bO0nqnKWlBDYxri4GJ7c4EDInaF6q20lTh/mATmnDIKWJFfXnoVfH5g==", + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.35.4.tgz", + "integrity": "sha512-n++8XWcj+jCOr2IOl7h8LbKnGBDY4aPbmprMONBNFdn0ImXqpGVv5zliDs0V9HbmbCQLpbuo2ej9rAoOQTvMDA==", "license": "Apache-2.0", "dependencies": { "@img/colour": "^1.1.0", "detect-libc": "^2.1.2", - "semver": "^7.8.4" + "semver": "^7.8.5" }, "engines": { "node": ">=20.9.0" @@ -10106,31 +10058,36 @@ "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-darwin-arm64": "0.35.0", - "@img/sharp-darwin-x64": "0.35.0", - "@img/sharp-freebsd-wasm32": "0.35.0", - "@img/sharp-libvips-darwin-arm64": "1.3.0", - "@img/sharp-libvips-darwin-x64": "1.3.0", - "@img/sharp-libvips-linux-arm": "1.3.0", - "@img/sharp-libvips-linux-arm64": "1.3.0", - "@img/sharp-libvips-linux-ppc64": "1.3.0", - "@img/sharp-libvips-linux-riscv64": "1.3.0", - "@img/sharp-libvips-linux-s390x": "1.3.0", - "@img/sharp-libvips-linux-x64": "1.3.0", - "@img/sharp-libvips-linuxmusl-arm64": "1.3.0", - "@img/sharp-libvips-linuxmusl-x64": "1.3.0", - "@img/sharp-linux-arm": "0.35.0", - "@img/sharp-linux-arm64": "0.35.0", - "@img/sharp-linux-ppc64": "0.35.0", - "@img/sharp-linux-riscv64": "0.35.0", - "@img/sharp-linux-s390x": "0.35.0", - "@img/sharp-linux-x64": "0.35.0", - "@img/sharp-linuxmusl-arm64": "0.35.0", - "@img/sharp-linuxmusl-x64": "0.35.0", - "@img/sharp-webcontainers-wasm32": "0.35.0", - "@img/sharp-win32-arm64": "0.35.0", - "@img/sharp-win32-ia32": "0.35.0", - "@img/sharp-win32-x64": "0.35.0" + "@img/sharp-darwin-arm64": "0.35.4", + "@img/sharp-darwin-x64": "0.35.4", + "@img/sharp-freebsd-wasm32": "0.35.4", + "@img/sharp-libvips-darwin-arm64": "1.3.3", + "@img/sharp-libvips-darwin-x64": "1.3.3", + "@img/sharp-libvips-linux-arm": "1.3.3", + "@img/sharp-libvips-linux-arm64": "1.3.3", + "@img/sharp-libvips-linux-ppc64": "1.3.3", + "@img/sharp-libvips-linux-riscv64": "1.3.3", + "@img/sharp-libvips-linux-s390x": "1.3.3", + "@img/sharp-libvips-linux-x64": "1.3.3", + "@img/sharp-libvips-linuxmusl-arm64": "1.3.3", + "@img/sharp-libvips-linuxmusl-x64": "1.3.3", + "@img/sharp-linux-arm": "0.35.4", + "@img/sharp-linux-arm64": "0.35.4", + "@img/sharp-linux-ppc64": "0.35.4", + "@img/sharp-linux-riscv64": "0.35.4", + "@img/sharp-linux-s390x": "0.35.4", + "@img/sharp-linux-x64": "0.35.4", + "@img/sharp-linuxmusl-arm64": "0.35.4", + "@img/sharp-linuxmusl-x64": "0.35.4", + "@img/sharp-webcontainers-wasm32": "0.35.4", + "@img/sharp-win32-arm64": "0.35.4", + "@img/sharp-win32-ia32": "0.35.4", + "@img/sharp-win32-x64": "0.35.4" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } } }, "node_modules/sharp/node_modules/semver": { From 739b4c55657b82f866884027710abe5f49c394ea Mon Sep 17 00:00:00 2001 From: Sylvain Galliez <128614184+zedarvates@users.noreply.github.com> Date: Sat, 12 Sep 2026 19:39:55 +0200 Subject: [PATCH 087/113] fix(deps): keep security updates on aligned Vitest 4.1.11 --- creative-studio-ui/package-lock.json | 229 +++++++++++++------- creative-studio-ui/package.json | 6 +- docs/security/PR55_DEPENDENCY_VALIDATION.md | 76 +++++++ 3 files changed, 224 insertions(+), 87 deletions(-) create mode 100644 docs/security/PR55_DEPENDENCY_VALIDATION.md diff --git a/creative-studio-ui/package-lock.json b/creative-studio-ui/package-lock.json index c3170529..ffacab4e 100644 --- a/creative-studio-ui/package-lock.json +++ b/creative-studio-ui/package-lock.json @@ -71,8 +71,8 @@ "@types/react-dom": "^19.2.3", "@vitejs/plugin-react": "^4.7.0", "@vitejs/plugin-react-swc": "^4.2.2", - "@vitest/coverage-v8": "^4.1.8", - "@vitest/ui": "^4.1.8", + "@vitest/coverage-v8": "^4.1.11", + "@vitest/ui": "^4.1.11", "autoprefixer": "^10.4.20", "axe-core": "^4.11.1", "electron": "^41.10.3", @@ -91,7 +91,7 @@ "typescript": "~5.9.3", "typescript-eslint": "^8.69.0", "vite": "^7.3.6", - "vitest": "^5.0.0" + "vitest": "^4.1.11" } }, "node_modules/@acemir/cssom": { @@ -1588,9 +1588,9 @@ } }, "node_modules/@jridgewell/sourcemap-codec": { - "version": "1.6.0", - "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.6.0.tgz", - "integrity": "sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw==", + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", "license": "MIT" }, "node_modules/@jridgewell/trace-mapping": { @@ -5255,14 +5255,14 @@ "license": "MIT" }, "node_modules/@vitest/coverage-v8": { - "version": "4.1.8", - "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-4.1.8.tgz", - "integrity": "sha512-lt3kovsyHwYe00wq4D1ti0Z974fWj4NLp6siqiyEufUpyFwK9Yhi7rBhac9JL5aA0zoMrJqc4vYPZRUnI7l7nw==", + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-4.1.11.tgz", + "integrity": "sha512-8MVGEFnJIcdGjcbfKmeq8z0pZHH0JlVtoVZH9Q/qwUp6wyFnEJUBMrw9DCaj+ra3vShGmhavjalMIhPNxZAUcw==", "dev": true, "license": "MIT", "dependencies": { "@bcoe/v8-coverage": "^1.0.2", - "@vitest/utils": "4.1.8", + "@vitest/utils": "4.1.11", "ast-v8-to-istanbul": "^1.0.0", "istanbul-lib-coverage": "^3.2.2", "istanbul-lib-report": "^3.0.1", @@ -5276,8 +5276,8 @@ "url": "https://opencollective.com/vitest" }, "peerDependencies": { - "@vitest/browser": "4.1.8", - "vitest": "4.1.8" + "@vitest/browser": "4.1.11", + "vitest": "4.1.11" }, "peerDependenciesMeta": { "@vitest/browser": { @@ -5345,16 +5345,15 @@ } }, "node_modules/@vitest/mocker": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-5.0.0.tgz", - "integrity": "sha512-66PGTMIiVJP3t4a5yxU9qPtf7MdTBs8jmToMvy+HVflB3Yy13WJZTtPePdvU+wjRV02SKK5doLbSA6o9pwOmiA==", + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.11.tgz", + "integrity": "sha512-2XJVD55d1o5AZous5CCGKS74g/riOj9odEt2bQpCVZeblHyHdnMeFl4jl0XjU21stf4mbjUkew2eXQZt65g5CQ==", "dev": true, "license": "MIT", "dependencies": { - "@jridgewell/trace-mapping": "0.3.31", - "@vitest/spy": "5.0.0", + "@vitest/spy": "4.1.11", "estree-walker": "^3.0.3", - "magic-string": "^1.2.3" + "magic-string": "^0.30.21" }, "funding": { "url": "https://opencollective.com/vitest" @@ -5373,9 +5372,9 @@ } }, "node_modules/@vitest/mocker/node_modules/@vitest/spy": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-5.0.0.tgz", - "integrity": "sha512-uy+luWBAPw9XfthoHi5AkfHUnuPYEESjl0p/r+meoBnU8bxg5GDQ3Ey8MjcJ6sqahkL4PFyrvfMJJBw7LbU06g==", + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.11.tgz", + "integrity": "sha512-apNa/prQy2qCeywhnixOHPRCgGNhvg7T4Dapfl1GahLp/R+uhBm5cPyFoNVyqsNd2h1nJxL6BqqdIjiABL60YA==", "dev": true, "license": "MIT", "funding": { @@ -5383,9 +5382,9 @@ } }, "node_modules/@vitest/pretty-format": { - "version": "4.1.8", - "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.8.tgz", - "integrity": "sha512-9GasEBxpZ1VYIpqHf/0+YGg121uSNwCKOJqIrTwWP/TB7DmFCiaBpNl3aPZzoLWfWkuqhbH8vJIVobZkvdo2cA==", + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.11.tgz", + "integrity": "sha512-yiZzPbGTS9Sr/JpFl8zHrcIkAofNbFV6k21vIgQN/cY/oxZeXhJv5sc/MBJ5jFKWmWs+oJHw0UXLZjmf931+Vw==", "dev": true, "license": "MIT", "dependencies": { @@ -5395,6 +5394,36 @@ "url": "https://opencollective.com/vitest" } }, + "node_modules/@vitest/runner": { + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.11.tgz", + "integrity": "sha512-LztvUgdwMNJMIkj3hQnnxiC2Xy1zNxq928W/xhjCLaNCzqTZOudjwbQf6v9IntZGPw132i2Lq2rgTRZHD3JHNw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "4.1.11", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.11.tgz", + "integrity": "sha512-pN7ikn1ON7h8ee4gIAp4AzyK+zBtJPzVbqOgu5LCEh4VaJVbPQcgYQYJIMGQPXVeJJq1fnfazis7a5pFNPahog==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.11", + "@vitest/utils": "4.1.11", + "magic-string": "^0.30.21", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, "node_modules/@vitest/spy": { "version": "3.2.4", "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-3.2.4.tgz", @@ -5410,13 +5439,13 @@ } }, "node_modules/@vitest/ui": { - "version": "4.1.8", - "resolved": "https://registry.npmjs.org/@vitest/ui/-/ui-4.1.8.tgz", - "integrity": "sha512-RUS2ZU2TsduVrI+9c12uTNaKrNUTsm6yFt3fueEUB9iKvyC2UP83F+sqIz00HQIah4UOL1TMoDAki8K0NjGvsA==", + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@vitest/ui/-/ui-4.1.11.tgz", + "integrity": "sha512-r/rwyKoev21mWdRGSEkZOqkQ2BYy68mwjihg9M90nNRbf4NGrgzZ4cj6JNCEwlOGJkbKeMgsjlykvwKUbRr7gw==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/utils": "4.1.8", + "@vitest/utils": "4.1.11", "fflate": "^0.8.2", "flatted": "^3.4.2", "pathe": "^2.0.3", @@ -5428,17 +5457,17 @@ "url": "https://opencollective.com/vitest" }, "peerDependencies": { - "vitest": "4.1.8" + "vitest": "4.1.11" } }, "node_modules/@vitest/utils": { - "version": "4.1.8", - "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.8.tgz", - "integrity": "sha512-uOJamYALNhfJ6iolExyQM40yIQwDqYnkKtQ5VCiSe17E33H0aQ/u+1GlRuz4LZBk6Mm3sg90G9hEbmEt37C1Zg==", + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.11.tgz", + "integrity": "sha512-zTCVGpyFsGWBhllOyKlTw/vnr6D9qxsfSDyfbyZmTyjHw5N/VuvzHpHoQjm2ZJzn4RJgx5w4r7V0er69CmLgPQ==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/pretty-format": "4.1.8", + "@vitest/pretty-format": "4.1.11", "convert-source-map": "^2.0.0", "tinyrainbow": "^3.1.0" }, @@ -8364,13 +8393,13 @@ } }, "node_modules/magic-string": { - "version": "1.3.1", - "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-1.3.1.tgz", - "integrity": "sha512-rm91zr2Ou+XueDTohjQQjdQEcYM6zVi8KVUCG8Ec3vHwUEKrhSdCNyfuIywkA6hcCAteIn0ZOtAHA6eGpiX+Pg==", + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", "dev": true, "license": "MIT", "dependencies": { - "@jridgewell/sourcemap-codec": "^1.6.0" + "@jridgewell/sourcemap-codec": "^1.5.5" } }, "node_modules/magicast": { @@ -9576,9 +9605,9 @@ "license": "ISC" }, "node_modules/picomatch": { - "version": "4.0.7", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.7.tgz", - "integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==", + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", "dev": true, "license": "MIT", "engines": { @@ -11278,19 +11307,16 @@ "peer": true }, "node_modules/tinybench": { - "version": "6.1.4", - "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-6.1.4.tgz", - "integrity": "sha512-9APumHG7r4yOk4X4WlkmE71aZcv1gvin1czO3OQ1U9iJcFA5Ja/ygyb0vPOVHTthFozUYs8CLoLUlM8grb2lTQ==", + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", "dev": true, - "license": "MIT", - "engines": { - "node": ">=20.0.0" - } + "license": "MIT" }, "node_modules/tinyexec": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.3.0.tgz", - "integrity": "sha512-QKAl9m8gWWGHV8jZcPeym6j+XULi6tOf1mT83WYJ4Lk2ytW/uwAWkrP0uFsdoYMdueVJ0qs26wZ+23xeB4ibNQ==", + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.3.1.tgz", + "integrity": "sha512-GCvB3aoys96IuDFBMcTB46JOR6mdMtAToqwiW8JlWhsoh1mhHi/xn9ss/Dg7N555GiJyEt2qzoG/NHCwM6h1EA==", "dev": true, "license": "MIT", "engines": { @@ -11298,14 +11324,14 @@ } }, "node_modules/tinyglobby": { - "version": "0.2.17", - "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", - "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "version": "0.2.15", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", + "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==", "dev": true, "license": "MIT", "dependencies": { "fdir": "^6.5.0", - "picomatch": "^4.0.4" + "picomatch": "^4.0.3" }, "engines": { "node": ">=12.0.0" @@ -11315,9 +11341,9 @@ } }, "node_modules/tinyrainbow": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.1.0.tgz", - "integrity": "sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==", + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.1.1.tgz", + "integrity": "sha512-yau8yJdTt989Mm0Bd/236QnzEiPf2xLLTqUZRUJOo/3CB078LSwzei343DgtJVmfJKJE3TMINY1u42SQsP6mXw==", "dev": true, "license": "MIT", "engines": { @@ -11909,31 +11935,38 @@ } }, "node_modules/vitest": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/vitest/-/vitest-5.0.0.tgz", - "integrity": "sha512-gpsMNoRhMjMktVxPtstOH4/PJuPyovVaMDr4oDilXaGH1EcqM2OE96SoHT2VIQ6fTGtTjqmHDrEu2X9RQiXf8Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/chai": "^5.2.2", - "@vitest/mocker": "5.0.0", - "chai": "^6.2.2", - "es-module-lexer": "^2.3.2", - "expect-type": "^1.4.0", - "magic-string": "^1.2.3", - "obug": "^2.1.4", - "picomatch": "^4.0.7", - "std-env": "^4.2.0", - "tinybench": "6.1.4", - "tinyexec": "1.3.0", - "tinyglobby": "^0.2.17", + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.11.tgz", + "integrity": "sha512-fhACrNXUidIbGSBr5FlbuBkO7VWC1ZyLl0DO4CU2DrQoAPxX84Ysxs+HeGQpii5lZWV1Q4gBZTTu49mF+A6Edw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/expect": "4.1.11", + "@vitest/mocker": "4.1.11", + "@vitest/pretty-format": "4.1.11", + "@vitest/runner": "4.1.11", + "@vitest/snapshot": "4.1.11", + "@vitest/spy": "4.1.11", + "@vitest/utils": "4.1.11", + "es-module-lexer": "^2.0.0", + "expect-type": "^1.3.0", + "magic-string": "^0.30.21", + "obug": "^2.1.1", + "pathe": "^2.0.3", + "picomatch": "^4.0.3", + "std-env": "^4.0.0-rc.1", + "tinybench": "^2.9.0", + "tinyexec": "^1.0.2", + "tinyglobby": "^0.2.15", + "tinyrainbow": "^3.1.0", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0", "why-is-node-running": "^2.3.0" }, "bin": { "vitest": "vitest.mjs" }, "engines": { - "node": "^22.12.0 || ^24.0.0 || >=26.0.0" + "node": "^20.0.0 || ^22.0.0 || >=24.0.0" }, "funding": { "url": "https://opencollective.com/vitest" @@ -11941,16 +11974,16 @@ "peerDependencies": { "@edge-runtime/vm": "*", "@opentelemetry/api": "^1.9.0", - "@types/node": "^22.0.0 || >=24.0.0", - "@vitest/browser-playwright": "5.0.0", - "@vitest/browser-preview": "5.0.0", - "@vitest/browser-webdriverio": "^5.0.0-beta.5 || >=5.0.0", - "@vitest/coverage-istanbul": "5.0.0", - "@vitest/coverage-v8": "5.0.0", - "@vitest/ui": "5.0.0", + "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", + "@vitest/browser-playwright": "4.1.11", + "@vitest/browser-preview": "4.1.11", + "@vitest/browser-webdriverio": "4.1.11", + "@vitest/coverage-istanbul": "4.1.11", + "@vitest/coverage-v8": "4.1.11", + "@vitest/ui": "4.1.11", "happy-dom": "*", "jsdom": "*", - "vite": "^6.4.0 || ^7.0.0 || ^8.0.0" + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" }, "peerDependenciesMeta": { "@edge-runtime/vm": { @@ -11991,6 +12024,34 @@ } } }, + "node_modules/vitest/node_modules/@vitest/expect": { + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.11.tgz", + "integrity": "sha512-VX2x5vNJXET47KAFzwERI+KRMtTTCSWTfSMKsW7JsUsXV4psq++e3DvZpuTDOpHcxytiDs6p2nhVb2tVDiiUYw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.1.0", + "@types/chai": "^5.2.2", + "@vitest/spy": "4.1.11", + "@vitest/utils": "4.1.11", + "chai": "^6.2.2", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/vitest/node_modules/@vitest/spy": { + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.11.tgz", + "integrity": "sha512-apNa/prQy2qCeywhnixOHPRCgGNhvg7T4Dapfl1GahLp/R+uhBm5cPyFoNVyqsNd2h1nJxL6BqqdIjiABL60YA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://opencollective.com/vitest" + } + }, "node_modules/vitest/node_modules/chai": { "version": "6.2.2", "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz", diff --git a/creative-studio-ui/package.json b/creative-studio-ui/package.json index ecf6a462..33585e83 100644 --- a/creative-studio-ui/package.json +++ b/creative-studio-ui/package.json @@ -91,8 +91,8 @@ "@types/react-dom": "^19.2.3", "@vitejs/plugin-react": "^4.7.0", "@vitejs/plugin-react-swc": "^4.2.2", - "@vitest/coverage-v8": "^4.1.8", - "@vitest/ui": "^4.1.8", + "@vitest/coverage-v8": "^4.1.11", + "@vitest/ui": "^4.1.11", "autoprefixer": "^10.4.20", "axe-core": "^4.11.1", "electron": "^41.10.3", @@ -111,6 +111,6 @@ "typescript": "~5.9.3", "typescript-eslint": "^8.69.0", "vite": "^7.3.6", - "vitest": "^5.0.0" + "vitest": "^4.1.11" } } diff --git a/docs/security/PR55_DEPENDENCY_VALIDATION.md b/docs/security/PR55_DEPENDENCY_VALIDATION.md new file mode 100644 index 00000000..ad5f2368 --- /dev/null +++ b/docs/security/PR55_DEPENDENCY_VALIDATION.md @@ -0,0 +1,76 @@ +# PR #55 dependency validation — 2026-09-12 + +This report records a security dependency correction and its validation limits. It does not authorize or claim a merge. + +## Source and scope + +- Repository: `zedarvates/StoryCore-Engine`. +- Compared base: `5b6c83bd26f60f7eb5e4da53f958f2d3b06edb4a`. +- Original Dependabot PR head: `0046d9328a8adc48d2a401685786bf2d9f5901c7`. +- Full isolated checkouts were used. Application source, test source and configuration are unchanged. +- Keep sharp **0.35.4** and Joi **18.2.9** from Dependabot, with their original root/config manifests and locks byte-for-byte unchanged. +- Change the UI's `vitest`, `@vitest/ui` and `@vitest/coverage-v8` ranges to **^4.1.11**, locked at **4.1.11**. npm also resolves the associated Vitest modules and transitive dependencies. +- Vitest 5 is deferred. Its partially upgraded graph required incompatible 4.x/5.x peers, and the existing nested mock in `AssetPanel.test.tsx` needs migration before v5. + +The [Vitest 4.1.11 release](https://github.com/vitest-dev/vitest/releases/tag/v4.1.11) includes the redirect-mock allowlist security fix. + +## Executed evidence + +Environment: **Linux x64, Node.js 24.19.0, npm 11.9.0**. These are local checkout results, not GitHub Actions or homelab results. + +| Check | Result | +| --- | --- | +| `npm ci --ignore-scripts --no-audit --no-fund`, root | PASS; 751 packages installed | +| Same installation, `config/` | PASS; 547 packages installed | +| Standard UI resolution, base and candidate: `npm ci --dry-run --ignore-scripts --no-audit --no-fund` | BLOCKED by the same pre-existing ESLint peer conflict | +| UI diagnostic installation, base and candidate: `npm ci --ignore-scripts --no-audit --no-fund --force` | Completed; 760 packages installed in each. Not a passing standard installation | +| npm Arborist virtual-tree peer check | Candidate and base have the same one invalid present peer: React Hooks ESLint plugin versus ESLint. Candidate has no invalid Vitest peer | +| `node addons/content_sensitivity/tests/test_censorship.js` | 11 passed, including real Sharp pixelation of a synthetic image | +| Native image smoke | Actual loaded versions: sharp 0.35.4, libvips 8.18.6, libheif 1.23.2; AVIF decode, resize and PNG encode passed | +| Joi and wait-on smoke, root and config | Joi 18.2.9 loaded; flat `__proto__` message code rejected; custom language input leaves global Object.prototype unchanged; wait-on detects a local HTTP fixture | +| UI `npm test -- --maxWorkers=2 --bail=1` | 108 passed, then one failure; stopped early, so the full suite is not validated | +| Base reproduction with Vitest 4.1.8 | Same failure and same 108 preceding successes in `RelationshipManager.test.ts` | +| Targeted `AssetPanel.test.tsx`, Vitest 4.1.11 | 6/6 passed; the nested mock produces a warning | +| Same AssetPanel tests with V8 coverage | 6/6 passed; coverage provider 4.1.11 works. For AssetPanel.tsx only: 95/174 lines, 54.59%. This is not repository-wide coverage | +| UI `npm run build:check` | BLOCKED at TypeScript; Vite build was not reached | +| TypeScript comparison with matching root/UI installation context | Base and candidate each report 2,147 diagnostic lines. After checkout-path normalization, their diagnostic multisets are identical | + +The repeated test failure is `ReferenceError: removeRelationship is not defined` at `creative-studio-ui/src/services/__tests__/RelationshipManager.test.ts:2016`. Its import is named `_removeRelationship`. The source is unchanged in this correction. + +For the TypeScript comparison, the base was rebuilt with `node node_modules/typescript/bin/tsc -b --force` after installing its root dependencies. Comparing against a base without root dependencies gives misleading differences due to missing Jest and Ant Design types; that preliminary comparison is not used as evidence. + +## Security audit before/after + +`npm audit --json --package-lock-only --ignore-scripts` was executed for all three lockfiles in the base and candidate. + +| Dependency tree | Base vulnerable package entries | Candidate entries | +| --- | ---: | ---: | +| Root | 2: sharp and Joi | 0 | +| config | 1: Joi | 0 | +| creative-studio-ui | 4: Vitest, mocker, UI and coverage | 0 | + +These are package entries per lockfile, not distinct vulnerability counts. Zero means no vulnerability reported by the npm advisory service at the time of this run. + +The reports cover: +- [sharp / libheif](https://github.com/advisories/GHSA-rgj7-g3m4-5g8c). +- [Joi recursive-link RangeError](https://github.com/advisories/GHSA-q7cg-457f-vx79), [custom-message prototype pollution](https://github.com/advisories/GHSA-6w3j-5fw6-r9vr), and [template rename prototype changes](https://github.com/advisories/GHSA-gg4h-3hg2-grpc). Joi 18.2.9 also includes the [additional flat-message-code fix](https://github.com/hapijs/joi/pull/3151). +- [Vitest redirect-mock arbitrary file read](https://github.com/advisories/GHSA-82fw-gwwq-j7x9). + +The private Dependabot alert #461 details and alert closure state were not verified. Candidate audit results do not imply alerts on the unchanged default branch have closed. + +## Remaining boundaries + +1. `eslint-plugin-react-hooks@7.0.1` accepts ESLint through 9, but the existing UI pins ESLint 10.9.1. This blocks normal npm resolution in both base and candidate. `--force` was used only for isolated lock generation and diagnostic installs; no persistent npm bypass or CI relaxation is added. +2. The full UI test suite and TypeScript/build gate remain unsuccessful. The reproduced failures do not originate in this correction. +3. Lifecycle scripts were not executed. Electron packaging, Windows and the actual homelab were not tested. +4. The original head had only SonarCloud Code Analysis. The existing Harbour workflow's path filter does not cover this dependency slice. No relevant GitHub Actions success is claimed. + +## Tested input fingerprints + +SHA-256 values bind the executable checks to the two modified UI dependency files; this report itself does not affect their contents. + +| File | SHA-256 | +| --- | --- | +| `creative-studio-ui/package.json` | `c36937b3ef143546973e73970c1c5161f2c1197553ab1b58ad542ab7ad09379a` | +| `creative-studio-ui/package-lock.json` | `32acf5282507fc87b69afac3b6061c307e795ed2fe8080d78586f7dc4bf732a3` | + From 8e0c66284477c877288efb67ddc31cc2a6de82a0 Mon Sep 17 00:00:00 2001 From: Sylvain Galliez <128614184+zedarvates@users.noreply.github.com> Date: Sun, 13 Sep 2026 11:35:14 +0200 Subject: [PATCH 088/113] docs(audio): define provider-neutral music planning layer --- docs/music-planning-layer-v1.md | 167 ++++++++++++++++++++++++++++++++ 1 file changed, 167 insertions(+) create mode 100644 docs/music-planning-layer-v1.md diff --git a/docs/music-planning-layer-v1.md b/docs/music-planning-layer-v1.md new file mode 100644 index 00000000..46ecab87 --- /dev/null +++ b/docs/music-planning-layer-v1.md @@ -0,0 +1,167 @@ +# StoryCore Music Planning Layer v1 + +Status: **draft contract; provider-agnostic; non-generating by itself**. + +## Goal + +Insert an editable symbolic planning layer between narrative intent and any music +generator. StoryCore should be able to reason about, compare, revise and version +a score plan without coupling the project to one model or one licence regime. + +```text +story / scene / shot intent + | + v +music brief + | + v +MusicPlan v1 <---- human/agent edits and validation + | + +----> provider adapter A + +----> provider adapter B + +----> DAW / MIDI-oriented export later + | + v +rendered audio candidate + | + v +technical + narrative + licence validation + | + v +last-known-good audio artefact +``` + +The design is inspired by open-source music workflows that expose an editable +intermediate representation, but this contract is StoryCore-owned and must not +require or redistribute third-party model weights. + +## Why the intermediate plan matters + +A direct `prompt -> wav` path throws away useful structure. `MusicPlan` keeps the +intent that can be inspected before expensive generation: + +- sections and their narrative purpose; +- tempo and metre; +- tonal centre / mode when known; +- motifs and motif reuse; +- instrumentation roles rather than provider-specific tokens; +- energy and tension curves; +- dialogue-safe density constraints; +- scene/shot synchronization cues; +- optional lyric blocks; +- provenance and licence information for every external dependency. + +A renderer may ignore unsupported optional fields, but it must report that as an +execution delta rather than silently pretending the full plan was honoured. + +## Three execution modes + +### `full` + +The symbolic plan is authoritative enough to be edited and compared before +rendering. Use when continuity, leitmotifs, timing or reproducibility matter. + +### `guided` + +StoryCore fixes high-level structure and synchronization while the selected +provider is free to elaborate harmony, accompaniment or sound design. + +### `free` + +Only the narrative music brief is binding. This keeps a direct-generation +baseline available for comparison. It must not be labelled equivalent to a +`full` render. + +## Provider boundary + +Provider adapters translate `MusicPlan` into provider-specific inputs and return +an execution report containing at least: + +- requested mode; +- executed mode; +- unsupported/dropped fields; +- model/provider identifier and version when available; +- model-weights licence and code licence separately; +- deterministic parameters or seed when supported; +- input and output artefact digests; +- runtime/hardware observations when measured; +- validation evidence references. + +The provider adapter cannot promote its own output to last-known-good. Promotion +belongs to a separate validation/harness step. + +## Commercial-use boundary + +Permissively licensed code does not make separately licensed model weights +commercially usable. An adapter may exist for research/evaluation while its +weights remain forbidden in a commercial path. + +For StoryCore/Obolune-facing production, fail closed when: + +- the weights licence is unknown; +- the licence is non-commercial and the requested path is commercial; +- output terms prevent the intended distribution; +- attribution/provenance requirements cannot be satisfied. + +This specifically means that a useful open-source architecture may be studied or +adapted without making its restricted weights a production dependency. + +## Validation order + +1. JSON/schema and reference integrity. +2. Licence/provenance gate. +3. Plan-level checks: duration, section ordering, cue references and bounded + values. +4. Adapter execution with explicit requested/executed delta. +5. Technical audio checks. +6. Narrative checks: cue timing, dialogue masking, motif/scene consistency. +7. Optional human review. +8. Promotion to last-known-good only with evidence. + +A failure keeps the candidate and diagnostics for comparison but leaves the +previous verified artefact intact. + +## Benchmark contract + +Do not compare only `model A` versus `model B`. Record the complete path: + +`story fixture x model/provider x adapter/harness x hardware x parameters` + +Minimum measures should include: + +- successful render rate; +- plan fields honoured / dropped; +- latency and peak resource use when observable; +- duration/cue alignment error; +- narrative evaluator result; +- licence eligibility for the target use; +- human preference only when the comparison protocol records it explicitly. + +A cheaper or smaller generator can therefore win when its harness better obeys +the plan. + +## First implementation slice + +1. Land the provider-neutral JSON schema. +2. Create three tiny synthetic fixtures: `full`, `guided`, `free`. +3. Add a validator that never invokes a music model. +4. Add one adapter interface with a mock provider first. +5. Only then evaluate external music backends. +6. Keep all external-model activation opt-in until real comparison evidence + exists. + +## Relationship to the Botte Secrète Execution Harness + +When StoryCore runs under Botte Secrète, map: + +- `MusicPlan` + narrative references -> context snapshot; +- provider capabilities -> capabilities; +- licence, VRAM, duration and budget -> constraints; +- provider execution report -> requested/executed delta; +- generated stems/mix -> candidate artefacts; +- validators -> evidence; +- previous accepted soundtrack -> recovery point; +- benchmark observations -> Capability Atlas. + +StoryCore must remain usable without Botte; the interchange should stay a small +JSON/data contract rather than importing Botte as a hard runtime dependency. From ef37f795d7eac277a6bcb583f664b413d1a693c2 Mon Sep 17 00:00:00 2001 From: Sylvain Galliez <128614184+zedarvates@users.noreply.github.com> Date: Sun, 13 Sep 2026 11:35:31 +0200 Subject: [PATCH 089/113] feat(audio): add MusicPlan v1 interchange schema --- schemas/music-plan-v1.schema.json | 121 ++++++++++++++++++++++++++++++ 1 file changed, 121 insertions(+) create mode 100644 schemas/music-plan-v1.schema.json diff --git a/schemas/music-plan-v1.schema.json b/schemas/music-plan-v1.schema.json new file mode 100644 index 00000000..e73d000d --- /dev/null +++ b/schemas/music-plan-v1.schema.json @@ -0,0 +1,121 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://storycore.local/schemas/music-plan-v1.schema.json", + "title": "StoryCore MusicPlan v1", + "type": "object", + "additionalProperties": false, + "required": [ + "schema_version", + "plan_id", + "mode", + "duration_seconds", + "sections", + "sync_cues", + "provenance" + ], + "properties": { + "schema_version": {"const": 1}, + "plan_id": {"type": "string", "minLength": 1, "maxLength": 128}, + "mode": {"enum": ["full", "guided", "free"]}, + "title": {"type": "string", "maxLength": 256}, + "narrative_intent": {"type": "string", "maxLength": 4000}, + "duration_seconds": {"type": "number", "exclusiveMinimum": 0, "maximum": 7200}, + "tempo_bpm": {"type": ["number", "null"], "minimum": 20, "maximum": 300}, + "meter": {"type": ["string", "null"], "maxLength": 32}, + "tonal_center": {"type": ["string", "null"], "maxLength": 64}, + "sections": { + "type": "array", + "minItems": 1, + "maxItems": 128, + "items": { + "type": "object", + "additionalProperties": false, + "required": ["id", "start_seconds", "end_seconds", "purpose"], + "properties": { + "id": {"type": "string", "minLength": 1, "maxLength": 128}, + "start_seconds": {"type": "number", "minimum": 0}, + "end_seconds": {"type": "number", "exclusiveMinimum": 0}, + "purpose": {"type": "string", "minLength": 1, "maxLength": 1000}, + "energy": {"type": ["number", "null"], "minimum": 0, "maximum": 1}, + "tension": {"type": ["number", "null"], "minimum": 0, "maximum": 1}, + "dialogue_safe": {"type": "boolean", "default": false}, + "motif_refs": { + "type": "array", + "items": {"type": "string", "minLength": 1, "maxLength": 128}, + "uniqueItems": true, + "default": [] + }, + "instrument_roles": { + "type": "array", + "items": {"type": "string", "minLength": 1, "maxLength": 128}, + "uniqueItems": true, + "default": [] + } + } + } + }, + "motifs": { + "type": "array", + "maxItems": 64, + "items": { + "type": "object", + "additionalProperties": false, + "required": ["id", "intent"], + "properties": { + "id": {"type": "string", "minLength": 1, "maxLength": 128}, + "intent": {"type": "string", "minLength": 1, "maxLength": 1000}, + "symbolic_hint": {"type": ["string", "null"], "maxLength": 2000} + } + }, + "default": [] + }, + "sync_cues": { + "type": "array", + "maxItems": 256, + "items": { + "type": "object", + "additionalProperties": false, + "required": ["id", "time_seconds", "intent"], + "properties": { + "id": {"type": "string", "minLength": 1, "maxLength": 128}, + "time_seconds": {"type": "number", "minimum": 0}, + "intent": {"type": "string", "minLength": 1, "maxLength": 1000}, + "story_ref": {"type": ["string", "null"], "maxLength": 512} + } + } + }, + "lyrics": { + "type": ["object", "null"], + "additionalProperties": false, + "required": ["language", "text"], + "properties": { + "language": {"type": "string", "minLength": 2, "maxLength": 35}, + "text": {"type": "string", "maxLength": 20000} + } + }, + "provenance": { + "type": "object", + "additionalProperties": false, + "required": ["commercial_target", "dependencies"], + "properties": { + "commercial_target": {"type": "boolean"}, + "dependencies": { + "type": "array", + "maxItems": 128, + "items": { + "type": "object", + "additionalProperties": false, + "required": ["name", "kind", "license"], + "properties": { + "name": {"type": "string", "minLength": 1, "maxLength": 256}, + "kind": {"enum": ["code", "model-weights", "dataset", "asset", "other"]}, + "license": {"type": "string", "minLength": 1, "maxLength": 256}, + "version": {"type": ["string", "null"], "maxLength": 128}, + "source": {"type": ["string", "null"], "maxLength": 1000} + } + } + } + } + } + } +} From c026a4ee9657a9a914091a7b0fc27df1bd645306 Mon Sep 17 00:00:00 2001 From: Sylvain Galliez <128614184+zedarvates@users.noreply.github.com> Date: Sun, 13 Sep 2026 11:59:25 +0200 Subject: [PATCH 090/113] feat(audio): add model-free MusicPlan invariant validator --- src/music_plan.py | 164 ++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 164 insertions(+) create mode 100644 src/music_plan.py diff --git a/src/music_plan.py b/src/music_plan.py new file mode 100644 index 00000000..e832c081 --- /dev/null +++ b/src/music_plan.py @@ -0,0 +1,164 @@ +"""Provider-neutral MusicPlan v1 validation helpers. + +This module is deliberately model-free and standard-library only. It validates +cross-field invariants that JSON Schema alone cannot express and enforces the +commercial licence boundary before a provider adapter is selected. +""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import Any + +NON_COMMERCIAL_MARKERS = ("CC BY-NC", "BY-NC", "NON-COMMERCIAL", "NONCOMMERCIAL") + + +@dataclass(frozen=True) +class MusicPlanValidation: + valid: bool + errors: tuple[str, ...] + warnings: tuple[str, ...] = () + + +def _is_non_commercial(license_name: str) -> bool: + upper = license_name.upper().replace("_", "-") + return any(marker in upper for marker in NON_COMMERCIAL_MARKERS) + + +def validate_music_plan(plan: dict[str, Any]) -> MusicPlanValidation: + """Validate deterministic MusicPlan invariants without invoking a model.""" + errors: list[str] = [] + warnings: list[str] = [] + + if plan.get("schema_version") != 1: + errors.append("schema_version must be 1") + if plan.get("mode") not in {"full", "guided", "free"}: + errors.append("mode must be full, guided, or free") + + duration = plan.get("duration_seconds") + if not isinstance(duration, (int, float)) or isinstance(duration, bool) or duration <= 0: + errors.append("duration_seconds must be a positive number") + duration = None + + sections = plan.get("sections") + if not isinstance(sections, list) or not sections: + errors.append("sections must be a non-empty list") + sections = [] + + section_ids: set[str] = set() + motif_refs: set[str] = set() + previous_end = 0.0 + for index, section in enumerate(sections): + if not isinstance(section, dict): + errors.append(f"sections[{index}] must be an object") + continue + sid = section.get("id") + if not isinstance(sid, str) or not sid: + errors.append(f"sections[{index}].id must be non-empty") + elif sid in section_ids: + errors.append(f"duplicate section id: {sid}") + else: + section_ids.add(sid) + start = section.get("start_seconds") + end = section.get("end_seconds") + if not isinstance(start, (int, float)) or isinstance(start, bool): + errors.append(f"sections[{index}].start_seconds must be numeric") + continue + if not isinstance(end, (int, float)) or isinstance(end, bool): + errors.append(f"sections[{index}].end_seconds must be numeric") + continue + if start < 0 or end <= start: + errors.append(f"sections[{index}] has invalid time bounds") + if start < previous_end: + errors.append(f"sections[{index}] overlaps the preceding section") + previous_end = max(previous_end, float(end)) + if duration is not None and end > duration: + errors.append(f"sections[{index}] exceeds duration_seconds") + refs = section.get("motif_refs", []) + if isinstance(refs, list): + motif_refs.update(ref for ref in refs if isinstance(ref, str)) + + motifs = plan.get("motifs", []) + motif_ids: set[str] = set() + if isinstance(motifs, list): + for index, motif in enumerate(motifs): + if not isinstance(motif, dict): + errors.append(f"motifs[{index}] must be an object") + continue + mid = motif.get("id") + if not isinstance(mid, str) or not mid: + errors.append(f"motifs[{index}].id must be non-empty") + elif mid in motif_ids: + errors.append(f"duplicate motif id: {mid}") + else: + motif_ids.add(mid) + unresolved = sorted(motif_refs - motif_ids) + if unresolved: + errors.append("unresolved motif_refs: " + ", ".join(unresolved)) + + cues = plan.get("sync_cues") + if not isinstance(cues, list): + errors.append("sync_cues must be a list") + cues = [] + cue_ids: set[str] = set() + for index, cue in enumerate(cues): + if not isinstance(cue, dict): + errors.append(f"sync_cues[{index}] must be an object") + continue + cid = cue.get("id") + if not isinstance(cid, str) or not cid: + errors.append(f"sync_cues[{index}].id must be non-empty") + elif cid in cue_ids: + errors.append(f"duplicate sync cue id: {cid}") + else: + cue_ids.add(cid) + time_seconds = cue.get("time_seconds") + if not isinstance(time_seconds, (int, float)) or isinstance(time_seconds, bool) or time_seconds < 0: + errors.append(f"sync_cues[{index}].time_seconds must be non-negative") + elif duration is not None and time_seconds > duration: + errors.append(f"sync_cues[{index}] exceeds duration_seconds") + + provenance = plan.get("provenance") + if not isinstance(provenance, dict): + errors.append("provenance must be an object") + else: + commercial = provenance.get("commercial_target") + if not isinstance(commercial, bool): + errors.append("provenance.commercial_target must be boolean") + dependencies = provenance.get("dependencies") + if not isinstance(dependencies, list): + errors.append("provenance.dependencies must be a list") + else: + for index, dep in enumerate(dependencies): + if not isinstance(dep, dict): + errors.append(f"provenance.dependencies[{index}] must be an object") + continue + license_name = dep.get("license") + if not isinstance(license_name, str) or not license_name.strip(): + errors.append(f"provenance.dependencies[{index}].license is required") + continue + if commercial and dep.get("kind") == "model-weights" and _is_non_commercial(license_name): + errors.append( + f"commercial target cannot use non-commercial model weights: {dep.get('name', index)}" + ) + + mode = plan.get("mode") + if mode == "full" and not motifs: + warnings.append("full mode has no symbolic motifs") + if mode == "free" and motifs: + warnings.append("free mode carries motifs that a provider may intentionally ignore") + + return MusicPlanValidation(not errors, tuple(errors), tuple(warnings)) + + +def execution_delta(requested: dict[str, Any], executed: dict[str, Any]) -> tuple[str, ...]: + """Return material provider degradation that must be acknowledged explicitly.""" + deltas: list[str] = [] + if requested.get("mode") != executed.get("mode"): + deltas.append(f"mode:{requested.get('mode')}->{executed.get('mode')}") + requested_fields = {key for key, value in requested.items() if value not in (None, [], "")} + executed_fields = set(executed) + for field in sorted(requested_fields - executed_fields): + if field not in {"schema_version", "plan_id", "provenance"}: + deltas.append(f"dropped:{field}") + return tuple(deltas) From da117097af5d3f7751005674ea331a821088ac4a Mon Sep 17 00:00:00 2001 From: Sylvain Galliez <128614184+zedarvates@users.noreply.github.com> Date: Sun, 13 Sep 2026 11:59:37 +0200 Subject: [PATCH 091/113] test(audio): add full MusicPlan fixture --- tests/fixtures/music_plan/full.json | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) create mode 100644 tests/fixtures/music_plan/full.json diff --git a/tests/fixtures/music_plan/full.json b/tests/fixtures/music_plan/full.json new file mode 100644 index 00000000..497e939d --- /dev/null +++ b/tests/fixtures/music_plan/full.json @@ -0,0 +1,23 @@ +{ + "schema_version": 1, + "plan_id": "fixture-full-v1", + "mode": "full", + "title": "Arrival and reveal", + "narrative_intent": "Build restrained anticipation, then reveal the destination without masking dialogue.", + "duration_seconds": 30, + "tempo_bpm": 92, + "meter": "4/4", + "tonal_center": "D minor", + "sections": [ + {"id": "arrival", "start_seconds": 0, "end_seconds": 18, "purpose": "quiet approach", "energy": 0.35, "tension": 0.55, "dialogue_safe": true, "motif_refs": ["arrival-motif"], "instrument_roles": ["soft strings", "pulse"]}, + {"id": "reveal", "start_seconds": 18, "end_seconds": 30, "purpose": "visual reveal", "energy": 0.72, "tension": 0.3, "dialogue_safe": false, "motif_refs": ["arrival-motif"], "instrument_roles": ["strings", "low brass"]} + ], + "motifs": [ + {"id": "arrival-motif", "intent": "recognizable four-note identity", "symbolic_hint": "short rising phrase, sparse rhythm"} + ], + "sync_cues": [ + {"id": "door-open", "time_seconds": 18, "intent": "accent the reveal", "story_ref": "scene-01/shot-04"} + ], + "lyrics": null, + "provenance": {"commercial_target": true, "dependencies": []} +} From f7dbfc05a28aae8a492a5f2425b66240d5756b92 Mon Sep 17 00:00:00 2001 From: Sylvain Galliez <128614184+zedarvates@users.noreply.github.com> Date: Sun, 13 Sep 2026 11:59:46 +0200 Subject: [PATCH 092/113] test(audio): add guided MusicPlan fixture --- tests/fixtures/music_plan/guided.json | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) create mode 100644 tests/fixtures/music_plan/guided.json diff --git a/tests/fixtures/music_plan/guided.json b/tests/fixtures/music_plan/guided.json new file mode 100644 index 00000000..aa6f9ba3 --- /dev/null +++ b/tests/fixtures/music_plan/guided.json @@ -0,0 +1,17 @@ +{ + "schema_version": 1, + "plan_id": "fixture-guided-v1", + "mode": "guided", + "title": "Chase transition", + "duration_seconds": 24, + "sections": [ + {"id": "setup", "start_seconds": 0, "end_seconds": 8, "purpose": "establish momentum", "energy": 0.45, "tension": 0.5, "dialogue_safe": true, "motif_refs": [], "instrument_roles": ["percussion"]}, + {"id": "chase", "start_seconds": 8, "end_seconds": 24, "purpose": "accelerate chase", "energy": 0.9, "tension": 0.85, "dialogue_safe": false, "motif_refs": [], "instrument_roles": ["percussion", "bass"]} + ], + "motifs": [], + "sync_cues": [ + {"id": "cut-chase", "time_seconds": 8, "intent": "mark transition to chase", "story_ref": "scene-02/shot-01"} + ], + "lyrics": null, + "provenance": {"commercial_target": true, "dependencies": []} +} From f9754b72776474af7cc4278346c716b93bce1f0f Mon Sep 17 00:00:00 2001 From: Sylvain Galliez <128614184+zedarvates@users.noreply.github.com> Date: Sun, 13 Sep 2026 11:59:53 +0200 Subject: [PATCH 093/113] test(audio): add free MusicPlan fixture --- tests/fixtures/music_plan/free.json | 14 ++++++++++++++ 1 file changed, 14 insertions(+) create mode 100644 tests/fixtures/music_plan/free.json diff --git a/tests/fixtures/music_plan/free.json b/tests/fixtures/music_plan/free.json new file mode 100644 index 00000000..c299b0e1 --- /dev/null +++ b/tests/fixtures/music_plan/free.json @@ -0,0 +1,14 @@ +{ + "schema_version": 1, + "plan_id": "fixture-free-v1", + "mode": "free", + "title": "Ambient interlude", + "duration_seconds": 20, + "sections": [ + {"id": "interlude", "start_seconds": 0, "end_seconds": 20, "purpose": "open ambience", "energy": 0.25, "tension": 0.2, "dialogue_safe": true, "motif_refs": [], "instrument_roles": []} + ], + "motifs": [], + "sync_cues": [], + "lyrics": null, + "provenance": {"commercial_target": false, "dependencies": []} +} From d4923693e0e71daaf60dae9c117c452ca1fd62ee Mon Sep 17 00:00:00 2001 From: Sylvain Galliez <128614184+zedarvates@users.noreply.github.com> Date: Sun, 13 Sep 2026 12:00:03 +0200 Subject: [PATCH 094/113] test(audio): validate MusicPlan invariants and licence boundary --- tests/test_music_plan.py | 67 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 67 insertions(+) create mode 100644 tests/test_music_plan.py diff --git a/tests/test_music_plan.py b/tests/test_music_plan.py new file mode 100644 index 00000000..24b4b000 --- /dev/null +++ b/tests/test_music_plan.py @@ -0,0 +1,67 @@ +from __future__ import annotations + +import json +from pathlib import Path + +from src.music_plan import execution_delta, validate_music_plan + +FIXTURES = Path(__file__).parent / "fixtures" / "music_plan" + + +def _load(name: str) -> dict: + return json.loads((FIXTURES / name).read_text(encoding="utf-8")) + + +def test_three_reference_modes_validate_without_model() -> None: + for name in ("full.json", "guided.json", "free.json"): + result = validate_music_plan(_load(name)) + assert result.valid, (name, result.errors) + + +def test_overlapping_sections_fail_closed() -> None: + plan = _load("guided.json") + plan["sections"][1]["start_seconds"] = 7 + result = validate_music_plan(plan) + assert not result.valid + assert any("overlaps" in error for error in result.errors) + + +def test_unresolved_motif_reference_fails_closed() -> None: + plan = _load("full.json") + plan["sections"][0]["motif_refs"] = ["missing-motif"] + result = validate_music_plan(plan) + assert not result.valid + assert any("unresolved motif_refs" in error for error in result.errors) + + +def test_commercial_target_blocks_noncommercial_model_weights() -> None: + plan = _load("full.json") + plan["provenance"]["dependencies"].append({ + "name": "example-nc-weights", + "kind": "model-weights", + "license": "CC BY-NC 4.0", + }) + result = validate_music_plan(plan) + assert not result.valid + assert any("non-commercial model weights" in error for error in result.errors) + + +def test_noncommercial_research_fixture_can_remain_noncommercial() -> None: + plan = _load("free.json") + plan["provenance"]["dependencies"].append({ + "name": "example-nc-weights", + "kind": "model-weights", + "license": "CC BY-NC 4.0", + }) + result = validate_music_plan(plan) + assert result.valid + + +def test_provider_degradation_is_explicit() -> None: + requested = _load("full.json") + executed = dict(requested) + executed["mode"] = "free" + executed.pop("motifs") + delta = execution_delta(requested, executed) + assert "mode:full->free" in delta + assert "dropped:motifs" in delta From e9aa207a94bbc2fef7f5cb034522058478195ae8 Mon Sep 17 00:00:00 2001 From: Sylvain Galliez <128614184+zedarvates@users.noreply.github.com> Date: Sun, 13 Sep 2026 12:00:13 +0200 Subject: [PATCH 095/113] ci(audio): add isolated MusicPlan contract proof --- .github/workflows/music-plan-contract.yml | 42 +++++++++++++++++++++++ 1 file changed, 42 insertions(+) create mode 100644 .github/workflows/music-plan-contract.yml diff --git a/.github/workflows/music-plan-contract.yml b/.github/workflows/music-plan-contract.yml new file mode 100644 index 00000000..d8a68fe6 --- /dev/null +++ b/.github/workflows/music-plan-contract.yml @@ -0,0 +1,42 @@ +name: MusicPlan Contract + +on: + pull_request: + branches: [main] + paths: + - "schemas/music-plan-v1.schema.json" + - "src/music_plan.py" + - "tests/test_music_plan.py" + - "tests/fixtures/music_plan/**" + - ".github/workflows/music-plan-contract.yml" + +permissions: + contents: read + +jobs: + validate: + runs-on: ubuntu-latest + strategy: + matrix: + python-version: ["3.10", "3.12"] + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.python-version }} + - name: Install test runner only + run: python -m pip install pytest + - name: Compile model-free validator + run: python -m py_compile src/music_plan.py + - name: Run MusicPlan contract tests + run: python -m pytest -q tests/test_music_plan.py + - name: Verify schema and fixtures parse as JSON + run: | + python - <<'PY' + import json + from pathlib import Path + paths = [Path('schemas/music-plan-v1.schema.json'), *sorted(Path('tests/fixtures/music_plan').glob('*.json'))] + for path in paths: + json.loads(path.read_text(encoding='utf-8')) + print(f'OK {path}') + PY From 18a54bea45900cbebff6a46d9d7b232b9973eccd Mon Sep 17 00:00:00 2001 From: Sylvain Galliez <128614184+zedarvates@users.noreply.github.com> Date: Sun, 13 Sep 2026 14:18:44 +0200 Subject: [PATCH 096/113] feat(audio): add deterministic mock music provider contract --- src/music_provider.py | 118 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 118 insertions(+) create mode 100644 src/music_provider.py diff --git a/src/music_provider.py b/src/music_provider.py new file mode 100644 index 00000000..3d1c0b70 --- /dev/null +++ b/src/music_provider.py @@ -0,0 +1,118 @@ +"""Deterministic provider contract for MusicPlan v1. + +The provider adapter is deliberately model-free: it proves how a future music +backend must report requested versus executed state without silently degrading a +plan. It never promotes artifacts, downloads weights, or performs network I/O. +""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import Any + +from src.music_plan import execution_delta, validate_music_plan + + +class MusicProviderContractError(ValueError): + """Raised when a provider response violates the explicit-degradation contract.""" + + +@dataclass(frozen=True) +class ProviderResult: + provider_id: str + requested_mode: str + executed_mode: str + requested_plan_id: str + unsupported_fields: tuple[str, ...] + deltas: tuple[str, ...] + reason: str + acknowledged: bool + artifact_id: str + artifact_status: str = "candidate" + activation_allowed: bool = False + promoted: bool = False + executed_external_model: bool = False + + def as_dict(self) -> dict[str, Any]: + return { + "provider_id": self.provider_id, + "requested_mode": self.requested_mode, + "executed_mode": self.executed_mode, + "requested_plan_id": self.requested_plan_id, + "unsupported_fields": list(self.unsupported_fields), + "deltas": list(self.deltas), + "reason": self.reason, + "acknowledged": self.acknowledged, + "artifact_id": self.artifact_id, + "artifact_status": self.artifact_status, + "activation_allowed": self.activation_allowed, + "promoted": self.promoted, + "executed_external_model": self.executed_external_model, + } + + +class MockMusicProvider: + """Small deterministic provider used to prove the adapter contract.""" + + def __init__( + self, + provider_id: str = "mock-music-provider", + *, + supported_modes: tuple[str, ...] = ("full", "guided", "free"), + unsupported_fields: tuple[str, ...] = (), + fallback_mode: str | None = None, + degradation_reason: str = "", + ) -> None: + self.provider_id = provider_id + self.supported_modes = supported_modes + self.unsupported_fields = unsupported_fields + self.fallback_mode = fallback_mode + self.degradation_reason = degradation_reason + + def prepare(self, plan: dict[str, Any]) -> ProviderResult: + validation = validate_music_plan(plan) + if not validation.valid: + raise MusicProviderContractError( + "invalid MusicPlan: " + "; ".join(validation.errors) + ) + + requested_mode = str(plan["mode"]) + executed = dict(plan) + if requested_mode not in self.supported_modes: + if not self.fallback_mode or self.fallback_mode not in self.supported_modes: + raise MusicProviderContractError( + f"provider does not support requested mode {requested_mode!r} and has no valid fallback" + ) + executed["mode"] = self.fallback_mode + + for field in self.unsupported_fields: + executed.pop(field, None) + + deltas = execution_delta(plan, executed) + acknowledged = bool(deltas) + reason = self.degradation_reason.strip() if deltas else "" + if deltas and not reason: + raise MusicProviderContractError( + "provider changed requested state without an explicit degradation reason" + ) + + return ProviderResult( + provider_id=self.provider_id, + requested_mode=requested_mode, + executed_mode=str(executed["mode"]), + requested_plan_id=str(plan["plan_id"]), + unsupported_fields=tuple( + field for field in self.unsupported_fields if field in plan + ), + deltas=deltas, + reason=reason, + acknowledged=acknowledged, + artifact_id=f"candidate:{self.provider_id}:{plan['plan_id']}", + ) + + +__all__ = [ + "MockMusicProvider", + "MusicProviderContractError", + "ProviderResult", +] From 8d8b86b160f853817c1e16289e50bd0dd4ba2b6e Mon Sep 17 00:00:00 2001 From: Sylvain Galliez <128614184+zedarvates@users.noreply.github.com> Date: Sun, 13 Sep 2026 14:19:06 +0200 Subject: [PATCH 097/113] test(audio): prove explicit provider degradation contract --- tests/test_music_plan.py | 57 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 57 insertions(+) diff --git a/tests/test_music_plan.py b/tests/test_music_plan.py index 24b4b000..63c6d462 100644 --- a/tests/test_music_plan.py +++ b/tests/test_music_plan.py @@ -3,7 +3,10 @@ import json from pathlib import Path +import pytest + from src.music_plan import execution_delta, validate_music_plan +from src.music_provider import MockMusicProvider, MusicProviderContractError FIXTURES = Path(__file__).parent / "fixtures" / "music_plan" @@ -65,3 +68,57 @@ def test_provider_degradation_is_explicit() -> None: delta = execution_delta(requested, executed) assert "mode:full->free" in delta assert "dropped:motifs" in delta + + +def test_full_capability_mock_keeps_requested_state() -> None: + result = MockMusicProvider().prepare(_load("full.json")) + assert result.requested_mode == "full" + assert result.executed_mode == "full" + assert result.deltas == () + assert result.unsupported_fields == () + assert result.acknowledged is False + assert result.reason == "" + assert result.artifact_status == "candidate" + assert result.activation_allowed is False + assert result.promoted is False + assert result.executed_external_model is False + + +def test_limited_mock_reports_mode_and_field_degradation() -> None: + provider = MockMusicProvider( + provider_id="mock-limited", + supported_modes=("guided", "free"), + fallback_mode="guided", + unsupported_fields=("motifs",), + degradation_reason="mock provider lacks full symbolic motif control", + ) + result = provider.prepare(_load("full.json")) + assert result.executed_mode == "guided" + assert "mode:full->guided" in result.deltas + assert "dropped:motifs" in result.deltas + assert result.unsupported_fields == ("motifs",) + assert result.acknowledged is True + assert result.reason + assert result.artifact_status == "candidate" + assert result.activation_allowed is False + assert result.promoted is False + assert result.executed_external_model is False + + +def test_silent_provider_degradation_is_rejected() -> None: + provider = MockMusicProvider( + provider_id="mock-bad", + supported_modes=("guided",), + fallback_mode="guided", + ) + with pytest.raises(MusicProviderContractError, match="explicit degradation reason"): + provider.prepare(_load("full.json")) + + +def test_provider_without_valid_fallback_fails_closed() -> None: + provider = MockMusicProvider( + provider_id="mock-no-fallback", + supported_modes=("guided",), + ) + with pytest.raises(MusicProviderContractError, match="no valid fallback"): + provider.prepare(_load("full.json")) From ce7d71b402b028b5b69db3edef215157676d15f3 Mon Sep 17 00:00:00 2001 From: Sylvain Galliez <128614184+zedarvates@users.noreply.github.com> Date: Sun, 13 Sep 2026 14:19:16 +0200 Subject: [PATCH 098/113] ci(audio): cover mock provider contract --- .github/workflows/music-plan-contract.yml | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/.github/workflows/music-plan-contract.yml b/.github/workflows/music-plan-contract.yml index d8a68fe6..7d50ac15 100644 --- a/.github/workflows/music-plan-contract.yml +++ b/.github/workflows/music-plan-contract.yml @@ -6,6 +6,7 @@ on: paths: - "schemas/music-plan-v1.schema.json" - "src/music_plan.py" + - "src/music_provider.py" - "tests/test_music_plan.py" - "tests/fixtures/music_plan/**" - ".github/workflows/music-plan-contract.yml" @@ -26,8 +27,8 @@ jobs: python-version: ${{ matrix.python-version }} - name: Install test runner only run: python -m pip install pytest - - name: Compile model-free validator - run: python -m py_compile src/music_plan.py + - name: Compile model-free contracts + run: python -m py_compile src/music_plan.py src/music_provider.py - name: Run MusicPlan contract tests run: python -m pytest -q tests/test_music_plan.py - name: Verify schema and fixtures parse as JSON From 11511499aa07173ff0741dc667a554605bb1800e Mon Sep 17 00:00:00 2001 From: Sylvain Galliez <128614184+zedarvates@users.noreply.github.com> Date: Sun, 13 Sep 2026 14:20:35 +0200 Subject: [PATCH 099/113] docs(audio): record mock provider proof boundary --- docs/music-planning-layer-v1.md | 62 ++++++++++++++++++++++++--------- 1 file changed, 46 insertions(+), 16 deletions(-) diff --git a/docs/music-planning-layer-v1.md b/docs/music-planning-layer-v1.md index 46ecab87..788b0721 100644 --- a/docs/music-planning-layer-v1.md +++ b/docs/music-planning-layer-v1.md @@ -1,6 +1,6 @@ # StoryCore Music Planning Layer v1 -Status: **draft contract; provider-agnostic; non-generating by itself**. +Status: **experimental contract; provider-neutral; no provider activated**. ## Goal @@ -15,7 +15,7 @@ story / scene / shot intent music brief | v -MusicPlan v1 <---- human/agent edits and validation +MusicPlan v1 <---- human/agent edits and deterministic validation | +----> provider adapter A +----> provider adapter B @@ -72,7 +72,19 @@ Only the narrative music brief is binding. This keeps a direct-generation baseline available for comparison. It must not be labelled equivalent to a `full` render. -## Provider boundary +## Deterministic validation before inference + +The model-free validator checks the invariants that JSON Schema alone cannot +express: positive bounded duration, ordered non-overlapping sections, unique +IDs, resolved motif references, cues inside duration, and the commercial +licence boundary. No LLM, music model, network request, or weight download is +needed for this gate. + +The reference fixtures cover the `full`, `guided`, and `free` modes so future +providers are compared against the same small contracts rather than ad-hoc +prompts. + +## Provider boundary and explicit degradation Provider adapters translate `MusicPlan` into provider-specific inputs and return an execution report containing at least: @@ -80,6 +92,7 @@ an execution report containing at least: - requested mode; - executed mode; - unsupported/dropped fields; +- an explicit reason when requested state cannot be preserved; - model/provider identifier and version when available; - model-weights licence and code licence separately; - deterministic parameters or seed when supported; @@ -87,8 +100,17 @@ an execution report containing at least: - runtime/hardware observations when measured; - validation evidence references. -The provider adapter cannot promote its own output to last-known-good. Promotion -belongs to a separate validation/harness step. +The deterministic `MockMusicProvider` proves this contract without performing +inference. A fully capable mock keeps the requested state unchanged. A limited +mock may fall back, for example from `full` to `guided`, but the delta and +unsupported fields are exposed and a non-empty degradation reason is mandatory. +A changed execution state without that reason fails closed. + +The provider result remains a **candidate**. The mock contract explicitly keeps +`activation_allowed=false`, `promoted=false`, and +`executed_external_model=false`. The provider adapter cannot promote its own +output to last-known-good; promotion belongs to a separate validation/harness +step. ## Commercial-use boundary @@ -112,11 +134,11 @@ adapted without making its restricted weights a production dependency. 2. Licence/provenance gate. 3. Plan-level checks: duration, section ordering, cue references and bounded values. -4. Adapter execution with explicit requested/executed delta. -5. Technical audio checks. +4. Adapter preparation with explicit requested/executed delta. +5. Technical audio checks after a real provider exists. 6. Narrative checks: cue timing, dialogue masking, motif/scene consistency. 7. Optional human review. -8. Promotion to last-known-good only with evidence. +8. Promotion to last-known-good only with independent evidence. A failure keeps the candidate and diagnostics for comparison but leaves the previous verified artefact intact. @@ -140,15 +162,16 @@ Minimum measures should include: A cheaper or smaller generator can therefore win when its harness better obeys the plan. -## First implementation slice +## Current proof boundary -1. Land the provider-neutral JSON schema. -2. Create three tiny synthetic fixtures: `full`, `guided`, `free`. -3. Add a validator that never invokes a music model. -4. Add one adapter interface with a mock provider first. -5. Only then evaluate external music backends. -6. Keep all external-model activation opt-in until real comparison evidence - exists. +The isolated `MusicPlan Contract` workflow compiles `src/music_plan.py` and +`src/music_provider.py`, runs the focused contract tests, and parses the schema +and fixtures on Python 3.10 and 3.12. Claims about this slice must remain bound +to an exact-head successful run. + +This slice does **not** generate music, benchmark audio quality, choose a +production provider, download weights, call a remote service, authorize a +release, or authorize a merge. ## Relationship to the Botte Secrète Execution Harness @@ -165,3 +188,10 @@ When StoryCore runs under Botte Secrète, map: StoryCore must remain usable without Botte; the interchange should stay a small JSON/data contract rather than importing Botte as a hard runtime dependency. + +## Next bounded slice + +Define a provider-neutral handoff/evidence envelope carrying the execution delta, +provider/harness/hardware identity, candidate artifact reference, verification +state, and explicit non-activation/non-promotion flags. Only after that envelope +is proven should a real local or external music backend be measured. From 6c37dc3488b06a845f370e99b9a1327837d8c794 Mon Sep 17 00:00:00 2001 From: Sylvain Galliez <128614184+zedarvates@users.noreply.github.com> Date: Sun, 13 Sep 2026 14:20:51 +0200 Subject: [PATCH 100/113] ci(audio): include MusicPlan contract documentation in proof scope --- .github/workflows/music-plan-contract.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/music-plan-contract.yml b/.github/workflows/music-plan-contract.yml index 7d50ac15..4871dda0 100644 --- a/.github/workflows/music-plan-contract.yml +++ b/.github/workflows/music-plan-contract.yml @@ -9,6 +9,7 @@ on: - "src/music_provider.py" - "tests/test_music_plan.py" - "tests/fixtures/music_plan/**" + - "docs/music-planning-layer-v1.md" - ".github/workflows/music-plan-contract.yml" permissions: From 115ee32a251fee85abeac1471d2712ca286a069a Mon Sep 17 00:00:00 2001 From: Sylvain Galliez <128614184+zedarvates@users.noreply.github.com> Date: Sun, 13 Sep 2026 14:24:51 +0200 Subject: [PATCH 101/113] fix(audio): compose JSON Schema validation and recursive execution deltas --- src/music_plan.py | 84 ++++++++++++++++++++++++++++++++++++++--------- 1 file changed, 68 insertions(+), 16 deletions(-) diff --git a/src/music_plan.py b/src/music_plan.py index e832c081..33e2c6bf 100644 --- a/src/music_plan.py +++ b/src/music_plan.py @@ -1,16 +1,22 @@ """Provider-neutral MusicPlan v1 validation helpers. -This module is deliberately model-free and standard-library only. It validates -cross-field invariants that JSON Schema alone cannot express and enforces the -commercial licence boundary before a provider adapter is selected. +Validation is model-free but uses the repository's existing ``jsonschema`` +dependency for Draft 2020-12 structural checks, then applies StoryCore-specific +cross-field and commercial-use invariants. """ from __future__ import annotations +import json from dataclasses import dataclass +from pathlib import Path from typing import Any +from jsonschema import Draft202012Validator + NON_COMMERCIAL_MARKERS = ("CC BY-NC", "BY-NC", "NON-COMMERCIAL", "NONCOMMERCIAL") +UNKNOWN_LICENSE_MARKERS = frozenset({"unknown", "unspecified", "unqualified", "tbd", "n/a", "none"}) +DEFAULT_SCHEMA_PATH = Path(__file__).resolve().parents[1] / "schemas" / "music-plan-v1.schema.json" @dataclass(frozen=True) @@ -25,9 +31,24 @@ def _is_non_commercial(license_name: str) -> bool: return any(marker in upper for marker in NON_COMMERCIAL_MARKERS) -def validate_music_plan(plan: dict[str, Any]) -> MusicPlanValidation: - """Validate deterministic MusicPlan invariants without invoking a model.""" +def _is_unknown_license(license_name: str) -> bool: + return license_name.strip().lower() in UNKNOWN_LICENSE_MARKERS + + +def _schema_errors(plan: dict[str, Any], schema_path: Path = DEFAULT_SCHEMA_PATH) -> list[str]: + schema = json.loads(schema_path.read_text(encoding="utf-8")) + Draft202012Validator.check_schema(schema) + validator = Draft202012Validator(schema) errors: list[str] = [] + for issue in sorted(validator.iter_errors(plan), key=lambda item: list(item.absolute_path)): + path = ".".join(str(part) for part in issue.absolute_path) or "$" + errors.append(f"schema:{path}: {issue.message}") + return errors + + +def validate_music_plan(plan: dict[str, Any]) -> MusicPlanValidation: + """Validate MusicPlan structure and deterministic invariants without a model.""" + errors: list[str] = _schema_errors(plan) warnings: list[str] = [] if plan.get("schema_version") != 1: @@ -137,10 +158,16 @@ def validate_music_plan(plan: dict[str, Any]) -> MusicPlanValidation: if not isinstance(license_name, str) or not license_name.strip(): errors.append(f"provenance.dependencies[{index}].license is required") continue - if commercial and dep.get("kind") == "model-weights" and _is_non_commercial(license_name): - errors.append( - f"commercial target cannot use non-commercial model weights: {dep.get('name', index)}" - ) + if commercial and dep.get("kind") == "model-weights": + name = dep.get("name", index) + if _is_unknown_license(license_name): + errors.append( + f"commercial target cannot use model weights with unknown licence: {name}" + ) + elif _is_non_commercial(license_name): + errors.append( + f"commercial target cannot use non-commercial model weights: {name}" + ) mode = plan.get("mode") if mode == "full" and not motifs: @@ -151,14 +178,39 @@ def validate_music_plan(plan: dict[str, Any]) -> MusicPlanValidation: return MusicPlanValidation(not errors, tuple(errors), tuple(warnings)) +def _diff_values(requested: Any, executed: Any, path: str, deltas: list[str]) -> None: + if isinstance(requested, dict) and isinstance(executed, dict): + requested_keys = set(requested) + executed_keys = set(executed) + for key in sorted(requested_keys - executed_keys): + child = f"{path}.{key}" if path else key + if child not in {"schema_version", "plan_id", "provenance"}: + deltas.append(f"dropped:{child}") + for key in sorted(executed_keys - requested_keys): + child = f"{path}.{key}" if path else key + deltas.append(f"added:{child}") + for key in sorted(requested_keys & executed_keys): + child = f"{path}.{key}" if path else key + if child == "mode": + continue + _diff_values(requested[key], executed[key], child, deltas) + return + + if isinstance(requested, list) and isinstance(executed, list): + if len(requested) != len(executed): + deltas.append(f"changed:{path}.length") + for index, (left, right) in enumerate(zip(requested, executed)): + _diff_values(left, right, f"{path}[{index}]", deltas) + return + + if requested != executed: + deltas.append(f"changed:{path}") + + def execution_delta(requested: dict[str, Any], executed: dict[str, Any]) -> tuple[str, ...]: - """Return material provider degradation that must be acknowledged explicitly.""" + """Return material provider changes, including nested and value-level deltas.""" deltas: list[str] = [] if requested.get("mode") != executed.get("mode"): deltas.append(f"mode:{requested.get('mode')}->{executed.get('mode')}") - requested_fields = {key for key, value in requested.items() if value not in (None, [], "")} - executed_fields = set(executed) - for field in sorted(requested_fields - executed_fields): - if field not in {"schema_version", "plan_id", "provenance"}: - deltas.append(f"dropped:{field}") - return tuple(deltas) + _diff_values(requested, executed, "", deltas) + return tuple(dict.fromkeys(deltas)) From 1e41f9b8703ee29791487bf6eee3af1af4f90de6 Mon Sep 17 00:00:00 2001 From: Sylvain Galliez <128614184+zedarvates@users.noreply.github.com> Date: Sun, 13 Sep 2026 14:25:21 +0200 Subject: [PATCH 102/113] test(audio): cover schema and nested execution degradation --- tests/test_music_plan.py | 65 +++++++++++++++++++++++++++++++++++++++- 1 file changed, 64 insertions(+), 1 deletion(-) diff --git a/tests/test_music_plan.py b/tests/test_music_plan.py index 63c6d462..7c035398 100644 --- a/tests/test_music_plan.py +++ b/tests/test_music_plan.py @@ -1,5 +1,6 @@ from __future__ import annotations +import copy import json from pathlib import Path @@ -21,6 +22,22 @@ def test_three_reference_modes_validate_without_model() -> None: assert result.valid, (name, result.errors) +def test_required_plan_id_is_enforced_by_schema() -> None: + plan = _load("full.json") + plan.pop("plan_id") + result = validate_music_plan(plan) + assert not result.valid + assert any("schema:$" in error and "plan_id" in error for error in result.errors) + + +def test_energy_range_is_enforced_by_schema() -> None: + plan = _load("full.json") + plan["sections"][0]["energy"] = 2 + result = validate_music_plan(plan) + assert not result.valid + assert any("energy" in error and "greater than the maximum" in error for error in result.errors) + + def test_overlapping_sections_fail_closed() -> None: plan = _load("guided.json") plan["sections"][1]["start_seconds"] = 7 @@ -49,6 +66,18 @@ def test_commercial_target_blocks_noncommercial_model_weights() -> None: assert any("non-commercial model weights" in error for error in result.errors) +def test_commercial_target_blocks_unknown_model_weight_licence() -> None: + plan = _load("full.json") + plan["provenance"]["dependencies"].append({ + "name": "unqualified-weights", + "kind": "model-weights", + "license": "unknown", + }) + result = validate_music_plan(plan) + assert not result.valid + assert any("unknown licence" in error for error in result.errors) + + def test_noncommercial_research_fixture_can_remain_noncommercial() -> None: plan = _load("free.json") plan["provenance"]["dependencies"].append({ @@ -62,7 +91,7 @@ def test_noncommercial_research_fixture_can_remain_noncommercial() -> None: def test_provider_degradation_is_explicit() -> None: requested = _load("full.json") - executed = dict(requested) + executed = copy.deepcopy(requested) executed["mode"] = "free" executed.pop("motifs") delta = execution_delta(requested, executed) @@ -70,6 +99,40 @@ def test_provider_degradation_is_explicit() -> None: assert "dropped:motifs" in delta +def test_duration_shortening_is_reported() -> None: + requested = _load("full.json") + executed = copy.deepcopy(requested) + executed["duration_seconds"] = requested["duration_seconds"] / 2 + for section in executed["sections"]: + section["start_seconds"] /= 2 + section["end_seconds"] /= 2 + for cue in executed["sync_cues"]: + cue["time_seconds"] /= 2 + assert validate_music_plan(executed).valid + delta = execution_delta(requested, executed) + assert "changed:duration_seconds" in delta + assert "changed:sections[0].end_seconds" in delta + assert any(item.startswith("changed:sync_cues[0].time_seconds") for item in delta) + + +def test_emptied_sync_cues_are_reported() -> None: + requested = _load("full.json") + executed = copy.deepcopy(requested) + executed["sync_cues"] = [] + assert validate_music_plan(executed).valid + delta = execution_delta(requested, executed) + assert "changed:sync_cues.length" in delta + + +def test_nested_cue_change_is_reported() -> None: + requested = _load("full.json") + executed = copy.deepcopy(requested) + executed["sync_cues"][0]["time_seconds"] += 1 + assert validate_music_plan(executed).valid + delta = execution_delta(requested, executed) + assert "changed:sync_cues[0].time_seconds" in delta + + def test_full_capability_mock_keeps_requested_state() -> None: result = MockMusicProvider().prepare(_load("full.json")) assert result.requested_mode == "full" From d3b9bfeee19414522526af626b6f2e716be48871 Mon Sep 17 00:00:00 2001 From: Sylvain Galliez <128614184+zedarvates@users.noreply.github.com> Date: Sun, 13 Sep 2026 14:25:35 +0200 Subject: [PATCH 103/113] ci(audio): validate Draft 2020-12 MusicPlan contract --- .github/workflows/music-plan-contract.yml | 18 ++++++++++++------ 1 file changed, 12 insertions(+), 6 deletions(-) diff --git a/.github/workflows/music-plan-contract.yml b/.github/workflows/music-plan-contract.yml index 4871dda0..f84e8c0e 100644 --- a/.github/workflows/music-plan-contract.yml +++ b/.github/workflows/music-plan-contract.yml @@ -26,19 +26,25 @@ jobs: - uses: actions/setup-python@v5 with: python-version: ${{ matrix.python-version }} - - name: Install test runner only - run: python -m pip install pytest + - name: Install focused validation dependencies + run: python -m pip install 'pytest>=8,<10' 'jsonschema>=4.23,<5' - name: Compile model-free contracts run: python -m py_compile src/music_plan.py src/music_provider.py - name: Run MusicPlan contract tests run: python -m pytest -q tests/test_music_plan.py - - name: Verify schema and fixtures parse as JSON + - name: Verify Draft 2020-12 schema and reference fixtures run: | python - <<'PY' import json from pathlib import Path - paths = [Path('schemas/music-plan-v1.schema.json'), *sorted(Path('tests/fixtures/music_plan').glob('*.json'))] - for path in paths: - json.loads(path.read_text(encoding='utf-8')) + from jsonschema import Draft202012Validator + + schema_path = Path('schemas/music-plan-v1.schema.json') + schema = json.loads(schema_path.read_text(encoding='utf-8')) + Draft202012Validator.check_schema(schema) + validator = Draft202012Validator(schema) + for path in sorted(Path('tests/fixtures/music_plan').glob('*.json')): + data = json.loads(path.read_text(encoding='utf-8')) + validator.validate(data) print(f'OK {path}') PY From 197f36bd2a439e2147ad1b02fcb68ab40c96689d Mon Sep 17 00:00:00 2001 From: Sylvain Galliez <128614184+zedarvates@users.noreply.github.com> Date: Sun, 13 Sep 2026 14:26:33 +0200 Subject: [PATCH 104/113] feat(audio): add portable provider handoff envelope --- src/music_provider.py | 67 ++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 66 insertions(+), 1 deletion(-) diff --git a/src/music_provider.py b/src/music_provider.py index 3d1c0b70..cf623780 100644 --- a/src/music_provider.py +++ b/src/music_provider.py @@ -8,7 +8,7 @@ from __future__ import annotations from dataclasses import dataclass -from typing import Any +from typing import Any, Iterable from src.music_plan import execution_delta, validate_music_plan @@ -51,6 +51,70 @@ def as_dict(self) -> dict[str, Any]: } +def build_provider_handoff( + result: ProviderResult, + *, + provider_version: str = "", + model: str = "", + harness: str = "music-plan-v1", + hardware: str = "", + input_digest: str = "", + candidate_digest: str = "", + evidence_refs: Iterable[str] = (), + verification_state: str = "unverified", +) -> dict[str, Any]: + """Build a data-only interchange envelope for a later validation harness. + + This function does not import Botte Secrète, execute a provider, promote an + artifact, or write memory. It merely carries the bounded provider outcome + and provenance dimensions that another harness may verify independently. + """ + allowed_states = {"unverified", "partially_verified", "verified", "failed"} + if verification_state not in allowed_states: + raise MusicProviderContractError("unsupported verification_state") + refs = tuple(str(ref) for ref in evidence_refs if str(ref).strip()) + if verification_state == "verified" and not refs: + raise MusicProviderContractError("verified handoff requires evidence references") + if result.artifact_status != "candidate": + raise MusicProviderContractError("provider handoff accepts candidate artifacts only") + if result.activation_allowed or result.promoted: + raise MusicProviderContractError("provider result cannot carry activation or promotion authority") + + return { + "schema_version": "storycore.music-provider-handoff/v1", + "plan_id": result.requested_plan_id, + "provider": { + "id": result.provider_id, + "version": provider_version, + "model": model, + "harness": harness, + "hardware": hardware, + }, + "execution": { + "requested_mode": result.requested_mode, + "executed_mode": result.executed_mode, + "unsupported_fields": list(result.unsupported_fields), + "deltas": list(result.deltas), + "reason": result.reason, + "acknowledged": result.acknowledged, + "executed_external_model": result.executed_external_model, + }, + "artifact": { + "id": result.artifact_id, + "status": result.artifact_status, + "input_digest": input_digest, + "candidate_digest": candidate_digest, + }, + "verification": { + "state": verification_state, + "evidence_refs": list(refs), + }, + "activation_allowed": False, + "promoted": False, + "memory_write_performed": False, + } + + class MockMusicProvider: """Small deterministic provider used to prove the adapter contract.""" @@ -115,4 +179,5 @@ def prepare(self, plan: dict[str, Any]) -> ProviderResult: "MockMusicProvider", "MusicProviderContractError", "ProviderResult", + "build_provider_handoff", ] From 5cbaad58e930198e4249857035fb9d5f4f2486b5 Mon Sep 17 00:00:00 2001 From: Sylvain Galliez <128614184+zedarvates@users.noreply.github.com> Date: Sun, 13 Sep 2026 14:27:00 +0200 Subject: [PATCH 105/113] test(audio): prove portable handoff stays non-activating --- tests/test_music_plan.py | 42 +++++++++++++++++++++++++++++++++++++++- 1 file changed, 41 insertions(+), 1 deletion(-) diff --git a/tests/test_music_plan.py b/tests/test_music_plan.py index 7c035398..7c6ffe03 100644 --- a/tests/test_music_plan.py +++ b/tests/test_music_plan.py @@ -7,7 +7,11 @@ import pytest from src.music_plan import execution_delta, validate_music_plan -from src.music_provider import MockMusicProvider, MusicProviderContractError +from src.music_provider import ( + MockMusicProvider, + MusicProviderContractError, + build_provider_handoff, +) FIXTURES = Path(__file__).parent / "fixtures" / "music_plan" @@ -168,6 +172,42 @@ def test_limited_mock_reports_mode_and_field_degradation() -> None: assert result.executed_external_model is False +def test_provider_handoff_is_data_only_and_non_activating() -> None: + provider = MockMusicProvider( + provider_id="mock-limited", + supported_modes=("guided",), + fallback_mode="guided", + unsupported_fields=("motifs",), + degradation_reason="fixture capability boundary", + ) + result = provider.prepare(_load("full.json")) + handoff = build_provider_handoff( + result, + provider_version="test-v1", + model="none", + harness="music-plan-contract-tests", + hardware="github-hosted-cpu", + input_digest="sha256:input", + candidate_digest="sha256:candidate", + evidence_refs=("ci:fixture",), + verification_state="partially_verified", + ) + assert handoff["provider"]["id"] == "mock-limited" + assert handoff["provider"]["harness"] == "music-plan-contract-tests" + assert handoff["execution"]["deltas"] == list(result.deltas) + assert handoff["artifact"]["status"] == "candidate" + assert handoff["verification"]["evidence_refs"] == ["ci:fixture"] + assert handoff["activation_allowed"] is False + assert handoff["promoted"] is False + assert handoff["memory_write_performed"] is False + + +def test_verified_handoff_requires_evidence() -> None: + result = MockMusicProvider().prepare(_load("full.json")) + with pytest.raises(MusicProviderContractError, match="requires evidence"): + build_provider_handoff(result, verification_state="verified") + + def test_silent_provider_degradation_is_rejected() -> None: provider = MockMusicProvider( provider_id="mock-bad", From 079e063535a6aa7d25e1c19e5099d68e1fa6c623 Mon Sep 17 00:00:00 2001 From: Sylvain Galliez <128614184+zedarvates@users.noreply.github.com> Date: Sun, 13 Sep 2026 14:29:24 +0200 Subject: [PATCH 106/113] test(audio): add deterministic MusicPlan mutation probe --- scripts/music_plan_mutation_probe.py | 97 ++++++++++++++++++++++++++++ 1 file changed, 97 insertions(+) create mode 100644 scripts/music_plan_mutation_probe.py diff --git a/scripts/music_plan_mutation_probe.py b/scripts/music_plan_mutation_probe.py new file mode 100644 index 00000000..69dc253d --- /dev/null +++ b/scripts/music_plan_mutation_probe.py @@ -0,0 +1,97 @@ +#!/usr/bin/env python3 +"""Negative-control probe for the MusicPlan contract. + +This is deliberately separate from the ordinary unit suite. It starts from the +known-good full fixture, injects bounded invalid mutations, and succeeds only if +the public validation/provider boundary rejects every mutant. +""" + +from __future__ import annotations + +import copy +import json +from pathlib import Path + +from src.music_plan import validate_music_plan +from src.music_provider import MockMusicProvider, MusicProviderContractError + +FIXTURE = Path(__file__).resolve().parents[1] / "tests" / "fixtures" / "music_plan" / "full.json" + + +def _base() -> dict: + return json.loads(FIXTURE.read_text(encoding="utf-8")) + + +def _mutants() -> list[tuple[str, dict]]: + cases: list[tuple[str, dict]] = [] + + missing_id = copy.deepcopy(_base()) + missing_id.pop("plan_id", None) + cases.append(("missing-plan-id", missing_id)) + + invalid_energy = copy.deepcopy(_base()) + invalid_energy["sections"][0]["energy"] = 2 + cases.append(("energy-out-of-range", invalid_energy)) + + unknown_license = copy.deepcopy(_base()) + unknown_license["provenance"]["dependencies"].append({ + "name": "mutation-unknown-weights", + "kind": "model-weights", + "license": "unknown", + }) + cases.append(("unknown-commercial-model-license", unknown_license)) + + unresolved_motif = copy.deepcopy(_base()) + unresolved_motif["sections"][0]["motif_refs"] = ["mutation-missing-motif"] + cases.append(("unresolved-motif", unresolved_motif)) + + overlapping = copy.deepcopy(_base()) + overlapping["sections"][1]["start_seconds"] = overlapping["sections"][0]["start_seconds"] + cases.append(("overlapping-sections", overlapping)) + + return cases + + +def main() -> int: + failures: list[str] = [] + provider = MockMusicProvider() + + base_validation = validate_music_plan(_base()) + if not base_validation.valid: + print("FAIL positive control: reference fixture no longer validates") + return 1 + print("PASS positive control: reference fixture validates") + + for name, mutant in _mutants(): + validation = validate_music_plan(mutant) + validator_rejected = not validation.valid + provider_rejected = False + try: + provider.prepare(mutant) + except MusicProviderContractError: + provider_rejected = True + except Exception as exc: # fail closed, but distinguish contract leakage + failures.append(f"{name}: leaked {type(exc).__name__} instead of MusicProviderContractError") + print(f"FAIL {name}: unexpected exception {type(exc).__name__}") + continue + + if validator_rejected and provider_rejected: + print(f"PASS mutation rejected: {name}") + else: + failures.append( + f"{name}: validator_rejected={validator_rejected}, provider_rejected={provider_rejected}" + ) + print(f"FAIL mutation survived: {name}") + + if failures: + print("\nNEGATIVE CONTROL FAILED") + for failure in failures: + print(f"- {failure}") + return 1 + + print("\nNEGATIVE CONTROL PASSED: every controlled mutant was rejected") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) From ea6867ee4d62b07b63ed9d0b084a640d20626748 Mon Sep 17 00:00:00 2001 From: Sylvain Galliez <128614184+zedarvates@users.noreply.github.com> Date: Sun, 13 Sep 2026 14:29:40 +0200 Subject: [PATCH 107/113] ci(audio): run controlled MusicPlan negative mutation probe --- .github/workflows/music-plan-contract.yml | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/.github/workflows/music-plan-contract.yml b/.github/workflows/music-plan-contract.yml index f84e8c0e..537a2382 100644 --- a/.github/workflows/music-plan-contract.yml +++ b/.github/workflows/music-plan-contract.yml @@ -7,6 +7,7 @@ on: - "schemas/music-plan-v1.schema.json" - "src/music_plan.py" - "src/music_provider.py" + - "scripts/music_plan_mutation_probe.py" - "tests/test_music_plan.py" - "tests/fixtures/music_plan/**" - "docs/music-planning-layer-v1.md" @@ -29,9 +30,11 @@ jobs: - name: Install focused validation dependencies run: python -m pip install 'pytest>=8,<10' 'jsonschema>=4.23,<5' - name: Compile model-free contracts - run: python -m py_compile src/music_plan.py src/music_provider.py + run: python -m py_compile src/music_plan.py src/music_provider.py scripts/music_plan_mutation_probe.py - name: Run MusicPlan contract tests run: python -m pytest -q tests/test_music_plan.py + - name: Run controlled negative mutation probe + run: python scripts/music_plan_mutation_probe.py - name: Verify Draft 2020-12 schema and reference fixtures run: | python - <<'PY' From 401abccf8365c4a3c5a68abc22fd794bbd92a49a Mon Sep 17 00:00:00 2001 From: Sylvain Galliez <128614184+zedarvates@users.noreply.github.com> Date: Sun, 13 Sep 2026 14:30:18 +0200 Subject: [PATCH 108/113] docs(audio): align MusicPlan proof and handoff status --- docs/music-planning-layer-v1.md | 233 +++++++++++++------------------- 1 file changed, 93 insertions(+), 140 deletions(-) diff --git a/docs/music-planning-layer-v1.md b/docs/music-planning-layer-v1.md index 788b0721..ba26a143 100644 --- a/docs/music-planning-layer-v1.md +++ b/docs/music-planning-layer-v1.md @@ -5,193 +5,146 @@ Status: **experimental contract; provider-neutral; no provider activated**. ## Goal Insert an editable symbolic planning layer between narrative intent and any music -generator. StoryCore should be able to reason about, compare, revise and version -a score plan without coupling the project to one model or one licence regime. +generator. StoryCore should be able to inspect, compare, revise and version a +score plan without coupling the project to one model or one licence regime. ```text story / scene / shot intent | v -music brief +MusicPlan v1 | v -MusicPlan v1 <---- human/agent edits and deterministic validation +Draft 2020-12 schema + deterministic invariants | - +----> provider adapter A - +----> provider adapter B - +----> DAW / MIDI-oriented export later + v +provider capability / licence check + | + v +requested vs executed delta | v -rendered audio candidate +candidate artifact + portable handoff | v -technical + narrative + licence validation +independent validation later | v -last-known-good audio artefact +last-known-good promotion outside provider adapter ``` -The design is inspired by open-source music workflows that expose an editable -intermediate representation, but this contract is StoryCore-owned and must not +The design is inspired by open-source workflows that expose an editable +intermediate representation, but this contract is StoryCore-owned and does not require or redistribute third-party model weights. -## Why the intermediate plan matters - -A direct `prompt -> wav` path throws away useful structure. `MusicPlan` keeps the -intent that can be inspected before expensive generation: - -- sections and their narrative purpose; -- tempo and metre; -- tonal centre / mode when known; -- motifs and motif reuse; -- instrumentation roles rather than provider-specific tokens; -- energy and tension curves; -- dialogue-safe density constraints; -- scene/shot synchronization cues; -- optional lyric blocks; -- provenance and licence information for every external dependency. - -A renderer may ignore unsupported optional fields, but it must report that as an -execution delta rather than silently pretending the full plan was honoured. - ## Three execution modes -### `full` - -The symbolic plan is authoritative enough to be edited and compared before -rendering. Use when continuity, leitmotifs, timing or reproducibility matter. - -### `guided` +- `full`: strongest symbolic plan; use where continuity, motifs, timing or + reproducibility matter. +- `guided`: StoryCore fixes high-level structure and synchronization while the + provider may elaborate details. +- `free`: narrative/time brief remains binding, but detailed symbolic planning + is optional. It must not be represented as equivalent to a `full` render. -StoryCore fixes high-level structure and synchronization while the selected -provider is free to elaborate harmony, accompaniment or sound design. +## Validation before inference -### `free` +The model-free gate composes the repository's existing `jsonschema` dependency +with StoryCore-specific checks. It: -Only the narrative music brief is binding. This keeps a direct-generation -baseline available for comparison. It must not be labelled equivalent to a -`full` render. +- validates the Draft 2020-12 schema itself; +- validates required fields and bounded values in a requested plan; +- rejects invalid section timing and overlaps; +- checks unique IDs and motif references; +- checks cue bounds; +- rejects declared non-commercial model weights on commercial targets; +- treats known unknown/unqualified model-weight licence markers as ineligible + for commercial use. -## Deterministic validation before inference +The reference fixtures cover `full`, `guided`, and `free`. No LLM, music model, +network request or weight download is needed for this gate. -The model-free validator checks the invariants that JSON Schema alone cannot -express: positive bounded duration, ordered non-overlapping sections, unique -IDs, resolved motif references, cues inside duration, and the commercial -licence boundary. No LLM, music model, network request, or weight download is -needed for this gate. +## Explicit degradation -The reference fixtures cover the `full`, `guided`, and `free` modes so future -providers are compared against the same small contracts rather than ad-hoc -prompts. +`execution_delta(requested, executed)` is recursive. It records mode changes, +removed or added fields, changed scalar values, list-length changes and nested +changes. Examples include: -## Provider boundary and explicit degradation - -Provider adapters translate `MusicPlan` into provider-specific inputs and return -an execution report containing at least: - -- requested mode; -- executed mode; -- unsupported/dropped fields; -- an explicit reason when requested state cannot be preserved; -- model/provider identifier and version when available; -- model-weights licence and code licence separately; -- deterministic parameters or seed when supported; -- input and output artefact digests; -- runtime/hardware observations when measured; -- validation evidence references. - -The deterministic `MockMusicProvider` proves this contract without performing -inference. A fully capable mock keeps the requested state unchanged. A limited -mock may fall back, for example from `full` to `guided`, but the delta and -unsupported fields are exposed and a non-empty degradation reason is mandatory. -A changed execution state without that reason fails closed. - -The provider result remains a **candidate**. The mock contract explicitly keeps -`activation_allowed=false`, `promoted=false`, and -`executed_external_model=false`. The provider adapter cannot promote its own -output to last-known-good; promotion belongs to a separate validation/harness -step. +```text +mode:full->guided +dropped:motifs +changed:duration_seconds +changed:sync_cues.length +changed:sync_cues[0].time_seconds +``` -## Commercial-use boundary +A provider must never silently change requested state. -Permissively licensed code does not make separately licensed model weights -commercially usable. An adapter may exist for research/evaluation while its -weights remain forbidden in a commercial path. +`MockMusicProvider` is deterministic and performs no inference. A fully capable +mock keeps the requested state unchanged. A limited mock may use an explicit +fallback and list unsupported fields, but any material delta requires a +non-empty degradation reason or the preparation fails closed. -For StoryCore/Obolune-facing production, fail closed when: +## Portable provider handoff -- the weights licence is unknown; -- the licence is non-commercial and the requested path is commercial; -- output terms prevent the intended distribution; -- attribution/provenance requirements cannot be satisfied. +`build_provider_handoff()` creates a data-only interchange envelope containing: -This specifically means that a useful open-source architecture may be studied or -adapted without making its restricted weights a production dependency. +- plan ID; +- provider/version/model; +- adapter/harness and hardware identity; +- requested/executed modes; +- unsupported fields, deltas, reason and acknowledgement; +- candidate artifact identity and optional digests; +- verification state and evidence references. -## Validation order +It does not import Botte Secrète, execute a provider or write memory. Provider +results remain candidates. The envelope always carries +`activation_allowed=false`, `promoted=false` and +`memory_write_performed=false`. A `verified` handoff requires evidence. -1. JSON/schema and reference integrity. -2. Licence/provenance gate. -3. Plan-level checks: duration, section ordering, cue references and bounded - values. -4. Adapter preparation with explicit requested/executed delta. -5. Technical audio checks after a real provider exists. -6. Narrative checks: cue timing, dialogue masking, motif/scene consistency. -7. Optional human review. -8. Promotion to last-known-good only with independent evidence. +## Commercial boundary -A failure keeps the candidate and diagnostics for comparison but leaves the -previous verified artefact intact. +Code, model weights, datasets and assets retain separate provenance/licences. +Permissive adapter code cannot make restricted model weights commercially +usable. Non-commercial or unknown/unqualified weights remain outside the +StoryCore/Obolune commercial path until independently qualified. -## Benchmark contract +## Benchmark identity -Do not compare only `model A` versus `model B`. Record the complete path: +Future measurements must retain the complete path: `story fixture x model/provider x adapter/harness x hardware x parameters` -Minimum measures should include: - -- successful render rate; -- plan fields honoured / dropped; -- latency and peak resource use when observable; -- duration/cue alignment error; -- narrative evaluator result; -- licence eligibility for the target use; -- human preference only when the comparison protocol records it explicitly. - -A cheaper or smaller generator can therefore win when its harness better obeys -the plan. +A model-only score is insufficient because the harness can materially change +plan preservation, failures, quality, latency and resource use. ## Current proof boundary -The isolated `MusicPlan Contract` workflow compiles `src/music_plan.py` and -`src/music_provider.py`, runs the focused contract tests, and parses the schema -and fixtures on Python 3.10 and 3.12. Claims about this slice must remain bound -to an exact-head successful run. - -This slice does **not** generate music, benchmark audio quality, choose a -production provider, download weights, call a remote service, authorize a -release, or authorize a merge. +The isolated `MusicPlan Contract` workflow runs on Python 3.10 and 3.12. It +installs only focused test/schema dependencies, compiles the MusicPlan/provider +contracts, executes the focused regression suite, checks the Draft 2020-12 +schema, and validates all three reference fixtures against it. -## Relationship to the Botte Secrète Execution Harness +Exact-head CI remains the authority for PR claims. This contract does **not** +prove real audio quality, a production provider, output rights beyond the +explicit licence policy, hardware performance, end-to-end StoryCore integration, +or last-known-good audio recovery. -When StoryCore runs under Botte Secrète, map: +## Relationship to Botte Secrète -- `MusicPlan` + narrative references -> context snapshot; -- provider capabilities -> capabilities; -- licence, VRAM, duration and budget -> constraints; -- provider execution report -> requested/executed delta; -- generated stems/mix -> candidate artefacts; -- validators -> evidence; -- previous accepted soundtrack -> recovery point; -- benchmark observations -> Capability Atlas. +StoryCore remains standalone. A future Botte integration should consume the +portable handoff as data rather than importing Botte as a hard runtime +dependency. Conceptually: -StoryCore must remain usable without Botte; the interchange should stay a small -JSON/data contract rather than importing Botte as a hard runtime dependency. +- MusicPlan + narrative refs -> context snapshot; +- provider capabilities/licence/hardware -> constraints; +- provider handoff -> execution delta + candidate artifact; +- independent validators -> evidence; +- accepted soundtrack -> recovery point; +- measured provider runs -> Capability Atlas observations. ## Next bounded slice -Define a provider-neutral handoff/evidence envelope carrying the execution delta, -provider/harness/hardware identity, candidate artifact reference, verification -state, and explicit non-activation/non-promotion flags. Only after that envelope -is proven should a real local or external music backend be measured. +Before any real provider is connected, add deterministic artifact-digest and +independent-verification fixtures around the portable handoff. Real local or +external music inference waits for explicit licence/hardware qualification and +must remain non-promoting by default. From 189d3d15fc711b006405ecd997485a4583ab5e4c Mon Sep 17 00:00:00 2001 From: Sylvain Galliez <128614184+zedarvates@users.noreply.github.com> Date: Sun, 13 Sep 2026 14:34:39 +0200 Subject: [PATCH 109/113] fix(ci): run MusicPlan mutation probe as a module --- .github/workflows/music-plan-contract.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/music-plan-contract.yml b/.github/workflows/music-plan-contract.yml index 537a2382..62911ea7 100644 --- a/.github/workflows/music-plan-contract.yml +++ b/.github/workflows/music-plan-contract.yml @@ -34,7 +34,7 @@ jobs: - name: Run MusicPlan contract tests run: python -m pytest -q tests/test_music_plan.py - name: Run controlled negative mutation probe - run: python scripts/music_plan_mutation_probe.py + run: python -m scripts.music_plan_mutation_probe - name: Verify Draft 2020-12 schema and reference fixtures run: | python - <<'PY' From d7af6f568b7e2ab94b5144f23d0e0b6de3dabeb9 Mon Sep 17 00:00:00 2001 From: Sylvain Galliez <128614184+zedarvates@users.noreply.github.com> Date: Sun, 13 Sep 2026 15:00:47 +0200 Subject: [PATCH 110/113] test(audio): add private holdout evaluator protocol --- scripts/music_plan_holdout_evaluator.py | 57 +++++++++++++++++++++++++ 1 file changed, 57 insertions(+) create mode 100644 scripts/music_plan_holdout_evaluator.py diff --git a/scripts/music_plan_holdout_evaluator.py b/scripts/music_plan_holdout_evaluator.py new file mode 100644 index 00000000..3526cc6b --- /dev/null +++ b/scripts/music_plan_holdout_evaluator.py @@ -0,0 +1,57 @@ +#!/usr/bin/env python3 +"""JSON-stdin evaluator for private MusicPlan holdouts. + +This file contains no holdout cases. It is designed to be invoked by Botte +Secrete's private holdout runner. The private payload supplies a MusicPlan and a +hidden expected-validity bit; stdout returns only {"passed": bool}. +""" + +from __future__ import annotations + +import json +import sys +from typing import Any + +from src.music_plan import validate_music_plan +from src.music_provider import MockMusicProvider, MusicProviderContractError + + +def evaluate(payload: dict[str, Any]) -> bool: + wrapper = payload.get("input") + if not isinstance(wrapper, dict): + return False + plan = wrapper.get("plan") + expected_valid = wrapper.get("expected_valid") + if not isinstance(plan, dict) or not isinstance(expected_valid, bool): + return False + + validation = validate_music_plan(plan) + validator_valid = validation.valid + + provider_valid = True + try: + MockMusicProvider().prepare(plan) + except MusicProviderContractError: + provider_valid = False + except Exception: + return False + + observed_valid = validator_valid and provider_valid + return observed_valid is expected_valid + + +def main() -> int: + try: + payload = json.loads(sys.stdin.read()) + except json.JSONDecodeError: + print(json.dumps({"passed": False})) + return 0 + if not isinstance(payload, dict): + print(json.dumps({"passed": False})) + return 0 + print(json.dumps({"passed": evaluate(payload)})) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) From 385a2ea631c271bc44e0169d81b8062f2408bd9d Mon Sep 17 00:00:00 2001 From: Sylvain Galliez <128614184+zedarvates@users.noreply.github.com> Date: Sun, 13 Sep 2026 15:01:02 +0200 Subject: [PATCH 111/113] ci(audio): verify private holdout evaluator protocol --- .github/workflows/music-plan-contract.yml | 21 ++++++++++++++++++++- 1 file changed, 20 insertions(+), 1 deletion(-) diff --git a/.github/workflows/music-plan-contract.yml b/.github/workflows/music-plan-contract.yml index 62911ea7..cf3ecd98 100644 --- a/.github/workflows/music-plan-contract.yml +++ b/.github/workflows/music-plan-contract.yml @@ -8,6 +8,7 @@ on: - "src/music_plan.py" - "src/music_provider.py" - "scripts/music_plan_mutation_probe.py" + - "scripts/music_plan_holdout_evaluator.py" - "tests/test_music_plan.py" - "tests/fixtures/music_plan/**" - "docs/music-planning-layer-v1.md" @@ -30,11 +31,29 @@ jobs: - name: Install focused validation dependencies run: python -m pip install 'pytest>=8,<10' 'jsonschema>=4.23,<5' - name: Compile model-free contracts - run: python -m py_compile src/music_plan.py src/music_provider.py scripts/music_plan_mutation_probe.py + run: python -m py_compile src/music_plan.py src/music_provider.py scripts/music_plan_mutation_probe.py scripts/music_plan_holdout_evaluator.py - name: Run MusicPlan contract tests run: python -m pytest -q tests/test_music_plan.py - name: Run controlled negative mutation probe run: python -m scripts.music_plan_mutation_probe + - name: Verify private holdout evaluator protocol + run: | + python - <<'PY' + import json + import subprocess + import sys + from pathlib import Path + + plan = json.loads(Path('tests/fixtures/music_plan/full.json').read_text(encoding='utf-8')) + request = {"id": "public-protocol-smoke", "input": {"plan": plan, "expected_valid": True}} + r = subprocess.run( + [sys.executable, '-m', 'scripts.music_plan_holdout_evaluator'], + input=json.dumps(request), text=True, capture_output=True, check=True, + ) + response = json.loads(r.stdout) + assert response == {"passed": True}, response + print('holdout evaluator protocol OK') + PY - name: Verify Draft 2020-12 schema and reference fixtures run: | python - <<'PY' From 552a39febeb8ec47e7cc28bf492cc4f39b008bae Mon Sep 17 00:00:00 2001 From: Sylvain Galliez <128614184+zedarvates@users.noreply.github.com> Date: Sun, 13 Sep 2026 20:52:05 +0200 Subject: [PATCH 112/113] fix(asset-creator): prepare Trellis templates before image upload --- .../storycore_asset_creator/README.md | 29 ++- .../src/pipeline_image_to_3d.py | 32 ++- .../src/trellis_workflows.py | 34 +++- tests/asset_creator/README.md | 39 ++++ tests/asset_creator/test_preflight.py | 188 ++++++++++++++++++ 5 files changed, 301 insertions(+), 21 deletions(-) create mode 100644 tests/asset_creator/README.md create mode 100644 tests/asset_creator/test_preflight.py diff --git a/addons/official/storycore_asset_creator/README.md b/addons/official/storycore_asset_creator/README.md index 2cd61736..748cae7b 100644 --- a/addons/official/storycore_asset_creator/README.md +++ b/addons/official/storycore_asset_creator/README.md @@ -91,12 +91,37 @@ workflows/ trellis2_lowvram.json ← workflow Low VRAM (recommandé) trellis2_standard.json ← workflow qualité standard trellis2_lowpoly.json ← workflow low poly - trellis2_trunk_only.json ← workflow tronc seul (inclus) + trellis2_trunk_only.json ← workflow tronc seul (à fournir) ``` > Les workflows `PixelArtistry_Trellis2_*.json` de votre dossier `Downloads/3s/` > doivent être copiés ici et renommés selon la convention ci-dessus. +Ces quatre fichiers ne sont pas inclus dans l'arbre du dépôt examiné pour cette +correction. Un nom de preset déclaré ne garantit pas qu'une recette est installée. + +Le pipeline prépare désormais la recette **avant tout appel client et tout upload**. +Une image source absente, un preset inconnu, un fichier JSON absent ou illisible, +une structure d'éditeur incompatible ou un nœud d'image non modifiable provoquent +une erreur locale. Un preset inconnu ne sélectionne plus silencieusement `lowvram`. +Le template préparé est conservé en mémoire ; après upload, seul son nom d'image +est remplacé par celui renvoyé par ComfyUI, sans relecture de la recette sur disque. + +**Portée de cette validation :** le chargeur manipule encore les champs d'éditeur +`nodes` / `widgets_values`. Cette correction ne convertit pas le template au format +API de `/prompt` et ne prouve pas que le graphe est exécutable. La conversion, la +disponibilité des nœuds/modèles, les délais HTTP et un essai réel restent à vérifier +à partir des workflows effectivement utilisés dans Asset Factory / ComfyUI. + +Tests locaux sur recettes synthétiques, depuis la racine du dépôt : + +```bash +python -m unittest discover -s tests/asset_creator -v +``` + +Ils ne nécessitent ni Blender, ni serveur ComfyUI, ni GPU. Voir leur +[portée précise](../../../tests/asset_creator/README.md). + ### 3. Installation de l'addon ``` @@ -181,7 +206,7 @@ storycore_asset_creator/ ├── trellis2_lowvram.json ← Workflow ComfyUI (à copier) ├── trellis2_standard.json ← Workflow ComfyUI (à copier) ├── trellis2_lowpoly.json ← Workflow ComfyUI (à copier) - └── trellis2_trunk_only.json ← Workflow tronc seul (placeholder inclus) + └── trellis2_trunk_only.json ← Workflow tronc seul (à copier) ``` --- diff --git a/addons/official/storycore_asset_creator/src/pipeline_image_to_3d.py b/addons/official/storycore_asset_creator/src/pipeline_image_to_3d.py index 68f458d1..7b94e0b5 100644 --- a/addons/official/storycore_asset_creator/src/pipeline_image_to_3d.py +++ b/addons/official/storycore_asset_creator/src/pipeline_image_to_3d.py @@ -20,7 +20,11 @@ from typing import Any, Callable, Dict, Optional from .comfyui_client import ComfyUIClient -from .trellis_workflows import build_workflow, get_expected_output_names +from .trellis_workflows import ( + build_workflow, + get_expected_output_names, + patch_input_image, +) class ImageTo3DPipeline: @@ -94,7 +98,19 @@ def run( start = time.time() img_path = Path(image_path) output_path = Path(output_dir) - output_path.mkdir(parents=True, exist_ok=True) + + # Preparer une seule fois avant tout appel client ou creation de sortie. + if not img_path.is_file(): + raise FileNotFoundError(f"Image source introuvable: {img_path}") + self._log(f"Preparation workflow Trellis2 ({preset})...", progress_callback) + workflow = build_workflow( + image_filename=img_path.name, + asset_name=asset_name, + preset=preset, + seed=seed, + remove_background=remove_background, + resolution=512 if preset == "lowvram" else 1024, + ) # 1. Verifier ComfyUI self._log("Verification ComfyUI...", progress_callback) @@ -104,21 +120,15 @@ def run( ) # 2. Upload image + output_path.mkdir(parents=True, exist_ok=True) self._log(f"Upload image: {img_path.name}", progress_callback) upload_info = self.client.upload_image(str(img_path)) uploaded_filename = upload_info.get("name", img_path.name) self._log(f"Image uploadee: {uploaded_filename}", progress_callback) - # 3. Construire et envoyer le workflow + # 3. Reutiliser le template prepare avec le nom retourne par l'upload. self._log(f"Lancement Trellis2 ({preset})...", progress_callback) - workflow = build_workflow( - image_filename=uploaded_filename, - asset_name=asset_name, - preset=preset, - seed=seed, - remove_background=remove_background, - resolution=512 if preset == "lowvram" else 1024, - ) + workflow = patch_input_image(workflow, uploaded_filename) prompt_id = self.client.queue_workflow(workflow) self._log(f"Workflow en queue: {prompt_id}", progress_callback) diff --git a/addons/official/storycore_asset_creator/src/trellis_workflows.py b/addons/official/storycore_asset_creator/src/trellis_workflows.py index 67791040..5b1d524e 100644 --- a/addons/official/storycore_asset_creator/src/trellis_workflows.py +++ b/addons/official/storycore_asset_creator/src/trellis_workflows.py @@ -44,12 +44,21 @@ def load_workflow(preset: str = "lowvram") -> Dict[str, Any]: Returns: dict workflow (deepcopy pour eviter mutations) """ - filename = PRESETS.get(preset, WORKFLOW_LOWVRAM) + if not isinstance(preset, str) or preset not in PRESETS: + raise ValueError(f"Preset Trellis2 inconnu: {preset!r}") + filename = PRESETS[preset] path = _WORKFLOWS_DIR / filename if not path.exists(): raise FileNotFoundError(f"Workflow introuvable: {path}") with open(path, encoding="utf-8") as f: - return copy.deepcopy(json.load(f)) + workflow = json.load(f) + if ( + not isinstance(workflow, dict) + or not isinstance(workflow.get("nodes"), list) + or not all(isinstance(node, dict) for node in workflow["nodes"]) + ): + raise ValueError("Workflow Trellis2: liste de nodes d'editeur attendue") + return copy.deepcopy(workflow) def patch_input_image(workflow: Dict[str, Any], image_filename: str) -> Dict[str, Any]: @@ -59,10 +68,18 @@ def patch_input_image(workflow: Dict[str, Any], image_filename: str) -> Dict[str Le node d'entree image a type 'Trellis2LoadImageWithTransparency'. widgets_values[0] = nom du fichier image. """ - for node in workflow.get("nodes", []): - if node.get("type") == "Trellis2LoadImageWithTransparency": - if "widgets_values" in node and len(node["widgets_values"]) > 0: - node["widgets_values"][0] = image_filename + image_nodes = [ + node + for node in workflow.get("nodes", []) + if node.get("type") == "Trellis2LoadImageWithTransparency" + ] + if not image_nodes or any( + not isinstance(node.get("widgets_values"), list) or not node["widgets_values"] + for node in image_nodes + ): + raise ValueError("Workflow Trellis2: noeud d'image absent ou non modifiable") + for node in image_nodes: + node["widgets_values"][0] = image_filename return workflow @@ -140,9 +157,10 @@ def build_workflow( resolution: int = 512, ) -> Dict[str, Any]: """ - Construit un workflow pret a envoyer a ComfyUI. + Prepare les champs du template d'editeur Trellis2. Applique tous les patches dans l'ordre correct. + Ne valide ni le format de soumission API ni les dependances installees. Args: image_filename : nom du fichier upload dans ComfyUI (ex: "hero.png") @@ -152,7 +170,7 @@ def build_workflow( remove_background: True si l'image n'a pas de fond transparent resolution : 512 (lowvram) ou 1024 (qualite) - Returns: workflow dict pret pour ComfyUIClient.queue_workflow() + Returns: template dict prepare (compatibilite API a verifier separement). """ wf = load_workflow(preset) wf = patch_input_image(wf, image_filename) diff --git a/tests/asset_creator/README.md b/tests/asset_creator/README.md new file mode 100644 index 00000000..99ce11ac --- /dev/null +++ b/tests/asset_creator/README.md @@ -0,0 +1,39 @@ +# Asset Creator: local preflight regressions + +Run from the repository root with Python 3.11+: + +```bash +python -m unittest discover -s tests/asset_creator -v +``` + +The suite uses the standard library, temporary files, synthetic editor templates +and a fake ComfyUI client. Socket creation is forbidden during each test. The fake +download returns paths without writing GLBs: successful ordering is not evidence +of generation, artifact integrity, or compatibility with the ComfyUI API. + +The nine tests cover: + +- missing recipe and malformed JSON before any client call; +- an unknown preset with an available `lowvram` recipe, without silent fallback; +- a missing source image before any client call; +- invalid editor structure and missing/unpatchable input-image nodes; +- one preparation reused after the on-disk recipe changes, preserving the + requested parameters and the server-returned image name; +- all four declared presets preparing without changing their source files; +- an unavailable server causing no upload or submission. + +On the base `5b6c83bd26f60f7eb5e4da53f958f2d3b06edb4a`, these tests expose the +upload-before-preparation ordering, silent preset fallback and template-validation +gaps. The suite reports multiple failures within parameterized subtests; these +must not be represented as distinct end-to-end generation attempts. + +Local validation on 2026-09-13 (Linux, Python 3.12.14): the unmodified base with +the regression suite reports 8 failures and 5 errors across its subtests; with +the correction all 9 test methods pass. `git diff --check` also passes. This is +a focused sparse-checkout proof, not a repository-wide test run. Ruff was not +available in this environment and no Ruff result is claimed. + +The addon still patches editor JSON rather than constructing a proven API prompt. +Real PixelArtistry/Trellis2 JSON, node/model versions, API-format conversion, +timeouts, job recovery, real outputs and the full Asset Factory integration remain +separate work. No workflow recipe or model is downloaded by these tests. diff --git a/tests/asset_creator/test_preflight.py b/tests/asset_creator/test_preflight.py new file mode 100644 index 00000000..2d06be36 --- /dev/null +++ b/tests/asset_creator/test_preflight.py @@ -0,0 +1,188 @@ +"""Local ordering regressions; synthetic templates, no ComfyUI or Blender run.""" + +import copy +import io +import json +from contextlib import redirect_stdout +from pathlib import Path +from tempfile import TemporaryDirectory +import unittest +from unittest.mock import Mock, patch + +from addons.official.storycore_asset_creator.src import trellis_workflows as workflows +from addons.official.storycore_asset_creator.src.pipeline_image_to_3d import ( + ImageTo3DPipeline, +) + + +def synthetic_template(): + """Exercise existing editor fields; this is not an executable API graph.""" + return { + "nodes": [ + {"type": "Trellis2LoadImageWithTransparency", "widgets_values": ["old.png"]}, + {"type": "PrimitiveString", "widgets_values": ["OldAsset"]}, + { + "type": "Trellis2MeshWithVoxelAdvancedGenerator", + "widgets_values": [0, "fixed", "1024"], + }, + {"type": "Trellis2PreProcessImage", "widgets_values": [25, True]}, + ], + "extra": {"fixture": "synthetic-editor-template"}, + } + + +class PreflightTests(unittest.TestCase): + def setUp(self): + temporary = TemporaryDirectory() + self.addCleanup(temporary.cleanup) + self.root = Path(temporary.name) + self.recipes = self.root / "workflows" + self.recipes.mkdir() + self.image = self.root / "source.png" + # The fake upload never reads this marker as an image. + self.image.write_bytes(b"synthetic input marker, not a PNG") + self.output = self.root / "output" + recipe_patch = patch.object(workflows, "_WORKFLOWS_DIR", self.recipes) + recipe_patch.start() + self.addCleanup(recipe_patch.stop) + # Fail any accidental request, including a future unmocked code path. + network_patch = patch( + "socket.socket", side_effect=AssertionError("network forbidden") + ) + network_patch.start() + self.addCleanup(network_patch.stop) + self.pipeline = ImageTo3DPipeline(comfyui_port=8188) + self.client = Mock(spec=self.pipeline.client) + self.pipeline.client = self.client + self.client.is_alive.return_value = True + self.client.upload_image.return_value = {"name": "server-renamed.png"} + self.client.queue_workflow.return_value = "synthetic-prompt-id" + self.client.wait_for_result.return_value = {"fixture": {}} + self.client.get_output_files.return_value = [] + self.client.download_output.side_effect = ( + lambda filename, dest: str(Path(dest) / filename) + ) + + def write_recipe(self, template=None, preset="lowvram"): + path = self.recipes / workflows.PRESETS[preset] + path.write_text( + json.dumps(synthetic_template() if template is None else template), + encoding="utf-8", + ) + return path + + def run_pipeline(self, **kwargs): + with redirect_stdout(io.StringIO()): + return self.pipeline.run( + image_path=str(self.image), + asset_name="Fixture", + output_dir=str(self.output), + **kwargs, + ) + + def assert_no_client_calls(self): + self.assertEqual(self.client.mock_calls, []) + self.assertFalse(self.output.exists()) + + def test_missing_recipe_fails_before_any_client_call(self): + with self.assertRaises(FileNotFoundError): + self.run_pipeline() + self.assert_no_client_calls() + + def test_invalid_json_fails_before_any_client_call(self): + path = self.write_recipe() + path.write_text("{broken", encoding="utf-8") + with self.assertRaises(json.JSONDecodeError): + self.run_pipeline() + self.assert_no_client_calls() + + def test_unknown_preset_does_not_fall_back_to_lowvram(self): + self.write_recipe() + with self.assertRaisesRegex(ValueError, "Preset Trellis2 inconnu"): + self.run_pipeline(preset="lowvrma") + self.assert_no_client_calls() + + def test_missing_source_fails_before_any_client_call(self): + self.write_recipe() + self.image.unlink() + with self.assertRaises(FileNotFoundError): + self.run_pipeline() + self.assert_no_client_calls() + + def test_invalid_editor_shape_fails_before_any_client_call(self): + for template in ([], {}, {"nodes": {}}, {"nodes": [None]}): + with self.subTest(template=template): + self.write_recipe(template) + with self.assertRaises(ValueError): + self.run_pipeline() + self.assert_no_client_calls() + + def test_missing_or_unpatchable_image_node_fails_before_upload(self): + for widgets in (None, [], "not-a-widget-list"): + with self.subTest(widgets=widgets): + template = synthetic_template() + template["nodes"][0]["widgets_values"] = widgets + self.write_recipe(template) + with self.assertRaises(ValueError): + self.run_pipeline() + self.assert_no_client_calls() + self.write_recipe({"nodes": [{"type": "Unrelated", "widgets_values": []}]}) + with self.assertRaises(ValueError): + self.run_pipeline() + self.assert_no_client_calls() + + def test_prepared_recipe_is_reused_and_upload_name_is_patched(self): + path = self.write_recipe() + original = json.loads(path.read_text(encoding="utf-8")) + + def server_available(): + # A template changed after preflight must not replace the prepared one. + path.write_text("{changed after preflight", encoding="utf-8") + return True + + self.client.is_alive.side_effect = server_available + with patch.object( + workflows, "load_workflow", wraps=workflows.load_workflow + ) as load: + result = self.run_pipeline(seed=73, remove_background=False) + load.assert_called_once_with("lowvram") + self.client.upload_image.assert_called_once_with(str(self.image)) + self.client.queue_workflow.assert_called_once() + submitted = self.client.queue_workflow.call_args.args[0] + expected = copy.deepcopy(original) + expected["nodes"][0]["widgets_values"][0] = "server-renamed.png" + expected["nodes"][1]["widgets_values"][0] = "Fixture" + expected["nodes"][2]["widgets_values"] = [73, "fixed", "512"] + expected["nodes"][3]["widgets_values"] = [25, False] + self.assertEqual(submitted, expected) + self.assertEqual(result["prompt_id"], "synthetic-prompt-id") + self.assertEqual( + [call[0] for call in self.client.mock_calls[:4]], + ["is_alive", "upload_image", "queue_workflow", "wait_for_result"], + ) + + def test_all_declared_presets_can_prepare_without_modifying_source(self): + for preset in workflows.PRESETS: + with self.subTest(preset=preset): + path = self.write_recipe(preset=preset) + before = path.read_bytes() + prepared = workflows.build_workflow( + "replacement.png", "Fixture", preset + ) + self.assertEqual( + prepared["nodes"][0]["widgets_values"], ["replacement.png"] + ) + self.assertEqual(path.read_bytes(), before) + + def test_unavailable_server_never_uploads_or_queues(self): + self.write_recipe() + self.client.is_alive.return_value = False + with self.assertRaises(ConnectionError): + self.run_pipeline() + self.client.is_alive.assert_called_once() + self.client.upload_image.assert_not_called() + self.client.queue_workflow.assert_not_called() + + +if __name__ == "__main__": + unittest.main() From d5bdfc5fcf9dc559d603666337b576570f9e2d78 Mon Sep 17 00:00:00 2001 From: Sylvain Galliez <128614184+zedarvates@users.noreply.github.com> Date: Thu, 17 Sep 2026 19:37:40 +0200 Subject: [PATCH 113/113] fix(asset-creator): handle ComfyUI timeouts and terminal status --- .../storycore_asset_creator/COMFYUI_CLIENT.md | 107 ++++++ .../storycore_asset_creator/README.md | 6 + .../src/comfyui_client.py | 179 ++++++--- tests/asset_creator/test_comfyui_client.py | 342 ++++++++++++++++++ 4 files changed, 589 insertions(+), 45 deletions(-) create mode 100644 addons/official/storycore_asset_creator/COMFYUI_CLIENT.md create mode 100644 tests/asset_creator/test_comfyui_client.py diff --git a/addons/official/storycore_asset_creator/COMFYUI_CLIENT.md b/addons/official/storycore_asset_creator/COMFYUI_CLIENT.md new file mode 100644 index 00000000..805868cd --- /dev/null +++ b/addons/official/storycore_asset_creator/COMFYUI_CLIENT.md @@ -0,0 +1,107 @@ +# Suivi des tâches ComfyUI + +Le client Python existant reste le point d'accès HTTP de l'Asset Creator. +Cette correction n'ajoute aucun service externe, modèle ou abonnement. + +## Deux défauts reproduits + +Sur le commit `5b6c83bd26f60f7eb5e4da53f958f2d3b06edb4a`, avec des réponses +simulées et les sockets interdites : + +- Une entrée d'historique contenant `status_str="error"`, `completed=false` + et des sorties partielles était renvoyée comme un résultat réussi. +- L'appel HTTP à `/history/{prompt_id}` ne recevait aucun `timeout`. + La lecture du client montre la même omission pour les autres appels, + sauf `/system_stats`. + +## Comportement du client + +| Situation | Résultat | +|---|---| +| `status.status_str == "success"` et `status.completed is True` | Retourne le dictionnaire `outputs`, même vide. | +| `status.status_str == "error"` | Lève `RuntimeError`, même avec des fichiers intermédiaires. | +| Ancien champ `error` au premier niveau | Lève `RuntimeError`. | +| Historique absent, statut inconnu ou incomplet | Continue le suivi jusqu'à son expiration. | +| Budget de suivi écoulé | `ComfyUIWaitTimeout`, avec `reason="wait_deadline"`. | +| `requests.exceptions.Timeout` pendant un GET de suivi | `ComfyUIWaitTimeout`, avec `reason="http_timeout"` et cause conservée. | +| Autre erreur HTTP ou réseau | Exception Requests transmise à l'appelant. | + +`ComfyUIWaitTimeout` hérite de `TimeoutError` et expose `prompt_id`. +Le client ne supprime pas la tâche, ne l'annule pas et ne la soumet pas à +nouveau quand le suivi expire. Une expiration ne prouve pas que la tâche a +échoué ou qu'elle tourne encore ; son état doit être relu sur le serveur. + +## Deux délais distincts + +- `request_timeout=30.0` : délai Requests appliqué à tous les appels HTTP. + Le contrôle de disponibilité garde un plafond de 5 secondes. +- `wait_for_result(..., timeout=300.0, poll_interval=2.0)` : budget du suivi + et intervalle entre lectures. Le budget utilise une horloge monotone. + Avant chaque GET, le délai HTTP est limité au budget restant ; les pauses + sont également limitées. Le budget est revérifié au retour des GET et + après le callback avant de dormir. + +Ces trois paramètres doivent être finis et strictement positifs. +`get_history` et `get_queue_status` acceptent aussi un `timeout` nommé pour +réduire leur délai HTTP, sans dépasser celui du client. + +Le délai Requests concerne la connexion et l'inactivité de lecture. Il +ne garantit **pas** une durée totale stricte : plusieurs adresses réseau, +une réponse qui arrive lentement ou un callback bloquant peuvent dépasser +le budget. Aucun thread n'est abandonné pour simuler une telle garantie. +Voir la [documentation officielle Requests sur les délais](https://requests.readthedocs.io/en/latest/user/advanced/#timeouts). + +## Reprendre le suivi + +```python +from addons.official.storycore_asset_creator.src.comfyui_client import ( + ComfyUIClient, + ComfyUIWaitTimeout, +) + +client = ComfyUIClient.from_project_config(request_timeout=30.0) +# prompt_id est l'identifiant déjà renvoyé par queue_workflow et sauvegardé. +try: + outputs = client.wait_for_result(prompt_id, timeout=300.0) +except ComfyUIWaitTimeout as error: + prompt_id_a_reprendre = error.prompt_id + # Plus tard : client.wait_for_result(prompt_id_a_reprendre, timeout=300.0) +``` + +L'appelant doit sauvegarder l'identifiant dès l'acceptation. Cette correction +ne crée pas de stockage persistant de tâches et ne raccorde pas encore la +reprise à une interface MCP, CLI ou Blender. + +Si le POST `/prompt` expire avant de renvoyer un identifiant, l'acceptation +reste incertaine. Le client laisse remonter l'exception Requests et n'ajoute +aucune relance automatique. Il faut vérifier la file du serveur avant de +décider d'une nouvelle soumission. + +Les réponses des téléchargements sont fermées même en cas d'erreur. +Un téléchargement interrompu peut toujours laisser un fichier partiel : +la correction ne rend pas l'écriture atomique. + +## Contrat et vérification + +Contrat lu dans ComfyUI au commit +[`387f98aa2822f684b8597959a52a467d88cc4806`](https://github.com/Comfy-Org/ComfyUI/commit/387f98aa2822f684b8597959a52a467d88cc4806) : +[`execution.py`](https://github.com/Comfy-Org/ComfyUI/blob/387f98aa2822f684b8597959a52a467d88cc4806/execution.py#L1316-L1340) +décrit le statut enregistré ; +[`main.py`](https://github.com/Comfy-Org/ComfyUI/blob/387f98aa2822f684b8597959a52a467d88cc4806/main.py#L367-L372) +le renseigne à la fin du traitement. Le client ne déduit pas la réussite +de la seule présence d'un fichier. + +Depuis la racine du dépôt, avec `requests` installé : + +```bash +python -m unittest discover -s tests/asset_creator -p test_comfyui_client.py -v +``` + +Les 18 tests utilisent des réponses HTTP simulées et une horloge contrôlée. +La création de sockets y est interdite. Ils couvrent les statuts terminaux, +les sorties partielles, les délais, la reprise et les ressources HTTP. +Vérification locale effectuée avec Python 3.12.14 et Requests 2.34.2. + +Il ne s'agit pas d'un test sur un serveur ComfyUI, Blender ou un GPU. +Les recettes Trellis2 locales, leurs extensions et le format API réellement +soumis restent à vérifier dans l'environnement d'exécution. diff --git a/addons/official/storycore_asset_creator/README.md b/addons/official/storycore_asset_creator/README.md index 2cd61736..33744eb0 100644 --- a/addons/official/storycore_asset_creator/README.md +++ b/addons/official/storycore_asset_creator/README.md @@ -51,6 +51,12 @@ Le port ComfyUI **n'est jamais supposé** — il varie selon l'édition install 3. Variables d'environnement ← STORYCORE_COMFYUI_HOST / STORYCORE_COMFYUI_PORT ``` +### Délais et reprise du suivi + +Le client distingue le délai HTTP du budget d'attente de la génération. +Une expiration du suivi conserve l'identifiant du prompt ; elle ne l'annule +pas. Voir [le contrat du client et les tests](COMFYUI_CLIENT.md). + ### Override via variables d'environnement ```bash diff --git a/addons/official/storycore_asset_creator/src/comfyui_client.py b/addons/official/storycore_asset_creator/src/comfyui_client.py index 6e5cf545..4a6fafe6 100644 --- a/addons/official/storycore_asset_creator/src/comfyui_client.py +++ b/addons/official/storycore_asset_creator/src/comfyui_client.py @@ -10,10 +10,11 @@ from __future__ import annotations +import math import time import uuid from pathlib import Path -from typing import Any, Dict, Optional +from typing import Any try: import requests @@ -21,6 +22,29 @@ requests = None # Blender embeds its own Python; requests peut manquer +class ComfyUIWaitTimeout(TimeoutError): + """Suivi interrompu; prompt_id permet de reprendre sans soumettre de nouveau.""" + + def __init__(self, prompt_id: str, reason: str = "wait_deadline"): + self.prompt_id = prompt_id + self.reason = reason + detail = ( + "delai HTTP depasse" + if reason == "http_timeout" + else "delai d'attente depasse" + ) + super().__init__( + f"ComfyUI {prompt_id}: {detail}. " + "Le suivi est interrompu; le prompt n'a pas ete annule." + ) + + +def _positive_seconds(value: float, name: str) -> float: + if not math.isfinite(value) or value <= 0: + raise ValueError(f"{name} doit etre un nombre fini strictement positif") + return value + + class ComfyUIClient: """ Client HTTP pour ComfyUI (localhost ou remote). @@ -35,7 +59,14 @@ class ComfyUIClient: NE PAS hardcoder le port 8188 — lire depuis config/comfyui_config.json. """ - def __init__(self, host: str = "127.0.0.1", port: Optional[int] = None): + def __init__( + self, + host: str = "127.0.0.1", + port: int | None = None, + *, + request_timeout: float = 30.0, + ): + self.request_timeout = _positive_seconds(request_timeout, "request_timeout") if port is None: # Tenter de charger depuis la config projet try: @@ -51,7 +82,9 @@ def __init__(self, host: str = "127.0.0.1", port: Optional[int] = None): self.client_id = str(uuid.uuid4()) @classmethod - def from_project_config(cls, blender_prefs=None) -> "ComfyUIClient": + def from_project_config( + cls, blender_prefs=None, *, request_timeout: float = 30.0 + ) -> ComfyUIClient: """ Cree un client en lisant la config depuis config/comfyui_config.json (avec surcharge optionnelle depuis les preferences Blender). @@ -66,19 +99,28 @@ def from_project_config(cls, blender_prefs=None) -> "ComfyUIClient": from .config_loader import get_comfyui_connection host, port = get_comfyui_connection(blender_prefs=blender_prefs) - return cls(host=host, port=port) + return cls(host=host, port=port, request_timeout=request_timeout) + + def _http_timeout(self, timeout: float | None) -> float: + if timeout is None: + return self.request_timeout + return min(self.request_timeout, _positive_seconds(timeout, "timeout")) # ── API ────────────────────────────────────────────────────────────────── def is_alive(self) -> bool: """Verifie que ComfyUI repond.""" + if requests is None: + return False try: - r = requests.get(f"{self.base_url}/system_stats", timeout=5) + r = requests.get( + f"{self.base_url}/system_stats", timeout=min(5.0, self.request_timeout) + ) return r.status_code == 200 - except Exception: + except requests.exceptions.RequestException: return False - def upload_image(self, image_path: str, subfolder: str = "") -> Dict[str, Any]: + def upload_image(self, image_path: str, subfolder: str = "") -> dict[str, Any]: """ Upload une image dans ComfyUI input/. @@ -90,12 +132,17 @@ def upload_image(self, image_path: str, subfolder: str = "") -> Dict[str, Any]: data = {"type": "input", "overwrite": "true"} if subfolder: data["subfolder"] = subfolder - r = requests.post(f"{self.base_url}/upload/image", files=files, data=data) + r = requests.post( + f"{self.base_url}/upload/image", + files=files, + data=data, + timeout=self.request_timeout, + ) r.raise_for_status() return r.json() def queue_workflow( - self, workflow: Dict[str, Any], client_id: Optional[str] = None + self, workflow: dict[str, Any], client_id: str | None = None ) -> str: """ Envoie le workflow dans la queue ComfyUI. @@ -106,19 +153,25 @@ def queue_workflow( "prompt": workflow, "client_id": client_id or self.client_id, } - r = requests.post(f"{self.base_url}/prompt", json=payload) + r = requests.post( + f"{self.base_url}/prompt", json=payload, timeout=self.request_timeout + ) r.raise_for_status() return r.json()["prompt_id"] - def get_queue_status(self) -> Dict[str, Any]: + def get_queue_status(self, *, timeout: float | None = None) -> dict[str, Any]: """Retourne le statut de la queue.""" - r = requests.get(f"{self.base_url}/queue") + r = requests.get(f"{self.base_url}/queue", timeout=self._http_timeout(timeout)) r.raise_for_status() return r.json() - def get_history(self, prompt_id: str) -> Optional[Dict[str, Any]]: + def get_history( + self, prompt_id: str, *, timeout: float | None = None + ) -> dict[str, Any] | None: """Retourne l'historique d'un prompt execute.""" - r = requests.get(f"{self.base_url}/history/{prompt_id}") + r = requests.get( + f"{self.base_url}/history/{prompt_id}", timeout=self._http_timeout(timeout) + ) r.raise_for_status() data = r.json() return data.get(prompt_id) @@ -129,44 +182,79 @@ def wait_for_result( timeout: float = 300.0, poll_interval: float = 2.0, progress_callback=None, - ) -> Dict[str, Any]: + ) -> dict[str, Any]: """ Attend la fin d'un prompt en polling. Args: prompt_id : ID retourne par queue_workflow - timeout : secondes max avant abandon + timeout : budget de suivi (hors garanties de temps reel) poll_interval : intervalle de polling en secondes progress_callback: callable(status_str) optionnel Returns: outputs dict du prompt - Raises: TimeoutError si depasse le timeout + Raises: ComfyUIWaitTimeout si budget ecoule ou delai HTTP depasse RuntimeError si erreur dans le workflow + + Un timeout arrete le suivi, pas le prompt. Les delais Requests bornent + connexion/inactivite de lecture, pas la duree totale d'une requete. """ - start = time.time() - while True: - elapsed = time.time() - start - if elapsed > timeout: - raise TimeoutError(f"Trellis2: timeout apres {timeout}s") + _positive_seconds(timeout, "timeout") + _positive_seconds(poll_interval, "poll_interval") + if requests is None: + raise ImportError("Le client ComfyUI requiert le module requests") + start = time.monotonic() + deadline = start + timeout + + def remaining() -> float: + budget = deadline - time.monotonic() + if budget <= 0: + raise ComfyUIWaitTimeout(prompt_id) + return budget - history = self.get_history(prompt_id) + while True: + try: + history = self.get_history(prompt_id, timeout=remaining()) + except requests.exceptions.Timeout as error: + raise ComfyUIWaitTimeout(prompt_id, "http_timeout") from error + remaining() if history: if "error" in history: - raise RuntimeError(f"ComfyUI erreur: {history['error']}") - outputs = history.get("outputs", {}) - if outputs: - return outputs + raise RuntimeError( + f"ComfyUI {prompt_id} erreur: {history['error']}" + ) + status = history.get("status") + if isinstance(status, dict): + if status.get("status_str") == "error": + raise RuntimeError( + f"ComfyUI {prompt_id} erreur: {status.get('messages', [])}" + ) + if ( + status.get("status_str") == "success" + and status.get("completed") is True + ): + outputs = history.get("outputs", {}) + if not isinstance(outputs, dict): + raise RuntimeError( + f"ComfyUI {prompt_id}: outputs invalides" + ) + return outputs if progress_callback: - queue = self.get_queue_status() + try: + queue = self.get_queue_status(timeout=remaining()) + except requests.exceptions.Timeout as error: + raise ComfyUIWaitTimeout(prompt_id, "http_timeout") from error + remaining() running = len(queue.get("queue_running", [])) pending = len(queue.get("queue_pending", [])) + elapsed = time.monotonic() - start progress_callback( f"Running: {running} | Pending: {pending} | {elapsed:.0f}s" ) - time.sleep(poll_interval) + time.sleep(min(poll_interval, remaining())) def download_output(self, filename: str, dest_dir: str, subfolder: str = "") -> str: """ @@ -177,36 +265,37 @@ def download_output(self, filename: str, dest_dir: str, subfolder: str = "") -> params = {"filename": filename, "type": "output"} if subfolder: params["subfolder"] = subfolder - r = requests.get(f"{self.base_url}/view", params=params, stream=True) - r.raise_for_status() - - dest = Path(dest_dir) - dest.mkdir(parents=True, exist_ok=True) - out_path = dest / filename + with requests.get( + f"{self.base_url}/view", + params=params, + stream=True, + timeout=self.request_timeout, + ) as r: + r.raise_for_status() + dest = Path(dest_dir) + dest.mkdir(parents=True, exist_ok=True) + out_path = dest / filename - with open(out_path, "wb") as f: - for chunk in r.iter_content(chunk_size=8192): - f.write(chunk) + with open(out_path, "wb") as f: + f.writelines(r.iter_content(chunk_size=8192)) return str(out_path) - def get_output_files(self, outputs: Dict[str, Any]) -> list[str]: + def get_output_files(self, outputs: dict[str, Any]) -> list[str]: """ Extrait la liste des noms de fichiers depuis les outputs d'un prompt. Cherche les nodes de type 'images', 'gltf', 'glb_path' etc. """ files = [] - for node_id, node_outputs in outputs.items(): - for key, values in node_outputs.items(): + for node_outputs in outputs.values(): + for values in node_outputs.values(): if isinstance(values, list): for v in values: if isinstance(v, dict) and "filename" in v: files.append(v["filename"]) - elif isinstance(values, str) and ( - values.endswith(".glb") - or values.endswith(".gltf") - or values.endswith(".png") + elif isinstance(values, str) and values.endswith( + (".glb", ".gltf", ".png") ): files.append(Path(values).name) return files diff --git a/tests/asset_creator/test_comfyui_client.py b/tests/asset_creator/test_comfyui_client.py new file mode 100644 index 00000000..56954fe6 --- /dev/null +++ b/tests/asset_creator/test_comfyui_client.py @@ -0,0 +1,342 @@ +"""HTTP/status regressions without a ComfyUI server, Blender or GPU.""" + +import importlib.util +import tempfile +import unittest +from pathlib import Path +from unittest.mock import MagicMock, Mock, patch + +import requests + +SOURCE = ( + Path(__file__).resolve().parents[2] + / "addons/official/storycore_asset_creator/src/comfyui_client.py" +) +SPEC = importlib.util.spec_from_file_location("asset_creator_client", SOURCE) +client_module = importlib.util.module_from_spec(SPEC) +SPEC.loader.exec_module(client_module) +ComfyUIClient = client_module.ComfyUIClient +PROMPT_ID = "accepted-prompt-id" +OUTPUTS = {"7": {"images": [{"filename": "partial.png"}]}} + + +def history(outputs=None, *, status="success", completed=True): + return { + "outputs": OUTPUTS if outputs is None else outputs, + "status": { + "status_str": status, + "completed": completed, + "messages": [["execution_error", {"exception_message": "out of memory"}]] + if status == "error" + else [], + }, + } + + +def response(payload=None): + result = MagicMock() + result.json.return_value = payload + result.status_code = 200 + result.__enter__.return_value = result + return result + + +class Clock: + def __init__(self): + self.now = 0.0 + self.sleeps = [] + + def monotonic(self): + return self.now + + def sleep(self, seconds): + self.sleeps.append(seconds) + self.now += seconds + + +class ComfyUIClientTests(unittest.TestCase): + def setUp(self): + sockets = patch( + "socket.socket", side_effect=AssertionError("network forbidden") + ) + sockets.start() + self.addCleanup(sockets.stop) + self.clock = Clock() + clock_patch = patch.object(client_module, "time", self.clock) + clock_patch.start() + self.addCleanup(clock_patch.stop) + self.client = ComfyUIClient(port=8188, request_timeout=7.0) + + def test_error_status_wins_over_partial_outputs(self): + for completed in (False, True): + with ( + self.subTest(completed=completed), + patch.object( + self.client, + "get_history", + return_value=history(status="error", completed=completed), + ), + ): + with self.assertRaisesRegex(RuntimeError, "out of memory") as caught: + self.client.wait_for_result(PROMPT_ID) + self.assertIn(PROMPT_ID, str(caught.exception)) + self.assertEqual([], self.clock.sleeps) + + def test_legacy_top_level_error_still_fails(self): + with ( + patch.object( + self.client, + "get_history", + return_value={"error": "failed", "outputs": OUTPUTS}, + ), + self.assertRaisesRegex(RuntimeError, "failed"), + ): + self.client.wait_for_result(PROMPT_ID) + + def test_only_explicit_completed_success_returns_outputs(self): + for outputs in ({}, OUTPUTS): + with ( + self.subTest(outputs=outputs), + patch.object(self.client, "get_history", return_value=history(outputs)), + ): + self.assertEqual(outputs, self.client.wait_for_result(PROMPT_ID)) + self.assertEqual([], self.clock.sleeps) + + def test_unknown_or_incomplete_status_does_not_return_partial_outputs(self): + cases = [ + {"outputs": OUTPUTS}, + {"outputs": OUTPUTS, "status": None}, + {"outputs": OUTPUTS, "status": "success"}, + history(completed=False), + history(completed="true"), + history(status="unknown"), + ] + for item in cases: + with ( + self.subTest(item=item), + patch.object(self.client, "get_history", return_value=item), + self.assertRaises(TimeoutError), + ): + self.client.wait_for_result(PROMPT_ID, timeout=1) + + def test_success_with_malformed_outputs_fails(self): + with ( + patch.object(self.client, "get_history", return_value=history([])), + self.assertRaisesRegex(RuntimeError, "outputs invalides"), + ): + self.client.wait_for_result(PROMPT_ID) + + def test_sleep_is_clipped_and_no_request_starts_after_deadline(self): + with ( + patch.object( + client_module.requests, "get", return_value=response({}) + ) as get, + self.assertRaises(client_module.ComfyUIWaitTimeout) as caught, + ): + self.client.wait_for_result(PROMPT_ID, timeout=5, poll_interval=3) + self.assertEqual([3, 2], self.clock.sleeps) + self.assertEqual( + [5, 2], [call.kwargs["timeout"] for call in get.call_args_list] + ) + self.assertEqual(PROMPT_ID, caught.exception.prompt_id) + self.assertEqual("wait_deadline", caught.exception.reason) + + def test_late_history_response_does_not_start_queue_poll(self): + def late_history(*args, **kwargs): + self.clock.now += 6 + return response({PROMPT_ID: history()}) + + callback = Mock() + with ( + patch.object( + client_module.requests, "get", side_effect=late_history + ) as get, + self.assertRaises(client_module.ComfyUIWaitTimeout), + ): + self.client.wait_for_result( + PROMPT_ID, timeout=5, progress_callback=callback + ) + self.assertEqual(1, get.call_count) + callback.assert_not_called() + self.assertEqual([], self.clock.sleeps) + + def test_queue_poll_uses_remaining_budget(self): + budgets = [] + + def get(url, **kwargs): + budgets.append(kwargs["timeout"]) + if "/history/" in url: + self.clock.now += 3 + return response({}) + self.clock.now += 2 + return response({"queue_running": [], "queue_pending": []}) + + callback = Mock() + with ( + patch.object(client_module.requests, "get", side_effect=get), + self.assertRaises(client_module.ComfyUIWaitTimeout), + ): + self.client.wait_for_result( + PROMPT_ID, timeout=5, progress_callback=callback + ) + self.assertEqual([5, 2], budgets) + callback.assert_not_called() + + def test_callback_time_counts_towards_deadline(self): + def slow_callback(status): + self.clock.now += 5 + + with ( + patch.object( + client_module.requests, + "get", + side_effect=[ + response({}), + response({"queue_running": [], "queue_pending": []}), + ], + ) as get, + self.assertRaises(client_module.ComfyUIWaitTimeout), + ): + self.client.wait_for_result( + PROMPT_ID, timeout=5, progress_callback=slow_callback + ) + self.assertEqual(2, get.call_count) + self.assertEqual([], self.clock.sleeps) + + def test_http_timeout_preserves_prompt_and_cause_for_both_poll_endpoints(self): + for endpoint in ("history", "queue"): + error = requests.exceptions.ReadTimeout("server silent") + effects = [error] if endpoint == "history" else [response({}), error] + with ( + self.subTest(endpoint=endpoint), + patch.object(client_module.requests, "get", side_effect=effects) as get, + patch.object(client_module.requests, "post") as post, + ): + with self.assertRaises(client_module.ComfyUIWaitTimeout) as caught: + self.client.wait_for_result(PROMPT_ID, progress_callback=Mock()) + self.assertEqual(PROMPT_ID, caught.exception.prompt_id) + self.assertEqual("http_timeout", caught.exception.reason) + self.assertIs(error, caught.exception.__cause__) + self.assertEqual(len(effects), get.call_count) + post.assert_not_called() + + def test_resume_wait_after_timeout_does_not_submit_again(self): + with ( + patch.object( + client_module.requests, + "get", + side_effect=[ + requests.exceptions.ReadTimeout(), + response({PROMPT_ID: history()}), + ], + ) as get, + patch.object(client_module.requests, "post") as post, + ): + with self.assertRaises(client_module.ComfyUIWaitTimeout) as caught: + self.client.wait_for_result(PROMPT_ID) + outputs = self.client.wait_for_result(caught.exception.prompt_id) + self.assertEqual(OUTPUTS, outputs) + self.assertTrue( + all(call.args[0].endswith(PROMPT_ID) for call in get.call_args_list) + ) + post.assert_not_called() + + def test_invalid_durations_fail_before_http(self): + for value in (0, -1, float("nan"), float("inf"), -float("inf")): + with ( + self.subTest(value=value), + patch.object(client_module.requests, "get") as get, + ): + with self.assertRaises(ValueError): + ComfyUIClient(port=8188, request_timeout=value) + for argument in ("timeout", "poll_interval"): + with self.assertRaises(ValueError): + self.client.wait_for_result(PROMPT_ID, **{argument: value}) + get.assert_not_called() + + def test_all_http_operations_receive_timeout(self): + for duration in (1.5, 30.0): + with self.subTest(duration=duration), tempfile.TemporaryDirectory() as temp: + client = ComfyUIClient(port=8188, request_timeout=duration) + source = Path(temp) / "source.png" + source.write_bytes(b"synthetic image") + reply = response({"prompt_id": PROMPT_ID}) + with patch.object( + client_module.requests, "post", return_value=reply + ) as post: + client.upload_image(str(source), subfolder="assets") + self.assertEqual(PROMPT_ID, client.queue_workflow({})) + self.assertEqual( + [duration, duration], + [c.kwargs["timeout"] for c in post.call_args_list], + ) + self.assertTrue( + post.call_args_list[0].kwargs["files"]["image"][1].closed + ) + reply = response({}) + reply.iter_content.return_value = [b"mesh"] + with patch.object( + client_module.requests, "get", return_value=reply + ) as get: + self.assertTrue(client.is_alive()) + client.get_queue_status() + client.get_history(PROMPT_ID) + output = client.download_output("mesh.glb", temp) + self.assertEqual( + [min(5, duration), duration, duration, duration], + [c.kwargs["timeout"] for c in get.call_args_list], + ) + self.assertEqual(b"mesh", Path(output).read_bytes()) + reply.__exit__.assert_called_once() + + def test_get_timeouts_are_capped_by_client_configuration(self): + with patch.object( + client_module.requests, "get", return_value=response({}) + ) as get: + self.client.get_history(PROMPT_ID, timeout=99) + self.client.get_queue_status(timeout=1) + self.assertEqual([7, 1], [c.kwargs["timeout"] for c in get.call_args_list]) + + def test_submission_timeout_is_not_retried(self): + error = requests.exceptions.ReadTimeout("response lost") + with ( + patch.object(client_module.requests, "post", side_effect=error) as post, + self.assertRaises(requests.exceptions.ReadTimeout) as caught, + ): + self.client.queue_workflow({}) + self.assertIs(error, caught.exception) + post.assert_called_once() + + def test_http_error_is_preserved(self): + reply = response() + reply.raise_for_status.side_effect = requests.exceptions.HTTPError("503") + with ( + patch.object(client_module.requests, "get", return_value=reply), + self.assertRaises(requests.exceptions.HTTPError), + ): + self.client.wait_for_result(PROMPT_ID) + + def test_download_closes_response_on_stream_error(self): + reply = response() + reply.iter_content.side_effect = requests.exceptions.ConnectionError( + "lost stream" + ) + with ( + tempfile.TemporaryDirectory() as temp, + patch.object(client_module.requests, "get", return_value=reply), + self.assertRaises(requests.exceptions.ConnectionError), + ): + self.client.download_output("mesh.glb", temp) + reply.__exit__.assert_called_once() + + def test_health_check_handles_missing_dependency_and_network_errors(self): + with patch.object(client_module, "requests", None): + self.assertFalse(self.client.is_alive()) + with patch.object( + client_module.requests, "get", side_effect=requests.exceptions.ReadTimeout() + ): + self.assertFalse(self.client.is_alive()) + + +if __name__ == "__main__": + unittest.main()