Skip to content

fix(scope): make the pending schema ref single-use so it cannot leak between operations - #2792

Merged
rubenvdlinde merged 1 commit into
developmentfrom
fix/schema-ref-leaks-between-operations
Aug 22, 2026
Merged

fix(scope): make the pending schema ref single-use so it cannot leak between operations#2792
rubenvdlinde merged 1 commit into
developmentfrom
fix/schema-ref-leaks-between-operations

Conversation

@rubenvdlinde

Copy link
Copy Markdown
Contributor

Follow-up to #2774, fixing a regression it introduced.

ObjectService::setSchema() records the raw ref so a later setRegister() can re-resolve it inside the register the caller names — that is what made the boundary hold regardless of the order the two setters are called in. But the ref was never cleared after being consumed, and ObjectService is reused for many operations in one process. A ref left behind by an operation that set a schema and never set a register was therefore re-resolved against the next caller's register, and refused it.

Measured on the shared dev instance minutes after #2774 landed, during occ upgrade: a pipelinq repair step seeding trustConfiguration was told "Schema slug posJournalEntryOutbound is not carried by register trust-configuration" — a slug from an entirely unrelated operation. Four repair steps across two apps failed that way; the error text was accurate about the boundary and wrong about which schema anyone had asked for, which is what gave the leak away.

The pending ref is now consumed and cleared by the first setRegister() that follows. Order-independence for the setSchema/setRegister pair is unchanged; the state is simply confined to the operation that created it.

Two regression tests: the ref is cleared once consumed, and a setRegister() with no pending ref leaves an already-resolved schema untouched. Must-fail control: the first fails against the leaking version and passes against this one. ObjectServiceTest 141 green; lint/phpcs/psalm/phpstan exit 0; phpmd's 16 findings are byte-identical to clean development.

🤖 Generated with Claude Code

ObjectService::setSchema() records the raw ref so a later setRegister()
can re-resolve it inside the register the caller names. It was never
cleared after being consumed, and ObjectService is reused for many
operations in one process — so a ref left behind by an operation that
set a schema and never set a register was re-resolved against the NEXT
caller's register and refused it.

Measured on the shared instance right after the scoping landed: a
pipelinq repair step seeding trustConfiguration was told
'posJournalEntryOutbound is not carried by register trust-configuration'
— a slug from an entirely unrelated operation. Four repair steps across
two apps failed that way.

The ref is now consumed and cleared by the first setRegister() that
follows, which keeps order-independence for the setSchema/setRegister
pair while confining the state to the operation that created it. Two
regression tests; must-fail control confirms the first fails against the
leaking version.
@github-actions

Copy link
Copy Markdown
Contributor

Quality Report — ConductionNL/openregister @ 0c422dc

Check PHP Vue Security License Tests
lint
phpcs
phpmd
psalm
phpstan
phpmetrics
eslint
stylelint
build
check-specs
test-l10n
format
composer ✅ 175/175
npm ✅ 528/528
app:check-code ⏭️
info.xml
REUSE
PHPUnit
Newman
Playwright
Hydra gates

Quality workflow — 2026-08-22 11:07 UTC

Download the full PDF report from the workflow artifacts.

@rubenvdlinde
rubenvdlinde merged commit 1ebd732 into development Aug 22, 2026
46 checks passed
@rubenvdlinde
rubenvdlinde deleted the fix/schema-ref-leaks-between-operations branch August 22, 2026 11:10
rubenvdlinde added a commit that referenced this pull request Aug 22, 2026
* fix(scope): clear the pending schema ref on the save path too

#2792 made the pending ref single-use and cleared it in find(). saveObject() and
patchObject() do not go through find() — they call setContextFromParameters(),
which was left as it was. So the leak is still live on the write path, which is
where it is most visible.

MEASURED on buildiq's E2E, 2026-08-22 12:26 UTC (after #2792 landed at 11:09):

    POST /index.php/apps/docudesk/api/templates
    Failed to create template: Schema slug "application" is not carried by
    register "docudesk" (id 19), which carries 9 schema(s). 1 schema(s)
    elsewhere on this instance carry this slug; none of them is served here,
    because naming a register makes it a boundary.

Docudesk asked for register 19 / schema 18 (`template`) — the seed writes that
pair and verifies it on read-back immediately before. `application` is
openbuild's schema, left pending by an unrelated earlier call on the same
shared ObjectService instance. setRegister() then consumed it and re-resolved it
inside docudesk's register, which correctly refused a schema that register does
not carry. The refusal is right; the ref should never have been there.

find() already guards this, with the same reasoning in its own comment. The fix
belongs in the shared helper rather than a second copy at the third call site:
when a caller supplies BOTH register and schema, it has named the pair outright,
so any pending ref is stale by definition and is dropped before resolution.

Order-independence is untouched — a caller that genuinely does
setSchema($s)->setRegister($r) still re-resolves — and the boundary is exactly
as strict, because the schema is still resolved inside the register named here.

Two failing fleet E2E suites trace to this: buildiq's template create (above) and
integriq's flow halting immediately after its `write` step.

* test: the pending-ref leak on the write path, reproduced

find() already has a regression test for this leak. saveObject() and
patchObject() do not go through find(), so nothing covered the path that was
still broken.

Drives setContextFromParameters() by reflection rather than saveObject(): the
leak lives entirely in context resolution, and the rest of a save needs a dozen
more collaborators that would obscure what is being asserted.

Paired control, against the parent commit's lib/:

  without the fix   SchemaNotInRegisterException: Schema slug "application" is
                    not carried by register "docudesk" (id 19) …
  with the fix      OK (2 tests, 2 assertions)

That exception is the production message verbatim, from buildiq's E2E on
2026-08-22 12:26 UTC.

The wider tests/Unit/Service/ suite reports 8 errors both WITH and WITHOUT this
change (LeafProvidersMetadataTest, DbalObjectSourceProviderTest,
TextExtractionServiceTest) — pre-existing, measured both directions rather than
assumed.

---------

Co-authored-by: Conduction Release Bot <release-bot@conduction.nl>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant