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.
Content-bound stamp (approve / reject)
Section titled “Content-bound stamp (approve / reject)”An approval stamp signs the document, not just a random challenge. When the client asks for a step-up challenge (POST /api/passkey/stepup/options with purpose: "approval.stamp", subject: "{instanceId}|approve|reject"), the server reads the instance from the database, writes the key items of what is being approved into one canonical line, and builds the challenge as
challenge = SHA-256(canonical line) // 32 bytes — the same size as a random challengeThe line starts with a fresh random nonce, so every challenge is still unique, and the authenticator’s signature now covers the content. Nothing is stored in the authenticator (TPM) — only the challenge value changes. This is the same idea as PSD2 dynamic linking (authentication bound to amount and payee). The response carries the signed fields back as binding:
{ "hasPasskey": true, "options": { "challenge": "…", "…": "…" }, "binding": { "version": "axowl-stepup-v1", "nonce": "9c0e…(64 hex)", "purpose": "approval.stamp", "subject": "3f1c…|approve", "fields": [ { "key": "org", "value": "…" }, { "key": "instance", "value": "3f1c…" }, { "key": "number", "value": "acme-2026-000042" }, { "key": "title", "value": "Travel expenses" }, { "key": "decision", "value": "approve" }, { "key": "requester", "value": "…" }, { "key": "body.sha256", "value": "…" }, { "key": "attachments.sha256", "value": "…" }, { "key": "grant", "value": "org.expense.submit" }, { "key": "input.reason", "value": "" } ] }}- Canonical line (v1) —
axowl-stepup-v1followed by|key=<UTF-8 byte length>:<value>per item, in this order:nonce,purpose,subject, thenfieldsas listed (a key may repeat, e.g. onegrantitem per scope, sorted ordinally). Example:axowl-stepup-v1|nonce=64:9c0e…|purpose=14:approval.stamp|subject=44:3f1c…|approve|org=36:…|decision=7:approve|…. The length prefix means a value cannot forge another item with an embedded|.body.sha256/attachments.sha256are lowercase hex SHA-256 of the storedBodyJson/AttachmentsJson(the attachment manifest already lists each file’s SHA-256, so files go in by hash, not by content);grantitems are the scopes the requester receives on approval (empty on reject). - Inputs — values the approver types (the reject reason) are sent with the options request as
inputs: [{ "key": "input.reason", "value": "…" }](trimmed) and must equal thereasonsent to/stamp. Only keys allowed for the purpose are kept. - Before signing, the console rebuilds the line from
bindingand checks that its SHA-256 equals the challenge; on mismatch it refuses to sign. It then shows the fields in a confirmation window, taking every displayed value from those items only — what the approver sees is what the passkey signs. - At
/stamp, the server rebuilds the fields from the database at execution time and compares them to the signed challenge. A changed body, attachments, granted scopes or reason, a swapped decision, or a seal issued for another instance is refused with403 seal_binding_mismatch. The email decide page (/a/approval/{approve|reject}) takes the document number, title and grants it displays from the same items and lists them under “Your passkey signs exactly this”.
Verifying a stamp without trusting the server
Section titled “Verifying a stamp without trusting the server”Each stamp’s audit event (RPT031 approved / RPT032 rejected) carries SealProof (sp), a JSON object:
{ "v": "axowl-stepup-proof-v1", "canonical": "axowl-stepup-v1|nonce=64:…|purpose=14:approval.stamp|…", "credentialId": "…", "authenticatorData": "…", "clientDataJSON": "…", "signature": "…" }authenticatorData, clientDataJSON and signature are the base64url bytes the authenticator produced. A third party holding the credential’s public key checks:
SHA-256(canonical)equals the base64url-decodedchallengeinsideclientDataJSON(32 bytes);- the ES256 signature over
authenticatorData || SHA-256(clientDataJSON)verifies with the public key; canonicalmatches the document (rebuild it from the instance as above).
Axowl.Core.Helpers.StepUpProof implements 1 and 2 (CoversCanonical, VerifySignature). SealProof is null for stamps made by clients that did not request a content-bound challenge.
Second-device confirmation (phone)
Section titled “Second-device confirmation (phone)”Every approval stamp can be confirmed a second time on another device — usually the approver’s phone — with a different passkey, over the same content line. The phone screen shows what the server actually received, so a computer whose browser has been taken over cannot get a decision recorded that differs from what the person confirms on the phone.
- Policy — per organization, on by default; only the Owner can turn it off. While the global rollout is in its notice phase, stamps are still recorded with the computer passkey alone and the stamp response carries
secondDevice: { notice: true, hasSecondDevice }. - When confirmation is required,
POST /api/org/{slug}/approvals/instances/{id}/stampdoes not record the decision. It returns:202 { status: "awaiting_second_device", secondDevice: { requestId, expiresAt, channel } }— a one-time link (10 minutes) was sent to the approver (channel:alimtalk,email, ornullif nothing could be sent). KakaoTalk (alimtalk) is used only when the organization has the Korean Package add-on and the approver has a Korean mobile number; otherwise the link goes by email;409 { error: "second_device_required" }— the approver has no passkey other than the one just used; the console opens passkey registration in place (QR to the phone).
- Organizations with phone confirmation on allow phone passkey registration automatically, except device-bound organizations (use a security key or another computer).
- How the link is sent — KakaoTalk first (Korean Package organizations, Korean mobile numbers), then text message if the organization turned it on (Owner, Pro plan or higher; each message is charged to prepaid credit at the global SMS price) and the approver has a verified mobile number in a supported country, otherwise email. If a step cannot send (no credit, unsupported country, carrier failure) the next one is used — approvals are never blocked by delivery.
- Phone verification is asked only in organizations that turned text messages on: once when the option is first turned on (email to members without a verified number) and once on the next workspace visit (sign-in or invitation acceptance). It can be postponed; links keep coming by email until then.
| Method | Path | Purpose |
|---|---|---|
| GET | /api/org/{slug}/approvals/second-device/{requestId} | The computer waits for the phone: { status: pending | completing | done | denied | refused | expired, expiresAt, channel, result } |
| GET / PUT | /api/org/{slug}/settings/approval-second-device | { enabled, enforced, canEdit, smsEnabled, smsPlanAllowed, smsPricePerMessageUsd, koreanPackage } · PUT { enabled?, smsEnabled? } — Owner only (403 OWNER_REQUIRED); smsEnabled: true below Pro → 403 UPGRADE_REQUIRED |
| GET | /api/v1/users/me/phone | { phone, verified } — verified is true only for a number confirmed with a one-time code |
| POST | /api/v1/users/me/phone/verified | After POST /api/org/otp/verify succeeds, records that verified number as yours (no body; the server uses the number it saw verified) |
| GET | /api/v1/users/me/phone-prompts | Organizations that send links by text message where your number is not verified yet: [{ slug, name, enabledAt }] |
| GET | /a/approval/confirm?r={requestId}&t={token} | Phone page — shows only the items of the signed content line |
| GET | /a/approval/confirm/request?r=&t= | Same request as JSON: { requestId, status, expiresAt, version, purpose: "approval.confirm", subject, fields } |
| POST | /a/approval/confirm/challenge (form r, t) | WebAuthn options; challenge = SHA-256(canonical line) with purpose approval.confirm and subject {requestId}|{instanceId}|approve|reject; the first passkey is left out of allowCredentials |
| POST | /a/approval/confirm/complete (form r, t, assertion) | Verifies the phone assertion and records the decision |
| POST | /a/approval/confirm/deny (form r, t) | “I did not do this” — nothing is recorded |
The phone assertion is refused when the link is expired, used or wrong; when the document changed after the computer signed it; when the signed content differs from the request; when the passkey belongs to someone else; or when it is the same passkey the computer used. On success the audit event (RPT031/032) also carries SecondSealProof (sp2, same proof format as SealProof) and SecondCredentialId (cid2).
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.