Per D-15, Collection editors are a typed relation-manager field and `config_relation.yaml` compiles `view.list` and `manage.list`; the prior PHP partial is neither emitted nor executed.
ADMIN-03 empty/single/order contract: linked and candidate endpoints return [] for empty sets, preserve a one-row array, and use configured username/email plus primary-key tie-break ordering for stable adjacent pages.
ADMIN-03 equality/already-linked contract: already-linked users never appear as candidates; linking an existing pair is an idempotent success that neither duplicates nor restamps the pivot; duplicate IDs normalize and empty selections return 422.
Per D-16, link/unlink writes the pivot row explicitly inside a transaction, derives table/foreign-key/role/payload behavior from the model relation contract, and runs model-owned pivot hooks when declared.
Relation endpoints require the controller relation permission, scope the owner and candidate through controller/model constraints, reject forged owner/target/role/payload values, and return D-10 envelopes.
The Collection controller also inherits ADMIN-01/02/04 form/list/CRUD/bulk behavior and exact edge semantics.
[flagged-unverified] Relation handling must not execute the legacy PHP partial or infer a pivot table/column/role from hardcoded Fonoteka names inside the framework.
[flagged-unverified] A relation candidate from another collection, an owner member, or an already-linked user must not be linkable by forging an identifier or payload.
Phase Goal
As a backend administrator, I want to authenticate separately and manage resources described by Winter-shaped schemas, so that the administration surface stays permission-gated and reusable without coupling it to frontend users.
Deliver the complete Collections controller and a secure typed relation manager with explicit pivot ownership.
Purpose: Satisfy ADMIN-03 and replace the legacy partial with framework-owned schema and endpoints without hardcoding plugin pivot details.
Output: Collection embedded assets, relation compiler/service/routes, and real PostgreSQL edge/security tests.
fonoteka.CollectionsAdminController and complete Collection form/list assets
cabana.RelationSchema, cabana.RelationContract, cabana.RelationService, and pivot hook capability
Collection config_relation.yaml with view.list / manage.list
Permissioned linked/candidate/link/unlink route family
TestRelation* framework suite and TestCollectionsAdmin* assembled PostgreSQL suite
Task 1: Port Collection form and replace partial with relation-manager
../fonoteka.go/plugins/golem15/fonoteka/controllers/collections_admin_controller.go, ../fonoteka.go/plugins/golem15/fonoteka/controllers/collections/config_form.yaml, ../fonoteka.go/plugins/golem15/fonoteka/models/collection/fields.yaml, ../fonoteka.go/plugins/golem15/fonoteka/admin_collections_test.go
/media/nvme/dev/golem15/fonoteka/plugins/golem15/fonoteka/controllers/collections/config_form.yaml, /media/nvme/dev/golem15/fonoteka/plugins/golem15/fonoteka/models/collection/fields.yaml, ../fonoteka.go/plugins/golem15/fonoteka/models/collection.go, ../fonoteka.go/plugins/golem15/fonoteka/models/collection_editor.go, cabana/form_schema.go
- Test 1: every Collection form field/layout/locale hint compiles in stable order except the source partial, which becomes the typed D-15 relation-manager field.
- Test 2: missing/malformed relation config, legacy partial type, wrong model/controller, and path escape fail activation with context.
- Test 3: form and write routes enforce Collection operation permissions before model/provider/database work.
Create the Collection controller/form/model assets and preserve all source behavior while performing D-15's explicit partial-to-relation-manager replacement. Give the relation field a finite relation name and embedded config path, register exact form/create/update permissions, and make strict compilation reject legacy partials everywhere. Keep plugin-specific pivot knowledge on the Collection relation contract, not in cabana.
(cd ../fonoteka.go && go test ./plugins/golem15/fonoteka -run '^TestCollectionsAdmin(Form|RelationField|RejectsPartial|WritePermissions)$' -count=1)
The command exits non-zero, reports no matching test, a non-partial source form element is absent, the legacy partial compiles, relation config escapes/mismatches, or permission denial follows provider/database work.
The full Collection form compiles with the one locked relation-manager substitution and no executable PHP partial path.
Collections form/write registration is complete and points to the typed relation contract.
Task 2: Compile list and relation schemas with stable edge semantics
../fonoteka.go/plugins/golem15/fonoteka/controllers/collections/config_list.yaml, ../fonoteka.go/plugins/golem15/fonoteka/models/collection/columns.yaml, ../fonoteka.go/plugins/golem15/fonoteka/controllers/collections/config_relation.yaml, cabana/relation.go, cabana/relation_test.go
/media/nvme/dev/golem15/fonoteka/plugins/golem15/fonoteka/controllers/collections/config_list.yaml, /media/nvme/dev/golem15/fonoteka/plugins/golem15/fonoteka/models/collection/columns.yaml, /media/nvme/dev/golem15/fonoteka/plugins/golem15/fonoteka/controllers/collections/config_relation.yaml, ../fonoteka.go/plugins/golem15/fonoteka/models/collection.go, lagoon/relations.go
- Test 1: complete Collection list and relation `view.list`/`manage.list` schemas compile with all columns, actions, labels, search/sort, and exact permission.
- Test 2: linked/candidate empty and single sets are non-null arrays; equal username/email values use primary-key tie-break across adjacent pages.
- Test 3: candidates exclude the owner, out-of-scope/inactive records, and already-linked users at the database query level.
Port the complete Collection list and relation YAML into typed compiled schemas. Define a relation contract exposing target model/query, owner/candidate constraints, pivot metadata, display/search/sort fields, allowed role/payload semantics, and required permission. Build linked and candidate queries from that contract with bound values, stable configured ordering plus primary-key tie-break, allocated empty arrays, and database-level NOT EXISTS/owner/scope constraints.
go test ./cabana -run '^TestRelation(Schema|Empty|Single|StablePages|CandidateExclusions)$' -count=1
The command exits non-zero, reports no matching test, relation schema omits a source key, an empty set becomes null, equal values destabilize adjacent pages, or owner/out-of-scope/inactive/already-linked rows appear as candidates.
D-15 relation schema and ADMIN-03 linked/candidate edge behavior are typed, stable, and query-scoped before mutation.
Collection list and relation views compile and return deterministic eligible sets.
Task 3: Execute permissioned explicit pivot link and unlink
cabana/relation.go, cabana/http.go, ../fonoteka.go/plugins/golem15/fonoteka/controllers/collections_admin_controller.go, ../fonoteka.go/plugins/golem15/fonoteka/admin_collections_test.go
cabana/relation.go, cabana/http.go, ../fonoteka.go/plugins/golem15/fonoteka/controllers/collections_admin_controller.go, ../fonoteka.go/plugins/golem15/fonoteka/models/collection.go, ../fonoteka.go/plugins/golem15/fonoteka/models/collection_editor.go, lagoon/relations.go
- Test 1: relation permission precedes owner/target lookup; link/unlink use model-derived pivot metadata and invoke declared hooks transactionally.
- Test 2: empty selection is 422, duplicate targets normalize, existing link succeeds without duplicate/restamp/hook, and repeated unlink succeeds with removed zero.
- Test 3: forged owner/target/collection/role/payload, owner-as-target, cross-scope target, and concurrent duplicate links fail or converge without partial/duplicate pivot state.
Expose D-09 relation schema, linked, candidates, link, and unlink endpoints; attach the relation-specific D-03 permission before identifiers or queries. In `RelationService`, resolve owner/targets through the model contract, normalize/sort IDs, reject empty selections, lock relevant rows, and explicitly insert/delete pivot rows using contract-supplied table and column metadata. Invoke optional model pivot hooks inside the same transaction. Treat an existing identical link and absent repeated unlink as idempotent success without restamping timestamps or rerunning hooks; reject any client pivot role/payload not exactly allowed by the contract.
(cd ../fonoteka.go && go test ./plugins/golem15/fonoteka -run '^TestCollectionsAdmin(RelationPermissions|Link|Unlink|Idempotent|ForgedPivot|CrossScope|Concurrent)$' -count=1)
The command exits non-zero, reports no matching test, PostgreSQL is skipped, lookup occurs before permission, framework code hardcodes a Fonoteka pivot name, a forged/cross-scope relation persists, an idempotent replay restamps/reruns hooks, or concurrency creates partial/duplicate rows.
D-16 pivot ownership and every ADMIN-03 empty/equal/already-linked/order/idempotency/security edge are proven on real assembled routes.
The Collection relation manager is permissioned, scoped, explicit, transactional, hook-aware, stable, and replay-safe.
<threat_model>
Trust Boundaries
Boundary
Description
relation request→pivot
Untrusted owner/target/role/payload attempts to mutate a join table
controller scope→candidate domain
Globally existing users must be narrowed to eligible collection candidates
STRIDE Threat Register
Threat ID
Category
Component
Severity
Disposition
Mitigation Plan
T-09-16
Tampering / Elevation
relation pivot write
high
mitigate
Obtain pivot table/columns/allowed payload from the model contract, bind finite validated values, lock/deduplicate, run hooks transactionally, and test forged role/payload/columns.
T-09-17
Elevation / Information Disclosure
owner/target/candidate scope
high
mitigate
Require relation permission first, scope owner/target database queries, exclude owner/inactive/already-linked targets, and test cross-collection identifiers.
T-09-SC
Tampering
npm/pip/cargo installs
high
mitigate
No npm/pip/cargo install occurs; existing Go dependencies only, so the package-legitimacy gate remains closed.
</threat_model>
Run `go test ./cabana -run '^TestRelation' -count=1 && (cd ../fonoteka.go && go test ./plugins/golem15/fonoteka -run '^TestCollectionsAdmin' -count=1)`; it fails on non-zero exit, zero matched tests, schema/source omission, unstable edge behavior, permission/scope bypass, forged pivot persistence, hook/lifecycle bypass, or non-idempotent concurrency.
<success_criteria>
Collections form/list CRUD is complete, and the PHP partial is replaced by the typed D-15 manager.
ADMIN-03 linked/candidate endpoints have explicit empty/single/equal/already-linked/adjacent/order semantics.
D-16 pivot writes use model-owned metadata and hooks without framework hardcoding.
T-09-16 and T-09-17 fail closed under forged payload, cross-scope, replay, and concurrent fixtures.
</success_criteria>
Create `.planning/phases/09-backend-admin-authentication-and-schema-pipeline/09-10-SUMMARY.md` when done.