Skip to content

feat(tools): reference an existing file from a content element - #865

Merged
CybotTM merged 1 commit into
mainfrom
feat/attach-file-to-content-element
Aug 21, 2026
Merged

feat(tools): reference an existing file from a content element#865
CybotTM merged 1 commit into
mainfrom
feat/attach-file-to-content-element

Conversation

@CybotTM

@CybotTM CybotTM commented Aug 21, 2026

Copy link
Copy Markdown
Member

Closes #834. The seventh purpose-built writer, and the first that creates a sys_file_reference. Demo content with images stopped at the upload: nothing could make an uploaded file part of the site, because the files group is read-only by design (ADR-047) and the only FAL write was set_file_alternative_text on a metadata row that already existed.

A control caught my own design, and the design lost

The tool wrote through the DataHandler twice, and I had a measurement to justify it: a probe showed a single-pass write leaving the element's counter one short and the new reference sorting first. Both issue comments say so.

Then I disabled the second pass and every test still passed.

The probe had run without $GLOBALS['LANG']. In that environment the defect is real; in a complete backend environment — the only one this tool will run in, because refuseWithoutBackendEnvironment() checks — a single datamap carrying the parent list and the NEW_ placeholder produces the right count and the right sorting_foreign. One pass now, substNEWwithIDs only to identify the row for the read-back, and the docblock says what was measured rather than what I remembered.

The issue comments are corrected rather than left standing.

Three refusals the description did not name

Each came from the live TCA, not from reasoning about it:

The field must accept the file. image takes fourteen extensions, assets twenty-seven, media anything. A .docx on image is a relation the FormEngine would reject, so it is refused rather than written.
sys_language_uid must be in the payload. Without it a non-admin's write dies inside checkRecordEditAccess() on a missing languageField. Stated rather than defaulted, which also pins the tool to the default language like the other FAL writer.
title, alternative, description are exclude fields. The DataHandler drops them for a user without the grant — silently, empty errorLog. Without checking them the tool reports a caption it did not write.

That last one is also what made the read-back testable: remove the grant and the guard fires.

Discarding is a rollback, not a delete

When the read-back fails, removing the reference row is not enough — the element's counter still names it, which is exactly the inconsistent state this tool exists to avoid producing. discard() now writes the parent field back to the list that was there before the call. A test caught that too.

Six controls, each observed

defect injected result
read-back stops checking the texts fails
discard() stops restoring the counter fails
extension check removed fails
ambiguity branch removed fails
storage gate removed fails
page-permission check removed fails

Restored tree: 13 tests green. An earlier run of this same harness reported two of these as undetected; both were failed sed anchors, so the test had run against an unmodified file. The harness now compares the checksum before and after and says "anchor missed" instead of "undetected" — a control that cannot fail is worse than none.

One measured fact worth recording

No CType that ships with the core offers two of image / assets / media. textpic and image show image, textmedia shows assets, nothing shows two. The "several fields → name the one you mean" branch is therefore unreachable with core content types, so the test registers a two-field type of its own rather than leaving the branch an untested claim.

There is no position argument: the write appends by construction, and an argument nothing reads is worse than none.

Verification

gate result
-s functional -d sqlite (this class) 13 tests, 86 assertions, exit 0
-s functional (tool surfaces) 32 tests, 193 assertions, exit 0
-s unit (full) 7289 tests, 24795 assertions, exit 0
-s phpstan No errors
-s cgl -n SUCCESS
-s rector -n (pinned 8.2, run locally) Rector is done
composer ci:test:changelog exit 0

Every counting surface moves with it: 47 tools → 48, six writers → seven, across Tools.rst, README.md, seven landing-page data files in both languages, and the four tests that pin those numbers.

Assisted by claude-code:claude-opus-5 — Session

Copilot AI lite review requested due to automatic review settings August 21, 2026 07:26
@CybotTM
CybotTM requested a review from a team as a code owner August 21, 2026 07:26
@github-actions github-actions Bot added documentation Improvements or additions to documentation tests Test-related changes configuration Configuration changes labels Aug 21, 2026
@github-actions

Copy link
Copy Markdown
Contributor

Dependency Review

✅ No vulnerabilities or license issues or OpenSSF Scorecard issues found.

Scanned Files

None

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

github-actions[bot]
github-actions Bot previously approved these changes Aug 21, 2026

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Automated approval for maintainer PR

All automated quality gates passed. See SECURITY_CONTROLS.md for compensating controls.

@codecov

codecov Bot commented Aug 21, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 0% with 336 lines in your changes missing coverage. Please review.
✅ Project coverage is 85.61%. Comparing base (3ec0592) to head (f0b47ad).

