Skip to content

feat(app-repo): apply the v2 channels on install — they were parsed, then dropped - #85

Merged
rubenvdlinde merged 4 commits into
developmentfrom
feat/apply-v2-channels
Aug 1, 2026
Merged

feat(app-repo): apply the v2 channels on install — they were parsed, then dropped#85
rubenvdlinde merged 4 commits into
developmentfrom
feat/apply-v2-channels

Conversation

@rubenvdlinde

Copy link
Copy Markdown
Contributor

Closes the last step of the app-repo round trip. Depends on ConductionNL/hermiq#109.

The defect

Six steps carry an app between instances: serialize → bind → push → fetch → parse → apply. Five were built. The sixth never was — so installing a published v2 app produced an app holding its manifest and nothing that makes it run, and reported success.

Verified with a positive control before any code was written:

lookup hits outside parser / serializer / fetcher
$template['manifest'] / ['version'] (control) 7 — the grep works
$template['connectors'] 0
$template['automations'] 0
$template['skills'] 0
$template['dataRegisters'] 3 — all the export/zip path, reading Application.dataRegisters bindings, never a parsed template

Both entry points confirmed by reading them, not by grep alone: pull() persisted manifest + companion schemas only; installFromTemplateArray() read exactly slug and manifest.

This is the fourth time in this programme that one half of a round trip was extended and the other left behind. Publish looked perfect every time, because publish is the half that kept getting extended.

Three rules

Never overwrite. Connectors are shared infrastructure — one source can serve several apps — so a colliding UUID is skipped and reported. Enforced with saveObject(failIfExists: true): the guarantee lives in the call, not in a preceding existence check that could drift or race.

Never claim atomicity. OpenRegister has no cross-object transaction, so one failing item must not cost the caller the rest. ChannelApplyReport enforces created + skipped + failed === declared and throws when it doesn't hold — a dropped item is arithmetically impossible to hide. The 64-skill silent cap this programme already shipped is exactly what that identity exists to prevent.

Never drop silently. Every channel is bounded; truncation is logged and counted.

Two defects the tests caught, both of which would have shipped

  1. Collision was detected by message text. A plain PHP Unknown named parameter $failIfExists therefore reported itself as a benign already-exists — a wiring bug wearing the costume of an expected outcome, and the reason three tests were briefly green for the wrong reason. Now caught by type (ObjectExistsException); the stale test stub that hid it is fixed.
  2. The credential lookup used the wrong signature. It called findAll(filters:, register:, schema:); the real findAll takes a $config array. Since credentialExists() deliberately swallows a failed lookup and returns true (an inconclusive lookup must never manufacture an absence claim), needsCredentials would have been silently empty forever. Register/schema confirmed against the live instance (credential-broker / brokeredcredential) with a positive control rather than assumed.

Design notes

  • Skills delegate to hermiq's SkillBundleInstaller by repo coordinates rather than being reimplemented
  • openconnector and hermiq stay optional (OpenBuild declares only openregister) and degrade with a machine-readable reason while every other channel still applies
  • DataRegisterProvisioner split out when phpmd flagged complexity 61 > 50 — a real split, not a suppression

Verification

  • 750 tests OK · phpstan 0 · phpcs 0 in CI scope · phpmd 0
  • Mutation check: flipping failIfExists to false turns the suite red
  • phpstan run explicitly — openbuild ships no vendor/, so the local 40-gate suite silently skips it; gate-green never meant phpstan-green

Known, not fixed here

gate-46 reports 38 dangling @spec anchors, all pre-existing in ApplicationsController (36 identical on development). Their change dir was archived, so I repointed the path to the archive; the fragments were always list items rather than headings and would need a semantic remap onto canonical requirements — a separate pass, not something to guess at across 36 methods. gate-46 is local-only and not part of CI.

…then dropped

Six steps carry an app between instances: serialize -> bind -> push -> fetch ->
parse -> apply. Five were built. The sixth never was, so installing a published
v2 app produced an app holding its manifest and NOTHING that makes it run, and
reported success.

Verified against the code with a positive control before writing any of this:

  $template['manifest'] / ['version']  (control)  7 hits
  $template['connectors']                         0
  $template['automations']                        0
  $template['skills']                             0
  $template['dataRegisters']                      3, all in the export/zip path

Both entry points confirmed by reading them: pull() persisted manifest +
companion schemas only; installFromTemplateArray() read exactly slug + manifest.

This is the fourth time in this programme that one half of a round trip was
extended and the other left behind. Publish looked perfect every time, because
publish is the half that kept getting extended.

Three rules shape the implementation:

  NEVER OVERWRITE. Connectors are shared infrastructure - one source can serve
  several apps - so a colliding uuid is skipped and reported. Enforced with
  saveObject(failIfExists: true) so the guarantee lives in the call rather than
  in a preceding existence check that could drift or race.

  NEVER CLAIM ATOMICITY. OpenRegister has no cross-object transaction, so one
  failing item must not cost the caller the rest. ChannelApplyReport enforces
  created + skipped + failed === declared and THROWS when it does not hold, so a
  dropped item is arithmetically impossible to hide. The 64-skill silent cap this
  programme already shipped is what that identity exists to prevent.

  NEVER DROP SILENTLY. Every channel is bounded; truncation is logged AND counted.

