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.

Forms come in three kinds, and the edit dialog shows all three:

docTypeStampsGrants on approval
permission — Access Approvalyesthe linked role’s scopes (or linkedPermissionScope)
document — Document Approvalyesnothing
report — Reportingno — sent to recipientsnothing
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 for a human requester; on submit each approver is emailed the document.

Machine requesters never self-approve. When the requester’s badge is not Type=Human (an agent filing through MCP), the risk floor is raised to at least 2 on the tree path and a positions path that resolves to 0 approvers returns unroutable instead of auto-grant — the document stays submitted until a person above the agent who holds the scope can be found. The human path is unchanged. This lives in line derivation, not in the MCP tools, so it holds for any client. 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.