Skip to content

Teams & reporting

A team is a ConnectedId with Type=Group (see Connected ID). Reporting structure is modeled as edges between ConnectedIds, not columns — three relationship types: member_of (team membership), reporting (reports-to), reference (dotted-line).

Method · RoutePurposeSource
GET /List teamsReportingEndpoints.cs:23
POST /Create a team (Name, Kind?, LeadConnectedId?, ParentTeamId? — teams nest):111
PUT /{teamId}/leadSet/clear the team lead (null clears). Re-syncs every member’s auto reporting line (member → Lead) and emits reporting.team.lead_changed:142
GET /{teamId}/membersTeam members (?includeSubTeams=true):170
POST /{teamId}/membersAdd a member (ConnectedIdId). May return needsResolution when a reporting conflict needs the add/replace/none choice:186
DELETE /{teamId}/members/{memberId}Remove a member:224

When a member is added to a team with a lead, an automatic reporting edge (member → lead) is created. If the member already has a manager, the response carries needsResolution + existingManagers; the client resolves with Resolution = add / replace / none (:266).

Method · RoutePurposeSource
GET /api/org/{slug}/reporting/graphThe full tree — people + teams + all three edge types:36
POST /api/org/{slug}/relationshipsAdd a reporting or reference edge (SourceConnectedId, TargetConnectedId, Type):52
DELETE /api/org/{slug}/relationships?sourceId=&targetId=&type=Remove an edge:84

member_of edges are managed through the team-members endpoints above; /relationships handles reporting/reference only (:254). Edges are many-to-many and nestable. Related: Members, Roles & permissions.

A member’s Reporting Manager is simply the target of a reporting edge (member → manager). It is created three ways, all producing the same edge:

  • Automatically from a team lead — adding a member to a team that has a Lead creates a reporting edge (member → Lead) via TeamReportingSyncService.OnMemberJoinedTeamAsync. Such edges carry ConnectedIdRelationship.DerivedFromTeamId so that later team changes (member moved/removed, Lead changed) re-sync only the auto edges and never touch manually-set ones. If the member already has a manager, the add returns needsResolution and the client picks add / replace / none.
  • On invite — the “Reporting Manager (optional)” dropdown on Invite Member seeds the edge when the invitee is accepted.
  • Manually — the member kebab → “Reporting Manager” (multi-select, with cycle detection) or a drag on the tree, via POST /relationships.

The reporting tree these edges form is what the approval line walks up to resolve role columns (below).

Board of Directors — /api/org/{slug}/board

Section titled “Board of Directors — /api/org/{slug}/board”

The board is a ConnectedId(Type=Group, GroupKind="Board") — one per organization, reusing the team machinery: member_of edges are the director roster (and the voting register), the group’s LeadConnectedId is the chairman. Boards exist for Corporation / Project(SPV) organizations only, and the board group is excluded from team lists, plan team limits, and auto reporting lines.

Method · RoutePurpose
GET /Board + roster. { exists, boardId, chairmanConnectedId, members }; members carry SharePercent (shareholding %, for minutes)
POST /Create the board (one per org; also auto-created when a Director invitation is accepted)
POST /membersAppoint a director (ConnectedIdId) — personal seal required
POST /members/{memberId}/removeRemove a director — personal seal required
PUT /chairmanSet/clear the chairman (LeadConnectedId, null clears) — personal seal required

Every composition change is a governance boundary and requires a fresh personal-seal (passkey) step-up: purpose board.member.change, subject {slug}|{targetConnectedId}|{appoint|remove|chairman}.

Appointing via invitation is the primary path: invite a member with the Director or Representative Director role (see Members → Director invitations). The seal is collected when the invite is sent; on acceptance the member is seated automatically (with optional chairman designation and shareholding %).

Board meetings (POST /api/org/{slug}/meetings with Type = BoardMeeting) require the board to exist and to have a chairman — otherwise the create fails with BOARD_REQUIRED / BOARD_CHAIRMAN_REQUIRED. See Meetings.

Approval line — /api/org/{slug}/approvals/work-items/{id}/approval-line

Section titled “Approval line — /api/org/{slug}/approvals/work-items/{id}/approval-line”

Per-Form columns of the approval seal grid (replaces the hardcoded staff / team-lead / executive columns). Configured in the Create Form wizard (Step 2) — the config lives on the Form because a Form already is a Role and carries its own routing.

Method · RoutePurpose
GET /api/org/{slug}/approvals/work-items/{id}/approval-lineOrdered positions for this Form. Empty → the form falls back to the default staff / team-lead / executive columns (opt-in).
PUT /api/org/{slug}/approvals/work-items/{id}/approval-lineReplace all positions ({ positions: [...] }). Owner/admin only; the Form must belong to the org. Emits reporting.approvalline.changed (RPT038, AggregateId = WorkItemId).
GET /api/org/{slug}/approvals/work-items/{id}/resolve-linePreview — resolves the line for the current requester and returns the approver count, without mutating anything (ApprovalLineService.PreviewApproverCountAsync, read-only).

Each position has per-locale labels (labelKo/labelEn/labelJa) and a resolve type — how it maps to a person when an approval is submitted:

resolveTypeResolves to
drafterThe requester — no approval step
roleNearest holder of resolveRoleId (ConnectedIdRole, RBAC single source) up the requester’s reporting tree; falls back to resolveMemberConnectedId if no one up the tree holds the role
memberA fixed approver (resolveMemberConnectedId)