Two defects found while writing the tests, both of which would have shipped:

  1. Collision was detected by MESSAGE TEXT. A plain PHP 'Unknown named parameter
     $failIfExists' error therefore reported itself as a benign 'already exists'
     - a wiring bug wearing the costume of an expected outcome, and the reason
     three tests were briefly green for the wrong reason. Now caught BY TYPE
     (ObjectExistsException), and the stale test stub that hid it is fixed.

  2. The credential lookup called findAll(filters:, register:, schema:), which is
     not the real signature - findAll takes a $config array. credentialExists()
     swallows a failed lookup and returns true (an inconclusive lookup must never
     manufacture an absence claim), so needsCredentials would have been silently
     empty forever. Register/schema confirmed against the live instance
     (credential-broker / brokeredcredential) with a positive control, not assumed.

Skills delegate to hermiq's SkillBundleInstaller by repo coordinates rather than
being reimplemented, so frontmatter byte-fidelity and the ADR-068 aux-file rules
keep living in exactly one place. openconnector and hermiq stay OPTIONAL -
OpenBuild declares only openregister - and degrade with a machine-readable reason
while every other channel still applies.

The collision test is mutation-checked: flipping failIfExists to false turns it
red. phpstan is run explicitly, because openbuild ships no vendor/ and the local
40-gate suite silently SKIPS phpstan - green there never meant phpstan passed.
…, declare persistApplication throw

gate-16 wanted @SPEC on every new public method. gate-46 and gate-49 were
PRE-EXISTING on development (36 identical dangling anchors; persistApplication
untouched) and surfaced only because this change touches the file — fixed here
per the repo rule rather than left for later.

The 36 anchors pointed at retrofit-2026-05-24-annotate-openbuild, which has since
been ARCHIVED; repointed to the archive path. persistApplication already wraps its
save — the unguarded call is normaliseObject() AFTER it, so the contract is now
declared rather than the behaviour quietly changed.
The save is wrapped in catch(Throwable), which covers OpenRegister's
ValidationException and DoesNotExistException — the gate's heuristic matches on
named exceptions and cannot see that a Throwable catch subsumes them. Documenting
the actual contract is what the gate is for, so it is now stated rather than the
behaviour changed to suit the checker.
@github-actions

github-actions Bot commented Aug 1, 2026

Copy link
Copy Markdown
Contributor

Quality Report — ConductionNL/openbuild @ 87ab183

Check PHP Vue Security License Tests
lint
phpcs
phpmd
psalm
phpstan
phpmetrics
eslint
stylelint
composer ✅ 100/100
npm ✅ 659/659
PHPUnit ⏭️
Newman ⏭️
Playwright ⏭️

Quality workflow — 2026-08-01 22:10 UTC

Download the full PDF report from the workflow artifacts.

…s psalm/phpcs

THE BUG THIS COMMIT FIXES WOULD HAVE MADE THE WHOLE CHANGE A NO-OP.

AppRepoParser nests the v2 channels under `$payload['channels']`. The applier
read them from the top level, so every channel resolved to [] and reported
`declared: 0` — an install that does nothing and returns success, which is
precisely the failure this change exists to end. Found by reading the parser
while preparing the live probe, NOT by the unit tests: I had written the test
fixtures in the same wrong shape, so tests and implementation agreed with each
other while both disagreed with the real producer.

The durable fix is the new test, not the one-line change:
testAppliesTheChannelShapeTheParserActuallyProduces drives the REAL AppRepoParser
over a v2 file map and feeds its output straight into the applier, so the two
shapes cannot drift apart again without a red suite. Mutation-checked — reverting
channelOf() to the top-level read turns it red.

Lesson worth keeping: a hand-written fixture that mirrors the implementation's
assumption cannot detect a shape mismatch with the real producer. It only ever
tests that the code agrees with itself.

Also: adoptCounts() now takes truncated as the BOOL it is (hermiq knows
truncation happened, not how many items it missed) and absorbs any shortfall
between our declared count and the source's outcomes as a NAMED skip, so the
balance identity holds and the cause is stated rather than the difference
silently disagreeing. psalm needed ObjectExistsException on the cross-app
suppression list; two inline comments needed capitals.
@github-actions

github-actions Bot commented Aug 1, 2026

Copy link
Copy Markdown
Contributor

Quality Report — ConductionNL/openbuild @ 58f074b

Check PHP Vue Security License Tests
lint
phpcs
phpmd
psalm
phpstan
phpmetrics
eslint
stylelint
composer ✅ 100/100
npm ✅ 659/659
PHPUnit
Newman ⏭️
Playwright ⏭️

Coverage: 89.4% (17/19 statements)


Quality workflow — 2026-08-01 22:29 UTC

Download the full PDF report from the workflow artifacts.

@rubenvdlinde
rubenvdlinde merged commit 67dd113 into development Aug 1, 2026
30 checks passed
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.

2 participants