Files with missing lines Patch % Lines
...ce/Tool/Builtin/AttachFileToContentElementTool.php 0.00% 336 Missing ⚠️
Additional details and impacted files

Impacted file tree graph

@@             Coverage Diff              @@
##               main     #865      +/-   ##
============================================
- Coverage     86.51%   85.61%   -0.90%     
- Complexity     9957    10034      +77     
============================================
  Files           575      576       +1     
  Lines         32177    32513     +336     
============================================
  Hits          27837    27837              
- Misses         4340     4676     +336     
Flag Coverage Δ
unit 64.29% <0.00%> (-0.71%) ⬇️

Flags with carried forward coverage won't be shown. Click here to find out more.

Files with missing lines Coverage Δ
...ce/Tool/Builtin/AttachFileToContentElementTool.php 0.00% <0.00%> (ø)
🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@CybotTM

CybotTM commented Aug 21, 2026

Copy link
Copy Markdown
Member Author

Review record — no Copilot review exists (monthly, account-wide quota), so the green gate is not a read of this change. This is a writing tool touching FAL and the DataHandler, so the record is longer than usual.

The finding I would most want a reviewer to check, because it overturns my own design. The tool wrote through the DataHandler twice, justified by a probe showing a single pass leaving the counter short and the reference sorted first. Disabling the second pass changed no test. The probe had run without $GLOBALS['LANG'] — an environment the tool refuses to run in. One pass now. If anyone disagrees, the disagreement is with a measurement that is reproducible in either direction: add the language service and the defect disappears.

Authorisation, which is the part worth being paranoid about. Two independent gates against the acting user, both verified by controls that remove them:

  • The element's page needs CONTENT_EDIT (doesUserHaveAccess).
  • The file goes through FalStorageGate::isFileAccessible(). I read that method rather than trusting its name: it applies the extension's storage allow-list, then builds the storage through createFromRecord() with setEvaluatePermissions(true), the user's filePermissionsFor() and each of their mounts, then asserts with checkFileActionPermission('read', …). One call is the whole check, mounts included.

Both refuse in the same words as "does not exist", so a refusal never confirms a uid — asserted by a test that runs an absent file and an unreachable one and compares the strings.

The silent-drop class, which is where the sibling writer was bitten. title, alternative and description are exclude fields on sys_file_reference. Without the field grant the DataHandler drops them and logs nothing, so the tool would have reported a caption it never wrote. The read-back checks them; removing the grant makes it fire, and the test asserts the reference is removed again afterwards.

Discarding is a rollback. Deleting the row alone leaves the element's counter naming a reference that is gone — the exact inconsistency this tool exists to avoid. A test caught it.

Six controls, each observed (read-back texts, counter restore, extension check, ambiguity branch, storage gate, page permission). Worth naming: an earlier run of this harness reported two as undetected, and both were failed sed anchors — the test had run against an unmodified file. The harness now compares checksums and says "anchor missed". A control that cannot fail is worse than none, and I nearly reported two blind spots that did not exist.

Unreachable branch, made reachable in the test rather than left as a claim. No core CType offers two of image/assets/media — measured. The ambiguity refusal therefore cannot fire on core content, so the test registers its own two-field type.

Counting sweep: 47 → 48 tools and six → seven writers, across Tools.rst, README.md, seven landing-page data files in both languages and four tests. Verified by re-querying every surface for a stale number afterwards, not by trusting the replace count. The ADR files that also match \b47\b were checked and left alone — those are line citations and ADR-047 references, not tool counts.

Gates run locally at 8.2 including Rector, plus the full unit suite. Not merging on this record — I hold no merge authorisation for this PR.

Assisted by claude-code:claude-opus-5 — Session

@CybotTM

CybotTM commented Aug 21, 2026

Copy link
Copy Markdown
Member Author

Self-review: 6c7d198

The review this pull request demands is unsatisfiable (Copilot quota wall or repeated bot failures on this head). Per the documented fallback, the diff on this head was reviewed by the PR author; this comment is the on-the-record attestation the merge gate reads back. It stops matching on the next push.

@CybotTM
CybotTM added this pull request to the merge queue Aug 21, 2026
@github-merge-queue
github-merge-queue Bot removed this pull request from the merge queue due to a conflict with the base branch Aug 21, 2026
@CybotTM
CybotTM force-pushed the feat/attach-file-to-content-element branch from 6c7d198 to 5dbcab1 Compare August 21, 2026 11:17
github-actions[bot]
github-actions Bot previously approved these changes Aug 21, 2026

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Automated approval for maintainer PR