ApprovalLineService derives the line from the Form’s positions (instance.WorkItemId) — dual-role dedup (one stamp if the same person fills two columns), 0 approvers ⇒ auto-grant; on submit each approver is emailed the document. The entity ApprovalLinePosition is sealed (AuthIntegrityBase), scoped per-Form by WorkItemId.

An approval form that resolves to 0 approvers (e.g. an Account Owner with no one above them) would silently auto-grant a pointless approval. The New Request UI pre-checks via resolve-line on load and, when the count is 0, shows a blocking popup and disables Submit. The auto-grant core is untouched — the guard is purely a UI gate driven by the read-only preview.

Method · RoutePurpose
GET /api/org/{slug}/approvals/instancesList the requester’s / approver’s instances (read model carries IsRequester, IsReceiver, MyStepStatus, CreatorName, per-step ApproverName).
POST /api/org/{slug}/approvals/instancesSubmit a request. Derives the approval line, seals the instance, assigns the document number, and emails approvers. Requires a WebAuthn assertion — see below.
POST /api/org/{slug}/approvals/instances/{id}/stampAn approver stamps (approve/reject).
GET /api/org/{slug}/approvals/instances/by-number/{documentNumber}Look up a sealed instance by its {slug}-{yyyy}-{000001} document number.

Submitting (“stamp & submit”) is a human-presence-verified action. The client requests a challenge (api/passkey/login/options), calls navigator.credentials.get (Windows Hello / TPM), and sends the assertion on the submit request. The endpoint verifies it via IFido2Service and requires assertion.UserId == the session owner — mirroring the account-switch step-up. A member with no passkey cannot submit (clear “register a personal seal first” message); this is intended proof-of-decision, not a bug.

Every seal in the approval flow renders through one component, Components/Shared/PersonalSignature, from the user’s User.SignatureSpec (exposed on /me):

  • Submit popup shows the drafter’s designed personal seal (not a name-based default circle). MemberDetail refreshes AuthStore after a self signature save so the popup is fresh.
  • Document seal columns render each participant’s designed seal; the name-based fallback is used only for participants with no spec.
  • The org seal on the approval document renders correctly everywhere because the generated seal SVG is self-contained — its fonts are embedded at creation (CorporateSeal.RenderBytesEmbeddedAsync), so even the <img> path keeps the Korean seal-script face. Inline rendering (PersonalSignature.InlineSizedSvg) is still attempted for user-uploaded SVGs, which carry no embedded fonts.

Putting the pieces together — Form, seals, the Reporting Manager, and Requests & Approvals:

  1. Define the Form (WorkItem) — Owner/admin creates it in the Create Form wizard. Type = Approval (picks a Target action from the Action Library, auto-creates the linked Role) or Reporting (picks recipients: team/dept, member, external email). The Form carries its seal columns (ApprovalLinePosition[], inline on create), LineMode (single / chain / quorum), RiskLevel, and language.

  2. Compose a request — a member opens New Request, picks a Form, and fills the shared A4 ApprovalDocument: letterhead with the company seal (issuer), an auto header block (dept / drafter / date), recipients / cc, title, body, attachments, and the seal grid. The document number is previewed (resolve-line also pre-checks the approver count — a 0-approver Approval is blocked here; see No-approver block).

  3. Submit (POST /approvals/instancesCreateReportingInstanceCommandHandler) — the “stamp & submit” popup shows the drafter’s personal seal, then a WebAuthn/TPM assertion is required (human presence). The handler seals the ReportingInstance, assigns the document number {slug}-{yyyy}-{NNNNNN} atomically (ReportingDocumentSequence), and derives the approval line.

  4. Route to the Reporting ManagerApprovalLineService.DeriveFromPositionsAsync resolves each position: drafter → the requester (no step); roleResolveRoleApprover walks up the requester’s reporting tree (WalkUpAsync) and picks the nearest ancestor who holds that Role (ConnectedIdRole = RBAC single source), falling back to a pinned member; member → a fixed person. Duplicates and the requester are de-duped. This is how a report reaches the right manager: the Form says “the person holding role X above me”, and the tree says who that is. It writes an ordered ApprovalStep (status pending) per approver and emails each one (NotifyApproversAsync, records ApprovalStep.NotifiedAt). A Reporting-type Form instead emails the resolved recipients (NotifyRecipientsAsync, groups expanded to members). Zero approvers ⇒ auto-grant.

  5. Approve / reject in Requests & Approvals — each approver sees the instance under the Received filter and stamps in place (POST /instances/{id}/stamp). The handler re-checks the ceiling at stamp time (capability may have shrunk since submit), seals the step, emits RPT031/032, and recomputes progress via ApprovalStampLogic.ProgressStatus (single → 1 approval; chain/parallel → all; quorum → K-of-N; any reject → rejected). The approver’s personal seal stamps their column.

  6. Materialize on completion — when the line completes, ApprovalStampLogic.DecideMaterializeMode reads the uses control variable: uses=1 → the sealed instance is the one-time credential (no grant); otherwise the approved scope becomes a persistent ConnectedIdPermission for the requester (currently CreateDirectGrant) and ApprovalCompletedEvent (RPT033) fires.

Every step is sealed (AuthIntegrityBase) and emits a 5-piece audit event — the document, its number, each seal, and the final grant are all tamper-evident. See Capability grants for the ceiling model behind the line.