A short, human-readable summary of the problem.
A human-readable explanation specific to this occurrence of the problem.
A URI reference that identifies the specific occurrence of the problem. It may or may not yield further information if dereferenced.
A URI reference that identifies the problem type
Retrieves a ConstraintViolation resource.
Optional capability narrowing for this token, in the shared {@see AccessPolicy} shape: `{ categories: { tasks: 'view', … }, items: { project: { '<uuid>': 'edit' } } }`. `null` = unrestricted (acts exactly as the owner). Validated by {@see validateAccessPolicy()}.
Personal access token bound to a single user, used to authenticate MCP (and any future programmatic) callers via `Authorization: Bearer`. Plaintext is shown to the human exactly once at creation (in the POST response under `plainToken`) — only the sha256 hash is persisted, so a database leak cannot be replayed against the API. Hashing matches the pattern already established by {@see PasswordResetToken} and {@see UserInvite}, just with a different prefix and longer entropy. A token acts AS its owner, optionally narrowed by `accessPolicy` — the same {@see AccessPolicy} shape used for admin-impersonation consent (per-category + per-item none/view/edit). `null` means "unrestricted": the token can do exactly what its owner can in the app. The policy is enforced uniformly across the REST API + MCP by the shared engine (App\Security\Access\ActorPolicyResolver + AccessPolicyListener / AccessPolicyItemScope on REST; the MCP controller on tool dispatch).
`'task'`, `'page'`, or `'discussion'` — discriminator for the polymorphic parent FK. Stamped server-side from whichever of `task` / `page` / `discussion` the client supplied; never accepted directly on the wire so clients can't desync it from the FK trio.
Author is set server-side by {@see CommentAuthorProcessor} on POST; the setter exists so PATCH can no-op the field, but the serializer group keeps it read-only over the wire.
Bumped automatically on PATCH via the lifecycle hook; lets the PWA render an "(edited)" affordance without diffing payloads.
Unified comment thread (#228). Replaces the per-parent `TaskComment` / `PageComment` split: every comment now lives in one `comment` table with a polymorphic parent — `commentable_type` discriminates between `'task'`, `'page'`, and `'discussion'`, and exactly one of `task` / `page` / `discussion` is set to match. All three are real FKs (rather than a single bare `commentable_id`) so we keep cascade-on-delete from the parent. Comments are flat (no reply tree) and ordered chronologically. The previous task-side reply tree was flattened in the unification migration; the chronological view is the canonical thread surface. Access is parent-aware: - read: anyone who can read the parent (task accessibility OR page / discussion space membership). - post: same set as read. A locked discussion additionally refuses *new* comments (existing ones stay editable/deletable). - edit: author only. - delete: author, OR an admin escalation suited to the parent — the task's owner can delete on tasks; the space-admin can delete on pages and discussions. `@mention` tokens in `body` are scanned by {@see App\Service\CommentMentionService} after persist and create one `mention` Notification per resolved recipient. The recipient set is derived from the parent (task: owner + project space; page / discussion: the parent's space).
Top-level family — one of {@see CustomFieldKind}'s values.
Specialisation within the kind. The (kind, subtype) pair is the registry lookup key for the concrete strategy.
Per-kind configuration payload. Shape is owned by the strategy for the (kind, subtype) pair — see CustomFieldTypeInterface docblock.
Optional footer aggregation descriptor `{kind, label?}`. Null means no footer row for this field; the kind, when present, must be one supported by the strategy (enforced in {@see ValidCustomFieldDefinition}).
Whether a missing or null value is acceptable for this field.
Retrieves a CustomFieldDefinition resource.
Updates the CustomFieldDefinition resource.
Deletes the CustomFieldDefinition resource.
Per-project custom field definition. Widened in #227 from the flat 5-type enum to a (kind, subtype, config) triple backed by a strategy registry — see {@see App\CustomField\Type\CustomFieldTypeInterface}. Storage shape: - `kind` — top-level family (boolean|text|numeric|date|select|reference) - `subtype` — specialisation within the kind (e.g. `money` under `numeric`) - `config` — per-kind config payload (options, min/max, currency, multi, …) - `footer` — optional `{kind, label?}` aggregation descriptor - `nullable` — whether a missing value is legal for this field Per-kind config + footer validation runs through {@see ValidCustomFieldDefinition} (class-level constraint), which dispatches into the strategy. Per-value validation lives on Task's {@see App\Validator\ValidCustomFieldValues}. Read access mirrors the parent project's space (any space member). Write access is space-admin only — the field schema is structural and easy to disrupt for the rest of the project.
Stamped by DiscussionAuthorProcessor on POST; read-only on the wire so a client can't post on someone else's behalf.
Symfony's PropertyInfoExtractor derives the property name from the getter — `isPinned()` would strip the `is` prefix and yield `pinned`, which doesn't match the writable name `isPinned` (from `setIsPinned`).
Reply aggregates — computed at read time by {@see App\State\DiscussionAggregateProvider} and never persisted.
Falls back to the thread's creation time so a reply-less thread still reports a meaningful "last activity" instead of null.
Distinct people who have touched the thread (author first, then repliers), capped for display. The full count lives in {@see $participantCount}.
Space-level discussion thread. Read/list access mirrors the parent space — every space member can browse and post; edit is author-only; delete + pin/lock are author OR space admin. Originally project-scoped (#91) and reparented to the space (#185) so the natural unit of conversation matches the access boundary: spaces are what users join and switch between, not projects.
Variants serialized as public URLs, keyed by variant name.
Authenticated download URL for attachment-kind media. Avatars stay on the public `/media/...` URL — they're displayed everywhere inside the app and don't carry sensitive content. Returning null for non- attachment kinds keeps the frontend selector trivial: prefer `downloadUrl` when present, fall back to `variantUrls.original`.
Shared media container. Owned by the uploading user, referenced by domain entities (User.avatar today; tasks/projects/comments later). `variants` stores a map of variant name to Flysystem path — images get "thumb"/"profile" entries; plain attachments use "original".
Who triggered the notification (commenter, assigner, …). Null for system-generated rows like reminders. Embedded under `notification:read` so the row renders an actor avatar without a follow-up fetch. SET NULL on actor deletion so the row survives.
Optional link back to the task this notification refers to.
Comment that triggered this notification — only set for `mention` rows. Together with `recipient` it forms a unique key so editing a comment can't re-fire mentions to the same user.
Set by NotificationUpdateProcessor when the user marks the row read.
Set when the user archives the row (single PATCH or the bulk archive endpoint). `null` means it's still in the active inbox; any value means archived ("View archived" surfaces these).
Stamped by App\Command\DispatchNotificationDigestCommand once a row has been included in a digest email so the next digest run skips it.
Front-end deep-link for the row, resolved from whichever parent is set (comment unwraps to its own parent). Null when nothing is linkable. Computed at read time — never persisted.
Title of the linked entity (task / discussion / page title).
Breadcrumb context — the parent project or space name.
In-app notification for the recipient. Kinds: `task_reminder` (App\Command\DispatchTaskRemindersCommand), `mention` / `reply` / `comment` (comment activity, via App\Service\NotificationDispatcher), and `assigned` / `status` (task activity). Most carry an `actor` (the user who triggered it) and resolve a deep-link `target` from whichever of task / comment / discussion / page is set. Read access is per-user — App\Doctrine\NotificationRecipientExtension scopes listings to the current recipient. The only client-writable fields are `readAt` and `archivedAt` (mark-read / archive); everything else is server-controlled. Bulk mark-read / archive live on App\Controller\NotificationBulkController; the live bell subscribes to the per-user Mercure topic minted by NotificationsMercureTokenController.
Owning space — gates read/list via `PageAccessExtension` and drives the security expressions for write/delete. Required.
Optional parent page. Self-FK forms the tree; cascade-on-delete means deleting a parent removes all descendants. The PR doesn't enforce a max depth — the renderer collapses deep nests.
Stamped server-side by {@see PageAuthorProcessor} on create.
Sort key within the parent (or top-level if parent is null). Two pages can share a position; ties break on `createdAt DESC`. The client supplies it on create/edit; no auto-renumber.
Long-form, hierarchical Notion-style document owned by a `Space` (#183). Title + markdown body + optional parent page form a tree; audit history flows through Gedmo `Loggable` like Project/Task. Access mirrors the parent space (#185 access matrix): - read/list/create: any space member - edit: page author OR space admin - delete: page author OR space admin (cascades to descendants via the self-FK `ON DELETE CASCADE`) Comments live in the unified {@see Comment} entity (#228) with `commentable_type='page'`. Each page exposes its comments via the `comments` inverse mapping below for cascade-delete and template convenience; clients fetch the thread by querying `GET /comments?page=/pages/<uuid>` ordered by `createdAt ASC`.
The space this project lives in (#181). Set by ProjectOwnerProcessor to the creator's personal space when the client doesn't specify one — keeps the existing PWA, which is unaware of spaces, working unchanged. Made non-null at the DB level once the migration's backfill populates every existing row.
Total task count, surfaced on the wire for list/dashboard rows.
Count of completed tasks (those with a `completedOn` timestamp).
Backward-compatible serialization shim (#185 → PR 4): the existing PWA reads `project.members` to render member chips and to check "is the user a member?". The underlying property is gone (`project_member` was dropped) — this getter projects the parent space's effective members onto a plain User[] so the JSON-LD response continues to expose a `members` array.
Shared container for tasks. Lives inside a {@see Space} (#181), and access is determined by membership in that space — every space member can read, edit, and (subject to admin/author rules) delete the project and its tasks. The `owner` field records who created the project for display/audit — it does not grant extra privileges beyond what space membership already provides. Per #185: - read/list/create: any space member - edit: any space member (project metadata is shared state) - delete: project creator (owner) or space admin - Post requires the caller to be a member of the target space (enforced via securityPostDenormalize so the validator runs after the IRI denormalises into a Space entity).
Public ECDH key from the browser's subscription.keys.p256dh.
Authentication secret from subscription.keys.auth. Same write-only exposure as `p256dh`.
Web Push subscription registered by the browser via PushManager. Multiple rows per user are expected (one per device + browser combination). The endpoint URL is unique across the whole table — the browser surfaces the same URL for re-subscribes, so a unique index lets the API treat re-registration as an idempotent upsert candidate (today we 409 the duplicate; future iteration could collapse to the existing row). Listing and deletion are scoped to the current user via a Doctrine query extension (see PushSubscriptionUserExtension). Subscription payloads (p256dh + auth keys) are write-only on the API surface so the secrets never echo back in a GET response.
Stable, code-facing handle ("beta-new-editor"). Lowercase, no underscores — it maps 1:1 to the synthetic role `ROLE_SEGMENT_BETA_NEW_EDITOR` ({@see getRole()}), and the reverse mapping in {@see roleToKey()} relies on the no-underscore rule. Set once on create; read-only on the wire afterwards (like a username).
Master switch. A disabled segment matches nobody — the voter and evaluator short-circuit on it — so you can dark-launch or kill a rollout without deleting its config or membership rows.
Percentage (0–100) of users included by a deterministic hash of `key:userId`. Null/0 means "no rollout bucket". Stable per (user, segment): a user who is in at 20% stays in when you raise it to 30%.
Auto-include users whose runtime roles (User::getRoles, which appends the implicit ROLE_USER) intersect this list — e.g. `["ROLE_ADMIN"]` to expose a beta to every admin. Synthetic segment roles are rejected here (a segment can't be gated on another segment) by the entity validator below.
Who created the segment. Nullable + SET NULL so removing a user never orphans the segment config.
Manual include / exclude overrides. Read-only via the embedded collection; mutated through {@see \App\Controller\SegmentMemberController}.
A named cohort of users used for A/B testing, beta-feature gating, and gradual rollouts. Instance-level (admin-managed) rather than space-scoped: a segment is a property of the whole deployment, like roles or the waitlist toggle. Membership is *dynamic* — a user belongs to a segment when any rule matches, with explicit per-user overrides winning (see {@see \App\Service\SegmentEvaluator} for the precedence). The rules are: - manual include / exclude rows ({@see SegmentMembership}) - the user's roles intersecting {@see $includedRoles} - a deterministic percentage {@see $rolloutPercentage} bucket Each segment surfaces as a synthetic role `ROLE_SEGMENT_<KEY>` (see {@see getRole()}). Because membership is dynamic the role isn't stored on the user; {@see \App\Security\SegmentVoter} answers `is_granted()` live and the active role strings are appended to the `/api/me` roles array. So feature-gating anywhere is just `is_granted('ROLE_SEGMENT_BETA_EDITOR')` server-side or `user.roles.includes('ROLE_SEGMENT_BETA_EDITOR')` in the PWA.
True only for the auto-created personal space attached to each user at signup. Write-only at the system level — never settable via the API (no `space:write` group on the property), so client payloads can't fake a personal space, and the value is fixed for the lifetime of the row.
Controls whether the space accepts additional members. `private` spaces are creator-only — invite endpoints and add-member reject with 409, and {@see SpaceUpdateProcessor} kicks every other membership on a shared → private transition. `shared` is the default for new spaces; personal spaces are always `private`, enforced by {@see validatePersonalIsPrivate()}.
Override color for the space avatar tile. When null the UI falls back to the creator's `personalizedColor` — same swatch on the person and their personal space by default, with the option to pick a different one for shared spaces (or override the personal one). Constrained to the same WCAG-AA palette as user avatars so white initials always render legibly.
Maintained by Gedmo Timestampable on every UPDATE. Powers the "edited 2h ago" / "updated yesterday" line on the spaces grid — it tracks edits to the space row itself (name, description, visibility), not activity on its contained projects / pages.
Audit-only — the user who originally created the space. Kept nullable so user deletion doesn't take shared spaces with it (control comes from membership, not creation). For personal spaces, a partial unique index in the migration enforces that a user has at most one personal space.
Direct user memberships. Populated by SpaceCreateProcessor for new shared spaces and by UserPasswordHasherProcessor for the personal space at signup. PR 3 (#186) will add the invite-by-email flow that lets admins extend this collection.
Group memberships — adds every member of a `UserGroup` to the space transitively. PR 1 only models the relationship; the management UI for adding groups lands in PR 4 (#187).
MediaObjects attached at the space level (cover docs, shared specs, anything not pinned to a specific task). Replaces the old `project_attachment` join — attachments now live at the space so they're available to every project + member in it.
Transient list of email addresses to invite at creation time — consumed by {@see SpaceCreateProcessor}, never persisted. Validated inline so a single bad address fails the whole create with 422 (atomic batch invite — partial success would leave the UX in a state nobody asked for). All-personal duplicates and the creator's own address are dropped in the processor without erroring.
Project count rooted in this space. EXTRA_LAZY makes this a single COUNT query — no row hydration.
Page count rooted in this space. Includes every level of the page tree (root pages + descendants) since access is by space, not by parent — the grid copy reads "N pages" so all pages matter.
Top-level container for content (#181). Every user has a personal "Private" space, created on signup and not deletable. Additional shared spaces can be created and shared with other users and `UserGroup`s. Membership (`SpaceMembership` + `SpaceGroupMembership`) is what gates access to the space's content; the `createdBy` field is audit-only. PR 1 ships only the Space entity, its memberships, and a backfill that links existing projects/discussions/custom-field-definitions to their creator's personal space. The access-extension rewrite that actually swaps Project/Discussion/CFD permissions over to space membership lands in PR 2 (#185).
Hex color for badge rendering. Stored as lowercase `#RRGGBB` so the PWA can drop it straight into an inline style.
Optional project the task belongs to. When set, every project member can read and edit the task alongside its owner. Personal tasks leave this null.
Optional recurrence rule. Persisted as a JSON object. The base shape is `{"frequency": "daily"|"weekly"|"monthly"|"yearly", "interval": int}`, optionally extended with `byDay` (weekday tokens), `monthlyMode`, `bySetPos`, and an `ends` end-condition — see {@see \App\Service\RecurrenceCalculator} for the full shape. Validated by {@see ValidRecurrence} (and the cross-field rule that recurrence requires a due date). When set, completing the task triggers {@see TaskUpdateProcessor} to clone the next occurrence.
Reminders to fire around the due date. Each entry is an object — either relative (`{type:"relative", value:int, unit:"minutes"|"hours"|"days", repeat:bool}`, fired `value` units before the due date) or absolute (`{type:"absolute", at:ISO-8601, repeat:bool}`, fired at a fixed time).
Per-owner sort key. Lower positions render first. Set server-side: assigned by the persist processor on create, rewritten in bulk by the reorder endpoint. Negative values are allowed so new tasks can be inserted at the top without having to shift existing rows.
Owning side of the Task↔Tag many-to-many. Membership is edited via PATCH /tasks/{id} with a `tags` array of Tag IRIs. Tags are scoped to the task's owner; cross-user IRIs are rejected by TagOwnerExtension during deserialization.
Users assigned to this task. Always a subset of {owner ∪ project members} — enforced by ValidAssignees on persist. Assignment is purely a "who's responsible" label; it grants no extra read/edit privileges (those still follow owner + project membership).
MediaObjects attached to this task. Membership is edited via PATCH with an `attachments` array of MediaObject IRIs; the PWA uploads via `POST /media-objects` (kind=attachment) first to obtain the IRI.
Per-task values for the project's {@see CustomFieldDefinition}s (#84). Mutated via the Task write group: clients PATCH a fresh array of `{definition, value}` pairs and orphanRemoval reaps any row whose definition isn't in the new payload. Type/required/scope rules are enforced by {@see ValidCustomFieldValues}.
Search match provenance for the hidden sources (comment / custom field), computed at read time by {@see App\State\TaskSearchProvenanceProvider} only when `?search=` is present. Empty otherwise. Never persisted.
Hex color (e.g., "#1e6091") used behind the initials fallback when the user has no avatar. Initialized to a random palette entry at registration; the user can swap it for any other palette entry on /account, but never to a free-form value (to keep contrast safe).
Avatar variant URLs keyed by size ("thumb", "profile"). Null when the user has no avatar — the frontend falls back to initials on personalizedColor.
The user who currently controls the group. Set by UserGroupOwnerProcessor on create; reassigned via the dedicated, step-up-protected transfer endpoint (not via PATCH).
Stable, URL-friendly handle ("creative", "eng", …) rendered in the UI with a `g-` prefix ("g-creative"). Generated once from the title on create by {@see UserGroupOwnerProcessor} and intentionally NOT regenerated on rename — like a username, it's a stable identifier people may reference. Read-only on the wire (no `group:write`).
Override color for the group avatar tile. When null the UI falls back to the owner's `personalizedColor`. Constrained to the same WCAG-AA palette as user avatars so white initials stay legible.
Maintained by Gedmo Timestampable on every UPDATE — powers the "edited 2h ago" / "updated yesterday" line on the groups list and the "updated" row in the detail's At-a-glance card. Stored as `datetime` (not immutable) so Gedmo can mutate it in place.
The group's membership roster. The owner is added here automatically by UserGroupOwnerProcessor so access checks can rely solely on memberships without a special case for the owner.
Flattened summary of the spaces this group is attached to, for the detail page's "Attached to" card. Returned as plain arrays so the minimal space fields the UI needs don't pull the full Space serialization graph into the `group:read` context.
Reusable named set of users. The owner has full control: edit metadata, manage membership, transfer ownership, or delete the group. Other members are read-only — they appear in the group's listings but cannot mutate it. Named "UserGroup" rather than "Group" because (a) `group` is a reserved SQL keyword and (b) `Group` clashes with `Symfony\Component\Serializer\ Attribute\Groups`. The PWA still labels it "Group" in the UI. Membership is modelled by {@see UserGroupMembership} (one row per user, carrying a `joinedAt`) rather than a plain join table so the detail UI can show when each member joined. Transfer ownership, member removal, and leaving the group all run through dedicated controllers — there is no `group:write` on `owner` or `memberships`, so a stolen cookie can't silently reassign the group via a plain PATCH.
Retrieves the collection of ApiToken resources.
Retrieves the collection of Comment resources.
Retrieves the collection of CustomFieldDefinition resources.
Creates a CustomFieldDefinition resource.
The collection of CustomFieldDefinition resources
Retrieves the collection of Discussion resources.
Retrieves the collection of Notification resources.
The collection of Notification resources
Retrieves the collection of Page resources.
Retrieves the collection of Project resources.
Retrieves the collection of PushSubscription resources.
The collection of PushSubscription resources
Retrieves the collection of Segment resources.
Retrieves the collection of Space resources.
Retrieves the collection of Tag resources.
Retrieves the collection of Task resources.
Retrieves the collection of User resources.
Retrieves the collection of Group resources.
The message associated with the violation