All automated quality gates passed. See SECURITY_CONTROLS.md for compensating controls.

@CybotTM

CybotTM commented Aug 21, 2026

Copy link
Copy Markdown
Member Author

Self-review: 5dbcab1

The review this pull request demands is unsatisfiable (Copilot quota wall or repeated bot failures on this head). Per the documented fallback, the diff on this head was reviewed by the PR author; this comment is the on-the-record attestation the merge gate reads back. It stops matching on the next push.

@CybotTM
CybotTM added this pull request to the merge queue Aug 21, 2026
@github-merge-queue
github-merge-queue Bot removed this pull request from the merge queue due to a conflict with the base branch Aug 21, 2026
The seventh purpose-built writer, and the first that creates a
sys_file_reference. Demo content with images stopped at the upload:
nothing could make an uploaded file part of the site, because the files
group is read-only by design and the only FAL write was
set_file_alternative_text on a metadata row that already existed.

It appends one existing sys_file to a content element's image, assets or
media field through the DataHandler as the acting backend user, in the
live workspace and the default language. No upload, no move, no rename.
Both ends are authorised against that user: the element's page needs
content-edit rights, and the file has to lie in a permitted storage
inside their own file mounts. Either failure is refused in the same
words as an element that does not exist, so a refusal never confirms a
uid.

Three refusals the issue did not name, each measured against the live
TCA rather than assumed:

The field must accept the file. Each file field declares its own
extension list and they differ sharply — image takes fourteen, assets
twenty-seven, media anything. A .docx on image is a relation the
FormEngine would reject, so it is refused rather than written.

sys_language_uid has to be in the reference payload. Without it a
non-admin's write dies inside checkRecordEditAccess() on a missing
languageField. Stated rather than defaulted, and it pins the tool to the
default language like the other FAL writer.

title, alternative and description are exclude fields, so the
DataHandler drops them for a user without the grant — silently, with an
empty errorLog. The read-back checks them as well as the relation, or
the tool would report a caption it did not write.

Discarding is a rollback, not a delete. When the read-back fails the
reference row is removed AND the element's counter is written back to
the list it had before the call; removing only the row leaves exactly
the inconsistent state this tool exists to avoid.

There is no `position` argument. The write appends by construction.

A correction to my own design, caught by a control rather than by
review: it wrote through the DataHandler twice, because a probe had
shown a single pass leaving the counter one short and the new reference
sorting first. That probe ran without $GLOBALS['LANG'] — an environment
this tool refuses to run in. Disabling the second pass changed no test,
which is how the artefact surfaced. One pass now, and the docblock says
what was measured instead of what I remembered.

Functional rather than unit, and not by preference: the interesting
behaviour is what ends up in sys_file_reference AND in the element's own
counter, and the silent-drop defect is invisible to a mock. Six controls,
each observed: removing the text read-back, the counter restore, the
extension check, the ambiguity branch, the storage gate or the page
permission each fails a test.

No core CType offers two of the three fields — textpic and image show
image, textmedia shows assets, nothing shows two — so the ambiguity
branch is unreachable with core content types. The test registers its
own two-field type rather than leaving that branch an untested claim.

Every counting surface moves with it: 47 tools to 48 and six writers to
seven, across Tools.rst, README, seven landing-page data files in both
languages, and the four tests that pin those numbers.

Closes #834

Assisted-by: claude-code:claude-opus-5
Agent-Session: https://claude.ai/code/session_01MNg1MysJVugv1xo2husknU
Signed-off-by: Sebastian Mendel <sebastian.mendel@netresearch.de>
@sonarqubecloud

Copy link
Copy Markdown

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Automated approval for maintainer PR

All automated quality gates passed. See SECURITY_CONTROLS.md for compensating controls.

@CybotTM

CybotTM commented Aug 21, 2026

Copy link
Copy Markdown
Member Author

Self-review: f0b47ad

The review this pull request demands is unsatisfiable (Copilot quota wall or repeated bot failures on this head). Per the documented fallback, the diff on this head was reviewed by the PR author; this comment is the on-the-record attestation the merge gate reads back. It stops matching on the next push.

@CybotTM
CybotTM added this pull request to the merge queue Aug 21, 2026
Merged via the queue into main with commit 789d42b Aug 21, 2026
93 checks passed
@CybotTM
CybotTM deleted the feat/attach-file-to-content-element branch August 21, 2026 13:09
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

configuration Configuration changes documentation Improvements or additions to documentation tests Test-related changes

Projects

None yet

Development

Successfully merging this pull request may close these issues.

attach_file_to_content_element — a purpose-built writer that references an existing FAL file from a content element

2 participants