Rye Own your data. Let agents do the rest.

Layers

PM Conventions

Model tasks, projects, and sprints in Rye to align delivery data with broader business context.

Source file: design/layers/pm.md

PM Conventions

Task, Project, and Sprint Management on the Knowledge Graph


1. Relationship to the Core Schema

Like the CRM conventions, PM is implemented on the existing core tables. Tasks are nodes, not a separate system. A task linked to a CRM opportunity, a support ticket, and a customer account participates in the same graph traversals, access control, and event log.


2. Entity Conventions

2.1 Node Types

node_typePurposeRequired PropertiesOptional Properties
taskA unit of workcode, titledescription, task_type, due_date, priority, estimated_hours, tags
projectA collection of related workcode, namedescription, start_date, target_date, budget_hours
milestoneA target deliverable or datecode, name, target_datedescription, criteria
sprintA time-boxed iterationcode, name, start_date, end_dategoal, capacity_hours
workflow_templateA reusable process definitionname, stepsdescription, trigger_conditions

2.2 Edge Types

edge_typeSource -> TargetProperties
containsproject -> taskadded_at
subtask_oftask -> taskorder
blockstask -> taskreason
depends_ontask -> taskdependency_type
assigned_totask -> personrole ('owner', 'reviewer', 'collaborator', 'watcher')
milestone_targettask -> milestone
sprint_membertask -> sprintadded_at, committed
project_memberperson -> projectrole ('lead', 'member', 'stakeholder')
regardingtask -> (any node)context
originated_fromtask -> event

blocks vs depends_on: Use blocks for ad-hoc impediments discovered during work. Use depends_on for planned structural dependencies.

2.3 Assertion Types

assertion_typeSubjectClaim Shape
task_statustask{status, moved_from, reason}
task_prioritytask{level, reason, set_by}
estimatetask{hours, points, basis}
progresstask{percent, notes}
project_statusproject{status, health, notes}
sprint_goal_statussprint{status, notes}
milestone_statusmilestone{status, projected_date, confidence}

2.4 Event Types

event_typePurposeKey Properties
status_changeTask/project status transitionentity_code, from_status, to_status
commentDiscussion on a taskbody, mentions, reply_to
time_logTime tracked against a taskhours, description, date
assignment_changeTask reassignedfrom_user, to_user, reason
blocker_addedNew blocking relationshipblocker_code, blocked_code, reason
blocker_resolvedBlocking resolvedblocker_code, blocked_code, resolution
review_requestedTask ready for reviewreviewer_id, notes
task_createdNew task loggedcreated_by, source

3. Codes

Same format as CRM codes (see CRM Conventions):

EntityPrefixExample
TaskTSKTSK-2403-0187
ProjectPRJPRJ-2401-0005
MilestoneMILMIL-2403-0012
SprintSPRSPR-2403-0008

Cross-domain references: Codes are designed to appear in text. When a comment says “Blocked on TSK-2403-0187” or a task description says “Follow up from OPP-2403-0042”, these are parseable references. The code reference pattern for extraction:

(?:OPP|TSK|PRJ|CON|MIL|SPR)-\d{4}-\d{4}

4. Task Lifecycle

4.1 Status Model

backlog -> todo -> in_progress -> in_review -> done
                       |                        |
                    blocked                  cancelled
                       |
                  in_progress (unblocked)

Statuses are conventions, not constraints. Projects can define custom statuses.

4.2 Status Transitions

SELECT advance_task_status('<task_uuid>', 'in_progress', 'Starting work', 'user:jane');

5. Cross-Domain Tasks

Tasks are not siloed. A single task can connect to multiple domains:

[Task: TSK-2403-0187 "Investigate churn spike for Acme Corp"]
    |-- regarding --> [Customer: Acme Corp]                (CRM)
    |-- regarding --> [Ticket: Support escalation #1234]   (Support)
    |-- assigned_to --> [Person: Jane]                     (PM)
    |-- contains <-- [Project: Q1 Retention Initiative]    (PM)
    |-- sprint_member --> [Sprint: SPR-2403-0008]          (PM)

This task appears in: Jane’s workload, the Q1 Retention project board, the Acme Corp customer context, and the sprint board. No duplication, no sync.


6. Convenience Functions

6.1 Create Task

Uses an advisory lock on the project to prevent race conditions in project-local sequence numbering.

CREATE FUNCTION create_task(
    p_title text,
    p_description text DEFAULT NULL,
    p_project_id uuid DEFAULT NULL,
    p_assigned_to_id uuid DEFAULT NULL,
    p_properties jsonb DEFAULT '{}',
    p_teams text[] DEFAULT '{}',
    p_regarding_ids uuid[] DEFAULT '{}',
    p_regarding_roles text[] DEFAULT '{}'
) RETURNS uuid AS $$
DECLARE
    v_task_id uuid;
    v_code text;
    v_project_seq int;
    i int;
BEGIN
    v_code := generate_crm_code('TSK');

    -- Calculate project-local sequence with advisory lock to prevent races
    IF p_project_id IS NOT NULL THEN
        PERFORM pg_advisory_xact_lock(hashtext(p_project_id::text));
        SELECT coalesce(max((t.properties->>'project_seq')::int), 0) + 1
        INTO v_project_seq
        FROM edges c
        JOIN nodes t ON t.id = c.target_id AND t.node_type = 'task'
        WHERE c.source_id = p_project_id AND c.edge_type = 'contains' AND c.archived_at IS NULL;
    END IF;

    INSERT INTO nodes (node_type, label, properties, attrs, external_id, external_source)
    VALUES (
        'task', p_title,
        p_properties || jsonb_build_object(
            'code', v_code, 'title', p_title, 'description', p_description
        ) || CASE WHEN v_project_seq IS NOT NULL
            THEN jsonb_build_object('project_seq', v_project_seq)
            ELSE '{}'::jsonb END,
        jsonb_build_object('teams', to_jsonb(p_teams)),
        v_code, 'internal'
    )
    RETURNING id INTO v_task_id;

    INSERT INTO assertions (assertion_type, subject_node_id, claim, confidence)
    VALUES ('task_status', v_task_id, '{"status": "backlog"}', 1.0);

    IF p_project_id IS NOT NULL THEN
        INSERT INTO edges (edge_type, source_id, target_id, properties)
        VALUES ('contains', p_project_id, v_task_id, jsonb_build_object('added_at', now()));
    END IF;

    IF p_assigned_to_id IS NOT NULL THEN
        INSERT INTO edges (edge_type, source_id, target_id, properties, effective_from)
        VALUES ('assigned_to', v_task_id, p_assigned_to_id, '{"role": "owner"}', now());
    END IF;

    FOR i IN 1..coalesce(array_length(p_regarding_ids, 1), 0) LOOP
        INSERT INTO edges (edge_type, source_id, target_id, properties)
        VALUES ('regarding', v_task_id, p_regarding_ids[i],
            jsonb_build_object('context', coalesce(p_regarding_roles[i], 'subject')));
    END LOOP;

    INSERT INTO events (event_type, occurred_at, summary, properties, actor_system)
    VALUES ('task_created', now(),
        format('Created %s: %s', v_code, p_title),
        jsonb_build_object('task_code', v_code, 'project_id', p_project_id),
        current_setting('app.current_user_id', true));

    RETURN v_task_id;
END;
$$ LANGUAGE plpgsql;

6.2 Advance Task Status

CREATE FUNCTION advance_task_status(
    p_task_id uuid,
    p_new_status text,
    p_reason text DEFAULT NULL,
    p_actor text DEFAULT NULL
) RETURNS uuid AS $$
DECLARE
    v_old_assertion_id uuid;
    v_old_status text;
    v_new_assertion_id uuid;
    v_event_id uuid;
    v_task_code text;
BEGIN
    SELECT a.id, a.claim->>'status'
    INTO v_old_assertion_id, v_old_status
    FROM current_assertions a
    WHERE a.subject_node_id = p_task_id AND a.assertion_type = 'task_status';

    SELECT properties->>'code' INTO v_task_code FROM nodes WHERE id = p_task_id;

    INSERT INTO events (event_type, occurred_at, summary, properties, actor_system)
    VALUES (
        'status_change', now(),
        format('%s: %s -> %s', v_task_code, coalesce(v_old_status, 'none'), p_new_status),
        jsonb_build_object(
            'entity_code', v_task_code,
            'from_status', v_old_status,
            'to_status', p_new_status,
            'reason', p_reason),
        coalesce(p_actor, current_setting('app.current_user_id', true))
    )
    RETURNING id INTO v_event_id;

    INSERT INTO event_participants (event_id, node_id, role)
    VALUES (v_event_id, p_task_id, 'subject');

    IF v_old_assertion_id IS NOT NULL THEN
        v_new_assertion_id := supersede_assertion(
            p_old_assertion_id := v_old_assertion_id,
            p_new_assertion_type := 'task_status',
            p_new_subject_node_id := p_task_id,
            p_new_subject_edge_id := NULL,
            p_new_claim := jsonb_build_object(
                'status', p_new_status, 'moved_from', v_old_status, 'reason', p_reason),
            p_new_source_event_id := v_event_id,
            p_new_confidence := 1.0
        );
    ELSE
        INSERT INTO assertions (assertion_type, subject_node_id, claim, source_event_id, confidence)
        VALUES ('task_status', p_task_id,
            jsonb_build_object('status', p_new_status, 'reason', p_reason),
            v_event_id, 1.0)
        RETURNING id INTO v_new_assertion_id;
    END IF;

    RETURN v_new_assertion_id;
END;
$$ LANGUAGE plpgsql;

6.3 Add Comment

Uses a non-capturing group in the regex so the full code is matched, not just the prefix.

CREATE FUNCTION add_comment(
    p_task_id uuid,
    p_body text,
    p_actor text DEFAULT NULL,
    p_reply_to_event_id uuid DEFAULT NULL
) RETURNS uuid AS $$
DECLARE
    v_event_id uuid;
    v_task_code text;
    v_code_pattern text := '(?:OPP|TSK|PRJ|CON|MIL|SPR)-\d{4}-\d{4}';
    v_match text;
    v_mentioned_id uuid;
BEGIN
    SELECT properties->>'code' INTO v_task_code FROM nodes WHERE id = p_task_id;

    INSERT INTO events (event_type, occurred_at, summary, properties, actor_system)
    VALUES (
        'comment', now(),
        format('Comment on %s', v_task_code),
        jsonb_build_object('body', p_body, 'reply_to', p_reply_to_event_id),
        coalesce(p_actor, current_setting('app.current_user_id', true))
    )
    RETURNING id INTO v_event_id;

    INSERT INTO event_participants (event_id, node_id, role)
    VALUES (v_event_id, p_task_id, 'subject');

    FOR v_match IN
        SELECT (regexp_matches(p_body, v_code_pattern, 'g'))[1]
    LOOP
        SELECT id INTO v_mentioned_id
        FROM nodes WHERE properties->>'code' = v_match AND archived_at IS NULL;
        IF v_mentioned_id IS NOT NULL AND v_mentioned_id != p_task_id THEN
            INSERT INTO event_participants (event_id, node_id, role)
            VALUES (v_event_id, v_mentioned_id, 'mentioned')
            ON CONFLICT (event_id, node_id, role) DO NOTHING;
        END IF;
    END LOOP;

    RETURN v_event_id;
END;
$$ LANGUAGE plpgsql;

6.4 Log Time

CREATE FUNCTION log_time(
    p_task_id uuid,
    p_hours numeric,
    p_description text DEFAULT NULL,
    p_date date DEFAULT current_date,
    p_actor text DEFAULT NULL
) RETURNS uuid AS $$
DECLARE
    v_event_id uuid;
    v_task_code text;
BEGIN
    SELECT properties->>'code' INTO v_task_code FROM nodes WHERE id = p_task_id;

    INSERT INTO events (event_type, occurred_at, summary, properties, actor_system)
    VALUES (
        'time_log', p_date::timestamptz,
        format('%s: %s hours', v_task_code, p_hours),
        jsonb_build_object('hours', p_hours, 'description', p_description, 'date', p_date),
        coalesce(p_actor, current_setting('app.current_user_id', true))
    )
    RETURNING id INTO v_event_id;

    INSERT INTO event_participants (event_id, node_id, role)
    VALUES (v_event_id, p_task_id, 'subject');

    RETURN v_event_id;
END;
$$ LANGUAGE plpgsql;

6.5 Instantiate Workflow

Handles empty p_regarding_ids safely.

CREATE FUNCTION instantiate_workflow(
    p_template_id uuid,
    p_project_id uuid,
    p_context jsonb,
    p_regarding_ids uuid[] DEFAULT '{}',
    p_teams text[] DEFAULT '{}'
) RETURNS uuid[] AS $$
DECLARE
    v_steps jsonb;
    v_step jsonb;
    v_task_ids uuid[] := '{}';
    v_task_id uuid;
    v_title text;
    v_key text;
    v_value text;
    v_regarding_roles text[];
BEGIN
    SELECT properties->'steps' INTO v_steps FROM nodes WHERE id = p_template_id;

    -- Build regarding roles array safely
    IF p_regarding_ids IS NOT NULL AND array_length(p_regarding_ids, 1) > 0 THEN
        v_regarding_roles := array_fill('subject'::text, ARRAY[array_length(p_regarding_ids, 1)]);
    ELSE
        v_regarding_roles := '{}'::text[];
    END IF;

    FOR v_step IN SELECT * FROM jsonb_array_elements(v_steps) ORDER BY (value->>'order')::int
    LOOP
        v_title := v_step->>'title_template';
        FOR v_key, v_value IN SELECT * FROM jsonb_each_text(p_context)
        LOOP
            v_title := replace(v_title, '{' || v_key || '}', v_value);
        END LOOP;

        v_task_id := create_task(
            p_title := v_title,
            p_project_id := p_project_id,
            p_properties := jsonb_build_object(
                'task_type', v_step->>'task_type',
                'estimated_hours', (v_step->>'estimated_hours')::numeric,
                'workflow_step', (v_step->>'order')::int),
            p_teams := p_teams,
            p_regarding_ids := p_regarding_ids,
            p_regarding_roles := v_regarding_roles
        );

        v_task_ids := v_task_ids || v_task_id;

        IF v_step->'depends_on' IS NOT NULL THEN
            INSERT INTO edges (edge_type, source_id, target_id, properties)
            SELECT 'depends_on', v_task_id, v_task_ids[(dep_idx)::int],
                   '{"dependency_type": "finish_to_start"}'
            FROM jsonb_array_elements_text(v_step->'depends_on') AS dep_idx;
        END IF;
    END LOOP;

    RETURN v_task_ids;
END;
$$ LANGUAGE plpgsql;

7. Materialized Views

7.1 Task Board

Uses LATERAL joins to pick one owner/reviewer per task, preventing fan-out.

CREATE MATERIALIZED VIEW task_board AS
SELECT
    t.id AS node_id,
    t.label AS title,
    t.properties->>'code' AS code,
    t.properties->>'task_type' AS task_type,
    t.properties->>'due_date' AS due_date,
    t.properties->>'priority' AS priority,
    t.attrs->'teams' AS teams,
    stg.claim->>'status' AS status,
    stg.asserted_at AS status_since,
    est.claim->>'hours' AS estimated_hours,
    est.claim->>'points' AS story_points,
    prg.claim->>'percent' AS progress_percent,
    owner.label AS owner_name,
    owner.id AS owner_id,
    reviewer.label AS reviewer_name,
    reviewer.id AS reviewer_id,
    proj.label AS project_name,
    proj.properties->>'code' AS project_code,
    sprint.label AS sprint_name,
    sprint.properties->>'code' AS sprint_code,
    parent.label AS parent_task,
    parent.properties->>'code' AS parent_code,
    (SELECT count(*) FROM edges sub
        WHERE sub.edge_type = 'subtask_of' AND sub.target_id = t.id
        AND sub.archived_at IS NULL) AS subtask_count,
    (SELECT count(*) FROM edges blk
        WHERE blk.edge_type = 'blocks' AND blk.target_id = t.id
        AND blk.archived_at IS NULL) AS blocker_count,
    t.created_at
FROM nodes t
LEFT JOIN current_assertions stg ON stg.subject_node_id = t.id AND stg.assertion_type = 'task_status'
LEFT JOIN current_assertions est ON est.subject_node_id = t.id AND est.assertion_type = 'estimate'
LEFT JOIN current_assertions prg ON prg.subject_node_id = t.id AND prg.assertion_type = 'progress'
LEFT JOIN LATERAL (
    SELECT n.id, n.label FROM edges e
    JOIN nodes n ON n.id = e.target_id
    WHERE e.source_id = t.id AND e.edge_type = 'assigned_to'
      AND e.properties->>'role' = 'owner'
      AND e.effective_to IS NULL AND e.archived_at IS NULL
    LIMIT 1
) owner ON true
LEFT JOIN LATERAL (
    SELECT n.id, n.label FROM edges e
    JOIN nodes n ON n.id = e.target_id
    WHERE e.source_id = t.id AND e.edge_type = 'assigned_to'
      AND e.properties->>'role' = 'reviewer'
      AND e.effective_to IS NULL AND e.archived_at IS NULL
    LIMIT 1
) reviewer ON true
LEFT JOIN LATERAL (
    SELECT n.id, n.label, n.properties FROM edges e
    JOIN nodes n ON n.id = e.source_id AND n.node_type = 'project'
    WHERE e.target_id = t.id AND e.edge_type = 'contains' AND e.archived_at IS NULL
    LIMIT 1
) proj ON true
LEFT JOIN LATERAL (
    SELECT n.id, n.label, n.properties FROM edges e
    JOIN nodes n ON n.id = e.target_id
    WHERE e.source_id = t.id AND e.edge_type = 'sprint_member' AND e.archived_at IS NULL
    LIMIT 1
) sprint ON true
LEFT JOIN LATERAL (
    SELECT n.id, n.label, n.properties FROM edges e
    JOIN nodes n ON n.id = e.target_id
    WHERE e.source_id = t.id AND e.edge_type = 'subtask_of' AND e.archived_at IS NULL
    LIMIT 1
) parent ON true
WHERE t.node_type = 'task' AND t.archived_at IS NULL;

CREATE UNIQUE INDEX idx_tb_node ON task_board (node_id);
CREATE INDEX idx_tb_code ON task_board (code);
CREATE INDEX idx_tb_status ON task_board (status);
CREATE INDEX idx_tb_owner ON task_board (owner_id);
CREATE INDEX idx_tb_project ON task_board (project_code);
CREATE INDEX idx_tb_sprint ON task_board (sprint_code);
CREATE INDEX idx_tb_due ON task_board (due_date);

7.2 Common Queries

-- My open tasks, prioritized
SELECT * FROM task_board
WHERE owner_id = '<my_uuid>' AND status NOT IN ('done', 'cancelled')
ORDER BY
    CASE priority WHEN 'critical' THEN 1 WHEN 'high' THEN 2 WHEN 'medium' THEN 3 ELSE 4 END,
    due_date NULLS LAST;

-- Sprint board
SELECT * FROM task_board
WHERE sprint_code = 'SPR-2403-0008'
ORDER BY
    CASE status WHEN 'in_progress' THEN 1 WHEN 'in_review' THEN 2 WHEN 'todo' THEN 3 ELSE 4 END;

-- Overdue tasks
SELECT * FROM task_board
WHERE due_date::date < current_date AND status NOT IN ('done', 'cancelled')
ORDER BY due_date;

8. Access Control

Task access builds on the core RLS infrastructure. See Security.

  1. Task visibility follows team membership. A task tagged with {"teams": ["engineering"]} is visible to engineering team members.
  2. Project membership grants task visibility. If you’re a project_member, you see all tasks in that project.
  3. Cross-team tasks are visible to all tagged teams.