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).
Teams — /api/org/{slug}/teams
Section titled “Teams — /api/org/{slug}/teams”| Method · Route | Purpose | Source |
|---|---|---|
GET / | List teams | ReportingEndpoints.cs:23 |
POST / | Create a team (Name, Kind?, LeadConnectedId?, ParentTeamId? — teams nest) | :111 |
PUT /{teamId}/lead | Set/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}/members | Team members (?includeSubTeams=true) | :170 |
POST /{teamId}/members | Add 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).
Reporting tree & edges
Section titled “Reporting tree & edges”| Method · Route | Purpose | Source |
|---|---|---|
GET /api/org/{slug}/reporting/graph | The full tree — people + teams + all three edge types | :36 |
POST /api/org/{slug}/relationships | Add 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.
The Reporting Manager
Section titled “The Reporting Manager”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
reportingedge (member → Lead) viaTeamReportingSyncService.OnMemberJoinedTeamAsync. Such edges carryConnectedIdRelationship.DerivedFromTeamIdso 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 returnsneedsResolutionand 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 · Route | Purpose |
|---|---|
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 /members | Appoint a director (ConnectedIdId) — personal seal required |
POST /members/{memberId}/remove | Remove a director — personal seal required |
PUT /chairman | Set/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:
docType | Stamps | Grants on approval |
|---|---|---|
permission — Access Approval | yes | the linked role’s scopes (or linkedPermissionScope) |
document — Document Approval | yes | nothing |
report — Reporting | no — sent to recipients | nothing |
| Method · Route | Purpose |
|---|---|
GET /api/org/{slug}/approvals/work-items/{id}/approval-line | Ordered 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-line | Replace 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-line | Preview — 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:
resolveType | Resolves to |
|---|---|
drafter | The requester — no approval step |
role | Nearest 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 |
member | A 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.
No-approver block
Section titled “No-approver block”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.
Requests (instances) & submit step-up
Section titled “Requests (instances) & submit step-up”| Method · Route | Purpose |
|---|---|
GET /api/org/{slug}/approvals/instances | List the requester’s / approver’s instances (read model carries IsRequester, IsReceiver, MyStepStatus, CreatorName, per-step ApproverName). |
POST /api/org/{slug}/approvals/instances | Submit 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}/stamp | An 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. |
TPM / passkey step-up on submit
Section titled “TPM / passkey step-up on submit”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.
Personal seal (single-source render)
Section titled “Personal seal (single-source render)”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).
MemberDetailrefreshesAuthStoreafter 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.
End-to-end: how a request flows
Section titled “End-to-end: how a request flows”Putting the pieces together — Form, seals, the Reporting Manager, and Requests & Approvals:
-
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. -
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-linealso pre-checks the approver count — a 0-approver Approval is blocked here; see No-approver block). -
Submit (
POST /approvals/instances→CreateReportingInstanceCommandHandler) — the “stamp & submit” popup shows the drafter’s personal seal, then a WebAuthn/TPM assertion is required (human presence). The handler seals theReportingInstance, assigns the document number{slug}-{yyyy}-{NNNNNN}atomically (ReportingDocumentSequence), and derives the approval line. -
Route to the Reporting Manager —
ApprovalLineService.DeriveFromPositionsAsyncresolves each position:drafter→ the requester (no step);role→ResolveRoleApproverwalks 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 orderedApprovalStep(statuspending) per approver and emails each one (NotifyApproversAsync, recordsApprovalStep.NotifiedAt). A Reporting-type Form instead emails the resolved recipients (NotifyRecipientsAsync, groups expanded to members). Zero approvers ⇒ auto-grant. -
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 viaApprovalStampLogic.ProgressStatus(single → 1 approval; chain/parallel → all; quorum → K-of-N; any reject → rejected). The approver’s personal seal stamps their column. -
Materialize on completion — when the line completes,
ApprovalStampLogic.DecideMaterializeModereads theusescontrol variable:uses=1→ the sealed instance is the one-time credential (no grant); otherwise the approved scope becomes a persistentConnectedIdPermissionfor the requester (currentlyCreateDirectGrant) andApprovalCompletedEvent(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.